Skip to main content

dol/
events.rs

1//! Docker event streaming and collection.
2//!
3//! Provides [`EventSource`] trait, [`BollardEventSource`] (live Docker
4//! events), and [`MockEventSource`] (testing). The [`stream_events`]
5//! function processes events through a DOL pipeline in real-time, while
6//! [`collect_events`] returns a batch result.
7//!
8//! # Example
9//!
10//! ```ignore
11//! let source = BollardEventSource::new(Arc::new(docker));
12//! events::stream_events(query, &source, |row| { println!("{row:?}"); Ok(()) }).await?;
13//! ```
14
15use std::{collections::BTreeMap, pin::Pin, sync::Arc};
16
17use futures_util::{Stream, StreamExt};
18use serde_json::{Number, Value as JsonValue};
19use thiserror::Error;
20
21use crate::{
22    ast::{CollectionTarget, ComposeQuery, EventsQuery, LogsQuery, PipelineNode},
23    docker::{DockerClient, DockerError, DockerEvent},
24    eval::{self, EvalError},
25    executor::{ExecutionResult, Row},
26    json_string,
27};
28
29/// Shared type alias for the complex event stream return type.
30pub type EventStream = Pin<Box<dyn Stream<Item = Result<DockerEvent, EventsError>> + Send>>;
31
32/// Shared type alias for the log stream return type (mapped to EventsError).
33pub type LogStream = Pin<Box<dyn Stream<Item = Result<String, EventsError>> + Send>>;
34
35/// Shared type alias for the row-based stream return type (mapped to EventsError).
36pub type RowStream = Pin<Box<dyn Stream<Item = Result<Row, EventsError>> + Send>>;
37
38pub trait EventSource {
39    fn events_stream(
40        &self,
41    ) -> impl std::future::Future<Output = Result<EventStream, EventsError>> + Send
42    where
43        Self: Sync;
44}
45
46#[derive(Debug, Error)]
47pub enum EventsError {
48    #[error("{0}")]
49    Docker(#[from] DockerError),
50    #[error("failed to parse docker event JSON: {0}")]
51    Json(serde_json::Error),
52    #[error("unsupported event target: {0:?}")]
53    UnsupportedTarget(CollectionTarget),
54    #[error("unsupported event pipeline node: {0}")]
55    UnsupportedPipeline(&'static str),
56    #[error("{0}")]
57    Eval(#[from] EvalError),
58    #[error("assertion failed: {0}")]
59    AssertionFailed(String),
60}
61
62// ── BollardEventSource ──────────────────────────────────────────────────────
63
64/// An event source that uses a `DockerClient` (bollard) to stream Docker events.
65pub struct BollardEventSource<C> {
66    docker: std::sync::Arc<C>,
67}
68
69impl<C> BollardEventSource<C>
70where
71    C: DockerClient + Send + Sync + 'static,
72{
73    pub const fn new(docker: std::sync::Arc<C>) -> Self {
74        Self { docker }
75    }
76}
77
78impl<C> EventSource for BollardEventSource<C>
79where
80    C: DockerClient + Send + Sync + 'static,
81{
82    async fn events_stream(
83        &self,
84    ) -> Result<Pin<Box<dyn Stream<Item = Result<DockerEvent, EventsError>> + Send>>, EventsError>
85    {
86        let stream = self.docker.events_stream(None, None).await?;
87        let mapped = stream.map(|result| result.map_err(EventsError::Docker));
88        Ok(Box::pin(mapped))
89    }
90}
91
92// ── LogSource trait + implementations ──────────────────────────────────────
93
94pub trait LogSource {
95    fn logs_stream(
96        &self,
97        container_id: &str,
98        tail: usize,
99    ) -> impl std::future::Future<Output = Result<LogStream, EventsError>> + Send
100    where
101        Self: Sync;
102}
103
104/// A log source that uses a `DockerClient` (bollard) to stream Docker container logs.
105pub struct BollardLogSource<C> {
106    docker: std::sync::Arc<C>,
107}
108
109impl<C> BollardLogSource<C>
110where
111    C: DockerClient + Send + Sync + 'static,
112{
113    pub const fn new(docker: std::sync::Arc<C>) -> Self {
114        Self { docker }
115    }
116}
117
118impl<C> LogSource for BollardLogSource<C>
119where
120    C: DockerClient + Send + Sync + 'static,
121{
122    async fn logs_stream(&self, container_id: &str, tail: usize) -> Result<LogStream, EventsError> {
123        let stream = self
124            .docker
125            .container_logs_stream(container_id, tail)
126            .await?;
127        let mapped = stream.map(|result| result.map_err(EventsError::Docker));
128        Ok(Box::pin(mapped))
129    }
130}
131
132// ── MockLogSource ───────────────────────────────────────────────────────────
133
134#[derive(Debug, Clone, Default)]
135pub struct MockLogSource {
136    pub lines: Vec<String>,
137}
138
139impl LogSource for MockLogSource {
140    async fn logs_stream(
141        &self,
142        _container_id: &str,
143        _tail: usize,
144    ) -> Result<LogStream, EventsError> {
145        let items: Vec<Result<String, EventsError>> =
146            self.lines.clone().into_iter().map(Ok).collect();
147        Ok(Box::pin(futures_util::stream::iter(items)))
148    }
149}
150
151// ── MockEventSource ─────────────────────────────────────────────────────────
152
153#[derive(Debug, Clone, Default)]
154pub struct MockEventSource {
155    pub events: Vec<DockerEvent>,
156}
157
158impl EventSource for MockEventSource {
159    async fn events_stream(
160        &self,
161    ) -> Result<Pin<Box<dyn Stream<Item = Result<DockerEvent, EventsError>> + Send>>, EventsError>
162    {
163        let items: Vec<Result<DockerEvent, EventsError>> =
164            self.events.clone().into_iter().map(Ok).collect();
165        Ok(Box::pin(futures_util::stream::iter(items)))
166    }
167}
168
169/// Convert a log message line into a [`Row`] with `line`, `message`, and `container` fields.
170#[must_use]
171pub fn log_row(container: &str, line_num: u64, message: String) -> Row {
172    Row {
173        fields: BTreeMap::from([
174            ("line".to_owned(), JsonValue::Number(Number::from(line_num))),
175            ("message".to_owned(), JsonValue::String(message)),
176            ("container".to_owned(), json_string(container.to_owned())),
177        ]),
178    }
179}
180
181/// Collect log lines into an [`ExecutionResult`] (batch mode).
182pub async fn collect_logs<S>(query: &LogsQuery, source: &S) -> Result<ExecutionResult, EventsError>
183where
184    S: LogSource + ?Sized + Sync,
185{
186    let tail = query.tail.unwrap_or(100) as usize;
187    let limit = query.pipeline.iter().find_map(|node| match node {
188        PipelineNode::Limit(limit) => Some(*limit),
189        _ => None,
190    });
191
192    let mut rows = Vec::new();
193    let mut line_num = 0u64;
194    let mut stream = source.logs_stream(&query.container, tail).await?;
195    while let Some(line) = stream.next().await {
196        line_num += 1;
197        let mut row = Some(log_row(&query.container, line_num, line?));
198
199        if let Some(filter) = &query.filter
200            && !eval::evaluate_expression(&row.as_ref().expect("row is present").fields, filter)?
201        {
202            continue;
203        }
204
205        let mut keep = true;
206        for node in &query.pipeline {
207            let current = row.take().expect("row is present while pipeline runs");
208            match apply_pipeline_node(current, node)? {
209                PipelineOutcome::Row(next_row) => row = Some(next_row),
210                PipelineOutcome::Drop => {
211                    keep = false;
212                    break;
213                }
214                PipelineOutcome::LimitReached => {
215                    return Ok(ExecutionResult { rows });
216                }
217            }
218        }
219
220        if keep {
221            rows.push(row.expect("row is present after pipeline"));
222            if limit.is_some_and(|limit| rows.len() as u64 >= limit) {
223                break;
224            }
225        }
226    }
227
228    Ok(ExecutionResult { rows })
229}
230
231/// Stream compose service logs in real-time. Finds containers matching the compose
232/// project + service, merges their log streams via [`futures_util::stream::select_all`],
233/// and applies the compose query's pipeline to each row.
234pub async fn stream_compose_logs<C, F>(
235    query: &ComposeQuery,
236    docker: Arc<C>,
237    mut on_row: F,
238) -> Result<(), EventsError>
239where
240    C: DockerClient + Send + Sync + 'static,
241    F: FnMut(Row) -> Result<(), EventsError>,
242{
243    let service_name = query.service.as_deref().unwrap_or("");
244    let tail = query.tail.unwrap_or(100) as usize;
245
246    let containers = docker.list_containers().await?;
247    let matching: Vec<_> = containers
248        .into_iter()
249        .filter(|c| {
250            let has_project = c.labels.iter().any(|label| {
251                let parts: Vec<&str> = label.splitn(2, '=').collect();
252                parts.len() == 2
253                    && parts[0] == "com.docker.compose.project"
254                    && parts[1] == query.project
255            });
256            let has_service = c.labels.iter().any(|label| {
257                let parts: Vec<&str> = label.splitn(2, '=').collect();
258                parts.len() == 2
259                    && parts[0] == "com.docker.compose.service"
260                    && parts[1] == service_name
261            });
262            has_project && (service_name.is_empty() || has_service)
263        })
264        .collect();
265
266    if matching.is_empty() {
267        return Ok(());
268    }
269
270    // Create a log stream for each matching container, tagged with container + service
271    let mut streams: Vec<RowStream> = Vec::new();
272    for container in &matching {
273        let raw = docker.container_logs_stream(&container.id, tail).await?;
274        let container_name = container.name.clone();
275        let svc = service_name.to_owned();
276        let mapped = raw.map(move |line_result| match line_result {
277            Ok(line) => Ok(Row {
278                fields: BTreeMap::from([
279                    ("message".to_owned(), JsonValue::String(line)),
280                    ("container".to_owned(), json_string(container_name.clone())),
281                    ("service".to_owned(), json_string(svc.clone())),
282                ]),
283            }),
284            Err(e) => Err(EventsError::Docker(e)),
285        });
286        streams.push(Box::pin(mapped));
287    }
288
289    // Merge all container streams
290    let mut merged = futures_util::stream::select_all(streams);
291
292    let limit = query.pipeline.iter().find_map(|node| match node {
293        PipelineNode::Limit(limit) => Some(*limit),
294        _ => None,
295    });
296    let mut emitted = 0u64;
297    let mut line_num = 0u64;
298
299    while let Some(item) = merged.next().await {
300        let mut row = Some(item?);
301        line_num += 1;
302        // Add line number to the row
303        if let Some(ref mut r) = row {
304            r.fields
305                .insert("line".to_owned(), JsonValue::Number(Number::from(line_num)));
306        }
307
308        // Apply the compose query's pipeline
309        let mut keep = true;
310        for node in &query.pipeline {
311            let current = row.take().expect("row is present while pipeline runs");
312            match apply_pipeline_node(current, node)? {
313                PipelineOutcome::Row(next_row) => row = Some(next_row),
314                PipelineOutcome::Drop => {
315                    keep = false;
316                    break;
317                }
318                PipelineOutcome::LimitReached => return Ok(()),
319            }
320        }
321
322        if keep {
323            on_row(row.expect("row is present after pipeline"))?;
324            emitted += 1;
325            if limit.is_some_and(|limit| emitted >= limit) {
326                return Ok(());
327            }
328        }
329    }
330
331    Ok(())
332}
333
334/// Stream Docker network events for a compose project in real-time.
335/// Streams Docker events, filters by `event_type == "network"` and the compose
336/// project label, and applies the compose query's pipeline to each row.
337pub async fn stream_compose_networks<C, F>(
338    query: &ComposeQuery,
339    docker: Arc<C>,
340    mut on_row: F,
341) -> Result<(), EventsError>
342where
343    C: DockerClient + Send + Sync + 'static,
344    F: FnMut(Row) -> Result<(), EventsError>,
345{
346    let label = format!("com.docker.compose.project={}", query.project);
347    let pipeline = &query.pipeline;
348
349    let limit = pipeline.iter().find_map(|node| match node {
350        PipelineNode::Limit(limit) => Some(*limit),
351        _ => None,
352    });
353
354    let mut stream = docker.events_stream(None, None).await?;
355    let mut emitted = 0u64;
356
357    while let Some(item) = stream.next().await {
358        let event = item?;
359
360        // Only network events
361        if event.event_type != "network" {
362            continue;
363        }
364
365        // Filter by compose project label
366        let has_project = event
367            .attributes
368            .iter()
369            .any(|(key, value)| format!("{key}={value}").contains(&label));
370        if !has_project {
371            continue;
372        }
373
374        let mut row = Some(event_row(event));
375
376        let mut keep = true;
377        for node in pipeline {
378            let current = row.take().expect("row is present while pipeline runs");
379            match apply_pipeline_node(current, node)? {
380                PipelineOutcome::Row(next_row) => row = Some(next_row),
381                PipelineOutcome::Drop => {
382                    keep = false;
383                    break;
384                }
385                PipelineOutcome::LimitReached => return Ok(()),
386            }
387        }
388
389        if keep {
390            on_row(row.expect("row is present after pipeline"))?;
391            emitted += 1;
392            if limit.is_some_and(|limit| emitted >= limit) {
393                return Ok(());
394            }
395        }
396    }
397
398    Ok(())
399}
400
401/// Stream log lines in real-time, applying the query pipeline to each row.
402pub async fn stream_logs<S, F>(
403    query: &LogsQuery,
404    source: &S,
405    mut on_row: F,
406) -> Result<(), EventsError>
407where
408    S: LogSource + ?Sized + Sync,
409    F: FnMut(Row) -> Result<(), EventsError>,
410{
411    let tail = query.tail.unwrap_or(100) as usize;
412    let limit = query.pipeline.iter().find_map(|node| match node {
413        PipelineNode::Limit(limit) => Some(*limit),
414        _ => None,
415    });
416
417    let mut emitted = 0u64;
418    let mut line_num = 0u64;
419    let mut stream = source.logs_stream(&query.container, tail).await?;
420    while let Some(line) = stream.next().await {
421        line_num += 1;
422        let mut row = Some(log_row(&query.container, line_num, line?));
423
424        if let Some(filter) = &query.filter
425            && !eval::evaluate_expression(&row.as_ref().expect("row is present").fields, filter)?
426        {
427            continue;
428        }
429
430        let mut keep = true;
431        for node in &query.pipeline {
432            let current = row.take().expect("row is present while pipeline runs");
433            match apply_pipeline_node(current, node)? {
434                PipelineOutcome::Row(next_row) => row = Some(next_row),
435                PipelineOutcome::Drop => {
436                    keep = false;
437                    break;
438                }
439                PipelineOutcome::LimitReached => return Ok(()),
440            }
441        }
442
443        if keep {
444            on_row(row.expect("row is present after pipeline"))?;
445            emitted += 1;
446            if limit.is_some_and(|limit| emitted >= limit) {
447                return Ok(());
448            }
449        }
450    }
451
452    Ok(())
453}
454
455pub async fn collect_events<S>(
456    query: &EventsQuery,
457    source: &S,
458) -> Result<ExecutionResult, EventsError>
459where
460    S: EventSource + ?Sized + Sync,
461{
462    ensure_supported_target(query.target)?;
463
464    let mut rows = Vec::new();
465    let limit = query.pipeline.iter().find_map(|node| match node {
466        PipelineNode::Limit(limit) => Some(*limit),
467        _ => None,
468    });
469
470    let mut stream = source.events_stream().await?;
471    while let Some(event) = stream.next().await {
472        let mut row = Some(event_row(event?));
473
474        if let Some(filter) = &query.filter
475            && !eval::evaluate_expression(&row.as_ref().expect("row is present").fields, filter)?
476        {
477            continue;
478        }
479
480        let mut keep = true;
481        for node in &query.pipeline {
482            let current = row.take().expect("row is present while pipeline runs");
483            match apply_pipeline_node(current, node)? {
484                PipelineOutcome::Row(next_row) => row = Some(next_row),
485                PipelineOutcome::Drop => {
486                    keep = false;
487                    break;
488                }
489                PipelineOutcome::LimitReached => {
490                    return Ok(ExecutionResult { rows });
491                }
492            }
493        }
494
495        if keep {
496            rows.push(row.expect("row is present after pipeline"));
497            if limit.is_some_and(|limit| rows.len() as u64 >= limit) {
498                break;
499            }
500        }
501    }
502
503    Ok(ExecutionResult { rows })
504}
505
506pub async fn stream_events<S, F>(
507    query: &EventsQuery,
508    source: &S,
509    mut on_row: F,
510) -> Result<(), EventsError>
511where
512    S: EventSource + ?Sized + Sync,
513    F: FnMut(Row) -> Result<(), EventsError>,
514{
515    ensure_supported_target(query.target)?;
516
517    let has_group_by = query
518        .pipeline
519        .iter()
520        .any(|n| matches!(n, PipelineNode::GroupBy { .. }));
521    let group_fields: Option<Vec<String>> = query.pipeline.iter().find_map(|n| {
522        if let PipelineNode::GroupBy { fields, .. } = n {
523            Some(fields.clone())
524        } else {
525            None
526        }
527    });
528
529    let mut emitted = 0_u64;
530    let limit = query.pipeline.iter().find_map(|node| match node {
531        PipelineNode::Limit(limit) => Some(*limit),
532        _ => None,
533    });
534
535    let mut group_counts: std::collections::BTreeMap<Vec<String>, u64> =
536        std::collections::BTreeMap::new();
537    let mut group_rows: std::collections::BTreeMap<Vec<String>, Row> =
538        std::collections::BTreeMap::new();
539    let mut rows_since_flush = 0_u64;
540
541    let mut stream = source.events_stream().await?;
542    while let Some(event) = stream.next().await {
543        let mut row = Some(event_row(event?));
544
545        if let Some(filter) = &query.filter
546            && !eval::evaluate_expression(&row.as_ref().expect("row is present").fields, filter)?
547        {
548            continue;
549        }
550
551        let mut keep = true;
552        for node in &query.pipeline {
553            let current = row.take().expect("row is present while pipeline runs");
554
555            if has_group_by {
556                match node {
557                    PipelineNode::GroupBy { fields, .. } => {
558                        let key: Vec<String> = fields
559                            .iter()
560                            .map(|f| {
561                                current
562                                    .fields
563                                    .get(f)
564                                    .map(eval::render_json_cell)
565                                    .unwrap_or_default()
566                            })
567                            .collect();
568
569                        let count = group_counts.entry(key.clone()).or_insert(0);
570                        *count += 1;
571
572                        if !group_rows.contains_key(&key) {
573                            let mut row_fields = BTreeMap::new();
574                            for f in fields {
575                                if let Some(v) = current.fields.get(f) {
576                                    row_fields.insert(f.clone(), v.clone());
577                                }
578                            }
579                            group_rows.insert(key.clone(), Row { fields: row_fields });
580                        }
581
582                        row = None;
583                        keep = false;
584                        rows_since_flush += 1;
585
586                        if rows_since_flush >= 50 {
587                            flush_grouped(
588                                group_fields.as_ref(),
589                                &mut group_counts,
590                                &mut group_rows,
591                                &mut on_row,
592                                &mut emitted,
593                                limit,
594                            )?;
595                            rows_since_flush = 0;
596                            if limit.is_some_and(|l| emitted >= l) {
597                                return Ok(());
598                            }
599                        }
600                        break;
601                    }
602                    _ => {
603                        row = match apply_pipeline_node(current, node)? {
604                            PipelineOutcome::Row(next_row) => Some(next_row),
605                            PipelineOutcome::Drop => {
606                                keep = false;
607                                break;
608                            }
609                            PipelineOutcome::LimitReached => {
610                                flush_grouped(
611                                    group_fields.as_ref(),
612                                    &mut group_counts,
613                                    &mut group_rows,
614                                    &mut on_row,
615                                    &mut emitted,
616                                    limit,
617                                )?;
618                                return Ok(());
619                            }
620                        };
621                    }
622                }
623            } else {
624                match apply_pipeline_node(current, node)? {
625                    PipelineOutcome::Row(next_row) => row = Some(next_row),
626                    PipelineOutcome::Drop => {
627                        keep = false;
628                        break;
629                    }
630                    PipelineOutcome::LimitReached => return Ok(()),
631                }
632            }
633        }
634
635        if keep && !has_group_by {
636            on_row(row.expect("row is present after pipeline"))?;
637            emitted += 1;
638            if limit.is_some_and(|limit| emitted >= limit) {
639                return Ok(());
640            }
641        }
642    }
643
644    if has_group_by {
645        flush_grouped(
646            group_fields.as_ref(),
647            &mut group_counts,
648            &mut group_rows,
649            &mut on_row,
650            &mut emitted,
651            limit,
652        )?;
653    }
654
655    Ok(())
656}
657
658fn flush_grouped<F>(
659    _group_fields: Option<&Vec<String>>,
660    group_counts: &mut std::collections::BTreeMap<Vec<String>, u64>,
661    group_rows: &mut std::collections::BTreeMap<Vec<String>, Row>,
662    on_row: &mut F,
663    emitted: &mut u64,
664    limit: Option<u64>,
665) -> Result<(), EventsError>
666where
667    F: FnMut(Row) -> Result<(), EventsError>,
668{
669    let keys: Vec<Vec<String>> = group_counts.keys().cloned().collect();
670    for key in keys {
671        if let Some(count) = group_counts.remove(&key)
672            && let Some(mut row) = group_rows.remove(&key)
673        {
674            row.fields
675                .insert("count".to_owned(), serde_json::json!(count));
676            on_row(row)?;
677            *emitted += 1;
678            if limit.is_some_and(|l| *emitted >= l) {
679                break;
680            }
681        }
682    }
683    Ok(())
684}
685
686#[must_use]
687pub fn event_row(event: DockerEvent) -> Row {
688    Row {
689        fields: BTreeMap::from([
690            ("time".to_owned(), json_string(event.time)),
691            ("type".to_owned(), json_string(event.event_type)),
692            ("action".to_owned(), json_string(event.action)),
693            ("actor_id".to_owned(), json_string(event.actor_id)),
694            ("container".to_owned(), json_option_string(event.container)),
695            ("image".to_owned(), json_option_string(event.image)),
696            (
697                "attributes".to_owned(),
698                JsonValue::Array(
699                    event
700                        .attributes
701                        .into_iter()
702                        .map(|(key, value)| JsonValue::String(format!("{key}={value}")))
703                        .collect(),
704                ),
705            ),
706        ]),
707    }
708}
709
710pub fn parse_docker_event_json(line: &str) -> Result<DockerEvent, EventsError> {
711    let value = serde_json::from_str::<JsonValue>(line).map_err(EventsError::Json)?;
712    let attributes = value
713        .get("Actor")
714        .and_then(|actor| actor.get("Attributes"))
715        .and_then(JsonValue::as_object)
716        .map(|attributes| {
717            attributes
718                .iter()
719                .map(|(key, value)| (key.clone(), json_value_to_string(value)))
720                .collect::<Vec<_>>()
721        })
722        .unwrap_or_default();
723
724    let container =
725        attribute(&attributes, "name").or_else(|| string(&value, &["container", "ActorID"]));
726    let image = attribute(&attributes, "image").or_else(|| string(&value, &["from"]));
727
728    Ok(DockerEvent {
729        time: string(&value, &["time", "timeNano", "Time", "TimeNano"]).unwrap_or_default(),
730        event_type: string(&value, &["Type", "type"]).unwrap_or_default(),
731        action: string(&value, &["Action", "action", "status"]).unwrap_or_default(),
732        actor_id: value
733            .get("Actor")
734            .and_then(|actor| actor.get("ID"))
735            .and_then(JsonValue::as_str)
736            .map(str::to_owned)
737            .or_else(|| string(&value, &["id", "ActorID"]))
738            .unwrap_or_default(),
739        container,
740        image,
741        attributes,
742    })
743}
744
745enum PipelineOutcome {
746    Row(Row),
747    Drop,
748    LimitReached,
749}
750
751fn apply_pipeline_node(row: Row, node: &PipelineNode) -> Result<PipelineOutcome, EventsError> {
752    match node {
753        PipelineNode::Where(expression) | PipelineNode::Having(expression) => {
754            if eval::evaluate_expression(&row.fields, expression)? {
755                Ok(PipelineOutcome::Row(row))
756            } else {
757                Ok(PipelineOutcome::Drop)
758            }
759        }
760        PipelineNode::Select(fields) => select_fields(&row, fields).map(PipelineOutcome::Row),
761        PipelineNode::Limit(0) => Ok(PipelineOutcome::LimitReached),
762        PipelineNode::Limit(_) => Ok(PipelineOutcome::Row(row)),
763        PipelineNode::GroupBy { .. } => {
764            // In streaming, GroupBy is handled at the stream level.
765            Ok(PipelineOutcome::Row(row))
766        }
767        PipelineNode::SortBy { .. } => Err(EventsError::UnsupportedPipeline("sort by")),
768        PipelineNode::Distinct => Err(EventsError::UnsupportedPipeline("distinct")),
769        PipelineNode::Offset(_) => Err(EventsError::UnsupportedPipeline("offset")),
770        PipelineNode::Alert(message) => {
771            eprintln!(
772                "[ALERT] {message}: {}",
773                row.fields
774                    .iter()
775                    .map(|(k, v)| format!("{k}={}", eval::render_json_cell(v)))
776                    .collect::<Vec<_>>()
777                    .join(", ")
778            );
779            Ok(PipelineOutcome::Row(row))
780        }
781        PipelineNode::Set { field, value } => {
782            let mut row = row;
783            let json_value = eval::evaluate_set_value(&row.fields, value)?;
784            row.fields.insert(field.clone(), json_value);
785            Ok(PipelineOutcome::Row(row))
786        }
787        PipelineNode::Fill {
788            field,
789            default,
790            condition,
791        } => {
792            let mut row = row;
793            // Check condition first (if present)
794            if let Some(cond) = condition
795                && !eval::evaluate_expression(&row.fields, cond)?
796            {
797                return Ok(PipelineOutcome::Row(row));
798            }
799            if !row.fields.contains_key(field)
800                || row.fields.get(field) == Some(&JsonValue::Null)
801                || matches!(row.fields.get(field), Some(JsonValue::String(s)) if s.is_empty())
802            {
803                let value = eval::evaluate_set_value(&row.fields, default)?;
804                row.fields.insert(field.clone(), value);
805            }
806            Ok(PipelineOutcome::Row(row))
807        }
808        PipelineNode::Debug => {
809            eprintln!(
810                "[debug] event: {}={}, action={}",
811                row.fields
812                    .get("type")
813                    .map(eval::render_json_cell)
814                    .unwrap_or_default(),
815                row.fields
816                    .get("container")
817                    .or_else(|| row.fields.get("actor_id"))
818                    .map(eval::render_json_cell)
819                    .unwrap_or_default(),
820                row.fields
821                    .get("action")
822                    .map(eval::render_json_cell)
823                    .unwrap_or_default(),
824            );
825            Ok(PipelineOutcome::Row(row))
826        }
827        PipelineNode::RowNumber { .. } => Err(EventsError::UnsupportedPipeline("row_number")),
828        PipelineNode::Rank { .. } => Err(EventsError::UnsupportedPipeline("rank")),
829        PipelineNode::Lag { .. } => Err(EventsError::UnsupportedPipeline("lag")),
830        PipelineNode::Lead { .. } => Err(EventsError::UnsupportedPipeline("lead")),
831        PipelineNode::Assert(condition) => {
832            if eval::evaluate_expression(&row.fields, condition)? {
833                Ok(PipelineOutcome::Row(row))
834            } else {
835                let details = row
836                    .fields
837                    .iter()
838                    .map(|(k, v)| format!("{k}={}", eval::render_json_cell(v)))
839                    .collect::<Vec<_>>()
840                    .join(", ");
841                Err(EventsError::AssertionFailed(format!(
842                    "assertion failed for row: {details}"
843                )))
844            }
845        }
846        PipelineNode::Let { name, value } => {
847            let mut row = row;
848            let value = eval::eval_expr(&BTreeMap::new(), value)?;
849            row.fields.insert(name.clone(), value);
850            Ok(PipelineOutcome::Row(row))
851        }
852        PipelineNode::If {
853            condition,
854            then_branch,
855            else_branch,
856        } => {
857            let empty = Vec::new();
858            let matched = eval::evaluate_expression(&row.fields, condition)?;
859            let branch = if matched {
860                then_branch
861            } else {
862                else_branch.as_ref().unwrap_or(&empty)
863            };
864            if branch.is_empty() {
865                Ok(PipelineOutcome::Row(row))
866            } else {
867                apply_inline_pipeline(row, branch)
868            }
869        }
870    }
871}
872
873fn select_fields(row: &Row, fields: &[String]) -> Result<Row, EventsError> {
874    let mut selected = BTreeMap::new();
875    for field in fields {
876        let value = row
877            .fields
878            .get(field)
879            .ok_or_else(|| EvalError::UnsupportedField {
880                field: field.clone(),
881            })?;
882        selected.insert(field.clone(), value.clone());
883    }
884    Ok(Row { fields: selected })
885}
886
887fn apply_inline_pipeline(
888    mut row: Row,
889    nodes: &[PipelineNode],
890) -> Result<PipelineOutcome, EventsError> {
891    for node in nodes {
892        match apply_pipeline_node(row, node)? {
893            PipelineOutcome::Row(next) => row = next,
894            PipelineOutcome::Drop => return Ok(PipelineOutcome::Drop),
895            PipelineOutcome::LimitReached => return Ok(PipelineOutcome::LimitReached),
896        }
897    }
898    Ok(PipelineOutcome::Row(row))
899}
900
901#[allow(clippy::unnecessary_wraps)]
902const fn ensure_supported_target(target: CollectionTarget) -> Result<(), EventsError> {
903    let _ = target;
904    Ok(())
905}
906
907fn string(value: &JsonValue, keys: &[&str]) -> Option<String> {
908    keys.iter()
909        .find_map(|key| value.get(*key))
910        .map(json_value_to_string)
911        .filter(|value| !value.is_empty())
912}
913
914fn attribute(attributes: &[(String, String)], key: &str) -> Option<String> {
915    attributes
916        .iter()
917        .find(|(candidate, _)| candidate == key)
918        .map(|(_, value)| value.clone())
919}
920
921fn json_value_to_string(value: &JsonValue) -> String {
922    match value {
923        JsonValue::Null => String::new(),
924        JsonValue::String(value) => value.clone(),
925        other => other.to_string(),
926    }
927}
928
929fn json_option_string(value: Option<String>) -> JsonValue {
930    value.map_or(JsonValue::Null, JsonValue::String)
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936    use crate::{
937        ast::{ComposeTarget, Query},
938        docker::{Container, MockDockerClient},
939        parser,
940    };
941
942    #[test]
943    fn parses_docker_event_json() {
944        let event = parse_docker_event_json(
945            r#"{"time":1717120800,"Type":"container","Action":"die","Actor":{"ID":"abc","Attributes":{"name":"api","image":"api:latest","exitCode":"1"}}}"#,
946        )
947        .expect("event should parse");
948
949        assert_eq!(event.event_type, "container");
950        assert_eq!(event.action, "die");
951        assert_eq!(event.actor_id, "abc");
952        assert_eq!(event.container.as_deref(), Some("api"));
953        assert_eq!(event.image.as_deref(), Some("api:latest"));
954    }
955
956    #[test]
957    fn filters_mock_event_stream() {
958        let rt = tokio::runtime::Runtime::new().unwrap();
959        let Query::Events(query) = parser::parse(
960            "events containers where action = \"die\" | select time, container, action",
961        )
962        .expect("query should parse")
963        .query
964        else {
965            panic!("expected events query");
966        };
967        let source = MockEventSource {
968            events: vec![
969                event("start", "api"),
970                event("die", "api"),
971                event("restart", "worker"),
972            ],
973        };
974
975        let result = rt
976            .block_on(collect_events(&query, &source))
977            .expect("events should collect");
978
979        assert_eq!(result.rows.len(), 1);
980        assert_eq!(
981            result.rows[0].fields["container"],
982            JsonValue::String("api".to_owned())
983        );
984        assert_eq!(
985            result.rows[0].fields.keys().cloned().collect::<Vec<_>>(),
986            vec![
987                "action".to_owned(),
988                "container".to_owned(),
989                "time".to_owned()
990            ]
991        );
992    }
993
994    #[test]
995    fn honors_stream_limit() {
996        let rt = tokio::runtime::Runtime::new().unwrap();
997        let Query::Events(query) = parser::parse("events containers | limit 1")
998            .expect("query should parse")
999            .query
1000        else {
1001            panic!("expected events query");
1002        };
1003        let source = MockEventSource {
1004            events: vec![event("start", "api"), event("die", "api")],
1005        };
1006
1007        let result = rt
1008            .block_on(collect_events(&query, &source))
1009            .expect("events should collect");
1010
1011        assert_eq!(result.rows.len(), 1);
1012    }
1013
1014    #[test]
1015    fn supports_non_container_event_targets() {
1016        let rt = tokio::runtime::Runtime::new().unwrap();
1017        let Query::Events(query) = parser::parse("events images where action = \"pull\"")
1018            .expect("query should parse")
1019            .query
1020        else {
1021            panic!("expected events query");
1022        };
1023        let source = MockEventSource {
1024            events: vec![
1025                DockerEvent {
1026                    time: "2026-05-31T02:00:00Z".to_owned(),
1027                    event_type: "image".to_owned(),
1028                    action: "pull".to_owned(),
1029                    actor_id: "user".to_owned(),
1030                    container: None,
1031                    image: Some("postgres:16".to_owned()),
1032                    attributes: Vec::new(),
1033                },
1034                DockerEvent {
1035                    time: "2026-05-31T02:00:00Z".to_owned(),
1036                    event_type: "image".to_owned(),
1037                    action: "delete".to_owned(),
1038                    actor_id: "user".to_owned(),
1039                    container: None,
1040                    image: Some("old:latest".to_owned()),
1041                    attributes: Vec::new(),
1042                },
1043            ],
1044        };
1045
1046        let result = rt
1047            .block_on(collect_events(&query, &source))
1048            .expect("events should collect");
1049
1050        assert_eq!(result.rows.len(), 1);
1051        assert_eq!(
1052            result.rows[0].fields["image"],
1053            JsonValue::String("postgres:16".to_owned())
1054        );
1055    }
1056
1057    // ── stream_events callback tests ────────────────────────────────────
1058
1059    #[test]
1060    fn stream_events_filter_with_callback() {
1061        let rt = tokio::runtime::Runtime::new().unwrap();
1062        let Query::Events(query) = parser::parse(
1063            "events containers where action = \"die\" | select time, container, action | limit 1",
1064        )
1065        .expect("query should parse")
1066        .query
1067        else {
1068            panic!("expected events query");
1069        };
1070        let source = MockEventSource {
1071            events: vec![
1072                event("start", "api"),
1073                event("die", "api"),
1074                event("restart", "worker"),
1075            ],
1076        };
1077
1078        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1079        let cb = std::sync::Arc::clone(&received);
1080
1081        rt.block_on(stream_events(&query, &source, move |row| {
1082            cb.lock().unwrap().push(row);
1083            Ok(())
1084        }))
1085        .expect("events should stream");
1086
1087        let rows = received.lock().unwrap();
1088        assert_eq!(rows.len(), 1);
1089        assert_eq!(
1090            rows[0].fields["container"],
1091            JsonValue::String("api".to_owned())
1092        );
1093        assert_eq!(
1094            rows[0].fields.keys().cloned().collect::<Vec<_>>(),
1095            vec![
1096                "action".to_owned(),
1097                "container".to_owned(),
1098                "time".to_owned()
1099            ]
1100        );
1101    }
1102
1103    // ── collect_logs tests ───────────────────────────────────────────────
1104
1105    #[test]
1106    fn collect_logs_applies_filter() {
1107        let rt = tokio::runtime::Runtime::new().unwrap();
1108        let Query::Logs(query) =
1109            parser::parse("logs container test-container | where message contains \"error\"")
1110                .expect("query should parse")
1111                .query
1112        else {
1113            panic!("expected logs query");
1114        };
1115        let source = MockLogSource {
1116            lines: vec![
1117                "info: starting up".to_owned(),
1118                "error: connection refused".to_owned(),
1119                "warn: retrying".to_owned(),
1120                "error: timeout".to_owned(),
1121            ],
1122        };
1123
1124        let result = rt
1125            .block_on(collect_logs(&query, &source))
1126            .expect("logs should collect");
1127
1128        assert_eq!(result.rows.len(), 2);
1129        for row in &result.rows {
1130            let msg = row.fields["message"].as_str().unwrap();
1131            assert!(msg.contains("error"), "expected error line, got: {msg}");
1132        }
1133    }
1134
1135    #[test]
1136    fn collect_logs_applies_limit() {
1137        let rt = tokio::runtime::Runtime::new().unwrap();
1138        let Query::Logs(query) = parser::parse("logs container test-container tail 100 | limit 2")
1139            .expect("query should parse")
1140            .query
1141        else {
1142            panic!("expected logs query");
1143        };
1144        let source = MockLogSource {
1145            lines: vec![
1146                "line1".to_owned(),
1147                "line2".to_owned(),
1148                "line3".to_owned(),
1149                "line4".to_owned(),
1150            ],
1151        };
1152
1153        let result = rt
1154            .block_on(collect_logs(&query, &source))
1155            .expect("logs should collect");
1156
1157        assert_eq!(result.rows.len(), 2);
1158        assert_eq!(
1159            result.rows[0].fields["message"],
1160            JsonValue::String("line1".to_owned())
1161        );
1162        assert_eq!(
1163            result.rows[1].fields["message"],
1164            JsonValue::String("line2".to_owned())
1165        );
1166    }
1167
1168    #[test]
1169    fn collect_logs_applies_select() {
1170        let rt = tokio::runtime::Runtime::new().unwrap();
1171        let Query::Logs(query) = parser::parse("logs container test-container | select message")
1172            .expect("query should parse")
1173            .query
1174        else {
1175            panic!("expected logs query");
1176        };
1177        let source = MockLogSource {
1178            lines: vec!["hello".to_owned(), "world".to_owned()],
1179        };
1180
1181        let result = rt
1182            .block_on(collect_logs(&query, &source))
1183            .expect("logs should collect");
1184
1185        assert_eq!(result.rows.len(), 2);
1186        // Only the selected field should be present
1187        assert!(
1188            !result.rows[0].fields.contains_key("line"),
1189            "line field should be filtered out by select"
1190        );
1191        assert!(
1192            !result.rows[0].fields.contains_key("container"),
1193            "container field should be filtered out by select"
1194        );
1195        // Positive check: selected field must be present with correct value
1196        assert_eq!(
1197            result.rows[0].fields["message"],
1198            JsonValue::String("hello".to_owned())
1199        );
1200        assert_eq!(
1201            result.rows[1].fields["message"],
1202            JsonValue::String("world".to_owned())
1203        );
1204    }
1205
1206    // ── stream_logs callback tests ───────────────────────────────────────
1207
1208    #[test]
1209    fn stream_logs_with_callback() {
1210        let rt = tokio::runtime::Runtime::new().unwrap();
1211        let Query::Logs(query) = parser::parse("logs container test-container tail 10")
1212            .expect("query should parse")
1213            .query
1214        else {
1215            panic!("expected logs query");
1216        };
1217        let source = MockLogSource {
1218            lines: vec!["hello".to_owned(), "world".to_owned()],
1219        };
1220
1221        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1222        let cb = std::sync::Arc::clone(&received);
1223
1224        rt.block_on(stream_logs(&query, &source, move |row| {
1225            cb.lock().unwrap().push(row);
1226            Ok(())
1227        }))
1228        .expect("logs should stream");
1229
1230        let rows = received.lock().unwrap();
1231        assert_eq!(rows.len(), 2);
1232        assert_eq!(
1233            rows[0].fields["message"],
1234            JsonValue::String("hello".to_owned())
1235        );
1236        assert_eq!(
1237            rows[1].fields["message"],
1238            JsonValue::String("world".to_owned())
1239        );
1240        // Verify all expected fields are present
1241        assert_eq!(rows[0].fields["line"], JsonValue::Number(Number::from(1)));
1242        assert_eq!(rows[1].fields["line"], JsonValue::Number(Number::from(2)));
1243        assert_eq!(
1244            rows[0].fields["container"],
1245            JsonValue::String("test-container".to_owned())
1246        );
1247    }
1248
1249    // ── stream_compose_networks tests ──────────────────────────────────────
1250
1251    fn net_event(action: &str, project: &str) -> DockerEvent {
1252        DockerEvent {
1253            time: "2026-05-31T02:00:00Z".to_owned(),
1254            event_type: "network".to_owned(),
1255            action: action.to_owned(),
1256            actor_id: "net-actor".to_owned(),
1257            container: None,
1258            image: None,
1259            attributes: vec![
1260                ("com.docker.compose.project".to_owned(), project.to_owned()),
1261                ("name".to_owned(), format!("{project}_default")),
1262            ],
1263        }
1264    }
1265
1266    #[test]
1267    fn stream_compose_networks_basic() {
1268        let rt = tokio::runtime::Runtime::new().unwrap();
1269        let docker = Arc::new(MockDockerClient {
1270            events: vec![
1271                net_event("create", "myapp"),
1272                net_event("connect", "myapp"),
1273                net_event("create", "other"), // different project, should be filtered out
1274            ],
1275            ..MockDockerClient::default()
1276        });
1277
1278        let query = ComposeQuery {
1279            project: "myapp".to_owned(),
1280            target: ComposeTarget::Networks,
1281            service: None,
1282            tail: None,
1283            pipeline: Vec::new(),
1284            port_number: None,
1285            config_target: None,
1286        };
1287
1288        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1289        let cb = std::sync::Arc::clone(&received);
1290
1291        rt.block_on(stream_compose_networks(&query, docker, move |row| {
1292            cb.lock().unwrap().push(row);
1293            Ok(())
1294        }))
1295        .expect("compose networks should stream");
1296
1297        let rows = received.lock().unwrap();
1298        assert_eq!(rows.len(), 2, "expected 2 network events for myapp");
1299
1300        let actions: Vec<&str> = rows
1301            .iter()
1302            .map(|r| r.fields["action"].as_str().unwrap())
1303            .collect();
1304        assert!(actions.contains(&"create"));
1305        assert!(actions.contains(&"connect"));
1306
1307        // Verify type field is "network" for all rows
1308        for row in rows.iter() {
1309            assert_eq!(
1310                row.fields["type"].as_str().unwrap(),
1311                "network",
1312                "event type should be 'network'"
1313            );
1314        }
1315    }
1316
1317    #[test]
1318    fn stream_compose_networks_filters_non_network_events() {
1319        let rt = tokio::runtime::Runtime::new().unwrap();
1320        let docker = Arc::new(MockDockerClient {
1321            events: vec![
1322                DockerEvent {
1323                    time: "2026-05-31T02:00:00Z".to_owned(),
1324                    event_type: "container".to_owned(),
1325                    action: "start".to_owned(),
1326                    actor_id: "c1".to_owned(),
1327                    container: Some("api".to_owned()),
1328                    image: Some("api:latest".to_owned()),
1329                    attributes: vec![
1330                        ("com.docker.compose.project".to_owned(), "myapp".to_owned()),
1331                        ("com.docker.compose.service".to_owned(), "api".to_owned()),
1332                    ],
1333                },
1334                net_event("connect", "myapp"),
1335            ],
1336            ..MockDockerClient::default()
1337        });
1338
1339        let query = ComposeQuery {
1340            project: "myapp".to_owned(),
1341            target: ComposeTarget::Networks,
1342            service: None,
1343            tail: None,
1344            pipeline: Vec::new(),
1345            port_number: None,
1346            config_target: None,
1347        };
1348
1349        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1350        let cb = std::sync::Arc::clone(&received);
1351
1352        rt.block_on(stream_compose_networks(&query, docker, move |row| {
1353            cb.lock().unwrap().push(row);
1354            Ok(())
1355        }))
1356        .expect("compose networks should stream");
1357
1358        let rows = received.lock().unwrap();
1359        assert_eq!(
1360            rows.len(),
1361            1,
1362            "expected only 1 network event (container event filtered out)"
1363        );
1364        assert_eq!(rows[0].fields["action"].as_str().unwrap(), "connect");
1365    }
1366
1367    #[test]
1368    fn stream_compose_networks_select_action() {
1369        let rt = tokio::runtime::Runtime::new().unwrap();
1370        let docker = Arc::new(MockDockerClient {
1371            events: vec![net_event("create", "myapp"), net_event("connect", "myapp")],
1372            ..MockDockerClient::default()
1373        });
1374
1375        let query = ComposeQuery {
1376            project: "myapp".to_owned(),
1377            target: ComposeTarget::Networks,
1378            service: None,
1379            tail: None,
1380            pipeline: vec![PipelineNode::Select(vec!["action".to_owned()])],
1381            port_number: None,
1382            config_target: None,
1383        };
1384
1385        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1386        let cb = std::sync::Arc::clone(&received);
1387
1388        rt.block_on(stream_compose_networks(&query, docker, move |row| {
1389            cb.lock().unwrap().push(row);
1390            Ok(())
1391        }))
1392        .expect("compose networks should stream");
1393
1394        let rows = received.lock().unwrap();
1395        assert_eq!(rows.len(), 2);
1396        // Only action field should be present
1397        assert!(rows[0].fields.contains_key("action"));
1398        assert!(!rows[0].fields.contains_key("type"));
1399        assert!(!rows[0].fields.contains_key("time"));
1400        assert!(!rows[0].fields.contains_key("actor_id"));
1401        assert_eq!(rows[0].fields["action"].as_str().unwrap(), "create");
1402        assert_eq!(rows[1].fields["action"].as_str().unwrap(), "connect");
1403    }
1404
1405    #[test]
1406    fn stream_compose_networks_with_limit() {
1407        let rt = tokio::runtime::Runtime::new().unwrap();
1408        let docker = Arc::new(MockDockerClient {
1409            events: vec![
1410                net_event("create", "myapp"),
1411                net_event("connect", "myapp"),
1412                net_event("remove", "myapp"),
1413            ],
1414            ..MockDockerClient::default()
1415        });
1416
1417        let query = ComposeQuery {
1418            project: "myapp".to_owned(),
1419            target: ComposeTarget::Networks,
1420            service: None,
1421            tail: None,
1422            pipeline: vec![PipelineNode::Limit(2)],
1423            port_number: None,
1424            config_target: None,
1425        };
1426
1427        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1428        let cb = std::sync::Arc::clone(&received);
1429
1430        rt.block_on(stream_compose_networks(&query, docker, move |row| {
1431            cb.lock().unwrap().push(row);
1432            Ok(())
1433        }))
1434        .expect("compose networks should stream");
1435
1436        let rows = received.lock().unwrap();
1437        assert_eq!(rows.len(), 2, "expected 2 rows (limit 2)");
1438    }
1439
1440    // ── stream_compose_logs tests ─────────────────────────────────────────
1441
1442    fn compose_container(id: &str, name: &str, project: &str, service: &str) -> Container {
1443        Container {
1444            id: id.to_owned(),
1445            name: name.to_owned(),
1446            labels: vec![
1447                format!("com.docker.compose.project={project}"),
1448                format!("com.docker.compose.service={service}"),
1449            ],
1450            state: "running".to_owned(),
1451            ..Container::default()
1452        }
1453    }
1454
1455    #[test]
1456    fn stream_compose_logs_basic() {
1457        let rt = tokio::runtime::Runtime::new().unwrap();
1458        let docker = Arc::new(MockDockerClient {
1459            containers: vec![
1460                compose_container("c1", "api-1", "myapp", "api"),
1461                compose_container("c2", "api-2", "myapp", "api"),
1462            ],
1463            logs: {
1464                let mut m = std::collections::HashMap::new();
1465                m.insert("c1".to_owned(), vec!["log1".to_owned(), "log2".to_owned()]);
1466                m.insert("c2".to_owned(), vec!["log3".to_owned(), "log4".to_owned()]);
1467                m
1468            },
1469            ..MockDockerClient::default()
1470        });
1471
1472        let query = ComposeQuery {
1473            project: "myapp".to_owned(),
1474            target: ComposeTarget::Logs,
1475            service: Some("api".to_owned()),
1476            tail: Some(100),
1477            pipeline: Vec::new(),
1478            port_number: None,
1479            config_target: None,
1480        };
1481
1482        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1483        let cb = std::sync::Arc::clone(&received);
1484
1485        rt.block_on(stream_compose_logs(&query, docker, move |row| {
1486            cb.lock().unwrap().push(row);
1487            Ok(())
1488        }))
1489        .expect("compose logs should stream");
1490
1491        let rows = received.lock().unwrap();
1492        // 2 containers × 2 lines each = 4 rows (order depends on select_all interleaving)
1493        assert_eq!(rows.len(), 4, "expected 4 log lines from 2 containers");
1494
1495        // Verify all expected messages appear
1496        let msgs: Vec<&str> = rows
1497            .iter()
1498            .map(|r| r.fields["message"].as_str().unwrap())
1499            .collect();
1500        assert!(msgs.contains(&"log1"));
1501        assert!(msgs.contains(&"log2"));
1502        assert!(msgs.contains(&"log3"));
1503        assert!(msgs.contains(&"log4"));
1504
1505        // Verify container names are correct
1506        for row in rows.iter() {
1507            let container = row.fields["container"].as_str().unwrap();
1508            assert!(
1509                container == "api-1" || container == "api-2",
1510                "unexpected container: {container}"
1511            );
1512        }
1513
1514        // Verify service field is present
1515        for row in rows.iter() {
1516            assert_eq!(
1517                row.fields["service"].as_str().unwrap(),
1518                "api",
1519                "service field should be 'api'"
1520            );
1521        }
1522    }
1523
1524    #[test]
1525    fn stream_compose_logs_select_message() {
1526        let rt = tokio::runtime::Runtime::new().unwrap();
1527        let docker = Arc::new(MockDockerClient {
1528            containers: vec![compose_container("c1", "api-1", "myapp", "api")],
1529            logs: {
1530                let mut m = std::collections::HashMap::new();
1531                m.insert("c1".to_owned(), vec!["hello".to_owned()]);
1532                m
1533            },
1534            ..MockDockerClient::default()
1535        });
1536
1537        let query = ComposeQuery {
1538            project: "myapp".to_owned(),
1539            target: ComposeTarget::Logs,
1540            service: Some("api".to_owned()),
1541            tail: Some(100),
1542            pipeline: vec![PipelineNode::Select(vec!["message".to_owned()])],
1543            port_number: None,
1544            config_target: None,
1545        };
1546
1547        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1548        let cb = std::sync::Arc::clone(&received);
1549
1550        rt.block_on(stream_compose_logs(&query, docker, move |row| {
1551            cb.lock().unwrap().push(row);
1552            Ok(())
1553        }))
1554        .expect("compose logs should stream");
1555
1556        let rows = received.lock().unwrap();
1557        assert_eq!(rows.len(), 1, "expected 1 row from 1 container");
1558
1559        // Only message field should be present
1560        assert!(
1561            rows[0].fields.contains_key("message"),
1562            "message field should be present"
1563        );
1564        assert_eq!(
1565            rows[0].fields["message"],
1566            JsonValue::String("hello".to_owned())
1567        );
1568        // Non-selected fields should be absent
1569        assert!(
1570            !rows[0].fields.contains_key("container"),
1571            "container should be filtered out by select"
1572        );
1573        assert!(
1574            !rows[0].fields.contains_key("service"),
1575            "service should be filtered out by select"
1576        );
1577    }
1578
1579    #[test]
1580    fn stream_compose_logs_no_matching_containers() {
1581        let rt = tokio::runtime::Runtime::new().unwrap();
1582        let docker = Arc::new(MockDockerClient {
1583            containers: vec![compose_container("c1", "api-1", "other", "api")],
1584            logs: {
1585                let mut m = std::collections::HashMap::new();
1586                m.insert("c1".to_owned(), vec!["log1".to_owned()]);
1587                m
1588            },
1589            ..MockDockerClient::default()
1590        });
1591
1592        let query = ComposeQuery {
1593            project: "myapp".to_owned(),
1594            target: ComposeTarget::Logs,
1595            service: Some("api".to_owned()),
1596            tail: Some(100),
1597            pipeline: Vec::new(),
1598            port_number: None,
1599            config_target: None,
1600        };
1601
1602        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1603        let cb = std::sync::Arc::clone(&received);
1604
1605        rt.block_on(stream_compose_logs(&query, docker, move |row| {
1606            cb.lock().unwrap().push(row);
1607            Ok(())
1608        }))
1609        .expect("compose logs should stream");
1610
1611        let rows = received.lock().unwrap();
1612        assert_eq!(rows.len(), 0, "expected 0 rows when no containers match");
1613    }
1614
1615    #[test]
1616    fn stream_compose_logs_with_limit() {
1617        let rt = tokio::runtime::Runtime::new().unwrap();
1618        let docker = Arc::new(MockDockerClient {
1619            containers: vec![compose_container("c1", "api-1", "myapp", "api")],
1620            logs: {
1621                let mut m = std::collections::HashMap::new();
1622                m.insert(
1623                    "c1".to_owned(),
1624                    vec!["a".to_owned(), "b".to_owned(), "c".to_owned()],
1625                );
1626                m
1627            },
1628            ..MockDockerClient::default()
1629        });
1630
1631        let query = ComposeQuery {
1632            project: "myapp".to_owned(),
1633            target: ComposeTarget::Logs,
1634            service: Some("api".to_owned()),
1635            tail: Some(100),
1636            pipeline: vec![PipelineNode::Limit(2)],
1637            port_number: None,
1638            config_target: None,
1639        };
1640
1641        let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1642        let cb = std::sync::Arc::clone(&received);
1643
1644        rt.block_on(stream_compose_logs(&query, docker, move |row| {
1645            cb.lock().unwrap().push(row);
1646            Ok(())
1647        }))
1648        .expect("compose logs should stream");
1649
1650        let rows = received.lock().unwrap();
1651        assert_eq!(rows.len(), 2, "expected 2 rows (limit 2)");
1652    }
1653
1654    fn event(action: &str, container: &str) -> DockerEvent {
1655        DockerEvent {
1656            time: "2026-05-31T02:00:00Z".to_owned(),
1657            event_type: "container".to_owned(),
1658            action: action.to_owned(),
1659            actor_id: format!("{container}-id"),
1660            container: Some(container.to_owned()),
1661            image: Some(format!("{container}:latest")),
1662            attributes: vec![("name".to_owned(), container.to_owned())],
1663        }
1664    }
1665}