apr-cli 0.31.1

CLI tool for APR model inspection, debugging, and operations
Documentation
//! Lint command implementation
//!
//! Implements APR-SPEC §4.11: Lint Command
//!
//! Static analysis for best practices, conventions, and "soft" requirements.
//! Unlike `validate` (which checks for corruption/invalidity), `lint` checks
//! for *quality* and *standardization*.

use crate::error::{CliError, Result};
use crate::output;
use aprender::format::{lint_model_file, LintLevel, LintReport};
use colored::Colorize;
use std::path::Path;

/// Run the lint command
// GH-685: added quiet param — suppress WARN/INFO when quiet=true
#[provable_contracts_macros::contract(
    "apr-cli-operations-v1",
    equation = "side_effect_classification"
)]
pub(crate) fn run(file: &Path, json: bool, quiet: bool) -> Result<()> {
    contract_pre_apr_model_validity!();
    contract_pre_lint_model_conventions!();
    // Validate input exists
    if !file.exists() {
        return Err(CliError::FileNotFound(file.to_path_buf()));
    }

    // Run lint (auto-detects APR, GGUF, SafeTensors via Rosetta Stone)
    let report = lint_model_file(file).map_err(|e| CliError::ValidationFailed(e.to_string()))?;

    // GH-257: JSON output mode
    if json {
        return print_json_report(file, &report);
    }

    output::header("Model Lint");
    println!("  Checking: {}", file.display().to_string().cyan());
    println!();

    // Display results (GH-685: quiet filters to errors only)
    display_report(&report, quiet);

    // GH-601: Exit non-zero when report says "Lint failed" — exit code must match display.
    // report.passed() returns false when error_count > 0 OR warn_count > 0.
    if report.passed() {
        contract_post_apr_model_validity!(&());
        contract_post_lint_model_conventions!(&());
        Ok(())
    } else {
        Err(CliError::ValidationFailed(format!(
            "Lint failed with {} error(s), {} warning(s), {} info(s)",
            report.error_count, report.warn_count, report.info_count
        )))
    }
}

/// GH-257: JSON output for lint results
#[allow(clippy::disallowed_methods)]
fn print_json_report(file: &Path, report: &LintReport) -> Result<()> {
    let issues: Vec<serde_json::Value> = report
        .issues
        .iter()
        .map(|issue| {
            serde_json::json!({
                "level": format!("{}", issue.level),
                "category": issue.category.name(),
                "message": issue.message,
                "suggestion": issue.suggestion,
            })
        })
        .collect();

    let output_json = serde_json::json!({
        "model": file.display().to_string(),
        "passed": report.passed(),
        "error_count": report.error_count,
        "warn_count": report.warn_count,
        "info_count": report.info_count,
        "total_issues": report.total_issues(),
        "issues": issues,
    });
    println!(
        "{}",
        serde_json::to_string_pretty(&output_json).unwrap_or_default()
    );

    // GH-601: Exit non-zero when lint fails — exit code must match JSON "passed" field.
    if report.passed() {
        Ok(())
    } else {
        Err(CliError::ValidationFailed(format!(
            "Lint failed with {} error(s), {} warning(s), {} info(s)",
            report.error_count, report.warn_count, report.info_count
        )))
    }
}

/// Format lint level as a badge.
fn level_badge(level: LintLevel) -> String {
    match level {
        LintLevel::Info => output::badge_info("INFO"),
        LintLevel::Warn => output::badge_warn("WARN"),
        LintLevel::Error => output::badge_fail("ERROR"),
    }
}

/// Print summary and final status.
fn print_summary(report: &LintReport) {
    let total = report.total_issues();

    if report.passed() {
        println!(
            "  {} {} issue(s) ({} info)",
            output::badge_pass("Lint passed"),
            total,
            report.info_count,
        );
    } else {
        println!(
            "  {} {} issue(s): {} error(s), {} warning(s), {} info(s)",
            output::badge_fail("Lint failed"),
            total,
            report.error_count,
            report.warn_count,
            report.info_count,
        );
    }
}

