1use 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#[derive(Debug, Clone, PartialEq, Serialize)]
60pub struct RigPlan {
61 pub name: String,
64 pub config_name: Option<String>,
76 pub compose_file: PathBuf,
78 pub project_dir: PathBuf,
81 pub services: Vec<String>,
83 pub host_ports: Vec<u16>,
85 pub port_mappings: Vec<PortMapping>,
87 pub volumes: Vec<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
95 pub gateway_service: Option<String>,
96 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
102 pub modules: BTreeMap<String, ModuleDeclaration>,
103}
104
105#[derive(Debug, Clone, PartialEq)]
109pub enum RigSelection {
110 Named(String),
113 Auto,
115}
116
117pub const WHK_HOME_ROOTS: &[&str] = &["~/Documents/whiskeyhouse", "~/whiskeyhouse"];
122
123const GIT_MODULE_RELPATH: &str = "ignition-git-module/docker/docker-compose.yml";
125const WHK_GLOBAL_RELPATH: &str = "whk-environment-orchestration/docker-compose.yml";
126
127const CWD_CANDIDATES: &[&str] = &[
129 "docker/compose.yml",
130 "docker/docker-compose.yml",
131 "compose.yml",
132 "compose.yaml",
133 "docker-compose.yml",
134];
135
136fn 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
156pub(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 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#[derive(Debug)]
193pub(crate) struct DiscoveryEnv {
194 pub cwd: PathBuf,
195 pub roots: Vec<PathBuf>,
196}
197
198pub 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
213pub(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 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 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 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 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
280async 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 plan.config_name = Some(name.to_string());
306 plan.modules.clone_from(&entry.modules);
307 if let Some(service) = &entry.module_service {
308 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
332async 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#[derive(Debug, Clone, PartialEq, Serialize)]
348pub struct PortConflict {
349 pub port: u16,
351 pub attribution: String,
355}
356
357pub 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 => { }
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
397fn 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 const MINIMAL_COMPOSE: &str = "services:\n sidecar:\n image: alpine:latest\n";
441
442 const RESOLVE_STDOUT: &str =
444 r#"{"name":"fixture-rig","services":{"sidecar":{"image":"alpine"}},"volumes":{}}"#;
445
446 #[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 #[test]
535 fn expand_path_tilde_and_env_vars() {
536 let lock = crate::config::ENV_LOCK.lock().expect("env lock");
537 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 assert_eq!(
546 expand_path("${IGNITION_NO_SUCH_VAR}/x"),
547 PathBuf::from("${IGNITION_NO_SUCH_VAR}/x")
548 );
549 assert_eq!(expand_path("/abs/path"), PathBuf::from("/abs/path"));
551
552 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 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 #[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 #[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 #[tokio::test]
733 async fn cwd_candidates_probed_in_order() {
734 let cwd = tempfile::tempdir().expect("tempdir");
735 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 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 }
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 #[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 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 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 #[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 #[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 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 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 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 #[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}