1use std::path::Path;
2
3use crate::error::{Error, Result};
4use crate::generate::GeneratedFile;
5
6pub struct ProcessBundleParams<'a> {
15 pub service_dir: &'a Path,
16 pub service_name: &'a str,
17 pub extra_networks: &'a [String],
18 pub extra_volumes: &'a [String],
19 pub podman_args: &'a [String],
21 pub extra_exec_start_pre: &'a [String],
23 pub port_names: &'a [String],
28}
29
30#[derive(Debug)]
32pub struct ProcessedBundle {
33 pub quadlet_files: Vec<GeneratedFile>,
34 pub config_files: Vec<GeneratedFile>,
35 pub images: Vec<String>,
36 pub bind_mount_dirs: Vec<std::path::PathBuf>,
38 pub files: Vec<(std::path::PathBuf, std::path::PathBuf)>,
44}
45
46pub fn extract_images(files: &[GeneratedFile]) -> Vec<String> {
48 let mut images = Vec::new();
49 for file in files {
50 let path_str = file.path.to_string_lossy();
51 if !path_str.ends_with(".container") {
52 continue;
53 }
54 for line in file.content.lines() {
55 let trimmed = line.trim();
56 if let Some(image) = trimmed.strip_prefix("Image=") {
57 let image = image.trim().to_string();
58 if !image.is_empty() && !images.contains(&image) {
59 images.push(image);
60 }
61 }
62 }
63 }
64 images
65}
66
67fn validate_quadlet_env_refs(
75 content: &str,
76 file_name: &str,
77 params: &ProcessBundleParams<'_>,
78) -> Result<()> {
79 if content.contains("{{") {
80 return Err(Error::Bundle(format!(
81 "quadlet '{}' for service '{}' contains '{{{{...}}}}' template syntax — quadlets \
82 are plain podman files; use runtime env vars (${{SERVICE_PORT_<NAME>}}, \
83 ${{SERVICE_HOME}}) instead",
84 file_name, params.service_name
85 )));
86 }
87 let mut rest = content;
88 while let Some(idx) = rest.find("${SERVICE_PORT_") {
89 let tail = &rest[idx + "${SERVICE_PORT_".len()..];
90 let Some(end) = tail.find('}') else { break };
91 let var_name = &tail[..end];
92 if !params
93 .port_names
94 .iter()
95 .any(|p| p.eq_ignore_ascii_case(var_name))
96 {
97 return Err(Error::Bundle(format!(
98 "quadlet '{}' for service '{}' references ${{SERVICE_PORT_{}}} but \
99 service.toml declares no [[ports]] entry named '{}'",
100 file_name,
101 params.service_name,
102 var_name,
103 var_name.to_lowercase()
104 )));
105 }
106 rest = &tail[end..];
107 }
108
109 let uses_service_vars =
114 content.contains("${SERVICE_HOME}") || content.contains("${SERVICE_PORT_");
115 if uses_service_vars && !section_has_line(content, "[Service]", "EnvironmentFile=") {
116 return Err(Error::Bundle(format!(
117 "quadlet '{}' for service '{}' uses ${{SERVICE_*}} vars but has no \
118 EnvironmentFile= in its [Service] section — systemd would expand them \
119 to empty strings",
120 file_name, params.service_name
121 )));
122 }
123 Ok(())
124}
125
126fn section_has_line(content: &str, section: &str, prefix: &str) -> bool {
128 let mut in_section = false;
129 for line in content.lines() {
130 let trimmed = line.trim();
131 if trimmed.starts_with('[') && trimmed.ends_with(']') {
132 in_section = trimmed == section;
133 } else if in_section && trimmed.starts_with(prefix) {
134 return true;
135 }
136 }
137 false
138}
139
140pub fn extract_bind_mount_dirs(
148 files: &[GeneratedFile],
149 service_home: &Path,
150) -> crate::error::Result<Vec<std::path::PathBuf>> {
151 let home = crate::home_dir()?;
152 let mut dirs = Vec::new();
153 for file in files {
154 let path_str = file.path.to_string_lossy();
155 if !path_str.ends_with(".container") {
156 continue;
157 }
158 for line in file.content.lines() {
159 let trimmed = line.trim();
160 if let Some(vol) = trimmed.strip_prefix("Volume=") {
161 if vol.contains(".volume:") {
163 continue;
164 }
165 if let Some(colon_pos) = vol.find(':') {
167 let host_path = &vol[..colon_pos];
168 if host_path.is_empty() {
169 continue;
170 }
171 let expanded = host_path
173 .replace("%h", &home.to_string_lossy())
174 .replace("${SERVICE_HOME}", &service_home.to_string_lossy());
175 let path = std::path::Path::new(&expanded);
177 if path.extension().is_some() {
178 continue;
179 }
180 dirs.push(std::path::PathBuf::from(expanded));
181 }
182 }
183 }
184 }
185 Ok(dirs)
186}
187
188pub fn inject_networks(content: &str, networks: &[String]) -> String {
195 if networks.is_empty() {
196 return content.to_string();
197 }
198 let extra_lines: String = networks
199 .iter()
200 .map(|n| {
201 if let Some((name, opts)) = n.split_once(':') {
202 format!("Network={name}.network:{opts}")
203 } else {
204 format!("Network={n}.network")
205 }
206 })
207 .collect::<Vec<_>>()
208 .join("\n");
209
210 inject_before_section(content, &extra_lines, "[Service]")
211}
212
213pub fn inject_podman_args(content: &str, args: &[String]) -> String {
215 if args.is_empty() {
216 return content.to_string();
217 }
218 let line = format!("PodmanArgs={}", args.join(" "));
219 inject_before_section(content, &line, "[Service]")
220}
221
222pub fn inject_extra_volumes(content: &str, volumes: &[String]) -> String {
225 if volumes.is_empty() {
226 return content.to_string();
227 }
228 let extra_lines: String = volumes
229 .iter()
230 .map(|v| format!("Volume={v}"))
231 .collect::<Vec<_>>()
232 .join("\n");
233
234 inject_before_section(content, &extra_lines, "[Service]")
235}
236
237fn inject_before_section(content: &str, extra_lines: &str, section_header: &str) -> String {
239 let mut lines: Vec<&str> = content.lines().collect();
240 let insert_pos = lines.iter().position(|l| l.trim() == section_header);
241
242 match insert_pos {
243 Some(pos) => {
244 let needs_blank = pos > 0 && !lines[pos - 1].trim().is_empty();
246 let mut insert = Vec::new();
247 if needs_blank {
248 insert.push("");
249 }
250 for line in extra_lines.lines() {
251 insert.push(line);
252 }
253 for (i, line) in insert.iter().enumerate() {
255 lines.insert(pos + i, line);
256 }
257 let mut result = lines.join("\n");
258 if content.ends_with('\n') {
260 result.push('\n');
261 }
262 result
263 }
264 None => {
265 let mut result = content.to_string();
267 if !result.ends_with('\n') {
268 result.push('\n');
269 }
270 result.push_str(extra_lines);
271 result.push('\n');
272 result
273 }
274 }
275}
276
277pub fn process_quadlet_bundle(params: &ProcessBundleParams<'_>) -> Result<ProcessedBundle> {
280 let quadlets_dir = params.service_dir.join("quadlets");
281
282 if !quadlets_dir.is_dir() {
283 return Err(Error::Bundle(format!(
284 "quadlets/ directory not found for service '{}'",
285 params.service_name
286 )));
287 }
288
289 let mut quadlet_files = Vec::new();
290 let service_home = crate::service_home(params.service_name)?;
291 let data_root = crate::paths::service_data_root()?;
292 let canonical_data_root = crate::home_dir()?.join(".local/share/services");
293
294 let entries = std::fs::read_dir(&quadlets_dir).map_err(|source| Error::FileRead {
295 path: quadlets_dir.clone(),
296 source,
297 })?;
298
299 for entry in entries {
300 let entry = entry.map_err(|source| Error::FileRead {
301 path: quadlets_dir.clone(),
302 source,
303 })?;
304 let path = entry.path();
305 if !path.is_file() {
306 continue;
307 }
308
309 let mut content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
310 path: path.clone(),
311 source,
312 })?;
313
314 let file_name = path
315 .file_name()
316 .ok_or_else(|| Error::Bundle(format!("invalid file path: {}", path.display())))?
317 .to_string_lossy();
318
319 validate_quadlet_env_refs(&content, &file_name, params)?;
320
321 if data_root != canonical_data_root {
330 content = content.replace("%h/.local/share/services", &data_root.to_string_lossy());
331 }
332
333 let is_main_container = file_name == format!("{}.container", params.service_name);
340 let header = format!("# Service-Source: registry/{}\n", params.service_name);
341 content = header + &content;
342
343 if file_name.ends_with(".container") {
345 content = inject_networks(&content, params.extra_networks);
346 content = inject_extra_volumes(&content, params.extra_volumes);
347 content = inject_podman_args(&content, params.podman_args);
348 if is_main_container {
351 for cmd in params.extra_exec_start_pre {
352 content = inject_before_section(
353 &content,
354 &format!("ExecStartPre={cmd}"),
355 "[Install]",
356 );
357 }
358 }
359 }
360
361 quadlet_files.push(GeneratedFile {
364 path: service_home.join(file_name.as_ref()),
365 content,
366 });
367 }
368
369 if quadlet_files.is_empty() {
370 return Err(Error::Bundle(format!(
371 "no quadlet files found in quadlets/ for service '{}'",
372 params.service_name
373 )));
374 }
375
376 quadlet_files.sort_by(|a, b| a.path.cmp(&b.path));
378
379 let images = extract_images(&quadlet_files);
380 let bind_mount_dirs = extract_bind_mount_dirs(&quadlet_files, &service_home)?;
381 let config_files = process_configs(params.service_dir, &service_home)?;
382 let files = collect_files(params.service_dir, &service_home)?;
383
384 Ok(ProcessedBundle {
385 quadlet_files,
386 config_files,
387 images,
388 bind_mount_dirs,
389 files,
390 })
391}
392
393pub fn collect_files(
398 service_dir: &Path,
399 service_home: &Path,
400) -> Result<Vec<(std::path::PathBuf, std::path::PathBuf)>> {
401 let files_dir = service_dir.join("files");
402 if !files_dir.is_dir() {
403 return Ok(Vec::new());
404 }
405 let mut out = Vec::new();
406 collect_files_recursive(&files_dir, &files_dir, service_home, &mut out)?;
407 out.sort_by(|a, b| a.1.cmp(&b.1));
408 Ok(out)
409}
410
411fn collect_files_recursive(
412 base_dir: &Path,
413 current_dir: &Path,
414 service_home: &Path,
415 out: &mut Vec<(std::path::PathBuf, std::path::PathBuf)>,
416) -> Result<()> {
417 let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
418 path: current_dir.to_path_buf(),
419 source,
420 })?;
421 for entry in entries {
422 let entry = entry.map_err(|source| Error::FileRead {
423 path: current_dir.to_path_buf(),
424 source,
425 })?;
426 let path = entry.path();
427 if path.is_dir() {
428 collect_files_recursive(base_dir, &path, service_home, out)?;
429 } else if path.is_file() {
430 let relative = path
431 .strip_prefix(base_dir)
432 .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
433 out.push((path.clone(), service_home.join(relative)));
434 }
435 }
436 Ok(())
437}
438
439pub fn process_configs(service_dir: &Path, service_home: &Path) -> Result<Vec<GeneratedFile>> {
442 let configs_dir = service_dir.join("configs");
443 if !configs_dir.is_dir() {
444 return Ok(Vec::new());
445 }
446
447 let mut files = Vec::new();
448 collect_configs_recursive(&configs_dir, &configs_dir, service_home, &mut files)?;
449 files.sort_by(|a, b| a.path.cmp(&b.path));
450 Ok(files)
451}
452
453fn collect_configs_recursive(
454 base_dir: &Path,
455 current_dir: &Path,
456 service_home: &Path,
457 files: &mut Vec<GeneratedFile>,
458) -> Result<()> {
459 let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
460 path: current_dir.to_path_buf(),
461 source,
462 })?;
463
464 for entry in entries {
465 let entry = entry.map_err(|source| Error::FileRead {
466 path: current_dir.to_path_buf(),
467 source,
468 })?;
469 let path = entry.path();
470
471 if path.is_dir() {
472 collect_configs_recursive(base_dir, &path, service_home, files)?;
473 } else if path.is_file() {
474 let relative = path
475 .strip_prefix(base_dir)
476 .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
477
478 let content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
479 path: path.clone(),
480 source,
481 })?;
482
483 files.push(GeneratedFile {
484 path: service_home.join("configs").join(relative),
485 content,
486 });
487 }
488 }
489
490 Ok(())
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use std::path::PathBuf;
497
498 #[test]
499 fn extract_images_from_container_files_only() {
500 let files = vec![
501 GeneratedFile {
502 path: PathBuf::from("/q/myapp.container"),
503 content: "[Container]\nImage=docker.io/library/nginx:latest\n".to_string(),
504 },
505 GeneratedFile {
506 path: PathBuf::from("/q/myapp.network"),
507 content: "[Network]\nImage=should-be-ignored\n".to_string(),
508 },
509 GeneratedFile {
510 path: PathBuf::from("/q/myapp-db.container"),
511 content: "[Container]\nImage=docker.io/library/postgres:16\n".to_string(),
512 },
513 ];
514 let images = extract_images(&files);
515 assert_eq!(
516 images,
517 vec![
518 "docker.io/library/nginx:latest".to_string(),
519 "docker.io/library/postgres:16".to_string(),
520 ]
521 );
522 }
523
524 #[test]
525 fn extract_images_deduplicates() {
526 let files = vec![
527 GeneratedFile {
528 path: PathBuf::from("/q/a.container"),
529 content: "Image=docker.io/img:1\n".to_string(),
530 },
531 GeneratedFile {
532 path: PathBuf::from("/q/b.container"),
533 content: "Image=docker.io/img:1\nImage=docker.io/img:2\n".to_string(),
534 },
535 ];
536 let images = extract_images(&files);
537 assert_eq!(
538 images,
539 vec!["docker.io/img:1".to_string(), "docker.io/img:2".to_string(),]
540 );
541 }
542
543 #[test]
544 fn inject_networks_before_service_section() {
545 let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
546 let result = inject_networks(content, &["caddy".to_string(), "auth".to_string()]);
547 assert_eq!(
548 result,
549 "[Container]\nImage=nginx\n\nNetwork=caddy.network\nNetwork=auth.network\n[Service]\nRestart=always\n"
550 );
551 }
552
553 #[test]
554 fn inject_networks_no_service_section_appends() {
555 let content = "[Container]\nImage=nginx\n";
556 let result = inject_networks(content, &["caddy".to_string()]);
557 assert_eq!(result, "[Container]\nImage=nginx\nNetwork=caddy.network\n");
558 }
559
560 #[test]
561 fn inject_extra_volumes_before_service_section() {
562 let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
563 let result =
564 inject_extra_volumes(content, &["/host/ca.crt:/etc/ssl/ca.crt:ro".to_string()]);
565 assert_eq!(
566 result,
567 "[Container]\nImage=nginx\n\nVolume=/host/ca.crt:/etc/ssl/ca.crt:ro\n[Service]\nRestart=always\n"
568 );
569 }
570
571 #[test]
572 fn inject_extra_volumes_no_service_section_appends() {
573 let content = "[Container]\nImage=nginx";
574 let result = inject_extra_volumes(content, &["/a:/b".to_string()]);
575 assert_eq!(result, "[Container]\nImage=nginx\nVolume=/a:/b\n");
576 }
577
578 #[test]
579 fn inject_networks_adds_blank_line_when_needed() {
580 let content =
581 "[Container]\nImage=nginx\nNetwork=mynet.network\n[Service]\nRestart=always\n";
582 let result = inject_networks(content, &["caddy".to_string()]);
583 assert_eq!(
585 result,
586 "[Container]\nImage=nginx\nNetwork=mynet.network\n\nNetwork=caddy.network\n[Service]\nRestart=always\n"
587 );
588 }
589
590 #[test]
591 fn process_quadlet_bundle_errors_on_missing_dir() {
592 let params = ProcessBundleParams {
593 service_dir: Path::new("/nonexistent"),
594 service_name: "test",
595 extra_networks: &[],
596 extra_volumes: &[],
597 podman_args: &[],
598 extra_exec_start_pre: &[],
599 port_names: &[],
600 };
601 let err = process_quadlet_bundle(¶ms).unwrap_err();
602 assert!(err.to_string().contains("quadlets/ directory not found"));
603 }
604
605 #[test]
606 fn process_quadlet_bundle_reads_and_processes_files() {
607 let tmp = tempfile::tempdir()
608 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
609 let service_dir = tmp.path().join("myservice");
610 let quadlets_dir = service_dir.join("quadlets");
611 std::fs::create_dir_all(&quadlets_dir)
612 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
613
614 std::fs::write(
615 quadlets_dir.join("app.container"),
616 "[Container]\nImage=nginx:latest\nPublishPort=${SERVICE_PORT_HTTP}:80\nVolume=${SERVICE_HOME}/data:/data\n\n[Service]\nEnvironmentFile=%h/.local/share/services/myservice/.env\nRestart=always\n",
617 )
618 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
619
620 std::fs::write(
621 quadlets_dir.join("app.network"),
622 "[Network]\nDriver=bridge\n",
623 )
624 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
625
626 let params = ProcessBundleParams {
627 service_dir: &service_dir,
628 service_name: "myservice",
629 extra_networks: &["caddy".to_string()],
630 extra_volumes: &[],
631 podman_args: &[],
632 extra_exec_start_pre: &[],
633 port_names: &["http".to_string()],
634 };
635
636 let bundle = process_quadlet_bundle(¶ms)
637 .unwrap_or_else(|e| unreachable!("process_quadlet_bundle should not fail: {e}"));
638
639 assert_eq!(bundle.quadlet_files.len(), 2);
640 assert_eq!(bundle.images, vec!["nginx:latest".to_string()]);
641
642 let container_file = bundle
643 .quadlet_files
644 .iter()
645 .find(|f| f.path.to_string_lossy().ends_with(".container"))
646 .unwrap_or_else(|| unreachable!("container file must exist"));
647 assert!(
649 container_file
650 .content
651 .contains("PublishPort=${SERVICE_PORT_HTTP}:80")
652 );
653 assert!(
654 container_file
655 .content
656 .contains("Volume=${SERVICE_HOME}/data:/data")
657 );
658 assert!(container_file.content.contains("Network=caddy.network"));
660 let service_home = crate::service_home("myservice")
662 .unwrap_or_else(|e| unreachable!("service_home should resolve in tests: {e}"));
663 assert!(bundle.bind_mount_dirs.contains(&service_home.join("data")));
664
665 let network_file = bundle
667 .quadlet_files
668 .iter()
669 .find(|f| f.path.to_string_lossy().ends_with(".network"))
670 .unwrap_or_else(|| unreachable!("network file must exist"));
671 assert!(!network_file.content.contains("Network=caddy.network"));
672 }
673
674 #[test]
675 fn process_quadlet_bundle_errors_on_empty_dir() {
676 let tmp = tempfile::tempdir()
677 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
678 let service_dir = tmp.path().join("empty");
679 let quadlets_dir = service_dir.join("quadlets");
680 std::fs::create_dir_all(&quadlets_dir)
681 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
682
683 let params = ProcessBundleParams {
684 service_dir: &service_dir,
685 service_name: "empty",
686 extra_networks: &[],
687 extra_volumes: &[],
688 podman_args: &[],
689 extra_exec_start_pre: &[],
690 port_names: &[],
691 };
692 let err = process_quadlet_bundle(¶ms).unwrap_err();
693 assert!(err.to_string().contains("no quadlet files found"));
694 }
695
696 #[test]
697 fn undeclared_port_var_errors() {
698 let tmp = tempfile::tempdir()
699 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
700 let service_dir = tmp.path().join("svc");
701 let quadlets_dir = service_dir.join("quadlets");
702 std::fs::create_dir_all(&quadlets_dir)
703 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
704 std::fs::write(
705 quadlets_dir.join("svc.container"),
706 "[Container]\nImage=nginx\nPublishPort=${SERVICE_PORT_HTPP}:80\n",
707 )
708 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
709
710 let params = ProcessBundleParams {
711 service_dir: &service_dir,
712 service_name: "svc",
713 extra_networks: &[],
714 extra_volumes: &[],
715 podman_args: &[],
716 extra_exec_start_pre: &[],
717 port_names: &["http".to_string()],
718 };
719 let err = process_quadlet_bundle(¶ms).unwrap_err();
720 assert!(err.to_string().contains("SERVICE_PORT_HTPP"));
721 assert!(err.to_string().contains("no [[ports]] entry"));
722 }
723
724 #[test]
725 fn service_vars_without_service_envfile_errors() {
726 let tmp = tempfile::tempdir()
727 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
728 let service_dir = tmp.path().join("svc");
729 let quadlets_dir = service_dir.join("quadlets");
730 std::fs::create_dir_all(&quadlets_dir)
731 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
732 std::fs::write(
735 quadlets_dir.join("svc.container"),
736 "[Container]\nImage=nginx\nVolume=${SERVICE_HOME}/data:/data\nEnvironmentFile=%h/.local/share/services/svc/.env\n\n[Service]\nRestart=always\n",
737 )
738 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
739
740 let params = ProcessBundleParams {
741 service_dir: &service_dir,
742 service_name: "svc",
743 extra_networks: &[],
744 extra_volumes: &[],
745 podman_args: &[],
746 extra_exec_start_pre: &[],
747 port_names: &["http".to_string()],
748 };
749 let err = process_quadlet_bundle(¶ms).unwrap_err();
750 assert!(err.to_string().contains("[Service] section"));
751 }
752
753 #[test]
754 fn leftover_template_syntax_errors() {
755 let tmp = tempfile::tempdir()
756 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
757 let service_dir = tmp.path().join("svc");
758 let quadlets_dir = service_dir.join("quadlets");
759 std::fs::create_dir_all(&quadlets_dir)
760 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
761 std::fs::write(
762 quadlets_dir.join("svc.container"),
763 "[Container]\nImage=nginx\nPublishPort={{ports.http}}:80\n",
764 )
765 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
766
767 let params = ProcessBundleParams {
768 service_dir: &service_dir,
769 service_name: "svc",
770 extra_networks: &[],
771 extra_volumes: &[],
772 podman_args: &[],
773 extra_exec_start_pre: &[],
774 port_names: &["http".to_string()],
775 };
776 let err = process_quadlet_bundle(¶ms).unwrap_err();
777 assert!(err.to_string().contains("plain podman files"));
778 }
779
780 #[test]
781 fn process_configs_reads_recursively() {
782 let tmp = tempfile::tempdir()
783 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
784 let service_dir = tmp.path().join("svc");
785 let configs_dir = service_dir.join("configs");
786 let sub_dir = configs_dir.join("subdir");
787 std::fs::create_dir_all(&sub_dir)
788 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
789
790 std::fs::write(configs_dir.join("main.conf"), "data_dir=/some/path\n")
791 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
792
793 std::fs::write(sub_dir.join("nested.conf"), "no placeholders\n")
794 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
795
796 let service_home = Path::new("/home/user/.local/share/services/svc");
797
798 let files = process_configs(&service_dir, service_home)
799 .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
800
801 assert_eq!(files.len(), 2);
802
803 let main_conf = files
804 .iter()
805 .find(|f| f.path.ends_with("main.conf"))
806 .unwrap_or_else(|| unreachable!("main.conf must exist"));
807 assert_eq!(
808 main_conf.path,
809 PathBuf::from("/home/user/.local/share/services/svc/configs/main.conf")
810 );
811 assert!(main_conf.content.contains("/some/path"));
812
813 let nested_conf = files
814 .iter()
815 .find(|f| f.path.ends_with("nested.conf"))
816 .unwrap_or_else(|| unreachable!("nested.conf must exist"));
817 assert_eq!(
818 nested_conf.path,
819 PathBuf::from("/home/user/.local/share/services/svc/configs/subdir/nested.conf")
820 );
821 assert_eq!(nested_conf.content, "no placeholders\n");
822 }
823
824 #[test]
825 fn extract_bind_mount_dirs_finds_host_paths() {
826 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/test".to_string());
827 let files = vec![
828 GeneratedFile {
829 path: PathBuf::from("/q/immich.container"),
830 content: "Volume=${SERVICE_HOME}/upload:/data:Z\nVolume=%h/backups:/backups:Z\nVolume=immich-db-data.volume:/var/lib/postgresql/data:U\n".to_string(),
831 },
832 GeneratedFile {
833 path: PathBuf::from("/q/immich.network"),
834 content: "[Network]\n".to_string(),
835 },
836 ];
837 let service_home = PathBuf::from(format!("{home}/.local/share/services/immich"));
838 let dirs = extract_bind_mount_dirs(&files, &service_home).unwrap();
839 assert_eq!(
840 dirs,
841 vec![
842 service_home.join("upload"),
843 PathBuf::from(format!("{home}/backups")),
844 ]
845 );
846 }
847
848 #[test]
849 fn extract_bind_mount_dirs_skips_named_volumes() {
850 let files = vec![GeneratedFile {
851 path: PathBuf::from("/q/svc.container"),
852 content: "Volume=svc-data.volume:/data:U\n".to_string(),
853 }];
854 let dirs = extract_bind_mount_dirs(&files, Path::new("/srv/svc")).unwrap();
855 assert!(dirs.is_empty());
856 }
857
858 #[test]
859 fn extract_bind_mount_dirs_skips_file_mounts() {
860 let files = vec![GeneratedFile {
861 path: PathBuf::from("/q/svc.container"),
862 content: "Volume=/path/to/ca.crt:/etc/ssl/certs/ca.crt:ro,Z\nVolume=/path/to/config:/config:Z\n".to_string(),
863 }];
864 let dirs = extract_bind_mount_dirs(&files, Path::new("/srv/svc")).unwrap();
865 assert_eq!(dirs, vec![PathBuf::from("/path/to/config")]);
867 }
868
869 #[test]
870 fn process_configs_returns_empty_when_no_configs_dir() {
871 let tmp = tempfile::tempdir()
872 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
873 let service_dir = tmp.path().join("svc");
874 std::fs::create_dir_all(&service_dir)
875 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
876
877 let files = process_configs(
878 &service_dir,
879 Path::new("/home/user/.local/share/services/svc"),
880 )
881 .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
882
883 assert!(files.is_empty());
884 }
885}