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 using CliTable for automatic template selection.
//!
//! This test runner uses the index file to automatically select templates,
//! just like the Python ntc-templates library does.
//!
//! Usage:
//!   cargo run --example test_clitable_ntc --features serde -- \
//!     --ntc-templates /path/to/ntc-templates \
//!     [--output results.json] \
//!     [--verbose]

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use textfsm_rust::{CliTable, ListItem, Value};
use walkdir::WalkDir;

/// Test results output format.
#[derive(Debug, Serialize)]
struct TestResults {
    total: usize,
    passed: usize,
    failed: usize,
    skipped: usize,
    pass_rate: f64,
    failures: Vec<TestFailure>,
    skipped_tests: Vec<SkippedTest>,
}

#[derive(Debug, Serialize)]
struct TestFailure {
    platform: String,
    command: String,
    test_file: String,
    error: String,
}

#[derive(Debug, Serialize)]
struct SkippedTest {
    platform: String,
    command: String,
    test_file: String,
    reason: 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>>,
}

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: test_clitable_ntc --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());
    println!("Index file: {}", index_path.display());

    // Create CliTable from the index
    let cli_table = match CliTable::new(&index_path, &templates_dir) {
        Ok(ct) => ct,
        Err(e) => {
            eprintln!("Failed to create CliTable: {}", e);
            std::process::exit(1);
        }
    };

    println!("\nLoaded index with {} entries", cli_table.index().entries().len());
    println!("\nRunning tests...\n");

    let mut total = 0;
    let mut passed = 0;
    let mut skipped = 0;
    let mut failures = Vec::new();
    let mut skipped_tests = Vec::new();

    // Walk through all test directories
    // Structure: tests/{platform}/{command}/*.yml
    for platform_entry in WalkDir::new(&tests_dir)
        .min_depth(1)
        .max_depth(1)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_dir())
    {
        let platform = platform_entry.file_name().to_string_lossy().to_string();

        // Skip __init__.py and other non-platform dirs
        if platform.starts_with('_') || platform.starts_with('.') {
            continue;
        }

        for command_entry in WalkDir::new(platform_entry.path())
            .min_depth(1)
            .max_depth(1)
            .into_iter()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_type().is_dir())
        {
            let command_dir_name = command_entry.file_name().to_string_lossy().to_string();

            // Convert directory name to command (e.g., "show_version" -> "show version")
            let command = command_dir_name.replace('_', " ");

            // Find all test files in this command directory
            for test_entry in WalkDir::new(command_entry.path())
                .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();
                total += 1;

                match run_test_with_clitable(&cli_table, &platform, &command, test_file) {
                    Ok(()) => {
                        passed += 1;
                        if args.verbose {
                            println!("PASS: {} '{}' - {}", platform, command, test_file.file_name().unwrap_or_default().to_string_lossy());
                        }
                    }
                    Err(TestError::Skipped(reason)) => {
                        skipped += 1;
                        if args.verbose {
                            println!("SKIP: {} '{}' - {}: {}", platform, command, test_file.file_name().unwrap_or_default().to_string_lossy(), reason);
                        }
                        skipped_tests.push(SkippedTest {
                            platform: platform.clone(),
                            command: command.clone(),
                            test_file: test_file.display().to_string(),
                            reason,
                        });
                    }
                    Err(TestError::Failed(error)) => {
                        if args.verbose {
                            println!("FAIL: {} '{}' - {}: {}", platform, command, test_file.file_name().unwrap_or_default().to_string_lossy(), error);
                        }
                        failures.push(TestFailure {
                            platform: platform.clone(),
                            command: command.clone(),
                            test_file: test_file.display().to_string(),
                            error,
                        });
                    }
                }
            }
        }
    }

    let failed = failures.len();
    let pass_rate = if total > 0 {
        ((passed as f64) / ((total - skipped) as f64)) * 100.0
    } else {
        0.0
    };

    let results = TestResults {
        total,
        passed,
        failed,
        skipped,
        pass_rate,
        failures,
        skipped_tests,
    };

    // Print summary
    println!("\n========================================");
    println!(" CliTable Test Results");
    println!("========================================");
    println!("Total:     {}", total);
    println!("Passed:    {}", passed);
    println!("Failed:    {}", failed);
    println!("Skipped:   {}", skipped);
    println!("Pass Rate: {:.2}% (of non-skipped)", pass_rate);
    println!("========================================\n");

    // Print first 10 failures if any
    if !results.failures.is_empty() {
        println!("First {} failures:", results.failures.len().min(10));
        for failure in results.failures.iter().take(10) {
            println!("  {} '{}': {}", failure.platform, failure.command, failure.error);
        }
        println!();
    }

    // Write results to JSON file if specified
    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!("Results written to {}", output_file.display());
    }
}

enum TestError {
    Skipped(String),
    Failed(String),
}

fn run_test_with_clitable(
    cli_table: &CliTable,
    platform: &str,
    command: &str,
    test_file: &Path,
) -> Result<(), TestError> {
    // Build attributes for template matching
    let mut attrs = HashMap::new();
    attrs.insert("Platform".to_string(), platform.to_string());
    attrs.insert("Command".to_string(), command.to_string());

    // Check if we can find a matching template
    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()));
    }

    // Read test YAML
    let test_content = fs::read_to_string(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)))?;

    // Read raw input
    let raw_file = test_file.with_extension("raw");
    let raw_input = fs::read_to_string(&raw_file)
        .map_err(|e| TestError::Failed(format!("failed to read raw file: {}", e)))?;

    // Parse using CliTable
    let table = cli_table.parse_cmd(&raw_input, &attrs)
        .map_err(|e| TestError::Failed(format!("failed to parse: {}", e)))?;

    // Compare results
    compare_results(&table, &test_data.parsed_sample)
        .map_err(TestError::Failed)
}

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

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

/// 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,
    }
}