1use 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#[derive(Debug, Clone, PartialEq, Serialize)]
53pub struct RigPlan {
54 pub name: String,
57 pub compose_file: PathBuf,
59 pub project_dir: PathBuf,
62 pub services: Vec<String>,
64 pub host_ports: Vec<u16>,
66 pub port_mappings: Vec<PortMapping>,
68 pub volumes: Vec<String>,
70}
71
72#[derive(Debug, Clone, PartialEq)]
76pub enum RigSelection {
77 Named(String),
80 Auto,
82}
83
84pub const WHK_HOME_ROOTS: &[&str] = &["~/Documents/whiskeyhouse", "~/whiskeyhouse"];
89
90const GIT_MODULE_RELPATH: &str = "ignition-git-module/docker/docker-compose.yml";
92const WHK_GLOBAL_RELPATH: &str = "whk-environment-orchestration/docker-compose.yml";
93
94const CWD_CANDIDATES: &[&str] = &[
96 "docker/compose.yml",
97 "docker/docker-compose.yml",
98 "compose.yml",
99 "compose.yaml",
100 "docker-compose.yml",
101];
102
103fn 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
123pub(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 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#[derive(Debug)]
160pub(crate) struct DiscoveryEnv {
161 pub cwd: PathBuf,
162 pub roots: Vec<PathBuf>,
163}
164
165pub 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
180pub(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 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 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 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 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
247async 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
270async 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#[derive(Debug, Clone, PartialEq, Serialize)]
286pub struct PortConflict {
287 pub port: u16,
289 pub attribution: String,
293}
294
295pub 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 => { }
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
335fn 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 const MINIMAL_COMPOSE: &str = "services:\n sidecar:\n image: alpine:latest\n";
379
380 const RESOLVE_STDOUT: &str =
382 r#"{"name":"fixture-rig","services":{"sidecar":{"image":"alpine"}},"volumes":{}}"#;
383
384 #[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 #[test]
471 fn expand_path_tilde_and_env_vars() {
472 let lock = crate::config::ENV_LOCK.lock().expect("env lock");
473 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 assert_eq!(
482 expand_path("${IGNITION_NO_SUCH_VAR}/x"),
483 PathBuf::from("${IGNITION_NO_SUCH_VAR}/x")
484 );
485 assert_eq!(expand_path("/abs/path"), PathBuf::from("/abs/path"));
487
488 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 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 #[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 #[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 #[tokio::test]
667 async fn cwd_candidates_probed_in_order() {
668 let cwd = tempfile::tempdir().expect("tempdir");
669 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 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 }
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 #[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 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 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 #[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 #[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 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 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 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 #[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}