repopilot 0.13.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use repopilot::baseline::diff::{BaselineScanReport, BaselineStatus, FindingBaselineStatus};
use repopilot::findings::types::{Evidence, Finding, FindingCategory, Severity};
use repopilot::output::{OutputFormat, render_baseline_scan_report, render_scan_summary};
use repopilot::scan::types::{
    ChangedFileCacheTelemetry, ChangedFileReasonSummary, HiddenSuggestionSummary,
    ScanCacheTelemetry, ScanCacheTimings, ScanSummary,
};
use std::path::PathBuf;

#[test]
fn console_output_includes_versioned_summary_and_grouped_findings() {
    let summary = ScanSummary {
        root_path: PathBuf::from("demo"),
        files_discovered: 0,
        files_analyzed: 1,
        directories_count: 1,
        non_empty_lines: 100,
        findings: vec![Finding {
            id: "finding-1".to_string(),
            rule_id: "security.secret-candidate".to_string(),
            recommendation: Finding::recommendation_for_rule_id("security.secret-candidate"),
            title: "Possible secret detected".to_string(),
            description: "A real secret may be committed.".to_string(),
            category: FindingCategory::Security,
            severity: Severity::High,
            confidence: Default::default(),
            evidence: vec![Evidence {
                path: PathBuf::from("src/config.rs"),
                line_start: 7,
                line_end: None,
                snippet: "API_KEY = \"abc123xyz987\"".to_string(),
            }],
            workspace_package: None,
            docs_url: None,
            provenance: Default::default(),
            risk: Default::default(),
        }],
        health_score: 95,
        ..ScanSummary::default()
    };

    let output = render_scan_summary(&summary, OutputFormat::Console)
        .expect("failed to render console report");

    assert!(output.contains("RepoPilot Scan"));
    assert!(output.contains(&format!("Version: {}", env!("CARGO_PKG_VERSION"))));
    assert!(output.contains("Risk Summary:"));
    assert!(output.contains("Top Risk Clusters:"));
    assert!(output.contains("Top Rules:"));
    assert!(output.contains("Findings:"));
    assert!(output.contains("Security:"));
    assert!(output.contains("security.secret-candidate (1)"));
    assert!(output.contains("src/config.rs:7"));
    assert!(output.contains("Recommendation:"));
}

#[test]
fn console_output_labels_max_files_remainder_as_limit_skipped() {
    let summary = ScanSummary {
        root_path: PathBuf::from("demo"),
        files_discovered: 3,
        files_analyzed: 1,
        directories_count: 1,
        non_empty_lines: 10,
        health_score: 100,
        files_skipped_by_limit: 2,
        ..ScanSummary::default()
    };

    let output = render_scan_summary(&summary, OutputFormat::Console)
        .expect("failed to render console report");

    assert!(output.contains("Files skipped (limit):"));
    assert!(!output.contains("Files skipped (ignore):"));
}

#[test]
fn console_output_includes_hidden_suggestion_breakdown() {
    let summary = ScanSummary {
        root_path: PathBuf::from("demo"),
        hidden_suggestions_count: 3,
        hidden_suggestions: vec![HiddenSuggestionSummary {
            intent: "maintainability".to_string(),
            rule_id: "architecture.large-file".to_string(),
            category: "architecture".to_string(),
            reason: "maintainability signals are hidden in the default profile".to_string(),
            count: 3,
        }],
        ..ScanSummary::default()
    };

    let output = render_scan_summary(&summary, OutputFormat::Console)
        .expect("failed to render console report");

    assert!(output.contains("Hidden suggestions breakdown:"));
    assert!(output.contains("architecture / maintainability / architecture.large-file"));
}

#[test]
fn console_output_includes_cache_telemetry_for_changed_scans() {
    let summary = ScanSummary {
        root_path: PathBuf::from("demo"),
        cache_telemetry: Some(cache_telemetry()),
        ..ScanSummary::default()
    };

    let output = render_scan_summary(&summary, OutputFormat::Console)
        .expect("failed to render console report");

    assert!(output.contains("Cache telemetry:"));
    assert!(output.contains("Cache hits:"));
    assert!(output.contains("misses:"));
    assert!(output.contains("Changed file reasons: modified (2)"));
    assert!(output.contains("Cache timing:"));
    assert!(output.contains("src/lib.rs: hit (modified, unchanged-content-and-config)"));
}

#[test]
fn console_baseline_status_stays_aligned_for_duplicate_findings() {
    let report = duplicate_status_report();

    let output = render_baseline_scan_report(&report, OutputFormat::Console, None)
        .expect("failed to render baseline console");

    let existing = output
        .find("Baseline: existing")
        .expect("existing status should render");
    let new = output
        .find("Baseline: new")
        .expect("new status should render");
    assert!(existing < new);
}

fn cache_telemetry() -> ScanCacheTelemetry {
    ScanCacheTelemetry {
        hits: 1,
        misses: 1,
        skipped: 0,
        hit_rate_percent: 50,
        changed_file_reasons: vec![ChangedFileReasonSummary {
            reason: "modified".to_string(),
            count: 2,
        }],
        changed_files: vec![ChangedFileCacheTelemetry {
            path: PathBuf::from("src/lib.rs"),
            change_reason: "modified".to_string(),
            cache_status: "hit".to_string(),
            cache_reason: "unchanged-content-and-config".to_string(),
        }],
        timings: ScanCacheTimings {
            load_us: 1_000,
            file_hash_us: 2_000,
            lookup_us: 3_000,
            hit_reuse_us: 4_000,
            miss_scan_us: 5_000,
            write_us: 6_000,
            estimated_time_saved_us: Some(7_000),
        },
    }
}

fn duplicate_status_report() -> BaselineScanReport {
    BaselineScanReport {
        summary: ScanSummary {
            root_path: PathBuf::from("demo"),
            findings: vec![
                duplicate_finding("existing", 7),
                duplicate_finding("new", 8),
            ],
            ..ScanSummary::default()
        },
        baseline_path: None,
        findings: vec![
            FindingBaselineStatus {
                key: "existing".to_string(),
                status: BaselineStatus::Existing,
            },
            FindingBaselineStatus {
                key: "new".to_string(),
                status: BaselineStatus::New,
            },
        ],
    }
}

fn duplicate_finding(id: &str, line: usize) -> Finding {
    Finding {
        id: id.to_string(),
        rule_id: "code-quality.long-function".to_string(),
        recommendation: Finding::recommendation_for_rule_id("code-quality.long-function"),
        title: "Duplicate-looking finding".to_string(),
        description: "Duplicate-looking finding for renderer status alignment.".to_string(),
        category: FindingCategory::CodeQuality,
        severity: Severity::Medium,
        confidence: Default::default(),
        evidence: vec![Evidence {
            path: PathBuf::from("src/lib.rs"),
            line_start: line,
            line_end: None,
            snippet: "fn duplicate() {}".to_string(),
        }],
        workspace_package: None,
        docs_url: None,
        provenance: Default::default(),
        risk: Default::default(),
    }
}