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