Skip to main content

pipeline_service/testing/
parser.rs

1// Test File Parser
2// Loads and validates roxid-test.yml test suite files
3
4use crate::testing::{PipelineTest, TestDefaults, TestSuite};
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// Parser for roxid-test.yml test suite files
10pub struct TestFileParser;
11
12/// Errors that can occur during test file parsing
13#[derive(Debug)]
14pub enum TestParseError {
15    /// File not found
16    NotFound(PathBuf),
17    /// IO error reading file
18    IoError(std::io::Error),
19    /// YAML parsing error
20    YamlError(serde_yaml::Error),
21    /// Validation error
22    ValidationError(String),
23}
24
25impl std::fmt::Display for TestParseError {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            TestParseError::NotFound(path) => {
29                write!(f, "Test file not found: {}", path.display())
30            }
31            TestParseError::IoError(e) => write!(f, "IO error reading test file: {}", e),
32            TestParseError::YamlError(e) => write!(f, "YAML parse error in test file: {}", e),
33            TestParseError::ValidationError(msg) => {
34                write!(f, "Test file validation error: {}", msg)
35            }
36        }
37    }
38}
39
40impl std::error::Error for TestParseError {}
41
42impl From<std::io::Error> for TestParseError {
43    fn from(err: std::io::Error) -> Self {
44        TestParseError::IoError(err)
45    }
46}
47
48impl From<serde_yaml::Error> for TestParseError {
49    fn from(err: serde_yaml::Error) -> Self {
50        TestParseError::YamlError(err)
51    }
52}
53
54impl TestFileParser {
55    /// Parse a test suite from a YAML string
56    pub fn parse(content: &str) -> Result<TestSuite, TestParseError> {
57        let suite: TestSuite = serde_yaml::from_str(content)?;
58        Self::validate(&suite)?;
59        Ok(suite)
60    }
61
62    /// Parse a test suite from a file path
63    pub fn parse_file(path: &Path) -> Result<TestSuite, TestParseError> {
64        if !path.exists() {
65            return Err(TestParseError::NotFound(path.to_path_buf()));
66        }
67
68        let content = fs::read_to_string(path)?;
69        let mut suite = Self::parse(&content)?;
70
71        // Resolve pipeline paths relative to the test file's directory
72        let base_dir = path
73            .parent()
74            .unwrap_or_else(|| Path::new("."))
75            .to_path_buf();
76
77        for test in &mut suite.tests {
78            if test.pipeline.is_relative() {
79                test.pipeline = base_dir.join(&test.pipeline);
80            }
81        }
82
83        Ok(suite)
84    }
85
86    /// Discover test files in a directory
87    ///
88    /// Looks for files matching: `roxid-test.yml`, `roxid-test.yaml`,
89    /// `*.roxid-test.yml`, `*.roxid-test.yaml`, or files in a `tests/` directory.
90    pub fn discover(dir: &Path) -> Vec<PathBuf> {
91        let mut test_files = Vec::new();
92
93        // Check for standard names
94        for name in &[
95            "roxid-test.yml",
96            "roxid-test.yaml",
97            ".roxid-test.yml",
98            ".roxid-test.yaml",
99        ] {
100            let path = dir.join(name);
101            if path.exists() {
102                test_files.push(path);
103            }
104        }
105
106        // Check tests/ directory
107        let tests_dir = dir.join("tests");
108        if tests_dir.is_dir() {
109            if let Ok(entries) = fs::read_dir(&tests_dir) {
110                for entry in entries.flatten() {
111                    let path = entry.path();
112                    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
113                        if (name.ends_with(".roxid-test.yml")
114                            || name.ends_with(".roxid-test.yaml")
115                            || name == "roxid-test.yml"
116                            || name == "roxid-test.yaml")
117                            && path.is_file()
118                        {
119                            test_files.push(path);
120                        }
121                    }
122                }
123            }
124        }
125
126        test_files.sort();
127        test_files
128    }
129
130    /// Apply suite defaults to a test, merging variables and parameters
131    pub fn apply_defaults(test: &mut PipelineTest, defaults: &TestDefaults) {
132        // Merge variables (test-specific overrides defaults)
133        for (key, value) in &defaults.variables {
134            test.variables.entry(key.clone()).or_insert(value.clone());
135        }
136
137        // Merge parameters (test-specific overrides defaults)
138        for (key, value) in &defaults.parameters {
139            test.parameters.entry(key.clone()).or_insert(value.clone());
140        }
141
142        // Apply working directory if not set
143        if test.working_dir.is_none() {
144            test.working_dir.clone_from(&defaults.working_dir);
145        }
146    }
147
148    /// Validate a test suite
149    fn validate(suite: &TestSuite) -> Result<(), TestParseError> {
150        if suite.tests.is_empty() {
151            return Err(TestParseError::ValidationError(
152                "Test suite must contain at least one test".to_string(),
153            ));
154        }
155
156        for (i, test) in suite.tests.iter().enumerate() {
157            if test.name.is_empty() {
158                return Err(TestParseError::ValidationError(format!(
159                    "Test at index {} must have a non-empty name",
160                    i
161                )));
162            }
163
164            if test.pipeline.as_os_str().is_empty() {
165                return Err(TestParseError::ValidationError(format!(
166                    "Test '{}' must specify a pipeline file",
167                    test.name
168                )));
169            }
170        }
171
172        // Check for duplicate test names
173        let mut names = std::collections::HashSet::new();
174        for test in &suite.tests {
175            if !names.insert(&test.name) {
176                return Err(TestParseError::ValidationError(format!(
177                    "Duplicate test name: '{}'",
178                    test.name
179                )));
180            }
181        }
182
183        Ok(())
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_parse_basic_suite() {
193        let yaml = r#"
194tests:
195  - name: "Basic test"
196    pipeline: pipeline.yml
197    assertions:
198      - pipeline_succeeded
199"#;
200        let suite = TestFileParser::parse(yaml).unwrap();
201        assert_eq!(suite.tests.len(), 1);
202        assert_eq!(suite.tests[0].name, "Basic test");
203    }
204
205    #[test]
206    fn test_parse_full_suite() {
207        let yaml = r#"
208name: "My test suite"
209defaults:
210  variables:
211    ENV: test
212  working_dir: /tmp
213tests:
214  - name: "Build test"
215    pipeline: azure-pipelines.yml
216    variables:
217      BUILD_CONFIG: Release
218    assertions:
219      - step_succeeded: Build
220      - step_output_contains:
221          step: Build
222          pattern: "Build succeeded"
223
224  - name: "Deploy skipped on PR"
225    pipeline: azure-pipelines.yml
226    variables:
227      BUILD_REASON: PullRequest
228    assertions:
229      - step_skipped: Deploy
230      - pipeline_succeeded
231"#;
232        let suite = TestFileParser::parse(yaml).unwrap();
233        assert_eq!(suite.name, Some("My test suite".to_string()));
234        assert!(suite.defaults.is_some());
235        assert_eq!(suite.tests.len(), 2);
236    }
237
238    #[test]
239    fn test_parse_empty_tests_fails() {
240        let yaml = r#"
241tests: []
242"#;
243        let result = TestFileParser::parse(yaml);
244        assert!(result.is_err());
245        assert!(matches!(
246            result.unwrap_err(),
247            TestParseError::ValidationError(_)
248        ));
249    }
250
251    #[test]
252    fn test_parse_duplicate_names_fails() {
253        let yaml = r#"
254tests:
255  - name: "Test A"
256    pipeline: pipeline.yml
257    assertions: []
258  - name: "Test A"
259    pipeline: pipeline.yml
260    assertions: []
261"#;
262        let result = TestFileParser::parse(yaml);
263        assert!(result.is_err());
264    }
265
266    #[test]
267    fn test_apply_defaults() {
268        let defaults = TestDefaults {
269            variables: {
270                let mut m = std::collections::HashMap::new();
271                m.insert("ENV".to_string(), "test".to_string());
272                m.insert("DEBUG".to_string(), "false".to_string());
273                m
274            },
275            parameters: std::collections::HashMap::new(),
276            working_dir: Some("/tmp".to_string()),
277        };
278
279        let mut test = PipelineTest {
280            name: "test".to_string(),
281            pipeline: PathBuf::from("pipeline.yml"),
282            variables: {
283                let mut m = std::collections::HashMap::new();
284                m.insert("ENV".to_string(), "prod".to_string()); // Should NOT be overridden
285                m
286            },
287            parameters: std::collections::HashMap::new(),
288            working_dir: None,
289            assertions: vec![],
290        };
291
292        TestFileParser::apply_defaults(&mut test, &defaults);
293        assert_eq!(test.variables.get("ENV").unwrap(), "prod"); // Test value wins
294        assert_eq!(test.variables.get("DEBUG").unwrap(), "false"); // Default applied
295        assert_eq!(test.working_dir, Some("/tmp".to_string())); // Default applied
296    }
297
298    #[test]
299    fn test_parse_file_not_found() {
300        let result = TestFileParser::parse_file(Path::new("/nonexistent/roxid-test.yml"));
301        assert!(matches!(result.unwrap_err(), TestParseError::NotFound(_)));
302    }
303
304    #[test]
305    fn test_discover_no_test_files() {
306        let dir = tempfile::tempdir().unwrap();
307        let files = TestFileParser::discover(dir.path());
308        assert!(files.is_empty());
309    }
310
311    #[test]
312    fn test_discover_standard_names() {
313        let dir = tempfile::tempdir().unwrap();
314        fs::write(dir.path().join("roxid-test.yml"), "tests: []").unwrap();
315        let files = TestFileParser::discover(dir.path());
316        assert_eq!(files.len(), 1);
317    }
318}