1use 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
29pub type EventStream = Pin<Box<dyn Stream<Item = Result<DockerEvent, EventsError>> + Send>>;
31
32pub type LogStream = Pin<Box<dyn Stream<Item = Result<String, EventsError>> + Send>>;
34
35pub 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
62pub 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
92pub 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
104pub 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#[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#[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#[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
181pub 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
231pub 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 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 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 if let Some(ref mut r) = row {
304 r.fields
305 .insert("line".to_owned(), JsonValue::Number(Number::from(line_num)));
306 }
307
308 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
334pub 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 if event.event_type != "network" {
362 continue;
363 }
364
365 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
401pub 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 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 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 #[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 #[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 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 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 #[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 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 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"), ],
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 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 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 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 assert_eq!(rows.len(), 4, "expected 4 log lines from 2 containers");
1494
1495 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 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 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 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 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}