notarai 0.7.0

CLI validator for NotarAI spec files
use crate::core::lint::{LintConfig, LintFinding, LintRuleId, LintSeverity};

pub fn run(format: &str) -> i32 {
    let project_root = match std::env::current_dir() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("Error: could not determine current directory: {e}");
            return 1;
        }
    };
    let notarai_dir = project_root.join(".notarai");
    if !notarai_dir.exists() {
        eprintln!("Error: .notarai/ directory not found. Run `notarai init` first.");
        return 2;
    }

    let config = LintConfig::load(&project_root);

    let findings = match crate::core::lint::run_all_lints(&project_root, &config) {
        Ok(f) => f,
        Err(e) => {
            eprintln!("Error: {e}");
            return 1;
        }
    };

    match format {
        "json" => print_json(&findings),
        _ => print_human(&findings),
    }

    if has_errors(&findings) { 1 } else { 0 }
}

fn has_errors(findings: &[LintFinding]) -> bool {
    findings
        .iter()
        .any(|f| matches!(f.severity, LintSeverity::Error))
}

fn print_human(findings: &[LintFinding]) {
    if findings.is_empty() {
        println!("All lint checks passed.");
        return;
    }

    let rules: &[(LintRuleId, &str)] = &[
        (LintRuleId::L001, "L001: Tier 1 spec with no behaviors"),
        (LintRuleId::L002, "L002: Behavior missing given"),
        (LintRuleId::L003, "L003: Behavior missing then"),
        (LintRuleId::L004, "L004: $ref target missing"),
        (LintRuleId::L005, "L005: Circular $ref"),
        (LintRuleId::L006, "L006: Stale decision without rationale"),
        (LintRuleId::L007, "L007: Open questions"),
        (LintRuleId::L008, "L008: Overly broad glob"),
        (LintRuleId::L009, "L009: Schema version mismatch"),
        (LintRuleId::L010, "L010: Duplicate behavior names"),
    ];

    for (rule_id, label) in rules {
        let rule_findings: Vec<&LintFinding> =
            findings.iter().filter(|f| &f.rule_id == rule_id).collect();
        if rule_findings.is_empty() {
            continue;
        }

        println!(
            "\x1b[33m{label}\x1b[0m ({} finding{})",
            rule_findings.len(),
            if rule_findings.len() == 1 { "" } else { "s" }
        );
        for f in &rule_findings {
            let prefix = match f.severity {
                LintSeverity::Error => "\x1b[31m  error  \x1b[0m",
                LintSeverity::Warning => "\x1b[33m  warning\x1b[0m",
                LintSeverity::Info => "\x1b[36m  info   \x1b[0m",
            };
            println!("{prefix}: {}", f.message);
        }
        println!();
    }

    let (errors, warnings, infos) = count_severity(findings);
    println!(
        "{} finding{} ({} error{}, {} warning{}, {} info).",
        errors + warnings + infos,
        if errors + warnings + infos == 1 {
            ""
        } else {
            "s"
        },
        errors,
        if errors == 1 { "" } else { "s" },
        warnings,
        if warnings == 1 { "" } else { "s" },
        infos,
    );
}

fn count_severity(findings: &[LintFinding]) -> (usize, usize, usize) {
    let mut errors = 0usize;
    let mut warnings = 0usize;
    let mut infos = 0usize;
    for f in findings {
        match f.severity {
            LintSeverity::Error => errors += 1,
            LintSeverity::Warning => warnings += 1,
            LintSeverity::Info => infos += 1,
        }
    }
    (errors, warnings, infos)
}

fn print_json(findings: &[LintFinding]) {
    let json_findings: Vec<serde_json::Value> = findings
        .iter()
        .map(|f| {
            serde_json::json!({
                "rule_id": f.rule_id.as_str(),
                "severity": f.severity.as_str(),
                "spec_path": f.spec_path,
                "message": f.message,
            })
        })
        .collect();

    let (errors, warnings, infos) = count_severity(findings);

    let output = serde_json::json!({
        "findings": json_findings,
        "summary": {
            "errors": errors,
            "warnings": warnings,
            "infos": infos,
        }
    });

    println!(
        "{}",
        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
    );
}