/// Display lint report
fn display_report(report: &LintReport, quiet: bool) {
    if report.issues.is_empty() {
        if !quiet {
            println!("  {}", output::badge_pass("No issues found"));
            println!();
        }
        return;
    }

    // Build table of issues (GH-685: quiet shows only errors)
    let mut rows: Vec<Vec<String>> = Vec::new();
    for issue in &report.issues {
        if quiet && issue.level != LintLevel::Error {
            continue;
        }
        let badge = level_badge(issue.level);
        let suggestion = issue.suggestion.as_deref().unwrap_or("").to_string();
        rows.push(vec![
            badge,
            issue.category.name().to_string(),
            issue.message.clone(),
            suggestion,
        ]);
    }
    println!(
        "{}",
        output::table(&["Level", "Category", "Message", "Suggestion"], &rows)
    );

    println!();
    print_summary(report);
}

#[cfg(test)]
mod tests {
    use super::*;
    use aprender::format::{LintCategory, LintIssue, LintReport};
    use std::io::Write;
    use tempfile::NamedTempFile;

    // ========================================================================
    // Unit Tests for level_badge
    // ========================================================================

    #[test]
    fn test_level_badge_info() {
        let badge = level_badge(LintLevel::Info);
        assert!(badge.contains("INFO"));
    }

    #[test]
    fn test_level_badge_warn() {
        let badge = level_badge(LintLevel::Warn);
        assert!(badge.contains("WARN"));
    }

    #[test]
    fn test_level_badge_error() {
        let badge = level_badge(LintLevel::Error);
        assert!(badge.contains("ERROR"));
    }

    // ========================================================================
    // Unit Tests for print_summary
    // ========================================================================

    #[test]
    fn test_print_summary_passed() {
        let report = LintReport::new();
        // Should not panic
        print_summary(&report);
    }

    #[test]
    fn test_print_summary_with_issues() {
        let mut report = LintReport::new();
        report.add_issue(LintIssue::metadata_warn("Test warning"));
        report.add_issue(LintIssue::new(
            LintLevel::Error,
            LintCategory::Metadata,
            "Test error",
        ));
        // Should not panic
        print_summary(&report);
    }

    #[test]
    fn test_print_summary_info_only() {
        let mut report = LintReport::new();
        report.add_issue(LintIssue::efficiency_info("Alignment suggestion"));
        // Should still pass (info only)
        assert!(report.passed());
        print_summary(&report);
    }

    // ========================================================================
    // Unit Tests for display_report
    // ========================================================================

    #[test]
    fn test_display_report_empty() {
        let report = LintReport::new();
        display_report(&report, false);
    }

    #[test]
    fn test_display_report_with_all_categories() {
        let mut report = LintReport::new();
        report.add_issue(LintIssue::metadata_warn("Missing license"));
        report.add_issue(LintIssue::naming_info("Use full names"));
        report.add_issue(LintIssue::efficiency_info("Consider alignment"));
        display_report(&report, false);
    }

    // ========================================================================
    // Integration Tests for run()
    // ========================================================================

    #[test]
    fn test_run_file_not_found() {
        let result = run(std::path::Path::new("/nonexistent/model.apr"), false, false);
        assert!(result.is_err());
        match result {
            Err(CliError::FileNotFound(path)) => {
                assert!(path.to_string_lossy().contains("nonexistent"));
            }
            _ => panic!("Expected FileNotFound error"),
        }
    }

    #[test]
    fn test_run_invalid_file() {
        // Create a temp file with invalid APR content
        let mut file = NamedTempFile::with_suffix(".apr").expect("create temp file");
        file.write_all(b"not a valid APR file")
            .expect("write to temp file");

        let result = run(file.path(), false, false);
        // Should return error since it's not a valid APR file
        assert!(result.is_err());
    }

    // ========================================================================
    // LintReport behavior tests
    // ========================================================================

