use crate::testing::{PipelineTest, TestDefaults, TestSuite};
use std::fs;
use std::path::{Path, PathBuf};
pub struct TestFileParser;
#[derive(Debug)]
pub enum TestParseError {
NotFound(PathBuf),
IoError(std::io::Error),
YamlError(serde_yaml::Error),
ValidationError(String),
}
impl std::fmt::Display for TestParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TestParseError::NotFound(path) => {
write!(f, "Test file not found: {}", path.display())
}
TestParseError::IoError(e) => write!(f, "IO error reading test file: {}", e),
TestParseError::YamlError(e) => write!(f, "YAML parse error in test file: {}", e),
TestParseError::ValidationError(msg) => {
write!(f, "Test file validation error: {}", msg)
}
}
}
}
impl std::error::Error for TestParseError {}
impl From<std::io::Error> for TestParseError {
fn from(err: std::io::Error) -> Self {
TestParseError::IoError(err)
}
}
impl From<serde_yaml::Error> for TestParseError {
fn from(err: serde_yaml::Error) -> Self {
TestParseError::YamlError(err)
}
}
impl TestFileParser {
pub fn parse(content: &str) -> Result<TestSuite, TestParseError> {
let suite: TestSuite = serde_yaml::from_str(content)?;
Self::validate(&suite)?;
Ok(suite)
}
pub fn parse_file(path: &Path) -> Result<TestSuite, TestParseError> {
if !path.exists() {
return Err(TestParseError::NotFound(path.to_path_buf()));
}
let content = fs::read_to_string(path)?;
let mut suite = Self::parse(&content)?;
let base_dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
for test in &mut suite.tests {
if test.pipeline.is_relative() {
test.pipeline = base_dir.join(&test.pipeline);
}
}
Ok(suite)
}
pub fn discover(dir: &Path) -> Vec<PathBuf> {
let mut test_files = Vec::new();
for name in &[
"roxid-test.yml",
"roxid-test.yaml",
".roxid-test.yml",
".roxid-test.yaml",
] {
let path = dir.join(name);
if path.exists() {
test_files.push(path);
}
}
let tests_dir = dir.join("tests");
if tests_dir.is_dir() {
if let Ok(entries) = fs::read_dir(&tests_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if (name.ends_with(".roxid-test.yml")
|| name.ends_with(".roxid-test.yaml")
|| name == "roxid-test.yml"
|| name == "roxid-test.yaml")
&& path.is_file()
{
test_files.push(path);
}
}
}
}
}
test_files.sort();
test_files
}
pub fn apply_defaults(test: &mut PipelineTest, defaults: &TestDefaults) {
for (key, value) in &defaults.variables {
test.variables.entry(key.clone()).or_insert(value.clone());
}
for (key, value) in &defaults.parameters {
test.parameters.entry(key.clone()).or_insert(value.clone());
}
if test.working_dir.is_none() {
test.working_dir.clone_from(&defaults.working_dir);
}
}
fn validate(suite: &TestSuite) -> Result<(), TestParseError> {
if suite.tests.is_empty() {
return Err(TestParseError::ValidationError(
"Test suite must contain at least one test".to_string(),
));
}
for (i, test) in suite.tests.iter().enumerate() {
if test.name.is_empty() {
return Err(TestParseError::ValidationError(format!(
"Test at index {} must have a non-empty name",
i
)));
}
if test.pipeline.as_os_str().is_empty() {
return Err(TestParseError::ValidationError(format!(
"Test '{}' must specify a pipeline file",
test.name
)));
}
}
let mut names = std::collections::HashSet::new();
for test in &suite.tests {
if !names.insert(&test.name) {
return Err(TestParseError::ValidationError(format!(
"Duplicate test name: '{}'",
test.name
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic_suite() {
let yaml = r#"
tests:
- name: "Basic test"
pipeline: pipeline.yml
assertions:
- pipeline_succeeded
"#;
let suite = TestFileParser::parse(yaml).unwrap();
assert_eq!(suite.tests.len(), 1);
assert_eq!(suite.tests[0].name, "Basic test");
}
#[test]
fn test_parse_full_suite() {
let yaml = r#"
name: "My test suite"
defaults:
variables:
ENV: test
working_dir: /tmp
tests:
- name: "Build test"
pipeline: azure-pipelines.yml
variables:
BUILD_CONFIG: Release
assertions:
- step_succeeded: Build
- step_output_contains:
step: Build
pattern: "Build succeeded"
- name: "Deploy skipped on PR"
pipeline: azure-pipelines.yml
variables:
BUILD_REASON: PullRequest
assertions:
- step_skipped: Deploy
- pipeline_succeeded
"#;
let suite = TestFileParser::parse(yaml).unwrap();
assert_eq!(suite.name, Some("My test suite".to_string()));
assert!(suite.defaults.is_some());
assert_eq!(suite.tests.len(), 2);
}
#[test]
fn test_parse_empty_tests_fails() {
let yaml = r#"
tests: []
"#;
let result = TestFileParser::parse(yaml);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
TestParseError::ValidationError(_)
));
}
#[test]
fn test_parse_duplicate_names_fails() {
let yaml = r#"
tests:
- name: "Test A"
pipeline: pipeline.yml
assertions: []
- name: "Test A"
pipeline: pipeline.yml
assertions: []
"#;
let result = TestFileParser::parse(yaml);
assert!(result.is_err());
}
#[test]
fn test_apply_defaults() {
let defaults = TestDefaults {
variables: {
let mut m = std::collections::HashMap::new();
m.insert("ENV".to_string(), "test".to_string());
m.insert("DEBUG".to_string(), "false".to_string());
m
},
parameters: std::collections::HashMap::new(),
working_dir: Some("/tmp".to_string()),
};
let mut test = PipelineTest {
name: "test".to_string(),
pipeline: PathBuf::from("pipeline.yml"),
variables: {
let mut m = std::collections::HashMap::new();
m.insert("ENV".to_string(), "prod".to_string()); m
},
parameters: std::collections::HashMap::new(),
working_dir: None,
assertions: vec![],
};
TestFileParser::apply_defaults(&mut test, &defaults);
assert_eq!(test.variables.get("ENV").unwrap(), "prod"); assert_eq!(test.variables.get("DEBUG").unwrap(), "false"); assert_eq!(test.working_dir, Some("/tmp".to_string())); }
#[test]
fn test_parse_file_not_found() {
let result = TestFileParser::parse_file(Path::new("/nonexistent/roxid-test.yml"));
assert!(matches!(result.unwrap_err(), TestParseError::NotFound(_)));
}
#[test]
fn test_discover_no_test_files() {
let dir = tempfile::tempdir().unwrap();
let files = TestFileParser::discover(dir.path());
assert!(files.is_empty());
}
#[test]
fn test_discover_standard_names() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("roxid-test.yml"), "tests: []").unwrap();
let files = TestFileParser::discover(dir.path());
assert_eq!(files.len(), 1);
}
}