Skip to main content

ignition_core/rig/
mod.rs

1//! Rig discovery + pre-flight (04-01, RIG-01): the [`RigPlan`] model,
2//! the LOCKED 5-level discovery that always ends in one resolve-then-act
3//! `config` run, and the port-collision pre-flight.
4//!
5//! ## Resolve-then-act (research Pattern 1)
6//!
7//! Discovery only ever finds a compose FILE; [`resolve_plan`] then runs
8//! `docker compose -f <file> --project-directory <dir> config --format
9//! json` through the runner and the returned `.name` (which honors the
10//! rig's own `.env` `COMPOSE_PROJECT_NAME`) becomes the identity truth
11//! every later op passes as explicit `-p <name>` (Pitfall 8: no
12//! implicit directory-name projects, ever).
13//!
14//! ## Discovery order (LOCKED — must-have truth)
15//!
16//! 1. `--rig NAME` flag → `[rigs.NAME]`
17//! 2. `IGNITION_RIG` env → same (the bin folds it into the flag — one
18//!    env→flag home, the IGNITION_PROFILE precedent)
19//! 3. `[rig].default` → `[rigs.<default>]` (a stale default is a LOUD
20//!    error, never a silent scan)
21//! 4. cwd candidates: `./docker/compose.yml`, `./docker/docker-compose.yml`,
22//!    `./compose.yml`, `./compose.yaml`, `./docker-compose.yml`
23//! 5. WHK conventions — the git-module repo, then the WHK-Global
24//!    orchestration repo, each probed under BOTH home roots
25//!    (plan-checker: machine layouts differ; never pin one root):
26//!    `~/Documents/whiskeyhouse/` first, `~/whiskeyhouse/` second.
27//!
28//! Nothing found → [`CoreError::Rig`] carrying the full search trail
29//! (agents self-diagnose). Convention roots live in ONE const array;
30//! [`whk_roots`] additionally honors `IGNITION_RIG_ROOTS` (path-separated)
31//! so binary tests can isolate the machine's real home and agents with
32//! rigs elsewhere can redirect the convention scan.
33
34use std::path::{Path, PathBuf};
35
36use serde::Serialize;
37
38pub mod compose;
39
40pub use compose::{
41    ComposeOutput, ComposeRunner, DockerCompose, DockerPsEntry, PortMapping, Publisher,
42    ServiceStatus, VolumeEntry, compose_version, config_args, docker_ps_publish_args, parse_config,
43    parse_docker_ps_ldjson, reset_preview,
44};
45
46use crate::config::Config;
47use crate::error::CoreError;
48
49/// The resolved rig — research Pattern 1's model, plus the target→
50/// published port pairs (the gateway-URL heuristic in `actions::rig`
51/// needs targets, not just the published half).
52#[derive(Debug, Clone, PartialEq, Serialize)]
53pub struct RigPlan {
54    /// Compose project name — THE identity truth (honors the rig's own
55    /// `.env` `COMPOSE_PROJECT_NAME` via the resolve run).
56    pub name: String,
57    /// The compose file discovery found.
58    pub compose_file: PathBuf,
59    /// Its directory (`--project-directory` on every resolve; where
60    /// `.env` is read from).
61    pub project_dir: PathBuf,
62    /// Service names, sorted (the config map's keys).
63    pub services: Vec<String>,
64    /// Published host ports (the published half of `port_mappings`).
65    pub host_ports: Vec<u16>,
66    /// Full target→published pairs.
67    pub port_mappings: Vec<PortMapping>,
68    /// Named volumes declared by the compose file.
69    pub volumes: Vec<String>,
70}
71
72/// What the caller wants resolved: an explicit name (the `--rig` flag,
73/// which the bin already folded `IGNITION_RIG` into) or the auto chain
74/// (`[rig].default` → cwd scan → convention scan).
75#[derive(Debug, Clone, PartialEq)]
76pub enum RigSelection {
77    /// `--rig NAME` / `IGNITION_RIG` — MUST exist in `[rigs.*]` or the
78    /// error lists the knowns (the ProfileNotFound shape precedent).
79    Named(String),
80    /// No preference — `[rig].default`, then the cwd/convention scan.
81    Auto,
82}
83
84/// WHK convention home roots, tried in this order (plan-checker
85/// 2026-08-22: this machine's whk-environment-orchestration lives under
86/// `~/whiskeyhouse/`, not `~/Documents/whiskeyhouse/` — layouts differ
87/// per machine, so BOTH roots are probed for BOTH convention repos).
88pub const WHK_HOME_ROOTS: &[&str] = &["~/Documents/whiskeyhouse", "~/whiskeyhouse"];
89
90/// Relative compose-file locations of the two WHK convention repos.
91const GIT_MODULE_RELPATH: &str = "ignition-git-module/docker/docker-compose.yml";
92const WHK_GLOBAL_RELPATH: &str = "whk-environment-orchestration/docker-compose.yml";
93
94/// cwd compose candidates, in order (discovery level 4).
95const CWD_CANDIDATES: &[&str] = &[
96    "docker/compose.yml",
97    "docker/docker-compose.yml",
98    "compose.yml",
99    "compose.yaml",
100    "docker-compose.yml",
101];
102
103/// The expanded convention roots for this invocation:
104/// `IGNITION_RIG_ROOTS` (path-separated, `~`-expanded) overrides the
105/// const pair — binary-test isolation plus a real agent affordance
106/// (rigs checked out elsewhere).
107fn whk_roots() -> Vec<PathBuf> {
108    if let Ok(override_roots) = std::env::var("IGNITION_RIG_ROOTS")
109        && !override_roots.trim().is_empty()
110    {
111        return override_roots
112            .split(':')
113            .filter(|part| !part.is_empty())
114            .map(expand_path)
115            .collect();
116    }
117    WHK_HOME_ROOTS
118        .iter()
119        .map(|root| expand_path(root))
120        .collect()
121}
122
123/// Expand a configured path: leading `~`/`~/` against the home dir,
124/// then `${NAME}` placeholders from the env (manual — no new dep).
125/// Unknown vars stay literal (visible, not silently empty).
126pub(crate) fn expand_path(raw: &str) -> PathBuf {
127    let mut path = raw.to_string();
128    if path == "~" {
129        if let Some(home) = home_dir() {
130            path = home.display().to_string();
131        }
132    } else if let Some(rest) = path.strip_prefix("~/")
133        && let Some(home) = home_dir()
134    {
135        path = home.join(rest).display().to_string();
136    }
137    while let Some(start) = path.find("${")
138        && let Some(end_rel) = path[start..].find('}')
139    {
140        let end = start + end_rel;
141        let name = &path[start + 2..end];
142        let replacement = std::env::var(name).unwrap_or_else(|_| format!("${{{name}}}"));
143        path.replace_range(start..=end, &replacement);
144        // A var that expands to contain "${" again would loop forever;
145        // unknown vars keep their literal braces and advance past them.
146        if replacement.starts_with("${") {
147            break;
148        }
149    }
150    PathBuf::from(path)
151}
152
153fn home_dir() -> Option<PathBuf> {
154    directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
155}
156
157/// Where discovery looks — parameterized so unit tests inject temp
158/// dirs (cwd + convention roots) without touching the real home.
159#[derive(Debug)]
160pub(crate) struct DiscoveryEnv {
161    pub cwd: PathBuf,
162    pub roots: Vec<PathBuf>,
163}
164
165/// Resolve the rig end-to-end: discovery → the one `config` run → a
166/// [`RigPlan`] whose `.name` every later op passes as explicit `-p`.
167pub async fn resolve_plan(
168    runner: &dyn ComposeRunner,
169    selection: RigSelection,
170    config: &Config,
171) -> Result<RigPlan, CoreError> {
172    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
173    let env = DiscoveryEnv {
174        cwd,
175        roots: whk_roots(),
176    };
177    resolve_plan_with(runner, selection, config, &env).await
178}
179
180/// The parameterized core of [`resolve_plan`] (tests inject cwd/roots).
181pub(crate) async fn resolve_plan_with(
182    runner: &dyn ComposeRunner,
183    selection: RigSelection,
184    config: &Config,
185    env: &DiscoveryEnv,
186) -> Result<RigPlan, CoreError> {
187    let known: Vec<String> = config.rigs.keys().cloned().collect();
188
189    // Levels 1+2: an explicit name (the flag already folded IGNITION_RIG).
190    if let RigSelection::Named(name) = &selection {
191        return match config.rigs.get(name) {
192            Some(entry) => resolve_entry(runner, name, entry).await,
193            None => Err(CoreError::Rig(format!(
194                "rig {name:?} not found (known rigs: {known:?}); add a [rigs.{name}] \
195                 entry or run from the rig's directory"
196            ))),
197        };
198    }
199
200    // Level 3: the config default — an explicit user preference, so it
201    // outranks the cwd scan (must-have truth #4), and a stale one is a
202    // LOUD error rather than a surprise scan.
203    if let Some(default) = config.rig.default.as_deref() {
204        return match config.rigs.get(default) {
205            Some(entry) => resolve_entry(runner, default, entry).await,
206            None => Err(CoreError::Rig(format!(
207                "[rig] default {default:?} names no [rigs.{default}] entry \
208                 (known rigs: {known:?})"
209            ))),
210        };
211    }
212
213    let mut trail: Vec<String> = Vec::new();
214
215    // Level 4: cwd candidates, in order.
216    for candidate in CWD_CANDIDATES {
217        let path = env.cwd.join(candidate);
218        if path.is_file() {
219            return resolve_file(runner, &path).await;
220        }
221        trail.push(candidate.to_string());
222    }
223
224    // Level 5: WHK conventions — git-module first, then WHK-Global,
225    // each under BOTH home roots (first hit wins).
226    for relpath in [GIT_MODULE_RELPATH, WHK_GLOBAL_RELPATH] {
227        for root in &env.roots {
228            let path = root.join(relpath);
229            if path.is_file() {
230                return resolve_file(runner, &path).await;
231            }
232            trail.push(path.display().to_string());
233        }
234    }
235
236    Err(CoreError::Rig(format!(
237        "no compose file discovered — pass --rig NAME, set IGNITION_RIG, configure \
238         [rig].default/[rigs.NAME], or run from a directory with a compose file \
239         (searched cwd candidates {trail:?}; WHK convention roots {:?})",
240        env.roots
241            .iter()
242            .map(|root| root.display().to_string())
243            .collect::<Vec<_>>()
244    )))
245}
246
247/// Resolve one `[rigs.NAME]` entry: expand its path, then the shared
248/// resolve run. An explicit `project_name` on the entry overrides the
249/// resolved `.name` (a deliberate escape hatch — omit it to honor the
250/// rig's own `.env`).
251async fn resolve_entry(
252    runner: &dyn ComposeRunner,
253    name: &str,
254    entry: &crate::config::RigEntry,
255) -> Result<RigPlan, CoreError> {
256    let file = expand_path(&entry.compose_file);
257    if !file.is_file() {
258        return Err(CoreError::Rig(format!(
259            "rig {name:?}: compose file {} not found",
260            file.display()
261        )));
262    }
263    let mut plan = resolve_file(runner, &file).await?;
264    if let Some(project_name) = &entry.project_name {
265        plan.name.clone_from(project_name);
266    }
267    Ok(plan)
268}
269
270/// The resolve-then-act step: ONE `config --format json` run through
271/// the runner; the parsed `.name` is the identity truth.
272async fn resolve_file(runner: &dyn ComposeRunner, file: &Path) -> Result<RigPlan, CoreError> {
273    let project_dir = file
274        .parent()
275        .filter(|parent| !parent.as_os_str().is_empty())
276        .map(Path::to_path_buf)
277        .unwrap_or_else(|| PathBuf::from("."));
278    let output = runner.run(&config_args(file, &project_dir)).await;
279    let stdout = compose::check_output(&output, "docker compose config")?;
280    parse_config(stdout, file, &project_dir)
281}
282
283/// A host port already held by something that is NOT this rig's own
284/// project (same-project occupants are recreate-safe).
285#[derive(Debug, Clone, PartialEq, Serialize)]
286pub struct PortConflict {
287    /// The contested host port.
288    pub port: u16,
289    /// Who holds it: `container <name> (rig <project>)` /
290    /// `container <name> (no compose project)` /
291    /// `host process <name> (pid <pid>)`.
292    pub attribution: String,
293}
294
295/// Port pre-flight (research Pattern 3), run before `up`: per host
296/// port, `docker ps --filter publish=<port> --format json` first (rich
297/// attribution); when docker reports no occupant, an advisory `lsof`
298/// pass attributes non-docker HOST processes. `lsof` absence is
299/// tolerated silently (skip — it is advisory-only). Occupants belonging
300/// to THIS project are fine (a recreate); anything else is a conflict.
301pub async fn port_preflight(
302    runner: &dyn ComposeRunner,
303    plan: &RigPlan,
304) -> Result<Vec<PortConflict>, CoreError> {
305    let mut conflicts = Vec::new();
306    for port in &plan.host_ports {
307        let output = runner.run_docker(&docker_ps_publish_args(*port)).await;
308        let stdout = compose::check_output(&output, "docker ps")?;
309        let entries = parse_docker_ps_ldjson(stdout);
310        for entry in &entries {
311            match entry.compose_project.as_deref() {
312                Some(project) if project == plan.name => { /* recreate-safe */ }
313                Some(other) => conflicts.push(PortConflict {
314                    port: *port,
315                    attribution: format!("container {} (rig {})", entry.name, other),
316                }),
317                None => conflicts.push(PortConflict {
318                    port: *port,
319                    attribution: format!("container {} (no compose project)", entry.name),
320                }),
321            }
322        }
323        if entries.is_empty()
324            && let Some(process) = lsof_listener(*port)
325        {
326            conflicts.push(PortConflict {
327                port: *port,
328                attribution: process,
329            });
330        }
331    }
332    Ok(conflicts)
333}
334
335/// Advisory host-process attribution: the LISTENing process on `port`
336/// per `lsof -nP -iTCP:<port> -sTCP:LISTEN` (first matching row's
337/// NAME (PID)). Any failure — including lsof simply being absent —
338/// is `None` (skip silently; docker attribution is the primary pass).
339fn lsof_listener(port: u16) -> Option<String> {
340    let output = std::process::Command::new("lsof")
341        .args([
342            "-nP".to_string(),
343            format!("-iTCP:{port}"),
344            "-sTCP:LISTEN".to_string(),
345        ])
346        .output()
347        .ok()?;
348    if !output.status.success() {
349        return None;
350    }
351    let stdout = String::from_utf8_lossy(&output.stdout);
352    let line = stdout
353        .lines()
354        .find(|line| line.contains(format!(":{port}").as_str()))
355        .or_else(|| stdout.lines().nth(1))?;
356    let mut fields = line.split_whitespace();
357    let name = fields.next()?.to_string();
358    let pid = fields.next()?;
359    Some(format!("host process {name} (pid {pid})"))
360}
361
362#[cfg(test)]
363mod tests {
364    use std::collections::VecDeque;
365    use std::path::{Path, PathBuf};
366    use std::sync::Mutex;
367
368    use super::{
369        ComposeOutput, ComposeRunner, DiscoveryEnv, DockerPsEntry, PortConflict, RigSelection,
370        WHK_HOME_ROOTS, config_args, expand_path, parse_config, parse_docker_ps_ldjson,
371        port_preflight, resolve_plan_with,
372    };
373    use crate::config::{Config, RigConfig, RigEntry};
374    use crate::error::CoreError;
375
376    /// A minimal valid compose file for fixtures that need a real file
377    /// on disk (discovery checks existence before resolving).
378    const MINIMAL_COMPOSE: &str = "services:\n  sidecar:\n    image: alpine:latest\n";
379
380    /// The resolve run's scripted answer: a one-service project.
381    const RESOLVE_STDOUT: &str =
382        r#"{"name":"fixture-rig","services":{"sidecar":{"image":"alpine"}},"volumes":{}}"#;
383
384    /// Scripted fake runner: records (program, args) per call and serves
385    /// queued outputs. `run` and `run_docker` are recorded separately so
386    /// tests can assert WHICH program shape an op used.
387    #[derive(Default)]
388    struct FakeRunner {
389        calls: Mutex<Vec<(&'static str, Vec<String>)>>,
390        outputs: Mutex<VecDeque<ComposeOutput>>,
391    }
392
393    impl FakeRunner {
394        fn with(outputs: Vec<ComposeOutput>) -> Self {
395            Self {
396                outputs: Mutex::new(outputs.into()),
397                ..Self::default()
398            }
399        }
400
401        fn next(&self, program: &'static str, args: &[String]) -> ComposeOutput {
402            self.calls.lock().unwrap().push((program, args.to_vec()));
403            self.outputs
404                .lock()
405                .unwrap()
406                .pop_front()
407                .expect("scripted outputs exhausted")
408        }
409
410        fn calls(&self) -> Vec<(&'static str, Vec<String>)> {
411            self.calls.lock().unwrap().clone()
412        }
413    }
414
415    #[async_trait::async_trait]
416    impl ComposeRunner for FakeRunner {
417        async fn run(&self, args: &[String]) -> ComposeOutput {
418            self.next("docker compose", args)
419        }
420
421        async fn run_docker(&self, args: &[String]) -> ComposeOutput {
422            self.next("docker", args)
423        }
424
425        async fn run_streaming(
426            &self,
427            args: &[String],
428            line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
429        ) -> ComposeOutput {
430            let output = self.next("docker compose", args);
431            for line in output.stdout.lines() {
432                line_sink(line);
433            }
434            ComposeOutput {
435                stdout: String::new(),
436                stderr: output.stderr,
437                code: output.code,
438            }
439        }
440    }
441
442    fn ok(stdout: &str) -> ComposeOutput {
443        ComposeOutput {
444            stdout: stdout.to_string(),
445            stderr: String::new(),
446            code: 0,
447        }
448    }
449
450    fn resolve_output() -> ComposeOutput {
451        ok(RESOLVE_STDOUT)
452    }
453
454    fn entry(path: &Path) -> RigEntry {
455        RigEntry {
456            compose_file: path.display().to_string(),
457            project_name: None,
458        }
459    }
460
461    fn discovery_env(cwd: &Path, roots: &[PathBuf]) -> DiscoveryEnv {
462        DiscoveryEnv {
463            cwd: cwd.to_path_buf(),
464            roots: roots.to_vec(),
465        }
466    }
467
468    // ----- path expansion -------------------------------------------------
469
470    #[test]
471    fn expand_path_tilde_and_env_vars() {
472        let lock = crate::config::ENV_LOCK.lock().expect("env lock");
473        // SAFETY: single-threaded under ENV_LOCK; restored before return.
474        unsafe { std::env::set_var("IGNITION_TEST_VAR", "expanded") };
475
476        assert_eq!(
477            expand_path("${IGNITION_TEST_VAR}/rigs"),
478            PathBuf::from("expanded/rigs")
479        );
480        // Unknown vars stay literal (visible, not silently empty).
481        assert_eq!(
482            expand_path("${IGNITION_NO_SUCH_VAR}/x"),
483            PathBuf::from("${IGNITION_NO_SUCH_VAR}/x")
484        );
485        // Plain paths pass through untouched.
486        assert_eq!(expand_path("/abs/path"), PathBuf::from("/abs/path"));
487
488        // ~ expands against the real home.
489        if let Some(home) = directories::BaseDirs::new() {
490            let home = home.home_dir();
491            assert_eq!(expand_path("~/"), home.to_path_buf());
492            assert_eq!(expand_path("~"), home.to_path_buf());
493            assert_eq!(expand_path("~/sub/rig"), home.join("sub/rig"));
494        }
495        // SAFETY: single-threaded under ENV_LOCK.
496        unsafe { std::env::remove_var("IGNITION_TEST_VAR") };
497        drop(lock);
498    }
499
500    #[test]
501    fn whk_roots_const_pins_both_home_roots_in_order() {
502        assert_eq!(
503            WHK_HOME_ROOTS,
504            &["~/Documents/whiskeyhouse", "~/whiskeyhouse"]
505        );
506    }
507
508    // ----- levels 1/2/3: named + config default ---------------------------
509
510    #[tokio::test]
511    async fn named_rig_resolves_and_runner_receives_exact_config_args() {
512        let dir = tempfile::tempdir().expect("tempdir");
513        let compose = dir.path().join("docker-compose.yml");
514        std::fs::write(&compose, MINIMAL_COMPOSE).expect("write compose");
515
516        let mut config = Config::default();
517        config.rigs.insert("git-module".into(), entry(&compose));
518
519        let runner = FakeRunner::with(vec![resolve_output()]);
520        let plan = resolve_plan_with(
521            &runner,
522            RigSelection::Named("git-module".into()),
523            &config,
524            &discovery_env(Path::new("/elsewhere"), &[PathBuf::from("/no-roots")]),
525        )
526        .await
527        .expect("named rig resolves");
528        assert_eq!(plan.name, "fixture-rig");
529        assert_eq!(plan.compose_file, compose);
530
531        let calls = runner.calls();
532        assert_eq!(calls.len(), 1, "exactly one resolve run");
533        assert_eq!(
534            calls[0],
535            ("docker compose", config_args(&compose, dir.path()),),
536            "resolve-then-act: -f + --project-directory + config --format json"
537        );
538    }
539
540    #[tokio::test]
541    async fn named_rig_miss_lists_knowns() {
542        let mut config = Config::default();
543        config
544            .rigs
545            .insert("alpha".into(), entry(Path::new("/does-not-matter.yml")));
546
547        let err = resolve_plan_with(
548            &FakeRunner::default(),
549            RigSelection::Named("nope".into()),
550            &config,
551            &discovery_env(Path::new("/elsewhere"), &[PathBuf::from("/no-roots")]),
552        )
553        .await
554        .expect_err("unknown rig name errors");
555        let message = err.to_string();
556        assert!(matches!(err, CoreError::Rig(_)));
557        assert_eq!(err.exit_code(), 7, "rig_error class");
558        assert!(message.contains("known rigs: [\"alpha\"]"), "{message}");
559    }
560
561    #[tokio::test]
562    async fn named_rig_with_missing_file_errors_with_expanded_path() {
563        let mut config = Config::default();
564        config.rigs.insert(
565            "ghost".into(),
566            entry(Path::new("/nonexistent/ghost-compose.yml")),
567        );
568        let err = resolve_plan_with(
569            &FakeRunner::default(),
570            RigSelection::Named("ghost".into()),
571            &config,
572            &discovery_env(Path::new("/elsewhere"), &[PathBuf::from("/no-roots")]),
573        )
574        .await
575        .expect_err("missing compose file errors");
576        assert!(
577            err.to_string().contains("ghost-compose.yml not found"),
578            "{}",
579            err
580        );
581    }
582
583    #[tokio::test]
584    async fn project_name_override_wins_over_resolved_name() {
585        let dir = tempfile::tempdir().expect("tempdir");
586        let compose = dir.path().join("compose.yml");
587        std::fs::write(&compose, MINIMAL_COMPOSE).expect("write compose");
588
589        let mut config = Config::default();
590        config.rigs.insert(
591            "override".into(),
592            RigEntry {
593                compose_file: compose.display().to_string(),
594                project_name: Some("explicit-name".into()),
595            },
596        );
597
598        let plan = resolve_plan_with(
599            &FakeRunner::with(vec![resolve_output()]),
600            RigSelection::Named("override".into()),
601            &config,
602            &discovery_env(Path::new("/elsewhere"), &[PathBuf::from("/no-roots")]),
603        )
604        .await
605        .expect("override resolves");
606        assert_eq!(plan.name, "explicit-name");
607    }
608
609    /// The precedence pin (must-have truth #4): `[rig].default` BEATS a
610    /// cwd full of compose candidates — the explicit user preference
611    /// outranks context scanning.
612    #[tokio::test]
613    async fn config_default_beats_cwd_candidates() {
614        let rig_dir = tempfile::tempdir().expect("tempdir");
615        let rig_compose = rig_dir.path().join("remote-compose.yml");
616        std::fs::write(&rig_compose, MINIMAL_COMPOSE).expect("write rig compose");
617
618        let cwd = tempfile::tempdir().expect("tempdir");
619        std::fs::write(cwd.path().join("compose.yml"), MINIMAL_COMPOSE).expect("write cwd compose");
620
621        let mut config = Config {
622            rig: RigConfig {
623                default: Some("remote".into()),
624            },
625            ..Config::default()
626        };
627        config.rigs.insert("remote".into(), entry(&rig_compose));
628
629        let plan = resolve_plan_with(
630            &FakeRunner::with(vec![resolve_output()]),
631            RigSelection::Auto,
632            &config,
633            &discovery_env(cwd.path(), &[PathBuf::from("/no-roots")]),
634        )
635        .await
636        .expect("default resolves");
637        assert_eq!(
638            plan.compose_file, rig_compose,
639            "the [rig].default entry wins over the cwd compose.yml"
640        );
641    }
642
643    #[tokio::test]
644    async fn stale_default_is_a_loud_error() {
645        let config = Config {
646            rig: RigConfig {
647                default: Some("ghost".into()),
648            },
649            ..Config::default()
650        };
651        let err = resolve_plan_with(
652            &FakeRunner::default(),
653            RigSelection::Auto,
654            &config,
655            &discovery_env(Path::new("/elsewhere"), &[PathBuf::from("/no-roots")]),
656        )
657        .await
658        .expect_err("stale default errors");
659        let message = err.to_string();
660        assert!(message.contains("[rig] default"), "{message}");
661        assert!(message.contains("ghost"), "{message}");
662    }
663
664    // ----- level 4: cwd candidates ----------------------------------------
665
666    #[tokio::test]
667    async fn cwd_candidates_probed_in_order() {
668        let cwd = tempfile::tempdir().expect("tempdir");
669        // The FIRST candidate: ./docker/compose.yml.
670        std::fs::create_dir(cwd.path().join("docker")).expect("mkdir");
671        let first = cwd.path().join("docker/compose.yml");
672        std::fs::write(&first, MINIMAL_COMPOSE).expect("write first candidate");
673        // A later candidate also exists — order must pick the first.
674        std::fs::write(cwd.path().join("docker-compose.yml"), MINIMAL_COMPOSE)
675            .expect("write later candidate");
676
677        let plan = resolve_plan_with(
678            &FakeRunner::with(vec![resolve_output()]),
679            RigSelection::Auto,
680            &Config::default(),
681            &discovery_env(cwd.path(), &[PathBuf::from("/no-roots")]),
682        )
683        .await
684        .expect("cwd candidate resolves");
685        assert_eq!(plan.compose_file, first);
686
687        // With NO candidate present (empty cwd): falls through to the
688        // roots, then errors with the trail — covered below.
689    }
690
691    #[tokio::test]
692    async fn no_rig_anywhere_errors_with_search_trail() {
693        let cwd = tempfile::tempdir().expect("tempdir");
694        let err = resolve_plan_with(
695            &FakeRunner::default(),
696            RigSelection::Auto,
697            &Config::default(),
698            &discovery_env(cwd.path(), &[PathBuf::from("/tmp/no-such-root")]),
699        )
700        .await
701        .expect_err("nothing found errors");
702        let message = err.to_string();
703        assert!(matches!(err, CoreError::Rig(_)));
704        assert_eq!(err.exit_code(), 7);
705        assert!(message.contains("no compose file discovered"), "{message}");
706        assert!(
707            message.contains("docker/compose.yml") && message.contains("docker-compose.yml"),
708            "cwd candidates named in the trail: {message}"
709        );
710        assert!(
711            message.contains("/tmp/no-such-root"),
712            "convention roots named in the trail: {message}"
713        );
714    }
715
716    // ----- level 5: WHK conventions (both roots, first hit wins) ----------
717
718    #[tokio::test]
719    async fn git_module_convention_probes_both_roots_first_hit_wins() {
720        let root1 = tempfile::tempdir().expect("root1");
721        let root2 = tempfile::tempdir().expect("root2");
722        // Only root2 has the git-module repo.
723        let path = root2
724            .path()
725            .join("ignition-git-module/docker/docker-compose.yml");
726        std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir");
727        std::fs::write(&path, MINIMAL_COMPOSE).expect("write");
728
729        let plan = resolve_plan_with(
730            &FakeRunner::with(vec![resolve_output()]),
731            RigSelection::Auto,
732            &Config::default(),
733            &discovery_env(
734                Path::new("/empty-cwd"),
735                &[root1.path().into(), root2.path().into()],
736            ),
737        )
738        .await
739        .expect("level-5 resolves via the second root");
740        assert_eq!(plan.compose_file, path);
741    }
742
743    #[tokio::test]
744    async fn whk_global_convention_tried_after_git_module() {
745        let root = tempfile::tempdir().expect("root");
746        // No git-module repo; WHK-Global present.
747        let path = root
748            .path()
749            .join("whk-environment-orchestration/docker-compose.yml");
750        std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir");
751        std::fs::write(&path, MINIMAL_COMPOSE).expect("write");
752
753        let plan = resolve_plan_with(
754            &FakeRunner::with(vec![resolve_output()]),
755            RigSelection::Auto,
756            &Config::default(),
757            &discovery_env(Path::new("/empty-cwd"), &[root.path().into()]),
758        )
759        .await
760        .expect("WHK-Global convention resolves");
761        assert_eq!(plan.compose_file, path);
762    }
763
764    #[tokio::test]
765    async fn git_module_beats_whk_global_when_both_exist() {
766        let root = tempfile::tempdir().expect("root");
767        let git_module = root
768            .path()
769            .join("ignition-git-module/docker/docker-compose.yml");
770        std::fs::create_dir_all(git_module.parent().unwrap()).expect("mkdir");
771        std::fs::write(&git_module, MINIMAL_COMPOSE).expect("write");
772        let whk_global = root
773            .path()
774            .join("whk-environment-orchestration/docker-compose.yml");
775        std::fs::create_dir_all(whk_global.parent().unwrap()).expect("mkdir");
776        std::fs::write(&whk_global, MINIMAL_COMPOSE).expect("write");
777
778        let plan = resolve_plan_with(
779            &FakeRunner::with(vec![resolve_output()]),
780            RigSelection::Auto,
781            &Config::default(),
782            &discovery_env(Path::new("/empty-cwd"), &[root.path().into()]),
783        )
784        .await
785        .expect("conventions resolve");
786        assert_eq!(
787            plan.compose_file, git_module,
788            "git-module outranks WHK-Global (discovery order)"
789        );
790    }
791
792    /// First root wins when BOTH roots carry the same convention repo —
793    /// the `~/Documents/whiskeyhouse/`-first ordering pin.
794    #[tokio::test]
795    async fn first_root_wins_when_both_roots_have_the_repo() {
796        let root1 = tempfile::tempdir().expect("root1");
797        let root2 = tempfile::tempdir().expect("root2");
798        let in_root1 = root1
799            .path()
800            .join("whk-environment-orchestration/docker-compose.yml");
801        std::fs::create_dir_all(in_root1.parent().unwrap()).expect("mkdir");
802        std::fs::write(&in_root1, MINIMAL_COMPOSE).expect("write");
803        let in_root2 = root2
804            .path()
805            .join("whk-environment-orchestration/docker-compose.yml");
806        std::fs::create_dir_all(in_root2.parent().unwrap()).expect("mkdir");
807        std::fs::write(&in_root2, MINIMAL_COMPOSE).expect("write");
808
809        let plan = resolve_plan_with(
810            &FakeRunner::with(vec![resolve_output()]),
811            RigSelection::Auto,
812            &Config::default(),
813            &discovery_env(
814                Path::new("/empty-cwd"),
815                &[root1.path().into(), root2.path().into()],
816            ),
817        )
818        .await
819        .expect("resolves");
820        assert_eq!(plan.compose_file, in_root1, "first root wins");
821    }
822
823    // ----- resolve failures propagate as Rig ------------------------------
824
825    #[tokio::test]
826    async fn failing_config_run_maps_to_rig_error_with_tail() {
827        let cwd = tempfile::tempdir().expect("tempdir");
828        std::fs::write(cwd.path().join("compose.yml"), MINIMAL_COMPOSE).expect("write compose");
829
830        let failed = ComposeOutput {
831            stdout: String::new(),
832            stderr: "no configuration file provided at ./compose.yml\n".into(),
833            code: 14,
834        };
835        let err = resolve_plan_with(
836            &FakeRunner::with(vec![failed]),
837            RigSelection::Auto,
838            &Config::default(),
839            &discovery_env(cwd.path(), &[PathBuf::from("/no-roots")]),
840        )
841        .await
842        .expect_err("config failure propagates");
843        let message = err.to_string();
844        assert!(
845            message.contains("docker compose config failed (exit 14)"),
846            "{message}"
847        );
848        assert!(message.contains("no configuration file"), "{message}");
849    }
850
851    // ----- port pre-flight --------------------------------------------------
852
853    fn plan_with_port(port: u16) -> crate::rig::RigPlan {
854        parse_config(
855            &format!(
856                r#"{{"name":"mine","services":{{"gw":{{"ports":[{{"target":8088,"published":"{port}"}}]}}}}}}"#
857            ),
858            Path::new("/p/compose.yml"),
859            Path::new("/p"),
860        )
861        .expect("plan parses")
862    }
863
864    #[tokio::test]
865    async fn preflight_free_port_reports_no_conflicts() {
866        // Port 1: privileged, nothing listens on it in test envs, and
867        // even where lsof exists it finds no listener → the clean case.
868        let runner = FakeRunner::with(vec![ok("")]);
869        let conflicts = port_preflight(&runner, &plan_with_port(1))
870            .await
871            .expect("preflight runs");
872        assert!(conflicts.is_empty(), "{conflicts:?}");
873        // The docker-attribution shape ran via run_docker (plain docker).
874        assert_eq!(runner.calls()[0].0, "docker");
875    }
876
877    #[tokio::test]
878    async fn preflight_same_project_occupant_is_recreate_safe() {
879        let occupant = r#"{"Names":"mine-gw-1","Labels":"com.docker.compose.project=mine"}"#;
880        let runner = FakeRunner::with(vec![ok(occupant)]);
881        let conflicts = port_preflight(&runner, &plan_with_port(18088))
882            .await
883            .expect("preflight runs");
884        assert!(conflicts.is_empty(), "own project → recreate, not conflict");
885    }
886
887    #[tokio::test]
888    async fn preflight_cross_project_occupant_conflicts_with_attribution() {
889        let occupant = r#"{"Names":"other-gw-1","Labels":"com.docker.compose.project=other"}"#;
890        let runner = FakeRunner::with(vec![ok(occupant)]);
891        let conflicts = port_preflight(&runner, &plan_with_port(18088))
892            .await
893            .expect("preflight runs");
894        assert_eq!(
895            conflicts,
896            vec![PortConflict {
897                port: 18088,
898                attribution: "container other-gw-1 (rig other)".into(),
899            }],
900            "the plan's Rig error reads: port 18088 in use by container other-gw-1 (rig other)"
901        );
902    }
903
904    #[tokio::test]
905    async fn preflight_non_compose_occupant_conflicts() {
906        let occupant = r#"{"Names":"standalone","Labels":""}"#;
907        let runner = FakeRunner::with(vec![ok(occupant)]);
908        let conflicts = port_preflight(&runner, &plan_with_port(18088))
909            .await
910            .expect("preflight runs");
911        assert_eq!(
912            conflicts,
913            vec![PortConflict {
914                port: 18088,
915                attribution: "container standalone (no compose project)".into(),
916            }]
917        );
918    }
919
920    #[tokio::test]
921    async fn preflight_docker_failure_maps_to_rig_error() {
922        let runner = FakeRunner::with(vec![ComposeOutput {
923            stdout: String::new(),
924            stderr: "docker daemon not running".into(),
925            code: 1,
926        }]);
927        let err = port_preflight(&runner, &plan_with_port(18088))
928            .await
929            .expect_err("docker ps failure errors");
930        assert!(err.to_string().contains("docker ps failed"), "{err}");
931    }
932
933    /// The docker-ps parser handles the map-Labels shape too (kept here
934    /// next to its consumer for the attribution story).
935    #[test]
936    fn docker_ps_map_labels_attributed() {
937        let entries: Vec<DockerPsEntry> =
938            parse_docker_ps_ldjson(r#"{"Names":"x","Labels":{"com.docker.compose.project":"p"}}"#);
939        assert_eq!(entries[0].compose_project.as_deref(), Some("p"));
940    }
941}