textfsm-rust 0.3.1

Template-based state machine for parsing semi-formatted text based on Google's TextFSM
Documentation
//! Test runner for ntc-templates compatibility testing.
//!
//! Uses parallel execution for faster testing.
//!
//! Usage:
//!   cargo run --release --example test_ntc_templates -- \
//!     --templates /path/to/ntc-templates/ntc_templates/templates \
//!     --tests /path/to/ntc-templates/tests \
//!     --output results.json

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;

/// Test results output format.
#[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,
}

/// Raw test data from YAML files in ntc-templates.
#[derive(Debug, Deserialize)]
struct RawTestCase {
    #[serde(default)]
    parsed_sample: Vec<HashMap<String, serde_yaml::Value>>,
}

/// A single test case to run.
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());

    // Collect all test cases first
    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);

    // Thread-safe counters and failure collection
    let passed = AtomicUsize::new(0);
    let failures: Mutex<Vec<TestFailure>> = Mutex::new(Vec::new());

    // Run tests in parallel
    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,
    };

    // Print summary
    println!("\n========================================");
    println!(" Test Results");
    println!("========================================");
    println!("Total:     {}", total);
    println!("Passed:    {}", passed_count);
    println!("Failed:    {}", failed);
    println!("Pass Rate: {:.2}%", pass_rate);
    println!("========================================\n");

    // Write results to JSON file
    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());
}

/// Collect all test cases from templates and tests directories.
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();

        // Find corresponding test files
        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();

    // ntc-templates structure:
    // - Template: {vendor}_{os}_{command}.textfsm (e.g., arista_eos_show_clock.textfsm)
    // - Tests: tests/{vendor}_{os}/{command}/*.yml (e.g., tests/arista_eos/show_clock/*.yml)
    //
    // Parse template name to get vendor_os and command
    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];

    // Look for test directory: tests/{vendor_os}/{command}/
    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> {
    // Read template
    let template_content =
        fs::read_to_string(template_path).map_err(|e| format!("failed to read template: {}", e))?;

    // Read test file
    let test_content =
        fs::read_to_string(test_file).map_err(|e| format!("failed to read test file: {}", e))?;

    // Parse test YAML
    let test_data: RawTestCase = serde_yaml::from_str(&test_content)
        .map_err(|e| format!("failed to parse test YAML: {}", e))?;

    // Get raw input from the .raw file (same name as .yml but with .raw extension)
    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))?;

    // Parse the template
    let template = Template::parse_str(&template_content)
        .map_err(|e| format!("failed to parse template: {}", e))?;

    // Create parser and parse input
    let mut parser = template.parser();
    let results = parser
        .parse_text(&raw_input)
        .map_err(|e| format!("failed to parse input: {}", e))?;

    // Get header for mapping results to column names
    let header = template.header();

    // Compare results with expected
    compare_results(&results, &header, &test_data.parsed_sample)
}

/// Compare parsed results with expected values from test YAML.
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");

            // ntc-templates uses uppercase column names in YAML
            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(())
}

/// Check if a parsed Value matches an expected YAML value.
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)) => {
            // Handle numeric comparisons (YAML might parse "123" as number)
            s == &n.to_string()
        }
        (Value::Single(s), serde_yaml::Value::Bool(b)) => {
            // Handle boolean comparisons
            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)) => {
            // Handle case where expected is empty string but we have empty list
            items.is_empty() && s.is_empty()
        }
        _ => false,
    }
}