Skip to main content

alef_e2e/
fixture.rs

1//! Fixture loading, validation, and grouping for e2e test generation.
2
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::Path;
7
8/// A single e2e test fixture.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Fixture {
11    /// Unique identifier (used as test function name).
12    pub id: String,
13    /// Optional category (defaults to parent directory name).
14    #[serde(default)]
15    pub category: Option<String>,
16    /// Human-readable description.
17    pub description: String,
18    /// Optional tags for filtering.
19    #[serde(default)]
20    pub tags: Vec<String>,
21    /// Skip directive.
22    #[serde(default)]
23    pub skip: Option<SkipDirective>,
24    /// Input data passed to the function under test.
25    #[serde(default)]
26    pub input: serde_json::Value,
27    /// List of assertions to check.
28    #[serde(default)]
29    pub assertions: Vec<Assertion>,
30    /// Source file path (populated during loading).
31    #[serde(skip)]
32    pub source: String,
33}
34
35impl Fixture {
36    /// Get the resolved category (explicit or from source directory).
37    pub fn resolved_category(&self) -> String {
38        self.category.clone().unwrap_or_else(|| {
39            Path::new(&self.source)
40                .parent()
41                .and_then(|p| p.file_name())
42                .and_then(|n| n.to_str())
43                .unwrap_or("default")
44                .to_string()
45        })
46    }
47}
48
49/// Skip directive for conditionally excluding fixtures.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SkipDirective {
52    /// Languages to skip (empty means skip all).
53    #[serde(default)]
54    pub languages: Vec<String>,
55    /// Human-readable reason for skipping.
56    #[serde(default)]
57    pub reason: Option<String>,
58}
59
60impl SkipDirective {
61    /// Check if this fixture should be skipped for a given language.
62    pub fn should_skip(&self, language: &str) -> bool {
63        self.languages.is_empty() || self.languages.iter().any(|l| l == language)
64    }
65}
66
67/// A single assertion in a fixture.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Assertion {
70    /// Assertion type (equals, contains, not_empty, error, etc.).
71    #[serde(rename = "type")]
72    pub assertion_type: String,
73    /// Field path to access on the result (dot-separated).
74    #[serde(default)]
75    pub field: Option<String>,
76    /// Expected value (string, number, bool, or array depending on type).
77    #[serde(default)]
78    pub value: Option<serde_json::Value>,
79    /// Expected values (for contains_all, contains_any).
80    #[serde(default)]
81    pub values: Option<Vec<serde_json::Value>>,
82}
83
84/// A group of fixtures sharing the same category.
85#[derive(Debug, Clone)]
86pub struct FixtureGroup {
87    pub category: String,
88    pub fixtures: Vec<Fixture>,
89}
90
91/// Load all fixtures from a directory recursively.
92pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
93    let mut fixtures = Vec::new();
94    load_fixtures_recursive(dir, dir, &mut fixtures)?;
95
96    // Validate: check for duplicate IDs
97    let mut seen: HashMap<String, String> = HashMap::new();
98    for f in &fixtures {
99        if let Some(prev_source) = seen.get(&f.id) {
100            bail!(
101                "duplicate fixture ID '{}': found in '{}' and '{}'",
102                f.id,
103                prev_source,
104                f.source
105            );
106        }
107        seen.insert(f.id.clone(), f.source.clone());
108    }
109
110    // Sort by (category, id) for deterministic output
111    fixtures.sort_by(|a, b| {
112        let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
113        cat_cmp.then_with(|| a.id.cmp(&b.id))
114    });
115
116    Ok(fixtures)
117}
118
119fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
120    let entries =
121        std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
122
123    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
124    paths.sort();
125
126    for path in paths {
127        if path.is_dir() {
128            load_fixtures_recursive(base, &path, fixtures)?;
129        } else if path.extension().is_some_and(|ext| ext == "json") {
130            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
131            // Skip schema files and files starting with _
132            if filename == "schema.json" || filename.starts_with('_') {
133                continue;
134            }
135            let content = std::fs::read_to_string(&path)
136                .with_context(|| format!("failed to read fixture: {}", path.display()))?;
137            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
138
139            // Try parsing as array first, then as single fixture
140            let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
141                serde_json::from_str(&content)
142                    .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
143            } else {
144                let single: Fixture = serde_json::from_str(&content)
145                    .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
146                vec![single]
147            };
148
149            for mut fixture in parsed {
150                fixture.source = relative.clone();
151                fixtures.push(fixture);
152            }
153        }
154    }
155    Ok(())
156}
157
158/// Group fixtures by their resolved category.
159pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
160    let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
161    for f in fixtures {
162        groups.entry(f.resolved_category()).or_default().push(f.clone());
163    }
164    let mut result: Vec<FixtureGroup> = groups
165        .into_iter()
166        .map(|(category, fixtures)| FixtureGroup { category, fixtures })
167        .collect();
168    result.sort_by(|a, b| a.category.cmp(&b.category));
169    result
170}