Skip to main content

dol/
cli.rs

1//! CLI argument parsing and command dispatch.
2//!
3//! Uses [`clap`] to define the `dol` command-line interface: subcommands
4//! (config, repl, top, dashboard), flags (`--output`, `--store`, `--watch`,
5//! etc.), and the positional DOL query string. The [`run`] function
6//! dispatches execution based on the parsed arguments.
7//!
8//! # Example
9//!
10//! ```ignore
11//! let cli = Cli::parse_from(["dol", "observe containers"]);
12//! cli::run(cli).await?;
13//! ```
14
15use std::path::PathBuf;
16use std::sync::{Arc, Mutex};
17
18use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
19
20use crate::{
21    ALERT_EVAL_INTERVAL, DEFAULT_METRICS_INTERVAL, DEFAULT_SNAPSHOT_INTERVAL,
22    alerts::{self, AlertEvaluator},
23    ast::Query,
24    collector::{self, CollectorConfig},
25    config::{self, ConfigAction, DolConfig},
26    dashboard,
27    docker::BollardDockerClient,
28    events::{self, BollardEventSource},
29    executor::{
30        self, ExecutionResult, Theme, render_csv, render_jsonl, render_table,
31        render_table_with_theme,
32    },
33    export::{self, ExportFormat},
34    metrics::{BollardMetricsCollector, MetricsCollector},
35    parser, planner,
36    sqlite_store::SqliteTelemetryStore,
37    storage::TelemetryStore,
38};
39
40#[derive(Debug, Parser)]
41#[command(
42    name = "dol",
43    version,
44    about = "Docker Observability Language — query, monitor, and analyze your Docker infrastructure",
45    long_about = "DOL (Docker Observability Language) is a query language for Docker infrastructure.
46Use SQL-like pipelines to observe containers, stream events, set up alerts,
47analyze anomalies, and inspect historical data — all from a single CLI.
48
49See the full language reference at: https://github.com/genc-murat/DockQL",
50    subcommand_value_name = "COMMAND",
51    subcommand_negates_reqs = true,
52    after_help = r#"
53EXAMPLES:
54
55  Basic queries:
56    dol "observe containers"                                   List all containers
57    dol "observe containers | where state = running"           Filter running containers
58    dol "observe containers | select name, image, status"      Select specific fields
59    dol "observe containers | sort by memory desc | limit 5"   Top 5 by memory usage
60    dol "events containers"                                    Stream Docker events live
61
62  Advanced queries:
63    dol "observe containers | group by image | count"          Count containers per image
64    dol "alert when cpu > 80% for 2m then webhook http://..."  CPU alert with webhook
65    dol "inspect container <name>"                             Inspect a single container
66    dol "compose ls"                                           List compose projects
67    dol "compose myapp networks | where action = connect"       Stream compose network events
68    dol "compose myapp logs api-service | where message contains error"  Stream compose service logs
69    dol "analyze containers find anomalies"                    Detect issues automatically
70    dol "analyze containers explain"                           Full diagnostic summary
71
72  Streaming targets (compose): logs, networks — add a pipeline to switch to live streaming mode
73
74  Working with files and store:
75    dol -f examples/ping.dol                                   Run query from file
76    dol --explain "observe containers"                         Show query plan (dry run)
77    dol --store ./dol.db --collect                             Start background collector
78
79  Output and integration:
80    dol "observe containers" --output json                     JSON output
81    dol "observe containers" --output csv --export results.csv Export to CSV file
82    dol "observe containers" --watch 5                         Re-run every 5 seconds
83    dol "observe containers" --theme light                     Light theme for tables
84
85  Interactive modes:
86    dol repl              Interactive REPL with tab completion
87    dol top               Live container monitor (top-like)
88    dol dashboard         Multi-panel dashboard with events
89    dol config view       Show current configuration
90    dol config set theme light  Set default theme to light
91
92For more: https://github.com/genc-murat/DockQL
93"#
94)]
95#[allow(clippy::struct_excessive_bools)]
96pub struct Cli {
97    /// DOL query to execute (positional).
98    pub query: Option<String>,
99
100    /// Read the DOL query from a .dol file.
101    #[arg(short = 'f', long)]
102    pub file: Option<String>,
103
104    /// Output format: table, json, json-compact, csv, jsonl.
105    #[arg(long, value_enum)]
106    pub output: Option<OutputFormat>,
107
108    /// Path to the `SQLite` telemetry store file.
109    #[arg(long)]
110    pub store: Option<String>,
111
112    /// Run in background collection mode. Requires --store.
113    #[arg(long)]
114    pub collect: bool,
115
116    /// Metrics collection interval in seconds (used with --collect).
117    #[arg(long, default_value_t = DEFAULT_METRICS_INTERVAL)]
118    pub metrics_interval: u64,
119
120    /// Snapshot collection interval in seconds (used with --collect).
121    #[arg(long, default_value_t = DEFAULT_SNAPSHOT_INTERVAL)]
122    pub snapshot_interval: u64,
123
124    /// Show telemetry store statistics.
125    #[arg(long)]
126    pub store_stats: bool,
127
128    /// Run retention cleanup on the store.
129    #[arg(long)]
130    pub apply_retention: bool,
131
132    /// Show the query execution plan without running it.
133    #[arg(long)]
134    pub explain: bool,
135
136    /// Re-run the query every N seconds (batch queries only).
137    #[arg(long)]
138    pub watch: Option<u64>,
139
140    /// Timeout in seconds for each query execution (applies to watch, alert, events, and store queries).
141    /// If the query takes longer than this, it will be aborted and logged.
142    #[arg(long)]
143    pub timeout: Option<u64>,
144
145    /// Export results to a file (format inferred from extension: .csv, .json, .jsonl, .table).
146    #[arg(long)]
147    pub export: Option<PathBuf>,
148
149    /// Export format for external systems: influx, loki, prometheus (use with --export).
150    #[arg(long)]
151    pub export_format: Option<ExportFormat>,
152
153    /// Push results to `InfluxDB` v1 HTTP write API (e.g. <http://localhost:8086/write?db=mydb>).
154    #[arg(long)]
155    pub export_influx: Option<String>,
156
157    /// Push results to Grafana Loki HTTP push API (e.g. <http://localhost:3100>).
158    #[arg(long)]
159    pub export_grafana_loki: Option<String>,
160
161    /// Push results to Prometheus Pushgateway (e.g. <http://localhost:9091>).
162    #[arg(long)]
163    pub export_prometheus: Option<String>,
164
165    /// Remote Docker host (e.g. <tcp://192.168.1.100:2375>).
166    #[arg(long)]
167    pub host: Option<String>,
168
169    /// Generate shell completion script.
170    #[arg(long, value_enum)]
171    pub completion: Option<clap_complete::Shell>,
172
173    /// Compare current state with the last store snapshot (requires --store).
174    #[arg(long)]
175    pub diff: bool,
176
177    /// Color theme for table output: dark (default) or light.
178    /// Can also be set in config file with `theme: dark|light`.
179    #[arg(long, value_enum)]
180    pub theme: Option<Theme>,
181
182    /// Subcommand (config, repl).
183    #[command(subcommand)]
184    pub command: Option<CliCommand>,
185}
186
187#[derive(Debug, Subcommand)]
188pub enum CliCommand {
189    /// Manage DOL configuration.
190    Config {
191        #[command(subcommand)]
192        action: ConfigAction,
193    },
194    /// Interactive REPL shell with tab completion, history, and syntax-colored error messages.
195    Repl,
196    /// Live-updating TUI container monitor (top-like) with CPU, memory, and network stats.
197    Top,
198    /// Multi-panel dashboard with container list, live event stream, and resource usage gauges.
199    Dashboard,
200}
201
202/// Output format: table, json, json-compact, csv, jsonl.
203#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
204pub enum OutputFormat {
205    Table,
206    Json,
207    /// Compact (minified) JSON without indentation or newlines.
208    #[value(name = "json-compact")]
209    JsonCompact,
210    Csv,
211    Jsonl,
212}
213
214/// Resolve the effective Docker host from CLI `--host` or config file,
215/// and set `DOCKER_HOST` so that all `docker` CLI subprocesses use it.
216/// CLI `--host` takes precedence over config `host`.
217fn apply_host(cli_host: Option<&str>, config_host: Option<&str>) {
218    let host = cli_host.or(config_host);
219    if let Some(host) = host {
220        // SAFETY: setting DOCKER_HOST from user-provided --host flag or config
221        unsafe {
222            std::env::set_var("DOCKER_HOST", host);
223        }
224    }
225}
226
227#[allow(clippy::significant_drop_tightening)]
228pub async fn run(cli: Cli) -> anyhow::Result<()> {
229    let config = DolConfig::load();
230
231    // Initialise global alert timeout config from loaded config.
232    alerts::init_alert_timeouts(
233        config.webhook_timeout.unwrap_or(10),
234        config.restart_timeout.unwrap_or(30),
235    );
236
237    // Set DOCKER_HOST *before* any subcommand or query execution so that
238    // all Docker clients pick up the correct host.
239    apply_host(cli.host.as_deref(), config.host.as_deref());
240
241    // Handle subcommands.
242    if let Some(cmd) = &cli.command {
243        match cmd {
244            CliCommand::Config { action } => {
245                return config::execute_config(action.clone());
246            }
247            CliCommand::Repl => {
248                return crate::repl::run_repl(&config).await;
249            }
250            CliCommand::Top => {
251                return dashboard::run_top(&config).await;
252            }
253            CliCommand::Dashboard => {
254                return dashboard::run_dashboard(&config).await;
255            }
256        }
257    }
258
259    // Resolve effective colour theme: CLI --theme > config theme > default dark
260    let effective_theme = cli
261        .theme
262        .or_else(|| {
263            config
264                .theme
265                .as_deref()
266                .and_then(|s| match s.to_lowercase().as_str() {
267                    "light" => Some(Theme::Light),
268                    "dark" => Some(Theme::Dark),
269                    _ => None,
270                })
271        })
272        .unwrap_or(Theme::Dark);
273
274    let output_format = cli.output.unwrap_or(OutputFormat::Table);
275
276    if let Some(shell) = cli.completion {
277        let mut cmd = Cli::command();
278        let name = cmd.get_name().to_string();
279        clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
280        return Ok(());
281    }
282
283    // Handle --store-stats mode.
284    if cli.store_stats {
285        let store_path = cli
286            .store
287            .as_deref()
288            .or(config.store.as_deref())
289            .ok_or_else(|| anyhow::anyhow!("--store-stats requires --store <path>"))?;
290        let store = SqliteTelemetryStore::open(store_path)?;
291        let stats = store.stats()?;
292        println!("Telemetry Store Statistics:");
293        println!("  Metrics:   {}", stats.metric_count);
294        println!("  Events:    {}", stats.event_count);
295        println!("  Snapshots: {}", stats.snapshot_count);
296        return Ok(());
297    }
298
299    // Handle --apply-retention mode.
300    if cli.apply_retention {
301        let store_path = cli
302            .store
303            .as_deref()
304            .or(config.store.as_deref())
305            .ok_or_else(|| anyhow::anyhow!("--apply-retention requires --store <path>"))?;
306        let mut store = SqliteTelemetryStore::open(store_path)?;
307        let stats = store.apply_retention()?;
308        println!("Retention cleanup complete:");
309        println!("  Metrics deleted:   {}", stats.metrics_deleted);
310        println!("  Events deleted:    {}", stats.events_deleted);
311        println!("  Snapshots deleted: {}", stats.snapshots_deleted);
312        return Ok(());
313    }
314
315    // Handle --collect mode.
316    if cli.collect {
317        let store_path = cli
318            .store
319            .as_deref()
320            .or(config.store.as_deref())
321            .ok_or_else(|| anyhow::anyhow!("--collect requires --store <path>"))?;
322        let store = SqliteTelemetryStore::open(store_path)?;
323        let store = Arc::new(Mutex::new(store));
324        let docker_inner = BollardDockerClient::connect_with_config(&config)?;
325        let metrics = BollardMetricsCollector::with_config(
326            std::sync::Arc::new(docker_inner.clone()),
327            &config,
328        );
329        let docker = docker_inner;
330        let config_cfg = CollectorConfig {
331            snapshot_interval_secs: cli.snapshot_interval,
332            metrics_interval_secs: cli.metrics_interval,
333        };
334
335        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
336
337        println!(
338            "Starting background collector (metrics every {}s, snapshots every {}s)...",
339            config_cfg.metrics_interval_secs, config_cfg.snapshot_interval_secs
340        );
341        println!("Press Ctrl+C to stop.");
342
343        let handle = collector::spawn_metrics_collector(
344            Arc::clone(&store),
345            docker,
346            metrics,
347            config_cfg,
348            shutdown_rx,
349        );
350
351        tokio::signal::ctrl_c().await?;
352        println!("\nShutting down collector...");
353        shutdown_tx.send(true)?;
354        handle.await?;
355
356        if let Ok(store) = store.lock() {
357            let stats = store.stats()?;
358            println!("Final store statistics:");
359            println!("  Metrics:   {}", stats.metric_count);
360            println!("  Events:    {}", stats.event_count);
361            println!("  Snapshots: {}", stats.snapshot_count);
362        }
363
364        return Ok(());
365    }
366
367    // Read query from --file if provided, otherwise use positional argument.
368    let query = if let Some(ref file_path) = cli.file {
369        std::fs::read_to_string(file_path)
370            .map_err(|e| anyhow::anyhow!("failed to read file '{file_path}': {e}"))?
371    } else {
372        cli.query.as_deref().unwrap_or_default().to_owned()
373    };
374    let query = query.trim().to_owned();
375
376    if query.is_empty() {
377        anyhow::bail!(
378            "empty DOL query; pass a query such as `observe containers` or use `--file <path>`"
379        );
380    }
381
382    let parsed = parser::parse(&query)?;
383
384    if cli.explain {
385        let plan = planner::plan(&parsed.query);
386        println!("{plan}");
387        return Ok(());
388    }
389
390    let export_writer = if let Some(ref path) = cli.export {
391        let file = std::fs::File::create(path)?;
392        Some(Mutex::new(file))
393    } else {
394        None
395    };
396
397    let output_result = |result: &ExecutionResult| -> anyhow::Result<()> {
398        // When --export-format is set, write in that format regardless of --output
399        if let Some(export_fmt) = cli.export_format {
400            if let Some(ref writer) = export_writer {
401                use std::io::Write;
402                let mut file = writer.lock().expect("lock writer");
403                match export_fmt {
404                    ExportFormat::Influx => {
405                        let text = export::format_as_influx(result, "containers");
406                        file.write_all(text.as_bytes())?;
407                    }
408                    ExportFormat::Loki => {
409                        let text = export::format_as_loki(result)?;
410                        file.write_all(text.as_bytes())?;
411                    }
412                    ExportFormat::Prometheus => {
413                        let text = export::format_as_prometheus(result);
414                        file.write_all(text.as_bytes())?;
415                    }
416                }
417            }
418            return Ok(());
419        }
420
421        match output_format {
422            OutputFormat::Table => {
423                let text = render_table_with_theme(result, effective_theme);
424                if let Some(ref writer) = export_writer {
425                    use std::io::Write;
426                    let mut file = writer.lock().expect("lock writer");
427                    writeln!(file, "{text}")?;
428                } else {
429                    print!("{text}");
430                }
431            }
432            OutputFormat::Json => {
433                if let Ok(json) = serde_json::to_string_pretty(&result) {
434                    if let Some(ref writer) = export_writer {
435                        use std::io::Write;
436                        let mut file = writer.lock().expect("lock writer");
437                        file.write_all(json.as_bytes())?;
438                    } else {
439                        println!("{json}");
440                    }
441                }
442            }
443            OutputFormat::JsonCompact => {
444                if let Ok(json) = serde_json::to_string(&result) {
445                    if let Some(ref writer) = export_writer {
446                        use std::io::Write;
447                        let mut file = writer.lock().expect("lock writer");
448                        file.write_all(json.as_bytes())?;
449                    } else {
450                        println!("{json}");
451                    }
452                }
453            }
454            OutputFormat::Csv => {
455                let csv = render_csv(result);
456                if let Some(ref writer) = export_writer {
457                    use std::io::Write;
458                    let mut file = writer.lock().expect("lock writer");
459                    writeln!(file, "{csv}")?;
460                } else {
461                    println!("{csv}");
462                }
463            }
464            OutputFormat::Jsonl => {
465                let jsonl = render_jsonl(result);
466                if let Some(ref writer) = export_writer {
467                    use std::io::Write;
468                    let mut file = writer.lock().expect("lock writer");
469                    file.write_all(jsonl.as_bytes())?;
470                    if !jsonl.is_empty() {
471                        writeln!(file)?;
472                    }
473                } else {
474                    println!("{jsonl}");
475                }
476            }
477        }
478        Ok(())
479    };
480
481    // ── Store (historical) queries with optional timeout ──────────────
482    if needs_store(&parsed.query) {
483        let store_path = cli.store.as_deref().or(config.store.as_deref()).ok_or_else(|| {
484            anyhow::anyhow!(
485                "this query requires historical data; provide --store <path> to use a telemetry store"
486            )
487        })?;
488        let store = SqliteTelemetryStore::open(store_path)?;
489        let result = if let Some(secs) = cli.timeout {
490            tokio::time::timeout(
491                std::time::Duration::from_secs(secs),
492                executor::execute_with_store(&parsed.query, &store),
493            )
494            .await
495            .map_err(|_| anyhow::anyhow!("query timed out after {secs}s"))??
496        } else {
497            executor::execute_with_store(&parsed.query, &store).await?
498        };
499        output_result(&result)?;
500        run_exports(&cli, &result).await?;
501        return Ok(());
502    }
503
504    // ── Alert mode with optional timeout ──────────────────────────────
505    if let Query::Alert(rule) = &parsed.query {
506        // When --watch is set, alert is handled in the watch loop below with the specified interval.
507        if cli.watch.is_none() {
508            let alert_docker = BollardDockerClient::connect_with_config(&config)
509                .ok()
510                .map(std::sync::Arc::new);
511            let mut evaluator = AlertEvaluator::new();
512            let mut interval =
513                tokio::time::interval(std::time::Duration::from_secs(ALERT_EVAL_INTERVAL));
514
515            let store: Option<Arc<Mutex<SqliteTelemetryStore>>> = cli
516                .store
517                .as_deref()
518                .or(config.store.as_deref())
519                .map(SqliteTelemetryStore::open)
520                .transpose()?
521                .map(|s| Arc::new(Mutex::new(s)));
522
523            println!("Evaluating alert... (Ctrl+C to stop)");
524            loop {
525                tokio::select! {
526                    _ = tokio::signal::ctrl_c() => {
527                        break;
528                    }
529                    _ = interval.tick() => {
530                        // Only the metrics.collect() call is blocking; the evaluator
531                        // is in-memory computation. Spawn just the collect.
532                        let samples = if let (Some(secs), Some(ad)) = (cli.timeout, alert_docker.as_ref()) {
533                            let m = BollardMetricsCollector::with_config(std::sync::Arc::clone(ad), &config);
534                            match tokio::time::timeout(
535                                std::time::Duration::from_secs(secs),
536                                m.collect(),
537                            )
538                            .await
539                            {
540                                Ok(Ok(s)) => s,
541                                Ok(Err(e)) => {
542                                    eprintln!("Metrics collection error: {e}");
543                                    continue;
544                                }
545                                Err(_) => {
546                                    eprintln!("metrics collection timed out after {secs}s");
547                                    continue;
548                                }
549                            }
550                        } else if let Some(ref ad) = alert_docker {
551                            match BollardMetricsCollector::with_config(std::sync::Arc::clone(ad), &config).collect().await {
552                                Ok(s) => s,
553                                Err(e) => {
554                                    eprintln!("Metrics collection error: {e}");
555                                    continue;
556                                }
557                            }
558                        } else {
559                            eprintln!("[metrics] Docker not connected — skipping collection");
560                            continue;
561                        };
562
563                        if let Some(ref store) = store
564                            && let Ok(mut s) = store.lock() {
565                                for sample in &samples {
566                                    let _ = s.write_metric(sample.clone());
567                                }
568                            }
569
570                        match evaluator.evaluate_samples(rule, &samples, std::time::Instant::now()) {
571                            Ok(events) => {
572                                for event in events {
573                                    match output_format {
574                                        OutputFormat::Table => println!("{}", alerts::render_alert_event(&event)),
575                                        OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => println!("{}", serde_json::to_string(&event)?),
576                                        OutputFormat::Csv => println!("{},{},{:?}", event.container_name, event.message, event.action),
577                                    }
578                                }
579                            }
580                            Err(e) => eprintln!("Alert evaluation error: {e}"),
581                        }
582                    }
583                }
584            }
585        }
586        // If --watch is set, fall through to the watch loop below
587    }
588
589    // ── Events streaming with optional auto-stop timeout ──────────────
590    if let Query::Events(events_query) = &parsed.query {
591        let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
592        let source = BollardEventSource::new(std::sync::Arc::clone(&docker));
593
594        let store: Option<Arc<Mutex<SqliteTelemetryStore>>> = cli
595            .store
596            .as_deref()
597            .or(config.store.as_deref())
598            .map(SqliteTelemetryStore::open)
599            .transpose()?
600            .map(|s| Arc::new(Mutex::new(s)));
601
602        let (event_callback_store, event_callback_fmt) = (store.clone(), output_format);
603        let event_callback =
604            move |row: crate::executor::Row| -> Result<(), crate::events::EventsError> {
605                if let Some(ref store) = event_callback_store
606                    && let Ok(mut s) = store.lock()
607                    && let (Some(time), Some(action)) = (
608                        row.fields.get("time").and_then(|v| v.as_str()),
609                        row.fields.get("action").and_then(|v| v.as_str()),
610                    )
611                {
612                    let event = crate::docker::DockerEvent {
613                        time: time.to_owned(),
614                        event_type: row
615                            .fields
616                            .get("type")
617                            .and_then(|v| v.as_str())
618                            .unwrap_or("container")
619                            .to_owned(),
620                        action: action.to_owned(),
621                        actor_id: row
622                            .fields
623                            .get("actor_id")
624                            .and_then(|v| v.as_str())
625                            .unwrap_or_default()
626                            .to_owned(),
627                        container: row
628                            .fields
629                            .get("container")
630                            .and_then(|v| v.as_str())
631                            .map(str::to_owned),
632                        image: row
633                            .fields
634                            .get("image")
635                            .and_then(|v| v.as_str())
636                            .map(str::to_owned),
637                        attributes: Vec::new(),
638                    };
639                    let _ = s.write_event(event);
640                }
641
642                let result = ExecutionResult { rows: vec![row] };
643                match event_callback_fmt {
644                    OutputFormat::Table => {
645                        println!("{}", render_table(&result));
646                    }
647                    OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => {
648                        println!(
649                            "{}",
650                            serde_json::to_string(&result.rows[0])
651                                .map_err(crate::events::EventsError::Json)?
652                        );
653                    }
654                    OutputFormat::Csv => {
655                        let mut columns: Vec<String> =
656                            result.rows[0].fields.keys().cloned().collect();
657                        columns.sort();
658                        let vals: Vec<String> = columns
659                            .iter()
660                            .map(|c| {
661                                result.rows[0]
662                                    .fields
663                                    .get(c)
664                                    .map(crate::eval::render_json_cell)
665                                    .unwrap_or_default()
666                            })
667                            .collect();
668                        println!("{}", vals.join(","));
669                    }
670                }
671                Ok(())
672            };
673
674        if let Some(secs) = cli.timeout {
675            tokio::time::timeout(
676                std::time::Duration::from_secs(secs),
677                events::stream_events(events_query, &source, &event_callback),
678            )
679            .await
680            .map_err(|_| anyhow::anyhow!("events stream timed out after {secs}s"))??;
681        } else {
682            events::stream_events(events_query, &source, &event_callback).await?;
683        }
684        return Ok(());
685    }
686
687    // ── Logs streaming ─────────────────────────────────────────────────
688    if let Query::Logs(logs_query) = &parsed.query {
689        let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
690        let source = events::BollardLogSource::new(std::sync::Arc::clone(&docker));
691
692        let log_output_format = output_format;
693        let log_callback =
694            move |row: crate::executor::Row| -> Result<(), crate::events::EventsError> {
695                let result = ExecutionResult { rows: vec![row] };
696                match log_output_format {
697                    OutputFormat::Table => {
698                        println!("{}", render_table(&result));
699                    }
700                    OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => {
701                        println!(
702                            "{}",
703                            serde_json::to_string(&result.rows[0])
704                                .map_err(crate::events::EventsError::Json)?
705                        );
706                    }
707                    OutputFormat::Csv => {
708                        let mut columns: Vec<String> =
709                            result.rows[0].fields.keys().cloned().collect();
710                        columns.sort();
711                        let vals: Vec<String> = columns
712                            .iter()
713                            .map(|c| {
714                                result.rows[0]
715                                    .fields
716                                    .get(c)
717                                    .map(crate::eval::render_json_cell)
718                                    .unwrap_or_default()
719                            })
720                            .collect();
721                        println!("{}", vals.join(","));
722                    }
723                }
724                Ok(())
725            };
726
727        if let Some(secs) = cli.timeout {
728            tokio::time::timeout(
729                std::time::Duration::from_secs(secs),
730                events::stream_logs(logs_query, &source, &log_callback),
731            )
732            .await
733            .map_err(|_| anyhow::anyhow!("log stream timed out after {secs}s"))??;
734        } else {
735            events::stream_logs(logs_query, &source, &log_callback).await?;
736        }
737        return Ok(());
738    }
739
740    // ── Compose logs streaming ────────────────────────────────────────────
741    if let Query::Compose(compose_query) = &parsed.query
742        && compose_query.target == crate::ast::ComposeTarget::Logs
743    {
744        let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
745
746        let compose_log_output_format = output_format;
747        let compose_log_callback =
748            move |row: crate::executor::Row| -> Result<(), crate::events::EventsError> {
749                let result = ExecutionResult { rows: vec![row] };
750                match compose_log_output_format {
751                    OutputFormat::Table => {
752                        println!("{}", render_table(&result));
753                    }
754                    OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => {
755                        println!(
756                            "{}",
757                            serde_json::to_string(&result.rows[0])
758                                .map_err(crate::events::EventsError::Json)?
759                        );
760                    }
761                    OutputFormat::Csv => {
762                        let mut columns: Vec<String> =
763                            result.rows[0].fields.keys().cloned().collect();
764                        columns.sort();
765                        let vals: Vec<String> = columns
766                            .iter()
767                            .map(|c| {
768                                result.rows[0]
769                                    .fields
770                                    .get(c)
771                                    .map(crate::eval::render_json_cell)
772                                    .unwrap_or_default()
773                            })
774                            .collect();
775                        println!("{}", vals.join(","));
776                    }
777                }
778                Ok(())
779            };
780
781        if let Some(secs) = cli.timeout {
782            tokio::time::timeout(
783                std::time::Duration::from_secs(secs),
784                events::stream_compose_logs(compose_query, docker, compose_log_callback),
785            )
786            .await
787            .map_err(|_| anyhow::anyhow!("compose log stream timed out after {secs}s"))??;
788        } else {
789            events::stream_compose_logs(compose_query, docker, compose_log_callback).await?;
790        }
791        return Ok(());
792    }
793
794    // ── Compose networks streaming ───────────────────────────────────────────
795    if let Query::Compose(compose_query) = &parsed.query
796        && compose_query.target == crate::ast::ComposeTarget::Networks
797        && !compose_query.pipeline.is_empty()
798    {
799        let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
800
801        let compose_net_output_format = output_format;
802        let compose_net_callback =
803            move |row: crate::executor::Row| -> Result<(), crate::events::EventsError> {
804                let result = ExecutionResult { rows: vec![row] };
805                match compose_net_output_format {
806                    OutputFormat::Table => {
807                        println!("{}", render_table(&result));
808                    }
809                    OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => {
810                        println!(
811                            "{}",
812                            serde_json::to_string(&result.rows[0])
813                                .map_err(crate::events::EventsError::Json)?
814                        );
815                    }
816                    OutputFormat::Csv => {
817                        let mut columns: Vec<String> =
818                            result.rows[0].fields.keys().cloned().collect();
819                        columns.sort();
820                        let vals: Vec<String> = columns
821                            .iter()
822                            .map(|c| {
823                                result.rows[0]
824                                    .fields
825                                    .get(c)
826                                    .map(crate::eval::render_json_cell)
827                                    .unwrap_or_default()
828                            })
829                            .collect();
830                        println!("{}", vals.join(","));
831                    }
832                }
833                Ok(())
834            };
835
836        if let Some(secs) = cli.timeout {
837            tokio::time::timeout(
838                std::time::Duration::from_secs(secs),
839                events::stream_compose_networks(compose_query, docker, compose_net_callback),
840            )
841            .await
842            .map_err(|_| anyhow::anyhow!("compose networks stream timed out after {secs}s"))??;
843        } else {
844            events::stream_compose_networks(compose_query, docker, compose_net_callback).await?;
845        }
846        return Ok(());
847    }
848
849    // ── Alert state for --watch (stateful evaluator persists across iterations) ──
850    let mut alert_evaluator = match &parsed.query {
851        Query::Alert(_) => Some(AlertEvaluator::new()),
852        _ => None,
853    };
854    let alert_rule: Option<crate::ast::AlertRule> = match &parsed.query {
855        Query::Alert(r) => Some(r.clone()),
856        _ => None,
857    };
858    let alert_store: Option<Arc<Mutex<SqliteTelemetryStore>>> = if alert_rule.is_some() {
859        cli.store
860            .as_deref()
861            .or(config.store.as_deref())
862            .map(SqliteTelemetryStore::open)
863            .transpose()?
864            .map(|s| Arc::new(Mutex::new(s)))
865    } else {
866        None
867    };
868
869    // ── Batch query with optional --watch ──────────────────────────────
870    let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
871    let metrics = BollardMetricsCollector::with_config(std::sync::Arc::clone(&docker), &config);
872
873    if let Some(interval_secs) = cli.watch {
874        let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
875        loop {
876            tokio::select! {
877                _ = tokio::signal::ctrl_c() => break,
878                _ = interval.tick() => {
879                    if let (Some(ev), Some(rule)) = (&mut alert_evaluator, &alert_rule) {
880                        // ── Alert evaluation in --watch loop ──
881                        let samples = if let Some(secs) = cli.timeout {
882                            match tokio::time::timeout(
883                                std::time::Duration::from_secs(secs),
884                                BollardMetricsCollector::with_config(std::sync::Arc::clone(&docker), &config).collect(),
885                            )
886                            .await
887                            {
888                                Ok(Ok(s)) => s,
889                                Ok(Err(e)) => {
890                                    eprintln!("Metrics collection error: {e}");
891                                    continue;
892                                }
893                                Err(_) => {
894                                    eprintln!("metrics collection timed out after {secs}s");
895                                    continue;
896                                }
897                            }
898                        } else {
899                            match                        BollardMetricsCollector::with_config(std::sync::Arc::clone(&docker), &config).collect().await {
900                                Ok(s) => s,
901                                Err(e) => {
902                                    eprintln!("Metrics collection error: {e}");
903                                    continue;
904                                }
905                            }
906                        };
907
908                        if let Some(ref store) = alert_store
909                            && let Ok(mut s) = store.lock() {
910                                for sample in &samples {
911                                    let _ = s.write_metric(sample.clone());
912                                }
913                            }
914
915                        match ev.evaluate_samples(rule, &samples, std::time::Instant::now()) {
916                            Ok(events) => {
917                                for event in events {
918                                    match output_format {
919                                        OutputFormat::Table => println!("{}", alerts::render_alert_event(&event)),
920                                        OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => println!("{}", serde_json::to_string(&event)?),
921                                        OutputFormat::Csv => println!("{},{},{:?}", event.container_name, event.message, event.action),
922                                    }
923                                }
924                            }
925                            Err(e) => eprintln!("Alert evaluation error: {e}"),
926                        }
927                    } else {
928                        // ── Batch query execution ──
929                        let result = if let Some(secs) = cli.timeout {
930                            let q = parsed.query.clone();                                    tokio::time::timeout(
931                                std::time::Duration::from_secs(secs),
932                                executor::execute_with_metrics(&q, Arc::clone(&docker), &metrics),
933                            )
934                            .await
935                            .map_err(|_| anyhow::anyhow!("query timed out after {secs}s"))?
936                        } else {
937                            executor::execute_with_metrics(&parsed.query, Arc::clone(&docker), &metrics).await
938                        };
939
940                        match result {
941                            Ok(ref result) => {
942                                if let Err(e) = output_result(result) {
943                                    eprintln!("Error writing output: {e}");
944                                }
945                                if let Err(e) = run_exports(&cli, result).await {
946                                    eprintln!("Export error: {e}");
947                                }
948                            }
949                            Err(e) => eprintln!("Error: {e}"),
950                        }
951                    }
952                }
953            }
954        }
955        return Ok(());
956    }
957
958    // ── Single batch query with optional timeout ──────────────────────
959    let result = if let Some(secs) = cli.timeout {
960        tokio::time::timeout(
961            std::time::Duration::from_secs(secs),
962            executor::execute_with_metrics(&parsed.query, Arc::clone(&docker), &metrics),
963        )
964        .await
965        .map_err(|_| anyhow::anyhow!("query timed out after {secs}s"))?
966    } else {
967        executor::execute_with_metrics(&parsed.query, Arc::clone(&docker), &metrics).await
968    }?;
969
970    if cli.diff {
971        let store_path = cli
972            .store
973            .as_deref()
974            .or(config.store.as_deref())
975            .ok_or_else(|| anyhow::anyhow!("--diff requires --store <path>"))?;
976        let store = SqliteTelemetryStore::open(store_path)?;
977        let diff_output = executor::render_diff(&result, &store)?;
978        println!("{diff_output}");
979        return Ok(());
980    }
981
982    output_result(&result)?;
983    run_exports(&cli, &result).await?;
984
985    Ok(())
986}
987
988/// Push query results to configured external export targets.
989async fn run_exports(cli: &Cli, result: &ExecutionResult) -> anyhow::Result<()> {
990    if let Some(ref url) = cli.export_influx {
991        eprintln!(
992            "Pushing {} rows to InfluxDB at {url} ...",
993            result.rows.len()
994        );
995        export::push_to_influxdb(url, result).await?;
996    }
997    if let Some(ref url) = cli.export_grafana_loki {
998        eprintln!(
999            "Pushing {} rows to Grafana Loki at {url} ...",
1000            result.rows.len()
1001        );
1002        export::push_to_loki(url, result).await?;
1003    }
1004    if let Some(ref url) = cli.export_prometheus {
1005        eprintln!(
1006            "Pushing {} rows to Prometheus Pushgateway at {url} ...",
1007            result.rows.len()
1008        );
1009        export::push_to_prometheus(url, result).await?;
1010    }
1011    Ok(())
1012}
1013const fn needs_store(query: &Query) -> bool {
1014    match query {
1015        Query::Inspect(q) => q.at.is_some(),
1016        Query::Events(q) => q.time.is_some(),
1017        _ => false,
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    /// Timeout for tests that expect run() to loop indefinitely (no Docker available).
1026    const TEST_RUN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
1027
1028    #[tokio::test]
1029    async fn rejects_empty_query() {
1030        let cli = Cli {
1031            query: Some("   ".to_owned()),
1032            file: None,
1033            output: None,
1034            store: None,
1035            collect: false,
1036            metrics_interval: DEFAULT_METRICS_INTERVAL,
1037            snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1038            store_stats: false,
1039            apply_retention: false,
1040            explain: false,
1041            watch: None,
1042            timeout: None,
1043            export: None,
1044            export_format: None,
1045            export_influx: None,
1046            export_grafana_loki: None,
1047            export_prometheus: None,
1048            host: None,
1049            completion: None,
1050            diff: false,
1051            theme: None,
1052            command: None,
1053        };
1054
1055        let error = run(cli).await.unwrap_err();
1056
1057        assert!(error.to_string().contains("empty DOL query"));
1058    }
1059
1060    #[tokio::test]
1061    async fn historical_query_requires_store_flag() {
1062        let cli = Cli {
1063            query: Some("inspect container api at \"2026-01-01 12:00:00\"".to_owned()),
1064            file: None,
1065            output: None,
1066            store: None,
1067            collect: false,
1068            metrics_interval: DEFAULT_METRICS_INTERVAL,
1069            snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1070            store_stats: false,
1071            apply_retention: false,
1072            explain: false,
1073            watch: None,
1074            timeout: None,
1075            export: None,
1076            export_format: None,
1077            export_influx: None,
1078            export_grafana_loki: None,
1079            export_prometheus: None,
1080            host: None,
1081            completion: None,
1082            diff: false,
1083            theme: None,
1084            command: None,
1085        };
1086
1087        let error = run(cli).await.unwrap_err();
1088
1089        assert!(error.to_string().contains("--store"));
1090    }
1091
1092    #[test]
1093    fn test_needs_store_inspect_at_returns_true() {
1094        let query = parser::parse("inspect container test at \"2026-01-01T00:00:00Z\"")
1095            .expect("parse")
1096            .query;
1097        assert!(needs_store(&query));
1098    }
1099
1100    #[test]
1101    fn test_needs_store_events_time_returns_true() {
1102        let query = parser::parse(
1103            "events containers from \"2026-01-01T00:00:00Z\" to \"2026-01-02T00:00:00Z\"",
1104        )
1105        .expect("parse")
1106        .query;
1107        assert!(needs_store(&query));
1108    }
1109
1110    #[test]
1111    fn test_needs_store_plain_observe_returns_false() {
1112        let query = parser::parse("observe containers").expect("parse").query;
1113        assert!(!needs_store(&query));
1114    }
1115
1116    #[test]
1117    fn test_needs_store_alert_returns_false() {
1118        let query = parser::parse("alert when cpu > 80% for 30s then print \"alert\"")
1119            .expect("parse")
1120            .query;
1121        assert!(!needs_store(&query));
1122    }
1123
1124    #[test]
1125    fn test_needs_store_plain_events_returns_false() {
1126        let query = parser::parse("events containers").expect("parse").query;
1127        assert!(!needs_store(&query));
1128    }
1129
1130    #[test]
1131    fn test_apply_host_sequential() {
1132        let original = std::env::var("DOCKER_HOST").ok();
1133
1134        // Test 1: CLI host sets env var
1135        unsafe {
1136            std::env::remove_var("DOCKER_HOST");
1137        }
1138        apply_host(Some("tcp://192.168.1.100:2375"), None);
1139        assert_eq!(
1140            std::env::var("DOCKER_HOST").unwrap(),
1141            "tcp://192.168.1.100:2375"
1142        );
1143
1144        // Test 2: CLI host takes precedence over config host
1145        apply_host(Some("tcp://cli:2375"), Some("tcp://config:2375"));
1146        assert_eq!(std::env::var("DOCKER_HOST").unwrap(), "tcp://cli:2375");
1147
1148        // Test 3: Without CLI host, config host is used
1149        unsafe {
1150            std::env::remove_var("DOCKER_HOST");
1151        }
1152        apply_host(None, Some("tcp://config:2375"));
1153        assert_eq!(std::env::var("DOCKER_HOST").unwrap(), "tcp://config:2375");
1154
1155        // Test 4: No args does nothing
1156        unsafe {
1157            std::env::remove_var("DOCKER_HOST");
1158        }
1159        apply_host(None, None);
1160        assert_eq!(std::env::var("DOCKER_HOST").unwrap_or_default(), "");
1161
1162        // Restore original
1163        match original {
1164            Some(v) => unsafe {
1165                std::env::set_var("DOCKER_HOST", v);
1166            },
1167            None => unsafe {
1168                std::env::remove_var("DOCKER_HOST");
1169            },
1170        }
1171    }
1172
1173    #[test]
1174    fn test_output_format_value_enum() {
1175        // Verify the enum has all expected variants
1176        assert!(matches!(OutputFormat::Table, OutputFormat::Table));
1177        assert!(matches!(OutputFormat::Json, OutputFormat::Json));
1178        assert!(matches!(
1179            OutputFormat::JsonCompact,
1180            OutputFormat::JsonCompact
1181        ));
1182        assert!(matches!(OutputFormat::Csv, OutputFormat::Csv));
1183        assert!(matches!(OutputFormat::Jsonl, OutputFormat::Jsonl));
1184    }
1185
1186    #[test]
1187    fn test_output_format_jsoncompact_is_distinct() {
1188        // JsonCompact must be a separate variant from Json
1189        match OutputFormat::JsonCompact {
1190            OutputFormat::Json => panic!("JsonCompact should not equal Json"),
1191            OutputFormat::JsonCompact => {}
1192            _ => {}
1193        }
1194    }
1195
1196    #[tokio::test]
1197    async fn watch_with_alert_runs_without_panic() {
1198        let cli = Cli {
1199            query: Some(r#"alert when cpu > 80% for 30s then print "High""#.to_owned()),
1200            file: None,
1201            watch: Some(1),
1202            output: None,
1203            store: None,
1204            collect: false,
1205            metrics_interval: DEFAULT_METRICS_INTERVAL,
1206            snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1207            store_stats: false,
1208            apply_retention: false,
1209            explain: false,
1210            timeout: None,
1211            export: None,
1212            export_format: None,
1213            export_influx: None,
1214            export_grafana_loki: None,
1215            export_prometheus: None,
1216            host: None,
1217            completion: None,
1218            diff: false,
1219            theme: None,
1220            command: None,
1221        };
1222
1223        // Watch loop runs indefinitely; use timeout to verify no panic
1224        let result = tokio::time::timeout(TEST_RUN_TIMEOUT, run(cli)).await;
1225
1226        assert!(
1227            result.is_err(),
1228            "watch+alert loop should run until timeout (no docker)"
1229        );
1230    }
1231
1232    // Flaky on Windows CI due to tokio::signal::ctrl_c() behaving differently
1233    // in non-TTY environments, causing the loop to exit early.
1234    #[cfg_attr(
1235        target_os = "windows",
1236        ignore = "flaky on Windows (ctrl_c resolves early in CI)"
1237    )]
1238    #[tokio::test]
1239    async fn watch_with_alert_with_timeout() {
1240        let cli = Cli {
1241            query: Some(r#"alert when cpu > 80% for 30s then print "High""#.to_owned()),
1242            file: None,
1243            watch: Some(5),
1244            output: None,
1245            store: None,
1246            collect: false,
1247            metrics_interval: DEFAULT_METRICS_INTERVAL,
1248            snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1249            store_stats: false,
1250            apply_retention: false,
1251            explain: false,
1252            timeout: Some(1),
1253            export: None,
1254            export_format: None,
1255            export_influx: None,
1256            export_grafana_loki: None,
1257            export_prometheus: None,
1258            host: None,
1259            completion: None,
1260            diff: false,
1261            theme: None,
1262            command: None,
1263        };
1264
1265        // With --timeout, the collect() call uses spawn_blocking inside the watch loop.
1266        // Without docker, metrics collection fails and prints error, loop continues.
1267        let result = tokio::time::timeout(TEST_RUN_TIMEOUT, run(cli)).await;
1268
1269        assert!(
1270            result.is_err(),
1271            "watch+alert with timeout should still loop indefinitely"
1272        );
1273    }
1274
1275    #[tokio::test]
1276    async fn alert_without_watch_runs_dedicated_loop() {
1277        let cli = Cli {
1278            query: Some(r#"alert when cpu > 80% for 30s then print "High""#.to_owned()),
1279            file: None,
1280            watch: None,
1281            output: None,
1282            store: None,
1283            collect: false,
1284            metrics_interval: DEFAULT_METRICS_INTERVAL,
1285            snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1286            store_stats: false,
1287            apply_retention: false,
1288            explain: false,
1289            timeout: None,
1290            export: None,
1291            export_format: None,
1292            export_influx: None,
1293            export_grafana_loki: None,
1294            export_prometheus: None,
1295            host: None,
1296            completion: None,
1297            diff: false,
1298            theme: None,
1299            command: None,
1300        };
1301
1302        // Dedicated alert loop also runs indefinitely
1303        let result = tokio::time::timeout(TEST_RUN_TIMEOUT, run(cli)).await;
1304
1305        assert!(
1306            result.is_err(),
1307            "dedicated alert loop should run until timeout (no docker)"
1308        );
1309    }
1310}