Skip to main content

dol/
repl.rs

1//! Interactive REPL (Read-Eval-Print Loop).
2//!
3//! Provides a [`rustyline`]-based shell with tab completion, command
4//! history, and syntax-coloured error messages. Supports all DOL query
5//! types plus meta-commands (`.help`, `.exit`, `.host`, `.watch`, etc.).
6//!
7//! # Example
8//!
9//! ```ignore
10//! repl::run_repl(&config).await?;
11//! ```
12
13use 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// ── REPL state ─────────────────────────────────────────────────
37
38/// Tracks persistent state across REPL commands.
39#[derive(Default)]
40struct ReplState {
41    /// The last DOL query that was successfully parsed and executed.
42    last_query: Option<String>,
43    /// If set, re-run `last_query` every N seconds after executing a query.
44    watch_interval_secs: Option<u64>,
45    /// If set, write query results to this file path.
46    export_path: Option<String>,
47    /// Output format for displaying / exporting results: "table", "json", "csv", or "jsonl".
48    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    // Initialise global alert timeout config from loaded config.
200    crate::alerts::init_alert_timeouts(
201        config.webhook_timeout.unwrap_or(10),
202        config.restart_timeout.unwrap_or(30),
203    );
204
205    // Apply config host on startup so all Docker clients use it.
206    let mut current_host = config.host.clone().unwrap_or_default();
207    if !current_host.is_empty() {
208        // SAFETY: single-threaded startup — no concurrent env access.
209        unsafe {
210            std::env::set_var("DOCKER_HOST", &current_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                        // SAFETY: REPL is single-threaded for user input.
277                        unsafe {
278                            std::env::set_var("DOCKER_HOST", &current_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                        // No argument: toggle off or show current state
292                        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        // Save as last query for .watch
360        state.last_query = Some(trimmed.clone());
361
362        // Determine the query to run: if watch is active, use last_query
363        let query_to_run = trimmed.clone();
364
365        // ── Execute query ──
366        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        // ── Watch loop ──
372        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
386/// Run the last query repeatedly every `interval` seconds until Ctrl+C.
387async 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 immediately so the first iteration runs without waiting.
390    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
408/// Parse and execute a DOL query, then display the results according to
409/// the current `ReplState` (output format, export path).
410async 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                // In streaming mode, use the current state for rendering
473                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    // Batch query.
579    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
623/// Format an anyhow error with ANSI colours, checking for known error types.
624fn 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    // Fallback: bold red prefix + Display
632    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, ""); // Default::default() for String is ""
652    }
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        // to_string_pretty produces "rows": [...] (no space before colon)
674        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        // Should not panic when export_path is None
685        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        // Save original DOCKER_HOST.
714        let original = std::env::var("DOCKER_HOST").ok();
715
716        // Simulate `.host tcp://127.0.0.1:1` — set an unreachable address.
717        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        // Run a compose networks STREAMING query (with pipeline).
728        // The streaming dispatch will call connect_with_config → bollard reads DOCKER_HOST
729        // → tries to connect to tcp://127.0.0.1:1 → fails with connection error.
730        let result = rt.block_on(execute_and_output(
731            "compose myapp networks | where action = connect",
732            &config,
733            &state,
734        ));
735
736        // Must fail (no real Docker at 127.0.0.1:1).
737        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        // The error should be a Docker connection error, NOT a parse error.
743        // Parse errors mention "parse error" — connection errors mention "connection",
744        // "refused", "timeout", or just the bollard error.
745        assert!(
746            !err.contains("parse error"),
747            "expected a connection/runtime error, but got a parse error: {err}"
748        );
749
750        // Restore original DOCKER_HOST.
751        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}