1use std::sync::Arc;
14use std::time::Duration;
15
16use rustyline::completion::{Completer, Pair};
17use rustyline::error::ReadlineError;
18use rustyline::highlight::Highlighter;
19use rustyline::hint::Hinter;
20use rustyline::history::DefaultHistory;
21use rustyline::validate::Validator;
22use rustyline::{CompletionType, Config, EditMode, Editor, Helper};
23
24use crate::{
25 ALERT_EVAL_INTERVAL, ANSI_BOLD, ANSI_FG_RED, ANSI_RESET,
26 ast::Query,
27 config::DolConfig,
28 docker::BollardDockerClient,
29 eval::EvalError,
30 executor::{self, render_csv, render_jsonl, render_table},
31 metrics::{BollardMetricsCollector, MetricsCollector},
32 parser,
33 sqlite_store::SqliteTelemetryStore,
34};
35
36#[derive(Default)]
40struct ReplState {
41 last_query: Option<String>,
43 watch_interval_secs: Option<u64>,
45 export_path: Option<String>,
47 output_format: String,
49}
50
51impl ReplState {
52 fn render_result(&self, result: &executor::ExecutionResult) -> String {
53 match self.output_format.as_str() {
54 "json" => serde_json::to_string_pretty(result).unwrap_or_default(),
55 "csv" => render_csv(result),
56 "jsonl" => render_jsonl(result),
57 _ => render_table(result),
58 }
59 }
60
61 fn export_result(&self, result: &executor::ExecutionResult) -> anyhow::Result<()> {
62 let path = match &self.export_path {
63 Some(p) => p.clone(),
64 None => return Ok(()),
65 };
66 let text = self.render_result(result);
67 std::fs::write(&path, &text)?;
68 println!(" Exported {} rows to {path}", result.rows.len());
69 Ok(())
70 }
71}
72
73#[derive(Default)]
74struct DolHelper;
75
76impl Helper for DolHelper {}
77
78impl Validator for DolHelper {
79 fn validate(
80 &self,
81 _ctx: &mut rustyline::validate::ValidationContext,
82 ) -> rustyline::Result<rustyline::validate::ValidationResult> {
83 Ok(rustyline::validate::ValidationResult::Valid(None))
84 }
85}
86
87impl Completer for DolHelper {
88 type Candidate = Pair;
89
90 fn complete(
91 &self,
92 line: &str,
93 pos: usize,
94 _ctx: &rustyline::Context<'_>,
95 ) -> Result<(usize, Vec<Pair>), ReadlineError> {
96 let keywords = [
97 "observe",
98 "events",
99 "inspect",
100 "analyze",
101 "alert",
102 "fields",
103 "containers",
104 "images",
105 "networks",
106 "volumes",
107 "container",
108 "image",
109 "network",
110 "volume",
111 "where",
112 "select",
113 "sort",
114 "by",
115 "limit",
116 "group",
117 "having",
118 "offset",
119 "distinct",
120 "fill",
121 "let",
122 "set",
123 "if",
124 "then",
125 "else",
126 "case",
127 "when",
128 "end",
129 "and",
130 "or",
131 "not",
132 "in",
133 "matches",
134 "contains",
135 "starts_with",
136 "ends_with",
137 "between",
138 "row_number",
139 "rank",
140 "lag",
141 "lead",
142 "debug",
143 "assert",
144 "last",
145 "at",
146 "from",
147 "to",
148 "asc",
149 "desc",
150 ".help",
151 ".exit",
152 ".host",
153 ".watch",
154 ".export",
155 ".output",
156 ".history",
157 "logs",
158 "ls",
159 "compose",
160 "services",
161 "health",
162 "stats",
163 "ps",
164 "port",
165 "config",
166 "find",
167 "anomalies",
168 "correlate",
169 "explain",
170 "print",
171 "webhook",
172 "restart",
173 ];
174
175 let line_before = &line[..pos];
176 let word = line_before.split_whitespace().last().unwrap_or("");
177 let partial = word.to_lowercase();
178
179 let mut pairs = Vec::new();
180 for kw in &keywords {
181 if kw.starts_with(&partial) {
182 pairs.push(Pair {
183 display: kw.to_string(),
184 replacement: kw.to_string(),
185 });
186 }
187 }
188 Ok((pos - word.len(), pairs))
189 }
190}
191
192impl Hinter for DolHelper {
193 type Hint = String;
194}
195
196impl Highlighter for DolHelper {}
197
198pub async fn run_repl(config: &DolConfig) -> anyhow::Result<()> {
199 crate::alerts::init_alert_timeouts(
201 config.webhook_timeout.unwrap_or(10),
202 config.restart_timeout.unwrap_or(30),
203 );
204
205 let mut current_host = config.host.clone().unwrap_or_default();
207 if !current_host.is_empty() {
208 unsafe {
210 std::env::set_var("DOCKER_HOST", ¤t_host);
211 }
212 }
213
214 let h = Config::builder()
215 .history_ignore_space(true)
216 .completion_type(CompletionType::List)
217 .edit_mode(EditMode::Emacs)
218 .build();
219
220 let mut rl = Editor::<DolHelper, DefaultHistory>::with_config(h)?;
221 rl.set_helper(Some(DolHelper));
222
223 let history_file = dirs::data_dir().map(|d| d.join("dol").join("repl_history.txt"));
224
225 if let Some(ref path) = history_file {
226 if let Some(parent) = path.parent() {
227 let _ = std::fs::create_dir_all(parent);
228 }
229 let _ = rl.load_history(path);
230 }
231
232 let mut state = ReplState {
233 output_format: "table".to_owned(),
234 ..ReplState::default()
235 };
236
237 println!("DOL REPL — type .help for commands, Ctrl+C or .exit to quit");
238 if current_host.is_empty() {
239 println!(" Connected to: local Docker socket");
240 } else {
241 println!(" Connected to: {current_host}");
242 }
243
244 loop {
245 let input = match rl.readline("dol> ") {
246 Ok(line) => line,
247 Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
248 println!();
249 break;
250 }
251 Err(e) => {
252 eprintln!("Readline error: {e}");
253 break;
254 }
255 };
256
257 let trimmed = input.trim().to_owned();
258 if trimmed.is_empty() {
259 continue;
260 }
261
262 if trimmed.starts_with('.') {
263 match trimmed.as_str() {
264 ".exit" | ".quit" => break,
265 ".help" => print_repl_help(&state),
266 cmd if cmd.starts_with(".host") => {
267 let val = cmd.strip_prefix(".host").unwrap_or("").trim();
268 if val.is_empty() {
269 if current_host.is_empty() {
270 println!("Docker host: local socket");
271 } else {
272 println!("Docker host: {current_host}");
273 }
274 } else {
275 current_host = val.to_owned();
276 unsafe {
278 std::env::set_var("DOCKER_HOST", ¤t_host);
279 }
280 println!("Docker host set to: {current_host}");
281 }
282 }
283 ".history" => {
284 for (i, entry) in rl.history().iter().enumerate() {
285 println!("{i:5} {entry}");
286 }
287 }
288 cmd if cmd.starts_with(".watch") => {
289 let val = cmd.strip_prefix(".watch").unwrap_or("").trim();
290 if val.is_empty() {
291 match state.watch_interval_secs {
293 Some(secs) => {
294 println!("Watch disabled (was every {secs}s)");
295 state.watch_interval_secs = None;
296 }
297 None => {
298 println!("No watch interval set. Use `.watch <secs>` to enable.");
299 }
300 }
301 } else {
302 match val.parse::<u64>() {
303 Ok(secs) if secs > 0 => {
304 state.watch_interval_secs = Some(secs);
305 println!(
306 "Watch every {secs}s enabled (will activate on next query)"
307 );
308 }
309 _ => {
310 println!(
311 "Invalid interval: {val}. Use a positive number (seconds)."
312 );
313 }
314 }
315 }
316 }
317 cmd if cmd.starts_with(".export") => {
318 let val = cmd.strip_prefix(".export").unwrap_or("").trim();
319 if val.is_empty() || val == "off" {
320 state.export_path = None;
321 println!("Export disabled");
322 } else {
323 state.export_path = Some(val.to_owned());
324 println!("Export to: {val}");
325 }
326 }
327 cmd if cmd.starts_with(".output") => {
328 let val = cmd.strip_prefix(".output").unwrap_or("").trim();
329 match val {
330 "table" | "json" | "csv" | "jsonl" => {
331 state.output_format = val.to_owned();
332 println!("Output format set to: {val}");
333 }
334 "" => {
335 println!("Current output format: {}", state.output_format);
336 }
337 _ => {
338 println!("Unknown format: {val}. Valid: table, json, csv, jsonl");
339 }
340 }
341 }
342 _ => {
343 println!("Unknown command: {trimmed}");
344 println!("Type .help for available commands");
345 }
346 }
347 rl.add_history_entry(trimmed.as_str())?;
348 if let Some(ref path) = history_file {
349 let _ = rl.save_history(path);
350 }
351 continue;
352 }
353
354 rl.add_history_entry(trimmed.as_str())?;
355 if let Some(ref path) = history_file {
356 let _ = rl.save_history(path);
357 }
358
359 state.last_query = Some(trimmed.clone());
361
362 let query_to_run = trimmed.clone();
364
365 if let Err(e) = execute_and_output(&query_to_run, config, &state).await {
367 let msg = format_error_color(&e);
368 eprintln!("{msg}");
369 }
370
371 if let Some(interval) = state.watch_interval_secs {
373 println!("Watching every {interval}s (Ctrl+C to stop)...");
374 let watch_query = state.last_query.clone().unwrap_or_default();
375 run_watch_loop(&watch_query, config, &state, interval).await;
376 }
377 }
378
379 if let Some(ref path) = history_file {
380 let _ = rl.save_history(path);
381 }
382
383 Ok(())
384}
385
386async fn run_watch_loop(query: &str, config: &DolConfig, state: &ReplState, interval: u64) {
388 let mut tick = tokio::time::interval(Duration::from_secs(interval));
389 tick.tick().await;
391
392 loop {
393 tokio::select! {
394 _ = tokio::signal::ctrl_c() => {
395 println!();
396 break;
397 }
398 _ = tick.tick() => {
399 if let Err(e) = execute_and_output(query, config, state).await {
400 let msg = format_error_color(&e);
401 eprintln!("{msg}");
402 }
403 }
404 }
405 }
406}
407
408async fn execute_and_output(
411 query: &str,
412 config: &DolConfig,
413 state: &ReplState,
414) -> anyhow::Result<()> {
415 let parsed = parser::parse(query)?;
416
417 match &parsed.query {
418 Query::Inspect(q) if q.at.is_some() => {
419 let store_path = config.store.as_deref().ok_or_else(|| {
420 anyhow::anyhow!("historical query requires a store; set `store` in config")
421 })?;
422 let store = SqliteTelemetryStore::open(store_path)?;
423 let result = executor::execute_with_store(&parsed.query, &store).await?;
424 let output = state.render_result(&result);
425 println!("{output}");
426 state.export_result(&result)?;
427 return Ok(());
428 }
429 Query::Events(q) if q.time.is_some() => {
430 let store_path = config.store.as_deref().ok_or_else(|| {
431 anyhow::anyhow!("historical query requires a store; set `store` in config")
432 })?;
433 let store = SqliteTelemetryStore::open(store_path)?;
434 let result = executor::execute_with_store(&parsed.query, &store).await?;
435 let output = state.render_result(&result);
436 println!("{output}");
437 state.export_result(&result)?;
438 return Ok(());
439 }
440 _ => {}
441 }
442
443 match &parsed.query {
444 Query::Alert(rule) => {
445 let docker = BollardDockerClient::connect_with_config(config)?;
446 let docker = Arc::new(docker);
447 let metrics = BollardMetricsCollector::with_config(Arc::clone(&docker), config);
448 let mut evaluator = crate::alerts::AlertEvaluator::new();
449 let mut interval =
450 tokio::time::interval(std::time::Duration::from_secs(ALERT_EVAL_INTERVAL));
451
452 println!("Evaluating alert... (Ctrl+C to stop)");
453 loop {
454 tokio::select! {
455 _ = tokio::signal::ctrl_c() => break,
456 _ = interval.tick() => {
457 let samples = metrics.collect().await?;
458 let events = evaluator.evaluate_samples(rule, &samples, std::time::Instant::now())?;
459 for event in events {
460 println!("{}", crate::alerts::render_alert_event(&event));
461 }
462 }
463 }
464 }
465 return Ok(());
466 }
467 Query::Events(events_query) => {
468 let docker = Arc::new(BollardDockerClient::connect_with_config(config)?);
469 let source = crate::events::BollardEventSource::new(Arc::clone(&docker));
470 return crate::events::stream_events(events_query, &source, move |row| {
471 let result = executor::ExecutionResult { rows: vec![row] };
472 let text = match state.output_format.as_str() {
474 "json" => serde_json::to_string_pretty(&result).unwrap_or_default(),
475 "csv" => render_csv(&result),
476 "jsonl" => {
477 let jsonl = render_jsonl(&result);
478 if jsonl.is_empty() {
479 String::new()
480 } else {
481 jsonl + "\n"
482 }
483 }
484 _ => render_table(&result),
485 };
486 if !text.is_empty() {
487 print!("{text}");
488 }
489 Ok(())
490 })
491 .await
492 .map_err(Into::into);
493 }
494 Query::Logs(logs_query) => {
495 let docker = Arc::new(BollardDockerClient::connect_with_config(config)?);
496 let source = crate::events::BollardLogSource::new(Arc::clone(&docker));
497 return crate::events::stream_logs(logs_query, &source, move |row| {
498 let result = executor::ExecutionResult { rows: vec![row] };
499 let text = match state.output_format.as_str() {
500 "json" => serde_json::to_string_pretty(&result).unwrap_or_default(),
501 "csv" => render_csv(&result),
502 "jsonl" => {
503 let jsonl = render_jsonl(&result);
504 if jsonl.is_empty() {
505 String::new()
506 } else {
507 jsonl + "\n"
508 }
509 }
510 _ => render_table(&result),
511 };
512 if !text.is_empty() {
513 print!("{text}");
514 }
515 Ok(())
516 })
517 .await
518 .map_err(Into::into);
519 }
520 Query::Compose(compose_query)
521 if compose_query.target == crate::ast::ComposeTarget::Networks
522 && !compose_query.pipeline.is_empty() =>
523 {
524 let docker = Arc::new(BollardDockerClient::connect_with_config(config)?);
525 return crate::events::stream_compose_networks(compose_query, docker, move |row| {
526 let result = executor::ExecutionResult { rows: vec![row] };
527 let text = match state.output_format.as_str() {
528 "json" => serde_json::to_string_pretty(&result).unwrap_or_default(),
529 "csv" => render_csv(&result),
530 "jsonl" => {
531 let jsonl = render_jsonl(&result);
532 if jsonl.is_empty() {
533 String::new()
534 } else {
535 jsonl + "\n"
536 }
537 }
538 _ => render_table(&result),
539 };
540 if !text.is_empty() {
541 print!("{text}");
542 }
543 Ok(())
544 })
545 .await
546 .map_err(Into::into);
547 }
548 Query::Compose(compose_query)
549 if compose_query.target == crate::ast::ComposeTarget::Logs =>
550 {
551 let docker = Arc::new(BollardDockerClient::connect_with_config(config)?);
552 return crate::events::stream_compose_logs(compose_query, docker, move |row| {
553 let result = executor::ExecutionResult { rows: vec![row] };
554 let text = match state.output_format.as_str() {
555 "json" => serde_json::to_string_pretty(&result).unwrap_or_default(),
556 "csv" => render_csv(&result),
557 "jsonl" => {
558 let jsonl = render_jsonl(&result);
559 if jsonl.is_empty() {
560 String::new()
561 } else {
562 jsonl + "\n"
563 }
564 }
565 _ => render_table(&result),
566 };
567 if !text.is_empty() {
568 print!("{text}");
569 }
570 Ok(())
571 })
572 .await
573 .map_err(Into::into);
574 }
575 _ => {}
576 }
577
578 let docker = Arc::new(BollardDockerClient::connect_with_config(config)?);
580 let metrics = BollardMetricsCollector::with_config(Arc::clone(&docker), config);
581 let result = executor::execute_with_metrics(&parsed.query, docker, &metrics).await?;
582 let output = state.render_result(&result);
583 println!("{output}");
584 state.export_result(&result)?;
585
586 Ok(())
587}
588
589fn print_repl_help(state: &ReplState) {
590 println!("DOL REPL Commands:");
591 println!(" .help Show this help");
592 println!(" .exit / .quit Exit the REPL");
593 println!(" .host [<addr>] Show or set Docker host (e.g. tcp://192.168.1.100:2375)");
594 println!(" .history Show command history");
595 println!(" .watch [<secs>] Re-run last query every N seconds (no arg to disable)");
596 println!(" .export <path> Export results to file (.export off to disable)");
597 println!(" .output <fmt> Set output format: table, json, csv, jsonl");
598 println!();
599 println!("Current settings:");
600 println!(
601 " output: {} | export: {} | watch: {}",
602 state.output_format,
603 state.export_path.as_deref().unwrap_or("off"),
604 state
605 .watch_interval_secs
606 .map_or("off".to_owned(), |s| format!("{s}s"))
607 );
608 println!();
609 println!("DOL Queries:");
610 println!(" observe containers");
611 println!(" events containers where action = die");
612 println!(" logs container <name>");
613 println!(" logs container <name> tail 50");
614 println!(" compose <project> logs service <name>");
615 println!(" compose <project> logs service <name> tail 50");
616 println!(" compose <project> networks");
617 println!(" compose <project> events");
618 println!(" Streaming compose targets: logs, networks — add a pipeline for live streaming");
619 println!(" inspect container <name>");
620 println!(" ... and any other DOL query");
621}
622
623fn format_error_color(err: &anyhow::Error) -> String {
625 if let Some(parse_err) = err.downcast_ref::<parser::ParseError>() {
626 return parse_err.format_color();
627 }
628 if let Some(eval_err) = err.downcast_ref::<EvalError>() {
629 return eval_err.format_color();
630 }
631 format!(
633 "{bold}{red}error{reset}: {msg}",
634 bold = ANSI_BOLD,
635 red = ANSI_FG_RED,
636 reset = ANSI_RESET,
637 msg = err,
638 )
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn test_repl_state_defaults() {
647 let state = ReplState::default();
648 assert!(state.last_query.is_none());
649 assert!(state.watch_interval_secs.is_none());
650 assert!(state.export_path.is_none());
651 assert_eq!(state.output_format, ""); }
653
654 #[test]
655 fn test_repl_state_render_table_default() {
656 let state = ReplState {
657 output_format: "table".to_owned(),
658 ..ReplState::default()
659 };
660 let result = executor::ExecutionResult { rows: vec![] };
661 let output = state.render_result(&result);
662 assert_eq!(output, "No rows");
663 }
664
665 #[test]
666 fn test_repl_state_render_json() {
667 let state = ReplState {
668 output_format: "json".to_owned(),
669 ..ReplState::default()
670 };
671 let result = executor::ExecutionResult { rows: vec![] };
672 let output = state.render_result(&result);
673 assert!(
675 output.contains(r#""rows""#),
676 "expected JSON output to contain 'rows' field, got: {output}"
677 );
678 }
679
680 #[test]
681 fn test_repl_state_export_none_does_nothing() {
682 let state = ReplState::default();
683 let result = executor::ExecutionResult { rows: vec![] };
684 assert!(state.export_result(&result).is_ok());
686 }
687
688 #[test]
689 fn test_print_repl_help_runs_without_panic() {
690 let state = ReplState {
691 output_format: "table".to_owned(),
692 ..ReplState::default()
693 };
694 print_repl_help(&state);
695 }
696
697 #[test]
698 fn test_execute_and_output_rejects_invalid_query() {
699 let rt = tokio::runtime::Runtime::new().unwrap();
700 let config = DolConfig::default();
701 let state = ReplState {
702 output_format: "table".to_owned(),
703 ..ReplState::default()
704 };
705 let result = rt.block_on(execute_and_output("invalid query here", &config, &state));
706 assert!(result.is_err());
707 }
708
709 #[test]
710 fn test_docker_host_env_propagates_to_streaming_compose_networks() {
711 let rt = tokio::runtime::Runtime::new().unwrap();
712
713 let original = std::env::var("DOCKER_HOST").ok();
715
716 unsafe {
718 std::env::set_var("DOCKER_HOST", "tcp://127.0.0.1:1");
719 }
720
721 let config = DolConfig::default();
722 let state = ReplState {
723 output_format: "table".to_owned(),
724 ..ReplState::default()
725 };
726
727 let result = rt.block_on(execute_and_output(
731 "compose myapp networks | where action = connect",
732 &config,
733 &state,
734 ));
735
736 assert!(
738 result.is_err(),
739 "expected connection error when DOCKER_HOST points to unreachable address"
740 );
741 let err = result.unwrap_err().to_string();
742 assert!(
746 !err.contains("parse error"),
747 "expected a connection/runtime error, but got a parse error: {err}"
748 );
749
750 match original {
752 Some(v) => unsafe {
753 std::env::set_var("DOCKER_HOST", v);
754 },
755 None => unsafe {
756 std::env::remove_var("DOCKER_HOST");
757 },
758 }
759 }
760}