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/// How many stderr lines ride a failed invocation's error message
46/// (research Pitfall 3: compose's tail is the diagnosis).
47const STDERR_TAIL_LINES: usize = 5;
48
49/// One completed `docker …` invocation: captured output + exit code.
50/// A spawn FAILURE (no docker binary) is `code: 127` with the spawn
51/// error in `stderr` — one error path, no io::Error leakage.
52#[derive(Debug, Clone, PartialEq)]
53pub struct ComposeOutput {
54    /// Captured stdout (UTF-8 lossy).
55    pub stdout: String,
56    /// Captured stderr (UTF-8 lossy).
57    pub stderr: String,
58    /// Process exit code (`-1` when terminated by signal).
59    pub code: i32,
60}
61
62/// The process seam for everything rig-related. Actions NEVER spawn
63/// processes directly — they script this trait (the GatewayApi
64/// precedent), which is what makes the whole family testable without
65/// docker.
66#[async_trait::async_trait]
67pub trait ComposeRunner: Send + Sync {
68    /// Run `docker compose <args…>` and capture stdout/stderr/exit.
69    async fn run(&self, args: &[String]) -> ComposeOutput;
70    /// Run the PLAIN docker CLI (`docker <args…>`) — the volume-ls and
71    /// port-attribution shapes that are NOT compose project ops.
72    async fn run_docker(&self, args: &[String]) -> ComposeOutput;
73    /// Stream `docker compose <args…>`: stdout forwards to `line_sink`
74    /// LINE-BY-LINE as it arrives (piped stdout — `logs -f` follow
75    /// mode), stderr is captured for diagnostics (a failed invocation
76    /// carries its tail in the error; a successful one's diagnostics
77    /// go to OUR stderr via tracing — NEVER the data stream). The
78    /// returned [`ComposeOutput`] carries EMPTY stdout: the lines
79    /// already went to the sink (fakes replay a preloaded stdout
80    /// through the sink — the keep-it-simple contract).
81    async fn run_streaming(
82        &self,
83        args: &[String],
84        line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
85    ) -> ComposeOutput;
86}
87
88/// Production [`ComposeRunner`]: shells out to the real `docker` binary
89/// via `tokio::process::Command`.
90pub struct DockerCompose;
91
92impl DockerCompose {
93    /// Spawn `program` with `args`, capture everything. Spawn failure
94    /// (binary absent) maps to the shell's 127 with the reason in
95    /// stderr — callers translate nonzero exits uniformly.
96    async fn spawn(program: &str, args: &[String]) -> ComposeOutput {
97        let joined = std::iter::once(program.to_string())
98            .chain(args.iter().cloned())
99            .collect::<Vec<_>>()
100            .join(" ");
101        match tokio::process::Command::new(program)
102            .args(args)
103            .stdout(std::process::Stdio::piped())
104            .stderr(std::process::Stdio::piped())
105            .output()
106            .await
107        {
108            Ok(output) => ComposeOutput {
109                stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
110                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
111                code: output.status.code().unwrap_or(-1),
112            },
113            Err(err) => ComposeOutput {
114                stdout: String::new(),
115                stderr: format!("failed to spawn `{joined}`: {err}"),
116                code: 127,
117            },
118        }
119    }
120
121    /// The STREAMING spawn (04-02): piped stdout read line-by-line
122    /// into `line_sink` until EOF, stderr drained CONCURRENTLY (a full
123    /// stderr pipe would deadlock the stdout reader), then the wait —
124    /// the child streams until it exits (Ctrl-C kills the whole
125    /// foreground process group, the `logs -f` precedent).
126    async fn spawn_streaming(
127        program: &str,
128        args: &[String],
129        line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
130    ) -> ComposeOutput {
131        use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
132
133        let joined = std::iter::once(program.to_string())
134            .chain(args.iter().cloned())
135            .collect::<Vec<_>>()
136            .join(" ");
137        let mut child = match tokio::process::Command::new(program)
138            .args(args)
139            .stdout(std::process::Stdio::piped())
140            .stderr(std::process::Stdio::piped())
141            .spawn()
142        {
143            Ok(child) => child,
144            Err(err) => {
145                return ComposeOutput {
146                    stdout: String::new(),
147                    stderr: format!("failed to spawn `{joined}`: {err}"),
148                    code: 127,
149                };
150            }
151        };
152        let stdout = child.stdout.take().expect("stdout piped above");
153        let mut stderr = child.stderr.take().expect("stderr piped above");
154        // Concurrent stderr drain — the join only fails on task panic.
155        let stderr_task = tokio::spawn(async move {
156            let mut buffer = String::new();
157            let _ = stderr.read_to_string(&mut buffer).await;
158            buffer
159        });
160        let mut lines = BufReader::new(stdout).lines();
161        while let Ok(Some(line)) = lines.next_line().await {
162            line_sink(&line);
163        }
164        match child.wait().await {
165            Ok(status) => ComposeOutput {
166                stdout: String::new(),
167                stderr: stderr_task.await.unwrap_or_default(),
168                code: status.code().unwrap_or(-1),
169            },
170            Err(err) => ComposeOutput {
171                stdout: String::new(),
172                stderr: format!("failed to wait for `{joined}`: {err}"),
173                code: -1,
174            },
175        }
176    }
177}
178
179#[async_trait::async_trait]
180impl ComposeRunner for DockerCompose {
181    async fn run(&self, args: &[String]) -> ComposeOutput {
182        let mut full = vec!["compose".to_string()];
183        full.extend(args.iter().cloned());
184        Self::spawn("docker", &full).await
185    }
186
187    async fn run_docker(&self, args: &[String]) -> ComposeOutput {
188        Self::spawn("docker", args).await
189    }
190
191    async fn run_streaming(
192        &self,
193        args: &[String],
194        line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
195    ) -> ComposeOutput {
196        let mut full = vec!["compose".to_string()];
197        full.extend(args.iter().cloned());
198        Self::spawn_streaming("docker", &full, line_sink).await
199    }
200}
201
202/// Map a completed invocation: exit 0 → the stdout str; nonzero →
203/// [`CoreError::Rig`] carrying the stderr tail (last
204/// [`STDERR_TAIL_LINES`] lines — research Pitfall 3).
205pub fn check_output<'a>(output: &'a ComposeOutput, context: &str) -> Result<&'a str, CoreError> {
206    if output.code == 0 {
207        Ok(&output.stdout)
208    } else {
209        let tail = stderr_tail(&output.stderr);
210        let note = if tail.is_empty() {
211            String::new()
212        } else {
213            format!(": {tail}")
214        };
215        Err(CoreError::Rig(format!(
216            "{context} failed (exit {}){note}",
217            output.code
218        )))
219    }
220}
221
222/// The last `lines` stderr lines, in order, trimmed — the diagnosis
223/// tail compose prints on failure.
224pub(crate) fn stderr_tail(stderr: &str) -> String {
225    stderr
226        .lines()
227        .rev()
228        .take(STDERR_TAIL_LINES)
229        .collect::<Vec<_>>()
230        .into_iter()
231        .rev()
232        .collect::<Vec<_>>()
233        .join("\n")
234        .trim()
235        .to_string()
236}
237
238/// Verify `docker compose` answers with major version ≥ 2 (the v1
239/// `docker-compose` Python binary never answers to `docker compose` —
240/// absence IS the install-hint case). Returns the version string
241/// (e.g. `"5.1.2"`).
242pub async fn compose_version(runner: &dyn ComposeRunner) -> Result<String, CoreError> {
243    let output = runner.run(&["version".to_string()]).await;
244    if output.code != 0 {
245        return Err(CoreError::Rig(format!(
246            "docker compose is unavailable (exit {}): {} — install Docker Desktop or \
247             the compose v2 plugin; the legacy docker-compose v1 binary is not supported",
248            output.code,
249            stderr_tail(&output.stderr)
250        )));
251    }
252    parse_compose_version(&output.stdout).ok_or_else(|| {
253        CoreError::Rig(format!(
254            "cannot parse `docker compose version` output ({:?}) — compose ≥ v2 \
255             (the `docker compose` plugin) is required",
256            output.stdout.trim()
257        ))
258    })
259}
260
261/// `"Docker Compose version v5.1.2"` → `Some("5.1.2")`; anything else —
262/// including a major < 2 — is `None`. Pure; pinned by tests.
263fn parse_compose_version(stdout: &str) -> Option<String> {
264    let rest = stdout.trim().strip_prefix("Docker Compose version ")?;
265    let version = rest.split_whitespace().next()?;
266    let version = version.strip_prefix('v').unwrap_or(version);
267    let mut parts = version.split('.');
268    let major: u32 = parts.next()?.parse().ok()?;
269    if major < 2 {
270        return None;
271    }
272    Some(version.to_string())
273}
274
275// ---------------------------------------------------------------------------
276// Arg builders — pure, exact-vector-pinned below.
277// ---------------------------------------------------------------------------
278
279/// The resolve step (research Pattern 1): `docker compose -f <file>
280/// --project-directory <dir> config --format json`. `--project-directory`
281/// is ALWAYS explicit — `.env` (and thus `COMPOSE_PROJECT_NAME`)
282/// loading is cwd-sensitive (Pitfall 8).
283pub fn config_args(file: &Path, project_dir: &Path) -> Vec<String> {
284    vec![
285        "-f".into(),
286        file.display().to_string(),
287        "--project-directory".into(),
288        project_dir.display().to_string(),
289        "config".into(),
290        "--format".into(),
291        "json".into(),
292    ]
293}
294
295/// `up` (research LOCKED shape): explicit `-p <name>` (never an
296/// implicit directory-name project), detached + `--wait` with an
297/// EXPLICIT timeout (Pitfall 3: `--wait` blocks on healthchecks and
298/// image pulls), `--remove-orphans`.
299pub fn up_args(plan: &RigPlan, wait_timeout_s: u64) -> Vec<String> {
300    vec![
301        "-p".into(),
302        plan.name.clone(),
303        "-f".into(),
304        plan.compose_file.display().to_string(),
305        "up".into(),
306        "-d".into(),
307        "--wait".into(),
308        "--wait-timeout".into(),
309        wait_timeout_s.to_string(),
310        "--remove-orphans".into(),
311    ]
312}
313
314/// `down`: stop + remove containers/networks; `--remove-orphans` always
315/// (Pitfall 4); `-v` (named+anonymous volume deletion) only for the
316/// reset teardown half (04-02).
317pub fn down_args(plan: &RigPlan, volumes: bool) -> Vec<String> {
318    let mut args = vec![
319        "-p".into(),
320        plan.name.clone(),
321        "-f".into(),
322        plan.compose_file.display().to_string(),
323        "down".into(),
324        "--remove-orphans".into(),
325    ];
326    if volumes {
327        args.push("-v".into());
328    }
329    args
330}
331
332/// `ps` as LDJSON (research Pitfall 1): one object per service.
333pub fn ps_args(plan: &RigPlan) -> Vec<String> {
334    vec![
335        "-p".into(),
336        plan.name.clone(),
337        "-f".into(),
338        plan.compose_file.display().to_string(),
339        "ps".into(),
340        "--format".into(),
341        "json".into(),
342    ]
343}
344
345/// `logs` (human-form passthrough by design — the streaming exception
346/// when `--follow`; wired by 04-02's `rig_logs` via `run` one-shot /
347/// `run_streaming` follow). Invocation shape LOCKED from day one.
348pub fn logs_args(plan: &RigPlan, tail: u32, follow: bool, service: Option<&str>) -> Vec<String> {
349    let mut args = vec![
350        "-p".into(),
351        plan.name.clone(),
352        "-f".into(),
353        plan.compose_file.display().to_string(),
354        "logs".into(),
355        "--tail".into(),
356        tail.to_string(),
357    ];
358    if follow {
359        args.push("-f".into());
360    }
361    if let Some(service) = service {
362        args.push(service.to_string());
363    }
364    args
365}
366
367/// `docker volume ls` (04-01 status / 04-02 reset preview): PLAIN
368/// docker CLI — no `compose` subcommand, no `-p` prefix (volumes are
369/// labeled, not project-scoped, at this layer). Invoked via
370/// [`ComposeRunner::run_docker`], which spawns `docker`, NOT
371/// `docker compose`, for exactly this shape.
372pub fn volume_ls_args(project: &str) -> Vec<String> {
373    vec![
374        "volume".into(),
375        "ls".into(),
376        "--filter".into(),
377        format!("label=com.docker.compose.project={project}"),
378        "--format".into(),
379        "json".into(),
380    ]
381}
382
383/// `docker ps --filter publish=<port> --format json` — host-port
384/// occupancy with attribution (research Pattern 3); also a PLAIN-docker
385/// shape via [`ComposeRunner::run_docker`].
386pub fn docker_ps_publish_args(port: u16) -> Vec<String> {
387    vec![
388        "ps".into(),
389        "--filter".into(),
390        format!("publish={port}"),
391        "--format".into(),
392        "json".into(),
393    ]
394}
395
396// ---------------------------------------------------------------------------
397// Parsers — pure functions over recorded-fixture strings.
398// ---------------------------------------------------------------------------
399
400/// One published-port mapping from a resolved compose config
401/// (container `target` → host `published`).
402#[derive(Debug, Clone, PartialEq, serde::Serialize)]
403pub struct PortMapping {
404    /// Container port.
405    pub target: u16,
406    /// Host port.
407    pub published: u16,
408}
409
410/// Parse `docker compose config --format json` output into a
411/// [`RigPlan`] skeleton: `.name` is THE identity truth (honors the
412/// rig's own `.env` `COMPOSE_PROJECT_NAME`), services are the service
413/// map's keys, `port_mappings` the collected target→published pairs,
414/// volumes the volume map's keys. Tolerant where compose is shape-shifty
415/// (`published` arrives as string OR number; the doc may be a bare
416/// object — current compose — or a single-element array — older
417/// builds); loud where identity is at stake (no `.name` → error, never
418/// an implicit directory-name project).
419pub fn parse_config(
420    stdout: &str,
421    compose_file: &Path,
422    project_dir: &Path,
423) -> Result<RigPlan, CoreError> {
424    let trimmed = stdout.trim();
425    let doc: Value = serde_json::from_str(trimmed).map_err(|err| {
426        CoreError::Rig(format!(
427            "cannot parse `docker compose config` output: {err}"
428        ))
429    })?;
430    let root = match doc {
431        Value::Array(mut items) => items.pop().unwrap_or(Value::Null),
432        Value::Object(object) => Value::Object(object),
433        other => other,
434    };
435    let name = root
436        .get("name")
437        .and_then(Value::as_str)
438        .filter(|name| !name.is_empty())
439        .ok_or_else(|| {
440            CoreError::Rig(
441                "resolved compose config carries no `.name` — the project name is the \
442                 rig's identity truth; refusing to guess (set COMPOSE_PROJECT_NAME in \
443                 the rig's .env)"
444                    .to_string(),
445            )
446        })?
447        .to_string();
448
449    let services: Vec<String> = root
450        .get("services")
451        .and_then(Value::as_object)
452        .map(|map| map.keys().cloned().collect())
453        .unwrap_or_default();
454
455    let mut port_mappings: Vec<PortMapping> = Vec::new();
456    if let Some(services) = root.get("services").and_then(Value::as_object) {
457        for service in services.values() {
458            let ports = service.get("ports").and_then(Value::as_array);
459            for port in ports.into_iter().flatten() {
460                // `published` is a STRING on current compose ("9088"),
461                // a number on some builds — tolerate both; entries
462                // without a published port (random host ports) don't
463                // map and are skipped.
464                let published = port.get("published").and_then(|value| {
465                    value
466                        .as_str()
467                        .and_then(|s| s.parse().ok())
468                        .or_else(|| value.as_u64().and_then(|n| u16::try_from(n).ok()))
469                });
470                let target = port
471                    .get("target")
472                    .and_then(|value| value.as_u64().and_then(|n| u16::try_from(n).ok()));
473                if let (Some(published), Some(target)) = (published, target)
474                    && !port_mappings
475                        .iter()
476                        .any(|mapping| mapping.published == published)
477                {
478                    port_mappings.push(PortMapping { target, published });
479                }
480            }
481        }
482    }
483    let host_ports = port_mappings
484        .iter()
485        .map(|mapping| mapping.published)
486        .collect();
487
488    let volumes: Vec<String> = root
489        .get("volumes")
490        .and_then(Value::as_object)
491        .map(|map| map.keys().cloned().collect())
492        .unwrap_or_default();
493
494    Ok(RigPlan {
495        name,
496        compose_file: compose_file.to_path_buf(),
497        project_dir: project_dir.to_path_buf(),
498        services,
499        host_ports,
500        port_mappings,
501        volumes,
502    })
503}
504
505// ---------------------------------------------------------------------------
506// Runner-scripted ops (04-02)
507// ---------------------------------------------------------------------------
508
509/// The reset preview (04-02, RIG-01): the named-volume names `rig
510/// reset`'s `down -v` half will remove, reported in the result data so
511/// agents see WHAT reset took before/as it acts. Label-filtered at the
512/// docker layer ([`volume_ls_args`]) and name-filtered here — only
513/// `<project>_-prefixed` volumes are reset's to take (defense in
514/// depth; research Pitfall 4 shape: `Name` + `Labels`).
515pub async fn reset_preview(
516    runner: &dyn ComposeRunner,
517    plan: &RigPlan,
518) -> Result<Vec<String>, CoreError> {
519    let output = runner.run_docker(&volume_ls_args(&plan.name)).await;
520    let stdout = check_output(&output, "docker volume ls")?;
521    let prefix = format!("{}_", plan.name);
522    Ok(parse_volume_ls_ldjson(stdout)
523        .into_iter()
524        .map(|entry| entry.name)
525        .filter(|name| name.starts_with(&prefix))
526        .collect())
527}
528
529/// One `docker compose ps` publisher row (live-captured field names).
530#[derive(Debug, Clone, PartialEq, Deserialize)]
531pub struct Publisher {
532    /// Bind host (`"0.0.0.0"`).
533    #[serde(default, rename = "URL")]
534    pub url: Option<String>,
535    /// Container port.
536    #[serde(default, rename = "TargetPort")]
537    pub target_port: Option<u16>,
538    /// Host port.
539    #[serde(default, rename = "PublishedPort")]
540    pub published_port: Option<u16>,
541    /// `tcp`/`udp`.
542    #[serde(default, rename = "Protocol")]
543    pub protocol: Option<String>,
544}
545
546/// One `docker compose ps` service row (live-captured field names;
547/// `Health`/`Publishers` optional — services without healthchecks or
548/// ports omit them).
549#[derive(Debug, Clone, PartialEq, Deserialize)]
550pub struct ServiceStatus {
551    /// Container name (`whk-global-ignition-1`).
552    #[serde(default, rename = "Name")]
553    pub name: String,
554    /// Service name (`ignition`).
555    #[serde(default, rename = "Service")]
556    pub service: String,
557    /// `running` / `exited` / …
558    #[serde(default, rename = "State")]
559    pub state: String,
560    /// `healthy` / `starting` / absent.
561    #[serde(default, rename = "Health")]
562    pub health: Option<String>,
563    /// Last exit code.
564    #[serde(default, rename = "ExitCode")]
565    pub exit_code: Option<i64>,
566    /// Published ports with attribution.
567    #[serde(default, rename = "Publishers")]
568    pub publishers: Vec<Publisher>,
569}
570
571/// Parse LDJSON (one JSON object per line — research Pitfall 1). The
572/// per-line split IS the delimiter contract (compose never wraps a row
573/// across lines), so a stray non-JSON warning line WARNs and skips
574/// without losing the rows after it; empty output is an empty vec (a
575/// down rig has no rows).
576fn parse_ldjson<T: serde::de::DeserializeOwned>(stdout: &str, what: &str) -> Vec<T> {
577    stdout
578        .lines()
579        .filter(|line| !line.trim().is_empty())
580        .filter_map(|line| match serde_json::from_str::<T>(line) {
581            Ok(item) => Some(item),
582            Err(err) => {
583                tracing::warn!(source = what, error = %err, "skipping unparseable line");
584                None
585            }
586        })
587        .collect()
588}
589
590/// Parse `docker compose ps --format json` LDJSON into
591/// [`ServiceStatus`] rows.
592pub fn parse_ps_ldjson(stdout: &str) -> Vec<ServiceStatus> {
593    parse_ldjson(stdout, "docker compose ps")
594}
595
596/// One `docker volume ls` row (only the name is consumed by status).
597#[derive(Debug, Clone, PartialEq, Deserialize)]
598pub struct VolumeEntry {
599    /// Volume name (`<project>_<volume>`).
600    #[serde(default, rename = "Name")]
601    pub name: String,
602    /// Labels verbatim (includes `com.docker.compose.project`).
603    #[serde(default, rename = "Labels")]
604    pub labels: Value,
605}
606
607/// Parse `docker volume ls --format json` LDJSON (research Pitfall 1).
608pub fn parse_volume_ls_ldjson(stdout: &str) -> Vec<VolumeEntry> {
609    parse_ldjson(stdout, "docker volume ls")
610}
611
612/// One `docker ps --filter publish=` row, reduced to attribution: the
613/// container's (first) name and its compose project label, when it has
614/// one. `docker ps` JSON differs from `compose ps` JSON (`Names`
615/// plural, `Labels` sometimes a `"k=v,k=v"` string instead of a map) —
616/// both shapes are tolerated and fixture-pinned.
617#[derive(Debug, Clone, PartialEq)]
618pub struct DockerPsEntry {
619    /// Container name.
620    pub name: String,
621    /// `com.docker.compose.project` label, when present.
622    pub compose_project: Option<String>,
623}
624
625/// Parse `docker ps --format json` LDJSON into [`DockerPsEntry`] rows
626/// (name + compose-project attribution only).
627pub fn parse_docker_ps_ldjson(stdout: &str) -> Vec<DockerPsEntry> {
628    parse_ldjson::<serde_json::Value>(stdout, "docker ps")
629        .into_iter()
630        .filter_map(|value| {
631            // `Names` (docker ps, plural, comma-joined when multiple)
632            // with `Name` (compose ps shape) accepted as a fallback.
633            let name = value
634                .get("Names")
635                .or_else(|| value.get("Name"))
636                .and_then(Value::as_str)
637                .map(|names| names.split(',').next().unwrap_or(names).trim().to_string())
638                .unwrap_or_default();
639            if name.is_empty() {
640                return None;
641            }
642            let compose_project = match value.get("Labels") {
643                Some(Value::String(labels)) => labels.split(',').find_map(|pair| {
644                    pair.trim()
645                        .strip_prefix("com.docker.compose.project=")
646                        .map(str::to_string)
647                }),
648                Some(labels @ Value::Object(_)) => labels
649                    .get("com.docker.compose.project")
650                    .and_then(Value::as_str)
651                    .map(str::to_string),
652                _ => None,
653            };
654            Some(DockerPsEntry {
655                name,
656                compose_project,
657            })
658        })
659        .collect()
660}
661
662#[cfg(test)]
663mod tests {
664    use std::path::Path;
665
666    use super::{
667        ComposeOutput, ComposeRunner, DockerCompose, PortMapping, check_output, config_args,
668        docker_ps_publish_args, down_args, logs_args, parse_compose_version, parse_config,
669        parse_docker_ps_ldjson, parse_ps_ldjson, parse_volume_ls_ldjson, ps_args, stderr_tail,
670        up_args, volume_ls_args,
671    };
672    use crate::rig::RigPlan;
673
674    fn sample_plan() -> RigPlan {
675        RigPlan {
676            name: "ignition-devops".into(),
677            compose_file: "/rigs/git-module/docker/docker-compose.yml".into(),
678            project_dir: "/rigs/git-module/docker".into(),
679            services: vec!["ignition".into(), "db".into()],
680            host_ports: vec![9088, 9443],
681            port_mappings: vec![
682                PortMapping {
683                    target: 8088,
684                    published: 9088,
685                },
686                PortMapping {
687                    target: 443,
688                    published: 9443,
689                },
690            ],
691            volumes: vec!["gw-data".into()],
692        }
693    }
694
695    fn s(args: &[&str]) -> Vec<String> {
696        args.iter().map(|a| a.to_string()).collect()
697    }
698
699    // ----- builder pins (the LOCKED invocation shapes) -------------------
700
701    #[test]
702    fn config_args_pinned() {
703        assert_eq!(
704            config_args(
705                Path::new("/rigs/docker/compose.yml"),
706                Path::new("/rigs/docker"),
707            ),
708            s(&[
709                "-f",
710                "/rigs/docker/compose.yml",
711                "--project-directory",
712                "/rigs/docker",
713                "config",
714                "--format",
715                "json"
716            ]),
717        );
718    }
719
720    #[test]
721    fn up_args_pinned() {
722        assert_eq!(
723            up_args(&sample_plan(), 300),
724            s(&[
725                "-p",
726                "ignition-devops",
727                "-f",
728                "/rigs/git-module/docker/docker-compose.yml",
729                "up",
730                "-d",
731                "--wait",
732                "--wait-timeout",
733                "300",
734                "--remove-orphans"
735            ]),
736        );
737    }
738
739    #[test]
740    fn down_args_pinned_with_and_without_volumes() {
741        assert_eq!(
742            down_args(&sample_plan(), false),
743            s(&[
744                "-p",
745                "ignition-devops",
746                "-f",
747                "/rigs/git-module/docker/docker-compose.yml",
748                "down",
749                "--remove-orphans"
750            ]),
751            "plain down keeps volumes (reset's -v arrives in 04-02)"
752        );
753        assert_eq!(
754            down_args(&sample_plan(), true),
755            s(&[
756                "-p",
757                "ignition-devops",
758                "-f",
759                "/rigs/git-module/docker/docker-compose.yml",
760                "down",
761                "--remove-orphans",
762                "-v"
763            ]),
764        );
765    }
766
767    #[test]
768    fn ps_args_pinned() {
769        assert_eq!(
770            ps_args(&sample_plan()),
771            s(&[
772                "-p",
773                "ignition-devops",
774                "-f",
775                "/rigs/git-module/docker/docker-compose.yml",
776                "ps",
777                "--format",
778                "json"
779            ]),
780        );
781    }
782
783    #[test]
784    fn logs_args_pinned() {
785        assert_eq!(
786            logs_args(&sample_plan(), 200, false, None),
787            s(&[
788                "-p",
789                "ignition-devops",
790                "-f",
791                "/rigs/git-module/docker/docker-compose.yml",
792                "logs",
793                "--tail",
794                "200"
795            ]),
796        );
797        assert_eq!(
798            logs_args(&sample_plan(), 50, true, Some("ignition")),
799            s(&[
800                "-p",
801                "ignition-devops",
802                "-f",
803                "/rigs/git-module/docker/docker-compose.yml",
804                "logs",
805                "--tail",
806                "50",
807                "-f",
808                "ignition"
809            ]),
810        );
811    }
812
813    #[test]
814    fn volume_ls_args_pinned_plain_docker_shape() {
815        assert_eq!(
816            volume_ls_args("ignition-devops"),
817            s(&[
818                "volume",
819                "ls",
820                "--filter",
821                "label=com.docker.compose.project=ignition-devops",
822                "--format",
823                "json"
824            ]),
825            "plain docker CLI: no compose subcommand, no -p prefix"
826        );
827    }
828
829    #[test]
830    fn docker_ps_publish_args_pinned() {
831        assert_eq!(
832            docker_ps_publish_args(18088),
833            s(&["ps", "--filter", "publish=18088", "--format", "json"]),
834        );
835    }
836
837    // ----- version parse -------------------------------------------------
838
839    #[test]
840    fn version_parses_live_capture_and_rejects_old_or_garbage() {
841        // Live v5.1.2 capture (research).
842        assert_eq!(
843            parse_compose_version("Docker Compose version v5.1.2\n"),
844            Some("5.1.2".into())
845        );
846        assert_eq!(
847            parse_compose_version("Docker Compose version v2.24.6\n"),
848            Some("2.24.6".into())
849        );
850        // Unversioned builds exist ("Docker Compose version 2.34.0").
851        assert_eq!(
852            parse_compose_version("Docker Compose version 2.34.0\n"),
853            Some("2.34.0".into())
854        );
855        // v1-shaped output and garbage are unusable.
856        assert_eq!(parse_compose_version("1.29.2"), None);
857        assert_eq!(parse_compose_version(""), None);
858        assert_eq!(parse_compose_version("docker-compose 1.25.5"), None);
859    }
860
861    // ----- exit mapping ---------------------------------------------------
862
863    #[test]
864    fn check_output_maps_nonzero_to_rig_with_stderr_tail() {
865        let ok = ComposeOutput {
866            stdout: "{}".into(),
867            stderr: String::new(),
868            code: 0,
869        };
870        assert_eq!(check_output(&ok, "ctx").unwrap(), "{}");
871
872        let tail = (1..=9).map(|n| format!("line-{n}")).collect::<Vec<_>>();
873        let failed = ComposeOutput {
874            stdout: String::new(),
875            stderr: tail.join("\n"),
876            code: 1,
877        };
878        let err = check_output(&failed, "docker compose up").unwrap_err();
879        assert!(matches!(err, crate::error::CoreError::Rig(_)));
880        let message = err.to_string();
881        assert!(
882            message.contains("docker compose up failed (exit 1)"),
883            "{message}"
884        );
885        // The tail is the LAST ~5 lines only.
886        assert!(
887            !message.contains("line-4\n"),
888            "tail keeps at most 5 lines: {message}"
889        );
890        assert!(message.contains("line-5"));
891        assert!(message.contains("line-9"));
892    }
893
894    #[test]
895    fn stderr_tail_trims_and_keeps_order() {
896        assert_eq!(stderr_tail("b\na\n"), "b\na");
897        assert_eq!(stderr_tail(""), "");
898        assert_eq!(stderr_tail("\n\n"), "");
899    }
900
901    // ----- parse_config ---------------------------------------------------
902
903    /// The whk-environment-orchestration-shaped resolve fixture:
904    /// `.name` from the rig's own `.env`, string-form `published`, a
905    /// volumes map, and ignored top-level keys.
906    const CONFIG_FIXTURE: &str = r#"{
907        "name": "whk-global",
908        "services": {
909            "ignition": {
910                "image": "inductiveautomation/ignition:8.3.6",
911                "ports": [
912                    {"mode": "host", "target": 8088, "published": "9088", "protocol": "tcp"},
913                    {"mode": "host", "target": 443, "published": "9443", "protocol": "tcp"}
914                ]
915            },
916            "git-server": {
917                "image": "git-server:latest",
918                "ports": [{"mode": "host", "target": 22, "published": "9022", "protocol": "tcp"}]
919            }
920        },
921        "volumes": {
922            "gw-data": {"name": "whk-global_gw-data", "driver": "local"},
923            "gw-tag-definition": {"name": "whk-global_gw-tag-definition"}
924        },
925        "networks": {"default": {"name": "whk-global_default"}},
926        "secrets": {}
927    }"#;
928
929    #[test]
930    fn parse_config_reads_name_services_ports_volumes() {
931        let plan = parse_config(
932            CONFIG_FIXTURE,
933            Path::new("/whk/whk-environment-orchestration/docker-compose.yml"),
934            Path::new("/whk/whk-environment-orchestration"),
935        )
936        .expect("fixture parses");
937        assert_eq!(plan.name, "whk-global");
938        assert_eq!(plan.services, vec!["git-server", "ignition"]);
939        // Services iterate sorted (serde_json's map) → ports arrive in
940        // service order: git-server's 9022 first.
941        assert_eq!(plan.host_ports, vec![9022, 9088, 9443]);
942        assert!(
943            plan.port_mappings.contains(&PortMapping {
944                target: 8088,
945                published: 9088
946            }),
947            "string-form `published` tolerated: {:?}",
948            plan.port_mappings
949        );
950        assert_eq!(plan.volumes, vec!["gw-data", "gw-tag-definition"]);
951        assert_eq!(
952            plan.compose_file,
953            Path::new("/whk/whk-environment-orchestration/docker-compose.yml")
954        );
955        assert_eq!(
956            plan.project_dir,
957            Path::new("/whk/whk-environment-orchestration")
958        );
959    }
960
961    #[test]
962    fn parse_config_tolerates_array_doc_and_numeric_published() {
963        // Older compose wraps the config doc in a single-element array,
964        // and some builds emit numeric `published`.
965        let array_form = format!("[{}]", CONFIG_FIXTURE.replace("\"9088\"", "9088"));
966        let plan = parse_config(&array_form, Path::new("/c.yml"), Path::new("/"))
967            .expect("array doc + numeric published parse");
968        assert_eq!(plan.name, "whk-global");
969        assert!(plan.host_ports.contains(&9088));
970    }
971
972    #[test]
973    fn parse_config_without_name_refuses() {
974        let fixture = r#"{"services": {"ignition": {}}}"#;
975        let err = parse_config(fixture, Path::new("/c.yml"), Path::new("/")).unwrap_err();
976        let message = err.to_string();
977        assert!(message.contains("no `.name`"), "{message}");
978        assert!(message.contains("COMPOSE_PROJECT_NAME"), "{message}");
979    }
980
981    #[test]
982    fn parse_config_empty_services_and_missing_sections() {
983        let plan = parse_config(r#"{"name": "bare"}"#, Path::new("/c.yml"), Path::new("/"))
984            .expect("minimal doc parses");
985        assert!(plan.services.is_empty());
986        assert!(plan.host_ports.is_empty());
987        assert!(plan.volumes.is_empty());
988    }
989
990    /// Research Open Question 4, resolved empirically: the rig's own
991    /// `.env` `COMPOSE_PROJECT_NAME` governs the resolved `.name` even
992    /// when `ign` runs from an unrelated cwd — because `config_args`
993    /// ALWAYS passes `--project-directory` (Pitfall 8). Runs the REAL
994    /// docker CLI (config is client-side; no daemon needed) and skips
995    /// quietly when docker is absent (CI).
996    #[tokio::test]
997    async fn config_resolves_env_project_name_from_project_directory() {
998        let dir = tempfile::tempdir().expect("tempdir");
999        let compose = dir.path().join("docker-compose.yml");
1000        std::fs::write(
1001            &compose,
1002            "services:\n  sidecar:\n    image: alpine:latest\n",
1003        )
1004        .expect("write compose");
1005        std::fs::write(
1006            dir.path().join(".env"),
1007            "COMPOSE_PROJECT_NAME=env-resolved-name\n",
1008        )
1009        .expect("write .env");
1010
1011        let runner = DockerCompose;
1012        if runner.run(&["version".to_string()]).await.code != 0 {
1013            eprintln!("skipping: docker compose unavailable");
1014            return;
1015        }
1016        // The spawn's cwd is the TEST binary's dir — nowhere near the
1017        // fixture — which is exactly the cwd-elsewhere case.
1018        let output = runner.run(&config_args(&compose, dir.path())).await;
1019        assert_eq!(output.code, 0, "config run: {}", output.stderr);
1020        let plan = parse_config(&output.stdout, &compose, dir.path()).expect("resolve parses");
1021        assert_eq!(
1022            plan.name, "env-resolved-name",
1023            "the .env name wins over the directory-derived default"
1024        );
1025    }
1026
1027    /// The STREAMING spawn (04-02): piped stdout forwarded line-by-line
1028    /// to the sink, stderr drained, exit reported. Runs the REAL docker
1029    /// CLI (`logs` on an absent project is exit 0 + empty output on
1030    /// compose v2 — no daemon-side state needed) and skips quietly when
1031    /// docker is absent (CI). Client-side proof of the follow seam.
1032    #[tokio::test]
1033    async fn run_streaming_forwards_lines_via_piped_stdout() {
1034        let dir = tempfile::tempdir().expect("tempdir");
1035        let compose = dir.path().join("docker-compose.yml");
1036        std::fs::write(
1037            &compose,
1038            "services:\n  sidecar:\n    image: alpine:latest\n",
1039        )
1040        .expect("write compose");
1041
1042        let runner = DockerCompose;
1043        if runner.run(&["version".to_string()]).await.code != 0 {
1044            eprintln!("skipping: docker compose unavailable");
1045            return;
1046        }
1047        // `compose version` is CLIENT-side; the `compose logs` below
1048        // needs a LIVE daemon — its absent-project exit-0 contract only
1049        // holds when the daemon answers. Probe the daemon (plain
1050        // `docker version` exits nonzero when unreachable — e.g. an
1051        // auto-stopped OrbStack) so a daemon-less machine skips as
1052        // quietly as a docker-less one.
1053        if runner.run_docker(&["version".to_string()]).await.code != 0 {
1054            eprintln!("skipping: docker daemon unreachable");
1055            return;
1056        }
1057        let plan = parse_config(
1058            r#"{"name":"stream-fixture","services":{"sidecar":{"image":"alpine"}}}"#,
1059            &compose,
1060            dir.path(),
1061        )
1062        .expect("plan parses");
1063        let mut streamed = 0usize;
1064        let mut sink = |_: &str| streamed += 1;
1065        let output = runner
1066            .run_streaming(&logs_args(&plan, 5, false, None), &mut sink)
1067            .await;
1068        assert_eq!(
1069            output.code, 0,
1070            "compose logs on an absent project: {}",
1071            output.stderr
1072        );
1073        assert_eq!(
1074            streamed, 0,
1075            "no containers → no lines, but the spawn/read/wait ran"
1076        );
1077        assert!(
1078            output.stdout.is_empty(),
1079            "streamed stdout never reports back"
1080        );
1081    }
1082
1083    // ----- LDJSON parsers (research Pitfall 1: BOTH conventions pinned) --
1084
1085    /// Live-captured compose ps row shape (whk-global-style): one
1086    /// object per line, Health/Publishers present on this row.
1087    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)"}"#;
1088
1089    /// A second row WITHOUT Health/Publishers (service publishes no
1090    /// ports, no healthcheck) — the optional-fields tolerance pin.
1091    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"}"#;
1092
1093    #[test]
1094    fn parse_ps_ldjson_reads_rows_and_tolerates_missing_optionals() {
1095        let rows = parse_ps_ldjson(&format!("{PS_ROW_FULL}\n{PS_ROW_MINIMAL}\n"));
1096        assert_eq!(rows.len(), 2);
1097        assert_eq!(rows[0].name, "whk-global-ignition-1");
1098        assert_eq!(rows[0].service, "ignition");
1099        assert_eq!(rows[0].state, "running");
1100        assert_eq!(rows[0].health.as_deref(), Some("healthy"));
1101        assert_eq!(rows[0].exit_code, Some(0));
1102        assert_eq!(rows[0].publishers.len(), 2);
1103        assert_eq!(rows[0].publishers[0].target_port, Some(8088));
1104        assert_eq!(rows[0].publishers[0].published_port, Some(9088));
1105        assert_eq!(rows[0].publishers[0].protocol.as_deref(), Some("tcp"));
1106
1107        assert_eq!(rows[1].health, None);
1108        assert!(rows[1].publishers.is_empty());
1109    }
1110
1111    #[test]
1112    fn parse_ps_ldjson_empty_and_warning_lines() {
1113        assert!(parse_ps_ldjson("").is_empty(), "a down rig has no rows");
1114        // A stray non-JSON warning line skips (warn), the rows survive.
1115        let rows = parse_ps_ldjson(&format!("[NOTE] something\n{PS_ROW_MINIMAL}\n"));
1116        assert_eq!(rows.len(), 1);
1117    }
1118
1119    #[test]
1120    fn parse_volume_ls_ldjson_fixture() {
1121        let stdout = concat!(
1122            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"}"#,
1123            "\n",
1124            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"}"#,
1125            "\n",
1126        );
1127        let volumes = parse_volume_ls_ldjson(stdout);
1128        assert_eq!(volumes.len(), 2);
1129        assert_eq!(volumes[0].name, "ignition-devops_gw-data");
1130        assert_eq!(volumes[1].name, "ignition-devops_gw-tag-definition");
1131        assert!(parse_volume_ls_ldjson("").is_empty());
1132    }
1133
1134    #[test]
1135    fn parse_docker_ps_labels_as_string_and_map() {
1136        // docker ps emits `Names` (plural) and Labels as a k=v string.
1137        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"}"#;
1138        let rows = parse_docker_ps_ldjson(string_labels);
1139        assert_eq!(rows.len(), 1);
1140        assert_eq!(rows[0].name, "other-project-sidecar-1");
1141        assert_eq!(rows[0].compose_project.as_deref(), Some("other-project"));
1142
1143        // The compose-ps-style map labels + Name key are accepted too.
1144        let map_labels =
1145            r#"{"Name":"ign-research","Labels":{"com.docker.compose.project":"research"}}"#;
1146        let rows = parse_docker_ps_ldjson(map_labels);
1147        assert_eq!(rows[0].name, "ign-research");
1148        assert_eq!(rows[0].compose_project.as_deref(), Some("research"));
1149
1150        // Non-compose containers attribute with no project.
1151        let rows = parse_docker_ps_ldjson(r#"{"Names":"standalone-thing","Labels":""}"#);
1152        assert_eq!(rows[0].compose_project, None);
1153        assert!(parse_docker_ps_ldjson("").is_empty());
1154    }
1155}