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