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        // Real files live under service_home; the caller emits Step::Symlink
267        // afterwards to expose them at the systemd-mandated quadlet path.
268        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    // Sort for deterministic ordering
282    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
298/// Collect vendored files from `<service_dir>/files/` recursively. Returns
299/// (src, dst) pairs where `src` is the registry path and `dst` is the
300/// corresponding path under `service_home`. The CLI copies them with
301/// `std::fs::copy` at apply time (binary-safe, no UTF-8 assumption).
302pub 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
344/// Read files from `<service_dir>/configs/` recursively,
345/// map them to `<service_home>/configs/<relative_path>`.
346pub 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        // Should insert blank line before injected content when previous line is not blank
489        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(&params).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(&params)
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        // Check content is preserved as-is (no placeholder substitution)
548        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        // Check network injection happened
559        assert!(container_file.content.contains("Network=caddy.network"));
560
561        // Network file should NOT have network injection
562        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(&params).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        // Only the directory mount, not the .crt file mount
676        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}