1use std::path::Path;
2
3use crate::error::{Error, Result};
4use crate::generate::GeneratedFile;
5
6pub struct ProcessBundleParams<'a> {
8 pub service_dir: &'a Path,
9 pub service_name: &'a str,
10 pub extra_networks: &'a [String],
11 pub extra_volumes: &'a [String],
12 pub podman_args: &'a [String],
14 pub extra_exec_start_pre: &'a [String],
16 pub port_vars: &'a [(String, String)],
20}
21
22#[derive(Debug)]
24pub struct ProcessedBundle {
25 pub quadlet_files: Vec<GeneratedFile>,
26 pub config_files: Vec<GeneratedFile>,
27 pub images: Vec<String>,
28 pub bind_mount_dirs: Vec<std::path::PathBuf>,
30 pub files: Vec<(std::path::PathBuf, std::path::PathBuf)>,
36}
37
38pub fn extract_images(files: &[GeneratedFile]) -> Vec<String> {
40 let mut images = Vec::new();
41 for file in files {
42 let path_str = file.path.to_string_lossy();
43 if !path_str.ends_with(".container") {
44 continue;
45 }
46 for line in file.content.lines() {
47 let trimmed = line.trim();
48 if let Some(image) = trimmed.strip_prefix("Image=") {
49 let image = image.trim().to_string();
50 if !image.is_empty() && !images.contains(&image) {
51 images.push(image);
52 }
53 }
54 }
55 }
56 images
57}
58
59pub fn extract_bind_mount_dirs(
67 files: &[GeneratedFile],
68) -> crate::error::Result<Vec<std::path::PathBuf>> {
69 let home = crate::home_dir()?;
70 let mut dirs = Vec::new();
71 for file in files {
72 let path_str = file.path.to_string_lossy();
73 if !path_str.ends_with(".container") {
74 continue;
75 }
76 for line in file.content.lines() {
77 let trimmed = line.trim();
78 if let Some(vol) = trimmed.strip_prefix("Volume=") {
79 if vol.contains(".volume:") {
81 continue;
82 }
83 if let Some(colon_pos) = vol.find(':') {
85 let host_path = &vol[..colon_pos];
86 if host_path.is_empty() {
87 continue;
88 }
89 let expanded = host_path.replace("%h", &home.to_string_lossy());
91 let path = std::path::Path::new(&expanded);
93 if path.extension().is_some() {
94 continue;
95 }
96 dirs.push(std::path::PathBuf::from(expanded));
97 }
98 }
99 }
100 }
101 Ok(dirs)
102}
103
104pub fn inject_networks(content: &str, networks: &[String]) -> String {
111 if networks.is_empty() {
112 return content.to_string();
113 }
114 let extra_lines: String = networks
115 .iter()
116 .map(|n| {
117 if let Some((name, opts)) = n.split_once(':') {
118 format!("Network={name}.network:{opts}")
119 } else {
120 format!("Network={n}.network")
121 }
122 })
123 .collect::<Vec<_>>()
124 .join("\n");
125
126 inject_before_section(content, &extra_lines, "[Service]")
127}
128
129pub fn inject_podman_args(content: &str, args: &[String]) -> String {
131 if args.is_empty() {
132 return content.to_string();
133 }
134 let line = format!("PodmanArgs={}", args.join(" "));
135 inject_before_section(content, &line, "[Service]")
136}
137
138pub fn inject_extra_volumes(content: &str, volumes: &[String]) -> String {
141 if volumes.is_empty() {
142 return content.to_string();
143 }
144 let extra_lines: String = volumes
145 .iter()
146 .map(|v| format!("Volume={v}"))
147 .collect::<Vec<_>>()
148 .join("\n");
149
150 inject_before_section(content, &extra_lines, "[Service]")
151}
152
153fn inject_before_section(content: &str, extra_lines: &str, section_header: &str) -> String {
155 let mut lines: Vec<&str> = content.lines().collect();
156 let insert_pos = lines.iter().position(|l| l.trim() == section_header);
157
158 match insert_pos {
159 Some(pos) => {
160 let needs_blank = pos > 0 && !lines[pos - 1].trim().is_empty();
162 let mut insert = Vec::new();
163 if needs_blank {
164 insert.push("");
165 }
166 for line in extra_lines.lines() {
167 insert.push(line);
168 }
169 for (i, line) in insert.iter().enumerate() {
171 lines.insert(pos + i, line);
172 }
173 let mut result = lines.join("\n");
174 if content.ends_with('\n') {
176 result.push('\n');
177 }
178 result
179 }
180 None => {
181 let mut result = content.to_string();
183 if !result.ends_with('\n') {
184 result.push('\n');
185 }
186 result.push_str(extra_lines);
187 result.push('\n');
188 result
189 }
190 }
191}
192
193pub fn process_quadlet_bundle(params: &ProcessBundleParams<'_>) -> Result<ProcessedBundle> {
196 let quadlets_dir = params.service_dir.join("quadlets");
197
198 if !quadlets_dir.is_dir() {
199 return Err(Error::Bundle(format!(
200 "quadlets/ directory not found for service '{}'",
201 params.service_name
202 )));
203 }
204
205 let mut quadlet_files = Vec::new();
206 let service_home = crate::service_home(params.service_name)?;
207
208 let entries = std::fs::read_dir(&quadlets_dir).map_err(|source| Error::FileRead {
209 path: quadlets_dir.clone(),
210 source,
211 })?;
212
213 for entry in entries {
214 let entry = entry.map_err(|source| Error::FileRead {
215 path: quadlets_dir.clone(),
216 source,
217 })?;
218 let path = entry.path();
219 if !path.is_file() {
220 continue;
221 }
222
223 let mut content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
224 path: path.clone(),
225 source,
226 })?;
227
228 let file_name = path
229 .file_name()
230 .ok_or_else(|| Error::Bundle(format!("invalid file path: {}", path.display())))?
231 .to_string_lossy();
232
233 let is_main_container = file_name == format!("{}.container", params.service_name);
240 let header = format!("# Service-Source: registry/{}\n", params.service_name);
241 content = header + &content;
242
243 if file_name.ends_with(".container") {
245 content = inject_networks(&content, params.extra_networks);
246 content = inject_extra_volumes(&content, params.extra_volumes);
247 content = inject_podman_args(&content, params.podman_args);
248 if is_main_container {
251 for cmd in params.extra_exec_start_pre {
252 content = inject_before_section(
253 &content,
254 &format!("ExecStartPre={cmd}"),
255 "[Install]",
256 );
257 }
258 }
259 for (var, val) in params.port_vars {
262 content = content.replace(&format!("${{{var}}}"), val);
263 }
264 }
265
266 if let Some(root) = crate::paths::data_dir_override() {
274 content = content.replace("%h/.local/share/services", &root.to_string_lossy());
275 }
276
277 quadlet_files.push(GeneratedFile {
280 path: service_home.join(file_name.as_ref()),
281 content,
282 });
283 }
284
285 if quadlet_files.is_empty() {
286 return Err(Error::Bundle(format!(
287 "no quadlet files found in quadlets/ for service '{}'",
288 params.service_name
289 )));
290 }
291
292 quadlet_files.sort_by(|a, b| a.path.cmp(&b.path));
294
295 let images = extract_images(&quadlet_files);
296 let bind_mount_dirs = extract_bind_mount_dirs(&quadlet_files)?;
297 let config_files = process_configs(params.service_dir, &service_home)?;
298 let files = collect_files(params.service_dir, &service_home)?;
299
300 Ok(ProcessedBundle {
301 quadlet_files,
302 config_files,
303 images,
304 bind_mount_dirs,
305 files,
306 })
307}
308
309pub fn collect_files(
314 service_dir: &Path,
315 service_home: &Path,
316) -> Result<Vec<(std::path::PathBuf, std::path::PathBuf)>> {
317 let files_dir = service_dir.join("files");
318 if !files_dir.is_dir() {
319 return Ok(Vec::new());
320 }
321 let mut out = Vec::new();
322 collect_files_recursive(&files_dir, &files_dir, service_home, &mut out)?;
323 out.sort_by(|a, b| a.1.cmp(&b.1));
324 Ok(out)
325}
326
327fn collect_files_recursive(
328 base_dir: &Path,
329 current_dir: &Path,
330 service_home: &Path,
331 out: &mut Vec<(std::path::PathBuf, std::path::PathBuf)>,
332) -> Result<()> {
333 let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
334 path: current_dir.to_path_buf(),
335 source,
336 })?;
337 for entry in entries {
338 let entry = entry.map_err(|source| Error::FileRead {
339 path: current_dir.to_path_buf(),
340 source,
341 })?;
342 let path = entry.path();
343 if path.is_dir() {
344 collect_files_recursive(base_dir, &path, service_home, out)?;
345 } else if path.is_file() {
346 let relative = path
347 .strip_prefix(base_dir)
348 .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
349 out.push((path.clone(), service_home.join(relative)));
350 }
351 }
352 Ok(())
353}
354
355pub fn process_configs(service_dir: &Path, service_home: &Path) -> Result<Vec<GeneratedFile>> {
358 let configs_dir = service_dir.join("configs");
359 if !configs_dir.is_dir() {
360 return Ok(Vec::new());
361 }
362
363 let mut files = Vec::new();
364 collect_configs_recursive(&configs_dir, &configs_dir, service_home, &mut files)?;
365 files.sort_by(|a, b| a.path.cmp(&b.path));
366 Ok(files)
367}
368
369fn collect_configs_recursive(
370 base_dir: &Path,
371 current_dir: &Path,
372 service_home: &Path,
373 files: &mut Vec<GeneratedFile>,
374) -> Result<()> {
375 let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
376 path: current_dir.to_path_buf(),
377 source,
378 })?;
379
380 for entry in entries {
381 let entry = entry.map_err(|source| Error::FileRead {
382 path: current_dir.to_path_buf(),
383 source,
384 })?;
385 let path = entry.path();
386
387 if path.is_dir() {
388 collect_configs_recursive(base_dir, &path, service_home, files)?;
389 } else if path.is_file() {
390 let relative = path
391 .strip_prefix(base_dir)
392 .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
393
394 let content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
395 path: path.clone(),
396 source,
397 })?;
398
399 files.push(GeneratedFile {
400 path: service_home.join("configs").join(relative),
401 content,
402 });
403 }
404 }
405
406 Ok(())
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use std::path::PathBuf;
413
414 #[test]
415 fn extract_images_from_container_files_only() {
416 let files = vec![
417 GeneratedFile {
418 path: PathBuf::from("/q/myapp.container"),
419 content: "[Container]\nImage=docker.io/library/nginx:latest\n".to_string(),
420 },
421 GeneratedFile {
422 path: PathBuf::from("/q/myapp.network"),
423 content: "[Network]\nImage=should-be-ignored\n".to_string(),
424 },
425 GeneratedFile {
426 path: PathBuf::from("/q/myapp-db.container"),
427 content: "[Container]\nImage=docker.io/library/postgres:16\n".to_string(),
428 },
429 ];
430 let images = extract_images(&files);
431 assert_eq!(
432 images,
433 vec![
434 "docker.io/library/nginx:latest".to_string(),
435 "docker.io/library/postgres:16".to_string(),
436 ]
437 );
438 }
439
440 #[test]
441 fn extract_images_deduplicates() {
442 let files = vec![
443 GeneratedFile {
444 path: PathBuf::from("/q/a.container"),
445 content: "Image=docker.io/img:1\n".to_string(),
446 },
447 GeneratedFile {
448 path: PathBuf::from("/q/b.container"),
449 content: "Image=docker.io/img:1\nImage=docker.io/img:2\n".to_string(),
450 },
451 ];
452 let images = extract_images(&files);
453 assert_eq!(
454 images,
455 vec!["docker.io/img:1".to_string(), "docker.io/img:2".to_string(),]
456 );
457 }
458
459 #[test]
460 fn inject_networks_before_service_section() {
461 let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
462 let result = inject_networks(content, &["caddy".to_string(), "auth".to_string()]);
463 assert_eq!(
464 result,
465 "[Container]\nImage=nginx\n\nNetwork=caddy.network\nNetwork=auth.network\n[Service]\nRestart=always\n"
466 );
467 }
468
469 #[test]
470 fn inject_networks_no_service_section_appends() {
471 let content = "[Container]\nImage=nginx\n";
472 let result = inject_networks(content, &["caddy".to_string()]);
473 assert_eq!(result, "[Container]\nImage=nginx\nNetwork=caddy.network\n");
474 }
475
476 #[test]
477 fn inject_extra_volumes_before_service_section() {
478 let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
479 let result =
480 inject_extra_volumes(content, &["/host/ca.crt:/etc/ssl/ca.crt:ro".to_string()]);
481 assert_eq!(
482 result,
483 "[Container]\nImage=nginx\n\nVolume=/host/ca.crt:/etc/ssl/ca.crt:ro\n[Service]\nRestart=always\n"
484 );
485 }
486
487 #[test]
488 fn inject_extra_volumes_no_service_section_appends() {
489 let content = "[Container]\nImage=nginx";
490 let result = inject_extra_volumes(content, &["/a:/b".to_string()]);
491 assert_eq!(result, "[Container]\nImage=nginx\nVolume=/a:/b\n");
492 }
493
494 #[test]
495 fn inject_networks_adds_blank_line_when_needed() {
496 let content =
497 "[Container]\nImage=nginx\nNetwork=mynet.network\n[Service]\nRestart=always\n";
498 let result = inject_networks(content, &["caddy".to_string()]);
499 assert_eq!(
501 result,
502 "[Container]\nImage=nginx\nNetwork=mynet.network\n\nNetwork=caddy.network\n[Service]\nRestart=always\n"
503 );
504 }
505
506 #[test]
507 fn process_quadlet_bundle_errors_on_missing_dir() {
508 let params = ProcessBundleParams {
509 service_dir: Path::new("/nonexistent"),
510 service_name: "test",
511 extra_networks: &[],
512 extra_volumes: &[],
513 podman_args: &[],
514 extra_exec_start_pre: &[],
515 port_vars: &[],
516 };
517 let err = process_quadlet_bundle(¶ms).unwrap_err();
518 assert!(err.to_string().contains("quadlets/ directory not found"));
519 }
520
521 #[test]
522 fn process_quadlet_bundle_reads_and_processes_files() {
523 let tmp = tempfile::tempdir()
524 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
525 let service_dir = tmp.path().join("myservice");
526 let quadlets_dir = service_dir.join("quadlets");
527 std::fs::create_dir_all(&quadlets_dir)
528 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
529
530 std::fs::write(
531 quadlets_dir.join("app.container"),
532 "[Container]\nImage=nginx:latest\nVolume=%h/.local/share/services/myservice/data:/data\n\n[Service]\nRestart=always\n",
533 )
534 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
535
536 std::fs::write(
537 quadlets_dir.join("app.network"),
538 "[Network]\nDriver=bridge\n",
539 )
540 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
541
542 let params = ProcessBundleParams {
543 service_dir: &service_dir,
544 service_name: "myservice",
545 extra_networks: &["caddy".to_string()],
546 extra_volumes: &[],
547 podman_args: &[],
548 extra_exec_start_pre: &[],
549 port_vars: &[],
550 };
551
552 let bundle = process_quadlet_bundle(¶ms)
553 .unwrap_or_else(|e| unreachable!("process_quadlet_bundle should not fail: {e}"));
554
555 assert_eq!(bundle.quadlet_files.len(), 2);
556 assert_eq!(bundle.images, vec!["nginx:latest".to_string()]);
557
558 let container_file = bundle
560 .quadlet_files
561 .iter()
562 .find(|f| f.path.to_string_lossy().ends_with(".container"))
563 .unwrap_or_else(|| unreachable!("container file must exist"));
564 assert!(
565 container_file
566 .content
567 .contains("%h/.local/share/services/myservice/data:/data")
568 );
569 assert!(container_file.content.contains("Network=caddy.network"));
571
572 let network_file = bundle
574 .quadlet_files
575 .iter()
576 .find(|f| f.path.to_string_lossy().ends_with(".network"))
577 .unwrap_or_else(|| unreachable!("network file must exist"));
578 assert!(!network_file.content.contains("Network=caddy.network"));
579 }
580
581 #[test]
582 fn process_quadlet_bundle_errors_on_empty_dir() {
583 let tmp = tempfile::tempdir()
584 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
585 let service_dir = tmp.path().join("empty");
586 let quadlets_dir = service_dir.join("quadlets");
587 std::fs::create_dir_all(&quadlets_dir)
588 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
589
590 let params = ProcessBundleParams {
591 service_dir: &service_dir,
592 service_name: "empty",
593 extra_networks: &[],
594 extra_volumes: &[],
595 podman_args: &[],
596 extra_exec_start_pre: &[],
597 port_vars: &[],
598 };
599 let err = process_quadlet_bundle(¶ms).unwrap_err();
600 assert!(err.to_string().contains("no quadlet files found"));
601 }
602
603 #[test]
604 fn process_configs_reads_recursively() {
605 let tmp = tempfile::tempdir()
606 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
607 let service_dir = tmp.path().join("svc");
608 let configs_dir = service_dir.join("configs");
609 let sub_dir = configs_dir.join("subdir");
610 std::fs::create_dir_all(&sub_dir)
611 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
612
613 std::fs::write(configs_dir.join("main.conf"), "data_dir=/some/path\n")
614 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
615
616 std::fs::write(sub_dir.join("nested.conf"), "no placeholders\n")
617 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
618
619 let service_home = Path::new("/home/user/.local/share/services/svc");
620
621 let files = process_configs(&service_dir, service_home)
622 .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
623
624 assert_eq!(files.len(), 2);
625
626 let main_conf = files
627 .iter()
628 .find(|f| f.path.ends_with("main.conf"))
629 .unwrap_or_else(|| unreachable!("main.conf must exist"));
630 assert_eq!(
631 main_conf.path,
632 PathBuf::from("/home/user/.local/share/services/svc/configs/main.conf")
633 );
634 assert!(main_conf.content.contains("/some/path"));
635
636 let nested_conf = files
637 .iter()
638 .find(|f| f.path.ends_with("nested.conf"))
639 .unwrap_or_else(|| unreachable!("nested.conf must exist"));
640 assert_eq!(
641 nested_conf.path,
642 PathBuf::from("/home/user/.local/share/services/svc/configs/subdir/nested.conf")
643 );
644 assert_eq!(nested_conf.content, "no placeholders\n");
645 }
646
647 #[test]
648 fn extract_bind_mount_dirs_finds_host_paths() {
649 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/test".to_string());
650 let files = vec![
651 GeneratedFile {
652 path: PathBuf::from("/q/immich.container"),
653 content: "Volume=%h/.local/share/services/immich/upload:/data:Z\nVolume=immich-db-data.volume:/var/lib/postgresql/data:U\n".to_string(),
654 },
655 GeneratedFile {
656 path: PathBuf::from("/q/immich.network"),
657 content: "[Network]\n".to_string(),
658 },
659 ];
660 let dirs = extract_bind_mount_dirs(&files).unwrap();
661 assert_eq!(
662 dirs,
663 vec![PathBuf::from(format!(
664 "{home}/.local/share/services/immich/upload"
665 ))]
666 );
667 }
668
669 #[test]
670 fn extract_bind_mount_dirs_skips_named_volumes() {
671 let files = vec![GeneratedFile {
672 path: PathBuf::from("/q/svc.container"),
673 content: "Volume=svc-data.volume:/data:U\n".to_string(),
674 }];
675 let dirs = extract_bind_mount_dirs(&files).unwrap();
676 assert!(dirs.is_empty());
677 }
678
679 #[test]
680 fn extract_bind_mount_dirs_skips_file_mounts() {
681 let files = vec![GeneratedFile {
682 path: PathBuf::from("/q/svc.container"),
683 content: "Volume=/path/to/ca.crt:/etc/ssl/certs/ca.crt:ro,Z\nVolume=/path/to/config:/config:Z\n".to_string(),
684 }];
685 let dirs = extract_bind_mount_dirs(&files).unwrap();
686 assert_eq!(dirs, vec![PathBuf::from("/path/to/config")]);
688 }
689
690 #[test]
691 fn process_configs_returns_empty_when_no_configs_dir() {
692 let tmp = tempfile::tempdir()
693 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
694 let service_dir = tmp.path().join("svc");
695 std::fs::create_dir_all(&service_dir)
696 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
697
698 let files = process_configs(
699 &service_dir,
700 Path::new("/home/user/.local/share/services/svc"),
701 )
702 .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
703
704 assert!(files.is_empty());
705 }
706}