    #[test]
    fn test_lint_report_counts() {
        let mut report = LintReport::new();
        assert_eq!(report.info_count, 0);
        assert_eq!(report.warn_count, 0);
        assert_eq!(report.error_count, 0);

        report.add_issue(LintIssue::efficiency_info("Info 1"));
        report.add_issue(LintIssue::efficiency_info("Info 2"));
        report.add_issue(LintIssue::metadata_warn("Warn 1"));
        report.add_issue(LintIssue::new(
            LintLevel::Error,
            LintCategory::Naming,
            "Error 1",
        ));

        assert_eq!(report.info_count, 2);
        assert_eq!(report.warn_count, 1);
        assert_eq!(report.error_count, 1);
        assert_eq!(report.total_issues(), 4);
    }

    #[test]
    fn test_lint_report_passed() {
        let mut report = LintReport::new();
        assert!(report.passed());

        // Info only should still pass
        report.add_issue(LintIssue::efficiency_info("Just info"));
        assert!(report.passed());

        // Adding warning should fail
        report.add_issue(LintIssue::metadata_warn("Warning"));
        assert!(!report.passed());
    }

    #[test]
    fn test_lint_report_passed_strict() {
        let mut report = LintReport::new();
        assert!(report.passed_strict());

        // Even info should fail strict
        report.add_issue(LintIssue::efficiency_info("Just info"));
        assert!(!report.passed_strict());
    }

    #[test]
    fn test_lint_report_issues_at_level() {
        let mut report = LintReport::new();
        report.add_issue(LintIssue::efficiency_info("Info 1"));
        report.add_issue(LintIssue::metadata_warn("Warn 1"));
        report.add_issue(LintIssue::efficiency_info("Info 2"));

        let infos = report.issues_at_level(LintLevel::Info);
        assert_eq!(infos.len(), 2);

        let warns = report.issues_at_level(LintLevel::Warn);
        assert_eq!(warns.len(), 1);

        let errors = report.issues_at_level(LintLevel::Error);
        assert!(errors.is_empty());
    }

    #[test]
    fn test_lint_report_issues_in_category() {
        let mut report = LintReport::new();
        report.add_issue(LintIssue::metadata_warn("Meta 1"));
        report.add_issue(LintIssue::naming_info("Name 1"));
        report.add_issue(LintIssue::metadata_warn("Meta 2"));

        let meta_issues = report.issues_in_category(LintCategory::Metadata);
        assert_eq!(meta_issues.len(), 2);

        let naming_issues = report.issues_in_category(LintCategory::Naming);
        assert_eq!(naming_issues.len(), 1);

        let efficiency_issues = report.issues_in_category(LintCategory::Efficiency);
        assert!(efficiency_issues.is_empty());
    }

    // ========================================================================
    // LintIssue tests
    // ========================================================================

    #[test]
    fn test_lint_issue_display() {
        let issue = LintIssue::new(LintLevel::Warn, LintCategory::Metadata, "Missing license");
        let display = format!("{}", issue);
        assert!(display.contains("WARN"));
        assert!(display.contains("Metadata"));
        assert!(display.contains("Missing license"));
    }

    #[test]
    fn test_lint_issue_display_with_suggestion() {
        let issue = LintIssue::new(LintLevel::Info, LintCategory::Naming, "Short name")
            .with_suggestion("Use longer name");
        let display = format!("{}", issue);
        assert!(display.contains("suggestion"));
        assert!(display.contains("Use longer name"));
    }

    #[test]
    fn test_lint_level_display() {
        assert_eq!(format!("{}", LintLevel::Info), "INFO");
        assert_eq!(format!("{}", LintLevel::Warn), "WARN");
        assert_eq!(format!("{}", LintLevel::Error), "ERROR");
    }

    #[test]
    fn test_lint_category_display() {
        assert_eq!(format!("{}", LintCategory::Metadata), "Metadata");
        assert_eq!(format!("{}", LintCategory::Naming), "Tensor Naming");
        assert_eq!(format!("{}", LintCategory::Efficiency), "Efficiency");
    }
}