Skip to main content

ryra_core/generate/
bundle.rs

1use std::path::Path;
2
3use crate::error::{Error, Result};
4use crate::generate::GeneratedFile;
5
6/// Parameters for [`process_quadlet_bundle`].
7pub 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    /// Extra args passed via `PodmanArgs=` in the quadlet.
13    pub podman_args: &'a [String],
14    /// Extra ExecStartPre commands to inject into [Service] section.
15    pub extra_exec_start_pre: &'a [String],
16    /// Port variable expansions (e.g., `SERVICE_PORT_HTTP` → `8080`).
17    /// Quadlet `PublishPort=${VAR}:container_port` directives need literal
18    /// values because systemd doesn't expand EnvironmentFile vars in directives.
19    pub port_vars: &'a [(String, String)],
20}
21
22/// Result of processing a quadlet bundle from the registry.
23#[derive(Debug)]
24pub struct ProcessedBundle {
25    pub quadlet_files: Vec<GeneratedFile>,
26    pub config_files: Vec<GeneratedFile>,
27    pub images: Vec<String>,
28    /// Host directories that must exist before containers start (bind mount sources).
29    pub bind_mount_dirs: Vec<std::path::PathBuf>,
30    /// Vendored files (src, dst) to copy raw from the registry into
31    /// service_home. Kept separate from `config_files` because the config
32    /// pipeline is UTF-8 only (template rendering) — DLLs, archives and
33    /// other binaries don't fit there. The `files/` subtree mirrors
34    /// service_home paths.
35    pub files: Vec<(std::path::PathBuf, std::path::PathBuf)>,
36}
37
38/// Scan processed `.container` files for `Image=` lines. Deduplicate.
39pub 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
59/// Extract host directories from bind mount `Volume=` lines in `.container` files.
60/// Bind mounts are `Volume=/host/path:/container/path:flags` (NOT `.volume:` references).
61/// These directories must exist before the container starts.
62///
63/// Expands `%h` to the user's home directory (systemd specifier).
64/// File bind mounts (host path has a file extension like `.crt`, `.yml`) are skipped —
65/// only directory mounts need pre-creation.
66pub 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                // Skip named volume references (e.g., "myvolume.volume:/path:U")
80                if vol.contains(".volume:") {
81                    continue;
82                }
83                // Bind mount format: /host/path:/container/path[:flags]
84                if let Some(colon_pos) = vol.find(':') {
85                    let host_path = &vol[..colon_pos];
86                    if host_path.is_empty() {
87                        continue;
88                    }
89                    // Expand %h systemd specifier to actual home directory
90                    let expanded = host_path.replace("%h", &home.to_string_lossy());
91                    // Skip file bind mounts — only directories need pre-creation.
92                    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
104/// Append `Network=<name>.network` lines to the `[Container]` section of a quadlet file.
105/// Inserts just before `[Service]` section if it exists, otherwise appends at end.
106///
107/// Each entry in `networks` is a network name optionally followed by `:` and
108/// extra options (e.g., `"authelia:alias=auth.test.local"` →
109/// `Network=authelia.network:alias=auth.test.local`).
110pub 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
129/// Append `PodmanArgs=` to the `[Container]` section of a quadlet file.
130pub 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
138/// Append `Volume=` lines to the `[Container]` section of a quadlet file.
139/// Inserts just before `[Service]` section if it exists, otherwise appends at end.
140pub 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
153/// Insert `extra_lines` just before the line matching `section_header`, or append at end.
154fn 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            // Insert extra lines before the section header, with a blank line separator if needed
161            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            // Splice in the extra lines
170            for (i, line) in insert.iter().enumerate() {
171                lines.insert(pos + i, line);
172            }
173            let mut result = lines.join("\n");
174            // Preserve trailing newline if original had one
175            if content.ends_with('\n') {
176                result.push('\n');
177            }
178            result
179        }
180        None => {
181            // No section header found — append at end
182            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
193/// Main entry point: read all quadlet files from `<service_dir>/quadlets/`,
194/// apply substitutions and injections, extract images, process config files.
195pub 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        // Stamp every generated quadlet with a provenance marker so
234        // `ryra remove` and `ryra list` can tell registry-managed files
235        // from hand-written ones. The marker format matches the one
236        // used in Caddyfile site blocks and /etc/hosts entries.
237        // Wiring details (exposure / url / auth / registry) live in the
238        // service's metadata.toml — this comment carries provenance only.
239        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        // Only inject networks/volumes into .container files
244        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            // Inject ExecStartPre into the main service container
249            // (the one named <service>.container, not sidecars)
250            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            // Expand ${SERVICE_PORT_*} in PublishPort lines — systemd doesn't
260            // expand EnvironmentFile vars in quadlet directives.
261            for (var, val) in params.port_vars {
262                content = content.replace(&format!("${{{var}}}"), val);
263            }
264        }
265
266        // When the service-data root is overridden (RYRA_DATA_DIR — the host
267        // test sandbox), the registry templates' hardcoded
268        // `%h/.local/share/services/...` bind-mount, EnvironmentFile, and
269        // ExecStartPre/Post paths must follow the data root too. Otherwise the
270        // container would mount the *real* services dir while ryra writes the
271        // `.env`/configs into the sandbox — a split between where data is read
272        // and written. No-op for normal installs (override is None).
273        if let Some(root) = crate::paths::data_dir_override() {
274            content = content.replace("%h/.local/share/services", &root.to_string_lossy());
275        }
276
277        // Real files live under service_home; the caller emits Step::Symlink
278        // afterwards to expose them at the systemd-mandated quadlet path.
279        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    // Sort for deterministic ordering
293    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
309/// Collect vendored files from `<service_dir>/files/` recursively. Returns
310/// (src, dst) pairs where `src` is the registry path and `dst` is the
311/// corresponding path under `service_home`. The CLI copies them with
312/// `std::fs::copy` at apply time (binary-safe, no UTF-8 assumption).
313pub 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
355/// Read files from `<service_dir>/configs/` recursively,
356/// map them to `<service_home>/configs/<relative_path>`.
357pub 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        // Should insert blank line before injected content when previous line is not blank
500        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(&params).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(&params)
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        // Check content is preserved as-is (no placeholder substitution)
559        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        // Check network injection happened
570        assert!(container_file.content.contains("Network=caddy.network"));
571
572        // Network file should NOT have network injection
573        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(&params).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        // Only the directory mount, not the .crt file mount
687        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}