mod beans;
pub mod document;
pub mod runner;
mod junit;
#[cfg(test)]
mod document_tests;
#[cfg(test)]
mod driver_tests;
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use camel_dsl::discovery::is_test_document;
use clap::Args;
use document::parse_test_document;
use runner::run_test_doc;
#[derive(Args, Debug)]
pub struct TestArgs {
#[arg(value_name = "FILE|DIR", required = true)]
pub files: Vec<PathBuf>,
#[arg(long, value_name = "FILE")]
pub junit: Option<PathBuf>,
#[arg(long = "filter-file", value_name = "GLOB")]
pub filter_files: Vec<String>,
#[arg(long = "filter-endpoint", value_name = "NAME")]
pub filter_endpoints: Vec<String>,
}
#[derive(Debug, Default)]
pub struct TestRunConfig {
pub files: Vec<PathBuf>,
pub junit: Option<PathBuf>,
pub filter_files: Vec<glob::Pattern>,
pub filter_endpoints: Vec<String>,
}
pub fn config_from_args(args: &TestArgs) -> Result<TestRunConfig, String> {
let mut filter_files = Vec::with_capacity(args.filter_files.len());
for glob in &args.filter_files {
let pattern = glob::Pattern::new(glob)
.map_err(|e| format!("invalid --filter-file pattern {glob}: {e}"))?;
filter_files.push(pattern);
}
Ok(TestRunConfig {
files: args.files.clone(),
junit: args.junit.clone(),
filter_files,
filter_endpoints: args.filter_endpoints.clone(),
})
}
pub struct TestRunSummary {
pub exit_code: i32,
pub passed: usize,
pub failed: usize,
}
const EXCLUDED_DIR_NAMES: [&str; 3] = ["target", ".git", "node_modules"];
fn expand_test_paths(args: &[PathBuf]) -> (Vec<PathBuf>, Vec<(PathBuf, String)>) {
let mut documents = Vec::new();
let mut errors = Vec::new();
let mut seen = HashSet::new();
for arg in args {
if arg.is_dir() {
let mut found = Vec::new();
collect_test_documents(arg, &mut found, &mut errors);
found.sort_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
if found.is_empty() {
errors.push((arg.clone(), "no test documents found".to_string()));
}
for path in found {
push_unique(path, &mut documents, &mut seen);
}
} else {
push_unique(arg.clone(), &mut documents, &mut seen);
}
}
(documents, errors)
}
fn collect_test_documents(
dir: &Path,
found: &mut Vec<PathBuf>,
errors: &mut Vec<(PathBuf, String)>,
) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
errors.push((dir.to_path_buf(), e.to_string()));
return;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
errors.push((dir.to_path_buf(), e.to_string()));
continue;
}
};
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(e) => {
errors.push((path.clone(), e.to_string()));
continue;
}
};
if file_type.is_dir() {
let name = entry.file_name();
if !EXCLUDED_DIR_NAMES.iter().any(|excluded| name == *excluded) {
collect_test_documents(&path, found, errors);
}
} else if is_test_document(&path) {
found.push(path);
}
}
}
fn push_unique(path: PathBuf, documents: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>) {
let key = match std::fs::canonicalize(&path) {
Ok(canonical) => canonical,
Err(_) => path.clone(),
};
if seen.insert(key) {
documents.push(path);
}
}
pub async fn run_tests_full(
config: &TestRunConfig,
out: &mut dyn Write,
err: &mut dyn Write,
) -> TestRunSummary {
let mut passed = 0usize;
let mut failed = 0usize;
let mut had_parse_error = false;
let mut had_misuse = false;
let mut any_survivor = false;
let (documents, expansion_errors) = expand_test_paths(&config.files);
let mut expansion_reports: Vec<junit::ExpansionReport> = Vec::new();
for (path, message) in &expansion_errors {
had_parse_error = true;
let _ = writeln!(err, "{}: {message}", path.display());
expansion_reports.push(junit::ExpansionReport {
name: path.display().to_string(),
error: message.clone(),
});
}
let any_filter = !config.filter_files.is_empty() || !config.filter_endpoints.is_empty();
let mut admitted: Vec<&PathBuf> = Vec::new();
if config.filter_files.is_empty() {
admitted.extend(documents.iter());
} else {
let options = glob::MatchOptions {
require_literal_separator: true,
..glob::MatchOptions::new()
};
for path in &documents {
let displayed = path.display().to_string();
if config
.filter_files
.iter()
.any(|pattern| pattern.matches_with(&displayed, options))
{
admitted.push(path);
}
}
}
let mut doc_reports: Vec<junit::DocReport> = Vec::new();
for path in admitted {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) => {
had_parse_error = true;
any_survivor = true;
let _ = writeln!(err, "{}: {e}", path.display());
doc_reports.push(junit::DocReport {
path: path.clone(),
rows: Vec::new(),
doc_error: Some(e.to_string()),
});
continue;
}
};
let doc = match parse_test_document(&text) {
Ok(doc) => doc,
Err(e) => {
had_parse_error = true;
any_survivor = true;
let _ = writeln!(err, "{}: {e}", path.display());
doc_reports.push(junit::DocReport {
path: path.clone(),
rows: Vec::new(),
doc_error: Some(e.to_string()),
});
continue;
}
};
if !config.filter_endpoints.is_empty()
&& !config
.filter_endpoints
.iter()
.any(|name| doc.expects.contains_key(name))
{
continue;
}
any_survivor = true;
if let Some(stubs) = doc.repository_stubs() {
let pairs: Vec<String> = stubs
.stub_pairs()
.iter()
.map(|(kind, name)| format!("{kind}={name}"))
.collect();
if !pairs.is_empty() {
let _ = writeln!(
err,
"R-REPOSITORY-STUB: {} stubbed as memory; backend semantics not exercised (cache: prefix purge, TTL/stale timing, disk offload, stats; idempotent/claim-check: persistence; all: backend failure) — cover them in the integration tier",
pairs.join(" ")
);
}
}
let parent_dir = path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let result = run_test_doc(&doc, &parent_dir).await.0;
if let Some(doc_error) = result.doc_error {
had_parse_error = true;
let _ = writeln!(err, "{}: {doc_error}", path.display());
doc_reports.push(junit::DocReport {
path: path.clone(),
rows: Vec::new(),
doc_error: Some(doc_error),
});
continue;
}
for er in &result.endpoint_results {
match &er.outcome {
Ok(()) => {
passed += 1;
let _ = writeln!(out, "PASS {}#{}", path.display(), er.endpoint);
}
Err(detail) => {
failed += 1;
let _ = writeln!(out, "FAIL {}#{} — {detail}", path.display(), er.endpoint);
}
}
}
doc_reports.push(junit::DocReport {
path: path.clone(),
rows: result.endpoint_results,
doc_error: None,
});
}
if any_filter && !any_survivor {
had_misuse = true;
let _ = writeln!(err, "{}", filter_misuse_message(config));
}
let mut exit_code = if had_parse_error || had_misuse {
2
} else if failed > 0 {
1
} else {
0
};
let _ = writeln!(out, "{passed} passed, {failed} failed");
if let Some(path) = &config.junit
&& let Err(e) = junit::write_report(path, &expansion_reports, &doc_reports)
{
let _ = writeln!(err, "failed to write {}: {e}", path.display());
if exit_code < 2 {
exit_code = 2;
}
}
TestRunSummary {
exit_code,
passed,
failed,
}
}
fn filter_misuse_message(config: &TestRunConfig) -> String {
let mut message = String::from("no test documents matched");
if !config.filter_files.is_empty() {
message.push_str(" --filter-file");
for pattern in &config.filter_files {
message.push(' ');
message.push_str(pattern.as_str());
}
}
if !config.filter_endpoints.is_empty() {
message.push_str(" --filter-endpoint");
for name in &config.filter_endpoints {
message.push(' ');
message.push_str(name);
}
}
message
}
pub async fn run_tests(
files: &[PathBuf],
out: &mut dyn Write,
err: &mut dyn Write,
) -> TestRunSummary {
let config = TestRunConfig {
files: files.to_vec(),
..Default::default()
};
run_tests_full(&config, out, err).await
}