use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use textfsm_rust::{CliTable, ListItem, Template, Value};
use walkdir::WalkDir;
#[derive(Debug, Serialize)]
struct ComprehensiveResults {
direct: TestResults,
clitable: TestResults,
comparison: Comparison,
}
#[derive(Debug, Serialize)]
struct TestResults {
total: usize,
passed: usize,
failed: usize,
skipped: usize,
pass_rate: f64,
failures: Vec<TestFailure>,
}
#[derive(Debug, Serialize)]
struct Comparison {
both_pass: usize,
both_fail: usize,
direct_only_pass: usize,
clitable_only_pass: usize,
direct_only_failures: Vec<String>,
clitable_only_failures: Vec<String>,
}
#[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 Args {
ntc_templates_dir: PathBuf,
output_file: Option<PathBuf>,
verbose: bool,
}
fn parse_args() -> Args {
let args: Vec<String> = std::env::args().collect();
let mut ntc_templates_dir = None;
let mut output_file = None;
let mut verbose = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--ntc-templates" => {
ntc_templates_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;
}
"--help" | "-h" => {
println!("Usage: comprehensive_ntc_test --ntc-templates <path> [--output results.json] [--verbose]");
std::process::exit(0);
}
_ => {
i += 1;
}
}
}
Args {
ntc_templates_dir: ntc_templates_dir
.or_else(|| std::env::var("NTC_TEMPLATES_DIR").ok().map(PathBuf::from))
.expect("--ntc-templates or NTC_TEMPLATES_DIR environment variable is required"),
output_file,
verbose,
}
}
fn main() {
let args = parse_args();
let templates_dir = args.ntc_templates_dir.join("ntc_templates/templates");
let tests_dir = args.ntc_templates_dir.join("tests");
let index_path = templates_dir.join("index");
println!("ntc-templates directory: {}", args.ntc_templates_dir.display());
println!("Templates directory: {}", templates_dir.display());
println!("Tests directory: {}", tests_dir.display());
let cli_table = match CliTable::new(&index_path, &templates_dir) {
Ok(ct) => Some(ct),
Err(e) => {
eprintln!("Warning: Failed to create CliTable: {}", e);
None
}
};
println!("\nRunning comprehensive tests...\n");
let test_cases = collect_test_cases(&templates_dir, &tests_dir);
println!("Found {} test cases\n", test_cases.len());
println!("Running direct Template tests...");
let (direct_results, direct_outcomes) = run_direct_tests(&test_cases, &templates_dir, args.verbose);
println!("\nRunning CliTable tests...");
let (clitable_results, clitable_outcomes) = if let Some(ref ct) = cli_table {
run_clitable_tests(&test_cases, ct, args.verbose)
} else {
(TestResults {
total: 0,
passed: 0,
failed: 0,
skipped: test_cases.len(),
pass_rate: 0.0,
failures: vec![],
}, HashMap::new())
};
let comparison = compare_results(&direct_outcomes, &clitable_outcomes);
let results = ComprehensiveResults {
direct: direct_results,
clitable: clitable_results,
comparison,
};
print_summary(&results);
if let Some(output_file) = &args.output_file {
let json = serde_json::to_string_pretty(&results).expect("failed to serialize results");
fs::write(output_file, json).expect("failed to write output file");
println!("\nResults written to {}", output_file.display());
}
}
#[derive(Debug, Clone)]
struct TestCase {
template_name: String,
template_path: PathBuf,
test_file: PathBuf,
raw_file: PathBuf,
platform: String,
command: String,
}
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)
.max_depth(1)
.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 parts: Vec<&str> = template_name.splitn(3, '_').collect();
if parts.len() < 3 {
continue;
}
let platform = format!("{}_{}", parts[0], parts[1]);
let command = parts[2].to_string();
let test_dir = tests_dir.join(&platform).join(&command);
if test_dir.exists() {
for test_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")
})
{
let test_file = test_entry.path().to_path_buf();
let raw_file = test_file.with_extension("raw");
if raw_file.exists() {
test_cases.push(TestCase {
template_name: template_name.clone(),
template_path: template_path.clone(),
test_file,
raw_file,
platform: platform.clone(),
command: command.replace('_', " "),
});
}
}
}
}
test_cases
}
#[derive(Debug, Clone, PartialEq)]
enum TestOutcome {
Pass,
Fail(String),
Skip(String),
}
fn run_direct_tests(
test_cases: &[TestCase],
_templates_dir: &Path,
verbose: bool,
) -> (TestResults, HashMap<String, TestOutcome>) {
let mut total = 0;
let mut passed = 0;
let mut skipped = 0;
let mut failures = Vec::new();
let mut outcomes = HashMap::new();
for tc in test_cases {
total += 1;
let key = tc.test_file.display().to_string();
match run_direct_test(tc) {
Ok(()) => {
passed += 1;
outcomes.insert(key, TestOutcome::Pass);
if verbose {
println!("PASS [direct]: {}", tc.template_name);
}
}
Err(TestError::Skipped(reason)) => {
skipped += 1;
outcomes.insert(key, TestOutcome::Skip(reason));
}
Err(TestError::Failed(error)) => {
outcomes.insert(key.clone(), TestOutcome::Fail(error.clone()));
if verbose {
println!("FAIL [direct]: {}: {}", tc.template_name, error);
}
failures.push(TestFailure {
template: tc.template_name.clone(),
test_file: tc.test_file.display().to_string(),
error,
});
}
}
}
let pass_rate = if total > skipped {
(passed as f64 / (total - skipped) as f64) * 100.0
} else {
0.0
};
(
TestResults {
total,
passed,
failed: failures.len(),
skipped,
pass_rate,
failures,
},
outcomes,
)
}
fn run_clitable_tests(
test_cases: &[TestCase],
cli_table: &CliTable,
verbose: bool,
) -> (TestResults, HashMap<String, TestOutcome>) {
let mut total = 0;
let mut passed = 0;
let mut skipped = 0;
let mut failures = Vec::new();
let mut outcomes = HashMap::new();
for tc in test_cases {
total += 1;
let key = tc.test_file.display().to_string();
match run_clitable_test(tc, cli_table) {
Ok(()) => {
passed += 1;
outcomes.insert(key, TestOutcome::Pass);
if verbose {
println!("PASS [clitable]: {}", tc.template_name);
}
}
Err(TestError::Skipped(reason)) => {
skipped += 1;
outcomes.insert(key, TestOutcome::Skip(reason));
}
Err(TestError::Failed(error)) => {
outcomes.insert(key.clone(), TestOutcome::Fail(error.clone()));
if verbose {
println!("FAIL [clitable]: {}: {}", tc.template_name, error);
}
failures.push(TestFailure {
template: tc.template_name.clone(),
test_file: tc.test_file.display().to_string(),
error,
});
}
}
}
let pass_rate = if total > skipped {
(passed as f64 / (total - skipped) as f64) * 100.0
} else {
0.0
};
(
TestResults {
total,
passed,
failed: failures.len(),
skipped,
pass_rate,
failures,
},
outcomes,
)
}
enum TestError {
Skipped(String),
Failed(String),
}
fn run_direct_test(tc: &TestCase) -> Result<(), TestError> {
let template_content = fs::read_to_string(&tc.template_path)
.map_err(|e| TestError::Failed(format!("failed to read template: {}", e)))?;
let template = Template::parse_str(&template_content)
.map_err(|e| TestError::Failed(format!("failed to parse template: {}", e)))?;
let test_content = fs::read_to_string(&tc.test_file)
.map_err(|e| TestError::Failed(format!("failed to read test file: {}", e)))?;
let test_data: RawTestCase = serde_yaml::from_str(&test_content)
.map_err(|e| TestError::Failed(format!("failed to parse test YAML: {}", e)))?;
let raw_input = fs::read_to_string(&tc.raw_file)
.map_err(|e| TestError::Failed(format!("failed to read raw file: {}", e)))?;
let mut parser = template.parser();
let results = parser
.parse_text(&raw_input)
.map_err(|e| TestError::Failed(format!("failed to parse input: {}", e)))?;
let header = template.header();
compare_parsed_results(&results, &header, &test_data.parsed_sample)
.map_err(TestError::Failed)
}
fn run_clitable_test(tc: &TestCase, cli_table: &CliTable) -> Result<(), TestError> {
let mut attrs = HashMap::new();
attrs.insert("Platform".to_string(), tc.platform.clone());
attrs.insert("Command".to_string(), tc.command.clone());
let templates = cli_table.find_templates(&attrs).map_err(|e| {
TestError::Skipped(format!("no matching template: {}", e))
})?;
if templates.is_empty() {
return Err(TestError::Skipped("no matching template found".into()));
}
let test_content = fs::read_to_string(&tc.test_file)
.map_err(|e| TestError::Failed(format!("failed to read test file: {}", e)))?;
let test_data: RawTestCase = serde_yaml::from_str(&test_content)
.map_err(|e| TestError::Failed(format!("failed to parse test YAML: {}", e)))?;
let raw_input = fs::read_to_string(&tc.raw_file)
.map_err(|e| TestError::Failed(format!("failed to read raw file: {}", e)))?;
let table = cli_table
.parse_cmd(&raw_input, &attrs)
.map_err(|e| TestError::Failed(format!("failed to parse: {}", e)))?;
compare_table_results(&table, &test_data.parsed_sample)
.map_err(TestError::Failed)
}
fn compare_parsed_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_lowercase())
.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 compare_table_results(
table: &textfsm_rust::TextTable,
expected: &[HashMap<String, serde_yaml::Value>],
) -> Result<(), String> {
if table.len() != expected.len() {
return Err(format!(
"row count mismatch: got {} rows, expected {}",
table.len(),
expected.len()
));
}
let header = table.header();
for (row_idx, (row, expected_row)) in table.iter().zip(expected.iter()).enumerate() {
for col_name in header {
let value = row.get(col_name).unwrap_or(&Value::Empty);
let expected_value = expected_row
.get(&col_name.to_lowercase())
.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,
}
}
fn compare_results(
direct: &HashMap<String, TestOutcome>,
clitable: &HashMap<String, TestOutcome>,
) -> Comparison {
let mut both_pass = 0;
let mut both_fail = 0;
let mut direct_only_pass = 0;
let mut clitable_only_pass = 0;
let mut direct_only_failures = Vec::new();
let mut clitable_only_failures = Vec::new();
for (key, direct_outcome) in direct {
let clitable_outcome = clitable.get(key);
match (direct_outcome, clitable_outcome) {
(TestOutcome::Pass, Some(TestOutcome::Pass)) => both_pass += 1,
(TestOutcome::Fail(_), Some(TestOutcome::Fail(_))) => both_fail += 1,
(TestOutcome::Pass, Some(TestOutcome::Fail(e))) => {
direct_only_pass += 1;
clitable_only_failures.push(format!("{}: {}", key, e));
}
(TestOutcome::Fail(e), Some(TestOutcome::Pass)) => {
clitable_only_pass += 1;
direct_only_failures.push(format!("{}: {}", key, e));
}
_ => {} }
}
Comparison {
both_pass,
both_fail,
direct_only_pass,
clitable_only_pass,
direct_only_failures,
clitable_only_failures,
}
}
fn print_summary(results: &ComprehensiveResults) {
println!("\n========================================");
println!(" Comprehensive Test Results");
println!("========================================");
println!("\n--- Direct Template Results ---");
println!("Total: {}", results.direct.total);
println!("Passed: {}", results.direct.passed);
println!("Failed: {}", results.direct.failed);
println!("Skipped: {}", results.direct.skipped);
println!("Pass Rate: {:.2}%", results.direct.pass_rate);
println!("\n--- CliTable Results ---");
println!("Total: {}", results.clitable.total);
println!("Passed: {}", results.clitable.passed);
println!("Failed: {}", results.clitable.failed);
println!("Skipped: {}", results.clitable.skipped);
println!("Pass Rate: {:.2}%", results.clitable.pass_rate);
println!("\n--- Comparison ---");
println!("Both pass: {}", results.comparison.both_pass);
println!("Both fail: {}", results.comparison.both_fail);
println!("Direct only pass: {}", results.comparison.direct_only_pass);
println!("CliTable only pass: {}", results.comparison.clitable_only_pass);
if !results.comparison.direct_only_failures.is_empty() {
println!("\n--- Tests that FAIL with direct but PASS with CliTable (first 5) ---");
for f in results.comparison.direct_only_failures.iter().take(5) {
println!(" {}", f);
}
}
if !results.comparison.clitable_only_failures.is_empty() {
println!("\n--- Tests that PASS with direct but FAIL with CliTable (first 5) ---");
for f in results.comparison.clitable_only_failures.iter().take(5) {
println!(" {}", f);
}
}
println!("\n========================================");
}