1use serde_json::{Number, Value as JsonValue};
18use std::collections::{BTreeMap, HashMap, HashSet};
19use thiserror::Error;
20
21#[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
128const 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(×tamp)
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
424fn 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, })
438 })
439 .cloned()
440 .collect()
441}
442
443fn 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
456fn 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
471fn 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 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#[must_use]
505pub fn render_table_ratatui(result: &ExecutionResult) -> String {
506 render_table_ratatui_with_theme(result, Theme::Dark)
507}
508
509#[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 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 for w in &mut col_widths {
539 *w = (*w + 2).clamp(6, MAX_COL_WIDTH + 2);
540 }
541
542 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 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 + spacing_total;
579
580 let term_width = crossterm::terminal::size().map_or(120, |(w, _)| w);
582
583 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 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(), 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 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 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 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 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
710fn 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 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 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
760fn style_to_ansi(style: Style) -> String {
762 let mut codes = String::new();
763
764 if style != Style::default() {
766 codes.push_str(ANSI_RESET);
767 }
768
769 if style.add_modifier.contains(Modifier::BOLD) {
771 codes.push_str(ANSI_BOLD);
772 }
773 if style.add_modifier.contains(Modifier::DIM) {
775 codes.push_str(ANSI_DIM);
776 }
777 if style.add_modifier.contains(Modifier::ITALIC) {
779 codes.push_str(ANSI_ITALIC);
780 }
781 if style.add_modifier.contains(Modifier::UNDERLINED) {
783 codes.push_str(ANSI_UNDERLINE);
784 }
785
786 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 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
937async 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 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 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 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 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 writer
2108 .write_record(&columns)
2109 .expect("csv header write should succeed");
2110
2111 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
2154fn ansi_threshold_color(value_str: &str) -> &'static str {
2157 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 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 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; widths[si] += bw + 1; #[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 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 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 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 lines.push(format!("{title_color_code} {entity} {ANSI_RESET}"));
2284
2285 lines.push(format!(
2287 "{header_color_code}{}{ANSI_RESET}",
2288 render_table_line(&columns.clone(), &widths)
2289 ));
2290
2291 lines.push(
2293 widths
2294 .iter()
2295 .map(|width| format!("{ANSI_DIM}{}{ANSI_RESET}", "─".repeat(*width)))
2296 .collect::<Vec<_>>()
2297 .join(" "),
2298 );
2299
2300 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 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 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 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 groups
2529 .into_values()
2530 .map(|acc| {
2531 let mut row = acc.row;
2532 if aggregates.is_empty() {
2533 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 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 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()], 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 #[test]
3900 fn if_without_else_passes_through() {
3901 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 assert_eq!(result.rows.len(), 2);
3914 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 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 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 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 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 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 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 #[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 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 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 assert!(table.contains("\x1b[32m25"));
4162 assert!(table.contains("\x1b[33m65"));
4164 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)), ]),
4176 Row::from_fields([
4177 ("name", JsonValue::String("medium".to_owned())),
4178 ("memory", json_u64(805_306_368)), ]),
4180 Row::from_fields([
4181 ("name", JsonValue::String("large".to_owned())),
4182 ("memory", json_u64(2_147_483_648)), ]),
4184 ],
4185 };
4186 let table = render_table_colored(&result);
4187 assert!(table.contains("\x1b[32m268435456"));
4189 assert!(table.contains("\x1b[33m805306368"));
4191 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 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 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 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 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 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 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 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 let output = render_table_colored(&result);
5277
5278 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 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 let output = render_table_ratatui(&result);
5311
5312 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 assert!(
5321 !output.contains("finished_at"),
5322 "finished_at should be hidden in ratatui output"
5323 );
5324 }
5325}