Skip to main content

ignition_core/rig/
compose.rs

1//! The compose shell-out engine (04-01, RIG-01): runner seam, version
2//! check, LOCKED arg builders, and the LDJSON/array parsers — serde
3//! models out, no printing (the TUI rides the actions layer in Phase 6).
4//!
5//! ## The runner seam
6//!
7//! [`ComposeRunner`] is the ONLY way any rig code spawns a process:
8//! actions and discovery take `&dyn ComposeRunner`, so every decision
9//! path is unit-testable against a scripted fake (no docker needed).
10//! The production [`DockerCompose`] shells out via
11//! `tokio::process::Command` (the workspace `process` feature — the one
12//! Phase 4 dependency change). `run` prefixes the `compose` subcommand;
13//! `run_docker` spawns the PLAIN docker CLI (volume ls / port
14//! attribution — no `-p` prefix, those are not compose project ops);
15//! `run_streaming` pipes stdout and forwards lines as they arrive (the
16//! `logs -f` follow shape, 04-02).
17//!
18//! ## LOCKED invocation shapes (research §Compose invocation shapes)
19//!
20//! Every builder is a pure function whose exact output vector is
21//! unit-pinned — `-p <resolved-name>` EXPLICIT on every project op
22//! (Pitfall 8: no implicit directory-name projects), `--project-directory`
23//! always on resolve (Pitfall 8: `.env` loading is cwd-sensitive),
24//! `--remove-orphans` on up AND down (Pitfall 4), `--wait-timeout`
25//! explicit on up (Pitfall 3: healthchecks + image pulls).
26//!
27//! ## The two output conventions (research Pitfall 1)
28//!
29//! `ps`/`volume ls`/`docker ps` emit ONE OBJECT PER LINE (LDJSON —
30//! parsed with a `StreamDeserializer`); `config --format json` emits a
31//! SINGLE doc (an object on current compose; an older array shape is
32//! tolerated by unwrapping the first element). BOTH conventions are
33//! fixture-pinned so the divergence can never regress into a naive
34//! `from_str::<Vec<T>>`.
35
36use std::path::Path;
37
38use serde::Deserialize;
39use serde_json::Value;
40
41use crate::error::CoreError;
42
43use super::RigPlan;
44
45/// Gateway target ports (D-12) — the SAME documented heuristic
46/// [`crate::actions::rig::gateway_url_from`] uses, one layer down:
47/// services arrive in sorted key order (`serde_json` is built without
48/// `preserve_order`), so the first service publishing a port targeting
49/// 8088 (else 443) is deterministic.
50const GATEWAY_HTTP_TARGET: u16 = 8088;
51const GATEWAY_HTTPS_TARGET: u16 = 443;
52
53/// How many stderr lines ride a failed invocation's error message
54/// (research Pitfall 3: compose's tail is the diagnosis).
55const STDERR_TAIL_LINES: usize = 5;
56
57/// One completed `docker …` invocation: captured output + exit code.
58/// A spawn FAILURE (no docker binary) is `code: 127` with the spawn
59/// error in `stderr` — one error path, no io::Error leakage.
60#[derive(Debug, Clone, PartialEq)]
61pub struct ComposeOutput {
62    /// Captured stdout (UTF-8 lossy).
63    pub stdout: String,
64    /// Captured stderr (UTF-8 lossy).
65    pub stderr: String,
66    /// Process exit code (`-1` when terminated by signal).
67    pub code: i32,
68}
69
70/// The process seam for everything rig-related. Actions NEVER spawn
71/// processes directly — they script this trait (the GatewayApi
72/// precedent), which is what makes the whole family testable without
73/// docker.
74#[async_trait::async_trait]
75pub trait ComposeRunner: Send + Sync {
76    /// Run `docker compose <args…>` and capture stdout/stderr/exit.
77    async fn run(&self, args: &[String]) -> ComposeOutput;
78    /// Run the PLAIN docker CLI (`docker <args…>`) — the volume-ls and
79    /// port-attribution shapes that are NOT compose project ops.
80    async fn run_docker(&self, args: &[String]) -> ComposeOutput;
81    /// Stream `docker compose <args…>`: stdout forwards to `line_sink`
82    /// LINE-BY-LINE as it arrives (piped stdout — `logs -f` follow
83    /// mode), stderr is captured for diagnostics (a failed invocation
84    /// carries its tail in the error; a successful one's diagnostics
85    /// go to OUR stderr via tracing — NEVER the data stream). The
86    /// returned [`ComposeOutput`] carries EMPTY stdout: the lines
87    /// already went to the sink (fakes replay a preloaded stdout
88    /// through the sink — the keep-it-simple contract).
89    async fn run_streaming(
90        &self,
91        args: &[String],
92        line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
93    ) -> ComposeOutput;
94}
95
96/// Production [`ComposeRunner`]: shells out to the real `docker` binary
97/// via `tokio::process::Command`.
98pub struct DockerCompose;
99
100impl DockerCompose {
101    /// Spawn `program` with `args`, capture everything. Spawn failure
102    /// (binary absent) maps to the shell's 127 with the reason in
103    /// stderr — callers translate nonzero exits uniformly.
104    async fn spawn(program: &str, args: &[String]) -> ComposeOutput {
105        let joined = std::iter::once(program.to_string())
106            .chain(args.iter().cloned())
107            .collect::<Vec<_>>()
108            .join(" ");
109        match tokio::process::Command::new(program)
110            .args(args)
111            .stdout(std::process::Stdio::piped())
112            .stderr(std::process::Stdio::piped())
113            .output()
114            .await
115        {
116            Ok(output) => ComposeOutput {
117                stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
118                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
119                code: output.status.code().unwrap_or(-1),
120            },
121            Err(err) => ComposeOutput {
122                stdout: String::new(),
123                stderr: format!("failed to spawn `{joined}`: {err}"),
124                code: 127,
125            },
126        }
127    }
128
129    /// The STREAMING spawn (04-02): piped stdout read line-by-line
130    /// into `line_sink` until EOF, stderr drained CONCURRENTLY (a full
131    /// stderr pipe would deadlock the stdout reader), then the wait —
132    /// the child streams until it exits (Ctrl-C kills the whole
133    /// foreground process group, the `logs -f` precedent).
134    async fn spawn_streaming(
135        program: &str,
136        args: &[String],
137        line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
138    ) -> ComposeOutput {
139        use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
140
141        let joined = std::iter::once(program.to_string())
142            .chain(args.iter().cloned())
143            .collect::<Vec<_>>()
144            .join(" ");
145        let mut child = match tokio::process::Command::new(program)
146            .args(args)
147            .stdout(std::process::Stdio::piped())
148            .stderr(std::process::Stdio::piped())
149            .spawn()
150        {
151            Ok(child) => child,
152            Err(err) => {
153                return ComposeOutput {
154                    stdout: String::new(),
155                    stderr: format!("failed to spawn `{joined}`: {err}"),
156                    code: 127,
157                };
158            }
159        };
160        let stdout = child.stdout.take().expect("stdout piped above");
161        let mut stderr = child.stderr.take().expect("stderr piped above");
162        // Concurrent stderr drain — the join only fails on task panic.
163        let stderr_task = tokio::spawn(async move {
164            let mut buffer = String::new();
165            let _ = stderr.read_to_string(&mut buffer).await;
166            buffer
167        });
168        let mut lines = BufReader::new(stdout).lines();
169        while let Ok(Some(line)) = lines.next_line().await {
170            line_sink(&line);
171        }
172        match child.wait().await {
173            Ok(status) => ComposeOutput {
174                stdout: String::new(),
175                stderr: stderr_task.await.unwrap_or_default(),
176                code: status.code().unwrap_or(-1),
177            },
178            Err(err) => ComposeOutput {
179                stdout: String::new(),
180                stderr: format!("failed to wait for `{joined}`: {err}"),
181                code: -1,
182            },
183        }
184    }
185}
186
187#[async_trait::async_trait]
188impl ComposeRunner for DockerCompose {
189    async fn run(&self, args: &[String]) -> ComposeOutput {
190        let mut full = vec!["compose".to_string()];
191        full.extend(args.iter().cloned());
192        Self::spawn("docker", &full).await
193    }
194
195    async fn run_docker(&self, args: &[String]) -> ComposeOutput {
196        Self::spawn("docker", args).await
197    }
198
199    async fn run_streaming(
200        &self,
201        args: &[String],
202        line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
203    ) -> ComposeOutput {
204        let mut full = vec!["compose".to_string()];
205        full.extend(args.iter().cloned());
206        Self::spawn_streaming("docker", &full, line_sink).await
207    }
208}
209
210/// Map a completed invocation: exit 0 → the stdout str; nonzero →
211/// [`CoreError::Rig`] carrying the stderr tail (last
212/// [`STDERR_TAIL_LINES`] lines — research Pitfall 3).
213pub fn check_output<'a>(output: &'a ComposeOutput, context: &str) -> Result<&'a str, CoreError> {
214    if output.code == 0 {
215        Ok(&output.stdout)
216    } else {
217        let tail = stderr_tail(&output.stderr);
218        let note = if tail.is_empty() {
219            String::new()
220        } else {
221            format!(": {tail}")
222        };
223        Err(CoreError::Rig(format!(
224            "{context} failed (exit {}){note}",
225            output.code
226        )))
227    }
228}
229
230/// The last `lines` stderr lines, in order, trimmed — the diagnosis
231/// tail compose prints on failure.
232pub(crate) fn stderr_tail(stderr: &str) -> String {
233    stderr
234        .lines()
235        .rev()
236        .take(STDERR_TAIL_LINES)
237        .collect::<Vec<_>>()
238        .into_iter()
239        .rev()
240        .collect::<Vec<_>>()
241        .join("\n")
242        .trim()
243        .to_string()
244}
245
246/// Verify `docker compose` answers with major version ≥ 2 (the v1
247/// `docker-compose` Python binary never answers to `docker compose` —
248/// absence IS the install-hint case). Returns the version string
249/// (e.g. `"5.1.2"`).
250pub async fn compose_version(runner: &dyn ComposeRunner) -> Result<String, CoreError> {
251    let output = runner.run(&["version".to_string()]).await;
252    if output.code != 0 {
253        return Err(CoreError::Rig(format!(
254            "docker compose is unavailable (exit {}): {} — install Docker Desktop or \
255             the compose v2 plugin; the legacy docker-compose v1 binary is not supported",
256            output.code,
257            stderr_tail(&output.stderr)
258        )));
259    }
260    parse_compose_version(&output.stdout).ok_or_else(|| {
261        CoreError::Rig(format!(
262            "cannot parse `docker compose version` output ({:?}) — compose ≥ v2 \
263             (the `docker compose` plugin) is required",
264            output.stdout.trim()
265        ))
266    })
267}
268
269/// `"Docker Compose version v5.1.2"` → `Some("5.1.2")`; anything else —
270/// including a major < 2 — is `None`. Pure; pinned by tests.
271fn parse_compose_version(stdout: &str) -> Option<String> {
272    let rest = stdout.trim().strip_prefix("Docker Compose version ")?;
273    let version = rest.split_whitespace().next()?;
274    let version = version.strip_prefix('v').unwrap_or(version);
275    let mut parts = version.split('.');
276    let major: u32 = parts.next()?.parse().ok()?;
277    if major < 2 {
278        return None;
279    }
280    Some(version.to_string())
281}
282
283// ---------------------------------------------------------------------------
284// Arg builders — pure, exact-vector-pinned below.
285// ---------------------------------------------------------------------------
286
287/// The resolve step (research Pattern 1): `docker compose -f <file>
288/// --project-directory <dir> config --format json`. `--project-directory`
289/// is ALWAYS explicit — `.env` (and thus `COMPOSE_PROJECT_NAME`)
290/// loading is cwd-sensitive (Pitfall 8).
291pub fn config_args(file: &Path, project_dir: &Path) -> Vec<String> {
292    vec![
293        "-f".into(),
294        file.display().to_string(),
295        "--project-directory".into(),
296        project_dir.display().to_string(),
297        "config".into(),
298        "--format".into(),
299        "json".into(),
300    ]
301}
302
303/// The shared `-f` file-flags helper (D-11): `-p <name>` then `-f
304/// <base>` then one `-f <override>` per entry in `override_files`, IN
305/// ORDER. This is the ONE code path every project-scoped builder
306/// (`up_args`, `down_args`, `ps_args`, `logs_args`) routes through, so
307/// an override can never land before the base file — inverting merge
308/// precedence and shifting `--project-directory`/`.env` inference
309/// (research Pitfall 3) — and the four builders cannot drift from each
310/// other. Empty `override_files` reproduces the pre-Phase-16 vector
311/// exactly (SC-1: the structural reason an undeclared rig is
312/// byte-identical).
313fn project_and_file_args(plan: &RigPlan, override_files: &[std::path::PathBuf]) -> Vec<String> {
314    let mut args = vec![
315        "-p".into(),
316        plan.name.clone(),
317        "-f".into(),
318        plan.compose_file.display().to_string(),
319    ];
320    for override_file in override_files {
321        args.push("-f".into());
322        args.push(override_file.display().to_string());
323    }
324    args
325}
326
327/// `up` (research LOCKED shape): explicit `-p <name>` (never an
328/// implicit directory-name project), detached + `--wait` with an
329/// EXPLICIT timeout (Pitfall 3: `--wait` blocks on healthchecks and
330/// image pulls), `--remove-orphans`. `override_files` (Phase 16, D-11)
331/// appends after the base `-f`, before the `up` subcommand token —
332/// empty for an undeclared rig, which reproduces the pre-Phase-16
333/// vector exactly (SC-1).
334pub fn up_args(
335    plan: &RigPlan,
336    wait_timeout_s: u64,
337    override_files: &[std::path::PathBuf],
338) -> Vec<String> {
339    let mut args = project_and_file_args(plan, override_files);
340    args.extend([
341        "up".into(),
342        "-d".into(),
343        "--wait".into(),
344        "--wait-timeout".into(),
345        wait_timeout_s.to_string(),
346        "--remove-orphans".into(),
347    ]);
348    args
349}
350
351/// `down`: stop + remove containers/networks; `--remove-orphans` always
352/// (Pitfall 4); `-v` (named+anonymous volume deletion) only for the
353/// reset teardown half (04-02). `override_files` (Phase 16, D-11) routes
354/// through the same shared helper [`up_args`] uses — empty for an
355/// undeclared rig or a rig `rig_down` found no on-disk override for,
356/// which reproduces the pre-Phase-16 vector exactly (SC-1).
357pub fn down_args(
358    plan: &RigPlan,
359    volumes: bool,
360    override_files: &[std::path::PathBuf],
361) -> Vec<String> {
362    let mut args = project_and_file_args(plan, override_files);
363    args.push("down".into());
364    args.push("--remove-orphans".into());
365    if volumes {
366        args.push("-v".into());
367    }
368    args
369}
370
371/// `ps` as LDJSON (research Pitfall 1): one object per service.
372/// `override_files` (Phase 16, D-11) routes through the same shared
373/// helper [`up_args`] uses — empty when `rig_status` found no on-disk
374/// override, which reproduces the pre-Phase-16 vector exactly (SC-1).
375pub fn ps_args(plan: &RigPlan, override_files: &[std::path::PathBuf]) -> Vec<String> {
376    let mut args = project_and_file_args(plan, override_files);
377    args.push("ps".into());
378    args.push("--format".into());
379    args.push("json".into());
380    args
381}
382
383/// `logs` (human-form passthrough by design — the streaming exception
384/// when `--follow`; wired by 04-02's `rig_logs` via `run` one-shot /
385/// `run_streaming` follow). Invocation shape LOCKED from day one.
386/// `override_files` (Phase 16, D-11) routes through the same shared
387/// helper [`up_args`] uses — empty when `rig_logs` found no on-disk
388/// override, which reproduces the pre-Phase-16 vector exactly (SC-1).
389pub fn logs_args(
390    plan: &RigPlan,
391    tail: u32,
392    follow: bool,
393    service: Option<&str>,
394    override_files: &[std::path::PathBuf],
395) -> Vec<String> {
396    let mut args = project_and_file_args(plan, override_files);
397    args.push("logs".into());
398    args.push("--tail".into());
399    args.push(tail.to_string());
400    if follow {
401        args.push("-f".into());
402    }
403    if let Some(service) = service {
404        args.push(service.to_string());
405    }
406    args
407}
408
409/// `docker volume ls` (04-01 status / 04-02 reset preview): PLAIN
410/// docker CLI — no `compose` subcommand, no `-p` prefix (volumes are
411/// labeled, not project-scoped, at this layer). Invoked via
412/// [`ComposeRunner::run_docker`], which spawns `docker`, NOT
413/// `docker compose`, for exactly this shape.
414pub fn volume_ls_args(project: &str) -> Vec<String> {
415    vec![
416        "volume".into(),
417        "ls".into(),
418        "--filter".into(),
419        format!("label=com.docker.compose.project={project}"),
420        "--format".into(),
421        "json".into(),
422    ]
423}
424
425/// `docker ps --filter publish=<port> --format json` — host-port
426/// occupancy with attribution (research Pattern 3); also a PLAIN-docker
427/// shape via [`ComposeRunner::run_docker`].
428pub fn docker_ps_publish_args(port: u16) -> Vec<String> {
429    vec![
430        "ps".into(),
431        "--filter".into(),
432        format!("publish={port}"),
433        "--format".into(),
434        "json".into(),
435    ]
436}
437
438// ---------------------------------------------------------------------------
439// Parsers — pure functions over recorded-fixture strings.
440// ---------------------------------------------------------------------------
441
442/// One published-port mapping from a resolved compose config
443/// (container `target` → host `published`).
444#[derive(Debug, Clone, PartialEq, serde::Serialize)]
445pub struct PortMapping {
446    /// Container port.
447    pub target: u16,
448    /// Host port.
449    pub published: u16,
450}
451
452/// Parse `docker compose config --format json` output into a
453/// [`RigPlan`] skeleton: `.name` is THE identity truth (honors the
454/// rig's own `.env` `COMPOSE_PROJECT_NAME`), services are the service
455/// map's keys, `port_mappings` the collected target→published pairs,
456/// volumes the volume map's keys. Tolerant where compose is shape-shifty
457/// (`published` arrives as string OR number; the doc may be a bare
458/// object — current compose — or a single-element array — older
459/// builds); loud where identity is at stake (no `.name` → error, never
460/// an implicit directory-name project).
461pub fn parse_config(
462    stdout: &str,
463    compose_file: &Path,
464    project_dir: &Path,
465) -> Result<RigPlan, CoreError> {
466    let trimmed = stdout.trim();
467    let doc: Value = serde_json::from_str(trimmed).map_err(|err| {
468        CoreError::Rig(format!(
469            "cannot parse `docker compose config` output: {err}"
470        ))
471    })?;
472    let root = match doc {
473        Value::Array(mut items) => items.pop().unwrap_or(Value::Null),
474        Value::Object(object) => Value::Object(object),
475        other => other,
476    };
477    let name = root
478        .get("name")
479        .and_then(Value::as_str)
480        .filter(|name| !name.is_empty())
481        .ok_or_else(|| {
482            CoreError::Rig(
483                "resolved compose config carries no `.name` — the project name is the \
484                 rig's identity truth; refusing to guess (set COMPOSE_PROJECT_NAME in \
485                 the rig's .env)"
486                    .to_string(),
487            )
488        })?
489        .to_string();
490
491    let services: Vec<String> = root
492        .get("services")
493        .and_then(Value::as_object)
494        .map(|map| map.keys().cloned().collect())
495        .unwrap_or_default();
496
497    let mut port_mappings: Vec<PortMapping> = Vec::new();
498    // The gateway-service derivation (D-12): the first service (sorted
499    // key order) publishing a port targeting 8088, else the first
500    // targeting 443, else `None`. One pass over the same services map.
501    let mut gateway_service: Option<String> = None;
502    let mut gateway_service_https: Option<String> = None;
503    if let Some(services) = root.get("services").and_then(Value::as_object) {
504        for (service_name, service) in services {
505            let ports = service.get("ports").and_then(Value::as_array);
506            for port in ports.into_iter().flatten() {
507                if gateway_service.is_none() {
508                    let target = port
509                        .get("target")
510                        .and_then(|value| value.as_u64().and_then(|n| u16::try_from(n).ok()));
511                    match target {
512                        Some(GATEWAY_HTTP_TARGET) => {
513                            gateway_service = Some(service_name.clone());
514                        }
515                        Some(GATEWAY_HTTPS_TARGET) if gateway_service_https.is_none() => {
516                            gateway_service_https = Some(service_name.clone());
517                        }
518                        _ => {}
519                    }
520                }
521                // `published` is a STRING on current compose ("9088"),
522                // a number on some builds — tolerate both; entries
523                // without a published port (random host ports) don't
524                // map and are skipped.
525                let published = port.get("published").and_then(|value| {
526                    value
527                        .as_str()
528                        .and_then(|s| s.parse().ok())
529                        .or_else(|| value.as_u64().and_then(|n| u16::try_from(n).ok()))
530                });
531                let target = port
532                    .get("target")
533                    .and_then(|value| value.as_u64().and_then(|n| u16::try_from(n).ok()));
534                if let (Some(published), Some(target)) = (published, target)
535                    && !port_mappings
536                        .iter()
537                        .any(|mapping| mapping.published == published)
538                {
539                    port_mappings.push(PortMapping { target, published });
540                }
541            }
542        }
543    }
544    let host_ports = port_mappings
545        .iter()
546        .map(|mapping| mapping.published)
547        .collect();
548    let gateway_service = gateway_service.or(gateway_service_https);
549
550    let volumes: Vec<String> = root
551        .get("volumes")
552        .and_then(Value::as_object)
553        .map(|map| map.keys().cloned().collect())
554        .unwrap_or_default();
555
556    Ok(RigPlan {
557        name,
558        config_name: None,
559        compose_file: compose_file.to_path_buf(),
560        project_dir: project_dir.to_path_buf(),
561        services,
562        host_ports,
563        port_mappings,
564        volumes,
565        gateway_service,
566        modules: std::collections::BTreeMap::new(),
567    })
568}
569
570// ---------------------------------------------------------------------------
571// Runner-scripted ops (04-02)
572// ---------------------------------------------------------------------------
573
574/// The reset preview (04-02, RIG-01): the named-volume names `rig
575/// reset`'s `down -v` half will remove, reported in the result data so
576/// agents see WHAT reset took before/as it acts. Label-filtered at the
577/// docker layer ([`volume_ls_args`]) and name-filtered here — only
578/// `<project>_-prefixed` volumes are reset's to take (defense in
579/// depth; research Pitfall 4 shape: `Name` + `Labels`).
580pub async fn reset_preview(
581    runner: &dyn ComposeRunner,
582    plan: &RigPlan,
583) -> Result<Vec<String>, CoreError> {
584    let output = runner.run_docker(&volume_ls_args(&plan.name)).await;
585    let stdout = check_output(&output, "docker volume ls")?;
586    let prefix = format!("{}_", plan.name);
587    Ok(parse_volume_ls_ldjson(stdout)
588        .into_iter()
589        .map(|entry| entry.name)
590        .filter(|name| name.starts_with(&prefix))
591        .collect())
592}
593
594/// One `docker compose ps` publisher row (live-captured field names).
595#[derive(Debug, Clone, PartialEq, Deserialize)]
596pub struct Publisher {
597    /// Bind host (`"0.0.0.0"`).
598    #[serde(default, rename = "URL")]
599    pub url: Option<String>,
600    /// Container port.
601    #[serde(default, rename = "TargetPort")]
602    pub target_port: Option<u16>,
603    /// Host port.
604    #[serde(default, rename = "PublishedPort")]
605    pub published_port: Option<u16>,
606    /// `tcp`/`udp`.
607    #[serde(default, rename = "Protocol")]
608    pub protocol: Option<String>,
609}
610
611/// One `docker compose ps` service row (live-captured field names;
612/// `Health`/`Publishers` optional — services without healthchecks or
613/// ports omit them).
614#[derive(Debug, Clone, PartialEq, Deserialize)]
615pub struct ServiceStatus {
616    /// Container name (`whk-global-ignition-1`).
617    #[serde(default, rename = "Name")]
618    pub name: String,
619    /// Service name (`ignition`).
620    #[serde(default, rename = "Service")]
621    pub service: String,
622    /// `running` / `exited` / …
623    #[serde(default, rename = "State")]
624    pub state: String,
625    /// `healthy` / `starting` / absent.
626    #[serde(default, rename = "Health")]
627    pub health: Option<String>,
628    /// Last exit code.
629    #[serde(default, rename = "ExitCode")]
630    pub exit_code: Option<i64>,
631    /// Published ports with attribution.
632    #[serde(default, rename = "Publishers")]
633    pub publishers: Vec<Publisher>,
634}
635
636/// Parse LDJSON (one JSON object per line — research Pitfall 1). The
637/// per-line split IS the delimiter contract (compose never wraps a row
638/// across lines), so a stray non-JSON warning line WARNs and skips
639/// without losing the rows after it; empty output is an empty vec (a
640/// down rig has no rows).
641fn parse_ldjson<T: serde::de::DeserializeOwned>(stdout: &str, what: &str) -> Vec<T> {
642    stdout
643        .lines()
644        .filter(|line| !line.trim().is_empty())
645        .filter_map(|line| match serde_json::from_str::<T>(line) {
646            Ok(item) => Some(item),
647            Err(err) => {
648                tracing::warn!(source = what, error = %err, "skipping unparseable line");
649                None
650            }
651        })
652        .collect()
653}
654
655/// Parse `docker compose ps --format json` LDJSON into
656/// [`ServiceStatus`] rows.
657pub fn parse_ps_ldjson(stdout: &str) -> Vec<ServiceStatus> {
658    parse_ldjson(stdout, "docker compose ps")
659}
660
661/// One `docker volume ls` row (only the name is consumed by status).
662#[derive(Debug, Clone, PartialEq, Deserialize)]
663pub struct VolumeEntry {
664    /// Volume name (`<project>_<volume>`).
665    #[serde(default, rename = "Name")]
666    pub name: String,
667    /// Labels verbatim (includes `com.docker.compose.project`).
668    #[serde(default, rename = "Labels")]
669    pub labels: Value,
670}
671
672/// Parse `docker volume ls --format json` LDJSON (research Pitfall 1).
673pub fn parse_volume_ls_ldjson(stdout: &str) -> Vec<VolumeEntry> {
674    parse_ldjson(stdout, "docker volume ls")
675}
676
677/// One `docker ps --filter publish=` row, reduced to attribution: the
678/// container's (first) name and its compose project label, when it has
679/// one. `docker ps` JSON differs from `compose ps` JSON (`Names`
680/// plural, `Labels` sometimes a `"k=v,k=v"` string instead of a map) —
681/// both shapes are tolerated and fixture-pinned.
682#[derive(Debug, Clone, PartialEq)]
683pub struct DockerPsEntry {
684    /// Container name.
685    pub name: String,
686    /// `com.docker.compose.project` label, when present.
687    pub compose_project: Option<String>,
688}
689
690/// Parse `docker ps --format json` LDJSON into [`DockerPsEntry`] rows
691/// (name + compose-project attribution only).
692pub fn parse_docker_ps_ldjson(stdout: &str) -> Vec<DockerPsEntry> {
693    parse_ldjson::<serde_json::Value>(stdout, "docker ps")
694        .into_iter()
695        .filter_map(|value| {
696            // `Names` (docker ps, plural, comma-joined when multiple)
697            // with `Name` (compose ps shape) accepted as a fallback.
698            let name = value
699                .get("Names")
700                .or_else(|| value.get("Name"))
701                .and_then(Value::as_str)
702                .map(|names| names.split(',').next().unwrap_or(names).trim().to_string())
703                .unwrap_or_default();
704            if name.is_empty() {
705                return None;
706            }
707            let compose_project = match value.get("Labels") {
708                Some(Value::String(labels)) => labels.split(',').find_map(|pair| {
709                    pair.trim()
710                        .strip_prefix("com.docker.compose.project=")
711                        .map(str::to_string)
712                }),
713                Some(labels @ Value::Object(_)) => labels
714                    .get("com.docker.compose.project")
715                    .and_then(Value::as_str)
716                    .map(str::to_string),
717                _ => None,
718            };
719            Some(DockerPsEntry {
720                name,
721                compose_project,
722            })
723        })
724        .collect()
725}
726
727#[cfg(test)]
728mod tests {
729    use std::path::Path;
730
731    use super::{
732        ComposeOutput, ComposeRunner, DockerCompose, PortMapping, check_output, config_args,
733        docker_ps_publish_args, down_args, logs_args, parse_compose_version, parse_config,
734        parse_docker_ps_ldjson, parse_ps_ldjson, parse_volume_ls_ldjson, ps_args, stderr_tail,
735        up_args, volume_ls_args,
736    };
737    use crate::rig::RigPlan;
738
739    fn sample_plan() -> RigPlan {
740        RigPlan {
741            name: "ignition-devops".into(),
742            config_name: None,
743            compose_file: "/rigs/git-module/docker/docker-compose.yml".into(),
744            project_dir: "/rigs/git-module/docker".into(),
745            services: vec!["ignition".into(), "db".into()],
746            host_ports: vec![9088, 9443],
747            port_mappings: vec![
748                PortMapping {
749                    target: 8088,
750                    published: 9088,
751                },
752                PortMapping {
753                    target: 443,
754                    published: 9443,
755                },
756            ],
757            volumes: vec!["gw-data".into()],
758            gateway_service: Some("ignition".into()),
759            modules: std::collections::BTreeMap::new(),
760        }
761    }
762
763    fn s(args: &[&str]) -> Vec<String> {
764        args.iter().map(|a| a.to_string()).collect()
765    }
766
767    // ----- builder pins (the LOCKED invocation shapes) -------------------
768
769    #[test]
770    fn config_args_pinned() {
771        assert_eq!(
772            config_args(
773                Path::new("/rigs/docker/compose.yml"),
774                Path::new("/rigs/docker"),
775            ),
776            s(&[
777                "-f",
778                "/rigs/docker/compose.yml",
779                "--project-directory",
780                "/rigs/docker",
781                "config",
782                "--format",
783                "json"
784            ]),
785        );
786    }
787
788    #[test]
789    fn up_args_pinned() {
790        assert_eq!(
791            up_args(&sample_plan(), 300, &[]),
792            s(&[
793                "-p",
794                "ignition-devops",
795                "-f",
796                "/rigs/git-module/docker/docker-compose.yml",
797                "up",
798                "-d",
799                "--wait",
800                "--wait-timeout",
801                "300",
802                "--remove-orphans"
803            ]),
804        );
805    }
806
807    /// D-11: a declared override's `-f` is the SECOND `-f`, never the
808    /// first — an override landing first would invert merge precedence
809    /// and shift `--project-directory`/`.env` inference.
810    #[test]
811    fn up_args_appends_override_after_base_file() {
812        let override_path =
813            std::path::PathBuf::from("/rigs/git-module/docker/compose.ign-modules.yml");
814        assert_eq!(
815            up_args(&sample_plan(), 300, std::slice::from_ref(&override_path)),
816            s(&[
817                "-p",
818                "ignition-devops",
819                "-f",
820                "/rigs/git-module/docker/docker-compose.yml",
821                "-f",
822                "/rigs/git-module/docker/compose.ign-modules.yml",
823                "up",
824                "-d",
825                "--wait",
826                "--wait-timeout",
827                "300",
828                "--remove-orphans"
829            ]),
830        );
831    }
832
833    #[test]
834    fn down_args_pinned_with_and_without_volumes() {
835        assert_eq!(
836            down_args(&sample_plan(), false, &[]),
837            s(&[
838                "-p",
839                "ignition-devops",
840                "-f",
841                "/rigs/git-module/docker/docker-compose.yml",
842                "down",
843                "--remove-orphans"
844            ]),
845            "plain down keeps volumes (reset's -v arrives in 04-02)"
846        );
847        assert_eq!(
848            down_args(&sample_plan(), true, &[]),
849            s(&[
850                "-p",
851                "ignition-devops",
852                "-f",
853                "/rigs/git-module/docker/docker-compose.yml",
854                "down",
855                "--remove-orphans",
856                "-v"
857            ]),
858        );
859    }
860
861    #[test]
862    fn ps_args_pinned() {
863        assert_eq!(
864            ps_args(&sample_plan(), &[]),
865            s(&[
866                "-p",
867                "ignition-devops",
868                "-f",
869                "/rigs/git-module/docker/docker-compose.yml",
870                "ps",
871                "--format",
872                "json"
873            ]),
874        );
875    }
876
877    #[test]
878    fn logs_args_pinned() {
879        assert_eq!(
880            logs_args(&sample_plan(), 200, false, None, &[]),
881            s(&[
882                "-p",
883                "ignition-devops",
884                "-f",
885                "/rigs/git-module/docker/docker-compose.yml",
886                "logs",
887                "--tail",
888                "200"
889            ]),
890        );
891        assert_eq!(
892            logs_args(&sample_plan(), 50, true, Some("ignition"), &[]),
893            s(&[
894                "-p",
895                "ignition-devops",
896                "-f",
897                "/rigs/git-module/docker/docker-compose.yml",
898                "logs",
899                "--tail",
900                "50",
901                "-f",
902                "ignition"
903            ]),
904        );
905    }
906
907    /// D-11: every one of the four project-scoped builders puts the
908    /// override's `-f` AFTER the base file's `-f`, and the subcommand
909    /// token follows both — proven for all four, not just `up_args`
910    /// (Task 1 already pinned that one in
911    /// `up_args_appends_override_after_base_file`).
912    #[test]
913    fn every_builder_puts_the_override_after_the_base_file() {
914        let override_path =
915            std::path::PathBuf::from("/rigs/git-module/docker/compose.ign-modules.yml");
916        let overrides = std::slice::from_ref(&override_path);
917
918        assert_eq!(
919            down_args(&sample_plan(), false, overrides),
920            s(&[
921                "-p",
922                "ignition-devops",
923                "-f",
924                "/rigs/git-module/docker/docker-compose.yml",
925                "-f",
926                "/rigs/git-module/docker/compose.ign-modules.yml",
927                "down",
928                "--remove-orphans"
929            ]),
930        );
931        assert_eq!(
932            ps_args(&sample_plan(), overrides),
933            s(&[
934                "-p",
935                "ignition-devops",
936                "-f",
937                "/rigs/git-module/docker/docker-compose.yml",
938                "-f",
939                "/rigs/git-module/docker/compose.ign-modules.yml",
940                "ps",
941                "--format",
942                "json"
943            ]),
944        );
945        assert_eq!(
946            logs_args(&sample_plan(), 200, false, None, overrides),
947            s(&[
948                "-p",
949                "ignition-devops",
950                "-f",
951                "/rigs/git-module/docker/docker-compose.yml",
952                "-f",
953                "/rigs/git-module/docker/compose.ign-modules.yml",
954                "logs",
955                "--tail",
956                "200"
957            ]),
958        );
959        assert_eq!(
960            up_args(&sample_plan(), 300, overrides),
961            s(&[
962                "-p",
963                "ignition-devops",
964                "-f",
965                "/rigs/git-module/docker/docker-compose.yml",
966                "-f",
967                "/rigs/git-module/docker/compose.ign-modules.yml",
968                "up",
969                "-d",
970                "--wait",
971                "--wait-timeout",
972                "300",
973                "--remove-orphans"
974            ]),
975        );
976    }
977
978    /// With TWO overrides, the relative order is stable and deterministic
979    /// — driven purely by `override_files`' own order (the caller's
980    /// responsibility, e.g. `ModuleSpec::id` sort order upstream), never
981    /// by map iteration inside the builder itself.
982    #[test]
983    fn override_order_is_stable_across_two_modules() {
984        let first = std::path::PathBuf::from("/rigs/git-module/docker/compose.ign-modules-a.yml");
985        let second = std::path::PathBuf::from("/rigs/git-module/docker/compose.ign-modules-b.yml");
986        let overrides = [first.clone(), second.clone()];
987
988        assert_eq!(
989            up_args(&sample_plan(), 300, &overrides),
990            s(&[
991                "-p",
992                "ignition-devops",
993                "-f",
994                "/rigs/git-module/docker/docker-compose.yml",
995                "-f",
996                "/rigs/git-module/docker/compose.ign-modules-a.yml",
997                "-f",
998                "/rigs/git-module/docker/compose.ign-modules-b.yml",
999                "up",
1000                "-d",
1001                "--wait",
1002                "--wait-timeout",
1003                "300",
1004                "--remove-orphans"
1005            ]),
1006            "the two overrides keep the exact order they were passed in"
1007        );
1008
1009        // Reversed input order produces the reversed output order — the
1010        // builder is not silently re-sorting.
1011        let reversed = [second, first];
1012        assert_eq!(
1013            up_args(&sample_plan(), 300, &reversed),
1014            s(&[
1015                "-p",
1016                "ignition-devops",
1017                "-f",
1018                "/rigs/git-module/docker/docker-compose.yml",
1019                "-f",
1020                "/rigs/git-module/docker/compose.ign-modules-b.yml",
1021                "-f",
1022                "/rigs/git-module/docker/compose.ign-modules-a.yml",
1023                "up",
1024                "-d",
1025                "--wait",
1026                "--wait-timeout",
1027                "300",
1028                "--remove-orphans"
1029            ]),
1030        );
1031    }
1032
1033    /// D-09: `config_args` — the resolve step — must NEVER receive an
1034    /// override. It carries exactly one `-f` flag (the base file), no
1035    /// matter what. There is no `override_files` parameter to even pass
1036    /// one through, which is what this test guards against a later
1037    /// well-meaning refactor threading one in.
1038    #[test]
1039    fn config_args_never_receives_an_override() {
1040        let args = config_args(
1041            Path::new("/rigs/docker/compose.yml"),
1042            Path::new("/rigs/docker"),
1043        );
1044        assert_eq!(
1045            args,
1046            s(&[
1047                "-f",
1048                "/rigs/docker/compose.yml",
1049                "--project-directory",
1050                "/rigs/docker",
1051                "config",
1052                "--format",
1053                "json"
1054            ]),
1055        );
1056        assert_eq!(
1057            args.iter().filter(|arg| arg.as_str() == "-f").count(),
1058            1,
1059            "config_args must carry exactly one -f flag — the base file, never an override"
1060        );
1061    }
1062
1063    #[test]
1064    fn volume_ls_args_pinned_plain_docker_shape() {
1065        assert_eq!(
1066            volume_ls_args("ignition-devops"),
1067            s(&[
1068                "volume",
1069                "ls",
1070                "--filter",
1071                "label=com.docker.compose.project=ignition-devops",
1072                "--format",
1073                "json"
1074            ]),
1075            "plain docker CLI: no compose subcommand, no -p prefix"
1076        );
1077    }
1078
1079    #[test]
1080    fn docker_ps_publish_args_pinned() {
1081        assert_eq!(
1082            docker_ps_publish_args(18088),
1083            s(&["ps", "--filter", "publish=18088", "--format", "json"]),
1084        );
1085    }
1086
1087    // ----- version parse -------------------------------------------------
1088
1089    #[test]
1090    fn version_parses_live_capture_and_rejects_old_or_garbage() {
1091        // Live v5.1.2 capture (research).
1092        assert_eq!(
1093            parse_compose_version("Docker Compose version v5.1.2\n"),
1094            Some("5.1.2".into())
1095        );
1096        assert_eq!(
1097            parse_compose_version("Docker Compose version v2.24.6\n"),
1098            Some("2.24.6".into())
1099        );
1100        // Unversioned builds exist ("Docker Compose version 2.34.0").
1101        assert_eq!(
1102            parse_compose_version("Docker Compose version 2.34.0\n"),
1103            Some("2.34.0".into())
1104        );
1105        // v1-shaped output and garbage are unusable.
1106        assert_eq!(parse_compose_version("1.29.2"), None);
1107        assert_eq!(parse_compose_version(""), None);
1108        assert_eq!(parse_compose_version("docker-compose 1.25.5"), None);
1109    }
1110
1111    // ----- exit mapping ---------------------------------------------------
1112
1113    #[test]
1114    fn check_output_maps_nonzero_to_rig_with_stderr_tail() {
1115        let ok = ComposeOutput {
1116            stdout: "{}".into(),
1117            stderr: String::new(),
1118            code: 0,
1119        };
1120        assert_eq!(check_output(&ok, "ctx").unwrap(), "{}");
1121
1122        let tail = (1..=9).map(|n| format!("line-{n}")).collect::<Vec<_>>();
1123        let failed = ComposeOutput {
1124            stdout: String::new(),
1125            stderr: tail.join("\n"),
1126            code: 1,
1127        };
1128        let err = check_output(&failed, "docker compose up").unwrap_err();
1129        assert!(matches!(err, crate::error::CoreError::Rig(_)));
1130        let message = err.to_string();
1131        assert!(
1132            message.contains("docker compose up failed (exit 1)"),
1133            "{message}"
1134        );
1135        // The tail is the LAST ~5 lines only.
1136        assert!(
1137            !message.contains("line-4\n"),
1138            "tail keeps at most 5 lines: {message}"
1139        );
1140        assert!(message.contains("line-5"));
1141        assert!(message.contains("line-9"));
1142    }
1143
1144    #[test]
1145    fn stderr_tail_trims_and_keeps_order() {
1146        assert_eq!(stderr_tail("b\na\n"), "b\na");
1147        assert_eq!(stderr_tail(""), "");
1148        assert_eq!(stderr_tail("\n\n"), "");
1149    }
1150
1151    // ----- parse_config ---------------------------------------------------
1152
1153    /// The whk-environment-orchestration-shaped resolve fixture:
1154    /// `.name` from the rig's own `.env`, string-form `published`, a
1155    /// volumes map, and ignored top-level keys.
1156    const CONFIG_FIXTURE: &str = r#"{
1157        "name": "whk-global",
1158        "services": {
1159            "ignition": {
1160                "image": "inductiveautomation/ignition:8.3.6",
1161                "ports": [
1162                    {"mode": "host", "target": 8088, "published": "9088", "protocol": "tcp"},
1163                    {"mode": "host", "target": 443, "published": "9443", "protocol": "tcp"}
1164                ]
1165            },
1166            "git-server": {
1167                "image": "git-server:latest",
1168                "ports": [{"mode": "host", "target": 22, "published": "9022", "protocol": "tcp"}]
1169            }
1170        },
1171        "volumes": {
1172            "gw-data": {"name": "whk-global_gw-data", "driver": "local"},
1173            "gw-tag-definition": {"name": "whk-global_gw-tag-definition"}
1174        },
1175        "networks": {"default": {"name": "whk-global_default"}},
1176        "secrets": {}
1177    }"#;
1178
1179    #[test]
1180    fn parse_config_reads_name_services_ports_volumes() {
1181        let plan = parse_config(
1182            CONFIG_FIXTURE,
1183            Path::new("/whk/whk-environment-orchestration/docker-compose.yml"),
1184            Path::new("/whk/whk-environment-orchestration"),
1185        )
1186        .expect("fixture parses");
1187        assert_eq!(plan.name, "whk-global");
1188        assert_eq!(plan.services, vec!["git-server", "ignition"]);
1189        // Services iterate sorted (serde_json's map) → ports arrive in
1190        // service order: git-server's 9022 first.
1191        assert_eq!(plan.host_ports, vec![9022, 9088, 9443]);
1192        assert!(
1193            plan.port_mappings.contains(&PortMapping {
1194                target: 8088,
1195                published: 9088
1196            }),
1197            "string-form `published` tolerated: {:?}",
1198            plan.port_mappings
1199        );
1200        assert_eq!(plan.volumes, vec!["gw-data", "gw-tag-definition"]);
1201        assert_eq!(
1202            plan.compose_file,
1203            Path::new("/whk/whk-environment-orchestration/docker-compose.yml")
1204        );
1205        assert_eq!(
1206            plan.project_dir,
1207            Path::new("/whk/whk-environment-orchestration")
1208        );
1209    }
1210
1211    #[test]
1212    fn parse_config_tolerates_array_doc_and_numeric_published() {
1213        // Older compose wraps the config doc in a single-element array,
1214        // and some builds emit numeric `published`.
1215        let array_form = format!("[{}]", CONFIG_FIXTURE.replace("\"9088\"", "9088"));
1216        let plan = parse_config(&array_form, Path::new("/c.yml"), Path::new("/"))
1217            .expect("array doc + numeric published parse");
1218        assert_eq!(plan.name, "whk-global");
1219        assert!(plan.host_ports.contains(&9088));
1220    }
1221
1222    #[test]
1223    fn parse_config_without_name_refuses() {
1224        let fixture = r#"{"services": {"ignition": {}}}"#;
1225        let err = parse_config(fixture, Path::new("/c.yml"), Path::new("/")).unwrap_err();
1226        let message = err.to_string();
1227        assert!(message.contains("no `.name`"), "{message}");
1228        assert!(message.contains("COMPOSE_PROJECT_NAME"), "{message}");
1229    }
1230
1231    #[test]
1232    fn parse_config_empty_services_and_missing_sections() {
1233        let plan = parse_config(r#"{"name": "bare"}"#, Path::new("/c.yml"), Path::new("/"))
1234            .expect("minimal doc parses");
1235        assert!(plan.services.is_empty());
1236        assert!(plan.host_ports.is_empty());
1237        assert!(plan.volumes.is_empty());
1238    }
1239
1240    /// Research Open Question 4, resolved empirically: the rig's own
1241    /// `.env` `COMPOSE_PROJECT_NAME` governs the resolved `.name` even
1242    /// when `ign` runs from an unrelated cwd — because `config_args`
1243    /// ALWAYS passes `--project-directory` (Pitfall 8). Runs the REAL
1244    /// docker CLI (config is client-side; no daemon needed) and skips
1245    /// quietly when docker is absent (CI).
1246    #[tokio::test]
1247    async fn config_resolves_env_project_name_from_project_directory() {
1248        let dir = tempfile::tempdir().expect("tempdir");
1249        let compose = dir.path().join("docker-compose.yml");
1250        std::fs::write(
1251            &compose,
1252            "services:\n  sidecar:\n    image: alpine:latest\n",
1253        )
1254        .expect("write compose");
1255        std::fs::write(
1256            dir.path().join(".env"),
1257            "COMPOSE_PROJECT_NAME=env-resolved-name\n",
1258        )
1259        .expect("write .env");
1260
1261        let runner = DockerCompose;
1262        if runner.run(&["version".to_string()]).await.code != 0 {
1263            eprintln!("skipping: docker compose unavailable");
1264            return;
1265        }
1266        // The spawn's cwd is the TEST binary's dir — nowhere near the
1267        // fixture — which is exactly the cwd-elsewhere case.
1268        let output = runner.run(&config_args(&compose, dir.path())).await;
1269        assert_eq!(output.code, 0, "config run: {}", output.stderr);
1270        let plan = parse_config(&output.stdout, &compose, dir.path()).expect("resolve parses");
1271        assert_eq!(
1272            plan.name, "env-resolved-name",
1273            "the .env name wins over the directory-derived default"
1274        );
1275    }
1276
1277    /// The STREAMING spawn (04-02): piped stdout forwarded line-by-line
1278    /// to the sink, stderr drained, exit reported. Runs the REAL docker
1279    /// CLI (`logs` on an absent project is exit 0 + empty output on
1280    /// compose v2 — no daemon-side state needed) and skips quietly when
1281    /// docker is absent (CI). Client-side proof of the follow seam.
1282    #[tokio::test]
1283    async fn run_streaming_forwards_lines_via_piped_stdout() {
1284        let dir = tempfile::tempdir().expect("tempdir");
1285        let compose = dir.path().join("docker-compose.yml");
1286        std::fs::write(
1287            &compose,
1288            "services:\n  sidecar:\n    image: alpine:latest\n",
1289        )
1290        .expect("write compose");
1291
1292        let runner = DockerCompose;
1293        if runner.run(&["version".to_string()]).await.code != 0 {
1294            eprintln!("skipping: docker compose unavailable");
1295            return;
1296        }
1297        // `compose version` is CLIENT-side; the `compose logs` below
1298        // needs a LIVE daemon — its absent-project exit-0 contract only
1299        // holds when the daemon answers. Probe the daemon (plain
1300        // `docker version` exits nonzero when unreachable — e.g. an
1301        // auto-stopped OrbStack) so a daemon-less machine skips as
1302        // quietly as a docker-less one.
1303        if runner.run_docker(&["version".to_string()]).await.code != 0 {
1304            eprintln!("skipping: docker daemon unreachable");
1305            return;
1306        }
1307        let plan = parse_config(
1308            r#"{"name":"stream-fixture","services":{"sidecar":{"image":"alpine"}}}"#,
1309            &compose,
1310            dir.path(),
1311        )
1312        .expect("plan parses");
1313        let mut streamed = 0usize;
1314        let mut sink = |_: &str| streamed += 1;
1315        let output = runner
1316            .run_streaming(&logs_args(&plan, 5, false, None, &[]), &mut sink)
1317            .await;
1318        assert_eq!(
1319            output.code, 0,
1320            "compose logs on an absent project: {}",
1321            output.stderr
1322        );
1323        assert_eq!(
1324            streamed, 0,
1325            "no containers → no lines, but the spawn/read/wait ran"
1326        );
1327        assert!(
1328            output.stdout.is_empty(),
1329            "streamed stdout never reports back"
1330        );
1331    }
1332
1333    // ----- LDJSON parsers (research Pitfall 1: BOTH conventions pinned) --
1334
1335    /// Live-captured compose ps row shape (whk-global-style): one
1336    /// object per line, Health/Publishers present on this row.
1337    const PS_ROW_FULL: &str = r#"{"Command":"\"/usr/bin/start-ignition.sh\"","CreatedAt":"2026-08-22 10:00:00 +0000 UTC","ExitCode":0,"Health":"healthy","Labels":{"com.docker.compose.project":"whk-global","com.docker.compose.service":"ignition"},"Name":"whk-global-ignition-1","Networks":"whk-global_default","Ports":"0.0.0.0:9088->8088/tcp, 0.0.0.0:9443->443/tcp","Publishers":[{"URL":"0.0.0.0","TargetPort":8088,"PublishedPort":9088,"Protocol":"tcp"},{"URL":"0.0.0.0","TargetPort":443,"PublishedPort":9443,"Protocol":"tcp"}],"RunningFor":"2 hours","Service":"ignition","State":"running","Status":"Up 2 hours (healthy)"}"#;
1338
1339    /// A second row WITHOUT Health/Publishers (service publishes no
1340    /// ports, no healthcheck) — the optional-fields tolerance pin.
1341    const PS_ROW_MINIMAL: &str = r#"{"Command":"sleep","ExitCode":0,"Name":"whk-global-sidecar-1","Service":"sidecar","State":"exited","Status":"Exited (0) 3 minutes ago"}"#;
1342
1343    #[test]
1344    fn parse_ps_ldjson_reads_rows_and_tolerates_missing_optionals() {
1345        let rows = parse_ps_ldjson(&format!("{PS_ROW_FULL}\n{PS_ROW_MINIMAL}\n"));
1346        assert_eq!(rows.len(), 2);
1347        assert_eq!(rows[0].name, "whk-global-ignition-1");
1348        assert_eq!(rows[0].service, "ignition");
1349        assert_eq!(rows[0].state, "running");
1350        assert_eq!(rows[0].health.as_deref(), Some("healthy"));
1351        assert_eq!(rows[0].exit_code, Some(0));
1352        assert_eq!(rows[0].publishers.len(), 2);
1353        assert_eq!(rows[0].publishers[0].target_port, Some(8088));
1354        assert_eq!(rows[0].publishers[0].published_port, Some(9088));
1355        assert_eq!(rows[0].publishers[0].protocol.as_deref(), Some("tcp"));
1356
1357        assert_eq!(rows[1].health, None);
1358        assert!(rows[1].publishers.is_empty());
1359    }
1360
1361    #[test]
1362    fn parse_ps_ldjson_empty_and_warning_lines() {
1363        assert!(parse_ps_ldjson("").is_empty(), "a down rig has no rows");
1364        // A stray non-JSON warning line skips (warn), the rows survive.
1365        let rows = parse_ps_ldjson(&format!("[NOTE] something\n{PS_ROW_MINIMAL}\n"));
1366        assert_eq!(rows.len(), 1);
1367    }
1368
1369    #[test]
1370    fn parse_volume_ls_ldjson_fixture() {
1371        let stdout = concat!(
1372            r#"{"CreatedAt":"2026-08-22T10:00:00Z","Driver":"local","Labels":{"com.docker.compose.project":"ignition-devops","com.docker.compose.volume":"gw-data"},"Mountpoint":"/var/lib/docker/volumes/ignition-devops_gw-data/_data","Name":"ignition-devops_gw-data","Options":null,"Scope":"local"}"#,
1373            "\n",
1374            r#"{"CreatedAt":"2026-08-22T10:00:00Z","Driver":"local","Labels":{"com.docker.compose.project":"ignition-devops","com.docker.compose.volume":"gw-tag-definition"},"Mountpoint":"/var/lib/docker/volumes/ignition-devops_gw-tag-definition/_data","Name":"ignition-devops_gw-tag-definition","Options":null,"Scope":"local"}"#,
1375            "\n",
1376        );
1377        let volumes = parse_volume_ls_ldjson(stdout);
1378        assert_eq!(volumes.len(), 2);
1379        assert_eq!(volumes[0].name, "ignition-devops_gw-data");
1380        assert_eq!(volumes[1].name, "ignition-devops_gw-tag-definition");
1381        assert!(parse_volume_ls_ldjson("").is_empty());
1382    }
1383
1384    #[test]
1385    fn parse_docker_ps_labels_as_string_and_map() {
1386        // docker ps emits `Names` (plural) and Labels as a k=v string.
1387        let string_labels = r#"{"ID":"abc123","Image":"alpine","Labels":"com.docker.compose.project=other-project,com.docker.compose.service=sidecar","Names":"other-project-sidecar-1","Ports":"0.0.0.0:18088->8088/tcp","State":"running"}"#;
1388        let rows = parse_docker_ps_ldjson(string_labels);
1389        assert_eq!(rows.len(), 1);
1390        assert_eq!(rows[0].name, "other-project-sidecar-1");
1391        assert_eq!(rows[0].compose_project.as_deref(), Some("other-project"));
1392
1393        // The compose-ps-style map labels + Name key are accepted too.
1394        let map_labels =
1395            r#"{"Name":"ign-research","Labels":{"com.docker.compose.project":"research"}}"#;
1396        let rows = parse_docker_ps_ldjson(map_labels);
1397        assert_eq!(rows[0].name, "ign-research");
1398        assert_eq!(rows[0].compose_project.as_deref(), Some("research"));
1399
1400        // Non-compose containers attribute with no project.
1401        let rows = parse_docker_ps_ldjson(r#"{"Names":"standalone-thing","Labels":""}"#);
1402        assert_eq!(rows[0].compose_project, None);
1403        assert!(parse_docker_ps_ldjson("").is_empty());
1404    }
1405}