use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use textfsm_rust::{ListItem, Template, Value};
use walkdir::WalkDir;
#[derive(Debug, Serialize)]
struct TestResults {
total: usize,
passed: usize,
failed: usize,
pass_rate: f64,
failures: Vec<TestFailure>,
}
#[derive(Debug, Serialize, Clone)]
struct TestFailure {
template: String,
test_file: String,
error: String,
}
#[derive(Debug, Deserialize)]
struct RawTestCase {
#[serde(default)]
parsed_sample: Vec<HashMap<String, serde_yaml::Value>>,
}
struct TestCase {
template_path: PathBuf,
template_name: String,
test_file: PathBuf,
}
struct Args {
templates_dir: PathBuf,
tests_dir: PathBuf,
output_file: PathBuf,
verbose: bool,
}
fn parse_args() -> Args {
let args: Vec<String> = std::env::args().collect();
let mut templates_dir = None;
let mut tests_dir = None;
let mut output_file = None;
let mut verbose = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--templates" => {
templates_dir = Some(PathBuf::from(&args[i + 1]));
i += 2;
}
"--tests" => {
tests_dir = Some(PathBuf::from(&args[i + 1]));
i += 2;
}
"--output" => {
output_file = Some(PathBuf::from(&args[i + 1]));
i += 2;
}
"--verbose" | "-v" => {
verbose = true;
i += 1;
}
_ => {
i += 1;
}
}
}
Args {
templates_dir: templates_dir.expect("--templates is required"),
tests_dir: tests_dir.expect("--tests is required"),
output_file: output_file.expect("--output is required"),
verbose,
}
}
fn main() {
let args = parse_args();
println!("Templates directory: {}", args.templates_dir.display());
println!("Tests directory: {}", args.tests_dir.display());
println!("Output file: {}", args.output_file.display());
let test_cases = collect_test_cases(&args.templates_dir, &args.tests_dir);
let total = test_cases.len();
println!("\nFound {} test cases, running in parallel...\n", total);
let passed = AtomicUsize::new(0);
let failures: Mutex<Vec<TestFailure>> = Mutex::new(Vec::new());
test_cases.par_iter().for_each(|test_case| {
match run_single_test(&test_case.template_path, &test_case.test_file) {
Ok(()) => {
passed.fetch_add(1, Ordering::Relaxed);
if args.verbose {
println!(
"PASS: {} - {}",
test_case.template_name,
test_case.test_file.display()
);
}
}
Err(e) => {
if args.verbose {
println!(
"FAIL: {} - {}: {}",
test_case.template_name,
test_case.test_file.display(),
e
);
}
let mut failures_guard = failures.lock().unwrap();
failures_guard.push(TestFailure {
template: test_case.template_name.clone(),
test_file: test_case.test_file.display().to_string(),
error: e,
});
}
}
});
let passed_count = passed.load(Ordering::Relaxed);
let failures_list = failures.into_inner().unwrap();
let failed = total - passed_count;
let pass_rate = if total > 0 {
(passed_count as f64 / total as f64) * 100.0
} else {
0.0
};
let results = TestResults {
total,
passed: passed_count,
failed,
pass_rate,
failures: failures_list,
};
println!("\n========================================");
println!(" Test Results");
println!("========================================");
println!("Total: {}", total);
println!("Passed: {}", passed_count);
println!("Failed: {}", failed);
println!("Pass Rate: {:.2}%", pass_rate);
println!("========================================\n");
let json = serde_json::to_string_pretty(&results).expect("failed to serialize results");
fs::write(&args.output_file, json).expect("failed to write output file");
println!("Results written to {}", args.output_file.display());
}
fn collect_test_cases(templates_dir: &Path, tests_dir: &Path) -> Vec<TestCase> {
let mut test_cases = Vec::new();
for entry in WalkDir::new(templates_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "textfsm"))
{
let template_path = entry.path().to_path_buf();
let template_name = template_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let test_files = find_test_files(tests_dir, &template_name);
for test_file in test_files {
test_cases.push(TestCase {
template_path: template_path.clone(),
template_name: template_name.clone(),
test_file,
});
}
}
test_cases
}
fn find_test_files(tests_dir: &Path, template_name: &str) -> Vec<PathBuf> {
let mut test_files = Vec::new();
let parts: Vec<&str> = template_name.splitn(3, '_').collect();
if parts.len() < 3 {
return test_files;
}
let vendor_os = format!("{}_{}", parts[0], parts[1]);
let command = parts[2];
let test_dir = tests_dir.join(&vendor_os).join(command);
if test_dir.exists() {
for entry in WalkDir::new(&test_dir)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "yml" || ext == "yaml"))
{
test_files.push(entry.path().to_path_buf());
}
}
test_files
}
fn run_single_test(template_path: &std::path::Path, test_file: &Path) -> Result<(), String> {
let template_content =
fs::read_to_string(template_path).map_err(|e| format!("failed to read template: {}", e))?;
let test_content =
fs::read_to_string(test_file).map_err(|e| format!("failed to read test file: {}", e))?;
let test_data: RawTestCase = serde_yaml::from_str(&test_content)
.map_err(|e| format!("failed to parse test YAML: {}", e))?;
let raw_file = test_file.with_extension("raw");
let raw_input =
fs::read_to_string(&raw_file).map_err(|e| format!("failed to read raw file: {}", e))?;
let template = Template::parse_str(&template_content)
.map_err(|e| format!("failed to parse template: {}", e))?;
let mut parser = template.parser();
let results = parser
.parse_text(&raw_input)
.map_err(|e| format!("failed to parse input: {}", e))?;
let header = template.header();
compare_results(&results, &header, &test_data.parsed_sample)
}
fn compare_results(
results: &[Vec<Value>],
header: &[&str],
expected: &[HashMap<String, serde_yaml::Value>],
) -> Result<(), String> {
if results.len() != expected.len() {
return Err(format!(
"row count mismatch: got {} rows, expected {}",
results.len(),
expected.len()
));
}
for (row_idx, (result_row, expected_row)) in results.iter().zip(expected.iter()).enumerate() {
for (col_idx, value) in result_row.iter().enumerate() {
let col_name = header.get(col_idx).unwrap_or(&"unknown");
let expected_value = expected_row
.get(&col_name.to_uppercase())
.or_else(|| expected_row.get(*col_name));
if let Some(expected_val) = expected_value
&& !values_match(value, expected_val)
{
return Err(format!(
"row {}, column '{}': got {:?}, expected {:?}",
row_idx, col_name, value, expected_val
));
}
}
}
Ok(())
}
fn values_match(actual: &Value, expected: &serde_yaml::Value) -> bool {
match (actual, expected) {
(Value::Empty, serde_yaml::Value::Null) => true,
(Value::Empty, serde_yaml::Value::String(s)) => s.is_empty(),
(Value::Single(s), serde_yaml::Value::String(e)) => s == e,
(Value::Single(s), serde_yaml::Value::Number(n)) => {
s == &n.to_string()
}
(Value::Single(s), serde_yaml::Value::Bool(b)) => {
s == &b.to_string() || (s == "True" && *b) || (s == "False" && !*b)
}
(Value::List(items), serde_yaml::Value::Sequence(seq)) => {
if items.len() != seq.len() {
return false;
}
items.iter().zip(seq.iter()).all(|(item, exp)| match item {
ListItem::String(s) => match exp {
serde_yaml::Value::String(e) => s == e,
serde_yaml::Value::Number(n) => s == &n.to_string(),
_ => false,
},
ListItem::Dict(d) => match exp {
serde_yaml::Value::Mapping(m) => m.iter().all(|(k, v)| {
if let (serde_yaml::Value::String(key), serde_yaml::Value::String(val)) =
(k, v)
{
d.get(key) == Some(val)
} else {
false
}
}),
_ => false,
},
})
}
(Value::List(_), serde_yaml::Value::Null) => false,
(Value::Empty, serde_yaml::Value::Sequence(seq)) => seq.is_empty(),
(Value::List(items), serde_yaml::Value::String(s)) => {
items.is_empty() && s.is_empty()
}
_ => false,
}
}