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 quadlet_files.push(GeneratedFile {
269 path: service_home.join(file_name.as_ref()),
270 content,
271 });
272 }
273
274 if quadlet_files.is_empty() {
275 return Err(Error::Bundle(format!(
276 "no quadlet files found in quadlets/ for service '{}'",
277 params.service_name
278 )));
279 }
280
281 quadlet_files.sort_by(|a, b| a.path.cmp(&b.path));
283
284 let images = extract_images(&quadlet_files);
285 let bind_mount_dirs = extract_bind_mount_dirs(&quadlet_files)?;
286 let config_files = process_configs(params.service_dir, &service_home)?;
287 let files = collect_files(params.service_dir, &service_home)?;
288
289 Ok(ProcessedBundle {
290 quadlet_files,
291 config_files,
292 images,
293 bind_mount_dirs,
294 files,
295 })
296}
297
298pub fn collect_files(
303 service_dir: &Path,
304 service_home: &Path,
305) -> Result<Vec<(std::path::PathBuf, std::path::PathBuf)>> {
306 let files_dir = service_dir.join("files");
307 if !files_dir.is_dir() {
308 return Ok(Vec::new());
309 }
310 let mut out = Vec::new();
311 collect_files_recursive(&files_dir, &files_dir, service_home, &mut out)?;
312 out.sort_by(|a, b| a.1.cmp(&b.1));
313 Ok(out)
314}
315
316fn collect_files_recursive(
317 base_dir: &Path,
318 current_dir: &Path,
319 service_home: &Path,
320 out: &mut Vec<(std::path::PathBuf, std::path::PathBuf)>,
321) -> Result<()> {
322 let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
323 path: current_dir.to_path_buf(),
324 source,
325 })?;
326 for entry in entries {
327 let entry = entry.map_err(|source| Error::FileRead {
328 path: current_dir.to_path_buf(),
329 source,
330 })?;
331 let path = entry.path();
332 if path.is_dir() {
333 collect_files_recursive(base_dir, &path, service_home, out)?;
334 } else if path.is_file() {
335 let relative = path
336 .strip_prefix(base_dir)
337 .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
338 out.push((path.clone(), service_home.join(relative)));
339 }
340 }
341 Ok(())
342}
343
344pub fn process_configs(service_dir: &Path, service_home: &Path) -> Result<Vec<GeneratedFile>> {
347 let configs_dir = service_dir.join("configs");
348 if !configs_dir.is_dir() {
349 return Ok(Vec::new());
350 }
351
352 let mut files = Vec::new();
353 collect_configs_recursive(&configs_dir, &configs_dir, service_home, &mut files)?;
354 files.sort_by(|a, b| a.path.cmp(&b.path));
355 Ok(files)
356}
357
358fn collect_configs_recursive(
359 base_dir: &Path,
360 current_dir: &Path,
361 service_home: &Path,
362 files: &mut Vec<GeneratedFile>,
363) -> Result<()> {
364 let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
365 path: current_dir.to_path_buf(),
366 source,
367 })?;
368
369 for entry in entries {
370 let entry = entry.map_err(|source| Error::FileRead {
371 path: current_dir.to_path_buf(),
372 source,
373 })?;
374 let path = entry.path();
375
376 if path.is_dir() {
377 collect_configs_recursive(base_dir, &path, service_home, files)?;
378 } else if path.is_file() {
379 let relative = path
380 .strip_prefix(base_dir)
381 .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
382
383 let content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
384 path: path.clone(),
385 source,
386 })?;
387
388 files.push(GeneratedFile {
389 path: service_home.join("configs").join(relative),
390 content,
391 });
392 }
393 }
394
395 Ok(())
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use std::path::PathBuf;
402
403 #[test]
404 fn extract_images_from_container_files_only() {
405 let files = vec![
406 GeneratedFile {
407 path: PathBuf::from("/q/myapp.container"),
408 content: "[Container]\nImage=docker.io/library/nginx:latest\n".to_string(),
409 },
410 GeneratedFile {
411 path: PathBuf::from("/q/myapp.network"),
412 content: "[Network]\nImage=should-be-ignored\n".to_string(),
413 },
414 GeneratedFile {
415 path: PathBuf::from("/q/myapp-db.container"),
416 content: "[Container]\nImage=docker.io/library/postgres:16\n".to_string(),
417 },
418 ];
419 let images = extract_images(&files);
420 assert_eq!(
421 images,
422 vec![
423 "docker.io/library/nginx:latest".to_string(),
424 "docker.io/library/postgres:16".to_string(),
425 ]
426 );
427 }
428
429 #[test]
430 fn extract_images_deduplicates() {
431 let files = vec![
432 GeneratedFile {
433 path: PathBuf::from("/q/a.container"),
434 content: "Image=docker.io/img:1\n".to_string(),
435 },
436 GeneratedFile {
437 path: PathBuf::from("/q/b.container"),
438 content: "Image=docker.io/img:1\nImage=docker.io/img:2\n".to_string(),
439 },
440 ];
441 let images = extract_images(&files);
442 assert_eq!(
443 images,
444 vec!["docker.io/img:1".to_string(), "docker.io/img:2".to_string(),]
445 );
446 }
447
448 #[test]
449 fn inject_networks_before_service_section() {
450 let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
451 let result = inject_networks(content, &["caddy".to_string(), "auth".to_string()]);
452 assert_eq!(
453 result,
454 "[Container]\nImage=nginx\n\nNetwork=caddy.network\nNetwork=auth.network\n[Service]\nRestart=always\n"
455 );
456 }
457
458 #[test]
459 fn inject_networks_no_service_section_appends() {
460 let content = "[Container]\nImage=nginx\n";
461 let result = inject_networks(content, &["caddy".to_string()]);
462 assert_eq!(result, "[Container]\nImage=nginx\nNetwork=caddy.network\n");
463 }
464
465 #[test]
466 fn inject_extra_volumes_before_service_section() {
467 let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
468 let result =
469 inject_extra_volumes(content, &["/host/ca.crt:/etc/ssl/ca.crt:ro".to_string()]);
470 assert_eq!(
471 result,
472 "[Container]\nImage=nginx\n\nVolume=/host/ca.crt:/etc/ssl/ca.crt:ro\n[Service]\nRestart=always\n"
473 );
474 }
475
476 #[test]
477 fn inject_extra_volumes_no_service_section_appends() {
478 let content = "[Container]\nImage=nginx";
479 let result = inject_extra_volumes(content, &["/a:/b".to_string()]);
480 assert_eq!(result, "[Container]\nImage=nginx\nVolume=/a:/b\n");
481 }
482
483 #[test]
484 fn inject_networks_adds_blank_line_when_needed() {
485 let content =
486 "[Container]\nImage=nginx\nNetwork=mynet.network\n[Service]\nRestart=always\n";
487 let result = inject_networks(content, &["caddy".to_string()]);
488 assert_eq!(
490 result,
491 "[Container]\nImage=nginx\nNetwork=mynet.network\n\nNetwork=caddy.network\n[Service]\nRestart=always\n"
492 );
493 }
494
495 #[test]
496 fn process_quadlet_bundle_errors_on_missing_dir() {
497 let params = ProcessBundleParams {
498 service_dir: Path::new("/nonexistent"),
499 service_name: "test",
500 extra_networks: &[],
501 extra_volumes: &[],
502 podman_args: &[],
503 extra_exec_start_pre: &[],
504 port_vars: &[],
505 };
506 let err = process_quadlet_bundle(¶ms).unwrap_err();
507 assert!(err.to_string().contains("quadlets/ directory not found"));
508 }
509
510 #[test]
511 fn process_quadlet_bundle_reads_and_processes_files() {
512 let tmp = tempfile::tempdir()
513 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
514 let service_dir = tmp.path().join("myservice");
515 let quadlets_dir = service_dir.join("quadlets");
516 std::fs::create_dir_all(&quadlets_dir)
517 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
518
519 std::fs::write(
520 quadlets_dir.join("app.container"),
521 "[Container]\nImage=nginx:latest\nVolume=%h/.local/share/services/myservice/data:/data\n\n[Service]\nRestart=always\n",
522 )
523 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
524
525 std::fs::write(
526 quadlets_dir.join("app.network"),
527 "[Network]\nDriver=bridge\n",
528 )
529 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
530
531 let params = ProcessBundleParams {
532 service_dir: &service_dir,
533 service_name: "myservice",
534 extra_networks: &["caddy".to_string()],
535 extra_volumes: &[],
536 podman_args: &[],
537 extra_exec_start_pre: &[],
538 port_vars: &[],
539 };
540
541 let bundle = process_quadlet_bundle(¶ms)
542 .unwrap_or_else(|e| unreachable!("process_quadlet_bundle should not fail: {e}"));
543
544 assert_eq!(bundle.quadlet_files.len(), 2);
545 assert_eq!(bundle.images, vec!["nginx:latest".to_string()]);
546
547 let container_file = bundle
549 .quadlet_files
550 .iter()
551 .find(|f| f.path.to_string_lossy().ends_with(".container"))
552 .unwrap_or_else(|| unreachable!("container file must exist"));
553 assert!(
554 container_file
555 .content
556 .contains("%h/.local/share/services/myservice/data:/data")
557 );
558 assert!(container_file.content.contains("Network=caddy.network"));
560
561 let network_file = bundle
563 .quadlet_files
564 .iter()
565 .find(|f| f.path.to_string_lossy().ends_with(".network"))
566 .unwrap_or_else(|| unreachable!("network file must exist"));
567 assert!(!network_file.content.contains("Network=caddy.network"));
568 }
569
570 #[test]
571 fn process_quadlet_bundle_errors_on_empty_dir() {
572 let tmp = tempfile::tempdir()
573 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
574 let service_dir = tmp.path().join("empty");
575 let quadlets_dir = service_dir.join("quadlets");
576 std::fs::create_dir_all(&quadlets_dir)
577 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
578
579 let params = ProcessBundleParams {
580 service_dir: &service_dir,
581 service_name: "empty",
582 extra_networks: &[],
583 extra_volumes: &[],
584 podman_args: &[],
585 extra_exec_start_pre: &[],
586 port_vars: &[],
587 };
588 let err = process_quadlet_bundle(¶ms).unwrap_err();
589 assert!(err.to_string().contains("no quadlet files found"));
590 }
591
592 #[test]
593 fn process_configs_reads_recursively() {
594 let tmp = tempfile::tempdir()
595 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
596 let service_dir = tmp.path().join("svc");
597 let configs_dir = service_dir.join("configs");
598 let sub_dir = configs_dir.join("subdir");
599 std::fs::create_dir_all(&sub_dir)
600 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
601
602 std::fs::write(configs_dir.join("main.conf"), "data_dir=/some/path\n")
603 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
604
605 std::fs::write(sub_dir.join("nested.conf"), "no placeholders\n")
606 .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
607
608 let service_home = Path::new("/home/user/.local/share/services/svc");
609
610 let files = process_configs(&service_dir, service_home)
611 .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
612
613 assert_eq!(files.len(), 2);
614
615 let main_conf = files
616 .iter()
617 .find(|f| f.path.ends_with("main.conf"))
618 .unwrap_or_else(|| unreachable!("main.conf must exist"));
619 assert_eq!(
620 main_conf.path,
621 PathBuf::from("/home/user/.local/share/services/svc/configs/main.conf")
622 );
623 assert!(main_conf.content.contains("/some/path"));
624
625 let nested_conf = files
626 .iter()
627 .find(|f| f.path.ends_with("nested.conf"))
628 .unwrap_or_else(|| unreachable!("nested.conf must exist"));
629 assert_eq!(
630 nested_conf.path,
631 PathBuf::from("/home/user/.local/share/services/svc/configs/subdir/nested.conf")
632 );
633 assert_eq!(nested_conf.content, "no placeholders\n");
634 }
635
636 #[test]
637 fn extract_bind_mount_dirs_finds_host_paths() {
638 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/test".to_string());
639 let files = vec![
640 GeneratedFile {
641 path: PathBuf::from("/q/immich.container"),
642 content: "Volume=%h/.local/share/services/immich/upload:/data:Z\nVolume=immich-db-data.volume:/var/lib/postgresql/data:U\n".to_string(),
643 },
644 GeneratedFile {
645 path: PathBuf::from("/q/immich.network"),
646 content: "[Network]\n".to_string(),
647 },
648 ];
649 let dirs = extract_bind_mount_dirs(&files).unwrap();
650 assert_eq!(
651 dirs,
652 vec![PathBuf::from(format!(
653 "{home}/.local/share/services/immich/upload"
654 ))]
655 );
656 }
657
658 #[test]
659 fn extract_bind_mount_dirs_skips_named_volumes() {
660 let files = vec![GeneratedFile {
661 path: PathBuf::from("/q/svc.container"),
662 content: "Volume=svc-data.volume:/data:U\n".to_string(),
663 }];
664 let dirs = extract_bind_mount_dirs(&files).unwrap();
665 assert!(dirs.is_empty());
666 }
667
668 #[test]
669 fn extract_bind_mount_dirs_skips_file_mounts() {
670 let files = vec![GeneratedFile {
671 path: PathBuf::from("/q/svc.container"),
672 content: "Volume=/path/to/ca.crt:/etc/ssl/certs/ca.crt:ro,Z\nVolume=/path/to/config:/config:Z\n".to_string(),
673 }];
674 let dirs = extract_bind_mount_dirs(&files).unwrap();
675 assert_eq!(dirs, vec![PathBuf::from("/path/to/config")]);
677 }
678
679 #[test]
680 fn process_configs_returns_empty_when_no_configs_dir() {
681 let tmp = tempfile::tempdir()
682 .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
683 let service_dir = tmp.path().join("svc");
684 std::fs::create_dir_all(&service_dir)
685 .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
686
687 let files = process_configs(
688 &service_dir,
689 Path::new("/home/user/.local/share/services/svc"),
690 )
691 .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
692
693 assert!(files.is_empty());
694 }
695}