Skip to main content

dol/
executor.rs

1//! Query execution engine.
2//!
3//! Dispatches parsed [`Query`] variants to the appropriate executor:
4//! - [`execute_with_metrics`] — live queries against Docker + metrics
5//! - [`execute_with_store`] — historical queries against a telemetry store
6//!
7//! Also provides table rendering ([`render_table_ratatui`],
8//! [`render_table_colored`]), CSV/JSONL output, and diff support.
9//!
10//! # Example
11//!
12//! ```ignore
13//! let result = executor::execute_with_metrics(&query, &docker, &metrics).await?;
14//! println!("{}", executor::render_table_with_theme(&result, Theme::Dark));
15//! ```
16
17use serde_json::{Number, Value as JsonValue};
18use std::collections::{BTreeMap, HashMap, HashSet};
19use thiserror::Error;
20
21/// Color theme for the rendered table output.
22///
23/// - `Dark` (default): light text on dark background, `DarkGray` alternating rows.
24/// - `Light`: dark text on light background, no row background tint.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
26pub enum Theme {
27    #[default]
28    Dark,
29    Light,
30}
31
32use ratatui::Terminal;
33use ratatui::backend::TestBackend;
34use ratatui::layout::{Constraint, Rect};
35use ratatui::style::{Color, Modifier, Style};
36use ratatui::text::Span;
37use ratatui::widgets::Row as RatatuiRow;
38use ratatui::widgets::{Block, Borders, Cell, Table};
39
40use std::sync::Arc;
41
42use crate::{
43    ANSI_BG_BLACK, ANSI_BG_BLUE, ANSI_BG_CYAN, ANSI_BG_DARK_GRAY, ANSI_BG_GREEN,
44    ANSI_BG_LIGHT_BLUE, ANSI_BG_LIGHT_CYAN, ANSI_BG_LIGHT_GREEN, ANSI_BG_LIGHT_MAGENTA,
45    ANSI_BG_LIGHT_RED, ANSI_BG_LIGHT_YELLOW, ANSI_BG_MAGENTA, ANSI_BG_RED, ANSI_BG_WHITE,
46    ANSI_BG_YELLOW, ANSI_BOLD, ANSI_BOLD_RED, ANSI_DIM, ANSI_FG_BLACK, ANSI_FG_BLUE, ANSI_FG_CYAN,
47    ANSI_FG_DARK_GRAY, ANSI_FG_GREEN, ANSI_FG_LIGHT_BLUE, ANSI_FG_LIGHT_CYAN, ANSI_FG_LIGHT_GREEN,
48    ANSI_FG_LIGHT_MAGENTA, ANSI_FG_LIGHT_RED, ANSI_FG_LIGHT_YELLOW, ANSI_FG_MAGENTA, ANSI_FG_RED,
49    ANSI_FG_WHITE, ANSI_FG_YELLOW, ANSI_HEADER_DARK, ANSI_HEADER_LIGHT, ANSI_ITALIC, ANSI_RESET,
50    ANSI_TITLE_DARK, ANSI_TITLE_LIGHT, ANSI_UNDERLINE, CPU_RED_THRESHOLD, CPU_YELLOW_THRESHOLD,
51    HALF_GB, ONE_GB,
52    analyze::{self, AnalyzeError},
53    ast::{
54        AggregateExpr, CollectionTarget, DurationUnit, Expression, LogsQuery, ObserveQuery,
55        PipelineNode, Query, SortDirection,
56    },
57    docker::{Container, DockerClient, DockerError, Image, MetricSample, Network, Volume},
58    eval::{self, EvalError},
59    events::{self, EventsError},
60    json_string,
61    metrics::{MetricsCollector, MetricsError, NoopMetricsCollector},
62    storage::{self, TelemetryError, TelemetryStore},
63    target_alias,
64};
65
66#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize)]
67pub struct ExecutionResult {
68    pub rows: Vec<Row>,
69}
70
71#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize)]
72pub struct Row {
73    pub fields: BTreeMap<String, JsonValue>,
74}
75
76#[derive(Debug, Error)]
77pub enum ExecutorError {
78    #[error("{0}")]
79    Docker(DockerError),
80    #[error("{0}")]
81    Metrics(MetricsError),
82    #[error("{0}")]
83    Telemetry(TelemetryError),
84    #[error("{0}")]
85    Analyze(AnalyzeError),
86    #[error("{0}")]
87    Events(EventsError),
88    #[error("unsupported query for batch executor: {0}")]
89    UnsupportedQuery(&'static str),
90    #[error("unsupported pipeline node for batch executor: {0}")]
91    UnsupportedPipeline(&'static str),
92    #[error("{0}")]
93    Eval(#[from] EvalError),
94    #[error("no store snapshot available for {0}")]
95    SnapshotNotFound(&'static str),
96}
97
98impl From<DockerError> for ExecutorError {
99    fn from(error: DockerError) -> Self {
100        Self::Docker(error)
101    }
102}
103
104impl From<MetricsError> for ExecutorError {
105    fn from(error: MetricsError) -> Self {
106        Self::Metrics(error)
107    }
108}
109
110impl From<TelemetryError> for ExecutorError {
111    fn from(error: TelemetryError) -> Self {
112        Self::Telemetry(error)
113    }
114}
115
116impl From<AnalyzeError> for ExecutorError {
117    fn from(error: AnalyzeError) -> Self {
118        Self::Analyze(error)
119    }
120}
121
122impl From<EventsError> for ExecutorError {
123    fn from(error: EventsError) -> Self {
124        Self::Events(error)
125    }
126}
127
128/// Maximum column width for table rendering. Values longer than this are truncated.
129const MAX_COL_WIDTH: usize = 30;
130
131pub async fn execute<C>(query: &Query, docker: Arc<C>) -> Result<ExecutionResult, ExecutorError>
132where
133    C: DockerClient + Send + Sync + 'static,
134{
135    execute_with_metrics(query, docker, &NoopMetricsCollector).await
136}
137
138pub async fn execute_with_metrics<C, M>(
139    query: &Query,
140    docker: Arc<C>,
141    metrics: &M,
142) -> Result<ExecutionResult, ExecutorError>
143where
144    C: DockerClient + Send + Sync + 'static,
145    M: MetricsCollector + ?Sized,
146{
147    crate::semantic::validate_semantics(query)?;
148    match query {
149        Query::Observe(query) => execute_observe(query, docker.as_ref(), metrics).await,
150        Query::Events(query) => {
151            let source = events::BollardEventSource::new(docker);
152            events::collect_events(query, &source)
153                .await
154                .map_err(Into::into)
155        }
156        Query::Inspect(_) => Err(ExecutorError::UnsupportedQuery("inspect")),
157        Query::Compose(query) => {
158            if query.target == crate::ast::ComposeTarget::Events {
159                compose_events(query, &docker).await
160            } else {
161                execute_compose(query, docker.as_ref(), metrics).await
162            }
163        }
164        Query::Logs(query) => execute_logs(query, docker.as_ref()).await,
165        Query::Ping => execute_ping(docker.as_ref()).await,
166        Query::Analyze(query) => analyze::execute_analyze(query, docker.as_ref(), metrics)
167            .await
168            .map_err(ExecutorError::Analyze),
169        Query::Alert(_) => Err(ExecutorError::UnsupportedQuery("alert")),
170        Query::Fields(target) => execute_fields(*target),
171    }
172}
173
174pub async fn execute_with_store<S>(
175    query: &Query,
176    store: &S,
177) -> Result<ExecutionResult, ExecutorError>
178where
179    S: TelemetryStore + ?Sized,
180{
181    crate::semantic::validate_semantics(query)?;
182    match query {
183        Query::Inspect(query) if query.at.is_some() => {
184            storage::inspect_at(query, store).map_err(Into::into)
185        }
186        Query::Events(query) if query.time.is_some() => storage::historical_events(query, store)
187            .await
188            .map_err(Into::into),
189        Query::Observe(query) if query.time.is_some() => historical_observe(query, store),
190        Query::Analyze(query) => {
191            analyze::execute_analyze_with_store(query, store).map_err(ExecutorError::Analyze)
192        }
193        Query::Inspect(_) => Err(ExecutorError::UnsupportedQuery("inspect")),
194        Query::Events(_) => Err(ExecutorError::UnsupportedQuery("events")),
195        Query::Observe(_) => Err(ExecutorError::UnsupportedQuery("observe historical")),
196        Query::Compose(query) => historical_compose(query, store),
197        Query::Logs(_) => Err(ExecutorError::UnsupportedQuery("logs")),
198        Query::Ping => Err(ExecutorError::UnsupportedQuery("ping")),
199        Query::Alert(_) => Err(ExecutorError::UnsupportedQuery("alert")),
200        Query::Fields(target) => execute_fields(*target),
201    }
202}
203
204fn historical_observe<S>(query: &ObserveQuery, store: &S) -> Result<ExecutionResult, ExecutorError>
205where
206    S: TelemetryStore + ?Sized,
207{
208    use crate::ast::TimeSelector;
209
210    let timestamp = match &query.time {
211        Some(TimeSelector::Last(dur)) => {
212            let secs = match dur.unit {
213                DurationUnit::Seconds => dur.value as i64,
214                DurationUnit::Minutes => dur.value as i64 * 60,
215                DurationUnit::Hours => dur.value as i64 * 3600,
216                DurationUnit::Days => dur.value as i64 * 86400,
217            };
218            let now = chrono::Utc::now();
219            (now - chrono::Duration::seconds(secs)).to_rfc3339()
220        }
221        Some(TimeSelector::Range { from, to: _ }) => from.clone(),
222        None => return Err(ExecutorError::UnsupportedQuery("observe historical")),
223    };
224
225    let snapshot = store
226        .snapshot_at_or_before(&timestamp)
227        .map_err(ExecutorError::Telemetry)?
228        .ok_or(ExecutorError::SnapshotNotFound("historical_observe"))?;
229
230    let mut rows: Vec<Row> = match query.target {
231        CollectionTarget::Containers => snapshot
232            .containers
233            .into_iter()
234            .map(|c| {
235                let mut fields = BTreeMap::new();
236                fields.insert(
237                    "snapshot_at".into(),
238                    JsonValue::String(snapshot.timestamp.clone()),
239                );
240                fields.insert("id".into(), json_string(c.id));
241                fields.insert("name".into(), json_string(c.name));
242                fields.insert("image".into(), json_string(c.image));
243                fields.insert("status".into(), json_string(c.status));
244                fields.insert("state".into(), json_string(c.state));
245                fields.insert(
246                    "restart_count".into(),
247                    c.restart_count.map_or(JsonValue::Null, json_u64),
248                );
249                Row { fields }
250            })
251            .collect(),
252        CollectionTarget::Images => snapshot
253            .images
254            .into_iter()
255            .map(|img| {
256                let mut fields = BTreeMap::new();
257                fields.insert(
258                    "snapshot_at".into(),
259                    JsonValue::String(snapshot.timestamp.clone()),
260                );
261                fields.insert("id".into(), json_string(img.id));
262                fields.insert("repository".into(), json_string(img.repository));
263                fields.insert("tag".into(), json_string(img.tag));
264                fields.insert("size".into(), json_string(img.size));
265                Row { fields }
266            })
267            .collect(),
268        CollectionTarget::Networks => snapshot
269            .networks
270            .into_iter()
271            .map(|n| {
272                let mut fields = BTreeMap::new();
273                fields.insert(
274                    "snapshot_at".into(),
275                    JsonValue::String(snapshot.timestamp.clone()),
276                );
277                fields.insert("id".into(), json_string(n.id));
278                fields.insert("name".into(), json_string(n.name));
279                fields.insert("driver".into(), json_string(n.driver));
280                Row { fields }
281            })
282            .collect(),
283        CollectionTarget::Volumes => snapshot
284            .volumes
285            .into_iter()
286            .map(|v| {
287                let mut fields = BTreeMap::new();
288                fields.insert(
289                    "snapshot_at".into(),
290                    JsonValue::String(snapshot.timestamp.clone()),
291                );
292                fields.insert("name".into(), json_string(v.name));
293                fields.insert("driver".into(), json_string(v.driver));
294                Row { fields }
295            })
296            .collect(),
297    };
298
299    if let Some(filter) = &query.filter {
300        rows = filter_rows(rows, filter)?;
301    }
302
303    for node in &query.pipeline {
304        rows = apply_pipeline_node(rows, node)?;
305    }
306
307    Ok(ExecutionResult { rows })
308}
309
310fn historical_compose<S>(
311    query: &crate::ast::ComposeQuery,
312    store: &S,
313) -> Result<ExecutionResult, ExecutorError>
314where
315    S: TelemetryStore + ?Sized,
316{
317    let latest = store
318        .all_snapshots()
319        .map_err(ExecutorError::Telemetry)?
320        .into_iter()
321        .last()
322        .ok_or(ExecutorError::SnapshotNotFound("historical_compose"))?;
323
324    let compose_project_label = query.project.clone();
325
326    let mut rows: Vec<Row> = match query.target {
327        crate::ast::ComposeTarget::Containers
328        | crate::ast::ComposeTarget::Services
329        | crate::ast::ComposeTarget::Health
330        | crate::ast::ComposeTarget::Ps
331        | crate::ast::ComposeTarget::Stats => latest
332            .containers
333            .into_iter()
334            .filter(|c| {
335                has_compose_label(
336                    &c.labels,
337                    "com.docker.compose.project",
338                    &compose_project_label,
339                )
340            })
341            .map(|c| {
342                let mut fields = BTreeMap::new();
343                fields.insert(
344                    "snapshot_at".into(),
345                    JsonValue::String(latest.timestamp.clone()),
346                );
347                fields.insert("id".into(), json_string(c.id));
348                fields.insert("name".into(), json_string(c.name));
349                fields.insert("image".into(), json_string(c.image));
350                fields.insert("status".into(), json_string(c.status));
351                fields.insert("state".into(), json_string(c.state));
352                fields.insert(
353                    "restart_count".into(),
354                    c.restart_count.map_or(JsonValue::Null, json_u64),
355                );
356                if matches!(
357                    query.target,
358                    crate::ast::ComposeTarget::Services
359                        | crate::ast::ComposeTarget::Health
360                        | crate::ast::ComposeTarget::Ps
361                        | crate::ast::ComposeTarget::Stats
362                ) {
363                    let service = extract_label_value(&c.labels, "com.docker.compose.service")
364                        .map_or(JsonValue::Null, JsonValue::String);
365                    fields.insert("service".to_owned(), service);
366                }
367                Row { fields }
368            })
369            .collect(),
370        crate::ast::ComposeTarget::Networks => latest
371            .networks
372            .into_iter()
373            .filter(|n| {
374                has_compose_label(
375                    &n.labels,
376                    "com.docker.compose.project",
377                    &compose_project_label,
378                )
379            })
380            .map(|n| {
381                let mut fields = BTreeMap::new();
382                fields.insert(
383                    "snapshot_at".into(),
384                    JsonValue::String(latest.timestamp.clone()),
385                );
386                network_row_fields(n, &mut fields);
387                Row { fields }
388            })
389            .collect(),
390        crate::ast::ComposeTarget::Volumes => latest
391            .volumes
392            .into_iter()
393            .filter(|v| {
394                has_compose_label(
395                    &v.labels,
396                    "com.docker.compose.project",
397                    &compose_project_label,
398                )
399            })
400            .map(|v| {
401                let mut fields = BTreeMap::new();
402                fields.insert(
403                    "snapshot_at".into(),
404                    JsonValue::String(latest.timestamp.clone()),
405                );
406                volume_row_fields(v, &mut fields);
407                Row { fields }
408            })
409            .collect(),
410        _ => {
411            return Err(ExecutorError::UnsupportedQuery(
412                "compose target not supported for historical queries",
413            ));
414        }
415    };
416
417    for node in &query.pipeline {
418        rows = apply_pipeline_node(rows, node)?;
419    }
420
421    Ok(ExecutionResult { rows })
422}
423
424/// Return only columns that have at least one non-null, non-empty value across all rows.
425/// This hides columns that would appear blank and waste horizontal space.
426fn filter_display_columns(result: &ExecutionResult) -> Vec<String> {
427    result.rows[0]
428        .fields
429        .keys()
430        .filter(|&col| {
431            result.rows.iter().any(|row| match row.fields.get(col) {
432                Some(JsonValue::Null) | None => false,
433                Some(JsonValue::String(s)) => !s.is_empty(),
434                Some(JsonValue::Array(a)) => !a.is_empty(),
435                Some(JsonValue::Object(o)) => !o.is_empty(),
436                _ => true, // numbers, booleans are always kept
437            })
438        })
439        .cloned()
440        .collect()
441}
442
443/// Truncate a cell value to at most `max_width` characters, appending `…` when cut.
444fn truncate_cell(value: &str, max_width: usize) -> String {
445    if value.len() > max_width {
446        let cut = max_width.saturating_sub(1);
447        if cut == 0 {
448            return "…".to_owned();
449        }
450        format!("{}…", &value[..cut])
451    } else {
452        value.to_owned()
453    }
454}
455
456/// Infer a human-readable entity name from the column set.
457fn infer_entity_type(columns: &[String]) -> &'static str {
458    if columns.iter().any(|c| c == "repository") {
459        "Images"
460    } else if columns.iter().any(|c| c == "ports") {
461        "Containers"
462    } else if columns.iter().any(|c| c == "mountpoint") {
463        "Volumes"
464    } else if columns.iter().any(|c| c == "driver") {
465        "Networks"
466    } else {
467        "Results"
468    }
469}
470
471/// Build a visual size bar string for a cell value like "93.6MB" or "28.7GB".
472/// Uses log10 scaling so that values across orders of magnitude (MB vs GB vs TB)
473/// produce proportionally meaningful bars.  If all values cluster near the minimum
474/// a `▏` tick is shown to indicate non-zero size.
475fn size_bar(value_str: &str, min_bytes: f64, max_bytes: f64, max_width: usize) -> String {
476    if max_bytes <= 0.0 || value_str.is_empty() {
477        return String::new();
478    }
479    if let Some(bytes) = crate::eval::json_as_f64(&serde_json::Value::String(value_str.to_owned()))
480    {
481        if bytes <= 0.0 {
482            return String::new();
483        }
484        let log_max = max_bytes.max(1.0).log10();
485        let log_min = min_bytes.max(1.0).log10();
486        let log_val = bytes.log10();
487        // Normalise within [log_min, log_max] so the smallest value gets ~0 and
488        // the largest gets max_width.  Clamp to handle edge cases gracefully.
489        let range = (log_max - log_min).max(0.001);
490        let fraction = ((log_val - log_min) / range).clamp(0.0, 1.0);
491        let filled = (fraction * max_width as f64).round() as usize;
492        let filled = filled.min(max_width);
493        if filled == 0 && fraction > 0.0 {
494            return "▏".to_owned();
495        }
496        "█".repeat(filled)
497    } else {
498        String::new()
499    }
500}
501
502/// Render a table using ratatui's Table widget with box-drawing borders,
503/// colored headers, and styled cells. Uses an in-memory `TestBackend`.
504#[must_use]
505pub fn render_table_ratatui(result: &ExecutionResult) -> String {
506    render_table_ratatui_with_theme(result, Theme::Dark)
507}
508
509/// Render a table with the given theme.  Selects ratatui if the terminal is
510/// wide enough, otherwise falls back to the colour-coded plain-text render.
511#[must_use]
512pub fn render_table_with_theme(result: &ExecutionResult, theme: Theme) -> String {
513    render_table_ratatui_with_theme(result, theme)
514}
515
516fn render_table_ratatui_with_theme(result: &ExecutionResult, theme: Theme) -> String {
517    if result.rows.is_empty() {
518        return "No rows".to_owned();
519    }
520
521    // Filter out all-null/all-empty columns
522    let columns = filter_display_columns(result);
523    let col_count = columns.len() as u16;
524    let entity = infer_entity_type(&columns);
525    let mut col_widths: Vec<usize> = columns.iter().map(|c| c.len().min(MAX_COL_WIDTH)).collect();
526    for row in &result.rows {
527        for (i, col) in columns.iter().enumerate() {
528            let val = row
529                .fields
530                .get(col)
531                .map(eval::render_json_cell)
532                .unwrap_or_default();
533            let truncated = truncate_cell(&val, MAX_COL_WIDTH);
534            col_widths[i] = col_widths[i].max(truncated.len());
535        }
536    }
537    // Add padding: min width of 6, cap at MAX_COL_WIDTH
538    for w in &mut col_widths {
539        *w = (*w + 2).clamp(6, MAX_COL_WIDTH + 2);
540    }
541
542    // Pre-compute size bar data for the "size" column if present (log10 scaling)
543    let size_col_idx = columns.iter().position(|c| c == "size");
544    #[allow(clippy::option_if_let_else)]
545    let (size_min_bytes, size_max_bytes, size_bar_width) = if let Some(si) = size_col_idx {
546        let mut min_b = f64::MAX;
547        let mut max_b = 0.0_f64;
548        for row in &result.rows {
549            if let Some(val) = row.fields.get("size") {
550                let s = eval::render_json_cell(val);
551                if let Some(b) = eval::json_as_f64(&serde_json::Value::String(s)) {
552                    if b > max_b {
553                        max_b = b;
554                    }
555                    if b < min_b {
556                        min_b = b;
557                    }
558                }
559            }
560        }
561        let bw = 8;
562        col_widths[si] += bw + 1;
563        #[allow(clippy::float_cmp)]
564        (if min_b == f64::MAX { 0.0 } else { min_b }, max_b, bw)
565    } else {
566        (0.0, 0.0, 0)
567    };
568
569    // Total width: sum of column widths + left/right border (2) + column spacings
570    let col_spacing: u16 = 1;
571    let spacing_total: u16 = if col_count > 1 {
572        (col_count - 1) * col_spacing
573    } else {
574        0
575    };
576    let total_width: u16 = col_widths.iter().sum::<usize>() as u16
577        + 2  // left + right border
578        + spacing_total;
579
580    // Use terminal width if available, otherwise fall back to calculated width
581    let term_width = crossterm::terminal::size().map_or(120, |(w, _)| w);
582
583    // If the table is too wide for the terminal, fall back to the simpler
584    // colored render which handles overflow better (no fixed borders).
585    if total_width > term_width.saturating_sub(2) {
586        return render_table_colored_with_theme(result, theme);
587    }
588
589    let width = total_width.max(20);
590    let row_count = result.rows.len() as u16;
591    let height = row_count + 5;
592    let height = height.clamp(5, 200);
593
594    // Theme-specific styles
595    // Light theme: no alternating row background (white-on-white is invisible),
596    // use blue/dark headings readable on light terminals.
597    let (alt_row_style, header_fg, title_fg, border_fg) = match theme {
598        Theme::Dark => (
599            Style::default().bg(Color::DarkGray),
600            Color::Cyan,
601            Color::Cyan,
602            Color::DarkGray,
603        ),
604        Theme::Light => (
605            Style::default(), // no alternating bg for light theme
606            Color::Blue,
607            Color::Blue,
608            Color::Blue,
609        ),
610    };
611
612    let backend = TestBackend::new(width, height);
613    let mut terminal = Terminal::new(backend).unwrap();
614
615    let _ = terminal.draw(|f| {
616        let area = Rect::new(0, 0, width, height);
617
618        // ── Build data rows with alternating background ──
619        let ratatui_rows: Vec<RatatuiRow> = result
620            .rows
621            .iter()
622            .enumerate()
623            .map(|(idx, row)| {
624                let is_even = idx % 2 == 0;
625                let row_base = match (theme, is_even) {
626                    (Theme::Light, _) | (Theme::Dark, true) => Style::default(),
627                    (Theme::Dark, false) => alt_row_style,
628                };
629
630                let cells: Vec<Cell> = columns
631                    .iter()
632                    .map(|col| {
633                        let val = row
634                            .fields
635                            .get(col)
636                            .map(eval::render_json_cell)
637                            .unwrap_or_default();
638                        let val = truncate_cell(&val, MAX_COL_WIDTH);
639
640                        // Compute base style from value semantics
641                        let base_style = match col.as_str() {
642                            "state" | "status" => {
643                                Style::default().fg(ansi_state_color_ratatui(&val))
644                            }
645                            "cpu" => Style::default().fg(threshold_color_ratatui_val(&val)),
646                            "memory" => Style::default().fg(memory_color_ratatui(&val)),
647                            _ => Style::default(),
648                        };
649                        let cell_style = base_style.patch(row_base);
650
651                        // For the "size" column, augment with a visual bar
652                        if col.as_str() == "size" && size_max_bytes > 0.0 && !val.is_empty() {
653                            let bar =
654                                size_bar(&val, size_min_bytes, size_max_bytes, size_bar_width);
655                            if bar.is_empty() {
656                                Cell::from(Span::styled(val, cell_style))
657                            } else {
658                                let combined = format!("{val} {bar}");
659                                Cell::from(Span::styled(combined, cell_style))
660                            }
661                        } else {
662                            Cell::from(Span::styled(val, cell_style))
663                        }
664                    })
665                    .collect();
666                RatatuiRow::new(cells).height(1)
667            })
668            .collect();
669
670        // ── Build header cells ──
671        let header_cells: Vec<Cell> = columns
672            .iter()
673            .map(|col| {
674                let header_style = Style::default()
675                    .fg(header_fg)
676                    .add_modifier(Modifier::BOLD)
677                    .add_modifier(Modifier::UNDERLINED);
678                Cell::from(Span::styled(col.to_uppercase(), header_style))
679            })
680            .collect();
681
682        let constraints: Vec<Constraint> = col_widths
683            .iter()
684            .map(|w| Constraint::Length(*w as u16))
685            .collect();
686
687        let title_text = format!(" {entity} ");
688
689        let table = Table::new(ratatui_rows, constraints)
690            .header(RatatuiRow::new(header_cells).height(1))
691            .block(
692                Block::default()
693                    .title(ratatui::text::Line::from(ratatui::text::Span::styled(
694                        title_text,
695                        Style::default().fg(title_fg).add_modifier(Modifier::BOLD),
696                    )))
697                    .title_alignment(ratatui::layout::Alignment::Center)
698                    .borders(Borders::ALL)
699                    .border_style(Style::default().fg(border_fg)),
700            )
701            .column_spacing(1);
702
703        f.render_widget(table, area);
704    });
705
706    let buffer = terminal.backend().buffer();
707    buffer_to_ansi_string(buffer)
708}
709
710/// Convert a ratatui Buffer to an ANSI-colored string by iterating cells.
711fn buffer_to_ansi_string(buffer: &ratatui::buffer::Buffer) -> String {
712    use std::fmt::Write;
713    let area = buffer.area();
714    let mut output = String::new();
715    let mut prev_style: Option<Style> = None;
716    let mut has_content = false;
717
718    for y in 0..area.height {
719        let mut line = String::new();
720        let mut line_has_content = false;
721
722        for x in 0..area.width {
723            let idx = (y * area.width + x) as usize;
724            let cell = &buffer.content()[idx];
725            let symbol = cell.symbol();
726
727            if !symbol.is_empty() && symbol != " " {
728                line_has_content = true;
729            }
730
731            // Apply style if changed
732            let style = cell.style();
733            if prev_style != Some(style) {
734                if style == Style::default() {
735                    line.push_str(ANSI_RESET);
736                } else {
737                    let _ = write!(line, "{}", style_to_ansi(style));
738                }
739                prev_style = Some(style);
740            }
741
742            line.push_str(symbol);
743        }
744
745        if line_has_content {
746            // Trim trailing whitespace but preserve ANSI codes at the end
747            let trimmed = line.trim_end_matches(' ');
748            output.push_str(trimmed);
749            output.push('\n');
750            has_content = true;
751        }
752    }
753
754    if has_content {
755        output.push_str(ANSI_RESET);
756    }
757    output
758}
759
760/// Convert a full `ratatui::style::Style` to ANSI escape codes.
761fn style_to_ansi(style: Style) -> String {
762    let mut codes = String::new();
763
764    // Reset first if there's any style change
765    if style != Style::default() {
766        codes.push_str(ANSI_RESET);
767    }
768
769    // Bold
770    if style.add_modifier.contains(Modifier::BOLD) {
771        codes.push_str(ANSI_BOLD);
772    }
773    // Dim
774    if style.add_modifier.contains(Modifier::DIM) {
775        codes.push_str(ANSI_DIM);
776    }
777    // Italic
778    if style.add_modifier.contains(Modifier::ITALIC) {
779        codes.push_str(ANSI_ITALIC);
780    }
781    // Underline
782    if style.add_modifier.contains(Modifier::UNDERLINED) {
783        codes.push_str(ANSI_UNDERLINE);
784    }
785
786    // Foreground (extended 256-color support)
787    if let Some(fg) = style.fg {
788        match fg {
789            Color::Black => codes.push_str(ANSI_FG_BLACK),
790            Color::Red => codes.push_str(ANSI_FG_RED),
791            Color::Green => codes.push_str(ANSI_FG_GREEN),
792            Color::Yellow => codes.push_str(ANSI_FG_YELLOW),
793            Color::Blue => codes.push_str(ANSI_FG_BLUE),
794            Color::Magenta => codes.push_str(ANSI_FG_MAGENTA),
795            Color::Cyan => codes.push_str(ANSI_FG_CYAN),
796            Color::White => codes.push_str(ANSI_FG_WHITE),
797            Color::DarkGray => codes.push_str(ANSI_FG_DARK_GRAY),
798            Color::LightRed => codes.push_str(ANSI_FG_LIGHT_RED),
799            Color::LightGreen => codes.push_str(ANSI_FG_LIGHT_GREEN),
800            Color::LightYellow => codes.push_str(ANSI_FG_LIGHT_YELLOW),
801            Color::LightBlue => codes.push_str(ANSI_FG_LIGHT_BLUE),
802            Color::LightMagenta => codes.push_str(ANSI_FG_LIGHT_MAGENTA),
803            Color::LightCyan => codes.push_str(ANSI_FG_LIGHT_CYAN),
804            _ => {}
805        }
806    }
807
808    // Background (extended 256-color support)
809    if let Some(bg) = style.bg {
810        match bg {
811            Color::Black => codes.push_str(ANSI_BG_BLACK),
812            Color::Red => codes.push_str(ANSI_BG_RED),
813            Color::Green => codes.push_str(ANSI_BG_GREEN),
814            Color::Yellow => codes.push_str(ANSI_BG_YELLOW),
815            Color::Blue => codes.push_str(ANSI_BG_BLUE),
816            Color::Magenta => codes.push_str(ANSI_BG_MAGENTA),
817            Color::Cyan => codes.push_str(ANSI_BG_CYAN),
818            Color::White => codes.push_str(ANSI_BG_WHITE),
819            Color::DarkGray => codes.push_str(ANSI_BG_DARK_GRAY),
820            Color::LightRed => codes.push_str(ANSI_BG_LIGHT_RED),
821            Color::LightGreen => codes.push_str(ANSI_BG_LIGHT_GREEN),
822            Color::LightYellow => codes.push_str(ANSI_BG_LIGHT_YELLOW),
823            Color::LightBlue => codes.push_str(ANSI_BG_LIGHT_BLUE),
824            Color::LightMagenta => codes.push_str(ANSI_BG_LIGHT_MAGENTA),
825            Color::LightCyan => codes.push_str(ANSI_BG_LIGHT_CYAN),
826            _ => {}
827        }
828    }
829
830    codes
831}
832
833fn ansi_state_color_ratatui(value: &str) -> Color {
834    match value {
835        "running" | "ok" | "healthy" => Color::Green,
836        "exited" | "dead" => Color::Red,
837        "restarting" | "paused" | "warning" => Color::Yellow,
838        "created" => Color::Cyan,
839        "critical" => Color::LightRed,
840        _ => Color::White,
841    }
842}
843
844fn threshold_color_ratatui_val(value_str: &str) -> Color {
845    let cleaned = value_str.trim_end_matches(|c: char| !c.is_ascii_digit() && c != '.');
846    #[allow(clippy::option_if_let_else)]
847    cleaned.parse::<f64>().map_or(Color::White, |val| {
848        if val > CPU_RED_THRESHOLD {
849            Color::Red
850        } else if val > CPU_YELLOW_THRESHOLD {
851            Color::Yellow
852        } else {
853            Color::Green
854        }
855    })
856}
857
858fn memory_color_ratatui(value_str: &str) -> Color {
859    #[allow(clippy::option_if_let_else)]
860    value_str.parse::<f64>().map_or(Color::White, |bytes| {
861        if bytes > ONE_GB as f64 {
862            Color::Red
863        } else if bytes > HALF_GB as f64 {
864            Color::Yellow
865        } else {
866            Color::Green
867        }
868    })
869}
870
871#[must_use]
872pub fn render_table(result: &ExecutionResult) -> String {
873    if result.rows.is_empty() {
874        return "No rows".to_owned();
875    }
876
877    let columns = result.rows[0].fields.keys().cloned().collect::<Vec<_>>();
878    let mut widths = columns
879        .iter()
880        .map(std::string::String::len)
881        .collect::<Vec<_>>();
882    let rendered_rows = result
883        .rows
884        .iter()
885        .map(|row| {
886            columns
887                .iter()
888                .enumerate()
889                .map(|(index, column)| {
890                    let value = row
891                        .fields
892                        .get(column)
893                        .map(eval::render_json_cell)
894                        .unwrap_or_default();
895                    widths[index] = widths[index].max(value.len());
896                    value
897                })
898                .collect::<Vec<_>>()
899        })
900        .collect::<Vec<_>>();
901
902    let mut lines = Vec::new();
903    lines.push(render_table_line(&columns, &widths));
904    lines.push(
905        widths
906            .iter()
907            .map(|width| "-".repeat(*width))
908            .collect::<Vec<_>>()
909            .join("  "),
910    );
911    lines.extend(
912        rendered_rows
913            .iter()
914            .map(|row| render_table_line(row, &widths)),
915    );
916    lines.join("\n")
917}
918
919fn has_compose_label(labels: &[String], label_key: &str, label_value: &str) -> bool {
920    labels.iter().any(|label| {
921        let parts: Vec<&str> = label.splitn(2, '=').collect();
922        parts.len() == 2 && parts[0] == label_key && parts[1] == label_value
923    })
924}
925
926fn extract_label_value(labels: &[String], label_key: &str) -> Option<String> {
927    labels.iter().find_map(|label| {
928        let parts: Vec<&str> = label.splitn(2, '=').collect();
929        if parts.len() == 2 && parts[0] == label_key {
930            Some(parts[1].to_owned())
931        } else {
932            None
933        }
934    })
935}
936
937/// Execute a compose events query by streaming Docker events and filtering
938/// by the compose project label.
939async fn compose_events<C>(
940    query: &crate::ast::ComposeQuery,
941    docker: &std::sync::Arc<C>,
942) -> Result<ExecutionResult, ExecutorError>
943where
944    C: DockerClient + Send + Sync + 'static,
945{
946    let compose_project = query.project.clone();
947    let pipeline = query.pipeline.clone();
948
949    // Create an EventsQuery from the compose query's pipeline (no separate filter)
950    let events_query = crate::ast::EventsQuery {
951        target: CollectionTarget::Containers,
952        time: None,
953        filter: None,
954        pipeline: Vec::new(),
955    };
956
957    let source = events::BollardEventSource::new(std::sync::Arc::clone(docker));
958    let mut result = events::collect_events(&events_query, &source)
959        .await
960        .map_err(ExecutorError::from)?;
961
962    // Filter rows by compose project label
963    let label = format!("com.docker.compose.project={}", compose_project);
964    result.rows.retain(|row| {
965        row.fields
966            .get("attributes")
967            .and_then(|v| v.as_array())
968            .is_some_and(|attrs| {
969                attrs
970                    .iter()
971                    .any(|a| a.as_str().is_some_and(|s| s.contains(&label)))
972            })
973    });
974
975    // Apply the compose query's pipeline
976    for node in &pipeline {
977        result.rows = apply_pipeline_node(result.rows, node)?;
978    }
979
980    Ok(result)
981}
982
983async fn execute_compose<C, M>(
984    query: &crate::ast::ComposeQuery,
985    docker: &C,
986    metrics: &M,
987) -> Result<ExecutionResult, ExecutorError>
988where
989    C: DockerClient + ?Sized,
990    M: MetricsCollector + ?Sized,
991{
992    if matches!(query.target, crate::ast::ComposeTarget::Projects) {
993        return execute_compose_projects(query, docker).await;
994    }
995
996    if matches!(query.target, crate::ast::ComposeTarget::Config) {
997        return execute_compose_config(query, docker).await;
998    }
999
1000    let compose_project_label = query.project.clone();
1001
1002    let mut rows: Vec<Row> = match query.target {
1003        crate::ast::ComposeTarget::Containers
1004        | crate::ast::ComposeTarget::Services
1005        | crate::ast::ComposeTarget::Health
1006        | crate::ast::ComposeTarget::Ps
1007        | crate::ast::ComposeTarget::Stats => {
1008            let samples = latest_metrics_by_container(metrics.collect().await?);
1009            docker
1010                .list_containers()
1011                .await?
1012                .into_iter()
1013                .filter(|c| {
1014                    has_compose_label(
1015                        &c.labels,
1016                        "com.docker.compose.project",
1017                        &compose_project_label,
1018                    )
1019                })
1020                .map(|container| {
1021                    let service = match query.target {
1022                        crate::ast::ComposeTarget::Services
1023                        | crate::ast::ComposeTarget::Health
1024                        | crate::ast::ComposeTarget::Ps
1025                        | crate::ast::ComposeTarget::Stats => {
1026                            extract_label_value(&container.labels, "com.docker.compose.service")
1027                                .map_or(JsonValue::Null, JsonValue::String)
1028                        }
1029                        _ => JsonValue::Null,
1030                    };
1031                    let mut row = container_row(container, &samples);
1032                    if matches!(
1033                        query.target,
1034                        crate::ast::ComposeTarget::Services
1035                            | crate::ast::ComposeTarget::Health
1036                            | crate::ast::ComposeTarget::Ps
1037                            | crate::ast::ComposeTarget::Stats
1038                    ) {
1039                        row.fields.insert("service".to_owned(), service);
1040                    }
1041                    row
1042                })
1043                .collect()
1044        }
1045        crate::ast::ComposeTarget::Images => {
1046            let containers = docker.list_containers().await?;
1047            let all_images = docker.list_images().await?;
1048            let project_image_ids: std::collections::HashSet<String> = containers
1049                .iter()
1050                .filter(|c| {
1051                    has_compose_label(
1052                        &c.labels,
1053                        "com.docker.compose.project",
1054                        &compose_project_label,
1055                    )
1056                })
1057                .map(|c| c.image.clone())
1058                .collect();
1059
1060            all_images
1061                .into_iter()
1062                .filter(|img| {
1063                    project_image_ids.contains(&img.id)
1064                        || project_image_ids.iter().any(|pid| {
1065                            img.repository == *pid
1066                                || format!("{}:{}", img.repository, img.tag) == *pid
1067                        })
1068                        || project_image_ids.contains(&format!("{}:{}", img.repository, img.tag))
1069                })
1070                .map(|img| {
1071                    let mut row = image_row(img);
1072                    row.fields.insert(
1073                        "service".to_owned(),
1074                        JsonValue::String("unknown".to_owned()),
1075                    );
1076                    row
1077                })
1078                .collect()
1079        }
1080        crate::ast::ComposeTarget::Events => {
1081            unreachable!("handled in execute_with_metrics");
1082        }
1083        crate::ast::ComposeTarget::Port => {
1084            return execute_compose_port(query, docker).await;
1085        }
1086        crate::ast::ComposeTarget::Logs => {
1087            return execute_compose_logs(query, docker).await;
1088        }
1089        crate::ast::ComposeTarget::Networks => docker
1090            .list_networks()
1091            .await?
1092            .into_iter()
1093            .filter(|n| {
1094                has_compose_label(
1095                    &n.labels,
1096                    "com.docker.compose.project",
1097                    &compose_project_label,
1098                )
1099            })
1100            .map(network_row)
1101            .collect(),
1102        crate::ast::ComposeTarget::Volumes => docker
1103            .list_volumes()
1104            .await?
1105            .into_iter()
1106            .filter(|v| {
1107                has_compose_label(
1108                    &v.labels,
1109                    "com.docker.compose.project",
1110                    &compose_project_label,
1111                )
1112            })
1113            .map(volume_row)
1114            .collect(),
1115        crate::ast::ComposeTarget::Projects | crate::ast::ComposeTarget::Config => {
1116            unreachable!("handled above")
1117        }
1118    };
1119
1120    for node in &query.pipeline {
1121        rows = apply_pipeline_node(rows, node)?;
1122    }
1123
1124    Ok(ExecutionResult { rows })
1125}
1126
1127async fn execute_compose_projects<C>(
1128    query: &crate::ast::ComposeQuery,
1129    docker: &C,
1130) -> Result<ExecutionResult, ExecutorError>
1131where
1132    C: DockerClient + ?Sized,
1133{
1134    let containers = docker.list_containers().await?;
1135    let networks = docker.list_networks().await?;
1136    let volumes = docker.list_volumes().await?;
1137
1138    let mut projects: BTreeMap<String, BTreeMap<&str, u64>> = BTreeMap::new();
1139
1140    for container in &containers {
1141        if let Some(project) = extract_label_value(&container.labels, "com.docker.compose.project")
1142        {
1143            let entry = projects.entry(project).or_default();
1144            entry.insert("containers", entry.get("containers").unwrap_or(&0) + 1);
1145            if container.state == "running" {
1146                entry.insert("running", entry.get("running").unwrap_or(&0) + 1);
1147            } else {
1148                entry.insert("stopped", entry.get("stopped").unwrap_or(&0) + 1);
1149            }
1150        }
1151    }
1152
1153    for network in &networks {
1154        if let Some(project) = extract_label_value(&network.labels, "com.docker.compose.project") {
1155            let entry = projects.entry(project).or_default();
1156            entry.insert("networks", entry.get("networks").unwrap_or(&0) + 1);
1157        }
1158    }
1159
1160    for volume in &volumes {
1161        if let Some(project) = extract_label_value(&volume.labels, "com.docker.compose.project") {
1162            let entry = projects.entry(project).or_default();
1163            entry.insert("volumes", entry.get("volumes").unwrap_or(&0) + 1);
1164        }
1165    }
1166
1167    let mut rows: Vec<Row> = projects
1168        .into_iter()
1169        .map(|(project, counts)| {
1170            let containers = counts.get("containers").copied().unwrap_or(0);
1171            let running = counts.get("running").copied().unwrap_or(0);
1172            let stopped = counts.get("stopped").copied().unwrap_or(0);
1173            let networks = counts.get("networks").copied().unwrap_or(0);
1174            let volumes = counts.get("volumes").copied().unwrap_or(0);
1175            Row::from_fields([
1176                ("project", json_string(project)),
1177                ("containers", json_u64(containers)),
1178                ("running", json_u64(running)),
1179                ("stopped", json_u64(stopped)),
1180                ("networks", json_u64(networks)),
1181                ("volumes", json_u64(volumes)),
1182                (
1183                    "status",
1184                    if running == containers && containers > 0 {
1185                        json_string("running".to_owned())
1186                    } else if running == 0 {
1187                        json_string("stopped".to_owned())
1188                    } else {
1189                        json_string("partial".to_owned())
1190                    },
1191                ),
1192            ])
1193        })
1194        .collect();
1195
1196    for node in &query.pipeline {
1197        rows = apply_pipeline_node(rows, node)?;
1198    }
1199
1200    Ok(ExecutionResult { rows })
1201}
1202
1203async fn execute_compose_port<C>(
1204    query: &crate::ast::ComposeQuery,
1205    docker: &C,
1206) -> Result<ExecutionResult, ExecutorError>
1207where
1208    C: DockerClient + ?Sized,
1209{
1210    let service_name = query.service.as_deref().unwrap_or("");
1211    let port_number = query.port_number.unwrap_or(0);
1212
1213    let containers = docker.list_containers().await?;
1214    let matching: Vec<_> = containers
1215        .into_iter()
1216        .filter(|c| {
1217            has_compose_label(&c.labels, "com.docker.compose.project", &query.project)
1218                && extract_label_value(&c.labels, "com.docker.compose.service")
1219                    .as_deref()
1220                    .is_some_and(|s| s == service_name)
1221        })
1222        .collect();
1223
1224    let mut rows = Vec::new();
1225    for container in matching {
1226        let id_short = &container.id[..12.min(container.id.len())];
1227        if let Ok(full) = docker.inspect_container(id_short).await
1228            && let Some(_ports_str) = full.ports.first()
1229        {
1230            let _ = (service_name, port_number);
1231            rows.push(Row::from_fields([
1232                ("service", json_string(service_name.to_owned())),
1233                ("container", json_string(full.name.clone())),
1234                ("ports", json_string_array(full.ports.clone())),
1235            ]));
1236        }
1237    }
1238
1239    for node in &query.pipeline {
1240        rows = apply_pipeline_node(rows, node)?;
1241    }
1242
1243    Ok(ExecutionResult { rows })
1244}
1245
1246async fn execute_compose_logs<C>(
1247    query: &crate::ast::ComposeQuery,
1248    docker: &C,
1249) -> Result<ExecutionResult, ExecutorError>
1250where
1251    C: DockerClient + ?Sized,
1252{
1253    let service_name = query.service.as_deref().unwrap_or("");
1254    let tail = query.tail.unwrap_or(100) as usize;
1255
1256    let containers = docker.list_containers().await?;
1257    let matching: Vec<_> = containers
1258        .into_iter()
1259        .filter(|c| {
1260            has_compose_label(&c.labels, "com.docker.compose.project", &query.project)
1261                && extract_label_value(&c.labels, "com.docker.compose.service")
1262                    .as_deref()
1263                    .is_some_and(|s| s == service_name)
1264        })
1265        .collect();
1266
1267    let mut rows = Vec::new();
1268    for container in matching {
1269        let lines = docker.container_logs(&container.id, tail).await?;
1270        for (i, line) in lines.into_iter().enumerate() {
1271            let mut fields = BTreeMap::new();
1272            fields.insert("line".to_owned(), JsonValue::Number(Number::from(i + 1)));
1273            fields.insert("message".to_owned(), JsonValue::String(line));
1274            fields.insert(
1275                "service".to_owned(),
1276                JsonValue::String(service_name.to_owned()),
1277            );
1278            fields.insert(
1279                "container".to_owned(),
1280                JsonValue::String(container.name.clone()),
1281            );
1282            rows.push(Row { fields });
1283        }
1284    }
1285
1286    for node in &query.pipeline {
1287        rows = apply_pipeline_node(rows, node)?;
1288    }
1289
1290    Ok(ExecutionResult { rows })
1291}
1292
1293async fn execute_compose_config<C>(
1294    query: &crate::ast::ComposeQuery,
1295    docker: &C,
1296) -> Result<ExecutionResult, ExecutorError>
1297where
1298    C: DockerClient + ?Sized,
1299{
1300    let config_target = query.config_target.unwrap_or(crate::ast::ConfigTarget::All);
1301
1302    let containers = docker.list_containers().await?;
1303    let networks = docker.list_networks().await?;
1304    let volumes = docker.list_volumes().await?;
1305
1306    let project_containers: Vec<_> = containers
1307        .into_iter()
1308        .filter(|c| has_compose_label(&c.labels, "com.docker.compose.project", &query.project))
1309        .collect();
1310
1311    let project_networks: Vec<_> = networks
1312        .into_iter()
1313        .filter(|n| has_compose_label(&n.labels, "com.docker.compose.project", &query.project))
1314        .collect();
1315
1316    let project_volumes: Vec<_> = volumes
1317        .into_iter()
1318        .filter(|v| has_compose_label(&v.labels, "com.docker.compose.project", &query.project))
1319        .collect();
1320
1321    let mut rows = Vec::new();
1322
1323    if matches!(
1324        config_target,
1325        crate::ast::ConfigTarget::Services | crate::ast::ConfigTarget::All
1326    ) {
1327        let mut seen_services: std::collections::HashSet<String> = std::collections::HashSet::new();
1328        for container in &project_containers {
1329            if let Some(svc) = extract_label_value(&container.labels, "com.docker.compose.service")
1330                && seen_services.insert(svc.clone())
1331            {
1332                let labels = &container.labels;
1333                let depends_on: Vec<String> = labels
1334                    .iter()
1335                    .filter(|l| l.starts_with("com.docker.compose.depends_on"))
1336                    .cloned()
1337                    .collect();
1338                rows.push(Row::from_fields([
1339                    ("name", json_string(svc)),
1340                    ("image", json_string(container.image.clone())),
1341                    ("state", json_string(container.state.clone())),
1342                    ("status", json_string(container.status.clone())),
1343                    ("ports", json_string_array(container.ports.clone())),
1344                    (
1345                        "restart_count",
1346                        container.restart_count.map_or(JsonValue::Null, json_u64),
1347                    ),
1348                    (
1349                        "health",
1350                        container
1351                            .health
1352                            .clone()
1353                            .map_or(JsonValue::Null, JsonValue::String),
1354                    ),
1355                    ("depends_on", json_string_array(depends_on)),
1356                ]));
1357            }
1358        }
1359    }
1360
1361    if matches!(
1362        config_target,
1363        crate::ast::ConfigTarget::Networks | crate::ast::ConfigTarget::All
1364    ) {
1365        for network in &project_networks {
1366            let net_name = extract_label_value(&network.labels, "com.docker.compose.network")
1367                .unwrap_or_else(|| network.name.clone());
1368            rows.push(Row::from_fields([
1369                ("name", json_string(net_name)),
1370                ("driver", json_string(network.driver.clone())),
1371                ("scope", json_string(network.scope.clone())),
1372                ("containers", json_u64(network.containers.len() as u64)),
1373            ]));
1374        }
1375    }
1376
1377    if matches!(
1378        config_target,
1379        crate::ast::ConfigTarget::Volumes | crate::ast::ConfigTarget::All
1380    ) {
1381        for volume in &project_volumes {
1382            let vol_name = extract_label_value(&volume.labels, "com.docker.compose.volume")
1383                .unwrap_or_else(|| volume.name.clone());
1384            rows.push(Row::from_fields([
1385                ("name", json_string(vol_name)),
1386                ("driver", json_string(volume.driver.clone())),
1387                ("mountpoint", json_option_string(volume.mountpoint.clone())),
1388                ("scope", json_option_string(volume.scope.clone())),
1389            ]));
1390        }
1391    }
1392
1393    for node in &query.pipeline {
1394        rows = apply_pipeline_node(rows, node)?;
1395    }
1396
1397    Ok(ExecutionResult { rows })
1398}
1399
1400async fn execute_logs<C>(query: &LogsQuery, docker: &C) -> Result<ExecutionResult, ExecutorError>
1401where
1402    C: DockerClient + ?Sized,
1403{
1404    let tail = query.tail.unwrap_or(100) as usize;
1405    let lines = docker.container_logs(&query.container, tail).await?;
1406
1407    let mut rows: Vec<Row> = lines
1408        .into_iter()
1409        .enumerate()
1410        .map(|(i, line)| {
1411            let mut fields = BTreeMap::new();
1412            fields.insert("line".to_owned(), JsonValue::Number(Number::from(i + 1)));
1413            fields.insert("message".to_owned(), JsonValue::String(line));
1414            fields.insert(
1415                "container".to_owned(),
1416                JsonValue::String(query.container.clone()),
1417            );
1418            Row { fields }
1419        })
1420        .collect();
1421
1422    if let Some(filter) = &query.filter {
1423        rows = filter_rows(rows, filter)?;
1424    }
1425
1426    for node in &query.pipeline {
1427        rows = apply_pipeline_node(rows, node)?;
1428    }
1429
1430    Ok(ExecutionResult { rows })
1431}
1432
1433async fn execute_ping<C>(docker: &C) -> Result<ExecutionResult, ExecutorError>
1434where
1435    C: DockerClient + ?Sized,
1436{
1437    let reachable = docker.ping().await?;
1438    let mut fields = BTreeMap::new();
1439    fields.insert(
1440        "status".to_owned(),
1441        JsonValue::String(if reachable { "ok" } else { "error" }.to_owned()),
1442    );
1443    fields.insert(
1444        "message".to_owned(),
1445        JsonValue::String(if reachable {
1446            "Docker daemon is reachable".to_owned()
1447        } else {
1448            "Cannot connect to Docker daemon".to_owned()
1449        }),
1450    );
1451    Ok(ExecutionResult {
1452        rows: vec![Row { fields }],
1453    })
1454}
1455
1456#[allow(clippy::unnecessary_wraps)]
1457fn execute_fields(target: CollectionTarget) -> Result<ExecutionResult, ExecutorError> {
1458    let fields = field_descriptions(target);
1459    let rows = fields
1460        .into_iter()
1461        .map(|(field, kind)| {
1462            let mut map = BTreeMap::new();
1463            map.insert("field".to_owned(), JsonValue::String(field));
1464            map.insert("type".to_owned(), JsonValue::String(kind));
1465            Row { fields: map }
1466        })
1467        .collect();
1468    Ok(ExecutionResult { rows })
1469}
1470
1471fn field_descriptions(target: CollectionTarget) -> Vec<(String, String)> {
1472    match target {
1473        CollectionTarget::Containers => vec![
1474            ("id".into(), "string".into()),
1475            ("name".into(), "string".into()),
1476            ("image".into(), "string".into()),
1477            ("status".into(), "string".into()),
1478            ("state".into(), "string".into()),
1479            ("ports".into(), "array".into()),
1480            ("labels".into(), "array".into()),
1481            ("compose_project".into(), "string".into()),
1482            ("created_at".into(), "string".into()),
1483            ("started_at".into(), "string".into()),
1484            ("finished_at".into(), "string".into()),
1485            ("restart_count".into(), "integer".into()),
1486            ("cpu".into(), "float".into()),
1487            ("memory".into(), "integer".into()),
1488            ("memory_limit".into(), "integer".into()),
1489            ("network_rx".into(), "integer".into()),
1490            ("network_tx".into(), "integer".into()),
1491            ("disk_read".into(), "integer".into()),
1492            ("disk_write".into(), "integer".into()),
1493        ],
1494        CollectionTarget::Images => vec![
1495            ("id".into(), "string".into()),
1496            ("repository".into(), "string".into()),
1497            ("name".into(), "string".into()),
1498            ("tag".into(), "string".into()),
1499            ("digest".into(), "string".into()),
1500            ("size".into(), "string".into()),
1501            ("created_at".into(), "string".into()),
1502            ("labels".into(), "array".into()),
1503        ],
1504        CollectionTarget::Networks => vec![
1505            ("id".into(), "string".into()),
1506            ("name".into(), "string".into()),
1507            ("driver".into(), "string".into()),
1508            ("scope".into(), "string".into()),
1509            ("containers".into(), "array".into()),
1510            ("labels".into(), "array".into()),
1511        ],
1512        CollectionTarget::Volumes => vec![
1513            ("name".into(), "string".into()),
1514            ("driver".into(), "string".into()),
1515            ("mountpoint".into(), "string".into()),
1516            ("scope".into(), "string".into()),
1517            ("labels".into(), "array".into()),
1518        ],
1519    }
1520}
1521
1522async fn execute_observe<C, M>(
1523    query: &ObserveQuery,
1524    docker: &C,
1525    metrics: &M,
1526) -> Result<ExecutionResult, ExecutorError>
1527where
1528    C: DockerClient + ?Sized,
1529    M: MetricsCollector + ?Sized,
1530{
1531    let mut rows = match query.target {
1532        CollectionTarget::Containers => {
1533            let samples = latest_metrics_by_container(metrics.collect().await?);
1534            docker
1535                .list_containers()
1536                .await?
1537                .into_iter()
1538                .map(|container| container_row(container, &samples))
1539                .collect::<Vec<_>>()
1540        }
1541        CollectionTarget::Images => docker
1542            .list_images()
1543            .await?
1544            .into_iter()
1545            .map(image_row)
1546            .collect::<Vec<_>>(),
1547        CollectionTarget::Networks => docker
1548            .list_networks()
1549            .await?
1550            .into_iter()
1551            .map(network_row)
1552            .collect::<Vec<_>>(),
1553        CollectionTarget::Volumes => docker
1554            .list_volumes()
1555            .await?
1556            .into_iter()
1557            .map(volume_row)
1558            .collect::<Vec<_>>(),
1559    };
1560
1561    if let Some(join) = &query.join {
1562        let right_alias = target_alias(join.right);
1563        let right_rows: Vec<Row> = match join.right {
1564            CollectionTarget::Containers => {
1565                let samples = latest_metrics_by_container(metrics.collect().await?);
1566                docker
1567                    .list_containers()
1568                    .await?
1569                    .into_iter()
1570                    .map(|container| container_row(container, &samples))
1571                    .collect::<Vec<_>>()
1572            }
1573            CollectionTarget::Images => docker
1574                .list_images()
1575                .await?
1576                .into_iter()
1577                .map(image_row)
1578                .collect::<Vec<_>>(),
1579            CollectionTarget::Networks => docker
1580                .list_networks()
1581                .await?
1582                .into_iter()
1583                .map(network_row)
1584                .collect::<Vec<_>>(),
1585            CollectionTarget::Volumes => docker
1586                .list_volumes()
1587                .await?
1588                .into_iter()
1589                .map(volume_row)
1590                .collect::<Vec<_>>(),
1591        };
1592
1593        let left_alias = target_alias(query.target);
1594        let mut joined = Vec::new();
1595        for left_row in rows {
1596            for right_row in &right_rows {
1597                let left_val =
1598                    eval::eval_expr(&left_row.fields, &join.left_key).unwrap_or(JsonValue::Null);
1599                let right_val =
1600                    eval::eval_expr(&right_row.fields, &join.right_key).unwrap_or(JsonValue::Null);
1601                if eval::compare_json_values(&left_val, &right_val) == std::cmp::Ordering::Equal {
1602                    let mut merged_fields = BTreeMap::new();
1603                    for (k, v) in &left_row.fields {
1604                        merged_fields.insert(format!("{left_alias}.{k}"), v.clone());
1605                    }
1606                    for (k, v) in &right_row.fields {
1607                        merged_fields.insert(format!("{right_alias}.{k}"), v.clone());
1608                    }
1609                    joined.push(Row {
1610                        fields: merged_fields,
1611                    });
1612                }
1613            }
1614        }
1615        rows = joined;
1616    }
1617
1618    if let Some(filter) = &query.filter {
1619        rows = filter_rows(rows, filter)?;
1620    }
1621
1622    for node in &query.pipeline {
1623        rows = apply_pipeline_node(rows, node)?;
1624    }
1625
1626    Ok(ExecutionResult { rows })
1627}
1628
1629fn apply_pipeline_node(mut rows: Vec<Row>, node: &PipelineNode) -> Result<Vec<Row>, ExecutorError> {
1630    match node {
1631        PipelineNode::Where(expression) => filter_rows(rows, expression),
1632        PipelineNode::Select(fields) => rows
1633            .into_iter()
1634            .map(|row| select_fields(&row, fields))
1635            .collect(),
1636        PipelineNode::SortBy { fields } => {
1637            sort_rows(&mut rows, fields)?;
1638            Ok(rows)
1639        }
1640        PipelineNode::Limit(limit) => {
1641            rows.truncate(*limit as usize);
1642            Ok(rows)
1643        }
1644        PipelineNode::GroupBy { fields, aggregates } => Ok(group_rows(rows, fields, aggregates)),
1645        PipelineNode::Having(expr) => filter_rows(rows, expr),
1646        PipelineNode::Distinct => {
1647            let mut seen = HashSet::new();
1648            rows.retain(|row| {
1649                let key: Vec<String> = row.fields.values().map(eval::render_json_cell).collect();
1650                seen.insert(key)
1651            });
1652            Ok(rows)
1653        }
1654        PipelineNode::Offset(offset) => {
1655            let skip = *offset as usize;
1656            if skip < rows.len() {
1657                rows = rows.split_off(skip);
1658            } else {
1659                rows.clear();
1660            }
1661            Ok(rows)
1662        }
1663        PipelineNode::Alert(message) => {
1664            for row in &rows {
1665                let output = row
1666                    .fields
1667                    .iter()
1668                    .map(|(k, v)| format!("{k}={}", eval::render_json_cell(v)))
1669                    .collect::<Vec<_>>()
1670                    .join(", ");
1671                eprintln!("[ALERT] {message}: {output}");
1672            }
1673            Ok(rows)
1674        }
1675        PipelineNode::If {
1676            condition,
1677            then_branch,
1678            else_branch,
1679        } => {
1680            let empty = Vec::new();
1681            let mut result = Vec::new();
1682            for row in rows {
1683                let matched = eval::evaluate_expression(&row.fields, condition)?;
1684                let branch = if matched {
1685                    then_branch
1686                } else {
1687                    else_branch.as_ref().unwrap_or(&empty)
1688                };
1689                let mut current = vec![row];
1690                for node in branch {
1691                    current = apply_pipeline_node(current, node)?;
1692                }
1693                result.extend(current);
1694            }
1695            Ok(result)
1696        }
1697        PipelineNode::Set { field, value } => {
1698            for row in &mut rows {
1699                let json_value = eval::evaluate_set_value(&row.fields, value)?;
1700                row.fields.insert(field.clone(), json_value);
1701            }
1702            Ok(rows)
1703        }
1704        PipelineNode::Fill {
1705            field,
1706            default,
1707            condition,
1708        } => {
1709            for row in &mut rows {
1710                if let Some(cond) = condition
1711                    && !eval::evaluate_expression(&row.fields, cond)?
1712                {
1713                    continue;
1714                }
1715                if !row.fields.contains_key(field)
1716                    || row.fields.get(field) == Some(&JsonValue::Null)
1717                    || matches!(row.fields.get(field), Some(JsonValue::String(s)) if s.is_empty())
1718                {
1719                    let value = eval::evaluate_set_value(&row.fields, default)?;
1720                    row.fields.insert(field.clone(), value);
1721                }
1722            }
1723            Ok(rows)
1724        }
1725        PipelineNode::Let { name, value } => {
1726            let value = eval::eval_expr(&BTreeMap::new(), value)?;
1727            for row in &mut rows {
1728                row.fields.insert(name.clone(), value.clone());
1729            }
1730            Ok(rows)
1731        }
1732        PipelineNode::Debug => {
1733            eprintln!(
1734                "[DEBUG] {} rows, schema: {:?}",
1735                rows.len(),
1736                if rows.is_empty() {
1737                    "(empty)".to_owned()
1738                } else {
1739                    let keys: Vec<&str> = rows[0].fields.keys().map(|s| s.as_str()).collect();
1740                    keys.join(", ")
1741                }
1742            );
1743            Ok(rows)
1744        }
1745        PipelineNode::Assert(expr) => {
1746            for row in &rows {
1747                if !eval::evaluate_expression(&row.fields, expr)? {
1748                    let details: String = row
1749                        .fields
1750                        .iter()
1751                        .map(|(k, v)| format!("{}={}", k, eval::render_json_cell(v)))
1752                        .collect::<Vec<_>>()
1753                        .join(", ");
1754                    return Err(ExecutorError::Eval(EvalError::InvalidArguments {
1755                        name: format!("assertion failed for row: {}", details),
1756                    }));
1757                }
1758            }
1759            Ok(rows)
1760        }
1761        PipelineNode::RowNumber { alias } => {
1762            for (i, row) in rows.iter_mut().enumerate() {
1763                row.fields.insert(
1764                    alias.clone(),
1765                    JsonValue::Number(Number::from((i + 1) as u64)),
1766                );
1767            }
1768            Ok(rows)
1769        }
1770        PipelineNode::Rank { field, alias } => {
1771            let mut indices: Vec<usize> = (0..rows.len()).collect();
1772            indices.sort_by(|&a, &b| {
1773                let va = rows[a].fields.get(field);
1774                let vb = rows[b].fields.get(field);
1775                match (va, vb) {
1776                    (Some(a_val), Some(b_val)) => match (a_val.as_f64(), b_val.as_f64()) {
1777                        (Some(af), Some(bf)) => {
1778                            af.partial_cmp(&bf).unwrap_or(std::cmp::Ordering::Equal)
1779                        }
1780                        _ => {
1781                            let a_str = format!("{}", a_val);
1782                            let b_str = format!("{}", b_val);
1783                            a_str.cmp(&b_str)
1784                        }
1785                    },
1786                    (None, Some(_)) => std::cmp::Ordering::Greater,
1787                    (Some(_), None) => std::cmp::Ordering::Less,
1788                    (None, None) => std::cmp::Ordering::Equal,
1789                }
1790            });
1791
1792            let mut rank = 1u64;
1793            for pos in 0..indices.len() {
1794                let idx = indices[pos];
1795                if pos > 0 {
1796                    let prev_idx = indices[pos - 1];
1797                    let prev_val = rows[prev_idx].fields.get(field);
1798                    let cur_val = rows[idx].fields.get(field);
1799                    if prev_val != cur_val {
1800                        rank = (pos + 1) as u64;
1801                    }
1802                }
1803                rows[idx]
1804                    .fields
1805                    .insert(alias.clone(), JsonValue::Number(Number::from(rank)));
1806            }
1807            Ok(rows)
1808        }
1809        PipelineNode::Lag {
1810            field,
1811            alias,
1812            offset,
1813        } => {
1814            let offset = *offset as usize;
1815            for i in 0..rows.len() {
1816                if i >= offset {
1817                    let prev_val = rows[i - offset]
1818                        .fields
1819                        .get(field)
1820                        .cloned()
1821                        .unwrap_or(JsonValue::Null);
1822                    rows[i].fields.insert(alias.clone(), prev_val);
1823                } else {
1824                    rows[i].fields.insert(alias.clone(), JsonValue::Null);
1825                }
1826            }
1827            Ok(rows)
1828        }
1829        PipelineNode::Lead {
1830            field,
1831            alias,
1832            offset,
1833        } => {
1834            let offset = *offset as usize;
1835            let len = rows.len();
1836            for i in 0..len {
1837                let lead_idx = i + offset;
1838                if lead_idx < len {
1839                    let next_val = rows[lead_idx]
1840                        .fields
1841                        .get(field)
1842                        .cloned()
1843                        .unwrap_or(JsonValue::Null);
1844                    rows[i].fields.insert(alias.clone(), next_val);
1845                } else {
1846                    rows[i].fields.insert(alias.clone(), JsonValue::Null);
1847                }
1848            }
1849            Ok(rows)
1850        }
1851    }
1852}
1853
1854fn filter_rows(rows: Vec<Row>, expression: &Expression) -> Result<Vec<Row>, ExecutorError> {
1855    rows.into_iter()
1856        .map(|row| eval::evaluate_expression(&row.fields, expression).map(|keep| (row, keep)))
1857        .filter_map(|result| match result {
1858            Ok((row, true)) => Some(Ok(row)),
1859            Ok((_, false)) => None,
1860            Err(error) => Some(Err(error.into())),
1861        })
1862        .collect()
1863}
1864
1865fn select_fields(row: &Row, fields: &[String]) -> Result<Row, ExecutorError> {
1866    let mut selected = BTreeMap::new();
1867
1868    for field in fields {
1869        if let Some(value) = row.fields.get(field) {
1870            selected.insert(field.clone(), value.clone());
1871        } else if let Some(label_key) = field.strip_prefix("label.") {
1872            if let Some(JsonValue::Array(items)) = row.fields.get("labels") {
1873                for item in items {
1874                    if let JsonValue::String(entry) = item
1875                        && let Some(eq_pos) = entry.find('=')
1876                    {
1877                        let key = &entry[..eq_pos];
1878                        let val = &entry[eq_pos + 1..];
1879                        if key == label_key {
1880                            selected.insert(field.clone(), JsonValue::String(val.to_owned()));
1881                            break;
1882                        }
1883                    }
1884                }
1885            }
1886        } else {
1887            return Err(EvalError::UnsupportedField {
1888                field: field.clone(),
1889            }
1890            .into());
1891        }
1892    }
1893
1894    Ok(Row { fields: selected })
1895}
1896
1897fn sort_rows(rows: &mut [Row], fields: &[(String, SortDirection)]) -> Result<(), ExecutorError> {
1898    fn resolve_sort_value(row: &Row, field: &str) -> Option<JsonValue> {
1899        if let Some(v) = row.fields.get(field) {
1900            return Some(v.clone());
1901        }
1902        if let Some(label_key) = field.strip_prefix("label.")
1903            && let Some(JsonValue::Array(items)) = row.fields.get("labels")
1904        {
1905            for item in items {
1906                if let JsonValue::String(entry) = item
1907                    && let Some(eq_pos) = entry.find('=')
1908                {
1909                    let key = &entry[..eq_pos];
1910                    let val = &entry[eq_pos + 1..];
1911                    if key == label_key {
1912                        return Some(JsonValue::String(val.to_owned()));
1913                    }
1914                }
1915            }
1916        }
1917        None
1918    }
1919
1920    // Validate all sort fields exist
1921    for (field, _) in fields {
1922        if rows
1923            .iter()
1924            .any(|row| resolve_sort_value(row, field).is_none())
1925        {
1926            return Err(EvalError::UnsupportedField {
1927                field: field.to_owned(),
1928            }
1929            .into());
1930        }
1931    }
1932
1933    rows.sort_by(|left, right| {
1934        for (field, direction) in fields {
1935            let left_val = resolve_sort_value(left, field).unwrap_or(JsonValue::Null);
1936            let right_val = resolve_sort_value(right, field).unwrap_or(JsonValue::Null);
1937            let ordering = eval::compare_json_values(&left_val, &right_val);
1938            let ordering = match direction {
1939                SortDirection::Asc => ordering,
1940                SortDirection::Desc => ordering.reverse(),
1941            };
1942            if ordering != std::cmp::Ordering::Equal {
1943                return ordering;
1944            }
1945        }
1946        std::cmp::Ordering::Equal
1947    });
1948    Ok(())
1949}
1950
1951fn container_row(container: Container, samples: &HashMap<String, MetricSample>) -> Row {
1952    let sample = samples
1953        .get(&container.id)
1954        .or_else(|| samples.get(&container.name));
1955
1956    let compose_project = container.labels.iter().find_map(|label| {
1957        let parts: Vec<&str> = label.splitn(2, '=').collect();
1958        if parts.len() == 2 && parts[0] == "com.docker.compose.project" {
1959            Some(parts[1].to_owned())
1960        } else {
1961            None
1962        }
1963    });
1964
1965    Row::from_fields([
1966        ("id", json_string(container.id)),
1967        ("name", json_string(container.name)),
1968        ("image", json_string(container.image)),
1969        ("status", json_string(container.status)),
1970        ("state", json_string(container.state)),
1971        ("ports", json_string_array(container.ports)),
1972        ("labels", json_string_array(container.labels)),
1973        (
1974            "compose_project",
1975            compose_project.map_or(JsonValue::Null, JsonValue::String),
1976        ),
1977        ("created_at", json_option_string(container.created_at)),
1978        ("started_at", json_option_string(container.started_at)),
1979        ("finished_at", json_option_string(container.finished_at)),
1980        (
1981            "restart_count",
1982            container.restart_count.map_or(JsonValue::Null, json_u64),
1983        ),
1984        (
1985            "cpu",
1986            sample
1987                .and_then(|sample| sample.cpu_percent)
1988                .map_or(JsonValue::Null, json_f64),
1989        ),
1990        (
1991            "memory",
1992            sample
1993                .and_then(|sample| sample.memory_usage_bytes)
1994                .map_or(JsonValue::Null, json_u64),
1995        ),
1996        (
1997            "memory_limit",
1998            sample
1999                .and_then(|sample| sample.memory_limit_bytes)
2000                .map_or(JsonValue::Null, json_u64),
2001        ),
2002        (
2003            "network_rx",
2004            sample
2005                .and_then(|sample| sample.network_rx_bytes)
2006                .map_or(JsonValue::Null, json_u64),
2007        ),
2008        (
2009            "network_tx",
2010            sample
2011                .and_then(|sample| sample.network_tx_bytes)
2012                .map_or(JsonValue::Null, json_u64),
2013        ),
2014        (
2015            "disk_read",
2016            sample
2017                .and_then(|sample| sample.disk_read_bytes)
2018                .map_or(JsonValue::Null, json_u64),
2019        ),
2020        (
2021            "disk_write",
2022            sample
2023                .and_then(|sample| sample.disk_write_bytes)
2024                .map_or(JsonValue::Null, json_u64),
2025        ),
2026        (
2027            "health",
2028            container.health.map_or(JsonValue::Null, JsonValue::String),
2029        ),
2030    ])
2031}
2032
2033fn image_row(image: Image) -> Row {
2034    Row::from_fields([
2035        ("id", json_string(image.id)),
2036        ("repository", json_string(image.repository.clone())),
2037        ("name", json_string(image.repository)),
2038        ("tag", json_string(image.tag)),
2039        ("digest", json_option_string(image.digest)),
2040        ("size", json_string(image.size)),
2041        ("created_at", json_option_string(image.created_at)),
2042        ("labels", json_string_array(image.labels)),
2043    ])
2044}
2045
2046fn network_row(network: Network) -> Row {
2047    let mut fields = BTreeMap::new();
2048    network_row_fields(network, &mut fields);
2049    Row { fields }
2050}
2051
2052fn network_row_fields(network: Network, fields: &mut BTreeMap<String, JsonValue>) {
2053    fields.insert("id".into(), json_string(network.id));
2054    fields.insert("name".into(), json_string(network.name));
2055    fields.insert("driver".into(), json_string(network.driver));
2056    fields.insert("scope".into(), json_string(network.scope));
2057    fields.insert("containers".into(), json_string_array(network.containers));
2058    fields.insert("labels".into(), json_string_array(network.labels));
2059}
2060
2061fn volume_row(volume: Volume) -> Row {
2062    let mut fields = BTreeMap::new();
2063    volume_row_fields(volume, &mut fields);
2064    Row { fields }
2065}
2066
2067fn volume_row_fields(volume: Volume, fields: &mut BTreeMap<String, JsonValue>) {
2068    fields.insert("name".into(), json_string(volume.name));
2069    fields.insert("driver".into(), json_string(volume.driver));
2070    fields.insert("mountpoint".into(), json_option_string(volume.mountpoint));
2071    fields.insert("scope".into(), json_option_string(volume.scope));
2072    fields.insert("labels".into(), json_string_array(volume.labels));
2073}
2074
2075impl Row {
2076    fn from_fields<const N: usize>(fields: [(&str, JsonValue); N]) -> Self {
2077        Self {
2078            fields: fields
2079                .into_iter()
2080                .map(|(key, value)| (key.to_owned(), value))
2081                .collect(),
2082        }
2083    }
2084}
2085
2086fn render_table_line(values: &[String], widths: &[usize]) -> String {
2087    values
2088        .iter()
2089        .zip(widths)
2090        .map(|(value, width)| format!("{value:<width$}"))
2091        .collect::<Vec<_>>()
2092        .join("  ")
2093}
2094
2095#[must_use]
2096pub fn render_csv(result: &ExecutionResult) -> String {
2097    if result.rows.is_empty() {
2098        return String::new();
2099    }
2100
2101    let columns: Vec<String> = result.rows[0].fields.keys().cloned().collect();
2102    let mut buf = Vec::new();
2103    {
2104        let mut writer = csv::Writer::from_writer(&mut buf);
2105
2106        // Write header
2107        writer
2108            .write_record(&columns)
2109            .expect("csv header write should succeed");
2110
2111        // Write data rows
2112        for row in &result.rows {
2113            let values: Vec<String> = columns
2114                .iter()
2115                .map(|col| {
2116                    row.fields
2117                        .get(col)
2118                        .map(eval::render_json_cell)
2119                        .unwrap_or_default()
2120                })
2121                .collect();
2122            writer
2123                .write_record(&values)
2124                .expect("csv row write should succeed");
2125        }
2126
2127        writer.flush().expect("csv flush should succeed");
2128    }
2129
2130    String::from_utf8(buf).unwrap_or_default()
2131}
2132
2133#[must_use]
2134pub fn render_jsonl(result: &ExecutionResult) -> String {
2135    result
2136        .rows
2137        .iter()
2138        .filter_map(|row| serde_json::to_string(&row).ok())
2139        .collect::<Vec<_>>()
2140        .join("\n")
2141}
2142
2143fn ansi_state_color(value: &str) -> &'static str {
2144    match value {
2145        "running" | "ok" | "healthy" => ANSI_FG_GREEN,
2146        "exited" | "dead" => ANSI_FG_RED,
2147        "restarting" | "paused" | "warning" => ANSI_FG_YELLOW,
2148        "created" => ANSI_FG_CYAN,
2149        "critical" => ANSI_BOLD_RED,
2150        _ => ANSI_RESET,
2151    }
2152}
2153
2154/// Return ANSI green/yellow/red based on a numeric value threshold.
2155/// Green < 50%, yellow 50–80%, red > 80%.
2156fn ansi_threshold_color(value_str: &str) -> &'static str {
2157    // Strip trailing % or other non-numeric suffixes
2158    let cleaned = value_str.trim_end_matches(|c: char| !c.is_ascii_digit() && c != '.');
2159    #[allow(clippy::option_if_let_else)]
2160    cleaned.parse::<f64>().map_or(ANSI_RESET, |val| {
2161        if val > CPU_RED_THRESHOLD {
2162            ANSI_FG_RED
2163        } else if val > CPU_YELLOW_THRESHOLD {
2164            ANSI_FG_YELLOW
2165        } else {
2166            ANSI_FG_GREEN
2167        }
2168    })
2169}
2170
2171#[must_use]
2172pub fn render_table_colored(result: &ExecutionResult) -> String {
2173    render_table_colored_with_theme(result, Theme::Dark)
2174}
2175
2176fn render_table_colored_with_theme(result: &ExecutionResult, theme: Theme) -> String {
2177    if result.rows.is_empty() {
2178        return "No rows".to_owned();
2179    }
2180
2181    // Filter out all-null/all-empty columns
2182    let columns = filter_display_columns(result);
2183    let entity = infer_entity_type(&columns);
2184    let mut widths = columns
2185        .iter()
2186        .map(|column| column.len().min(MAX_COL_WIDTH))
2187        .collect::<Vec<_>>();
2188
2189    // Pre-compute size bar data for the "size" column if present
2190    // Uses log10 scaling to handle values across orders of magnitude (MB, GB, TB)
2191    let size_col_idx = columns.iter().position(|c| c == "size");
2192    #[allow(clippy::option_if_let_else)]
2193    let (size_min_bytes, size_max_bytes, size_bar_width) = if let Some(si) = size_col_idx {
2194        let mut min_b = f64::MAX;
2195        let mut max_b = 0.0_f64;
2196        for row in &result.rows {
2197            if let Some(val) = row.fields.get("size") {
2198                let s = eval::render_json_cell(val);
2199                if let Some(b) = eval::json_as_f64(&serde_json::Value::String(s)) {
2200                    if b > max_b {
2201                        max_b = b;
2202                    }
2203                    if b < min_b {
2204                        min_b = b;
2205                    }
2206                }
2207            }
2208        }
2209        let bw = 8; // fixed reasonable width for the bar
2210        widths[si] += bw + 1; // make room for the bar + space
2211        #[allow(clippy::float_cmp)]
2212        (if min_b == f64::MAX { 0.0 } else { min_b }, max_b, bw)
2213    } else {
2214        (0.0, 0.0, 0)
2215    };
2216
2217    let (row_alt_bg, title_color_code, header_color_code) = match theme {
2218        Theme::Dark => (ANSI_BG_DARK_GRAY, ANSI_TITLE_DARK, ANSI_HEADER_DARK),
2219        // Light theme: no alternating row background (white-on-white is invisible),
2220        // use blue/dark text which is readable on light terminals.
2221        Theme::Light => ("", ANSI_TITLE_LIGHT, ANSI_HEADER_LIGHT),
2222    };
2223
2224    let rendered_rows: Vec<Vec<(String, &'static str, &'static str)>> = result
2225        .rows
2226        .iter()
2227        .enumerate()
2228        .map(|(idx, row)| {
2229            let is_even = idx % 2 == 0;
2230            let row_bg = if is_even { "" } else { row_alt_bg };
2231
2232            columns
2233                .iter()
2234                .enumerate()
2235                .map(|(index, column)| {
2236                    let mut value = row
2237                        .fields
2238                        .get(column)
2239                        .map(eval::render_json_cell)
2240                        .unwrap_or_default();
2241                    widths[index] = widths[index].max(value.len().min(MAX_COL_WIDTH));
2242                    value = truncate_cell(&value, MAX_COL_WIDTH);
2243
2244                    // Determine per-cell foreground color
2245                    let cell_color: &'static str = match column.as_str() {
2246                        "cpu" => ansi_threshold_color(&value),
2247                        "memory" | "mem" =>
2248                        {
2249                            #[allow(clippy::option_if_let_else)]
2250                            if let Ok(bytes) = value.parse::<f64>() {
2251                                if bytes > ONE_GB as f64 {
2252                                    ANSI_FG_RED
2253                                } else if bytes > HALF_GB as f64 {
2254                                    ANSI_FG_YELLOW
2255                                } else {
2256                                    ANSI_FG_GREEN
2257                                }
2258                            } else {
2259                                ""
2260                            }
2261                        }
2262                        "state" | "status" => ansi_state_color(&value),
2263                        _ => "",
2264                    };
2265
2266                    // For the "size" column, augment with a visual bar
2267                    if column.as_str() == "size" && size_max_bytes > 0.0 && !value.is_empty() {
2268                        let bar = size_bar(&value, size_min_bytes, size_max_bytes, size_bar_width);
2269                        if !bar.is_empty() {
2270                            value = format!("{value} {bar}");
2271                        }
2272                    }
2273
2274                    (value, cell_color, row_bg)
2275                })
2276                .collect()
2277        })
2278        .collect();
2279
2280    let mut lines = Vec::new();
2281
2282    // ── Title ──
2283    lines.push(format!("{title_color_code} {entity} {ANSI_RESET}"));
2284
2285    // ── Header ──
2286    lines.push(format!(
2287        "{header_color_code}{}{ANSI_RESET}",
2288        render_table_line(&columns.clone(), &widths)
2289    ));
2290
2291    // ── Separator ──
2292    lines.push(
2293        widths
2294            .iter()
2295            .map(|width| format!("{ANSI_DIM}{}{ANSI_RESET}", "─".repeat(*width)))
2296            .collect::<Vec<_>>()
2297            .join("  "),
2298    );
2299
2300    // ── Data rows ──
2301    for row in &rendered_rows {
2302        let line = row
2303            .iter()
2304            .zip(&widths)
2305            .map(|((value, fg_color, bg_color), width)| {
2306                if fg_color.is_empty() && bg_color.is_empty() {
2307                    format!("{value:<width$}")
2308                } else {
2309                    format!("{bg_color}{fg_color}{value:<width$}{ANSI_RESET}")
2310                }
2311            })
2312            .collect::<Vec<_>>()
2313            .join("  ");
2314        lines.push(line);
2315    }
2316
2317    // ── Footer ──
2318    lines.push(format!(
2319        "{ANSI_DIM}{} {}{ANSI_RESET}",
2320        result.rows.len(),
2321        entity.to_lowercase()
2322    ));
2323
2324    lines.join("\n")
2325}
2326
2327pub fn render_diff<S>(current: &ExecutionResult, store: &S) -> Result<String, ExecutorError>
2328where
2329    S: crate::storage::TelemetryStore + ?Sized,
2330{
2331    let now = chrono::Utc::now().to_rfc3339();
2332    let snapshot = store
2333        .snapshot_at_or_before(&now)
2334        .map_err(ExecutorError::Telemetry)?
2335        .ok_or(ExecutorError::SnapshotNotFound("diff"))?;
2336
2337    let prev_ids: HashSet<&str> = snapshot.containers.iter().map(|c| c.id.as_str()).collect();
2338    let curr_ids: HashSet<&str> = current
2339        .rows
2340        .iter()
2341        .filter_map(|r| r.fields.get("id").and_then(|v| v.as_str()))
2342        .collect();
2343
2344    let added: Vec<&str> = curr_ids.difference(&prev_ids).copied().collect();
2345    let removed: Vec<&str> = prev_ids.difference(&curr_ids).copied().collect();
2346    let changed: Vec<&str> = curr_ids.intersection(&prev_ids).copied().collect();
2347
2348    let mut lines = Vec::new();
2349
2350    if !added.is_empty() {
2351        lines.push(format!(
2352            "{ANSI_FG_GREEN}Added containers ({}):{ANSI_RESET}",
2353            added.len()
2354        ));
2355        for id in &added {
2356            if let Some(row) = current
2357                .rows
2358                .iter()
2359                .find(|r| r.fields.get("id").and_then(|v| v.as_str()) == Some(id))
2360            {
2361                let name = row
2362                    .fields
2363                    .get("name")
2364                    .map(eval::render_json_cell)
2365                    .unwrap_or_default();
2366                lines.push(format!("  {ANSI_FG_GREEN}+ {name} ({id}){ANSI_RESET}"));
2367            }
2368        }
2369    }
2370
2371    if !removed.is_empty() {
2372        lines.push(format!(
2373            "{ANSI_FG_RED}Removed containers ({}):{ANSI_RESET}",
2374            removed.len()
2375        ));
2376        for id in &removed {
2377            if let Some(c) = snapshot.containers.iter().find(|c| c.id == *id) {
2378                lines.push(format!(
2379                    "  {ANSI_FG_RED}- {name} ({id}){ANSI_RESET}",
2380                    name = c.name
2381                ));
2382            }
2383        }
2384    }
2385
2386    if !changed.is_empty() {
2387        lines.push(format!("Changed containers ({}):", changed.len()));
2388        for id in &changed {
2389            if let Some(row) = current
2390                .rows
2391                .iter()
2392                .find(|r| r.fields.get("id").and_then(|v| v.as_str()) == Some(id))
2393            {
2394                let name = row
2395                    .fields
2396                    .get("name")
2397                    .map(eval::render_json_cell)
2398                    .unwrap_or_default();
2399                let state = row
2400                    .fields
2401                    .get("state")
2402                    .map(eval::render_json_cell)
2403                    .unwrap_or_default();
2404                if let Some(prev_c) = snapshot.containers.iter().find(|c| c.id == *id)
2405                    && prev_c.state != state
2406                {
2407                    lines.push(format!(
2408                        "  ~ {name}: {prev} -> {state}",
2409                        prev = prev_c.state
2410                    ));
2411                }
2412            }
2413        }
2414    }
2415
2416    if added.is_empty() && removed.is_empty() {
2417        lines.push("No changes detected.".to_owned());
2418    }
2419
2420    Ok(lines.join("\n"))
2421}
2422
2423fn json_option_string(value: Option<String>) -> JsonValue {
2424    value.map_or(JsonValue::Null, JsonValue::String)
2425}
2426
2427fn json_string_array(values: Vec<String>) -> JsonValue {
2428    JsonValue::Array(values.into_iter().map(JsonValue::String).collect())
2429}
2430
2431fn json_u64(value: u64) -> JsonValue {
2432    JsonValue::Number(Number::from(value))
2433}
2434
2435fn json_f64(value: f64) -> JsonValue {
2436    Number::from_f64(value).map_or(JsonValue::Null, JsonValue::Number)
2437}
2438
2439fn latest_metrics_by_container(samples: Vec<MetricSample>) -> HashMap<String, MetricSample> {
2440    let mut latest = HashMap::new();
2441    for sample in samples {
2442        latest.insert(sample.container_id.clone(), sample.clone());
2443        latest.insert(sample.container_name.clone(), sample);
2444    }
2445    latest
2446}
2447
2448fn group_rows(rows: Vec<Row>, group_fields: &[String], aggregates: &[AggregateExpr]) -> Vec<Row> {
2449    // Helper struct to accumulate state for each group
2450    struct GroupAcc {
2451        row: Row,
2452        count: u64,
2453        sums: HashMap<String, f64>,
2454        avg_sums: HashMap<String, f64>,
2455        avg_counts: HashMap<String, u64>,
2456        mins: HashMap<String, f64>,
2457        maxs: HashMap<String, f64>,
2458        min_inits: HashSet<String>,
2459        max_inits: HashSet<String>,
2460    }
2461
2462    let mut groups: BTreeMap<Vec<String>, GroupAcc> = BTreeMap::new();
2463
2464    for row in rows {
2465        let key: Vec<String> = group_fields
2466            .iter()
2467            .map(|f| {
2468                row.fields
2469                    .get(f)
2470                    .map(eval::render_json_cell)
2471                    .unwrap_or_default()
2472            })
2473            .collect();
2474
2475        let acc = groups.entry(key).or_insert_with(|| {
2476            let mut fields = BTreeMap::new();
2477            for f in group_fields {
2478                if let Some(v) = row.fields.get(f) {
2479                    fields.insert(f.clone(), v.clone());
2480                }
2481            }
2482            GroupAcc {
2483                row: Row { fields },
2484                count: 0,
2485                sums: HashMap::new(),
2486                avg_sums: HashMap::new(),
2487                avg_counts: HashMap::new(),
2488                mins: HashMap::new(),
2489                maxs: HashMap::new(),
2490                min_inits: HashSet::new(),
2491                max_inits: HashSet::new(),
2492            }
2493        });
2494
2495        acc.count += 1;
2496
2497        // Accumulate aggregate values
2498        for agg in aggregates {
2499            let alias = &agg.alias;
2500            if let Some(v) = row.fields.get(&agg.field)
2501                && let Some(num) = eval::json_as_f64(v)
2502            {
2503                match agg.function.as_str() {
2504                    "sum" => {
2505                        *acc.sums.entry(alias.clone()).or_insert(0.0) += num;
2506                    }
2507                    "avg" => {
2508                        *acc.avg_sums.entry(alias.clone()).or_insert(0.0) += num;
2509                        *acc.avg_counts.entry(alias.clone()).or_insert(0) += 1;
2510                    }
2511                    "min" => {
2512                        if !acc.min_inits.contains(alias) || num < acc.mins[alias] {
2513                            acc.mins.insert(alias.clone(), num);
2514                            acc.min_inits.insert(alias.clone());
2515                        }
2516                    }
2517                    "max" if !acc.max_inits.contains(alias) || num > acc.maxs[alias] => {
2518                        acc.maxs.insert(alias.clone(), num);
2519                        acc.max_inits.insert(alias.clone());
2520                    }
2521                    _ => {}
2522                }
2523            }
2524        }
2525    }
2526
2527    // Build result rows with aggregate values
2528    groups
2529        .into_values()
2530        .map(|acc| {
2531            let mut row = acc.row;
2532            if aggregates.is_empty() {
2533                // Backward compatible: add default count column
2534                row.fields.insert(
2535                    "count".to_owned(),
2536                    JsonValue::Number(Number::from(acc.count)),
2537                );
2538            } else {
2539                for agg in aggregates {
2540                    let alias = &agg.alias;
2541                    match agg.function.as_str() {
2542                        "count" => {
2543                            row.fields
2544                                .insert(alias.clone(), JsonValue::Number(Number::from(acc.count)));
2545                        }
2546                        "sum" => {
2547                            let val = acc.sums.get(alias).copied().unwrap_or(0.0);
2548                            row.fields.insert(alias.clone(), json_f64(val));
2549                        }
2550                        "avg" => {
2551                            let cnt = acc.avg_counts.get(alias).copied().unwrap_or(0);
2552                            let val = if cnt > 0 {
2553                                acc.avg_sums.get(alias).copied().unwrap_or(0.0) / cnt as f64
2554                            } else {
2555                                0.0
2556                            };
2557                            row.fields.insert(alias.clone(), json_f64(val));
2558                        }
2559                        "min" => {
2560                            if acc.min_inits.contains(alias) {
2561                                if let Some(val) = acc.mins.get(alias) {
2562                                    row.fields.insert(alias.clone(), json_f64(*val));
2563                                }
2564                            } else {
2565                                row.fields.insert(alias.clone(), JsonValue::Null);
2566                            }
2567                        }
2568                        "max" => {
2569                            if acc.max_inits.contains(alias) {
2570                                if let Some(val) = acc.maxs.get(alias) {
2571                                    row.fields.insert(alias.clone(), json_f64(*val));
2572                                }
2573                            } else {
2574                                row.fields.insert(alias.clone(), JsonValue::Null);
2575                            }
2576                        }
2577                        _ => {}
2578                    }
2579                }
2580            }
2581            row
2582        })
2583        .collect()
2584}
2585
2586#[cfg(test)]
2587mod tests {
2588    use crate::{
2589        ast::{ComposeTarget, ConfigTarget, Query},
2590        docker::{DockerEvent, MockDockerClient},
2591        metrics::MockMetricsCollector,
2592        parser,
2593    };
2594
2595    use super::*;
2596
2597    fn rt() -> tokio::runtime::Runtime {
2598        tokio::runtime::Runtime::new().unwrap()
2599    }
2600
2601    #[test]
2602    fn observes_containers_from_mock_client() {
2603        let client = mock_client();
2604        let parsed = parser::parse("observe containers").expect("query should parse");
2605
2606        let result = rt()
2607            .block_on(execute(&parsed.query, Arc::new(client)))
2608            .expect("query should execute");
2609
2610        assert_eq!(result.rows.len(), 2);
2611        assert_eq!(
2612            result.rows[0].fields["name"],
2613            JsonValue::String("api".to_owned())
2614        );
2615    }
2616
2617    #[test]
2618    fn filters_containers() {
2619        let client = mock_client();
2620        let parsed =
2621            parser::parse("observe containers where state = running").expect("query should parse");
2622
2623        let result = rt()
2624            .block_on(execute(&parsed.query, Arc::new(client)))
2625            .expect("query should execute");
2626
2627        assert_eq!(result.rows.len(), 1);
2628        assert_eq!(
2629            result.rows[0].fields["name"],
2630            JsonValue::String("api".to_owned())
2631        );
2632    }
2633
2634    #[test]
2635    fn applies_pipeline_select_sort_and_limit() {
2636        let client = mock_client();
2637        let parsed = parser::parse(
2638            "observe containers | sort by restart_count desc | limit 1 | select name, restart_count",
2639        )
2640        .expect("query should parse");
2641
2642        let result = rt()
2643            .block_on(execute(&parsed.query, Arc::new(client)))
2644            .expect("query should execute");
2645
2646        assert_eq!(result.rows.len(), 1);
2647        assert_eq!(
2648            result.rows[0].fields.keys().cloned().collect::<Vec<_>>(),
2649            vec!["name".to_owned(), "restart_count".to_owned()]
2650        );
2651        assert_eq!(
2652            result.rows[0].fields["name"],
2653            JsonValue::String("worker".to_owned())
2654        );
2655    }
2656
2657    #[test]
2658    fn filters_containers_by_cpu_metric() {
2659        let client = mock_client();
2660        let metrics = MockMetricsCollector {
2661            samples: vec![
2662                MetricSample {
2663                    container_id: "abc".to_owned(),
2664                    container_name: "api".to_owned(),
2665                    timestamp: "2026-05-31T02:00:00Z".to_owned(),
2666                    cpu_percent: Some(87.5),
2667                    memory_usage_bytes: Some(128),
2668                    memory_limit_bytes: Some(1024),
2669                    network_rx_bytes: Some(10),
2670                    network_tx_bytes: Some(20),
2671                    disk_read_bytes: Some(30),
2672                    disk_write_bytes: Some(40),
2673                },
2674                MetricSample {
2675                    container_id: "def".to_owned(),
2676                    container_name: "worker".to_owned(),
2677                    timestamp: "2026-05-31T02:00:00Z".to_owned(),
2678                    cpu_percent: Some(12.0),
2679                    memory_usage_bytes: Some(64),
2680                    memory_limit_bytes: Some(1024),
2681                    network_rx_bytes: Some(1),
2682                    network_tx_bytes: Some(2),
2683                    disk_read_bytes: Some(3),
2684                    disk_write_bytes: Some(4),
2685                },
2686            ],
2687        };
2688        let parsed = parser::parse("observe containers | where cpu > 80% | select name, cpu")
2689            .expect("query should parse");
2690
2691        let result = rt()
2692            .block_on(execute_with_metrics(
2693                &parsed.query,
2694                Arc::new(client),
2695                &metrics,
2696            ))
2697            .expect("query should execute");
2698
2699        assert_eq!(result.rows.len(), 1);
2700        assert_eq!(
2701            result.rows[0].fields["name"],
2702            JsonValue::String("api".to_owned())
2703        );
2704        assert_eq!(result.rows[0].fields["cpu"], json_f64(87.5));
2705    }
2706
2707    #[test]
2708    fn observes_images() {
2709        let client = mock_client();
2710        let parsed =
2711            parser::parse("observe images | select repository, tag").expect("query should parse");
2712
2713        let result = rt()
2714            .block_on(execute(&parsed.query, Arc::new(client)))
2715            .expect("query should execute");
2716
2717        assert_eq!(result.rows.len(), 1);
2718        assert_eq!(
2719            result.rows[0].fields["repository"],
2720            JsonValue::String("postgres".to_owned())
2721        );
2722    }
2723
2724    #[test]
2725    fn groups_by_state_with_sum() {
2726        let client = mock_client();
2727        let parsed = parser::parse(
2728            "observe containers | group by state with sum(restart_count) as total_restarts",
2729        )
2730        .expect("query should parse");
2731
2732        let result = rt()
2733            .block_on(execute(&parsed.query, Arc::new(client)))
2734            .expect("query should execute");
2735
2736        assert_eq!(result.rows.len(), 2);
2737        let running = result
2738            .rows
2739            .iter()
2740            .find(|row| row.fields["state"] == JsonValue::String("running".to_owned()))
2741            .expect("running group");
2742        let exited = result
2743            .rows
2744            .iter()
2745            .find(|row| row.fields["state"] == JsonValue::String("exited".to_owned()))
2746            .expect("exited group");
2747        assert_eq!(running.fields["total_restarts"], json_f64(0.0));
2748        assert_eq!(exited.fields["total_restarts"], json_f64(4.0));
2749    }
2750
2751    #[test]
2752    fn groups_by_state_with_avg_cpu() {
2753        let client = mock_client();
2754        let metrics = MockMetricsCollector {
2755            samples: vec![
2756                MetricSample {
2757                    container_id: "abc".to_owned(),
2758                    container_name: "api".to_owned(),
2759                    timestamp: "2026-05-31T02:00:00Z".to_owned(),
2760                    cpu_percent: Some(50.0),
2761                    memory_usage_bytes: Some(128),
2762                    memory_limit_bytes: Some(1024),
2763                    network_rx_bytes: Some(10),
2764                    network_tx_bytes: Some(20),
2765                    disk_read_bytes: Some(30),
2766                    disk_write_bytes: Some(40),
2767                },
2768                MetricSample {
2769                    container_id: "def".to_owned(),
2770                    container_name: "worker".to_owned(),
2771                    timestamp: "2026-05-31T02:00:00Z".to_owned(),
2772                    cpu_percent: Some(30.0),
2773                    memory_usage_bytes: Some(64),
2774                    memory_limit_bytes: Some(1024),
2775                    network_rx_bytes: Some(1),
2776                    network_tx_bytes: Some(2),
2777                    disk_read_bytes: Some(3),
2778                    disk_write_bytes: Some(4),
2779                },
2780            ],
2781        };
2782        let parsed = parser::parse("observe containers | group by state with avg(cpu) as avg_cpu")
2783            .expect("query should parse");
2784
2785        let result = rt()
2786            .block_on(execute_with_metrics(
2787                &parsed.query,
2788                Arc::new(client),
2789                &metrics,
2790            ))
2791            .expect("query should execute");
2792
2793        assert_eq!(result.rows.len(), 2);
2794        let running = result
2795            .rows
2796            .iter()
2797            .find(|row| row.fields["state"] == JsonValue::String("running".to_owned()))
2798            .expect("running group");
2799        let exited = result
2800            .rows
2801            .iter()
2802            .find(|row| row.fields["state"] == JsonValue::String("exited".to_owned()))
2803            .expect("exited group");
2804        assert_eq!(running.fields["avg_cpu"], json_f64(50.0));
2805        assert_eq!(exited.fields["avg_cpu"], json_f64(30.0));
2806    }
2807
2808    #[test]
2809    fn groups_by_state_with_min_max() {
2810        let client = mock_client();
2811        let parsed = parser::parse(
2812            "observe containers | group by state with min(restart_count) as min_r, max(restart_count) as max_r",
2813        )
2814        .expect("query should parse");
2815
2816        let result = rt()
2817            .block_on(execute(&parsed.query, Arc::new(client)))
2818            .expect("query should execute");
2819
2820        assert_eq!(result.rows.len(), 2);
2821        let running = result
2822            .rows
2823            .iter()
2824            .find(|row| row.fields["state"] == JsonValue::String("running".to_owned()))
2825            .expect("running group");
2826        let exited = result
2827            .rows
2828            .iter()
2829            .find(|row| row.fields["state"] == JsonValue::String("exited".to_owned()))
2830            .expect("exited group");
2831        assert_eq!(running.fields["min_r"], json_f64(0.0));
2832        assert_eq!(running.fields["max_r"], json_f64(0.0));
2833        assert_eq!(exited.fields["min_r"], json_f64(4.0));
2834        assert_eq!(exited.fields["max_r"], json_f64(4.0));
2835    }
2836
2837    #[test]
2838    fn groups_by_state_with_count_alias() {
2839        let client = mock_client();
2840        let parsed = parser::parse("observe containers | group by state with count(id) as cnt")
2841            .expect("query should parse");
2842
2843        let result = rt()
2844            .block_on(execute(&parsed.query, Arc::new(client)))
2845            .expect("query should execute");
2846
2847        assert_eq!(result.rows.len(), 2);
2848        let running = result
2849            .rows
2850            .iter()
2851            .find(|row| row.fields["state"] == JsonValue::String("running".to_owned()))
2852            .expect("running group");
2853        let exited = result
2854            .rows
2855            .iter()
2856            .find(|row| row.fields["state"] == JsonValue::String("exited".to_owned()))
2857            .expect("exited group");
2858        assert_eq!(running.fields["cnt"], JsonValue::Number(Number::from(1)));
2859        assert_eq!(exited.fields["cnt"], JsonValue::Number(Number::from(1)));
2860        // When explicit aggregates are used, no default `count` column
2861        assert!(!running.fields.contains_key("count"));
2862    }
2863
2864    #[test]
2865    fn groups_rows_by_state() {
2866        let client = mock_client();
2867        let parsed =
2868            parser::parse("observe containers | group by state").expect("query should parse");
2869
2870        let result = rt()
2871            .block_on(execute(&parsed.query, Arc::new(client)))
2872            .expect("query should execute");
2873
2874        assert_eq!(result.rows.len(), 2);
2875        let running = result
2876            .rows
2877            .iter()
2878            .find(|row| row.fields["state"] == JsonValue::String("running".to_owned()))
2879            .expect("running group");
2880        let exited = result
2881            .rows
2882            .iter()
2883            .find(|row| row.fields["state"] == JsonValue::String("exited".to_owned()))
2884            .expect("exited group");
2885        assert_eq!(running.fields["count"], JsonValue::Number(Number::from(1)));
2886        assert_eq!(exited.fields["count"], JsonValue::Number(Number::from(1)));
2887    }
2888
2889    #[test]
2890    fn rejects_unknown_field() {
2891        let client = mock_client();
2892        let parsed =
2893            parser::parse("observe containers | select missing").expect("query should parse");
2894
2895        let error = rt()
2896            .block_on(execute(&parsed.query, Arc::new(client)))
2897            .unwrap_err();
2898
2899        assert!(matches!(
2900            error,
2901            ExecutorError::Eval(eval::EvalError::UnsupportedField { .. })
2902        ));
2903    }
2904
2905    #[test]
2906    fn rejects_invalid_type_comparison() {
2907        let client = mock_client();
2908        let parsed =
2909            parser::parse("observe containers | where state > 50").expect("query should parse");
2910
2911        let error = rt()
2912            .block_on(execute(&parsed.query, Arc::new(client)))
2913            .unwrap_err();
2914
2915        assert!(matches!(
2916            error,
2917            ExecutorError::Eval(eval::EvalError::InvalidComparison { .. })
2918        ));
2919    }
2920
2921    #[test]
2922    fn renders_table() {
2923        let result = ExecutionResult {
2924            rows: vec![Row::from_fields([
2925                ("name", JsonValue::String("api".to_owned())),
2926                ("state", JsonValue::String("running".to_owned())),
2927            ])],
2928        };
2929
2930        let table = render_table(&result);
2931
2932        assert!(table.contains("name"));
2933        assert!(table.contains("running"));
2934    }
2935
2936    #[test]
2937    fn sets_literal_field() {
2938        let client = mock_client();
2939        let parsed = parser::parse("observe containers | set tier = \"prod\" | select name, tier")
2940            .expect("query should parse");
2941
2942        let result = rt()
2943            .block_on(execute(&parsed.query, Arc::new(client)))
2944            .expect("query should execute");
2945
2946        assert_eq!(result.rows.len(), 2);
2947        assert_eq!(
2948            result.rows[0].fields["tier"],
2949            JsonValue::String("prod".to_owned())
2950        );
2951    }
2952
2953    #[test]
2954    fn sets_case_field() {
2955        let client = mock_client();
2956        let parsed = parser::parse(
2957            "observe containers | set health = case when state = running then \"up\" else \"down\" end | select name, health",
2958        ).expect("query should parse");
2959
2960        let result = rt()
2961            .block_on(execute(&parsed.query, Arc::new(client)))
2962            .expect("query should execute");
2963
2964        assert_eq!(result.rows.len(), 2);
2965        assert_eq!(
2966            result.rows[0].fields["health"],
2967            JsonValue::String("up".to_owned())
2968        );
2969        assert_eq!(
2970            result.rows[1].fields["health"],
2971            JsonValue::String("down".to_owned())
2972        );
2973    }
2974
2975    #[test]
2976    fn applies_if_pipeline() {
2977        let client = mock_client();
2978        let parsed = parser::parse(
2979            "observe containers | if state = running then set status_label = \"active\" else set status_label = \"inactive\" | select name, status_label",
2980        ).expect("query should parse");
2981
2982        let result = rt()
2983            .block_on(execute(&parsed.query, Arc::new(client)))
2984            .expect("query should execute");
2985
2986        assert_eq!(result.rows.len(), 2);
2987        assert_eq!(
2988            result.rows[0].fields["status_label"],
2989            JsonValue::String("active".to_owned())
2990        );
2991        assert_eq!(
2992            result.rows[1].fields["status_label"],
2993            JsonValue::String("inactive".to_owned())
2994        );
2995    }
2996
2997    #[test]
2998    fn executes_compose_query() {
2999        let client = compose_mock_client();
3000        let parsed = parser::parse("compose myapp").expect("query should parse");
3001        let result = rt()
3002            .block_on(execute(&parsed.query, Arc::new(client)))
3003            .expect("query should execute");
3004        // Only myapp containers should be returned
3005        assert_eq!(result.rows.len(), 2);
3006        assert_eq!(
3007            result.rows[0].fields["name"],
3008            JsonValue::String("api".to_owned())
3009        );
3010        assert_eq!(
3011            result.rows[1].fields["name"],
3012            JsonValue::String("db".to_owned())
3013        );
3014    }
3015
3016    #[test]
3017    fn executes_compose_with_pipeline() {
3018        let client = compose_mock_client();
3019        let parsed = parser::parse("compose myapp | select name, state, compose_project")
3020            .expect("query should parse");
3021        let result = rt()
3022            .block_on(execute(&parsed.query, Arc::new(client)))
3023            .expect("query should execute");
3024        assert_eq!(result.rows.len(), 2);
3025        for row in &result.rows {
3026            assert_eq!(
3027                row.fields["compose_project"],
3028                JsonValue::String("myapp".to_owned())
3029            );
3030        }
3031    }
3032
3033    #[test]
3034    fn executes_compose_services() {
3035        let client = compose_mock_client();
3036        let parsed = parser::parse("compose myapp services | select name, service")
3037            .expect("query should parse");
3038        let result = rt()
3039            .block_on(execute(&parsed.query, Arc::new(client)))
3040            .expect("query should execute");
3041        assert_eq!(result.rows.len(), 2);
3042        let api = result
3043            .rows
3044            .iter()
3045            .find(|r| r.fields["name"] == "api")
3046            .unwrap();
3047        assert_eq!(
3048            api.fields["service"],
3049            JsonValue::String("api-service".to_owned())
3050        );
3051        let db = result
3052            .rows
3053            .iter()
3054            .find(|r| r.fields["name"] == "db")
3055            .unwrap();
3056        assert_eq!(
3057            db.fields["service"],
3058            JsonValue::String("database".to_owned())
3059        );
3060    }
3061
3062    #[test]
3063    fn executes_compose_empty_for_missing_project() {
3064        let client = compose_mock_client();
3065        let parsed = parser::parse("compose nonexistent").expect("query should parse");
3066        let result = rt()
3067            .block_on(execute(&parsed.query, Arc::new(client)))
3068            .expect("query should execute");
3069        assert_eq!(result.rows.len(), 0);
3070    }
3071
3072    fn compose_mock_client() -> MockDockerClient {
3073        MockDockerClient {
3074            containers: vec![
3075                Container {
3076                    id: "abc".to_owned(),
3077                    name: "api".to_owned(),
3078                    image: "api:latest".to_owned(),
3079                    status: "Up 2 minutes".to_owned(),
3080                    state: "running".to_owned(),
3081                    ports: vec!["8080/tcp".to_owned()],
3082                    labels: vec![
3083                        "com.docker.compose.project=myapp".to_owned(),
3084                        "com.docker.compose.service=api-service".to_owned(),
3085                    ],
3086                    created_at: None,
3087                    started_at: None,
3088                    finished_at: None,
3089                    restart_count: Some(0),
3090                    health: Some("healthy".to_owned()),
3091                },
3092                Container {
3093                    id: "def".to_owned(),
3094                    name: "db".to_owned(),
3095                    image: "postgres:16".to_owned(),
3096                    status: "Up 5 minutes".to_owned(),
3097                    state: "running".to_owned(),
3098                    ports: vec!["5432/tcp".to_owned()],
3099                    labels: vec![
3100                        "com.docker.compose.project=myapp".to_owned(),
3101                        "com.docker.compose.service=database".to_owned(),
3102                    ],
3103                    created_at: None,
3104                    started_at: None,
3105                    finished_at: None,
3106                    restart_count: Some(1),
3107                    health: Some("unhealthy".to_owned()),
3108                },
3109                Container {
3110                    id: "xyz".to_owned(),
3111                    name: "worker".to_owned(),
3112                    image: "worker:latest".to_owned(),
3113                    status: "Exited (1) 1 minute ago".to_owned(),
3114                    state: "exited".to_owned(),
3115                    ports: Vec::new(),
3116                    labels: vec!["role=worker".to_owned()], // Not part of any compose project
3117                    created_at: None,
3118                    started_at: None,
3119                    finished_at: None,
3120                    restart_count: Some(4),
3121                    health: None,
3122                },
3123            ],
3124            images: vec![Image {
3125                id: "img".to_owned(),
3126                repository: "postgres".to_owned(),
3127                tag: "16".to_owned(),
3128                digest: None,
3129                size: "432MB".to_owned(),
3130                created_at: None,
3131                labels: Vec::new(),
3132            }],
3133            networks: vec![
3134                Network {
3135                    id: "net1".to_owned(),
3136                    name: "myapp_default".to_owned(),
3137                    driver: "bridge".to_owned(),
3138                    scope: "local".to_owned(),
3139                    containers: vec!["abc".to_owned(), "def".to_owned()],
3140                    labels: vec![
3141                        "com.docker.compose.project=myapp".to_owned(),
3142                        "com.docker.compose.network=default".to_owned(),
3143                    ],
3144                },
3145                Network {
3146                    id: "net2".to_owned(),
3147                    name: "myapp_frontend".to_owned(),
3148                    driver: "bridge".to_owned(),
3149                    scope: "local".to_owned(),
3150                    containers: vec!["abc".to_owned()],
3151                    labels: vec![
3152                        "com.docker.compose.project=myapp".to_owned(),
3153                        "com.docker.compose.network=frontend".to_owned(),
3154                    ],
3155                },
3156                Network {
3157                    id: "net3".to_owned(),
3158                    name: "bridge".to_owned(),
3159                    driver: "bridge".to_owned(),
3160                    scope: "local".to_owned(),
3161                    containers: Vec::new(),
3162                    labels: Vec::new(),
3163                },
3164            ],
3165            volumes: vec![
3166                Volume {
3167                    name: "myapp_pgdata".to_owned(),
3168                    driver: "local".to_owned(),
3169                    mountpoint: Some("/var/lib/docker/volumes/myapp_pgdata/_data".to_owned()),
3170                    scope: Some("local".to_owned()),
3171                    labels: vec![
3172                        "com.docker.compose.project=myapp".to_owned(),
3173                        "com.docker.compose.volume=pgdata".to_owned(),
3174                    ],
3175                },
3176                Volume {
3177                    name: "pgdata".to_owned(),
3178                    driver: "local".to_owned(),
3179                    mountpoint: Some("/var/lib/docker/volumes/pgdata/_data".to_owned()),
3180                    scope: Some("local".to_owned()),
3181                    labels: Vec::new(),
3182                },
3183            ],
3184            logs: std::collections::HashMap::new(),
3185            events: Vec::new(),
3186        }
3187    }
3188
3189    #[test]
3190    fn executes_compose_with_where_filter() {
3191        let client = compose_mock_client();
3192        let parsed =
3193            parser::parse("compose myapp | where state = \"running\"").expect("query should parse");
3194        let result = rt()
3195            .block_on(execute(&parsed.query, Arc::new(client)))
3196            .expect("query should execute");
3197        assert_eq!(result.rows.len(), 2);
3198        for row in &result.rows {
3199            assert_eq!(row.fields["state"], JsonValue::String("running".to_owned()));
3200        }
3201    }
3202
3203    #[test]
3204    fn executes_compose_with_sort() {
3205        let client = compose_mock_client();
3206        let parsed = parser::parse("compose myapp | sort by name asc").expect("query should parse");
3207        let result = rt()
3208            .block_on(execute(&parsed.query, Arc::new(client)))
3209            .expect("query should execute");
3210        assert_eq!(result.rows.len(), 2);
3211        assert_eq!(
3212            result.rows[0].fields["name"],
3213            JsonValue::String("api".to_owned())
3214        );
3215        assert_eq!(
3216            result.rows[1].fields["name"],
3217            JsonValue::String("db".to_owned())
3218        );
3219    }
3220
3221    #[test]
3222    fn executes_compose_with_limit() {
3223        let client = compose_mock_client();
3224        let parsed = parser::parse("compose myapp | limit 1").expect("query should parse");
3225        let result = rt()
3226            .block_on(execute(&parsed.query, Arc::new(client)))
3227            .expect("query should execute");
3228        assert_eq!(result.rows.len(), 1);
3229    }
3230
3231    #[test]
3232    fn executes_compose_with_select() {
3233        let client = compose_mock_client();
3234        let parsed =
3235            parser::parse("compose myapp | select name, image").expect("query should parse");
3236        let result = rt()
3237            .block_on(execute(&parsed.query, Arc::new(client)))
3238            .expect("query should execute");
3239        assert_eq!(result.rows.len(), 2);
3240        for row in &result.rows {
3241            assert_eq!(row.fields.len(), 2);
3242            assert!(row.fields.contains_key("name"));
3243            assert!(row.fields.contains_key("image"));
3244        }
3245    }
3246
3247    #[test]
3248    fn executes_compose_with_group_by_state() {
3249        let client = compose_mock_client();
3250        let parsed = parser::parse("compose myapp | group by state with count(id) as cnt")
3251            .expect("query should parse");
3252        let result = rt()
3253            .block_on(execute(&parsed.query, Arc::new(client)))
3254            .expect("query should execute");
3255        assert_eq!(result.rows.len(), 1);
3256        let running_row = result
3257            .rows
3258            .iter()
3259            .find(|r| r.fields["state"] == JsonValue::String("running".to_owned()))
3260            .unwrap();
3261        assert_eq!(running_row.fields["cnt"], JsonValue::Number(2.into()));
3262    }
3263
3264    #[test]
3265    fn executes_compose_services_without_service_label() {
3266        let client = MockDockerClient {
3267            containers: vec![Container {
3268                id: "abc".to_owned(),
3269                name: "orphan".to_owned(),
3270                image: "busybox:latest".to_owned(),
3271                status: "Up 1 minute".to_owned(),
3272                state: "running".to_owned(),
3273                ports: Vec::new(),
3274                labels: vec!["com.docker.compose.project=lonely".to_owned()],
3275                created_at: None,
3276                started_at: None,
3277                finished_at: None,
3278                restart_count: Some(0),
3279                health: None,
3280            }],
3281            images: vec![],
3282            networks: vec![],
3283            volumes: vec![],
3284            logs: std::collections::HashMap::new(),
3285            events: Vec::new(),
3286        };
3287        let parsed = parser::parse("compose lonely services | select name, service")
3288            .expect("query should parse");
3289        let result = rt()
3290            .block_on(execute(&parsed.query, Arc::new(client)))
3291            .expect("query should execute");
3292        assert_eq!(result.rows.len(), 1);
3293        assert_eq!(result.rows[0].fields["service"], JsonValue::Null);
3294    }
3295
3296    #[test]
3297    fn executes_compose_excludes_non_project_containers() {
3298        let client = compose_mock_client();
3299        let parsed = parser::parse("compose myapp | select name").expect("query should parse");
3300        let result = rt()
3301            .block_on(execute(&parsed.query, Arc::new(client)))
3302            .expect("query should execute");
3303        let names: Vec<&str> = result
3304            .rows
3305            .iter()
3306            .map(|r| r.fields["name"].as_str().unwrap())
3307            .collect();
3308        assert!(!names.contains(&"worker"));
3309        assert!(names.contains(&"api"));
3310        assert!(names.contains(&"db"));
3311    }
3312
3313    #[test]
3314    fn executes_compose_with_offset_and_limit() {
3315        let client = compose_mock_client();
3316        let parsed = parser::parse("compose myapp | sort by name asc | offset 1 | limit 1")
3317            .expect("query should parse");
3318        let result = rt()
3319            .block_on(execute(&parsed.query, Arc::new(client)))
3320            .expect("query should execute");
3321        assert_eq!(result.rows.len(), 1);
3322        assert_eq!(
3323            result.rows[0].fields["name"],
3324            JsonValue::String("db".to_owned())
3325        );
3326    }
3327
3328    #[test]
3329    fn executes_compose_with_distinct() {
3330        let client = compose_mock_client();
3331        let parsed =
3332            parser::parse("compose myapp | select state | distinct").expect("query should parse");
3333        let result = rt()
3334            .block_on(execute(&parsed.query, Arc::new(client)))
3335            .expect("query should execute");
3336        assert_eq!(result.rows.len(), 1);
3337        assert_eq!(
3338            result.rows[0].fields["state"],
3339            JsonValue::String("running".to_owned())
3340        );
3341    }
3342
3343    #[test]
3344    fn executes_historical_compose_query() {
3345        use crate::storage::{InMemoryTelemetryStore, TelemetrySnapshot};
3346
3347        let mut store = InMemoryTelemetryStore::default();
3348        store
3349            .write_snapshot(TelemetrySnapshot {
3350                timestamp: "2026-05-31T00:00:00Z".to_owned(),
3351                containers: vec![
3352                    Container {
3353                        id: "h1".to_owned(),
3354                        name: "hist-api".to_owned(),
3355                        image: "api:v1".to_owned(),
3356                        status: "Up 1 hour".to_owned(),
3357                        state: "running".to_owned(),
3358                        ports: vec!["8080/tcp".to_owned()],
3359                        labels: vec![
3360                            "com.docker.compose.project=histapp".to_owned(),
3361                            "com.docker.compose.service=api".to_owned(),
3362                        ],
3363                        created_at: None,
3364                        started_at: None,
3365                        finished_at: None,
3366                        restart_count: Some(0),
3367                        health: None,
3368                    },
3369                    Container {
3370                        id: "h2".to_owned(),
3371                        name: "hist-db".to_owned(),
3372                        image: "postgres:16".to_owned(),
3373                        status: "Up 1 hour".to_owned(),
3374                        state: "running".to_owned(),
3375                        ports: vec!["5432/tcp".to_owned()],
3376                        labels: vec![
3377                            "com.docker.compose.project=histapp".to_owned(),
3378                            "com.docker.compose.service=db".to_owned(),
3379                        ],
3380                        created_at: None,
3381                        started_at: None,
3382                        finished_at: None,
3383                        restart_count: Some(0),
3384                        health: None,
3385                    },
3386                    Container {
3387                        id: "h3".to_owned(),
3388                        name: "standalone".to_owned(),
3389                        image: "nginx:latest".to_owned(),
3390                        status: "Up 30 minutes".to_owned(),
3391                        state: "running".to_owned(),
3392                        ports: Vec::new(),
3393                        labels: vec![],
3394                        created_at: None,
3395                        started_at: None,
3396                        finished_at: None,
3397                        restart_count: Some(0),
3398                        health: None,
3399                    },
3400                ],
3401                images: vec![],
3402                networks: vec![],
3403                volumes: vec![],
3404            })
3405            .unwrap();
3406
3407        let parsed =
3408            parser::parse("compose histapp | select name, state").expect("query should parse");
3409        let result = rt()
3410            .block_on(execute_with_store(&parsed.query, &store))
3411            .expect("should execute");
3412        assert_eq!(result.rows.len(), 2);
3413        let names: Vec<&str> = result
3414            .rows
3415            .iter()
3416            .map(|r| r.fields["name"].as_str().unwrap())
3417            .collect();
3418        assert!(names.contains(&"hist-api"));
3419        assert!(names.contains(&"hist-db"));
3420        assert!(!names.contains(&"standalone"));
3421    }
3422
3423    #[test]
3424    fn executes_historical_compose_services() {
3425        use crate::storage::{InMemoryTelemetryStore, TelemetrySnapshot};
3426
3427        let mut store = InMemoryTelemetryStore::default();
3428        store
3429            .write_snapshot(TelemetrySnapshot {
3430                timestamp: "2026-05-31T00:00:00Z".to_owned(),
3431                containers: vec![Container {
3432                    id: "h1".to_owned(),
3433                    name: "web".to_owned(),
3434                    image: "web:latest".to_owned(),
3435                    status: "Up".to_owned(),
3436                    state: "running".to_owned(),
3437                    ports: Vec::new(),
3438                    labels: vec![
3439                        "com.docker.compose.project=webapp".to_owned(),
3440                        "com.docker.compose.service=frontend".to_owned(),
3441                    ],
3442                    created_at: None,
3443                    started_at: None,
3444                    finished_at: None,
3445                    restart_count: Some(0),
3446                    health: None,
3447                }],
3448                images: vec![],
3449                networks: vec![],
3450                volumes: vec![],
3451            })
3452            .unwrap();
3453
3454        let parsed = parser::parse("compose webapp services | select name, service")
3455            .expect("query should parse");
3456        let result = rt()
3457            .block_on(execute_with_store(&parsed.query, &store))
3458            .expect("should execute");
3459        assert_eq!(result.rows.len(), 1);
3460        assert_eq!(
3461            result.rows[0].fields["service"],
3462            JsonValue::String("frontend".to_owned())
3463        );
3464    }
3465
3466    #[test]
3467    fn executes_historical_compose_empty_for_missing_project() {
3468        use crate::storage::{InMemoryTelemetryStore, TelemetrySnapshot};
3469
3470        let mut store = InMemoryTelemetryStore::default();
3471        store
3472            .write_snapshot(TelemetrySnapshot {
3473                timestamp: "2026-05-31T00:00:00Z".to_owned(),
3474                containers: vec![Container {
3475                    id: "x".to_owned(),
3476                    name: "solo".to_owned(),
3477                    image: "nginx:latest".to_owned(),
3478                    status: "Up".to_owned(),
3479                    state: "running".to_owned(),
3480                    ports: Vec::new(),
3481                    labels: vec![],
3482                    created_at: None,
3483                    started_at: None,
3484                    finished_at: None,
3485                    restart_count: Some(0),
3486                    health: None,
3487                }],
3488                images: vec![],
3489                networks: vec![],
3490                volumes: vec![],
3491            })
3492            .unwrap();
3493
3494        let parsed = parser::parse("compose nonexistent").expect("query should parse");
3495        let result = rt()
3496            .block_on(execute_with_store(&parsed.query, &store))
3497            .expect("should execute");
3498        assert_eq!(result.rows.len(), 0);
3499    }
3500
3501    #[test]
3502    fn executes_compose_networks() {
3503        let client = compose_mock_client();
3504        let parsed = parser::parse("compose myapp networks").expect("query should parse");
3505        let result = rt()
3506            .block_on(execute(&parsed.query, Arc::new(client)))
3507            .expect("query should execute");
3508        assert_eq!(result.rows.len(), 2);
3509        let names: Vec<&str> = result
3510            .rows
3511            .iter()
3512            .map(|r| r.fields["name"].as_str().unwrap())
3513            .collect();
3514        assert!(names.contains(&"myapp_default"));
3515        assert!(names.contains(&"myapp_frontend"));
3516        assert!(!names.contains(&"bridge"));
3517    }
3518
3519    #[test]
3520    fn executes_compose_networks_with_pipeline() {
3521        let client = compose_mock_client();
3522        let parsed = parser::parse("compose myapp networks | select name, driver")
3523            .expect("query should parse");
3524        let result = rt()
3525            .block_on(execute(&parsed.query, Arc::new(client)))
3526            .expect("query should execute");
3527        assert_eq!(result.rows.len(), 2);
3528        for row in &result.rows {
3529            assert!(row.fields.contains_key("name"));
3530            assert!(row.fields.contains_key("driver"));
3531            assert_eq!(row.fields.len(), 2);
3532        }
3533    }
3534
3535    #[test]
3536    fn executes_compose_networks_empty_for_missing_project() {
3537        let client = compose_mock_client();
3538        let parsed = parser::parse("compose nonexistent networks").expect("query should parse");
3539        let result = rt()
3540            .block_on(execute(&parsed.query, Arc::new(client)))
3541            .expect("query should execute");
3542        assert_eq!(result.rows.len(), 0);
3543    }
3544
3545    #[test]
3546    fn executes_compose_volumes() {
3547        let client = compose_mock_client();
3548        let parsed = parser::parse("compose myapp volumes").expect("query should parse");
3549        let result = rt()
3550            .block_on(execute(&parsed.query, Arc::new(client)))
3551            .expect("query should execute");
3552        assert_eq!(result.rows.len(), 1);
3553        assert_eq!(
3554            result.rows[0].fields["name"],
3555            JsonValue::String("myapp_pgdata".to_owned())
3556        );
3557    }
3558
3559    #[test]
3560    fn executes_compose_volumes_with_pipeline() {
3561        let client = compose_mock_client();
3562        let parsed = parser::parse("compose myapp volumes | select name, driver")
3563            .expect("query should parse");
3564        let result = rt()
3565            .block_on(execute(&parsed.query, Arc::new(client)))
3566            .expect("query should execute");
3567        assert_eq!(result.rows.len(), 1);
3568        assert_eq!(
3569            result.rows[0].fields["name"],
3570            JsonValue::String("myapp_pgdata".to_owned())
3571        );
3572        assert_eq!(
3573            result.rows[0].fields["driver"],
3574            JsonValue::String("local".to_owned())
3575        );
3576    }
3577
3578    #[test]
3579    fn executes_compose_health() {
3580        let client = compose_mock_client();
3581        let parsed = parser::parse("compose myapp health | select name, service, health")
3582            .expect("query should parse");
3583        let result = rt()
3584            .block_on(execute(&parsed.query, Arc::new(client)))
3585            .expect("query should execute");
3586        assert_eq!(result.rows.len(), 2);
3587        let api = result
3588            .rows
3589            .iter()
3590            .find(|r| r.fields["name"] == "api")
3591            .unwrap();
3592        assert_eq!(
3593            api.fields["health"],
3594            JsonValue::String("healthy".to_owned())
3595        );
3596        let db = result
3597            .rows
3598            .iter()
3599            .find(|r| r.fields["name"] == "db")
3600            .unwrap();
3601        assert_eq!(
3602            db.fields["health"],
3603            JsonValue::String("unhealthy".to_owned())
3604        );
3605    }
3606
3607    #[test]
3608    fn executes_compose_health_with_filter() {
3609        let client = compose_mock_client();
3610        let parsed = parser::parse("compose myapp health | where health = \"unhealthy\"")
3611            .expect("query should parse");
3612        let result = rt()
3613            .block_on(execute(&parsed.query, Arc::new(client)))
3614            .expect("query should execute");
3615        assert_eq!(result.rows.len(), 1);
3616        assert_eq!(
3617            result.rows[0].fields["name"],
3618            JsonValue::String("db".to_owned())
3619        );
3620    }
3621
3622    #[test]
3623    fn executes_historical_compose_networks() {
3624        use crate::storage::{InMemoryTelemetryStore, TelemetrySnapshot};
3625
3626        let mut store = InMemoryTelemetryStore::default();
3627        store
3628            .write_snapshot(TelemetrySnapshot {
3629                timestamp: "2026-05-31T00:00:00Z".to_owned(),
3630                containers: vec![],
3631                images: vec![],
3632                networks: vec![
3633                    Network {
3634                        id: "n1".to_owned(),
3635                        name: "hist_default".to_owned(),
3636                        driver: "bridge".to_owned(),
3637                        scope: "local".to_owned(),
3638                        containers: Vec::new(),
3639                        labels: vec!["com.docker.compose.project=histapp".to_owned()],
3640                    },
3641                    Network {
3642                        id: "n2".to_owned(),
3643                        name: "bridge".to_owned(),
3644                        driver: "bridge".to_owned(),
3645                        scope: "local".to_owned(),
3646                        containers: Vec::new(),
3647                        labels: Vec::new(),
3648                    },
3649                ],
3650                volumes: vec![],
3651            })
3652            .unwrap();
3653
3654        let parsed =
3655            parser::parse("compose histapp networks | select name").expect("query should parse");
3656        let result = rt()
3657            .block_on(execute_with_store(&parsed.query, &store))
3658            .expect("should execute");
3659        assert_eq!(result.rows.len(), 1);
3660        assert_eq!(
3661            result.rows[0].fields["name"],
3662            JsonValue::String("hist_default".to_owned())
3663        );
3664    }
3665
3666    #[test]
3667    fn executes_historical_compose_volumes() {
3668        use crate::storage::{InMemoryTelemetryStore, TelemetrySnapshot};
3669
3670        let mut store = InMemoryTelemetryStore::default();
3671        store
3672            .write_snapshot(TelemetrySnapshot {
3673                timestamp: "2026-05-31T00:00:00Z".to_owned(),
3674                containers: vec![],
3675                images: vec![],
3676                networks: vec![],
3677                volumes: vec![Volume {
3678                    name: "hist_data".to_owned(),
3679                    driver: "local".to_owned(),
3680                    mountpoint: None,
3681                    scope: Some("local".to_owned()),
3682                    labels: vec!["com.docker.compose.project=histapp".to_owned()],
3683                }],
3684            })
3685            .unwrap();
3686
3687        let parsed = parser::parse("compose histapp volumes | select name, driver")
3688            .expect("query should parse");
3689        let result = rt()
3690            .block_on(execute_with_store(&parsed.query, &store))
3691            .expect("should execute");
3692        assert_eq!(result.rows.len(), 1);
3693        assert_eq!(
3694            result.rows[0].fields["name"],
3695            JsonValue::String("hist_data".to_owned())
3696        );
3697    }
3698
3699    fn join_mock_client() -> MockDockerClient {
3700        MockDockerClient {
3701            containers: vec![
3702                Container {
3703                    id: "i1".to_owned(),
3704                    name: "web".to_owned(),
3705                    image: "nginx:latest".to_owned(),
3706                    status: "Up 1 hour".to_owned(),
3707                    state: "running".to_owned(),
3708                    ports: vec!["80/tcp".to_owned()],
3709                    labels: vec![],
3710                    created_at: None,
3711                    started_at: None,
3712                    finished_at: None,
3713                    restart_count: Some(0),
3714                    health: None,
3715                },
3716                Container {
3717                    id: "i2".to_owned(),
3718                    name: "db".to_owned(),
3719                    image: "postgres:16".to_owned(),
3720                    status: "Up 2 hours".to_owned(),
3721                    state: "running".to_owned(),
3722                    ports: vec!["5432/tcp".to_owned()],
3723                    labels: vec![],
3724                    created_at: None,
3725                    started_at: None,
3726                    finished_at: None,
3727                    restart_count: Some(1),
3728                    health: None,
3729                },
3730            ],
3731            images: vec![
3732                Image {
3733                    id: "i1".to_owned(),
3734                    repository: "nginx".to_owned(),
3735                    tag: "latest".to_owned(),
3736                    digest: None,
3737                    size: "187MB".to_owned(),
3738                    created_at: None,
3739                    labels: Vec::new(),
3740                },
3741                Image {
3742                    id: "i2".to_owned(),
3743                    repository: "postgres".to_owned(),
3744                    tag: "16".to_owned(),
3745                    digest: None,
3746                    size: "432MB".to_owned(),
3747                    created_at: None,
3748                    labels: Vec::new(),
3749                },
3750            ],
3751            networks: vec![
3752                Network {
3753                    id: "frontend".to_owned(),
3754                    name: "frontend".to_owned(),
3755                    driver: "bridge".to_owned(),
3756                    scope: "local".to_owned(),
3757                    containers: vec!["web".to_owned()],
3758                    labels: Vec::new(),
3759                },
3760                Network {
3761                    id: "backend".to_owned(),
3762                    name: "backend".to_owned(),
3763                    driver: "bridge".to_owned(),
3764                    scope: "local".to_owned(),
3765                    containers: vec!["db".to_owned()],
3766                    labels: Vec::new(),
3767                },
3768            ],
3769            volumes: vec![Volume {
3770                name: "pgdata".to_owned(),
3771                driver: "local".to_owned(),
3772                mountpoint: Some("/var/lib/docker/volumes/pgdata/_data".to_owned()),
3773                scope: Some("local".to_owned()),
3774                labels: Vec::new(),
3775            }],
3776            logs: std::collections::HashMap::new(),
3777            events: Vec::new(),
3778        }
3779    }
3780
3781    fn mock_client() -> MockDockerClient {
3782        MockDockerClient {
3783            containers: vec![
3784                Container {
3785                    id: "abc".to_owned(),
3786                    name: "api".to_owned(),
3787                    image: "api:latest".to_owned(),
3788                    status: "Up 2 minutes".to_owned(),
3789                    state: "running".to_owned(),
3790                    ports: vec!["8080/tcp".to_owned()],
3791                    labels: vec!["role=api".to_owned()],
3792                    created_at: None,
3793                    started_at: None,
3794                    finished_at: None,
3795                    restart_count: Some(0),
3796                    health: None,
3797                },
3798                Container {
3799                    id: "def".to_owned(),
3800                    name: "worker".to_owned(),
3801                    image: "worker:latest".to_owned(),
3802                    status: "Exited (1) 1 minute ago".to_owned(),
3803                    state: "exited".to_owned(),
3804                    ports: Vec::new(),
3805                    labels: vec!["role=worker".to_owned()],
3806                    created_at: None,
3807                    started_at: None,
3808                    finished_at: None,
3809                    restart_count: Some(4),
3810                    health: None,
3811                },
3812            ],
3813            images: vec![Image {
3814                id: "img".to_owned(),
3815                repository: "postgres".to_owned(),
3816                tag: "16".to_owned(),
3817                digest: None,
3818                size: "432MB".to_owned(),
3819                created_at: None,
3820                labels: Vec::new(),
3821            }],
3822            networks: vec![Network {
3823                id: "net".to_owned(),
3824                name: "bridge".to_owned(),
3825                driver: "bridge".to_owned(),
3826                scope: "local".to_owned(),
3827                containers: vec!["api".to_owned()],
3828                labels: Vec::new(),
3829            }],
3830            volumes: vec![Volume {
3831                name: "pgdata".to_owned(),
3832                driver: "local".to_owned(),
3833                mountpoint: Some("/var/lib/docker/volumes/pgdata/_data".to_owned()),
3834                scope: Some("local".to_owned()),
3835                labels: Vec::new(),
3836            }],
3837            logs: std::collections::HashMap::new(),
3838            events: Vec::new(),
3839        }
3840    }
3841
3842    #[test]
3843    fn renders_csv() {
3844        let result = ExecutionResult {
3845            rows: vec![Row::from_fields([
3846                ("name", JsonValue::String("api".to_owned())),
3847                ("state", JsonValue::String("running".to_owned())),
3848            ])],
3849        };
3850        let csv = render_csv(&result);
3851        assert!(csv.contains("name,state"));
3852        assert!(csv.contains("api,running"));
3853    }
3854
3855    #[test]
3856    fn renders_jsonl() {
3857        let result = ExecutionResult {
3858            rows: vec![Row::from_fields([(
3859                "name",
3860                JsonValue::String("api".to_owned()),
3861            )])],
3862        };
3863        let jsonl = render_jsonl(&result);
3864        assert!(jsonl.contains("\"name\":\"api\""));
3865    }
3866
3867    #[test]
3868    fn label_field_access_in_pipeline() {
3869        let client = mock_client();
3870        let parsed = parser::parse(
3871            "observe containers | where label.role = \"api\" | select name, label.role",
3872        )
3873        .expect("query should parse");
3874        let result = rt()
3875            .block_on(execute(&parsed.query, Arc::new(client)))
3876            .expect("query should execute");
3877        assert_eq!(result.rows.len(), 1);
3878        assert_eq!(
3879            result.rows[0].fields["label.role"],
3880            JsonValue::String("api".to_owned())
3881        );
3882    }
3883
3884    #[test]
3885    fn execute_fields_containers() {
3886        let result = execute_fields(CollectionTarget::Containers).expect("fields should execute");
3887        assert!(!result.rows.is_empty());
3888        let field_names: Vec<&str> = result
3889            .rows
3890            .iter()
3891            .map(|r| r.fields["field"].as_str().unwrap())
3892            .collect();
3893        assert!(field_names.contains(&"name"));
3894        assert!(field_names.contains(&"cpu"));
3895    }
3896
3897    // ── if branching ──────────────────────────────────────────────
3898
3899    #[test]
3900    fn if_without_else_passes_through() {
3901        // No else branch — unmatched rows pass through unchanged, matched rows get set
3902        let client = mock_client();
3903        let parsed = parser::parse(
3904            "observe containers | if state = running then set priority = \"high\" | select name, state",
3905        )
3906        .expect("query should parse");
3907
3908        let result = rt()
3909            .block_on(execute(&parsed.query, Arc::new(client)))
3910            .expect("query should execute");
3911
3912        // Both rows pass through (matched: priority set; unmatched: unchanged)
3913        assert_eq!(result.rows.len(), 2);
3914        // Running container has priority
3915        let api = result
3916            .rows
3917            .iter()
3918            .find(|r| r.fields["name"] == "api")
3919            .unwrap();
3920        assert_eq!(api.fields["state"], JsonValue::String("running".to_owned()));
3921        // Exited container passes through
3922        let worker = result
3923            .rows
3924            .iter()
3925            .find(|r| r.fields["name"] == "worker")
3926            .unwrap();
3927        assert_eq!(
3928            worker.fields["state"],
3929            JsonValue::String("exited".to_owned())
3930        );
3931    }
3932
3933    #[test]
3934    fn else_if_chain() {
3935        // else-if chaining with three conditions
3936        let client = mock_client();
3937        let parsed = parser::parse(
3938            "observe containers | if state = running then set group = \"active\" else if state = exited then set group = \"stopped\" else set group = \"other\" | select name, state, group",
3939        )
3940        .expect("query should parse");
3941
3942        let result = rt()
3943            .block_on(execute(&parsed.query, Arc::new(client)))
3944            .expect("query should execute");
3945
3946        assert_eq!(result.rows.len(), 2);
3947        let api = result
3948            .rows
3949            .iter()
3950            .find(|r| r.fields["name"] == "api")
3951            .unwrap();
3952        assert_eq!(api.fields["group"], JsonValue::String("active".to_owned()));
3953        let worker = result
3954            .rows
3955            .iter()
3956            .find(|r| r.fields["name"] == "worker")
3957            .unwrap();
3958        assert_eq!(
3959            worker.fields["group"],
3960            JsonValue::String("stopped".to_owned())
3961        );
3962    }
3963
3964    #[test]
3965    fn nested_if_in_then() {
3966        // Nested if inside the then branch of an outer if
3967        // api: restart_count=0 -> outer else -> set status = "stable"
3968        // worker: restart_count=4 -> outer then -> inner if state=exited -> set status = "crashed"
3969        let client = mock_client();
3970        let parsed = parser::parse(
3971            "observe containers | if restart_count > 0 then if state = \"exited\" then set status = \"crashed\" else set status = \"restarting\" else set status = \"stable\" | select name, state, status",
3972        )
3973        .expect("query should parse");
3974
3975        let result = rt()
3976            .block_on(execute(&parsed.query, Arc::new(client)))
3977            .expect("query should execute");
3978
3979        assert_eq!(result.rows.len(), 2);
3980        let api = result
3981            .rows
3982            .iter()
3983            .find(|r| r.fields["name"] == "api")
3984            .unwrap();
3985        assert_eq!(api.fields["status"], JsonValue::String("stable".to_owned()));
3986        let worker = result
3987            .rows
3988            .iter()
3989            .find(|r| r.fields["name"] == "worker")
3990            .unwrap();
3991        assert_eq!(
3992            worker.fields["status"],
3993            JsonValue::String("crashed".to_owned())
3994        );
3995    }
3996
3997    #[test]
3998    fn nested_if_in_else() {
3999        // Nested if inside the else branch (else-if with additional nesting in the if)
4000        // api: state=running -> outer then -> set label = "up"
4001        // worker: state=exited -> outer else -> inner if restart_count>0 -> set label = "crashed"
4002        let client = mock_client();
4003        let parsed = parser::parse(
4004            "observe containers | if state = running then set label = \"up\" else if restart_count > 0 then set label = \"crashed\" else set label = \"down\" | select name, state, label",
4005        )
4006        .expect("query should parse");
4007
4008        let result = rt()
4009            .block_on(execute(&parsed.query, Arc::new(client)))
4010            .expect("query should execute");
4011
4012        assert_eq!(result.rows.len(), 2);
4013        let api = result
4014            .rows
4015            .iter()
4016            .find(|r| r.fields["name"] == "api")
4017            .unwrap();
4018        assert_eq!(api.fields["label"], JsonValue::String("up".to_owned()));
4019        let worker = result
4020            .rows
4021            .iter()
4022            .find(|r| r.fields["name"] == "worker")
4023            .unwrap();
4024        assert_eq!(
4025            worker.fields["label"],
4026            JsonValue::String("crashed".to_owned())
4027        );
4028    }
4029
4030    #[test]
4031    fn multiple_nodes_in_branches() {
4032        // Multiple pipeline nodes in both then and else branches
4033        // then: set a=1, set b=2
4034        let client = mock_client();
4035        let parsed = parser::parse(
4036            "observe containers | if state = running then set a = 1 | set b = 2 else set a = 0 | set b = 0 | select name, state, a, b",
4037        )
4038        .expect("query should parse");
4039
4040        let result = rt()
4041            .block_on(execute(&parsed.query, Arc::new(client)))
4042            .expect("query should execute");
4043
4044        assert_eq!(result.rows.len(), 2);
4045        let api = result
4046            .rows
4047            .iter()
4048            .find(|r| r.fields["name"] == "api")
4049            .unwrap();
4050        assert_eq!(api.fields["a"], JsonValue::Number(Number::from(1)));
4051        assert_eq!(api.fields["b"], JsonValue::Number(Number::from(2)));
4052        let worker = result
4053            .rows
4054            .iter()
4055            .find(|r| r.fields["name"] == "worker")
4056            .unwrap();
4057        assert_eq!(worker.fields["a"], JsonValue::Number(Number::from(0)));
4058        assert_eq!(worker.fields["b"], JsonValue::Number(Number::from(0)));
4059    }
4060
4061    #[test]
4062    fn if_with_set_in_both_branches() {
4063        // Set different values in then vs else
4064        let client = mock_client();
4065        let parsed = parser::parse(
4066            "observe containers | if state = running then set tier = \"prod\" else set tier = \"dev\" | select name, state, tier",
4067        )
4068        .expect("query should parse");
4069
4070        let result = rt()
4071            .block_on(execute(&parsed.query, Arc::new(client)))
4072            .expect("query should execute");
4073
4074        assert_eq!(result.rows.len(), 2);
4075        let api = result
4076            .rows
4077            .iter()
4078            .find(|r| r.fields["name"] == "api")
4079            .unwrap();
4080        assert_eq!(api.fields["tier"], JsonValue::String("prod".to_owned()));
4081        let worker = result
4082            .rows
4083            .iter()
4084            .find(|r| r.fields["name"] == "worker")
4085            .unwrap();
4086        assert_eq!(worker.fields["tier"], JsonValue::String("dev".to_owned()));
4087    }
4088
4089    // ── Render helpers ──────────────────────────────────────────
4090
4091    #[test]
4092    fn renders_table_with_no_rows() {
4093        let result = ExecutionResult { rows: vec![] };
4094        assert_eq!(render_table(&result), "No rows");
4095    }
4096
4097    #[test]
4098    fn renders_csv_empty() {
4099        let result = ExecutionResult { rows: vec![] };
4100        assert_eq!(render_csv(&result), "");
4101    }
4102
4103    #[test]
4104    fn renders_csv_with_commas_and_quotes() {
4105        let result = ExecutionResult {
4106            rows: vec![Row::from_fields([
4107                ("name", JsonValue::String("api,prod".to_owned())),
4108                ("note", JsonValue::String("contains \"quotes\"".to_owned())),
4109            ])],
4110        };
4111        let csv = render_csv(&result);
4112        // The csv crate should properly quote fields with commas or quotes
4113        assert!(
4114            csv.contains("\"api,prod\""),
4115            "comma field should be quoted: {csv}"
4116        );
4117        assert!(
4118            csv.contains("\"contains \"\"quotes\"\"\""),
4119            "quote field should be escaped: {csv}"
4120        );
4121    }
4122
4123    #[test]
4124    fn renders_table_colored_basic() {
4125        let result = ExecutionResult {
4126            rows: vec![Row::from_fields([
4127                ("name", JsonValue::String("api".to_owned())),
4128                ("state", JsonValue::String("running".to_owned())),
4129            ])],
4130        };
4131        let table = render_table_colored(&result);
4132        // Should contain ANSI color codes
4133        assert!(
4134            table.contains("\x1b["),
4135            "colored output should have ANSI codes"
4136        );
4137        assert!(table.contains("api"));
4138        assert!(table.contains("running"));
4139    }
4140
4141    #[test]
4142    fn renders_table_colored_cpu_thresholds() {
4143        let result = ExecutionResult {
4144            rows: vec![
4145                Row::from_fields([
4146                    ("name", JsonValue::String("low".to_owned())),
4147                    ("cpu", json_f64(25.0)),
4148                ]),
4149                Row::from_fields([
4150                    ("name", JsonValue::String("mid".to_owned())),
4151                    ("cpu", json_f64(65.0)),
4152                ]),
4153                Row::from_fields([
4154                    ("name", JsonValue::String("high".to_owned())),
4155                    ("cpu", json_f64(95.0)),
4156                ]),
4157            ],
4158        };
4159        let table = render_table_colored(&result);
4160        // green (\x1b[32m) for <50%
4161        assert!(table.contains("\x1b[32m25"));
4162        // yellow (\x1b[33m) for 50-80%
4163        assert!(table.contains("\x1b[33m65"));
4164        // red (\x1b[31m) for >80%
4165        assert!(table.contains("\x1b[31m95"));
4166    }
4167
4168    #[test]
4169    fn renders_table_colored_memory_thresholds() {
4170        let result = ExecutionResult {
4171            rows: vec![
4172                Row::from_fields([
4173                    ("name", JsonValue::String("small".to_owned())),
4174                    ("memory", json_u64(268_435_456)), // 256MB
4175                ]),
4176                Row::from_fields([
4177                    ("name", JsonValue::String("medium".to_owned())),
4178                    ("memory", json_u64(805_306_368)), // 768MB
4179                ]),
4180                Row::from_fields([
4181                    ("name", JsonValue::String("large".to_owned())),
4182                    ("memory", json_u64(2_147_483_648)), // 2GB
4183                ]),
4184            ],
4185        };
4186        let table = render_table_colored(&result);
4187        // green (\x1b[32m) for <512MB
4188        assert!(table.contains("\x1b[32m268435456"));
4189        // yellow (\x1b[33m) for 512MB-1GB
4190        assert!(table.contains("\x1b[33m805306368"));
4191        // red (\x1b[31m) for >1GB
4192        assert!(table.contains("\x1b[31m2147483648"));
4193    }
4194
4195    #[test]
4196    fn renders_table_colored_empty() {
4197        let result = ExecutionResult { rows: vec![] };
4198        assert_eq!(render_table_colored(&result), "No rows");
4199    }
4200
4201    #[test]
4202    fn executes_logs_query() {
4203        let client = {
4204            let mut c = mock_client();
4205            c.logs.insert(
4206                "abc".to_owned(),
4207                vec![
4208                    "line 1".to_owned(),
4209                    "line 2".to_owned(),
4210                    "line 3".to_owned(),
4211                ],
4212            );
4213            c
4214        };
4215        let parsed = parser::parse("logs container api tail 10").expect("query should parse");
4216        let result = rt()
4217            .block_on(execute(&parsed.query, Arc::new(client)))
4218            .expect("query should execute");
4219        assert_eq!(result.rows.len(), 3);
4220        assert_eq!(
4221            result.rows[0].fields["line"],
4222            JsonValue::Number(Number::from(1))
4223        );
4224        assert_eq!(
4225            result.rows[0].fields["message"],
4226            JsonValue::String("line 1".to_owned())
4227        );
4228        assert_eq!(
4229            result.rows[2].fields["message"],
4230            JsonValue::String("line 3".to_owned())
4231        );
4232    }
4233
4234    #[test]
4235    fn executes_logs_query_empty() {
4236        let client = mock_client();
4237        let parsed = parser::parse("logs container worker").expect("query should parse");
4238        let result = rt()
4239            .block_on(execute(&parsed.query, Arc::new(client)))
4240            .expect("query should execute");
4241        assert_eq!(result.rows.len(), 0);
4242    }
4243
4244    #[test]
4245    fn executes_ping_query() {
4246        let client = mock_client();
4247        let parsed = parser::parse("ping").expect("query should parse");
4248        let result = rt()
4249            .block_on(execute(&parsed.query, Arc::new(client)))
4250            .expect("query should execute");
4251        assert_eq!(result.rows.len(), 1);
4252        assert_eq!(
4253            result.rows[0].fields["status"],
4254            JsonValue::String("ok".to_owned())
4255        );
4256    }
4257
4258    #[test]
4259    fn renders_colored_header_is_bold_white() {
4260        let result = ExecutionResult {
4261            rows: vec![Row::from_fields([
4262                ("name", JsonValue::String("api".to_owned())),
4263                ("cpu", json_f64(50.0)),
4264            ])],
4265        };
4266        let table = render_table_colored(&result);
4267        // Header should be bold white (\x1b[1;37m)
4268        assert!(table.contains("\x1b[1;4;37m"));
4269        assert!(table.contains(ANSI_RESET));
4270    }
4271
4272    #[test]
4273    fn renders_jsonl_with_multiple_rows() {
4274        let result = ExecutionResult {
4275            rows: vec![
4276                Row::from_fields([("name", JsonValue::String("a".to_owned()))]),
4277                Row::from_fields([("name", JsonValue::String("b".to_owned()))]),
4278            ],
4279        };
4280        let jsonl = render_jsonl(&result);
4281        let lines: Vec<&str> = jsonl.lines().collect();
4282        assert_eq!(lines.len(), 2);
4283        assert!(lines[0].contains("\"name\":\"a\""));
4284        assert!(lines[1].contains("\"name\":\"b\""));
4285    }
4286
4287    #[test]
4288    fn executes_join_images_on_id() {
4289        let client = join_mock_client();
4290        let parsed =
4291            parser::parse("observe containers join images on id = id").expect("query should parse");
4292        let result = rt()
4293            .block_on(execute(&parsed.query, Arc::new(client)))
4294            .expect("query should execute");
4295        assert_eq!(result.rows.len(), 2);
4296        for row in &result.rows {
4297            assert!(row.fields.contains_key("c.id"));
4298            assert!(row.fields.contains_key("i.id"));
4299        }
4300        let web = result
4301            .rows
4302            .iter()
4303            .find(|r| r.fields["c.name"] == "web")
4304            .unwrap();
4305        assert_eq!(
4306            web.fields["c.image"],
4307            JsonValue::String("nginx:latest".to_owned())
4308        );
4309        assert_eq!(
4310            web.fields["i.repository"],
4311            JsonValue::String("nginx".to_owned())
4312        );
4313        let db = result
4314            .rows
4315            .iter()
4316            .find(|r| r.fields["c.name"] == "db")
4317            .unwrap();
4318        assert_eq!(
4319            db.fields["c.image"],
4320            JsonValue::String("postgres:16".to_owned())
4321        );
4322        assert_eq!(
4323            db.fields["i.repository"],
4324            JsonValue::String("postgres".to_owned())
4325        );
4326    }
4327
4328    #[test]
4329    fn executes_join_containers_networks_no_match() {
4330        let client = join_mock_client();
4331        let parsed = parser::parse("observe containers join networks on name = name")
4332            .expect("query should parse");
4333        let result = rt()
4334            .block_on(execute(&parsed.query, Arc::new(client)))
4335            .expect("query should execute");
4336        assert_eq!(result.rows.len(), 0);
4337    }
4338
4339    #[test]
4340    fn executes_join_with_where() {
4341        let client = join_mock_client();
4342        let parsed = parser::parse(
4343            "observe containers join images on id = id | where c.image = \"nginx:latest\"",
4344        )
4345        .expect("query should parse");
4346        let result = rt()
4347            .block_on(execute(&parsed.query, Arc::new(client)))
4348            .expect("query should execute");
4349        assert_eq!(result.rows.len(), 1);
4350        assert_eq!(
4351            result.rows[0].fields["c.name"],
4352            JsonValue::String("web".to_owned())
4353        );
4354    }
4355
4356    #[test]
4357    fn executes_join_with_select() {
4358        let client = join_mock_client();
4359        let parsed =
4360            parser::parse("observe containers join images on id = id | select c.name, i.name")
4361                .expect("query should parse");
4362        let result = rt()
4363            .block_on(execute(&parsed.query, Arc::new(client)))
4364            .expect("query should execute");
4365        assert_eq!(result.rows.len(), 2);
4366        for row in &result.rows {
4367            assert_eq!(row.fields.len(), 2);
4368            assert!(row.fields.contains_key("c.name"));
4369            assert!(row.fields.contains_key("i.name"));
4370        }
4371    }
4372
4373    #[test]
4374    fn executes_join_containers_volumes_no_match() {
4375        let client = join_mock_client();
4376        let parsed = parser::parse("observe containers join volumes on state = scope")
4377            .expect("query should parse");
4378        let result = rt()
4379            .block_on(execute(&parsed.query, Arc::new(client)))
4380            .expect("query should execute");
4381        assert_eq!(result.rows.len(), 0);
4382    }
4383
4384    #[test]
4385    fn executes_compose_ls() {
4386        let client = compose_mock_client();
4387        let parsed = parser::parse("compose ls").expect("query should parse");
4388        let result = rt()
4389            .block_on(execute(&parsed.query, Arc::new(client)))
4390            .expect("query should execute");
4391        assert_eq!(result.rows.len(), 1);
4392        let row = &result.rows[0];
4393        assert_eq!(row.fields["project"], JsonValue::String("myapp".to_owned()));
4394        assert_eq!(row.fields["containers"], JsonValue::Number(2.into()));
4395        assert_eq!(row.fields["running"], JsonValue::Number(2.into()));
4396        assert_eq!(row.fields["stopped"], JsonValue::Number(0.into()));
4397        assert_eq!(row.fields["networks"], JsonValue::Number(2.into()));
4398        assert_eq!(row.fields["volumes"], JsonValue::Number(1.into()));
4399        assert_eq!(
4400            row.fields["status"],
4401            JsonValue::String("running".to_owned())
4402        );
4403    }
4404
4405    #[test]
4406    fn executes_compose_ls_with_pipeline() {
4407        let client = compose_mock_client();
4408        let parsed = parser::parse("compose ls | where containers > 0 | sort by project asc")
4409            .expect("query should parse");
4410        let result = rt()
4411            .block_on(execute(&parsed.query, Arc::new(client)))
4412            .expect("query should execute");
4413        assert_eq!(result.rows.len(), 1);
4414        assert_eq!(
4415            result.rows[0].fields["project"],
4416            JsonValue::String("myapp".to_owned())
4417        );
4418    }
4419
4420    #[test]
4421    fn executes_compose_ls_partial_status() {
4422        let client = MockDockerClient {
4423            containers: vec![
4424                Container {
4425                    id: "a1".to_owned(),
4426                    name: "api".to_owned(),
4427                    image: "api:latest".to_owned(),
4428                    status: "Up".to_owned(),
4429                    state: "running".to_owned(),
4430                    ports: Vec::new(),
4431                    labels: vec![
4432                        "com.docker.compose.project=webapp".to_owned(),
4433                        "com.docker.compose.service=api".to_owned(),
4434                    ],
4435                    created_at: None,
4436                    started_at: None,
4437                    finished_at: None,
4438                    restart_count: None,
4439                    health: None,
4440                },
4441                Container {
4442                    id: "a2".to_owned(),
4443                    name: "db".to_owned(),
4444                    image: "postgres:16".to_owned(),
4445                    status: "Exited".to_owned(),
4446                    state: "exited".to_owned(),
4447                    ports: Vec::new(),
4448                    labels: vec![
4449                        "com.docker.compose.project=webapp".to_owned(),
4450                        "com.docker.compose.service=db".to_owned(),
4451                    ],
4452                    created_at: None,
4453                    started_at: None,
4454                    finished_at: None,
4455                    restart_count: None,
4456                    health: None,
4457                },
4458            ],
4459            ..Default::default()
4460        };
4461        let parsed = parser::parse("compose ls").expect("query should parse");
4462        let result = rt()
4463            .block_on(execute(&parsed.query, Arc::new(client)))
4464            .expect("query should execute");
4465        assert_eq!(result.rows.len(), 1);
4466        assert_eq!(
4467            result.rows[0].fields["status"],
4468            JsonValue::String("partial".to_owned())
4469        );
4470    }
4471
4472    #[test]
4473    fn executes_compose_images() {
4474        let client = compose_mock_client();
4475        let parsed = parser::parse("compose myapp images").expect("query should parse");
4476        let result = rt()
4477            .block_on(execute(&parsed.query, Arc::new(client)))
4478            .expect("query should execute");
4479        assert!(!result.rows.is_empty());
4480    }
4481
4482    #[test]
4483    fn executes_compose_stats() {
4484        let client = compose_mock_client();
4485        let parsed = parser::parse("compose myapp stats | select name, service")
4486            .expect("query should parse");
4487        let result = rt()
4488            .block_on(execute(&parsed.query, Arc::new(client)))
4489            .expect("query should execute");
4490        assert_eq!(result.rows.len(), 2);
4491        for row in &result.rows {
4492            assert!(row.fields.contains_key("name"));
4493            assert!(row.fields.contains_key("service"));
4494        }
4495    }
4496
4497    #[test]
4498    fn executes_compose_ps() {
4499        let client = compose_mock_client();
4500        let parsed =
4501            parser::parse("compose myapp ps | select name, service").expect("query should parse");
4502        let result = rt()
4503            .block_on(execute(&parsed.query, Arc::new(client)))
4504            .expect("query should execute");
4505        assert_eq!(result.rows.len(), 2);
4506        for row in &result.rows {
4507            assert!(row.fields.contains_key("name"));
4508            assert!(row.fields.contains_key("service"));
4509        }
4510    }
4511
4512    #[test]
4513    fn executes_compose_ps_with_filter() {
4514        let client = compose_mock_client();
4515        let parsed = parser::parse("compose myapp ps | where service = \"api-service\"")
4516            .expect("query should parse");
4517        let result = rt()
4518            .block_on(execute(&parsed.query, Arc::new(client)))
4519            .expect("query should execute");
4520        assert_eq!(result.rows.len(), 1);
4521        assert_eq!(
4522            result.rows[0].fields["name"],
4523            JsonValue::String("api".to_owned())
4524        );
4525    }
4526
4527    #[test]
4528    fn executes_compose_logs() {
4529        let mut client = compose_mock_client();
4530        client.logs.insert(
4531            "abc".to_owned(),
4532            vec!["line1".to_owned(), "line2".to_owned()],
4533        );
4534        let parsed =
4535            parser::parse("compose myapp logs api-service tail 10").expect("query should parse");
4536        let result = rt()
4537            .block_on(execute(&parsed.query, Arc::new(client)))
4538            .expect("query should execute");
4539        assert_eq!(result.rows.len(), 2);
4540        assert_eq!(
4541            result.rows[0].fields["message"],
4542            JsonValue::String("line1".to_owned())
4543        );
4544        assert_eq!(
4545            result.rows[0].fields["service"],
4546            JsonValue::String("api-service".to_owned())
4547        );
4548        assert_eq!(result.rows[0].fields["line"], JsonValue::Number(1.into()));
4549    }
4550
4551    #[test]
4552    fn executes_compose_logs_with_pipeline() {
4553        let mut client = compose_mock_client();
4554        client.logs.insert(
4555            "abc".to_owned(),
4556            vec![
4557                "info start".to_owned(),
4558                "error: something failed".to_owned(),
4559                "info end".to_owned(),
4560            ],
4561        );
4562        let parsed = parser::parse(
4563            "compose myapp logs api-service tail 10 | where message contains \"error\"",
4564        )
4565        .expect("query should parse");
4566        let result = rt()
4567            .block_on(execute(&parsed.query, Arc::new(client)))
4568            .expect("query should execute");
4569        assert_eq!(result.rows.len(), 1);
4570        assert!(
4571            result.rows[0].fields["message"]
4572                .as_str()
4573                .unwrap()
4574                .contains("error")
4575        );
4576    }
4577
4578    #[test]
4579    fn executes_compose_port() {
4580        let client = compose_mock_client();
4581        let parsed =
4582            parser::parse("compose myapp port api-service 8080").expect("query should parse");
4583        let result = rt()
4584            .block_on(execute(&parsed.query, Arc::new(client)))
4585            .expect("query should execute");
4586        assert_eq!(result.rows.len(), 1);
4587        assert_eq!(
4588            result.rows[0].fields["service"],
4589            JsonValue::String("api-service".to_owned())
4590        );
4591    }
4592
4593    #[test]
4594    fn executes_compose_config_services() {
4595        let client = compose_mock_client();
4596        let parsed = parser::parse("compose myapp config services").expect("query should parse");
4597        let result = rt()
4598            .block_on(execute(&parsed.query, Arc::new(client)))
4599            .expect("query should execute");
4600        assert_eq!(result.rows.len(), 2);
4601        for row in &result.rows {
4602            assert!(row.fields.contains_key("name"));
4603            assert!(row.fields.contains_key("image"));
4604            assert!(row.fields.contains_key("state"));
4605        }
4606    }
4607
4608    #[test]
4609    fn executes_compose_config_networks() {
4610        let client = compose_mock_client();
4611        let parsed = parser::parse("compose myapp config networks").expect("query should parse");
4612        let result = rt()
4613            .block_on(execute(&parsed.query, Arc::new(client)))
4614            .expect("query should execute");
4615        assert_eq!(result.rows.len(), 2);
4616        for row in &result.rows {
4617            assert!(row.fields.contains_key("name"));
4618            assert!(row.fields.contains_key("driver"));
4619        }
4620    }
4621
4622    #[test]
4623    fn executes_compose_config_volumes() {
4624        let client = compose_mock_client();
4625        let parsed = parser::parse("compose myapp config volumes").expect("query should parse");
4626        let result = rt()
4627            .block_on(execute(&parsed.query, Arc::new(client)))
4628            .expect("query should execute");
4629        assert_eq!(result.rows.len(), 1);
4630        assert!(result.rows[0].fields.contains_key("name"));
4631        assert!(result.rows[0].fields.contains_key("driver"));
4632    }
4633
4634    #[test]
4635    fn executes_compose_config_all() {
4636        let client = compose_mock_client();
4637        let parsed = parser::parse("compose myapp config").expect("query should parse");
4638        let result = rt()
4639            .block_on(execute(&parsed.query, Arc::new(client)))
4640            .expect("query should execute");
4641        assert!(result.rows.len() >= 5);
4642    }
4643
4644    #[test]
4645    fn executes_compose_config_with_pipeline() {
4646        let client = compose_mock_client();
4647        let parsed = parser::parse("compose myapp config services | where image = \"api:latest\"")
4648            .expect("query should parse");
4649        let result = rt()
4650            .block_on(execute(&parsed.query, Arc::new(client)))
4651            .expect("query should execute");
4652        assert_eq!(result.rows.len(), 1);
4653        assert_eq!(
4654            result.rows[0].fields["name"],
4655            JsonValue::String("api-service".to_owned())
4656        );
4657    }
4658
4659    #[test]
4660    fn parses_compose_ls() {
4661        let parsed = parser::parse("compose ls").expect("query should parse");
4662        match parsed.query {
4663            Query::Compose(q) => {
4664                assert_eq!(q.target, ComposeTarget::Projects);
4665                assert!(q.project.is_empty());
4666            }
4667            _ => panic!("expected Compose"),
4668        }
4669    }
4670
4671    #[test]
4672    fn parses_compose_images() {
4673        let parsed = parser::parse("compose myapp images").expect("query should parse");
4674        match parsed.query {
4675            Query::Compose(q) => {
4676                assert_eq!(q.target, ComposeTarget::Images);
4677                assert_eq!(q.project, "myapp");
4678            }
4679            _ => panic!("expected Compose"),
4680        }
4681    }
4682
4683    #[test]
4684    fn parses_compose_stats() {
4685        let parsed = parser::parse("compose myapp stats").expect("query should parse");
4686        match parsed.query {
4687            Query::Compose(q) => {
4688                assert_eq!(q.target, ComposeTarget::Stats);
4689                assert_eq!(q.project, "myapp");
4690            }
4691            _ => panic!("expected Compose"),
4692        }
4693    }
4694
4695    #[test]
4696    fn parses_compose_ps() {
4697        let parsed = parser::parse("compose myapp ps").expect("query should parse");
4698        match parsed.query {
4699            Query::Compose(q) => {
4700                assert_eq!(q.target, ComposeTarget::Ps);
4701                assert_eq!(q.project, "myapp");
4702            }
4703            _ => panic!("expected Compose"),
4704        }
4705    }
4706
4707    #[test]
4708    fn parses_compose_events() {
4709        let parsed = parser::parse("compose myapp events").expect("query should parse");
4710        match parsed.query {
4711            Query::Compose(q) => {
4712                assert_eq!(q.target, ComposeTarget::Events);
4713                assert_eq!(q.project, "myapp");
4714            }
4715            _ => panic!("expected Compose"),
4716        }
4717    }
4718
4719    #[test]
4720    fn parses_compose_logs() {
4721        let parsed =
4722            parser::parse("compose myapp logs api-service tail 50").expect("query should parse");
4723        match parsed.query {
4724            Query::Compose(q) => {
4725                assert_eq!(q.target, ComposeTarget::Logs);
4726                assert_eq!(q.project, "myapp");
4727                assert_eq!(q.service.as_deref(), Some("api-service"));
4728                assert_eq!(q.tail, Some(50));
4729            }
4730            _ => panic!("expected Compose"),
4731        }
4732    }
4733
4734    #[test]
4735    fn parses_compose_logs_no_tail() {
4736        let parsed = parser::parse("compose myapp logs api-service").expect("query should parse");
4737        match parsed.query {
4738            Query::Compose(q) => {
4739                assert_eq!(q.target, ComposeTarget::Logs);
4740                assert_eq!(q.service.as_deref(), Some("api-service"));
4741                assert_eq!(q.tail, None);
4742            }
4743            _ => panic!("expected Compose"),
4744        }
4745    }
4746
4747    #[test]
4748    fn parses_compose_port() {
4749        let parsed =
4750            parser::parse("compose myapp port api-service 8080").expect("query should parse");
4751        match parsed.query {
4752            Query::Compose(q) => {
4753                assert_eq!(q.target, ComposeTarget::Port);
4754                assert_eq!(q.project, "myapp");
4755                assert_eq!(q.service.as_deref(), Some("api-service"));
4756                assert_eq!(q.port_number, Some(8080));
4757            }
4758            _ => panic!("expected Compose"),
4759        }
4760    }
4761
4762    #[test]
4763    fn parses_compose_config() {
4764        let parsed = parser::parse("compose myapp config services").expect("query should parse");
4765        match parsed.query {
4766            Query::Compose(q) => {
4767                assert_eq!(q.target, ComposeTarget::Config);
4768                assert_eq!(q.project, "myapp");
4769                assert_eq!(q.config_target, Some(ConfigTarget::Services));
4770            }
4771            _ => panic!("expected Compose"),
4772        }
4773    }
4774
4775    #[test]
4776    fn parses_compose_config_networks() {
4777        let parsed = parser::parse("compose myapp config networks").expect("query should parse");
4778        match parsed.query {
4779            Query::Compose(q) => {
4780                assert_eq!(q.target, ComposeTarget::Config);
4781                assert_eq!(q.config_target, Some(ConfigTarget::Networks));
4782            }
4783            _ => panic!("expected Compose"),
4784        }
4785    }
4786
4787    #[test]
4788    fn parses_compose_config_volumes() {
4789        let parsed = parser::parse("compose myapp config volumes").expect("query should parse");
4790        match parsed.query {
4791            Query::Compose(q) => {
4792                assert_eq!(q.target, ComposeTarget::Config);
4793                assert_eq!(q.config_target, Some(ConfigTarget::Volumes));
4794            }
4795            _ => panic!("expected Compose"),
4796        }
4797    }
4798
4799    #[test]
4800    fn parses_compose_config_all() {
4801        let parsed = parser::parse("compose myapp config").expect("query should parse");
4802        match parsed.query {
4803            Query::Compose(q) => {
4804                assert_eq!(q.target, ComposeTarget::Config);
4805                assert_eq!(q.config_target, Some(ConfigTarget::All));
4806            }
4807            _ => panic!("expected Compose"),
4808        }
4809    }
4810
4811    #[test]
4812    fn parses_observe_compose_images() {
4813        let parsed = parser::parse("observe compose myapp images").expect("query should parse");
4814        match parsed.query {
4815            Query::Compose(q) => {
4816                assert_eq!(q.target, ComposeTarget::Images);
4817                assert_eq!(q.project, "myapp");
4818            }
4819            _ => panic!("expected Compose"),
4820        }
4821    }
4822
4823    #[test]
4824    fn parses_observe_compose_stats() {
4825        let parsed = parser::parse("observe compose myapp stats").expect("query should parse");
4826        match parsed.query {
4827            Query::Compose(q) => {
4828                assert_eq!(q.target, ComposeTarget::Stats);
4829            }
4830            _ => panic!("expected Compose"),
4831        }
4832    }
4833
4834    #[test]
4835    fn semantic_compose_projects_valid() {
4836        let parsed =
4837            parser::parse("compose ls | where containers > 1").expect("query should parse");
4838        let res = crate::semantic::validate_semantics(&parsed.query);
4839        assert!(res.is_ok());
4840    }
4841
4842    #[test]
4843    fn semantic_compose_projects_select_valid() {
4844        let parsed =
4845            parser::parse("compose ls | select project, containers").expect("query should parse");
4846        let res = crate::semantic::validate_semantics(&parsed.query);
4847        assert!(res.is_ok());
4848    }
4849
4850    #[test]
4851    fn semantic_compose_projects_invalid_field() {
4852        let parsed = parser::parse("compose ls | where cpu > 80%").expect("query should parse");
4853        let res = crate::semantic::validate_semantics(&parsed.query);
4854        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
4855    }
4856
4857    #[test]
4858    fn semantic_compose_images_valid() {
4859        let parsed = parser::parse("compose myapp images | where service = \"api\"")
4860            .expect("query should parse");
4861        let res = crate::semantic::validate_semantics(&parsed.query);
4862        assert!(res.is_ok());
4863    }
4864
4865    #[test]
4866    fn semantic_compose_images_select() {
4867        let parsed = parser::parse("compose myapp images | select name, tag, size")
4868            .expect("query should parse");
4869        let res = crate::semantic::validate_semantics(&parsed.query);
4870        assert!(res.is_ok());
4871    }
4872
4873    #[test]
4874    fn semantic_compose_stats_valid() {
4875        let parsed =
4876            parser::parse("compose myapp stats | where cpu > 80%").expect("query should parse");
4877        let res = crate::semantic::validate_semantics(&parsed.query);
4878        assert!(res.is_ok());
4879    }
4880
4881    #[test]
4882    fn semantic_compose_logs_valid() {
4883        let parsed =
4884            parser::parse("compose myapp logs api-service | where message contains \"error\"")
4885                .expect("query should parse");
4886        let res = crate::semantic::validate_semantics(&parsed.query);
4887        assert!(res.is_ok());
4888    }
4889
4890    #[test]
4891    fn semantic_compose_logs_select() {
4892        let parsed = parser::parse("compose myapp logs api-service | select line, message")
4893            .expect("query should parse");
4894        let res = crate::semantic::validate_semantics(&parsed.query);
4895        assert!(res.is_ok());
4896    }
4897
4898    #[test]
4899    fn semantic_compose_config_valid() {
4900        let parsed =
4901            parser::parse("compose myapp config | select name, image").expect("query should parse");
4902        let res = crate::semantic::validate_semantics(&parsed.query);
4903        assert!(res.is_ok());
4904    }
4905
4906    #[test]
4907    fn semantic_compose_port_valid() {
4908        let parsed = parser::parse("compose myapp port web 80 | select service")
4909            .expect("query should parse");
4910        let res = crate::semantic::validate_semantics(&parsed.query);
4911        assert!(res.is_ok());
4912    }
4913
4914    #[test]
4915    fn compose_events_returns_empty_when_no_events() {
4916        let client = compose_mock_client();
4917        let parsed = parser::parse("compose myapp events").expect("query should parse");
4918        let result = rt().block_on(execute(&parsed.query, Arc::new(client)));
4919        let result = result.expect("compose events should succeed");
4920        assert!(result.rows.is_empty(), "no events expected on mock client");
4921    }
4922
4923    #[test]
4924    fn compose_events_filters_by_project_and_pipeline() {
4925        let client = MockDockerClient {
4926            events: vec![
4927                // Matching event A — compose myapp, start action (should be filtered out by pipeline)
4928                DockerEvent {
4929                    time: "2026-06-01T10:00:00Z".to_owned(),
4930                    event_type: "container".to_owned(),
4931                    action: "start".to_owned(),
4932                    actor_id: "abc".to_owned(),
4933                    container: Some("api".to_owned()),
4934                    image: Some("api:latest".to_owned()),
4935                    attributes: vec![
4936                        ("com.docker.compose.project".to_owned(), "myapp".to_owned()),
4937                        ("com.docker.compose.service".to_owned(), "api".to_owned()),
4938                    ],
4939                },
4940                // Matching event B — compose myapp, die action (should pass)
4941                DockerEvent {
4942                    time: "2026-06-01T10:01:00Z".to_owned(),
4943                    event_type: "container".to_owned(),
4944                    action: "die".to_owned(),
4945                    actor_id: "abc".to_owned(),
4946                    container: Some("api".to_owned()),
4947                    image: Some("api:latest".to_owned()),
4948                    attributes: vec![("com.docker.compose.project".to_owned(), "myapp".to_owned())],
4949                },
4950                // Non-matching — different compose project
4951                DockerEvent {
4952                    time: "2026-06-01T10:02:00Z".to_owned(),
4953                    event_type: "container".to_owned(),
4954                    action: "die".to_owned(),
4955                    actor_id: "xyz".to_owned(),
4956                    container: Some("other".to_owned()),
4957                    image: Some("other:latest".to_owned()),
4958                    attributes: vec![(
4959                        "com.docker.compose.project".to_owned(),
4960                        "other-app".to_owned(),
4961                    )],
4962                },
4963                // Non-matching — no compose project at all
4964                DockerEvent {
4965                    time: "2026-06-01T10:03:00Z".to_owned(),
4966                    event_type: "container".to_owned(),
4967                    action: "kill".to_owned(),
4968                    actor_id: "def".to_owned(),
4969                    container: Some("standalone".to_owned()),
4970                    image: Some("standalone:latest".to_owned()),
4971                    attributes: Vec::new(),
4972                },
4973            ],
4974            ..Default::default()
4975        };
4976        let parsed = parser::parse(
4977            "compose myapp events | where action = \"die\" | select time, container, action",
4978        )
4979        .expect("query should parse");
4980        let result = rt()
4981            .block_on(execute(&parsed.query, Arc::new(client)))
4982            .expect("compose events should succeed");
4983
4984        // Only the matching myapp 'die' event should pass both the
4985        // compose project filter AND the pipeline where filter
4986        assert_eq!(result.rows.len(), 1);
4987        assert_eq!(result.rows[0].fields["container"], serde_json::json!("api"));
4988        assert_eq!(result.rows[0].fields["action"], serde_json::json!("die"));
4989    }
4990
4991    #[test]
4992    fn compose_ls_empty_projects() {
4993        let client = MockDockerClient::default();
4994        let parsed = parser::parse("compose ls").expect("query should parse");
4995        let result = rt()
4996            .block_on(execute(&parsed.query, Arc::new(client)))
4997            .expect("query should execute");
4998        assert_eq!(result.rows.len(), 0);
4999    }
5000
5001    #[test]
5002    fn compose_port_no_matching_container() {
5003        let client = compose_mock_client();
5004        let parsed =
5005            parser::parse("compose myapp port nonexistent 9999").expect("query should parse");
5006        let result = rt()
5007            .block_on(execute(&parsed.query, Arc::new(client)))
5008            .expect("query should execute");
5009        assert_eq!(result.rows.len(), 0);
5010    }
5011    #[test]
5012    fn test_row_number() {
5013        let client = mock_client();
5014        let parsed = parser::parse(r#"observe containers | row_number as rn | select name, rn"#)
5015            .expect("query should parse");
5016        let result = rt()
5017            .block_on(execute(&parsed.query, Arc::new(client)))
5018            .expect("query should execute");
5019        assert_eq!(result.rows.len(), 2);
5020        assert_eq!(
5021            result.rows[0].fields["rn"],
5022            JsonValue::Number(Number::from(1))
5023        );
5024        assert_eq!(
5025            result.rows[1].fields["rn"],
5026            JsonValue::Number(Number::from(2))
5027        );
5028    }
5029
5030    #[test]
5031    fn test_rank_field() {
5032        let client = mock_client();
5033        let parsed =
5034            parser::parse(r#"observe containers | rank(state) as rank | select name, state, rank"#)
5035                .expect("query should parse");
5036        let result = rt()
5037            .block_on(execute(&parsed.query, Arc::new(client)))
5038            .expect("query should execute");
5039        assert_eq!(result.rows.len(), 2);
5040        let exited_rank = result
5041            .rows
5042            .iter()
5043            .find(|r| r.fields.get("state") == Some(&JsonValue::String("exited".to_owned())))
5044            .and_then(|r| r.fields.get("rank"))
5045            .cloned();
5046        let running_rank = result
5047            .rows
5048            .iter()
5049            .find(|r| r.fields.get("state") == Some(&JsonValue::String("running".to_owned())))
5050            .and_then(|r| r.fields.get("rank"))
5051            .cloned();
5052        assert_eq!(exited_rank, Some(JsonValue::Number(Number::from(1))));
5053        assert_eq!(running_rank, Some(JsonValue::Number(Number::from(2))));
5054    }
5055
5056    #[test]
5057    fn test_lag_field() {
5058        let client = mock_client();
5059        let parsed = parser::parse(
5060            r#"observe containers | sort by name | lag(name, 1) as prev_name | select name, prev_name"#,
5061        )
5062        .expect("query should parse");
5063        let result = rt()
5064            .block_on(execute(&parsed.query, Arc::new(client)))
5065            .expect("query should execute");
5066        assert_eq!(result.rows.len(), 2);
5067        assert_eq!(result.rows[0].fields["prev_name"], JsonValue::Null);
5068        let first_name = result.rows[0].fields["name"].clone();
5069        assert_eq!(result.rows[1].fields["prev_name"], first_name);
5070    }
5071
5072    #[test]
5073    fn test_lead_field() {
5074        let client = mock_client();
5075        let parsed = parser::parse(
5076            r#"observe containers | sort by name | lead(name, 1) as next_name | select name, next_name"#,
5077        )
5078        .expect("query should parse");
5079        let result = rt()
5080            .block_on(execute(&parsed.query, Arc::new(client)))
5081            .expect("query should execute");
5082        assert_eq!(result.rows.len(), 2);
5083        let second_name = result.rows[1].fields["name"].clone();
5084        assert_eq!(result.rows[0].fields["next_name"], second_name);
5085        assert_eq!(result.rows[1].fields["next_name"], JsonValue::Null);
5086    }
5087
5088    #[test]
5089    fn filter_display_columns_hides_all_null_columns() {
5090        // Columns: name (non-empty), state (non-empty), meta (all Null),
5091        // tags (empty arrays), count (numbers), empty_str (empty strings),
5092        // obj has one non-empty row.
5093        let rows = vec![
5094            Row::from_fields([
5095                ("name", json_string("api")),
5096                ("state", json_string("running")),
5097                ("meta", JsonValue::Null),
5098                ("tags", JsonValue::Array(vec![])),
5099                ("count", JsonValue::Number(Number::from(3))),
5100                ("empty_str", JsonValue::String(String::new())),
5101                ("obj", serde_json::json!({"x": true})),
5102            ]),
5103            Row::from_fields([
5104                ("name", json_string("worker")),
5105                ("state", json_string("exited")),
5106                ("meta", JsonValue::Null),
5107                ("tags", JsonValue::Array(vec![])),
5108                ("count", JsonValue::Number(Number::from(1))),
5109                ("empty_str", JsonValue::String(String::new())),
5110                ("obj", serde_json::json!({})),
5111            ]),
5112        ];
5113        let result = ExecutionResult { rows };
5114        let columns = filter_display_columns(&result);
5115
5116        assert!(columns.contains(&"name".to_owned()), "name should be kept");
5117        assert!(
5118            columns.contains(&"state".to_owned()),
5119            "state should be kept"
5120        );
5121        assert!(
5122            columns.contains(&"count".to_owned()),
5123            "count (number) should be kept"
5124        );
5125        assert!(
5126            columns.contains(&"obj".to_owned()),
5127            "obj (one row non-empty) should be kept"
5128        );
5129
5130        assert!(
5131            !columns.contains(&"meta".to_owned()),
5132            "meta (all Null) should be hidden"
5133        );
5134        assert!(
5135            !columns.contains(&"tags".to_owned()),
5136            "tags (all empty arrays) should be hidden"
5137        );
5138        assert!(
5139            !columns.contains(&"empty_str".to_owned()),
5140            "empty_str (all empty strings) should be hidden"
5141        );
5142    }
5143
5144    #[test]
5145    fn observe_containers_tree_shakes_empty_columns() {
5146        let client = MockDockerClient {
5147            containers: vec![
5148                Container {
5149                    id: "c1".to_owned(),
5150                    name: "alpha".to_owned(),
5151                    image: "nginx:latest".to_owned(),
5152                    status: "Up 1 hour".to_owned(),
5153                    state: "running".to_owned(),
5154                    ports: vec!["80/tcp".to_owned()],
5155                    labels: vec![],
5156                    created_at: Some("2026-01-01".to_owned()),
5157                    started_at: None,
5158                    finished_at: None,
5159                    restart_count: None,
5160                    health: None,
5161                },
5162                Container {
5163                    id: "c2".to_owned(),
5164                    name: "beta".to_owned(),
5165                    image: "redis:7".to_owned(),
5166                    status: "Exited 0".to_owned(),
5167                    state: "exited".to_owned(),
5168                    ports: vec![],
5169                    labels: vec![],
5170                    created_at: None,
5171                    started_at: None,
5172                    finished_at: None,
5173                    restart_count: None,
5174                    health: None,
5175                },
5176            ],
5177            ..Default::default()
5178        };
5179        let parsed = parser::parse("observe containers").expect("query should parse");
5180
5181        let result = rt()
5182            .block_on(execute(&parsed.query, Arc::new(client)))
5183            .expect("query should execute");
5184
5185        let columns = filter_display_columns(&result);
5186
5187        assert!(columns.contains(&"id".to_owned()), "id should be kept");
5188        assert!(columns.contains(&"name".to_owned()), "name should be kept");
5189        assert!(
5190            columns.contains(&"image".to_owned()),
5191            "image should be kept"
5192        );
5193        assert!(
5194            columns.contains(&"status".to_owned()),
5195            "status should be kept"
5196        );
5197        assert!(
5198            columns.contains(&"state".to_owned()),
5199            "state should be kept"
5200        );
5201        assert!(
5202            columns.contains(&"ports".to_owned()),
5203            "ports should be kept"
5204        );
5205        assert!(
5206            !columns.contains(&"labels".to_owned()),
5207            "labels (all empty arrays) should be hidden"
5208        );
5209        assert!(
5210            columns.contains(&"created_at".to_owned()),
5211            "created_at has one value, should be kept"
5212        );
5213
5214        assert!(
5215            !columns.contains(&"started_at".to_owned()),
5216            "started_at (all Null) should be hidden"
5217        );
5218        assert!(
5219            !columns.contains(&"finished_at".to_owned()),
5220            "finished_at (all Null) should be hidden"
5221        );
5222        assert!(
5223            !columns.contains(&"restart_count".to_owned()),
5224            "restart_count (all Null) should be hidden"
5225        );
5226        assert!(
5227            !columns.contains(&"health".to_owned()),
5228            "health (all Null) should be hidden"
5229        );
5230        assert!(
5231            !columns.contains(&"cpu".to_owned()),
5232            "cpu (all Null, no metrics) should be hidden"
5233        );
5234        assert!(
5235            !columns.contains(&"memory".to_owned()),
5236            "memory (all Null, no metrics) should be hidden"
5237        );
5238
5239        let rendered = render_table_colored(&result);
5240        assert!(
5241            rendered.contains("alpha"),
5242            "rendered output should include alpha"
5243        );
5244        assert!(
5245            rendered.contains("beta"),
5246            "rendered output should include beta"
5247        );
5248        assert!(
5249            !rendered.contains("finished_at"),
5250            "rendered output should not contain finished_at"
5251        );
5252        assert!(
5253            !rendered.contains("started_at"),
5254            "rendered output should not contain started_at"
5255        );
5256    }
5257
5258    #[test]
5259    fn render_table_colored_tree_shakes_empty_columns() {
5260        let result = ExecutionResult {
5261            rows: vec![
5262                Row::from_fields([
5263                    ("name", json_string("alpha")),
5264                    ("state", json_string("running")),
5265                    ("finished_at", JsonValue::Null),
5266                ]),
5267                Row::from_fields([
5268                    ("name", json_string("beta")),
5269                    ("state", json_string("exited")),
5270                    ("finished_at", JsonValue::Null),
5271                ]),
5272            ],
5273        };
5274
5275        // Colored path should hide all-Null columns
5276        let output = render_table_colored(&result);
5277
5278        // Kept columns should appear in output
5279        assert!(
5280            output.contains("alpha"),
5281            "alpha should be in colored output"
5282        );
5283        assert!(output.contains("beta"), "beta should be in colored output");
5284
5285        // Hidden column names should NOT appear
5286        assert!(
5287            !output.contains("finished_at"),
5288            "finished_at should be hidden in colored output"
5289        );
5290    }
5291
5292    #[test]
5293    fn render_table_ratatui_tree_shakes_empty_columns() {
5294        let result = ExecutionResult {
5295            rows: vec![
5296                Row::from_fields([
5297                    ("name", json_string("alpha")),
5298                    ("state", json_string("running")),
5299                    ("finished_at", JsonValue::Null),
5300                ]),
5301                Row::from_fields([
5302                    ("name", json_string("beta")),
5303                    ("state", json_string("exited")),
5304                    ("finished_at", JsonValue::Null),
5305                ]),
5306            ],
5307        };
5308
5309        // Ratatui path should also hide all-Null columns
5310        let output = render_table_ratatui(&result);
5311
5312        // Kept columns should appear as UPPERCASE headers in ratatui output
5313        assert!(
5314            output.contains("alpha"),
5315            "alpha should be in ratatui output"
5316        );
5317        assert!(output.contains("beta"), "beta should be in ratatui output");
5318
5319        // Hidden column names (uppercased in ratatui) should NOT appear
5320        assert!(
5321            !output.contains("finished_at"),
5322            "finished_at should be hidden in ratatui output"
5323        );
5324    }
5325}