use std::fs;
use std::path::{Path, PathBuf};
use serde_json::json;
use crate::cli::args::OutputFormat;
#[derive(Debug, Clone)]
pub struct SpecCase {
pub spec_file: String,
pub case_id: String,
pub case_type: String,
}
#[derive(Debug, Clone)]
pub struct RunAllSummary {
pub total: usize,
pub attempted: usize,
pub passed: usize,
pub failed: usize,
pub skipped: usize,
pub failed_refs: Vec<String>,
}
fn walk_files(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_files(&path, out);
continue;
}
if path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.ends_with(".spec.md"))
.unwrap_or(false)
{
out.push(path);
}
}
}
pub fn normalize_spec_ref(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.starts_with('/') || trimmed.starts_with('#') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
fn block_type(block: &str) -> Option<String> {
for line in block.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix("type:") {
let v = rest.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}
pub fn list_specs(root: &Path, path_filter: Option<&str>) -> Result<Vec<SpecCase>, String> {
let search_root = path_filter
.map(|p| root.join(p.trim_start_matches('/')))
.unwrap_or_else(|| root.join("specs"));
let mut files = Vec::<PathBuf>::new();
walk_files(&search_root, &mut files);
files.sort();
let mut cases = Vec::<SpecCase>::new();
for file in files {
let text = fs::read_to_string(&file)
.map_err(|e| format!("failed to read spec file {}: {e}", file.display()))?;
for block in crate::domain::refs::extract_spec_test_blocks(&text) {
if let Some(id) = crate::domain::refs::block_id(&block) {
let rel = file
.strip_prefix(root)
.unwrap_or(&file)
.to_string_lossy()
.replace('\\', "/");
cases.push(SpecCase {
spec_file: format!("/{rel}"),
case_id: id,
case_type: block_type(&block).unwrap_or_else(|| "unknown".to_string()),
});
}
}
}
Ok(cases)
}
pub fn print_specs(cases: &[SpecCase], format: OutputFormat) {
match format {
OutputFormat::Text => {
if cases.is_empty() {
println!("No spec cases found.");
return;
}
println!("Discovered {} spec cases:", cases.len());
for case in cases {
println!("- {}#{} [{}]", case.spec_file, case.case_id, case.case_type);
}
}
OutputFormat::Json => {
let payload = json!(cases
.iter()
.map(|c| {
json!({
"ref": format!("{}#{}", c.spec_file, c.case_id),
"type": c.case_type
})
})
.collect::<Vec<_>>());
println!(
"{}",
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "[]".to_string())
);
}
}
}
pub fn run_all_specs<F>(cases: &[SpecCase], fail_fast: bool, mut run_one: F) -> RunAllSummary
where
F: FnMut(&str) -> i32,
{
let total = cases.len();
let runnable: Vec<&SpecCase> = cases
.iter()
.filter(|c| c.case_type == "contract.job")
.collect();
let skipped = total.saturating_sub(runnable.len());
let mut passed = 0usize;
let mut failed = 0usize;
let mut failed_refs = Vec::<String>::new();
for case in runnable {
let spec_ref = format!("{}#{}", case.spec_file, case.case_id);
let code = run_one(&spec_ref);
if code == 0 {
passed += 1;
} else {
failed += 1;
failed_refs.push(spec_ref.clone());
if fail_fast {
break;
}
}
}
RunAllSummary {
total,
attempted: passed + failed,
passed,
failed,
skipped,
failed_refs,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_ref_adds_root_slash() {
assert_eq!(
normalize_spec_ref("specs/a.spec.md#ID"),
"/specs/a.spec.md#ID"
);
assert_eq!(
normalize_spec_ref("/specs/a.spec.md#ID"),
"/specs/a.spec.md#ID"
);
}
}