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