Skip to main content

ryra_test/
registry.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5use crate::test_toml::{StepDef, TestToml};
6
7/// A discovered test suite — either simple (setup + assertions) or lifecycle (interleaved steps).
8#[derive(Debug, Clone)]
9pub enum DiscoveredTest {
10    /// Simple tests: setup services/quadlets, then run assertions.
11    Simple {
12        name: String,
13        /// The test.toml this test was loaded from. Used by `--list` to
14        /// group tests by file and show where to edit them.
15        source: PathBuf,
16        setup: SetupConfig,
17        tests: Vec<TestEntry>,
18        browser: bool,
19        ram_override: Option<u32>,
20        requires_sudo: bool,
21    },
22    /// Lifecycle tests: interleaved actions and assertions.
23    Lifecycle {
24        name: String,
25        source: PathBuf,
26        steps: Vec<StepDef>,
27        browser: bool,
28        ram_override: Option<u32>,
29        requires_sudo: bool,
30    },
31}
32
33#[derive(Debug, Clone, Default)]
34pub struct SetupConfig {
35    pub services: Vec<String>,
36    pub quadlets: Vec<String>,
37    pub quadlet_dir: Option<PathBuf>,
38}
39
40impl DiscoveredTest {
41    pub fn name(&self) -> &str {
42        match self {
43            DiscoveredTest::Simple { name, .. } => name,
44            DiscoveredTest::Lifecycle { name, .. } => name,
45        }
46    }
47
48    /// Path to the `test.toml` this test was discovered in. Same-file
49    /// tests share this path — used by `--list` to group and show
50    /// editable paths.
51    pub fn source(&self) -> &Path {
52        match self {
53            DiscoveredTest::Simple { source, .. } => source,
54            DiscoveredTest::Lifecycle { source, .. } => source,
55        }
56    }
57
58    /// Distinct step action kinds in order of first appearance. Used to
59    /// summarize what a test does on `--list` (e.g., "add → wait → http →
60    /// playwright" tells you it's a browser test without reading the file).
61    pub fn step_kinds(&self) -> Vec<&'static str> {
62        let mut kinds: Vec<&'static str> = Vec::new();
63        let push = |k: &'static str, v: &mut Vec<&'static str>| {
64            if !v.contains(&k) {
65                v.push(k);
66            }
67        };
68        match self {
69            DiscoveredTest::Lifecycle { steps, .. } => {
70                for step in steps {
71                    let kind = match step {
72                        StepDef::Add { .. } => "add",
73                        StepDef::Remove { .. } => "remove",
74                        StepDef::Wait { .. } => "wait",
75                        StepDef::Shell { .. } => "shell",
76                        StepDef::Http { .. } => "http",
77                        StepDef::Playwright { .. } => "playwright",
78                        StepDef::Mail { .. } => "mail",
79                    };
80                    push(kind, &mut kinds);
81                }
82            }
83            DiscoveredTest::Simple { setup, tests, .. } => {
84                if !setup.services.is_empty() || !setup.quadlets.is_empty() {
85                    push("setup", &mut kinds);
86                }
87                if !tests.is_empty() {
88                    push("shell", &mut kinds);
89                }
90            }
91        }
92        kinds
93    }
94
95    /// All services that need to be deployed for this test, in install order.
96    pub fn services(&self) -> Vec<&str> {
97        match self {
98            DiscoveredTest::Simple { setup, .. } => {
99                setup.services.iter().map(|s| s.as_str()).collect()
100            }
101            DiscoveredTest::Lifecycle { steps, .. } => {
102                let mut svcs = Vec::new();
103                for step in steps {
104                    if let StepDef::Add { service, .. } = step
105                        && !svcs.contains(&service.as_str())
106                    {
107                        svcs.push(service.as_str());
108                    }
109                }
110                svcs
111            }
112        }
113    }
114
115    pub fn tests(&self) -> &[TestEntry] {
116        match self {
117            DiscoveredTest::Simple { tests, .. } => tests,
118            DiscoveredTest::Lifecycle { .. } => &[],
119        }
120    }
121
122    pub fn test_count(&self) -> usize {
123        match self {
124            DiscoveredTest::Simple { tests, .. } => tests.len(),
125            DiscoveredTest::Lifecycle { steps, .. } => steps.len(),
126        }
127    }
128
129    #[allow(dead_code)]
130    pub fn summary(&self) -> String {
131        match self {
132            DiscoveredTest::Simple { name, setup, .. } => {
133                if setup.services.is_empty() {
134                    name.clone()
135                } else {
136                    format!("{} ({})", name, setup.services.join(" + "))
137                }
138            }
139            DiscoveredTest::Lifecycle { name, steps, .. } => {
140                format!("{} ({} steps)", name, steps.len())
141            }
142        }
143    }
144
145    pub fn is_lifecycle(&self) -> bool {
146        matches!(self, DiscoveredTest::Lifecycle { .. })
147    }
148
149    pub fn has_quadlets(&self) -> bool {
150        match self {
151            DiscoveredTest::Simple { setup, .. } => !setup.quadlets.is_empty(),
152            DiscoveredTest::Lifecycle { .. } => false,
153        }
154    }
155
156    pub fn needs_browser(&self) -> bool {
157        match self {
158            DiscoveredTest::Simple { browser, .. } => *browser,
159            DiscoveredTest::Lifecycle { browser, .. } => *browser,
160        }
161    }
162
163    /// Whether this test declared `requires_sudo` (file- or test-level).
164    pub fn requires_sudo(&self) -> bool {
165        match self {
166            DiscoveredTest::Simple { requires_sudo, .. } => *requires_sudo,
167            DiscoveredTest::Lifecycle { requires_sudo, .. } => *requires_sudo,
168        }
169    }
170
171    pub fn ram_override(&self) -> Option<u32> {
172        match self {
173            DiscoveredTest::Simple { ram_override, .. } => *ram_override,
174            DiscoveredTest::Lifecycle { ram_override, .. } => *ram_override,
175        }
176    }
177}
178
179#[allow(dead_code)]
180#[derive(Debug, Clone)]
181pub struct TestEntry {
182    pub name: String,
183    pub run: String,
184    pub timeout_secs: u64,
185    /// Env var overrides to pass to `ryra add` for this test.
186    pub env: std::collections::BTreeMap<String, String>,
187}
188
189/// Discover tests from a local project directory containing quadlet files + test.toml.
190///
191/// Returns `None` if the directory doesn't contain a test.toml.
192pub fn discover_local_project(project_dir: &Path) -> Result<Option<DiscoveredTest>> {
193    let test_toml_path = project_dir.join("test.toml");
194    if !test_toml_path.exists() {
195        return Ok(None);
196    }
197
198    let parsed = TestToml::parse(&test_toml_path)?;
199
200    // Find all quadlet files in the project directory
201    let quadlet_extensions = ["container", "volume", "network", "pod", "kube"];
202    let mut quadlet_files = Vec::new();
203    let entries = std::fs::read_dir(project_dir)
204        .with_context(|| format!("failed to read {}", project_dir.display()))?;
205    for entry in entries {
206        let entry = entry?;
207        let path = entry.path();
208        if let Some(ext) = path.extension().and_then(|e| e.to_str())
209            && quadlet_extensions.contains(&ext)
210            && let Some(name) = path.file_name().and_then(|n| n.to_str())
211        {
212            quadlet_files.push(name.to_string());
213        }
214    }
215
216    if quadlet_files.is_empty() {
217        anyhow::bail!(
218            "test.toml found at {} but no quadlet files (.container, .volume, .network, .pod) in the same directory",
219            test_toml_path.display()
220        );
221    }
222
223    let project_dir = std::fs::canonicalize(project_dir)
224        .with_context(|| format!("failed to canonicalize {}", project_dir.display()))?;
225
226    // Infer directory name for defaults
227    let dir_name = project_dir
228        .file_name()
229        .and_then(|n| n.to_str())
230        .unwrap_or("project")
231        .to_string();
232
233    let mut tests =
234        discover_from_test_toml(&test_toml_path, &parsed, &dir_name, Some(&project_dir))?;
235
236    // Local projects are a single-file, single-test concept — a `.container`
237    // directory with a `test.toml` is expected to describe exactly one test
238    // suite. The new multi-test format is a registry-level feature.
239    if tests.len() != 1 {
240        anyhow::bail!(
241            "local project test.toml must describe exactly one test (got {}); \
242             multi-test [[tests]] arrays are only supported inside the registry",
243            tests.len()
244        );
245    }
246    let mut test = tests.remove(0);
247
248    // Populate quadlets from discovered files if not explicitly set in [setup]
249    if let DiscoveredTest::Simple { ref mut setup, .. } = test
250        && setup.quadlets.is_empty()
251        && !quadlet_files.is_empty()
252    {
253        setup.quadlets = quadlet_files;
254    }
255
256    Ok(Some(test))
257}
258
259/// Scan a registry directory for all test definitions.
260///
261/// Reads `test.toml` from each `<service>/test.toml` and standalone test
262/// files from `tests/*.toml`.
263pub fn discover(registry_path: &Path) -> Result<Vec<DiscoveredTest>> {
264    let mut discovered = Vec::new();
265
266    // Scan service directories for test.toml
267    let entries = std::fs::read_dir(registry_path)
268        .with_context(|| format!("failed to read registry at {}", registry_path.display()))?;
269
270    for entry in entries {
271        let entry = entry?;
272        let path = entry.path();
273
274        // Skip the tests/ directory and hidden files
275        if !path.is_dir() {
276            continue;
277        }
278        let dir_name = match path.file_name().and_then(|n| n.to_str()) {
279            Some(n) => n.to_string(),
280            None => continue,
281        };
282        if dir_name == "tests" || dir_name.starts_with('.') {
283            continue;
284        }
285
286        let test_toml_path = path.join("test.toml");
287        if !test_toml_path.exists() {
288            continue;
289        }
290
291        match TestToml::parse(&test_toml_path) {
292            Ok(parsed) => {
293                match discover_from_test_toml(&test_toml_path, &parsed, &dir_name, None) {
294                    Ok(tests) => discovered.extend(tests),
295                    Err(e) => {
296                        eprintln!(
297                            "warning: failed to process {}: {e}",
298                            test_toml_path.display()
299                        );
300                    }
301                }
302            }
303            Err(e) => {
304                eprintln!("warning: failed to parse {}: {e}", test_toml_path.display());
305            }
306        }
307    }
308
309    // Scan tests/ directory for standalone test files
310    let tests_dir = registry_path.join("tests");
311    if tests_dir.is_dir() {
312        let entries = std::fs::read_dir(&tests_dir)
313            .with_context(|| format!("failed to read {}", tests_dir.display()))?;
314
315        for entry in entries {
316            let entry = entry?;
317            let path = entry.path();
318
319            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
320                continue;
321            }
322
323            let file_stem = path
324                .file_stem()
325                .and_then(|s| s.to_str())
326                .unwrap_or("unknown")
327                .to_string();
328
329            match TestToml::parse(&path) {
330                Ok(parsed) => match discover_from_test_toml(&path, &parsed, &file_stem, None) {
331                    Ok(tests) => discovered.extend(tests),
332                    Err(e) => {
333                        eprintln!("warning: failed to process {}: {e}", path.display());
334                    }
335                },
336                Err(e) => {
337                    eprintln!("warning: failed to parse {}: {e}", path.display());
338                }
339            }
340        }
341    }
342
343    // Sort by name for deterministic ordering
344    discovered.sort_by(|a, b| a.name().cmp(b.name()));
345
346    Ok(discovered)
347}
348
349/// Convert a parsed TestToml into a DiscoveredTest.
350///
351/// `service_name_hint` is used as the default test name and (for service-level test.toml)
352/// as the default setup service when no [setup] section exists.
353/// `quadlet_dir` is set for local project tests to point to the project directory.
354/// Convert a parsed `test.toml` into one-or-more `DiscoveredTest`s.
355///
356/// Three file shapes are handled:
357///
358/// 1. **New multi-test**: one-or-more `[[tests]]` entries each with their
359///    own `[[tests.steps]]`. Each becomes its own `Lifecycle` test, named
360///    as `<service>-<test-name>` (or just `<test-name>` for cross-cutting
361///    files in `registry/tests/`).
362/// 2. **Legacy lifecycle**: top-level `[[steps]]`, optionally with
363///    `[test] name = …`. One `Lifecycle` test per file.
364/// 3. **Legacy shell**: `[setup]` + `[[tests]] run = …`. One `Simple`
365///    test per file, multiple assertion steps.
366fn discover_from_test_toml(
367    path: &Path,
368    parsed: &TestToml,
369    service_name_hint: &str,
370    quadlet_dir: Option<&Path>,
371) -> Result<Vec<DiscoveredTest>> {
372    // --- Shape 1: new multi-test format ---
373    let new_format_tests: Vec<&crate::test_toml::TestDef> = parsed
374        .tests
375        .iter()
376        .filter(|t| !t.steps.is_empty())
377        .collect();
378    if !new_format_tests.is_empty() {
379        // Decide how to namespace. For a service's own test.toml (under
380        // registry/<svc>/), prefix test names with the service so each
381        // test is globally addressable (`ryra test forgejo-smtp`). For
382        // cross-cutting tests under registry/tests/, the file stem is
383        // already unique; use the test name as-is.
384        let is_service_owned = path
385            .parent()
386            .and_then(|p| p.file_name())
387            .and_then(|n| n.to_str())
388            == Some(service_name_hint);
389
390        let mut out = Vec::with_capacity(new_format_tests.len());
391        for t in new_format_tests {
392            let qualified = if is_service_owned && t.name != service_name_hint {
393                format!("{service_name_hint}-{}", t.name)
394            } else {
395                t.name.clone()
396            };
397            out.push(DiscoveredTest::Lifecycle {
398                name: qualified,
399                source: path.to_path_buf(),
400                steps: t.steps.clone(),
401                browser: t.browser || parsed.needs_browser(),
402                ram_override: t.ram.or(parsed.ram_override()),
403                requires_sudo: t.requires_sudo || parsed.requires_sudo(),
404            });
405        }
406        return Ok(out);
407    }
408
409    // --- Shape 2: legacy lifecycle ---
410    let name = parsed.name_or_default(path);
411    let test_name = if parsed.test.as_ref().and_then(|t| t.name.as_ref()).is_some() {
412        name
413    } else {
414        service_name_hint.to_string()
415    };
416    let browser = parsed.needs_browser();
417    let ram_override = parsed.ram_override();
418    let requires_sudo = parsed.requires_sudo();
419
420    if parsed.is_lifecycle() {
421        return Ok(vec![DiscoveredTest::Lifecycle {
422            name: test_name,
423            source: path.to_path_buf(),
424            steps: parsed.steps.clone(),
425            browser,
426            ram_override,
427            requires_sudo,
428        }]);
429    }
430
431    // --- Shape 3: legacy shell-style ---
432    let setup = match &parsed.setup {
433        Some(s) => SetupConfig {
434            services: s.services.clone(),
435            quadlets: s.quadlets.clone(),
436            quadlet_dir: quadlet_dir.map(PathBuf::from),
437        },
438        None => SetupConfig {
439            services: vec![service_name_hint.to_string()],
440            quadlets: Vec::new(),
441            quadlet_dir: quadlet_dir.map(PathBuf::from),
442        },
443    };
444
445    let tests = parsed
446        .tests
447        .iter()
448        .map(|t| TestEntry {
449            name: t.name.clone(),
450            run: t.run.clone().unwrap_or_default(),
451            timeout_secs: t.timeout,
452            env: t.env.clone(),
453        })
454        .collect();
455
456    Ok(vec![DiscoveredTest::Simple {
457        name: test_name,
458        source: path.to_path_buf(),
459        setup,
460        tests,
461        browser,
462        ram_override,
463        requires_sudo,
464    }])
465}
466
467/// Look up the recommended RAM (MB) for a service from its service.toml.
468pub fn service_recommended_ram(registry_path: &Path, service_name: &str) -> Result<Option<u64>> {
469    let service_toml = registry_path.join(service_name).join("service.toml");
470    if !service_toml.exists() {
471        return Ok(None);
472    }
473    let content = std::fs::read_to_string(&service_toml)
474        .with_context(|| format!("failed to read {}", service_toml.display()))?;
475    let parsed: ServiceTomlRam = toml::from_str(&content)
476        .with_context(|| format!("failed to parse {}", service_toml.display()))?;
477    Ok(parsed.requirements.and_then(|r| r.ram.recommended))
478}
479
480/// Compute the VM memory (MB) needed for a test based on its services'
481/// recommended RAM. Adds 512MB OS overhead, rounds up to 512MB increments,
482/// with a 1024MB floor.
483pub fn vm_memory_for_test(registry_path: &Path, test: &DiscoveredTest) -> u32 {
484    if let Some(ram) = test.ram_override() {
485        return ram;
486    }
487
488    let services: Vec<&str> = match test {
489        DiscoveredTest::Lifecycle { steps, .. } => steps
490            .iter()
491            .filter_map(|s| match s {
492                StepDef::Add { service, .. } => Some(service.as_str()),
493                // Also detect `ryra add <service>` in run steps
494                StepDef::Shell { run, .. } => run
495                    .split_whitespace()
496                    .collect::<Vec<_>>()
497                    .windows(3)
498                    .find(|w| w[0] == "ryra" && w[1] == "add")
499                    .map(|w| w[2]),
500                _ => None,
501            })
502            .collect(),
503        _ => test.services(),
504    };
505
506    let service_ram: u64 = services
507        .iter()
508        .map(|svc| {
509            service_recommended_ram(registry_path, svc)
510                .ok()
511                .flatten()
512                .unwrap_or(128) // default if not specified
513        })
514        .sum();
515
516    // Browser tests need extra memory for chromium + playwright
517    let browser_overhead = if test.needs_browser() { 512 } else { 0 };
518    let total = service_ram + 512 + browser_overhead; // OS/podman + browser overhead
519    let rounded = total.div_ceil(512) * 512; // round up to 512MB
520    rounded.max(1024) as u32
521}
522
523/// Look up the minimum disk (GB) for a service from its service.toml.
524pub fn service_min_disk(registry_path: &Path, service_name: &str) -> Result<Option<u32>> {
525    let service_toml = registry_path.join(service_name).join("service.toml");
526    if !service_toml.exists() {
527        return Ok(None);
528    }
529    let content = std::fs::read_to_string(&service_toml)
530        .with_context(|| format!("failed to read {}", service_toml.display()))?;
531    let parsed: ServiceTomlDisk = toml::from_str(&content)
532        .with_context(|| format!("failed to parse {}", service_toml.display()))?;
533    Ok(parsed.requirements.and_then(|r| r.disk).map(|d| d.min))
534}
535
536/// Compute the VM disk size (GB) needed for a test. Takes the max of all
537/// services' disk requirements, with a 20GB floor.
538pub fn vm_disk_for_test(registry_path: &Path, test: &DiscoveredTest) -> u32 {
539    let services: Vec<&str> = match test {
540        DiscoveredTest::Lifecycle { steps, .. } => steps
541            .iter()
542            .filter_map(|s| match s {
543                StepDef::Add { service, .. } => Some(service.as_str()),
544                StepDef::Shell { run, .. } => run
545                    .split_whitespace()
546                    .collect::<Vec<_>>()
547                    .windows(3)
548                    .find(|w| w[0] == "ryra" && w[1] == "add")
549                    .map(|w| w[2]),
550                _ => None,
551            })
552            .collect(),
553        _ => test.services(),
554    };
555
556    let max_disk: u32 = services
557        .iter()
558        .filter_map(|svc| service_min_disk(registry_path, svc).ok().flatten())
559        .max()
560        .unwrap_or(0);
561
562    max_disk.max(20)
563}
564
565/// Look up the primary container image for a service from its quadlet files.
566pub fn service_image(registry_path: &Path, service_name: &str) -> Result<Option<String>> {
567    let images = service_images(registry_path, service_name);
568    Ok(images.into_iter().next())
569}
570
571/// Parse a `ryra add <svc> …` args string and return the well-known service
572/// names it *implicitly* pulls in (so the caller can pre-cache their images).
573///
574/// - `--smtp=<name>` / `--smtp <name>` → `<name>` (typically `inbucket`)
575/// - `--auth` → `authelia`
576/// - `--domain …` / `--url …` → `caddy`
577///
578/// Unknown flag values pass through untouched; the caller decides what's real.
579fn implied_services_from_args(args: &str) -> Vec<&str> {
580    let tokens: Vec<&str> = args.split_whitespace().collect();
581    let mut out: Vec<&str> = Vec::new();
582
583    let push = |svc: &'static str, out: &mut Vec<&str>| {
584        if !out.contains(&svc) {
585            out.push(svc);
586        }
587    };
588
589    let mut i = 0;
590    while i < tokens.len() {
591        let t = tokens[i];
592        if let Some(val) = t.strip_prefix("--smtp=") {
593            if !val.is_empty() && !out.contains(&val) {
594                out.push(val);
595            }
596        } else if t == "--smtp" {
597            if let Some(val) = tokens.get(i + 1)
598                && !val.starts_with("--")
599                && !out.contains(val)
600            {
601                out.push(val);
602                i += 1;
603            }
604        } else if t == "--auth" || t.starts_with("--auth=") {
605            push("authelia", &mut out);
606        } else if t == "--domain"
607            || t.starts_with("--domain=")
608            || t == "--url"
609            || t.starts_with("--url=")
610        {
611            push("caddy", &mut out);
612        }
613        i += 1;
614    }
615    out
616}
617
618/// Get all container images for a service by scanning its `quadlets/` directory.
619pub fn service_images(registry_path: &Path, service_name: &str) -> Vec<String> {
620    let quadlets_dir = registry_path.join(service_name).join("quadlets");
621    let mut images = Vec::new();
622    if let Ok(entries) = std::fs::read_dir(&quadlets_dir) {
623        for entry in entries.flatten() {
624            let path = entry.path();
625            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
626            if !name.ends_with(".container") {
627                continue;
628            }
629            if let Ok(content) = std::fs::read_to_string(&path) {
630                for line in content.lines() {
631                    let trimmed = line.trim();
632                    if let Some(image) = trimmed.strip_prefix("Image=") {
633                        let image = image.trim();
634                        if !image.is_empty() && !images.contains(&image.to_string()) {
635                            images.push(image.to_string());
636                        }
637                    }
638                }
639            }
640        }
641    }
642    images
643}
644
645/// Get all container images needed for a test.
646pub fn images_for_test(registry_path: &Path, test: &DiscoveredTest) -> Vec<String> {
647    let mut images = Vec::new();
648    let add_image = |img: String, out: &mut Vec<String>| {
649        if !out.contains(&img) {
650            out.push(img);
651        }
652    };
653    let include_service = |svc: &str, out: &mut Vec<String>| {
654        for image in service_images(registry_path, svc) {
655            add_image(image, out);
656        }
657    };
658
659    match test {
660        DiscoveredTest::Lifecycle { steps, .. } => {
661            for step in steps {
662                match step {
663                    StepDef::Add { service, args, .. } => {
664                        include_service(service, &mut images);
665                        // `ryra add <svc> --smtp=<mail> --auth --domain …` pulls
666                        // additional well-known services (inbucket, authelia,
667                        // caddy) in *without* explicit add steps. We need to
668                        // pre-cache their images too, otherwise the in-VM
669                        // podman pull hits the public registry — and any
670                        // Docker Hub flake there fails the test.
671                        if let Some(args_str) = args {
672                            for implied in implied_services_from_args(args_str) {
673                                include_service(implied, &mut images);
674                            }
675                        }
676                    }
677                    StepDef::Shell { run, .. } => {
678                        // Parse "ryra add <service>" from run commands.
679                        let tokens: Vec<&str> = run.split_whitespace().collect();
680                        if let Some(idx) = tokens
681                            .windows(2)
682                            .position(|w| w[0] == "ryra" && w[1] == "add")
683                        {
684                            if let Some(svc) = tokens.get(idx + 2) {
685                                include_service(svc, &mut images);
686                            }
687                            // Also sweep any --smtp=<x> / --auth / --url flags
688                            // that appear further along in the same command.
689                            let rest = &tokens[idx + 2..];
690                            for implied in implied_services_from_args(&rest.join(" ")) {
691                                include_service(implied, &mut images);
692                            }
693                        }
694                    }
695                    _ => {}
696                }
697            }
698        }
699        DiscoveredTest::Simple { setup, .. } => {
700            // Images from registry services
701            for service in &setup.services {
702                for image in service_images(registry_path, service) {
703                    if !images.contains(&image) {
704                        images.push(image);
705                    }
706                }
707            }
708            // Images from quadlet files in the quadlet_dir
709            if let Some(ref dir) = setup.quadlet_dir {
710                for quadlet in &setup.quadlets {
711                    let full_path = dir.join(quadlet);
712                    if quadlet.ends_with(".container")
713                        && let Ok(content) = std::fs::read_to_string(&full_path)
714                    {
715                        for line in content.lines() {
716                            let trimmed = line.trim();
717                            if let Some(image) = trimmed.strip_prefix("Image=") {
718                                let image = image.trim();
719                                if !image.is_empty() && !images.contains(&image.to_string()) {
720                                    images.push(image.to_string());
721                                }
722                            }
723                        }
724                    }
725                }
726            }
727        }
728    }
729
730    images
731}
732
733// ---------------------------------------------------------------------------
734// Lightweight TOML structs for parsing service.toml (avoids full ServiceDef dependency)
735// ---------------------------------------------------------------------------
736
737#[derive(serde::Deserialize)]
738struct ServiceTomlRam {
739    #[serde(default)]
740    requirements: Option<RequirementsRam>,
741}
742
743#[derive(serde::Deserialize)]
744struct ServiceTomlDisk {
745    #[serde(default)]
746    requirements: Option<RequirementsDisk>,
747}
748
749#[derive(serde::Deserialize)]
750struct RequirementsDisk {
751    #[serde(default)]
752    disk: Option<DiskFields>,
753}
754
755#[derive(serde::Deserialize)]
756struct DiskFields {
757    min: u32,
758}
759
760#[derive(serde::Deserialize)]
761struct RequirementsRam {
762    ram: RamFields,
763}
764
765#[derive(serde::Deserialize)]
766struct RamFields {
767    #[serde(default)]
768    recommended: Option<u64>,
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    #[test]
776    fn discover_simple_test_from_test_toml() {
777        let dir = tempfile::tempdir().unwrap();
778        let test_toml = dir.path().join("test.toml");
779        std::fs::write(
780            &test_toml,
781            r#"
782[[tests]]
783name = "responds"
784run = "curl -sf http://127.0.0.1:$SERVICE_PORT_HTTP"
785
786[[tests]]
787name = "hostname"
788run = "curl -s http://127.0.0.1:$SERVICE_PORT_HTTP | grep -q Hostname"
789timeout = 10
790"#,
791        )
792        .unwrap();
793
794        let parsed = TestToml::parse(&test_toml).unwrap();
795        let mut out = discover_from_test_toml(&test_toml, &parsed, "whoami", None).unwrap();
796        assert_eq!(
797            out.len(),
798            1,
799            "legacy shell form produces a single Simple test"
800        );
801        let test = out.remove(0);
802
803        assert_eq!(test.name(), "whoami");
804        assert!(!test.is_lifecycle());
805        assert_eq!(test.test_count(), 2);
806        assert_eq!(test.services(), vec!["whoami"]); // inferred from hint
807        assert_eq!(test.tests()[0].name, "responds");
808        assert_eq!(test.tests()[1].timeout_secs, 10);
809    }
810
811    #[test]
812    fn discover_simple_test_with_setup() {
813        let dir = tempfile::tempdir().unwrap();
814        let test_toml = dir.path().join("test.toml");
815        std::fs::write(
816            &test_toml,
817            r#"
818[test]
819name = "whoami-plus-postgres"
820
821[setup]
822services = ["whoami", "postgres"]
823
824[[tests]]
825name = "both-running"
826run = "echo ok"
827"#,
828        )
829        .unwrap();
830
831        let parsed = TestToml::parse(&test_toml).unwrap();
832        let mut out = discover_from_test_toml(&test_toml, &parsed, "combo", None).unwrap();
833        assert_eq!(out.len(), 1);
834        let test = out.remove(0);
835
836        assert_eq!(test.name(), "whoami-plus-postgres");
837        assert_eq!(test.services(), vec!["whoami", "postgres"]);
838        assert_eq!(test.test_count(), 1);
839    }
840
841    #[test]
842    fn discover_lifecycle_test() {
843        let dir = tempfile::tempdir().unwrap();
844        let test_toml = dir.path().join("test.toml");
845        std::fs::write(
846            &test_toml,
847            r#"
848[test]
849name = "remove-test"
850
851[[steps]]
852action = "add"
853service = "whoami"
854
855[[steps]]
856action = "wait"
857service = "whoami"
858
859[[steps]]
860action = "shell"
861name = "responds"
862run = "curl -sf http://localhost"
863
864[[steps]]
865action = "remove"
866service = "whoami"
867
868[[steps]]
869action = "shell"
870name = "gone"
871run = "! id whoami"
872"#,
873        )
874        .unwrap();
875
876        let parsed = TestToml::parse(&test_toml).unwrap();
877        let mut out = discover_from_test_toml(&test_toml, &parsed, "remove-test", None).unwrap();
878        assert_eq!(out.len(), 1);
879        let test = out.remove(0);
880
881        assert_eq!(test.name(), "remove-test");
882        assert!(test.is_lifecycle());
883        assert_eq!(test.test_count(), 5);
884        assert_eq!(test.services(), vec!["whoami"]); // collected from Add steps
885    }
886
887    #[test]
888    fn discover_lifecycle_with_args() {
889        let dir = tempfile::tempdir().unwrap();
890        let test_toml = dir.path().join("test.toml");
891        std::fs::write(
892            &test_toml,
893            r#"
894[test]
895name = "auth-test"
896
897[[steps]]
898action = "add"
899service = "caddy"
900args = "--domain proxy.test.local"
901
902[[steps]]
903action = "shell"
904name = "caddy up"
905run = "curl -sf http://proxy.test.local"
906"#,
907        )
908        .unwrap();
909
910        let parsed = TestToml::parse(&test_toml).unwrap();
911        let mut out = discover_from_test_toml(&test_toml, &parsed, "auth-test", None).unwrap();
912        assert_eq!(out.len(), 1);
913        let test = out.remove(0);
914
915        assert!(test.is_lifecycle());
916        if let DiscoveredTest::Lifecycle { steps, .. } = &test {
917            if let StepDef::Add { service, args, .. } = &steps[0] {
918                assert_eq!(service, "caddy");
919                assert_eq!(args.as_deref(), Some("--domain proxy.test.local"));
920            } else {
921                panic!("expected Add step");
922            }
923        } else {
924            panic!("expected Lifecycle variant");
925        }
926    }
927
928    #[test]
929    fn discover_multi_test_service_owned() {
930        // Simulate a service-owned test.toml (path: .../whoami/test.toml)
931        // with three named tests, each bringing their own steps.
932        let dir = tempfile::tempdir().unwrap();
933        let svc_dir = dir.path().join("whoami");
934        std::fs::create_dir(&svc_dir).unwrap();
935        let test_toml = svc_dir.join("test.toml");
936        std::fs::write(
937            &test_toml,
938            r#"
939[[tests]]
940name = "whoami"
941[[tests.steps]]
942action = "add"
943service = "whoami"
944[[tests.steps]]
945action = "wait"
946service = "whoami"
947
948[[tests]]
949name = "diff"
950[[tests.steps]]
951action = "add"
952service = "whoami"
953[[tests.steps]]
954action = "shell"
955name = "idempotent"
956run = "true"
957
958[[tests]]
959name = "remove"
960[[tests.steps]]
961action = "add"
962service = "whoami"
963[[tests.steps]]
964action = "remove"
965service = "whoami"
966"#,
967        )
968        .unwrap();
969
970        let parsed = TestToml::parse(&test_toml).unwrap();
971        let out = discover_from_test_toml(&test_toml, &parsed, "whoami", None).unwrap();
972        assert_eq!(out.len(), 3);
973
974        // Test name equal to service name stays un-prefixed. Others get
975        // `<service>-<test>` so they're uniquely addressable on the CLI.
976        let names: Vec<&str> = out.iter().map(|t| t.name()).collect();
977        assert_eq!(names, vec!["whoami", "whoami-diff", "whoami-remove"]);
978        for t in &out {
979            assert!(t.is_lifecycle());
980        }
981    }
982
983    #[test]
984    fn discover_multi_test_cross_cutting() {
985        // Cross-cutting file under `tests/<stem>.toml` — no service-dir prefix.
986        let dir = tempfile::tempdir().unwrap();
987        let tests_dir = dir.path().join("tests");
988        std::fs::create_dir(&tests_dir).unwrap();
989        let test_toml = tests_dir.join("cross-thing.toml");
990        std::fs::write(
991            &test_toml,
992            r#"
993[[tests]]
994name = "first"
995[[tests.steps]]
996action = "add"
997service = "whoami"
998
999[[tests]]
1000name = "second"
1001[[tests.steps]]
1002action = "add"
1003service = "whoami"
1004"#,
1005        )
1006        .unwrap();
1007
1008        let parsed = TestToml::parse(&test_toml).unwrap();
1009        let out = discover_from_test_toml(&test_toml, &parsed, "cross-thing", None).unwrap();
1010        assert_eq!(out.len(), 2);
1011        let names: Vec<&str> = out.iter().map(|t| t.name()).collect();
1012        assert_eq!(names, vec!["first", "second"]);
1013    }
1014
1015    #[test]
1016    fn reject_test_with_both_run_and_steps() {
1017        let dir = tempfile::tempdir().unwrap();
1018        let test_toml = dir.path().join("test.toml");
1019        std::fs::write(
1020            &test_toml,
1021            r#"
1022[[tests]]
1023name = "bad"
1024run = "true"
1025[[tests.steps]]
1026action = "add"
1027service = "whoami"
1028"#,
1029        )
1030        .unwrap();
1031
1032        let err = TestToml::parse(&test_toml).expect_err("must reject run+steps");
1033        let msg = format!("{err:#}");
1034        assert!(
1035            msg.contains("exactly one of `run` or `steps`"),
1036            "got: {msg}"
1037        );
1038    }
1039
1040    #[test]
1041    fn discover_registry() {
1042        let registry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ryra-core/registry");
1043        if !registry.exists() {
1044            return; // skip if not available
1045        }
1046        let discovered = discover(&registry).unwrap();
1047
1048        // All tests should come from test.toml files now.
1049        // If no test.toml files exist yet, discovered will be empty — that's expected
1050        // during migration. Once registry/*/test.toml files are created, this test
1051        // will verify they're discovered correctly.
1052        if discovered.is_empty() {
1053            return; // registry hasn't been migrated yet
1054        }
1055
1056        let names: Vec<&str> = discovered.iter().map(|d| d.name()).collect();
1057
1058        // Basic checks — these only apply once test.toml files exist
1059        for test in &discovered {
1060            assert!(!test.name().is_empty());
1061            assert!(test.test_count() > 0);
1062        }
1063
1064        // If whoami test.toml exists, verify it
1065        if names.contains(&"whoami") {
1066            let whoami = discovered.iter().find(|d| d.name() == "whoami").unwrap();
1067            assert!(whoami.is_lifecycle());
1068            assert!(whoami.test_count() >= 1);
1069        }
1070    }
1071
1072    #[test]
1073    fn discover_local_project_from_dir() {
1074        // Create a temp dir with quadlet files and test.toml
1075        let dir = tempfile::tempdir().unwrap();
1076        let project_dir = dir.path();
1077
1078        // Write test.toml
1079        std::fs::write(
1080            project_dir.join("test.toml"),
1081            r#"
1082[test]
1083name = "test-app"
1084
1085[[tests]]
1086name = "responds"
1087run = "curl -sf http://127.0.0.1:8080"
1088"#,
1089        )
1090        .unwrap();
1091
1092        // Write a .container file
1093        std::fs::write(
1094            project_dir.join("test-app.container"),
1095            "[Container]\nImage=docker.io/traefik/whoami:v1.11.0\n\n[Service]\nRestart=always\n",
1096        )
1097        .unwrap();
1098
1099        // Write a .volume file
1100        std::fs::write(project_dir.join("test-app.volume"), "[Volume]\n").unwrap();
1101
1102        let result = discover_local_project(project_dir).unwrap();
1103        assert!(result.is_some());
1104
1105        let test = result.unwrap();
1106        assert_eq!(test.name(), "test-app");
1107        assert!(test.has_quadlets());
1108        assert_eq!(test.test_count(), 1);
1109
1110        if let DiscoveredTest::Simple { setup, .. } = &test {
1111            assert!(setup.quadlet_dir.is_some());
1112            // The inferred service is the directory name (temp dir name), not "test-app"
1113            // since there's no [setup] section, the hint (dir name) is used
1114        } else {
1115            panic!("expected Simple variant");
1116        }
1117    }
1118
1119    #[test]
1120    fn discover_local_project_with_setup_services() {
1121        let dir = tempfile::tempdir().unwrap();
1122        let project_dir = dir.path();
1123
1124        std::fs::write(
1125            project_dir.join("test.toml"),
1126            r#"
1127[test]
1128name = "my-app"
1129
1130[setup]
1131services = ["postgres", "redis"]
1132quadlets = ["my-app.container"]
1133
1134[[tests]]
1135name = "health-check"
1136run = "curl -sf http://127.0.0.1:8080/health"
1137timeout = 10
1138"#,
1139        )
1140        .unwrap();
1141
1142        // Write a .container file so discovery doesn't fail
1143        std::fs::write(
1144            project_dir.join("my-app.container"),
1145            "[Container]\nImage=docker.io/myapp:latest\n",
1146        )
1147        .unwrap();
1148
1149        let result = discover_local_project(project_dir).unwrap();
1150        let test = result.unwrap();
1151        assert_eq!(test.name(), "my-app");
1152        assert_eq!(test.services(), vec!["postgres", "redis"]);
1153        assert!(test.has_quadlets());
1154    }
1155
1156    #[test]
1157    fn discover_local_project_no_quadlets() {
1158        let dir = tempfile::tempdir().unwrap();
1159        std::fs::write(
1160            dir.path().join("test.toml"),
1161            "[[tests]]\nname = \"check\"\nrun = \"true\"\n",
1162        )
1163        .unwrap();
1164
1165        let result = discover_local_project(dir.path());
1166        assert!(result.is_err()); // should error about missing quadlet files
1167    }
1168
1169    #[test]
1170    fn browser_flag_on_simple_test() {
1171        let dir = tempfile::tempdir().unwrap();
1172        let test_toml = dir.path().join("test.toml");
1173        std::fs::write(
1174            &test_toml,
1175            r#"
1176[test]
1177browser = true
1178
1179[[tests]]
1180name = "browser check"
1181run = "true"
1182"#,
1183        )
1184        .unwrap();
1185
1186        let parsed = TestToml::parse(&test_toml).unwrap();
1187        let mut out = discover_from_test_toml(&test_toml, &parsed, "my-test", None).unwrap();
1188        assert_eq!(out.len(), 1);
1189        let test = out.remove(0);
1190        assert!(test.needs_browser());
1191    }
1192}