1use std::path::Path;
37
38use serde::Deserialize;
39use serde_json::Value;
40
41use crate::error::CoreError;
42
43use super::RigPlan;
44
45const STDERR_TAIL_LINES: usize = 5;
48
49#[derive(Debug, Clone, PartialEq)]
53pub struct ComposeOutput {
54 pub stdout: String,
56 pub stderr: String,
58 pub code: i32,
60}
61
62#[async_trait::async_trait]
67pub trait ComposeRunner: Send + Sync {
68 async fn run(&self, args: &[String]) -> ComposeOutput;
70 async fn run_docker(&self, args: &[String]) -> ComposeOutput;
73 async fn run_streaming(
82 &self,
83 args: &[String],
84 line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
85 ) -> ComposeOutput;
86}
87
88pub struct DockerCompose;
91
92impl DockerCompose {
93 async fn spawn(program: &str, args: &[String]) -> ComposeOutput {
97 let joined = std::iter::once(program.to_string())
98 .chain(args.iter().cloned())
99 .collect::<Vec<_>>()
100 .join(" ");
101 match tokio::process::Command::new(program)
102 .args(args)
103 .stdout(std::process::Stdio::piped())
104 .stderr(std::process::Stdio::piped())
105 .output()
106 .await
107 {
108 Ok(output) => ComposeOutput {
109 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
110 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
111 code: output.status.code().unwrap_or(-1),
112 },
113 Err(err) => ComposeOutput {
114 stdout: String::new(),
115 stderr: format!("failed to spawn `{joined}`: {err}"),
116 code: 127,
117 },
118 }
119 }
120
121 async fn spawn_streaming(
127 program: &str,
128 args: &[String],
129 line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
130 ) -> ComposeOutput {
131 use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
132
133 let joined = std::iter::once(program.to_string())
134 .chain(args.iter().cloned())
135 .collect::<Vec<_>>()
136 .join(" ");
137 let mut child = match tokio::process::Command::new(program)
138 .args(args)
139 .stdout(std::process::Stdio::piped())
140 .stderr(std::process::Stdio::piped())
141 .spawn()
142 {
143 Ok(child) => child,
144 Err(err) => {
145 return ComposeOutput {
146 stdout: String::new(),
147 stderr: format!("failed to spawn `{joined}`: {err}"),
148 code: 127,
149 };
150 }
151 };
152 let stdout = child.stdout.take().expect("stdout piped above");
153 let mut stderr = child.stderr.take().expect("stderr piped above");
154 let stderr_task = tokio::spawn(async move {
156 let mut buffer = String::new();
157 let _ = stderr.read_to_string(&mut buffer).await;
158 buffer
159 });
160 let mut lines = BufReader::new(stdout).lines();
161 while let Ok(Some(line)) = lines.next_line().await {
162 line_sink(&line);
163 }
164 match child.wait().await {
165 Ok(status) => ComposeOutput {
166 stdout: String::new(),
167 stderr: stderr_task.await.unwrap_or_default(),
168 code: status.code().unwrap_or(-1),
169 },
170 Err(err) => ComposeOutput {
171 stdout: String::new(),
172 stderr: format!("failed to wait for `{joined}`: {err}"),
173 code: -1,
174 },
175 }
176 }
177}
178
179#[async_trait::async_trait]
180impl ComposeRunner for DockerCompose {
181 async fn run(&self, args: &[String]) -> ComposeOutput {
182 let mut full = vec!["compose".to_string()];
183 full.extend(args.iter().cloned());
184 Self::spawn("docker", &full).await
185 }
186
187 async fn run_docker(&self, args: &[String]) -> ComposeOutput {
188 Self::spawn("docker", args).await
189 }
190
191 async fn run_streaming(
192 &self,
193 args: &[String],
194 line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
195 ) -> ComposeOutput {
196 let mut full = vec!["compose".to_string()];
197 full.extend(args.iter().cloned());
198 Self::spawn_streaming("docker", &full, line_sink).await
199 }
200}
201
202pub fn check_output<'a>(output: &'a ComposeOutput, context: &str) -> Result<&'a str, CoreError> {
206 if output.code == 0 {
207 Ok(&output.stdout)
208 } else {
209 let tail = stderr_tail(&output.stderr);
210 let note = if tail.is_empty() {
211 String::new()
212 } else {
213 format!(": {tail}")
214 };
215 Err(CoreError::Rig(format!(
216 "{context} failed (exit {}){note}",
217 output.code
218 )))
219 }
220}
221
222pub(crate) fn stderr_tail(stderr: &str) -> String {
225 stderr
226 .lines()
227 .rev()
228 .take(STDERR_TAIL_LINES)
229 .collect::<Vec<_>>()
230 .into_iter()
231 .rev()
232 .collect::<Vec<_>>()
233 .join("\n")
234 .trim()
235 .to_string()
236}
237
238pub async fn compose_version(runner: &dyn ComposeRunner) -> Result<String, CoreError> {
243 let output = runner.run(&["version".to_string()]).await;
244 if output.code != 0 {
245 return Err(CoreError::Rig(format!(
246 "docker compose is unavailable (exit {}): {} — install Docker Desktop or \
247 the compose v2 plugin; the legacy docker-compose v1 binary is not supported",
248 output.code,
249 stderr_tail(&output.stderr)
250 )));
251 }
252 parse_compose_version(&output.stdout).ok_or_else(|| {
253 CoreError::Rig(format!(
254 "cannot parse `docker compose version` output ({:?}) — compose ≥ v2 \
255 (the `docker compose` plugin) is required",
256 output.stdout.trim()
257 ))
258 })
259}
260
261fn parse_compose_version(stdout: &str) -> Option<String> {
264 let rest = stdout.trim().strip_prefix("Docker Compose version ")?;
265 let version = rest.split_whitespace().next()?;
266 let version = version.strip_prefix('v').unwrap_or(version);
267 let mut parts = version.split('.');
268 let major: u32 = parts.next()?.parse().ok()?;
269 if major < 2 {
270 return None;
271 }
272 Some(version.to_string())
273}
274
275pub fn config_args(file: &Path, project_dir: &Path) -> Vec<String> {
284 vec![
285 "-f".into(),
286 file.display().to_string(),
287 "--project-directory".into(),
288 project_dir.display().to_string(),
289 "config".into(),
290 "--format".into(),
291 "json".into(),
292 ]
293}
294
295pub fn up_args(plan: &RigPlan, wait_timeout_s: u64) -> Vec<String> {
300 vec![
301 "-p".into(),
302 plan.name.clone(),
303 "-f".into(),
304 plan.compose_file.display().to_string(),
305 "up".into(),
306 "-d".into(),
307 "--wait".into(),
308 "--wait-timeout".into(),
309 wait_timeout_s.to_string(),
310 "--remove-orphans".into(),
311 ]
312}
313
314pub fn down_args(plan: &RigPlan, volumes: bool) -> Vec<String> {
318 let mut args = vec![
319 "-p".into(),
320 plan.name.clone(),
321 "-f".into(),
322 plan.compose_file.display().to_string(),
323 "down".into(),
324 "--remove-orphans".into(),
325 ];
326 if volumes {
327 args.push("-v".into());
328 }
329 args
330}
331
332pub fn ps_args(plan: &RigPlan) -> Vec<String> {
334 vec![
335 "-p".into(),
336 plan.name.clone(),
337 "-f".into(),
338 plan.compose_file.display().to_string(),
339 "ps".into(),
340 "--format".into(),
341 "json".into(),
342 ]
343}
344
345pub fn logs_args(plan: &RigPlan, tail: u32, follow: bool, service: Option<&str>) -> Vec<String> {
349 let mut args = vec![
350 "-p".into(),
351 plan.name.clone(),
352 "-f".into(),
353 plan.compose_file.display().to_string(),
354 "logs".into(),
355 "--tail".into(),
356 tail.to_string(),
357 ];
358 if follow {
359 args.push("-f".into());
360 }
361 if let Some(service) = service {
362 args.push(service.to_string());
363 }
364 args
365}
366
367pub fn volume_ls_args(project: &str) -> Vec<String> {
373 vec![
374 "volume".into(),
375 "ls".into(),
376 "--filter".into(),
377 format!("label=com.docker.compose.project={project}"),
378 "--format".into(),
379 "json".into(),
380 ]
381}
382
383pub fn docker_ps_publish_args(port: u16) -> Vec<String> {
387 vec![
388 "ps".into(),
389 "--filter".into(),
390 format!("publish={port}"),
391 "--format".into(),
392 "json".into(),
393 ]
394}
395
396#[derive(Debug, Clone, PartialEq, serde::Serialize)]
403pub struct PortMapping {
404 pub target: u16,
406 pub published: u16,
408}
409
410pub fn parse_config(
420 stdout: &str,
421 compose_file: &Path,
422 project_dir: &Path,
423) -> Result<RigPlan, CoreError> {
424 let trimmed = stdout.trim();
425 let doc: Value = serde_json::from_str(trimmed).map_err(|err| {
426 CoreError::Rig(format!(
427 "cannot parse `docker compose config` output: {err}"
428 ))
429 })?;
430 let root = match doc {
431 Value::Array(mut items) => items.pop().unwrap_or(Value::Null),
432 Value::Object(object) => Value::Object(object),
433 other => other,
434 };
435 let name = root
436 .get("name")
437 .and_then(Value::as_str)
438 .filter(|name| !name.is_empty())
439 .ok_or_else(|| {
440 CoreError::Rig(
441 "resolved compose config carries no `.name` — the project name is the \
442 rig's identity truth; refusing to guess (set COMPOSE_PROJECT_NAME in \
443 the rig's .env)"
444 .to_string(),
445 )
446 })?
447 .to_string();
448
449 let services: Vec<String> = root
450 .get("services")
451 .and_then(Value::as_object)
452 .map(|map| map.keys().cloned().collect())
453 .unwrap_or_default();
454
455 let mut port_mappings: Vec<PortMapping> = Vec::new();
456 if let Some(services) = root.get("services").and_then(Value::as_object) {
457 for service in services.values() {
458 let ports = service.get("ports").and_then(Value::as_array);
459 for port in ports.into_iter().flatten() {
460 let published = port.get("published").and_then(|value| {
465 value
466 .as_str()
467 .and_then(|s| s.parse().ok())
468 .or_else(|| value.as_u64().and_then(|n| u16::try_from(n).ok()))
469 });
470 let target = port
471 .get("target")
472 .and_then(|value| value.as_u64().and_then(|n| u16::try_from(n).ok()));
473 if let (Some(published), Some(target)) = (published, target)
474 && !port_mappings
475 .iter()
476 .any(|mapping| mapping.published == published)
477 {
478 port_mappings.push(PortMapping { target, published });
479 }
480 }
481 }
482 }
483 let host_ports = port_mappings
484 .iter()
485 .map(|mapping| mapping.published)
486 .collect();
487
488 let volumes: Vec<String> = root
489 .get("volumes")
490 .and_then(Value::as_object)
491 .map(|map| map.keys().cloned().collect())
492 .unwrap_or_default();
493
494 Ok(RigPlan {
495 name,
496 compose_file: compose_file.to_path_buf(),
497 project_dir: project_dir.to_path_buf(),
498 services,
499 host_ports,
500 port_mappings,
501 volumes,
502 })
503}
504
505pub async fn reset_preview(
516 runner: &dyn ComposeRunner,
517 plan: &RigPlan,
518) -> Result<Vec<String>, CoreError> {
519 let output = runner.run_docker(&volume_ls_args(&plan.name)).await;
520 let stdout = check_output(&output, "docker volume ls")?;
521 let prefix = format!("{}_", plan.name);
522 Ok(parse_volume_ls_ldjson(stdout)
523 .into_iter()
524 .map(|entry| entry.name)
525 .filter(|name| name.starts_with(&prefix))
526 .collect())
527}
528
529#[derive(Debug, Clone, PartialEq, Deserialize)]
531pub struct Publisher {
532 #[serde(default, rename = "URL")]
534 pub url: Option<String>,
535 #[serde(default, rename = "TargetPort")]
537 pub target_port: Option<u16>,
538 #[serde(default, rename = "PublishedPort")]
540 pub published_port: Option<u16>,
541 #[serde(default, rename = "Protocol")]
543 pub protocol: Option<String>,
544}
545
546#[derive(Debug, Clone, PartialEq, Deserialize)]
550pub struct ServiceStatus {
551 #[serde(default, rename = "Name")]
553 pub name: String,
554 #[serde(default, rename = "Service")]
556 pub service: String,
557 #[serde(default, rename = "State")]
559 pub state: String,
560 #[serde(default, rename = "Health")]
562 pub health: Option<String>,
563 #[serde(default, rename = "ExitCode")]
565 pub exit_code: Option<i64>,
566 #[serde(default, rename = "Publishers")]
568 pub publishers: Vec<Publisher>,
569}
570
571fn parse_ldjson<T: serde::de::DeserializeOwned>(stdout: &str, what: &str) -> Vec<T> {
577 stdout
578 .lines()
579 .filter(|line| !line.trim().is_empty())
580 .filter_map(|line| match serde_json::from_str::<T>(line) {
581 Ok(item) => Some(item),
582 Err(err) => {
583 tracing::warn!(source = what, error = %err, "skipping unparseable line");
584 None
585 }
586 })
587 .collect()
588}
589
590pub fn parse_ps_ldjson(stdout: &str) -> Vec<ServiceStatus> {
593 parse_ldjson(stdout, "docker compose ps")
594}
595
596#[derive(Debug, Clone, PartialEq, Deserialize)]
598pub struct VolumeEntry {
599 #[serde(default, rename = "Name")]
601 pub name: String,
602 #[serde(default, rename = "Labels")]
604 pub labels: Value,
605}
606
607pub fn parse_volume_ls_ldjson(stdout: &str) -> Vec<VolumeEntry> {
609 parse_ldjson(stdout, "docker volume ls")
610}
611
612#[derive(Debug, Clone, PartialEq)]
618pub struct DockerPsEntry {
619 pub name: String,
621 pub compose_project: Option<String>,
623}
624
625pub fn parse_docker_ps_ldjson(stdout: &str) -> Vec<DockerPsEntry> {
628 parse_ldjson::<serde_json::Value>(stdout, "docker ps")
629 .into_iter()
630 .filter_map(|value| {
631 let name = value
634 .get("Names")
635 .or_else(|| value.get("Name"))
636 .and_then(Value::as_str)
637 .map(|names| names.split(',').next().unwrap_or(names).trim().to_string())
638 .unwrap_or_default();
639 if name.is_empty() {
640 return None;
641 }
642 let compose_project = match value.get("Labels") {
643 Some(Value::String(labels)) => labels.split(',').find_map(|pair| {
644 pair.trim()
645 .strip_prefix("com.docker.compose.project=")
646 .map(str::to_string)
647 }),
648 Some(labels @ Value::Object(_)) => labels
649 .get("com.docker.compose.project")
650 .and_then(Value::as_str)
651 .map(str::to_string),
652 _ => None,
653 };
654 Some(DockerPsEntry {
655 name,
656 compose_project,
657 })
658 })
659 .collect()
660}
661
662#[cfg(test)]
663mod tests {
664 use std::path::Path;
665
666 use super::{
667 ComposeOutput, ComposeRunner, DockerCompose, PortMapping, check_output, config_args,
668 docker_ps_publish_args, down_args, logs_args, parse_compose_version, parse_config,
669 parse_docker_ps_ldjson, parse_ps_ldjson, parse_volume_ls_ldjson, ps_args, stderr_tail,
670 up_args, volume_ls_args,
671 };
672 use crate::rig::RigPlan;
673
674 fn sample_plan() -> RigPlan {
675 RigPlan {
676 name: "ignition-devops".into(),
677 compose_file: "/rigs/git-module/docker/docker-compose.yml".into(),
678 project_dir: "/rigs/git-module/docker".into(),
679 services: vec!["ignition".into(), "db".into()],
680 host_ports: vec![9088, 9443],
681 port_mappings: vec![
682 PortMapping {
683 target: 8088,
684 published: 9088,
685 },
686 PortMapping {
687 target: 443,
688 published: 9443,
689 },
690 ],
691 volumes: vec!["gw-data".into()],
692 }
693 }
694
695 fn s(args: &[&str]) -> Vec<String> {
696 args.iter().map(|a| a.to_string()).collect()
697 }
698
699 #[test]
702 fn config_args_pinned() {
703 assert_eq!(
704 config_args(
705 Path::new("/rigs/docker/compose.yml"),
706 Path::new("/rigs/docker"),
707 ),
708 s(&[
709 "-f",
710 "/rigs/docker/compose.yml",
711 "--project-directory",
712 "/rigs/docker",
713 "config",
714 "--format",
715 "json"
716 ]),
717 );
718 }
719
720 #[test]
721 fn up_args_pinned() {
722 assert_eq!(
723 up_args(&sample_plan(), 300),
724 s(&[
725 "-p",
726 "ignition-devops",
727 "-f",
728 "/rigs/git-module/docker/docker-compose.yml",
729 "up",
730 "-d",
731 "--wait",
732 "--wait-timeout",
733 "300",
734 "--remove-orphans"
735 ]),
736 );
737 }
738
739 #[test]
740 fn down_args_pinned_with_and_without_volumes() {
741 assert_eq!(
742 down_args(&sample_plan(), false),
743 s(&[
744 "-p",
745 "ignition-devops",
746 "-f",
747 "/rigs/git-module/docker/docker-compose.yml",
748 "down",
749 "--remove-orphans"
750 ]),
751 "plain down keeps volumes (reset's -v arrives in 04-02)"
752 );
753 assert_eq!(
754 down_args(&sample_plan(), true),
755 s(&[
756 "-p",
757 "ignition-devops",
758 "-f",
759 "/rigs/git-module/docker/docker-compose.yml",
760 "down",
761 "--remove-orphans",
762 "-v"
763 ]),
764 );
765 }
766
767 #[test]
768 fn ps_args_pinned() {
769 assert_eq!(
770 ps_args(&sample_plan()),
771 s(&[
772 "-p",
773 "ignition-devops",
774 "-f",
775 "/rigs/git-module/docker/docker-compose.yml",
776 "ps",
777 "--format",
778 "json"
779 ]),
780 );
781 }
782
783 #[test]
784 fn logs_args_pinned() {
785 assert_eq!(
786 logs_args(&sample_plan(), 200, false, None),
787 s(&[
788 "-p",
789 "ignition-devops",
790 "-f",
791 "/rigs/git-module/docker/docker-compose.yml",
792 "logs",
793 "--tail",
794 "200"
795 ]),
796 );
797 assert_eq!(
798 logs_args(&sample_plan(), 50, true, Some("ignition")),
799 s(&[
800 "-p",
801 "ignition-devops",
802 "-f",
803 "/rigs/git-module/docker/docker-compose.yml",
804 "logs",
805 "--tail",
806 "50",
807 "-f",
808 "ignition"
809 ]),
810 );
811 }
812
813 #[test]
814 fn volume_ls_args_pinned_plain_docker_shape() {
815 assert_eq!(
816 volume_ls_args("ignition-devops"),
817 s(&[
818 "volume",
819 "ls",
820 "--filter",
821 "label=com.docker.compose.project=ignition-devops",
822 "--format",
823 "json"
824 ]),
825 "plain docker CLI: no compose subcommand, no -p prefix"
826 );
827 }
828
829 #[test]
830 fn docker_ps_publish_args_pinned() {
831 assert_eq!(
832 docker_ps_publish_args(18088),
833 s(&["ps", "--filter", "publish=18088", "--format", "json"]),
834 );
835 }
836
837 #[test]
840 fn version_parses_live_capture_and_rejects_old_or_garbage() {
841 assert_eq!(
843 parse_compose_version("Docker Compose version v5.1.2\n"),
844 Some("5.1.2".into())
845 );
846 assert_eq!(
847 parse_compose_version("Docker Compose version v2.24.6\n"),
848 Some("2.24.6".into())
849 );
850 assert_eq!(
852 parse_compose_version("Docker Compose version 2.34.0\n"),
853 Some("2.34.0".into())
854 );
855 assert_eq!(parse_compose_version("1.29.2"), None);
857 assert_eq!(parse_compose_version(""), None);
858 assert_eq!(parse_compose_version("docker-compose 1.25.5"), None);
859 }
860
861 #[test]
864 fn check_output_maps_nonzero_to_rig_with_stderr_tail() {
865 let ok = ComposeOutput {
866 stdout: "{}".into(),
867 stderr: String::new(),
868 code: 0,
869 };
870 assert_eq!(check_output(&ok, "ctx").unwrap(), "{}");
871
872 let tail = (1..=9).map(|n| format!("line-{n}")).collect::<Vec<_>>();
873 let failed = ComposeOutput {
874 stdout: String::new(),
875 stderr: tail.join("\n"),
876 code: 1,
877 };
878 let err = check_output(&failed, "docker compose up").unwrap_err();
879 assert!(matches!(err, crate::error::CoreError::Rig(_)));
880 let message = err.to_string();
881 assert!(
882 message.contains("docker compose up failed (exit 1)"),
883 "{message}"
884 );
885 assert!(
887 !message.contains("line-4\n"),
888 "tail keeps at most 5 lines: {message}"
889 );
890 assert!(message.contains("line-5"));
891 assert!(message.contains("line-9"));
892 }
893
894 #[test]
895 fn stderr_tail_trims_and_keeps_order() {
896 assert_eq!(stderr_tail("b\na\n"), "b\na");
897 assert_eq!(stderr_tail(""), "");
898 assert_eq!(stderr_tail("\n\n"), "");
899 }
900
901 const CONFIG_FIXTURE: &str = r#"{
907 "name": "whk-global",
908 "services": {
909 "ignition": {
910 "image": "inductiveautomation/ignition:8.3.6",
911 "ports": [
912 {"mode": "host", "target": 8088, "published": "9088", "protocol": "tcp"},
913 {"mode": "host", "target": 443, "published": "9443", "protocol": "tcp"}
914 ]
915 },
916 "git-server": {
917 "image": "git-server:latest",
918 "ports": [{"mode": "host", "target": 22, "published": "9022", "protocol": "tcp"}]
919 }
920 },
921 "volumes": {
922 "gw-data": {"name": "whk-global_gw-data", "driver": "local"},
923 "gw-tag-definition": {"name": "whk-global_gw-tag-definition"}
924 },
925 "networks": {"default": {"name": "whk-global_default"}},
926 "secrets": {}
927 }"#;
928
929 #[test]
930 fn parse_config_reads_name_services_ports_volumes() {
931 let plan = parse_config(
932 CONFIG_FIXTURE,
933 Path::new("/whk/whk-environment-orchestration/docker-compose.yml"),
934 Path::new("/whk/whk-environment-orchestration"),
935 )
936 .expect("fixture parses");
937 assert_eq!(plan.name, "whk-global");
938 assert_eq!(plan.services, vec!["git-server", "ignition"]);
939 assert_eq!(plan.host_ports, vec![9022, 9088, 9443]);
942 assert!(
943 plan.port_mappings.contains(&PortMapping {
944 target: 8088,
945 published: 9088
946 }),
947 "string-form `published` tolerated: {:?}",
948 plan.port_mappings
949 );
950 assert_eq!(plan.volumes, vec!["gw-data", "gw-tag-definition"]);
951 assert_eq!(
952 plan.compose_file,
953 Path::new("/whk/whk-environment-orchestration/docker-compose.yml")
954 );
955 assert_eq!(
956 plan.project_dir,
957 Path::new("/whk/whk-environment-orchestration")
958 );
959 }
960
961 #[test]
962 fn parse_config_tolerates_array_doc_and_numeric_published() {
963 let array_form = format!("[{}]", CONFIG_FIXTURE.replace("\"9088\"", "9088"));
966 let plan = parse_config(&array_form, Path::new("/c.yml"), Path::new("/"))
967 .expect("array doc + numeric published parse");
968 assert_eq!(plan.name, "whk-global");
969 assert!(plan.host_ports.contains(&9088));
970 }
971
972 #[test]
973 fn parse_config_without_name_refuses() {
974 let fixture = r#"{"services": {"ignition": {}}}"#;
975 let err = parse_config(fixture, Path::new("/c.yml"), Path::new("/")).unwrap_err();
976 let message = err.to_string();
977 assert!(message.contains("no `.name`"), "{message}");
978 assert!(message.contains("COMPOSE_PROJECT_NAME"), "{message}");
979 }
980
981 #[test]
982 fn parse_config_empty_services_and_missing_sections() {
983 let plan = parse_config(r#"{"name": "bare"}"#, Path::new("/c.yml"), Path::new("/"))
984 .expect("minimal doc parses");
985 assert!(plan.services.is_empty());
986 assert!(plan.host_ports.is_empty());
987 assert!(plan.volumes.is_empty());
988 }
989
990 #[tokio::test]
997 async fn config_resolves_env_project_name_from_project_directory() {
998 let dir = tempfile::tempdir().expect("tempdir");
999 let compose = dir.path().join("docker-compose.yml");
1000 std::fs::write(
1001 &compose,
1002 "services:\n sidecar:\n image: alpine:latest\n",
1003 )
1004 .expect("write compose");
1005 std::fs::write(
1006 dir.path().join(".env"),
1007 "COMPOSE_PROJECT_NAME=env-resolved-name\n",
1008 )
1009 .expect("write .env");
1010
1011 let runner = DockerCompose;
1012 if runner.run(&["version".to_string()]).await.code != 0 {
1013 eprintln!("skipping: docker compose unavailable");
1014 return;
1015 }
1016 let output = runner.run(&config_args(&compose, dir.path())).await;
1019 assert_eq!(output.code, 0, "config run: {}", output.stderr);
1020 let plan = parse_config(&output.stdout, &compose, dir.path()).expect("resolve parses");
1021 assert_eq!(
1022 plan.name, "env-resolved-name",
1023 "the .env name wins over the directory-derived default"
1024 );
1025 }
1026
1027 #[tokio::test]
1033 async fn run_streaming_forwards_lines_via_piped_stdout() {
1034 let dir = tempfile::tempdir().expect("tempdir");
1035 let compose = dir.path().join("docker-compose.yml");
1036 std::fs::write(
1037 &compose,
1038 "services:\n sidecar:\n image: alpine:latest\n",
1039 )
1040 .expect("write compose");
1041
1042 let runner = DockerCompose;
1043 if runner.run(&["version".to_string()]).await.code != 0 {
1044 eprintln!("skipping: docker compose unavailable");
1045 return;
1046 }
1047 if runner.run_docker(&["version".to_string()]).await.code != 0 {
1054 eprintln!("skipping: docker daemon unreachable");
1055 return;
1056 }
1057 let plan = parse_config(
1058 r#"{"name":"stream-fixture","services":{"sidecar":{"image":"alpine"}}}"#,
1059 &compose,
1060 dir.path(),
1061 )
1062 .expect("plan parses");
1063 let mut streamed = 0usize;
1064 let mut sink = |_: &str| streamed += 1;
1065 let output = runner
1066 .run_streaming(&logs_args(&plan, 5, false, None), &mut sink)
1067 .await;
1068 assert_eq!(
1069 output.code, 0,
1070 "compose logs on an absent project: {}",
1071 output.stderr
1072 );
1073 assert_eq!(
1074 streamed, 0,
1075 "no containers → no lines, but the spawn/read/wait ran"
1076 );
1077 assert!(
1078 output.stdout.is_empty(),
1079 "streamed stdout never reports back"
1080 );
1081 }
1082
1083 const PS_ROW_FULL: &str = r#"{"Command":"\"/usr/bin/start-ignition.sh\"","CreatedAt":"2026-08-22 10:00:00 +0000 UTC","ExitCode":0,"Health":"healthy","Labels":{"com.docker.compose.project":"whk-global","com.docker.compose.service":"ignition"},"Name":"whk-global-ignition-1","Networks":"whk-global_default","Ports":"0.0.0.0:9088->8088/tcp, 0.0.0.0:9443->443/tcp","Publishers":[{"URL":"0.0.0.0","TargetPort":8088,"PublishedPort":9088,"Protocol":"tcp"},{"URL":"0.0.0.0","TargetPort":443,"PublishedPort":9443,"Protocol":"tcp"}],"RunningFor":"2 hours","Service":"ignition","State":"running","Status":"Up 2 hours (healthy)"}"#;
1088
1089 const PS_ROW_MINIMAL: &str = r#"{"Command":"sleep","ExitCode":0,"Name":"whk-global-sidecar-1","Service":"sidecar","State":"exited","Status":"Exited (0) 3 minutes ago"}"#;
1092
1093 #[test]
1094 fn parse_ps_ldjson_reads_rows_and_tolerates_missing_optionals() {
1095 let rows = parse_ps_ldjson(&format!("{PS_ROW_FULL}\n{PS_ROW_MINIMAL}\n"));
1096 assert_eq!(rows.len(), 2);
1097 assert_eq!(rows[0].name, "whk-global-ignition-1");
1098 assert_eq!(rows[0].service, "ignition");
1099 assert_eq!(rows[0].state, "running");
1100 assert_eq!(rows[0].health.as_deref(), Some("healthy"));
1101 assert_eq!(rows[0].exit_code, Some(0));
1102 assert_eq!(rows[0].publishers.len(), 2);
1103 assert_eq!(rows[0].publishers[0].target_port, Some(8088));
1104 assert_eq!(rows[0].publishers[0].published_port, Some(9088));
1105 assert_eq!(rows[0].publishers[0].protocol.as_deref(), Some("tcp"));
1106
1107 assert_eq!(rows[1].health, None);
1108 assert!(rows[1].publishers.is_empty());
1109 }
1110
1111 #[test]
1112 fn parse_ps_ldjson_empty_and_warning_lines() {
1113 assert!(parse_ps_ldjson("").is_empty(), "a down rig has no rows");
1114 let rows = parse_ps_ldjson(&format!("[NOTE] something\n{PS_ROW_MINIMAL}\n"));
1116 assert_eq!(rows.len(), 1);
1117 }
1118
1119 #[test]
1120 fn parse_volume_ls_ldjson_fixture() {
1121 let stdout = concat!(
1122 r#"{"CreatedAt":"2026-08-22T10:00:00Z","Driver":"local","Labels":{"com.docker.compose.project":"ignition-devops","com.docker.compose.volume":"gw-data"},"Mountpoint":"/var/lib/docker/volumes/ignition-devops_gw-data/_data","Name":"ignition-devops_gw-data","Options":null,"Scope":"local"}"#,
1123 "\n",
1124 r#"{"CreatedAt":"2026-08-22T10:00:00Z","Driver":"local","Labels":{"com.docker.compose.project":"ignition-devops","com.docker.compose.volume":"gw-tag-definition"},"Mountpoint":"/var/lib/docker/volumes/ignition-devops_gw-tag-definition/_data","Name":"ignition-devops_gw-tag-definition","Options":null,"Scope":"local"}"#,
1125 "\n",
1126 );
1127 let volumes = parse_volume_ls_ldjson(stdout);
1128 assert_eq!(volumes.len(), 2);
1129 assert_eq!(volumes[0].name, "ignition-devops_gw-data");
1130 assert_eq!(volumes[1].name, "ignition-devops_gw-tag-definition");
1131 assert!(parse_volume_ls_ldjson("").is_empty());
1132 }
1133
1134 #[test]
1135 fn parse_docker_ps_labels_as_string_and_map() {
1136 let string_labels = r#"{"ID":"abc123","Image":"alpine","Labels":"com.docker.compose.project=other-project,com.docker.compose.service=sidecar","Names":"other-project-sidecar-1","Ports":"0.0.0.0:18088->8088/tcp","State":"running"}"#;
1138 let rows = parse_docker_ps_ldjson(string_labels);
1139 assert_eq!(rows.len(), 1);
1140 assert_eq!(rows[0].name, "other-project-sidecar-1");
1141 assert_eq!(rows[0].compose_project.as_deref(), Some("other-project"));
1142
1143 let map_labels =
1145 r#"{"Name":"ign-research","Labels":{"com.docker.compose.project":"research"}}"#;
1146 let rows = parse_docker_ps_ldjson(map_labels);
1147 assert_eq!(rows[0].name, "ign-research");
1148 assert_eq!(rows[0].compose_project.as_deref(), Some("research"));
1149
1150 let rows = parse_docker_ps_ldjson(r#"{"Names":"standalone-thing","Labels":""}"#);
1152 assert_eq!(rows[0].compose_project, None);
1153 assert!(parse_docker_ps_ldjson("").is_empty());
1154 }
1155}