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 pub excluded_quadlets: &'a [String],
33}
34
35#[derive(Debug)]
37pub struct ProcessedBundle {
38 pub quadlet_files: Vec<GeneratedFile>,
39 pub config_files: Vec<GeneratedFile>,
40 pub images: Vec<String>,
41 pub bind_mount_dirs: Vec<std::path::PathBuf>,
43 pub files: Vec<(std::path::PathBuf, std::path::PathBuf)>,
49}
50
51pub fn extract_images(files: &[GeneratedFile]) -> Vec<String> {
53 let mut images = Vec::new();
54 for file in files {
55 let path_str = file.path.to_string_lossy();
56 if !path_str.ends_with(".container") {
57 continue;
58 }
59 for line in file.content.lines() {
60 let trimmed = line.trim();
61 if let Some(image) = trimmed.strip_prefix("Image=") {
62 let image = image.trim().to_string();
63 if !image.is_empty() && !images.contains(&image) {
64 images.push(image);
65 }
66 }
67 }
68 }
69 images
70}
71
72fn validate_quadlet_env_refs(
80 content: &str,
81 file_name: &str,
82 params: &ProcessBundleParams<'_>,
83) -> Result<()> {
84 if content
88 .lines()
89 .any(|l| !l.trim_start().starts_with('#') && l.contains("{{"))
90 {
91 return Err(Error::Bundle(format!(
92 "quadlet '{}' for service '{}' contains '{{{{...}}}}' template syntax — quadlets \
93 are plain podman files; use runtime env vars (${{SERVICE_PORT_<NAME>}}, \
94 ${{SERVICE_HOME}}) instead",
95 file_name, params.service_name
96 )));
97 }
98 let mut rest = content;
99 while let Some(idx) = rest.find("${SERVICE_PORT_") {
100 let tail = &rest[idx + "${SERVICE_PORT_".len()..];
101 let Some(end) = tail.find('}') else { break };
102 let var_name = &tail[..end];
103 if !params
104 .port_names
105 .iter()
106 .any(|p| p.eq_ignore_ascii_case(var_name))
107 {
108 return Err(Error::Bundle(format!(
109 "quadlet '{}' for service '{}' references ${{SERVICE_PORT_{}}} but \
110 service.toml declares no [[ports]] entry named '{}'",
111 file_name,
112 params.service_name,
113 var_name,
114 var_name.to_lowercase()
115 )));
116 }
117 rest = &tail[end..];
118 }
119
120 let uses_service_vars =
125 content.contains("${SERVICE_HOME}") || content.contains("${SERVICE_PORT_");
126 if uses_service_vars && !section_has_line(content, "[Service]", "EnvironmentFile=") {
127 return Err(Error::Bundle(format!(
128 "quadlet '{}' for service '{}' uses ${{SERVICE_*}} vars but has no \
129 EnvironmentFile= in its [Service] section — systemd would expand them \
130 to empty strings",
131 file_name, params.service_name
132 )));
133 }
134 Ok(())
135}
136
137fn section_has_line(content: &str, section: &str, prefix: &str) -> bool {
139 let mut in_section = false;
140 for line in content.lines() {
141 let trimmed = line.trim();
142 if trimmed.starts_with('[') && trimmed.ends_with(']') {
143 in_section = trimmed == section;
144 } else if in_section && trimmed.starts_with(prefix) {
145 return true;
146 }
147 }
148 false
149}
150
151pub fn extract_bind_mount_dirs(
159 files: &[GeneratedFile],
160 service_home: &Path,
161) -> crate::error::Result<Vec<std::path::PathBuf>> {
162 let home = crate::home_dir()?;
163 let mut dirs = Vec::new();
164 for file in files {
165 let path_str = file.path.to_string_lossy();
166 if !path_str.ends_with(".container") {
167 continue;
168 }
169 for line in file.content.lines() {
170 let trimmed = line.trim();
171 if let Some(vol) = trimmed.strip_prefix("Volume=") {
172 if vol.contains(".volume:") {
174 continue;
175 }
176 if let Some(colon_pos) = vol.find(':') {
178 let host_path = &vol[..colon_pos];
179 if host_path.is_empty() {
180 continue;
181 }
182 let expanded = host_path
184 .replace("%h", &home.to_string_lossy())
185 .replace("${SERVICE_HOME}", &service_home.to_string_lossy());
186 let path = std::path::Path::new(&expanded);
188 if path.extension().is_some() {
189 continue;
190 }
191 dirs.push(std::path::PathBuf::from(expanded));
192 }
193 }
194 }
195 }
196 Ok(dirs)
197}
198
199pub fn inject_networks(content: &str, networks: &[String]) -> String {
206 if networks.is_empty() {
207 return content.to_string();
208 }
209 let extra_lines: String = networks
210 .iter()
211 .map(|n| {
212 if let Some((name, opts)) = n.split_once(':') {
213 format!("Network={name}.network:{opts}")
214 } else {
215 format!("Network={n}.network")
216 }
217 })
218 .collect::<Vec<_>>()
219 .join("\n");
220
221 inject_before_section(content, &extra_lines, "[Service]")
222}
223
224pub fn inject_podman_args(content: &str, args: &[String]) -> String {
226 if args.is_empty() {
227 return content.to_string();
228 }
229 let line = format!("PodmanArgs={}", args.join(" "));
230 inject_before_section(content, &line, "[Service]")
231}
232
233pub fn inject_extra_volumes(content: &str, volumes: &[String]) -> String {
236 if volumes.is_empty() {
237 return content.to_string();
238 }
239 let extra_lines: String = volumes
240 .iter()
241 .map(|v| format!("Volume={v}"))
242 .collect::<Vec<_>>()
243 .join("\n");
244
245 inject_before_section(content, &extra_lines, "[Service]")
246}
247
248fn inject_before_section(content: &str, extra_lines: &str, section_header: &str) -> String {
250 let mut lines: Vec<&str> = content.lines().collect();
251 let insert_pos = lines.iter().position(|l| l.trim() == section_header);
252
253 match insert_pos {
254 Some(pos) => {
255 let needs_blank = pos > 0 && !lines[pos - 1].trim().is_empty();
257 let mut insert = Vec::new();
258 if needs_blank {
259 insert.push("");
260 }
261 for line in extra_lines.lines() {
262 insert.push(line);
263 }
264 for (i, line) in insert.iter().enumerate() {
266 lines.insert(pos + i, line);
267 }
268 let mut result = lines.join("\n");
269 if content.ends_with('\n') {
271 result.push('\n');
272 }
273 result
274 }
275 None => {
276 let mut result = content.to_string();
278 if !result.ends_with('\n') {
279 result.push('\n');
280 }
281 result.push_str(extra_lines);
282 result.push('\n');
283 result
284 }
285 }
286}
287
288pub fn process_quadlet_bundle(params: &ProcessBundleParams<'_>) -> Result<ProcessedBundle> {
291 let quadlets_dir = params.service_dir.join("quadlets");
292
293 if !quadlets_dir.is_dir() {
294 return Err(Error::Bundle(format!(
295 "quadlets/ directory not found for service '{}'",
296 params.service_name
297 )));
298 }
299
300 let mut quadlet_files = Vec::new();
301 let mut any_excluded = false;
306 let service_home = crate::service_home(params.service_name)?;
307 let data_root = crate::paths::service_data_root()?;
308 let canonical_data_root = crate::home_dir()?.join(".local/share/services");
309
310 let entries = std::fs::read_dir(&quadlets_dir).map_err(|source| Error::FileRead {
311 path: quadlets_dir.clone(),
312 source,
313 })?;
314
315 for entry in entries {
316 let entry = entry.map_err(|source| Error::FileRead {
317 path: quadlets_dir.clone(),
318 source,
319 })?;
320 let path = entry.path();
321 if !path.is_file() {
322 continue;
323 }
324
325 let mut content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
326 path: path.clone(),
327 source,
328 })?;
329
330 let file_name = path
331 .file_name()
332 .ok_or_else(|| Error::Bundle(format!("invalid file path: {}", path.display())))?
333 .to_string_lossy();
334
335 if params
338 .excluded_quadlets
339 .iter()
340 .any(|q| q == file_name.as_ref())
341 {
342 any_excluded = true;
343 continue;
344 }
345
346 validate_quadlet_env_refs(&content, &file_name, params)?;
347
348 if data_root != canonical_data_root {
357 content = content.replace("%h/.local/share/services", &data_root.to_string_lossy());
358 }
359
360 let is_main_container = file_name == format!("{}.container", params.service_name);
367 let header = format!("# Service-Source: registry/{}\n", params.service_name);
368 content = header + &content;
369
370 if file_name.ends_with(".container") {
372 content = inject_networks(&content, params.extra_networks);
373 content = inject_extra_volumes(&content, params.extra_volumes);
374 content = inject_podman_args(&content, params.podman_args);
375 if is_main_container {
378 for cmd in params.extra_exec_start_pre {
379 content = inject_before_section(
380 &content,
381 &format!("ExecStartPre={cmd}"),
382 "[Install]",
383 );
384 }
385 }
386 }
387
388 quadlet_files.push(GeneratedFile {
391 path: service_home.join(file_name.as_ref()),
392 content,
393 });
394 }
395
396 if quadlet_files.is_empty() && !any_excluded {
400 return Err(Error::Bundle(format!(
401 "no quadlet files found in quadlets/ for service '{}'",
402 params.service_name
403 )));
404 }
405
406 quadlet_files.sort_by(|a, b| a.path.cmp(&b.path));
408
409 let images = extract_images(&quadlet_files);
410 let bind_mount_dirs = extract_bind_mount_dirs(&quadlet_files, &service_home)?;
411 let config_files = process_configs(params.service_dir, &service_home)?;
412 let files = collect_files(params.service_dir, &service_home)?;
413
414 Ok(ProcessedBundle {
415 quadlet_files,
416 config_files,
417 images,
418 bind_mount_dirs,
419 files,
420 })
421}
422
423pub fn collect_files(
428 service_dir: &Path,
429 service_home: &Path,
430) -> Result<Vec<(std::path::PathBuf, std::path::PathBuf)>> {
431 let files_dir = service_dir.join("files");
432 if !files_dir.is_dir() {
433 return Ok(Vec::new());
434 }
435 let mut out = Vec::new();
436 collect_files_recursive(&files_dir, &files_dir, service_home, &mut out)?;
437 out.sort_by(|a, b| a.1.cmp(&b.1));
438 Ok(out)
439}
440
441fn collect_files_recursive(
442 base_dir: &Path,
443 current_dir: &Path,
444 service_home: &Path,
445 out: &mut Vec<(std::path::PathBuf, std::path::PathBuf)>,
446) -> Result<()> {
447 let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
448 path: current_dir.to_path_buf(),
449 source,
450 })?;
451 for entry in entries {
452 let entry = entry.map_err(|source| Error::FileRead {
453 path: current_dir.to_path_buf(),
454 source,
455 })?;
456 let path = entry.path();
457 if path.is_dir() {
458 collect_files_recursive(base_dir, &path, service_home, out)?;
459 } else if path.is_file() {
460 let relative = path
461 .strip_prefix(base_dir)
462 .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
463 out.push((path.clone(), service_home.join(relative)));
464 }
465 }
466 Ok(())
467}
468
469pub fn process_configs(service_dir: &Path, service_home: &Path) -> Result<Vec<GeneratedFile>> {
472 let configs_dir = service_dir.join("configs");
473 if !configs_dir.is_dir() {
474 return Ok(Vec::new());
475 }
476
477 let mut files = Vec::new();
478 collect_configs_recursive(&configs_dir, &configs_dir, service_home, &mut files)?;
479 files.sort_by(|a, b| a.path.cmp(&b.path));
480 Ok(files)
481}
482
483fn collect_configs_recursive(
484 base_dir: &Path,
485 current_dir: &Path,
486 service_home: &Path,
487 files: &mut Vec<GeneratedFile>,
488) -> Result<()> {
489 let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
490 path: current_dir.to_path_buf(),
491 source,
492 })?;
493
494 for entry in entries {
495 let entry = entry.map_err(|source| Error::FileRead {
496 path: current_dir.to_path_buf(),
497 source,
498 })?;
499 let path = entry.path();
500
501 if path.is_dir() {
502 collect_configs_recursive(base_dir, &path, service_home, files)?;
503 } else if path.is_file() {
504 let relative = path
505 .strip_prefix(base_dir)
506 .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
507
508 let content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
509 path: path.clone(),
510 source,
511 })?;
512
513 files.push(GeneratedFile {
514 path: service_home.join("configs").join(relative),
515 content,
516 });
517 }
518 }
519
520 Ok(())
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use std::path::PathBuf;
527
528 #[test]
529 fn extract_images_from_container_files_only() {
530 let files = vec![
531 GeneratedFile {
532 path: PathBuf::from("/q/myapp.container"),
533 content: "[Container]\nImage=docker.io/library/nginx:latest\n".to_string(),
534 },
535 GeneratedFile {
536 path: PathBuf::from("/q/myapp.network"),
537 content: "[Network]\nImage=should-be-ignored\n".to_string(),
538 },
539 GeneratedFile {
540 path: PathBuf::from("/q/myapp-db.container"),
541 content: "[Container]\nImage=docker.io/library/postgres:16\n".to_string(),
542 },
543 ];
544 let images = extract_images(&files);
545 assert_eq!(
546 images,
547 vec![
548 "docker.io/library/nginx:latest".to_string(),
549 "docker.io/library/postgres:16".to_string(),
550 ]
551 );
552 }
553
554 #[test]
555 fn extract_images_deduplicates() {
556 let files = vec![
557 GeneratedFile {
558 path: PathBuf::from("/q/a.container"),
559 content: "Image=docker.io/img:1\n".to_string(),
560 },
561 GeneratedFile {
562 path: PathBuf::from("/q/b.container"),
563 content: "Image=docker.io/img:1\nImage=docker.io/img:2\n".to_string(),
564 },
565 ];
566 let images = extract_images(&files);
567 assert_eq!(
568 images,
569 vec!["docker.io/img:1".to_string(), "docker.io/img:2".to_string(),]
570 );
571 }
572
573 #[test]
574 fn inject_networks_before_service_section() {
575 let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
576 let result = inject_networks(content, &["caddy".to_string(), "auth".to_string()]);
577 assert_eq!(
578 result,
579 "[Container]\nImage=nginx\n\nNetwork=caddy.network\nNetwork=auth.network\n[Service]\nRestart=always\n"
580 );
581 }
582
583 #[test]
584 fn inject_networks_no_service_section_appends() {
585 let content = "[Container]\nImage=nginx\n";
586 let result = inject_networks(content, &["caddy".to_string()]);
587 assert_eq!(result, "[Container]\nImage=nginx\nNetwork=caddy.network\n");
588 }
589
590 #[test]
591 fn inject_extra_volumes_before_service_section() {
592 let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
593 let result =
594 inject_extra_volumes(content, &["/host/ca.crt:/etc/ssl/ca.crt:ro".to_string()]);
595 assert_eq!(
596 result,
597 "[Container]\nImage=nginx\n\nVolume=/host/ca.crt:/etc/ssl/ca.crt:ro\n[Service]\nRestart=always\n"
598 );
599 }
600
601 #[test]
602 fn inject_extra_volumes_no_service_section_appends() {
603 let content = "[Container]\nImage=nginx";
604 let result = inject_extra_volumes(content, &["/a:/b".to_string()]);
605 assert_eq!(result, "[Container]\nImage=nginx\nVolume=/a:/b\n");
606 }
607
608 #[test]
609 fn inject_networks_adds_blank_line_when_needed() {
610 let content =
611 "[Container]\nImage=nginx\nNetwork=mynet.network\n[Service]\nRestart=always\n";
612 let result = inject_networks(content, &["caddy".to_string()]);
613 assert_eq!(
615 result,
616 "[Container]\nImage=nginx\nNetwork=mynet.network\n\nNetwork=caddy.network\n[Service]\nRestart=always\n"
617 );
618 }
619
620 #[test]
621 fn excluded_quadlet_is_skipped_and_its_image_not_pulled() {
622 let tmp = tempfile::tempdir()
623 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
624 let service_dir = tmp.path().join("svc");
625 let quadlets_dir = service_dir.join("quadlets");
626 std::fs::create_dir_all(&quadlets_dir)
627 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
628 std::fs::write(
629 quadlets_dir.join("svc.container"),
630 "[Container]\nImage=app:1\n[Service]\nRestart=always\n",
631 )
632 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
633 std::fs::write(
635 quadlets_dir.join("svc-postgres.container"),
636 "[Container]\nImage=postgres:17\n[Service]\nRestart=always\n",
637 )
638 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
639
640 let excluded = vec!["svc-postgres.container".to_string()];
641 let params = ProcessBundleParams {
642 service_dir: &service_dir,
643 service_name: "svc",
644 extra_networks: &[],
645 extra_volumes: &[],
646 podman_args: &[],
647 extra_exec_start_pre: &[],
648 port_names: &[],
649 excluded_quadlets: &excluded,
650 };
651 let bundle = process_quadlet_bundle(¶ms)
652 .unwrap_or_else(|e| unreachable!("process_quadlet_bundle should not fail: {e}"));
653
654 assert_eq!(bundle.quadlet_files.len(), 1);
657 assert!(
658 bundle.quadlet_files[0]
659 .path
660 .to_string_lossy()
661 .ends_with("svc.container")
662 );
663 assert_eq!(bundle.images, vec!["app:1".to_string()]);
664 assert!(!bundle.images.iter().any(|i| i.contains("postgres")));
665 }
666
667 #[test]
668 fn process_quadlet_bundle_errors_on_missing_dir() {
669 let params = ProcessBundleParams {
670 service_dir: Path::new("/nonexistent"),
671 service_name: "test",
672 extra_networks: &[],
673 extra_volumes: &[],
674 podman_args: &[],
675 extra_exec_start_pre: &[],
676 port_names: &[],
677 excluded_quadlets: &[],
678 };
679 let err = process_quadlet_bundle(¶ms).unwrap_err();
680 assert!(err.to_string().contains("quadlets/ directory not found"));
681 }
682
683 #[test]
684 fn process_quadlet_bundle_reads_and_processes_files() {
685 let tmp = tempfile::tempdir()
686 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
687 let service_dir = tmp.path().join("myservice");
688 let quadlets_dir = service_dir.join("quadlets");
689 std::fs::create_dir_all(&quadlets_dir)
690 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
691
692 std::fs::write(
693 quadlets_dir.join("app.container"),
694 "[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",
695 )
696 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
697
698 std::fs::write(
699 quadlets_dir.join("app.network"),
700 "[Network]\nDriver=bridge\n",
701 )
702 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
703
704 let params = ProcessBundleParams {
705 service_dir: &service_dir,
706 service_name: "myservice",
707 extra_networks: &["caddy".to_string()],
708 extra_volumes: &[],
709 podman_args: &[],
710 extra_exec_start_pre: &[],
711 port_names: &["http".to_string()],
712 excluded_quadlets: &[],
713 };
714
715 let bundle = process_quadlet_bundle(¶ms)
716 .unwrap_or_else(|e| unreachable!("process_quadlet_bundle should not fail: {e}"));
717
718 assert_eq!(bundle.quadlet_files.len(), 2);
719 assert_eq!(bundle.images, vec!["nginx:latest".to_string()]);
720
721 let container_file = bundle
722 .quadlet_files
723 .iter()
724 .find(|f| f.path.to_string_lossy().ends_with(".container"))
725 .unwrap_or_else(|| unreachable!("container file must exist"));
726 assert!(
728 container_file
729 .content
730 .contains("PublishPort=${SERVICE_PORT_HTTP}:80")
731 );
732 assert!(
733 container_file
734 .content
735 .contains("Volume=${SERVICE_HOME}/data:/data")
736 );
737 assert!(container_file.content.contains("Network=caddy.network"));
739 let service_home = crate::service_home("myservice")
741 .unwrap_or_else(|e| unreachable!("service_home should resolve in tests: {e}"));
742 assert!(bundle.bind_mount_dirs.contains(&service_home.join("data")));
743
744 let network_file = bundle
746 .quadlet_files
747 .iter()
748 .find(|f| f.path.to_string_lossy().ends_with(".network"))
749 .unwrap_or_else(|| unreachable!("network file must exist"));
750 assert!(!network_file.content.contains("Network=caddy.network"));
751 }
752
753 #[test]
754 fn process_quadlet_bundle_errors_on_empty_dir() {
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("empty");
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
762 let params = ProcessBundleParams {
763 service_dir: &service_dir,
764 service_name: "empty",
765 extra_networks: &[],
766 extra_volumes: &[],
767 podman_args: &[],
768 extra_exec_start_pre: &[],
769 port_names: &[],
770 excluded_quadlets: &[],
771 };
772 let err = process_quadlet_bundle(¶ms).unwrap_err();
773 assert!(err.to_string().contains("no quadlet files found"));
774 }
775
776 #[test]
777 fn process_quadlet_bundle_all_excluded_is_empty_not_error() {
778 let tmp = tempfile::tempdir().unwrap();
782 let service_dir = tmp.path().join("svc");
783 let quadlets_dir = service_dir.join("quadlets");
784 std::fs::create_dir_all(&quadlets_dir).unwrap();
785 std::fs::write(
786 quadlets_dir.join("svc-db.container"),
787 "[Container]\nImage=docker.io/library/postgres:17-alpine\n",
788 )
789 .unwrap();
790
791 let excluded = vec!["svc-db.container".to_string()];
792 let params = ProcessBundleParams {
793 service_dir: &service_dir,
794 service_name: "svc",
795 extra_networks: &[],
796 extra_volumes: &[],
797 podman_args: &[],
798 extra_exec_start_pre: &[],
799 port_names: &[],
800 excluded_quadlets: &excluded,
801 };
802 let bundle =
803 process_quadlet_bundle(¶ms).expect("all-excluded should be Ok, not an error");
804 assert!(bundle.quadlet_files.is_empty());
805 assert!(bundle.images.is_empty());
806 }
807
808 #[test]
809 fn undeclared_port_var_errors() {
810 let tmp = tempfile::tempdir()
811 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
812 let service_dir = tmp.path().join("svc");
813 let quadlets_dir = service_dir.join("quadlets");
814 std::fs::create_dir_all(&quadlets_dir)
815 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
816 std::fs::write(
817 quadlets_dir.join("svc.container"),
818 "[Container]\nImage=nginx\nPublishPort=${SERVICE_PORT_HTPP}:80\n",
819 )
820 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
821
822 let params = ProcessBundleParams {
823 service_dir: &service_dir,
824 service_name: "svc",
825 extra_networks: &[],
826 extra_volumes: &[],
827 podman_args: &[],
828 extra_exec_start_pre: &[],
829 port_names: &["http".to_string()],
830 excluded_quadlets: &[],
831 };
832 let err = process_quadlet_bundle(¶ms).unwrap_err();
833 assert!(err.to_string().contains("SERVICE_PORT_HTPP"));
834 assert!(err.to_string().contains("no [[ports]] entry"));
835 }
836
837 #[test]
838 fn service_vars_without_service_envfile_errors() {
839 let tmp = tempfile::tempdir()
840 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
841 let service_dir = tmp.path().join("svc");
842 let quadlets_dir = service_dir.join("quadlets");
843 std::fs::create_dir_all(&quadlets_dir)
844 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
845 std::fs::write(
848 quadlets_dir.join("svc.container"),
849 "[Container]\nImage=nginx\nVolume=${SERVICE_HOME}/data:/data\nEnvironmentFile=%h/.local/share/services/svc/.env\n\n[Service]\nRestart=always\n",
850 )
851 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
852
853 let params = ProcessBundleParams {
854 service_dir: &service_dir,
855 service_name: "svc",
856 extra_networks: &[],
857 extra_volumes: &[],
858 podman_args: &[],
859 extra_exec_start_pre: &[],
860 port_names: &["http".to_string()],
861 excluded_quadlets: &[],
862 };
863 let err = process_quadlet_bundle(¶ms).unwrap_err();
864 assert!(err.to_string().contains("[Service] section"));
865 }
866
867 #[test]
868 fn leftover_template_syntax_errors() {
869 let tmp = tempfile::tempdir()
870 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
871 let service_dir = tmp.path().join("svc");
872 let quadlets_dir = service_dir.join("quadlets");
873 std::fs::create_dir_all(&quadlets_dir)
874 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
875 std::fs::write(
876 quadlets_dir.join("svc.container"),
877 "[Container]\nImage=nginx\nPublishPort={{ports.http}}:80\n",
878 )
879 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
880
881 let params = ProcessBundleParams {
882 service_dir: &service_dir,
883 service_name: "svc",
884 extra_networks: &[],
885 extra_volumes: &[],
886 podman_args: &[],
887 extra_exec_start_pre: &[],
888 port_names: &["http".to_string()],
889 excluded_quadlets: &[],
890 };
891 let err = process_quadlet_bundle(¶ms).unwrap_err();
892 assert!(err.to_string().contains("plain podman files"));
893 }
894
895 #[test]
896 fn process_configs_reads_recursively() {
897 let tmp = tempfile::tempdir()
898 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
899 let service_dir = tmp.path().join("svc");
900 let configs_dir = service_dir.join("configs");
901 let sub_dir = configs_dir.join("subdir");
902 std::fs::create_dir_all(&sub_dir)
903 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
904
905 std::fs::write(configs_dir.join("main.conf"), "data_dir=/some/path\n")
906 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
907
908 std::fs::write(sub_dir.join("nested.conf"), "no placeholders\n")
909 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
910
911 let service_home = Path::new("/home/user/.local/share/services/svc");
912
913 let files = process_configs(&service_dir, service_home)
914 .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
915
916 assert_eq!(files.len(), 2);
917
918 let main_conf = files
919 .iter()
920 .find(|f| f.path.ends_with("main.conf"))
921 .unwrap_or_else(|| unreachable!("main.conf must exist"));
922 assert_eq!(
923 main_conf.path,
924 PathBuf::from("/home/user/.local/share/services/svc/configs/main.conf")
925 );
926 assert!(main_conf.content.contains("/some/path"));
927
928 let nested_conf = files
929 .iter()
930 .find(|f| f.path.ends_with("nested.conf"))
931 .unwrap_or_else(|| unreachable!("nested.conf must exist"));
932 assert_eq!(
933 nested_conf.path,
934 PathBuf::from("/home/user/.local/share/services/svc/configs/subdir/nested.conf")
935 );
936 assert_eq!(nested_conf.content, "no placeholders\n");
937 }
938
939 #[test]
940 fn extract_bind_mount_dirs_finds_host_paths() {
941 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/test".to_string());
942 let files = vec![
943 GeneratedFile {
944 path: PathBuf::from("/q/immich.container"),
945 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(),
946 },
947 GeneratedFile {
948 path: PathBuf::from("/q/immich.network"),
949 content: "[Network]\n".to_string(),
950 },
951 ];
952 let service_home = PathBuf::from(format!("{home}/.local/share/services/immich"));
953 let dirs = extract_bind_mount_dirs(&files, &service_home).unwrap();
954 assert_eq!(
955 dirs,
956 vec![
957 service_home.join("upload"),
958 PathBuf::from(format!("{home}/backups")),
959 ]
960 );
961 }
962
963 #[test]
964 fn extract_bind_mount_dirs_skips_named_volumes() {
965 let files = vec![GeneratedFile {
966 path: PathBuf::from("/q/svc.container"),
967 content: "Volume=svc-data.volume:/data:U\n".to_string(),
968 }];
969 let dirs = extract_bind_mount_dirs(&files, Path::new("/srv/svc")).unwrap();
970 assert!(dirs.is_empty());
971 }
972
973 #[test]
974 fn extract_bind_mount_dirs_skips_file_mounts() {
975 let files = vec![GeneratedFile {
976 path: PathBuf::from("/q/svc.container"),
977 content: "Volume=/path/to/ca.crt:/etc/ssl/certs/ca.crt:ro,Z\nVolume=/path/to/config:/config:Z\n".to_string(),
978 }];
979 let dirs = extract_bind_mount_dirs(&files, Path::new("/srv/svc")).unwrap();
980 assert_eq!(dirs, vec![PathBuf::from("/path/to/config")]);
982 }
983
984 #[test]
985 fn process_configs_returns_empty_when_no_configs_dir() {
986 let tmp = tempfile::tempdir()
987 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
988 let service_dir = tmp.path().join("svc");
989 std::fs::create_dir_all(&service_dir)
990 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
991
992 let files = process_configs(
993 &service_dir,
994 Path::new("/home/user/.local/share/services/svc"),
995 )
996 .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
997
998 assert!(files.is_empty());
999 }
1000}