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