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`].
7///
8/// Registry quadlets are plain podman files, used exactly as authored.
9/// `${SERVICE_PORT_*}` / `${SERVICE_HOME}` are runtime env expansions:
10/// quadlet passes them through to the generated `ExecStart=podman run ...`
11/// line (podman >= 5.3), where systemd expands them from the `[Service]`
12/// section's `EnvironmentFile=` — the `.env` ryra writes. The quadlet
13/// therefore also works without ryra: copy it, write the `.env` by hand.
14pub struct ProcessBundleParams<'a> {
15    pub service_dir: &'a Path,
16    pub service_name: &'a str,
17    pub extra_networks: &'a [String],
18    pub extra_volumes: &'a [String],
19    /// Extra args passed via `PodmanArgs=` in the quadlet.
20    pub podman_args: &'a [String],
21    /// Extra ExecStartPre commands to inject into [Service] section.
22    pub extra_exec_start_pre: &'a [String],
23    /// Declared `[[ports]]` names from service.toml. Every
24    /// `${SERVICE_PORT_<NAME>}` in a quadlet must match one — runtime env
25    /// expansion can't catch typos (an undefined var expands to ""), so
26    /// this is validated here, at the boundary.
27    pub port_names: &'a [String],
28    /// Quadlet filenames to skip: those claimed by a `[[choice.option]]`
29    /// whose option was not selected. Skipped files are neither processed
30    /// nor symlinked, and their `Image=` is never collected, so an
31    /// unselected sidecar costs no pull.
32    pub excluded_quadlets: &'a [String],
33}
34
35/// Result of processing a quadlet bundle from the registry.
36#[derive(Debug)]
37pub struct ProcessedBundle {
38    pub quadlet_files: Vec<GeneratedFile>,
39    pub config_files: Vec<GeneratedFile>,
40    pub images: Vec<String>,
41    /// Host directories that must exist before containers start (bind mount sources).
42    pub bind_mount_dirs: Vec<std::path::PathBuf>,
43    /// Vendored files (src, dst) to copy raw from the registry into
44    /// service_home. Kept separate from `config_files` because the config
45    /// pipeline is UTF-8 only (template rendering) — DLLs, archives and
46    /// other binaries don't fit there. The `files/` subtree mirrors
47    /// service_home paths.
48    pub files: Vec<(std::path::PathBuf, std::path::PathBuf)>,
49}
50
51/// Scan processed `.container` files for `Image=` lines. Deduplicate.
52pub fn extract_images(files: &[GeneratedFile]) -> Vec<String> {
53    let mut images = Vec::new();
54    for file in files {
55        let path_str = file.path.to_string_lossy();
56        if !path_str.ends_with(".container") {
57            continue;
58        }
59        for line in file.content.lines() {
60            let trimmed = line.trim();
61            if let Some(image) = trimmed.strip_prefix("Image=") {
62                let image = image.trim().to_string();
63                if !image.is_empty() && !images.contains(&image) {
64                    images.push(image);
65                }
66            }
67        }
68    }
69    images
70}
71
72/// Validate the runtime env references a quadlet relies on.
73///
74/// Rejects leftover `{{...}}` template syntax (quadlets are plain podman
75/// files, not templates) and any `${SERVICE_PORT_<NAME>}` that doesn't match
76/// a declared `[[ports]]` entry. systemd expands an undefined var to an empty
77/// string at runtime, which produces a confusingly broken unit — so the typo
78/// is caught here, at add time, instead.
79fn validate_quadlet_env_refs(
80    content: &str,
81    file_name: &str,
82    params: &ProcessBundleParams<'_>,
83) -> Result<()> {
84    // Skip comment lines: a comment may legitimately mention `{{...}}` when it
85    // explains the `.env` value a directive reads. Only a directive using
86    // template syntax is the mistake (ryra renders `.env`, not quadlets).
87    if content
88        .lines()
89        .any(|l| !l.trim_start().starts_with('#') && l.contains("{{"))
90    {
91        return Err(Error::Bundle(format!(
92            "quadlet '{}' for service '{}' contains '{{{{...}}}}' template syntax — quadlets \
93             are plain podman files; use runtime env vars (${{SERVICE_PORT_<NAME>}}, \
94             ${{SERVICE_HOME}}) instead",
95            file_name, params.service_name
96        )));
97    }
98    let mut rest = content;
99    while let Some(idx) = rest.find("${SERVICE_PORT_") {
100        let tail = &rest[idx + "${SERVICE_PORT_".len()..];
101        let Some(end) = tail.find('}') else { break };
102        let var_name = &tail[..end];
103        if !params
104            .port_names
105            .iter()
106            .any(|p| p.eq_ignore_ascii_case(var_name))
107        {
108            return Err(Error::Bundle(format!(
109                "quadlet '{}' for service '{}' references ${{SERVICE_PORT_{}}} but \
110                 service.toml declares no [[ports]] entry named '{}'",
111                file_name,
112                params.service_name,
113                var_name,
114                var_name.to_lowercase()
115            )));
116        }
117        rest = &tail[end..];
118    }
119
120    // ${SERVICE_*} vars expand in the generated ExecStart from *service-level*
121    // env — a `[Container]` EnvironmentFile only feeds the container. A unit
122    // that uses the vars without a `[Service]` EnvironmentFile would expand
123    // them to empty strings at runtime, silently mounting wrong paths.
124    let uses_service_vars =
125        content.contains("${SERVICE_HOME}") || content.contains("${SERVICE_PORT_");
126    if uses_service_vars && !section_has_line(content, "[Service]", "EnvironmentFile=") {
127        return Err(Error::Bundle(format!(
128            "quadlet '{}' for service '{}' uses ${{SERVICE_*}} vars but has no \
129             EnvironmentFile= in its [Service] section — systemd would expand them \
130             to empty strings",
131            file_name, params.service_name
132        )));
133    }
134    Ok(())
135}
136
137/// Whether `section` (e.g. `[Service]`) contains a line starting with `prefix`.
138fn section_has_line(content: &str, section: &str, prefix: &str) -> bool {
139    let mut in_section = false;
140    for line in content.lines() {
141        let trimmed = line.trim();
142        if trimmed.starts_with('[') && trimmed.ends_with(']') {
143            in_section = trimmed == section;
144        } else if in_section && trimmed.starts_with(prefix) {
145            return true;
146        }
147    }
148    false
149}
150
151/// Extract host directories from bind mount `Volume=` lines in `.container` files.
152/// Bind mounts are `Volume=/host/path:/container/path:flags` (NOT `.volume:` references).
153/// These directories must exist before the container starts.
154///
155/// Expands `%h` (systemd specifier) and `${SERVICE_HOME}` (runtime env from
156/// the service `.env`) — ryra knows both values at install time, and the
157/// dirs must exist before the unit first starts.
158pub fn extract_bind_mount_dirs(
159    files: &[GeneratedFile],
160    service_home: &Path,
161) -> crate::error::Result<Vec<std::path::PathBuf>> {
162    let home = crate::home_dir()?;
163    let mut dirs = Vec::new();
164    for file in files {
165        let path_str = file.path.to_string_lossy();
166        if !path_str.ends_with(".container") {
167            continue;
168        }
169        for line in file.content.lines() {
170            let trimmed = line.trim();
171            if let Some(vol) = trimmed.strip_prefix("Volume=") {
172                // Skip named volume references (e.g., "myvolume.volume:/path:U")
173                if vol.contains(".volume:") {
174                    continue;
175                }
176                // Bind mount format: /host/path:/container/path[:flags]
177                if let Some(colon_pos) = vol.find(':') {
178                    let host_path = &vol[..colon_pos];
179                    if host_path.is_empty() {
180                        continue;
181                    }
182                    // Expand %h systemd specifier and ${SERVICE_HOME}
183                    let expanded = host_path
184                        .replace("%h", &home.to_string_lossy())
185                        .replace("${SERVICE_HOME}", &service_home.to_string_lossy());
186                    // Skip file bind mounts — only directories need pre-creation.
187                    let path = std::path::Path::new(&expanded);
188                    if path.extension().is_some() {
189                        continue;
190                    }
191                    dirs.push(std::path::PathBuf::from(expanded));
192                }
193            }
194        }
195    }
196    Ok(dirs)
197}
198
199/// Append `Network=<name>.network` lines to the `[Container]` section of a quadlet file.
200/// Inserts just before `[Service]` section if it exists, otherwise appends at end.
201///
202/// Each entry in `networks` is a network name optionally followed by `:` and
203/// extra options (e.g., `"authelia:alias=auth.test.local"` →
204/// `Network=authelia.network:alias=auth.test.local`).
205pub fn inject_networks(content: &str, networks: &[String]) -> String {
206    if networks.is_empty() {
207        return content.to_string();
208    }
209    let extra_lines: String = networks
210        .iter()
211        .map(|n| {
212            if let Some((name, opts)) = n.split_once(':') {
213                format!("Network={name}.network:{opts}")
214            } else {
215                format!("Network={n}.network")
216            }
217        })
218        .collect::<Vec<_>>()
219        .join("\n");
220
221    inject_before_section(content, &extra_lines, "[Service]")
222}
223
224/// Append `PodmanArgs=` to the `[Container]` section of a quadlet file.
225pub fn inject_podman_args(content: &str, args: &[String]) -> String {
226    if args.is_empty() {
227        return content.to_string();
228    }
229    let line = format!("PodmanArgs={}", args.join(" "));
230    inject_before_section(content, &line, "[Service]")
231}
232
233/// Append `Volume=` lines to the `[Container]` section of a quadlet file.
234/// Inserts just before `[Service]` section if it exists, otherwise appends at end.
235pub fn inject_extra_volumes(content: &str, volumes: &[String]) -> String {
236    if volumes.is_empty() {
237        return content.to_string();
238    }
239    let extra_lines: String = volumes
240        .iter()
241        .map(|v| format!("Volume={v}"))
242        .collect::<Vec<_>>()
243        .join("\n");
244
245    inject_before_section(content, &extra_lines, "[Service]")
246}
247
248/// Insert `extra_lines` just before the line matching `section_header`, or append at end.
249fn inject_before_section(content: &str, extra_lines: &str, section_header: &str) -> String {
250    let mut lines: Vec<&str> = content.lines().collect();
251    let insert_pos = lines.iter().position(|l| l.trim() == section_header);
252
253    match insert_pos {
254        Some(pos) => {
255            // Insert extra lines before the section header, with a blank line separator if needed
256            let needs_blank = pos > 0 && !lines[pos - 1].trim().is_empty();
257            let mut insert = Vec::new();
258            if needs_blank {
259                insert.push("");
260            }
261            for line in extra_lines.lines() {
262                insert.push(line);
263            }
264            // Splice in the extra lines
265            for (i, line) in insert.iter().enumerate() {
266                lines.insert(pos + i, line);
267            }
268            let mut result = lines.join("\n");
269            // Preserve trailing newline if original had one
270            if content.ends_with('\n') {
271                result.push('\n');
272            }
273            result
274        }
275        None => {
276            // No section header found — append at end
277            let mut result = content.to_string();
278            if !result.ends_with('\n') {
279                result.push('\n');
280            }
281            result.push_str(extra_lines);
282            result.push('\n');
283            result
284        }
285    }
286}
287
288/// Main entry point: read all quadlet files from `<service_dir>/quadlets/`,
289/// apply substitutions and injections, extract images, process config files.
290pub fn process_quadlet_bundle(params: &ProcessBundleParams<'_>) -> Result<ProcessedBundle> {
291    let quadlets_dir = params.service_dir.join("quadlets");
292
293    if !quadlets_dir.is_dir() {
294        return Err(Error::Bundle(format!(
295            "quadlets/ directory not found for service '{}'",
296            params.service_name
297        )));
298    }
299
300    let mut quadlet_files = Vec::new();
301    // Did the chosen options exclude every quadlet? Then an empty result is
302    // legitimate (e.g. a native service whose only quadlet is a bundled DB that
303    // `database=external` drops) — not the "you forgot to ship a quadlet"
304    // error below.
305    let mut any_excluded = false;
306    let service_home = crate::service_home(params.service_name)?;
307    let data_root = crate::paths::service_data_root()?;
308    let canonical_data_root = crate::home_dir()?.join(".local/share/services");
309
310    let entries = std::fs::read_dir(&quadlets_dir).map_err(|source| Error::FileRead {
311        path: quadlets_dir.clone(),
312        source,
313    })?;
314
315    for entry in entries {
316        let entry = entry.map_err(|source| Error::FileRead {
317            path: quadlets_dir.clone(),
318            source,
319        })?;
320        let path = entry.path();
321        if !path.is_file() {
322            continue;
323        }
324
325        let mut content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
326            path: path.clone(),
327            source,
328        })?;
329
330        let file_name = path
331            .file_name()
332            .ok_or_else(|| Error::Bundle(format!("invalid file path: {}", path.display())))?
333            .to_string_lossy();
334
335        // Skip quadlets claimed by an unselected choice option: not written,
336        // not symlinked, image not collected.
337        if params
338            .excluded_quadlets
339            .iter()
340            .any(|q| q == file_name.as_ref())
341        {
342            any_excluded = true;
343            continue;
344        }
345
346        validate_quadlet_env_refs(&content, &file_name, params)?;
347
348        // The registry's canonical `EnvironmentFile=%h/.local/share/services/
349        // <svc>/.env` is the one literal path in the unit (systemd resolves
350        // EnvironmentFile= before any env exists, so it can't be
351        // `${SERVICE_HOME}`-based). When the host resolves the data root
352        // somewhere else — the RYRA_DATA_DIR test sandbox, or a custom
353        // XDG_DATA_HOME — the line must follow, or the unit would read a
354        // `.env` ryra never wrote. On default setups the paths are equal
355        // and the quadlet is used exactly as authored.
356        if data_root != canonical_data_root {
357            content = content.replace("%h/.local/share/services", &data_root.to_string_lossy());
358        }
359
360        // Stamp every generated quadlet with a provenance marker so
361        // `ryra remove` and `ryra list` can tell registry-managed files
362        // from hand-written ones. The marker format matches the one
363        // used in Caddyfile site blocks and /etc/hosts entries.
364        // Wiring details (exposure / url / auth / registry) live in the
365        // service's metadata.toml — this comment carries provenance only.
366        let is_main_container = file_name == format!("{}.container", params.service_name);
367        let header = format!("# Service-Source: registry/{}\n", params.service_name);
368        content = header + &content;
369
370        // Only inject networks/volumes into .container files
371        if file_name.ends_with(".container") {
372            content = inject_networks(&content, params.extra_networks);
373            content = inject_extra_volumes(&content, params.extra_volumes);
374            content = inject_podman_args(&content, params.podman_args);
375            // Inject ExecStartPre into the main service container
376            // (the one named <service>.container, not sidecars)
377            if is_main_container {
378                for cmd in params.extra_exec_start_pre {
379                    content = inject_before_section(
380                        &content,
381                        &format!("ExecStartPre={cmd}"),
382                        "[Install]",
383                    );
384                }
385            }
386        }
387
388        // Real files live under service_home; the caller emits Step::Symlink
389        // afterwards to expose them at the systemd-mandated quadlet path.
390        quadlet_files.push(GeneratedFile {
391            path: service_home.join(file_name.as_ref()),
392            content,
393        });
394    }
395
396    // Error only when the dir yielded nothing AND nothing was excluded — i.e. a
397    // genuinely empty `quadlets/`. If every quadlet was excluded by the chosen
398    // options, an empty bundle is the correct, valid outcome (no aux containers).
399    if quadlet_files.is_empty() && !any_excluded {
400        return Err(Error::Bundle(format!(
401            "no quadlet files found in quadlets/ for service '{}'",
402            params.service_name
403        )));
404    }
405
406    // Sort for deterministic ordering
407    quadlet_files.sort_by(|a, b| a.path.cmp(&b.path));
408
409    let images = extract_images(&quadlet_files);
410    let bind_mount_dirs = extract_bind_mount_dirs(&quadlet_files, &service_home)?;
411    let config_files = process_configs(params.service_dir, &service_home)?;
412    let files = collect_files(params.service_dir, &service_home)?;
413
414    Ok(ProcessedBundle {
415        quadlet_files,
416        config_files,
417        images,
418        bind_mount_dirs,
419        files,
420    })
421}
422
423/// Collect vendored files from `<service_dir>/files/` recursively. Returns
424/// (src, dst) pairs where `src` is the registry path and `dst` is the
425/// corresponding path under `service_home`. The CLI copies them with
426/// `std::fs::copy` at apply time (binary-safe, no UTF-8 assumption).
427pub fn collect_files(
428    service_dir: &Path,
429    service_home: &Path,
430) -> Result<Vec<(std::path::PathBuf, std::path::PathBuf)>> {
431    let files_dir = service_dir.join("files");
432    if !files_dir.is_dir() {
433        return Ok(Vec::new());
434    }
435    let mut out = Vec::new();
436    collect_files_recursive(&files_dir, &files_dir, service_home, &mut out)?;
437    out.sort_by(|a, b| a.1.cmp(&b.1));
438    Ok(out)
439}
440
441fn collect_files_recursive(
442    base_dir: &Path,
443    current_dir: &Path,
444    service_home: &Path,
445    out: &mut Vec<(std::path::PathBuf, std::path::PathBuf)>,
446) -> Result<()> {
447    let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
448        path: current_dir.to_path_buf(),
449        source,
450    })?;
451    for entry in entries {
452        let entry = entry.map_err(|source| Error::FileRead {
453            path: current_dir.to_path_buf(),
454            source,
455        })?;
456        let path = entry.path();
457        if path.is_dir() {
458            collect_files_recursive(base_dir, &path, service_home, out)?;
459        } else if path.is_file() {
460            let relative = path
461                .strip_prefix(base_dir)
462                .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
463            out.push((path.clone(), service_home.join(relative)));
464        }
465    }
466    Ok(())
467}
468
469/// Read files from `<service_dir>/configs/` recursively,
470/// map them to `<service_home>/configs/<relative_path>`.
471pub fn process_configs(service_dir: &Path, service_home: &Path) -> Result<Vec<GeneratedFile>> {
472    let configs_dir = service_dir.join("configs");
473    if !configs_dir.is_dir() {
474        return Ok(Vec::new());
475    }
476
477    let mut files = Vec::new();
478    collect_configs_recursive(&configs_dir, &configs_dir, service_home, &mut files)?;
479    files.sort_by(|a, b| a.path.cmp(&b.path));
480    Ok(files)
481}
482
483fn collect_configs_recursive(
484    base_dir: &Path,
485    current_dir: &Path,
486    service_home: &Path,
487    files: &mut Vec<GeneratedFile>,
488) -> Result<()> {
489    let entries = std::fs::read_dir(current_dir).map_err(|source| Error::FileRead {
490        path: current_dir.to_path_buf(),
491        source,
492    })?;
493
494    for entry in entries {
495        let entry = entry.map_err(|source| Error::FileRead {
496            path: current_dir.to_path_buf(),
497            source,
498        })?;
499        let path = entry.path();
500
501        if path.is_dir() {
502            collect_configs_recursive(base_dir, &path, service_home, files)?;
503        } else if path.is_file() {
504            let relative = path
505                .strip_prefix(base_dir)
506                .map_err(|e| Error::Bundle(format!("failed to compute relative path: {e}")))?;
507
508            let content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
509                path: path.clone(),
510                source,
511            })?;
512
513            files.push(GeneratedFile {
514                path: service_home.join("configs").join(relative),
515                content,
516            });
517        }
518    }
519
520    Ok(())
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use std::path::PathBuf;
527
528    #[test]
529    fn extract_images_from_container_files_only() {
530        let files = vec![
531            GeneratedFile {
532                path: PathBuf::from("/q/myapp.container"),
533                content: "[Container]\nImage=docker.io/library/nginx:latest\n".to_string(),
534            },
535            GeneratedFile {
536                path: PathBuf::from("/q/myapp.network"),
537                content: "[Network]\nImage=should-be-ignored\n".to_string(),
538            },
539            GeneratedFile {
540                path: PathBuf::from("/q/myapp-db.container"),
541                content: "[Container]\nImage=docker.io/library/postgres:16\n".to_string(),
542            },
543        ];
544        let images = extract_images(&files);
545        assert_eq!(
546            images,
547            vec![
548                "docker.io/library/nginx:latest".to_string(),
549                "docker.io/library/postgres:16".to_string(),
550            ]
551        );
552    }
553
554    #[test]
555    fn extract_images_deduplicates() {
556        let files = vec![
557            GeneratedFile {
558                path: PathBuf::from("/q/a.container"),
559                content: "Image=docker.io/img:1\n".to_string(),
560            },
561            GeneratedFile {
562                path: PathBuf::from("/q/b.container"),
563                content: "Image=docker.io/img:1\nImage=docker.io/img:2\n".to_string(),
564            },
565        ];
566        let images = extract_images(&files);
567        assert_eq!(
568            images,
569            vec!["docker.io/img:1".to_string(), "docker.io/img:2".to_string(),]
570        );
571    }
572
573    #[test]
574    fn inject_networks_before_service_section() {
575        let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
576        let result = inject_networks(content, &["caddy".to_string(), "auth".to_string()]);
577        assert_eq!(
578            result,
579            "[Container]\nImage=nginx\n\nNetwork=caddy.network\nNetwork=auth.network\n[Service]\nRestart=always\n"
580        );
581    }
582
583    #[test]
584    fn inject_networks_no_service_section_appends() {
585        let content = "[Container]\nImage=nginx\n";
586        let result = inject_networks(content, &["caddy".to_string()]);
587        assert_eq!(result, "[Container]\nImage=nginx\nNetwork=caddy.network\n");
588    }
589
590    #[test]
591    fn inject_extra_volumes_before_service_section() {
592        let content = "[Container]\nImage=nginx\n\n[Service]\nRestart=always\n";
593        let result =
594            inject_extra_volumes(content, &["/host/ca.crt:/etc/ssl/ca.crt:ro".to_string()]);
595        assert_eq!(
596            result,
597            "[Container]\nImage=nginx\n\nVolume=/host/ca.crt:/etc/ssl/ca.crt:ro\n[Service]\nRestart=always\n"
598        );
599    }
600
601    #[test]
602    fn inject_extra_volumes_no_service_section_appends() {
603        let content = "[Container]\nImage=nginx";
604        let result = inject_extra_volumes(content, &["/a:/b".to_string()]);
605        assert_eq!(result, "[Container]\nImage=nginx\nVolume=/a:/b\n");
606    }
607
608    #[test]
609    fn inject_networks_adds_blank_line_when_needed() {
610        let content =
611            "[Container]\nImage=nginx\nNetwork=mynet.network\n[Service]\nRestart=always\n";
612        let result = inject_networks(content, &["caddy".to_string()]);
613        // Should insert blank line before injected content when previous line is not blank
614        assert_eq!(
615            result,
616            "[Container]\nImage=nginx\nNetwork=mynet.network\n\nNetwork=caddy.network\n[Service]\nRestart=always\n"
617        );
618    }
619
620    #[test]
621    fn excluded_quadlet_is_skipped_and_its_image_not_pulled() {
622        let tmp = tempfile::tempdir()
623            .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
624        let service_dir = tmp.path().join("svc");
625        let quadlets_dir = service_dir.join("quadlets");
626        std::fs::create_dir_all(&quadlets_dir)
627            .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
628        std::fs::write(
629            quadlets_dir.join("svc.container"),
630            "[Container]\nImage=app:1\n[Service]\nRestart=always\n",
631        )
632        .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
633        // The bundled DB sidecar, which `external` would exclude.
634        std::fs::write(
635            quadlets_dir.join("svc-postgres.container"),
636            "[Container]\nImage=postgres:17\n[Service]\nRestart=always\n",
637        )
638        .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
639
640        let excluded = vec!["svc-postgres.container".to_string()];
641        let params = ProcessBundleParams {
642            service_dir: &service_dir,
643            service_name: "svc",
644            extra_networks: &[],
645            extra_volumes: &[],
646            podman_args: &[],
647            extra_exec_start_pre: &[],
648            port_names: &[],
649            excluded_quadlets: &excluded,
650        };
651        let bundle = process_quadlet_bundle(&params)
652            .unwrap_or_else(|e| unreachable!("process_quadlet_bundle should not fail: {e}"));
653
654        // Only the main container survives; the excluded sidecar is gone, and
655        // its image was never collected, so nothing pulls it.
656        assert_eq!(bundle.quadlet_files.len(), 1);
657        assert!(
658            bundle.quadlet_files[0]
659                .path
660                .to_string_lossy()
661                .ends_with("svc.container")
662        );
663        assert_eq!(bundle.images, vec!["app:1".to_string()]);
664        assert!(!bundle.images.iter().any(|i| i.contains("postgres")));
665    }
666
667    #[test]
668    fn process_quadlet_bundle_errors_on_missing_dir() {
669        let params = ProcessBundleParams {
670            service_dir: Path::new("/nonexistent"),
671            service_name: "test",
672            extra_networks: &[],
673            extra_volumes: &[],
674            podman_args: &[],
675            extra_exec_start_pre: &[],
676            port_names: &[],
677            excluded_quadlets: &[],
678        };
679        let err = process_quadlet_bundle(&params).unwrap_err();
680        assert!(err.to_string().contains("quadlets/ directory not found"));
681    }
682
683    #[test]
684    fn process_quadlet_bundle_reads_and_processes_files() {
685        let tmp = tempfile::tempdir()
686            .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
687        let service_dir = tmp.path().join("myservice");
688        let quadlets_dir = service_dir.join("quadlets");
689        std::fs::create_dir_all(&quadlets_dir)
690            .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
691
692        std::fs::write(
693            quadlets_dir.join("app.container"),
694            "[Container]\nImage=nginx:latest\nPublishPort=${SERVICE_PORT_HTTP}:80\nVolume=${SERVICE_HOME}/data:/data\n\n[Service]\nEnvironmentFile=%h/.local/share/services/myservice/.env\nRestart=always\n",
695        )
696        .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
697
698        std::fs::write(
699            quadlets_dir.join("app.network"),
700            "[Network]\nDriver=bridge\n",
701        )
702        .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
703
704        let params = ProcessBundleParams {
705            service_dir: &service_dir,
706            service_name: "myservice",
707            extra_networks: &["caddy".to_string()],
708            extra_volumes: &[],
709            podman_args: &[],
710            extra_exec_start_pre: &[],
711            port_names: &["http".to_string()],
712            excluded_quadlets: &[],
713        };
714
715        let bundle = process_quadlet_bundle(&params)
716            .unwrap_or_else(|e| unreachable!("process_quadlet_bundle should not fail: {e}"));
717
718        assert_eq!(bundle.quadlet_files.len(), 2);
719        assert_eq!(bundle.images, vec!["nginx:latest".to_string()]);
720
721        let container_file = bundle
722            .quadlet_files
723            .iter()
724            .find(|f| f.path.to_string_lossy().ends_with(".container"))
725            .unwrap_or_else(|| unreachable!("container file must exist"));
726        // ${...} is runtime env — the quadlet passes through unmodified.
727        assert!(
728            container_file
729                .content
730                .contains("PublishPort=${SERVICE_PORT_HTTP}:80")
731        );
732        assert!(
733            container_file
734                .content
735                .contains("Volume=${SERVICE_HOME}/data:/data")
736        );
737        // Check network injection happened
738        assert!(container_file.content.contains("Network=caddy.network"));
739        // ${SERVICE_HOME} bind mounts resolve to the service home dir.
740        let service_home = crate::service_home("myservice")
741            .unwrap_or_else(|e| unreachable!("service_home should resolve in tests: {e}"));
742        assert!(bundle.bind_mount_dirs.contains(&service_home.join("data")));
743
744        // Network file should NOT have network injection
745        let network_file = bundle
746            .quadlet_files
747            .iter()
748            .find(|f| f.path.to_string_lossy().ends_with(".network"))
749            .unwrap_or_else(|| unreachable!("network file must exist"));
750        assert!(!network_file.content.contains("Network=caddy.network"));
751    }
752
753    #[test]
754    fn process_quadlet_bundle_errors_on_empty_dir() {
755        let tmp = tempfile::tempdir()
756            .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
757        let service_dir = tmp.path().join("empty");
758        let quadlets_dir = service_dir.join("quadlets");
759        std::fs::create_dir_all(&quadlets_dir)
760            .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
761
762        let params = ProcessBundleParams {
763            service_dir: &service_dir,
764            service_name: "empty",
765            extra_networks: &[],
766            extra_volumes: &[],
767            podman_args: &[],
768            extra_exec_start_pre: &[],
769            port_names: &[],
770            excluded_quadlets: &[],
771        };
772        let err = process_quadlet_bundle(&params).unwrap_err();
773        assert!(err.to_string().contains("no quadlet files found"));
774    }
775
776    #[test]
777    fn process_quadlet_bundle_all_excluded_is_empty_not_error() {
778        // A service whose only quadlet is excluded by the chosen option (e.g.
779        // `database=external` dropping the bundled DB) must yield an empty
780        // bundle, not the "no quadlet files" error.
781        let tmp = tempfile::tempdir().unwrap();
782        let service_dir = tmp.path().join("svc");
783        let quadlets_dir = service_dir.join("quadlets");
784        std::fs::create_dir_all(&quadlets_dir).unwrap();
785        std::fs::write(
786            quadlets_dir.join("svc-db.container"),
787            "[Container]\nImage=docker.io/library/postgres:17-alpine\n",
788        )
789        .unwrap();
790
791        let excluded = vec!["svc-db.container".to_string()];
792        let params = ProcessBundleParams {
793            service_dir: &service_dir,
794            service_name: "svc",
795            extra_networks: &[],
796            extra_volumes: &[],
797            podman_args: &[],
798            extra_exec_start_pre: &[],
799            port_names: &[],
800            excluded_quadlets: &excluded,
801        };
802        let bundle =
803            process_quadlet_bundle(&params).expect("all-excluded should be Ok, not an error");
804        assert!(bundle.quadlet_files.is_empty());
805        assert!(bundle.images.is_empty());
806    }
807
808    #[test]
809    fn undeclared_port_var_errors() {
810        let tmp = tempfile::tempdir()
811            .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
812        let service_dir = tmp.path().join("svc");
813        let quadlets_dir = service_dir.join("quadlets");
814        std::fs::create_dir_all(&quadlets_dir)
815            .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
816        std::fs::write(
817            quadlets_dir.join("svc.container"),
818            "[Container]\nImage=nginx\nPublishPort=${SERVICE_PORT_HTPP}:80\n",
819        )
820        .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
821
822        let params = ProcessBundleParams {
823            service_dir: &service_dir,
824            service_name: "svc",
825            extra_networks: &[],
826            extra_volumes: &[],
827            podman_args: &[],
828            extra_exec_start_pre: &[],
829            port_names: &["http".to_string()],
830            excluded_quadlets: &[],
831        };
832        let err = process_quadlet_bundle(&params).unwrap_err();
833        assert!(err.to_string().contains("SERVICE_PORT_HTPP"));
834        assert!(err.to_string().contains("no [[ports]] entry"));
835    }
836
837    #[test]
838    fn service_vars_without_service_envfile_errors() {
839        let tmp = tempfile::tempdir()
840            .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
841        let service_dir = tmp.path().join("svc");
842        let quadlets_dir = service_dir.join("quadlets");
843        std::fs::create_dir_all(&quadlets_dir)
844            .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
845        // ${SERVICE_HOME} volume but EnvironmentFile only in [Container]:
846        // expansion happens from service-level env, so this must error.
847        std::fs::write(
848            quadlets_dir.join("svc.container"),
849            "[Container]\nImage=nginx\nVolume=${SERVICE_HOME}/data:/data\nEnvironmentFile=%h/.local/share/services/svc/.env\n\n[Service]\nRestart=always\n",
850        )
851        .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
852
853        let params = ProcessBundleParams {
854            service_dir: &service_dir,
855            service_name: "svc",
856            extra_networks: &[],
857            extra_volumes: &[],
858            podman_args: &[],
859            extra_exec_start_pre: &[],
860            port_names: &["http".to_string()],
861            excluded_quadlets: &[],
862        };
863        let err = process_quadlet_bundle(&params).unwrap_err();
864        assert!(err.to_string().contains("[Service] section"));
865    }
866
867    #[test]
868    fn leftover_template_syntax_errors() {
869        let tmp = tempfile::tempdir()
870            .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
871        let service_dir = tmp.path().join("svc");
872        let quadlets_dir = service_dir.join("quadlets");
873        std::fs::create_dir_all(&quadlets_dir)
874            .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
875        std::fs::write(
876            quadlets_dir.join("svc.container"),
877            "[Container]\nImage=nginx\nPublishPort={{ports.http}}:80\n",
878        )
879        .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
880
881        let params = ProcessBundleParams {
882            service_dir: &service_dir,
883            service_name: "svc",
884            extra_networks: &[],
885            extra_volumes: &[],
886            podman_args: &[],
887            extra_exec_start_pre: &[],
888            port_names: &["http".to_string()],
889            excluded_quadlets: &[],
890        };
891        let err = process_quadlet_bundle(&params).unwrap_err();
892        assert!(err.to_string().contains("plain podman files"));
893    }
894
895    #[test]
896    fn process_configs_reads_recursively() {
897        let tmp = tempfile::tempdir()
898            .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
899        let service_dir = tmp.path().join("svc");
900        let configs_dir = service_dir.join("configs");
901        let sub_dir = configs_dir.join("subdir");
902        std::fs::create_dir_all(&sub_dir)
903            .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
904
905        std::fs::write(configs_dir.join("main.conf"), "data_dir=/some/path\n")
906            .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
907
908        std::fs::write(sub_dir.join("nested.conf"), "no placeholders\n")
909            .unwrap_or_else(|e| unreachable!("write should not fail in tests: {e}"));
910
911        let service_home = Path::new("/home/user/.local/share/services/svc");
912
913        let files = process_configs(&service_dir, service_home)
914            .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
915
916        assert_eq!(files.len(), 2);
917
918        let main_conf = files
919            .iter()
920            .find(|f| f.path.ends_with("main.conf"))
921            .unwrap_or_else(|| unreachable!("main.conf must exist"));
922        assert_eq!(
923            main_conf.path,
924            PathBuf::from("/home/user/.local/share/services/svc/configs/main.conf")
925        );
926        assert!(main_conf.content.contains("/some/path"));
927
928        let nested_conf = files
929            .iter()
930            .find(|f| f.path.ends_with("nested.conf"))
931            .unwrap_or_else(|| unreachable!("nested.conf must exist"));
932        assert_eq!(
933            nested_conf.path,
934            PathBuf::from("/home/user/.local/share/services/svc/configs/subdir/nested.conf")
935        );
936        assert_eq!(nested_conf.content, "no placeholders\n");
937    }
938
939    #[test]
940    fn extract_bind_mount_dirs_finds_host_paths() {
941        let home = std::env::var("HOME").unwrap_or_else(|_| "/home/test".to_string());
942        let files = vec![
943            GeneratedFile {
944                path: PathBuf::from("/q/immich.container"),
945                content: "Volume=${SERVICE_HOME}/upload:/data:Z\nVolume=%h/backups:/backups:Z\nVolume=immich-db-data.volume:/var/lib/postgresql/data:U\n".to_string(),
946            },
947            GeneratedFile {
948                path: PathBuf::from("/q/immich.network"),
949                content: "[Network]\n".to_string(),
950            },
951        ];
952        let service_home = PathBuf::from(format!("{home}/.local/share/services/immich"));
953        let dirs = extract_bind_mount_dirs(&files, &service_home).unwrap();
954        assert_eq!(
955            dirs,
956            vec![
957                service_home.join("upload"),
958                PathBuf::from(format!("{home}/backups")),
959            ]
960        );
961    }
962
963    #[test]
964    fn extract_bind_mount_dirs_skips_named_volumes() {
965        let files = vec![GeneratedFile {
966            path: PathBuf::from("/q/svc.container"),
967            content: "Volume=svc-data.volume:/data:U\n".to_string(),
968        }];
969        let dirs = extract_bind_mount_dirs(&files, Path::new("/srv/svc")).unwrap();
970        assert!(dirs.is_empty());
971    }
972
973    #[test]
974    fn extract_bind_mount_dirs_skips_file_mounts() {
975        let files = vec![GeneratedFile {
976            path: PathBuf::from("/q/svc.container"),
977            content: "Volume=/path/to/ca.crt:/etc/ssl/certs/ca.crt:ro,Z\nVolume=/path/to/config:/config:Z\n".to_string(),
978        }];
979        let dirs = extract_bind_mount_dirs(&files, Path::new("/srv/svc")).unwrap();
980        // Only the directory mount, not the .crt file mount
981        assert_eq!(dirs, vec![PathBuf::from("/path/to/config")]);
982    }
983
984    #[test]
985    fn process_configs_returns_empty_when_no_configs_dir() {
986        let tmp = tempfile::tempdir()
987            .unwrap_or_else(|e| unreachable!("tempdir creation should not fail in tests: {e}"));
988        let service_dir = tmp.path().join("svc");
989        std::fs::create_dir_all(&service_dir)
990            .unwrap_or_else(|e| unreachable!("dir creation should not fail in tests: {e}"));
991
992        let files = process_configs(
993            &service_dir,
994            Path::new("/home/user/.local/share/services/svc"),
995        )
996        .unwrap_or_else(|e| unreachable!("process_configs should not fail: {e}"));
997
998        assert!(files.is_empty());
999    }
1000}