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