1pub mod config;
5pub mod help;
6pub mod server;
7pub mod test;
8pub mod user;
9
10use crate::cli::config::{change_config_value, print_all};
11use crate::cli::server::confirm_and_delete;
12use colored::Colorize;
13use quorum_core::cli::AdminSession;
14use quorum_core::cli::db;
15use quorum_core::cli::server::{audit, logout, logs, shutdown, status};
16use quorum_core::db::DB;
17use std::io::{self, Write};
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};
20use tokio::sync::watch;
21
22const SESSION_TIMEOUT_MINS: u64 = 20;
23
24struct Command {
25 parts: Vec<String>,
26 #[allow(dead_code)]
27 params: Vec<String>,
28 raw: String,
29}
30
31macro_rules! config_match_arms {
32 ($session:expr, $cmd:expr, $command:expr, $($field:ident),*) => {
33 match $command.as_str() {
34 "show" => {
35 if !require_admin($session) {
36 return;
37 }
38 print_all();
39 }
40 $(
41 stringify!($field) => {
42 if !require_admin($session) {
43 return;
44 }
45 let value = $cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
46 change_config_value(stringify!($field), value);
47 }
48 )*
49 _ => unknown(&$cmd.raw),
50 }
51 };
52}
53
54fn parse(input: &str) -> Option<Command> {
65 let trimmed = input.trim();
66 if trimmed.is_empty() {
67 return None;
68 }
69
70 let (cmd_part, params_part) = match trimmed.split_once(' ') {
71 Some((cmd, params)) => (cmd, params.trim()),
72 None => (trimmed, ""),
73 };
74
75 let parts: Vec<String> = cmd_part
76 .split(':')
77 .map(|s| s.trim().to_lowercase())
78 .filter(|s| !s.is_empty())
79 .collect();
80
81 let params: Vec<String> = if params_part.is_empty() {
82 vec![]
83 } else {
84 params_part
85 .split(", ")
86 .map(|s| s.trim().to_string())
87 .filter(|s| !s.is_empty())
88 .collect()
89 };
90
91 Some(Command {
92 parts,
93 params,
94 raw: trimmed.to_string(),
95 })
96}
97
98pub async fn spawn_cli(db: DB, server_start: Instant, shutdown_tx: watch::Sender<bool>) {
121 let session = Arc::new(Mutex::new(AdminSession::new()));
122 let handle = tokio::runtime::Handle::current();
123 let shutdown_rx = shutdown_tx.subscribe();
124
125 tokio::task::spawn_blocking(move || {
126 std::thread::sleep(Duration::from_millis(600));
127
128 loop {
129 if shutdown_rx.has_changed().unwrap_or(false) {
131 break;
132 }
133
134 print!("{} ", ">".cyan().bold());
135 io::stdout().flush().unwrap();
136
137 let mut input = String::new();
138 match io::stdin().read_line(&mut input) {
139 Ok(0) => break, Ok(_) => {}
141 Err(_) => break,
142 }
143
144 {
145 let mut sess = session.lock().unwrap();
146 if sess.logged_in && !sess.is_valid() {
147 println!(
148 "{}",
149 "Session expired after 20 minutes of inactivity. Please run server:login again."
150 .yellow()
151 );
152 sess.logout();
153 }
154 }
155
156 let cmd = match parse(&input) {
157 Some(c) => c,
158 None => continue,
159 };
160
161 handle.block_on(dispatch(&cmd, &db, server_start, &session, &shutdown_tx));
162 }
163 });
164}
165
166async fn dispatch(
175 cmd: &Command,
176 db: &DB,
177 server_start: Instant,
178 session: &Arc<Mutex<AdminSession>>,
179 shutdown_tx: &watch::Sender<bool>,
180) {
181 match cmd.parts.as_slice() {
182 [ns] if ns == "help" => {
184 help::print_all();
185 }
186 [ns, command] if ns == "help" => {
187 help::print_command(command);
188 }
189
190 [ns, command] if ns == "server" => match command.as_str() {
192 "login" => server::login(session),
193 "status" => status(server_start).await,
194 "logout" => logout(session),
195 "shutdown" => {
196 if !require_admin(session) {
197 return;
198 }
199 shutdown(db, server_start, shutdown_tx).await;
200 }
201 "logs" => {
202 let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
203 logs(db, params).await;
204 }
205 "audit" => {
206 let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
207 audit(db, params).await;
208 }
209 "update-database" => {
210 if let Err(error) = server::reinitialize_schema(db).await {
211 eprintln!(
212 "{}",
213 format!("Failed to reinitialize schema: {error}").red()
214 );
215 }
216 }
217 _ => unknown(&cmd.raw),
218 },
219
220 [ns, command] if ns == "user" => match command.as_str() {
222 "delete" => {
223 if !require_admin(session) {
224 return;
225 }
226
227 let id = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
228 user::delete(db, id).await;
229 }
230 _ => unknown(&cmd.raw),
231 },
232
233 [ns, command] if ns == "test" => match command.as_str() {
235 "run" => test::run().await,
236 _ => unknown(&cmd.raw),
237 },
238
239 [ns, command] if ns == "db" => match command.as_str() {
241 "stats" => db::stats(db).await,
242 "table" => {
243 let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
244 db::table(db, params).await;
245 }
246 "delete" => {
247 if !require_admin(session) {
248 return;
249 }
250 confirm_and_delete(shutdown_tx).await;
251 }
252 _ => unknown(&cmd.raw),
253 },
254
255 [ns, command] if ns == "config" => {
257 config_match_arms!(
258 session,
259 cmd,
260 command,
261 server_port,
262 server_url,
263 server_host,
264 surreal_data_path,
265 surreal_ns,
266 surreal_db,
267 jwt_secret,
268 jwt_access_minutes,
269 jwt_refresh_days,
270 enable_testing,
271 default_per_second,
272 default_burst_size,
273 testing_per_second,
274 testing_burst_size
275 );
276 }
277
278 _ => unknown(&cmd.raw),
279 }
280}
281
282pub fn require_admin(session: &Arc<Mutex<AdminSession>>) -> bool {
304 let sess = session.lock().unwrap();
305 if !sess.is_valid() {
306 println!(
307 "{}",
308 "This command requires authentication. Please run server:login first.".red()
309 );
310 return false;
311 }
312 if !sess.is_admin {
313 println!("{}", "This command requires admin privileges.".red());
314 return false;
315 }
316 true
317}
318
319fn unknown(raw: &str) {
324 println!(
325 "{} {} {}",
326 "Unknown command:".red(),
327 raw.white(),
328 "- type `help` to see available commands.".dimmed()
329 );
330}