repopilot 0.12.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::audits::context::classify_file;
use crate::explain::model::{
    ExplainContext, ExplainDecision, ExplainReport, ExplainRiskSignal, ExplainSource,
    ExplainVisibility,
};
use crate::findings::types::{Confidence, Evidence, Finding, FindingCategory, Severity};
use crate::findings::visibility::classify_visibility;
use crate::knowledge::decision::decide_for_file;
use crate::knowledge::language::{detect_language_for_path, profile_by_id};
use crate::knowledge::model::{RuleDecisionAction, SupportLevel};
use crate::rules::registry::lookup_rule_metadata;
use crate::scan::facts::FileFacts;
use std::fs;
use std::io;
use std::path::Path;

pub fn build_explain_report(
    path: &Path,
    rule_id: Option<&str>,
    signal: Option<&str>,
    base_severity: Severity,
) -> Result<ExplainReport, io::Error> {
    let content = fs::read_to_string(path)?;
    let language_name = detect_language_for_path(path).map(str::to_string);
    let non_empty_lines = count_non_empty_lines(&content);
    let has_inline_tests = has_language_inline_tests(path, language_name.as_deref(), &content);

    let file = FileFacts {
        path: path.to_path_buf(),
        language: language_name.clone(),
        non_empty_lines,
        branch_count: 0,
        imports: Vec::new(),
        content: Some(content),
        has_inline_tests,
    };

    let audit_context = classify_file(&file);
    let language_id = audit_context.language_id();
    let language_support =
        profile_by_id(language_id).map(|profile| support_level_label(profile.support).to_string());

    let context = ExplainContext {
        language: language_id.to_string(),
        language_support,
        frameworks: audit_context
            .framework_ids()
            .into_iter()
            .map(str::to_string)
            .collect(),
        roles: audit_context
            .role_ids()
            .into_iter()
            .map(str::to_string)
            .collect(),
        paradigms: audit_context
            .paradigm_ids()
            .into_iter()
            .map(str::to_string)
            .collect(),
        runtimes: audit_context
            .runtime_ids()
            .into_iter()
            .map(str::to_string)
            .collect(),
        is_test: audit_context.is_test,
        is_production_code: audit_context.is_production_code(),
    };

    let decision = rule_id.map(|rule_id| {
        let decision = decide_for_file(rule_id, &file, base_severity, signal);

        ExplainDecision {
            rule_id: rule_id.to_string(),
            signal: signal.map(str::to_string),
            base_severity,
            action: decision_action_label(decision.action).to_string(),
            final_severity: decision.severity,
            reason: decision.reason,
            risk_signal: decision.risk_signal.map(|signal| ExplainRiskSignal {
                id: signal.id,
                label: signal.label,
                weight: signal.weight,
                reason: signal.reason,
            }),
            visibility: Some(explain_visibility(path, rule_id, decision.severity)),
        }
    });

    Ok(ExplainReport {
        path: path.display().to_string(),
        source: ExplainSource {
            language_name,
            non_empty_lines,
            has_inline_tests,
        },
        context,
        decision,
    })
}

fn count_non_empty_lines(content: &str) -> usize {
    content
        .lines()
        .filter(|line| !line.trim().is_empty())
        .count()
}

fn has_language_inline_tests(path: &Path, language: Option<&str>, content: &str) -> bool {
    match language {
        Some("Rust") => content.contains("#[cfg(test)]") || content.contains("#[test]"),
        Some("TypeScript")
        | Some("TypeScript React")
        | Some("JavaScript")
        | Some("JavaScript React") => {
            contains_call(content, "describe")
                || contains_call(content, "it")
                || contains_call(content, "test")
        }
        Some("Python") => content.contains("def test_") || content.contains("unittest."),
        Some("Go") => content.contains("func Test") || content.contains("func Benchmark"),
        Some("Java") | Some("Kotlin") => content.contains("@Test"),
        _ => path
            .file_name()
            .and_then(|name| name.to_str())
            .is_some_and(|name| name.contains("_test.") || name.contains(".test.")),
    }
}

fn contains_call(content: &str, name: &str) -> bool {
    let needle = format!("{name}(");
    content.match_indices(&needle).any(|(index, _)| {
        content[..index]
            .chars()
            .next_back()
            .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '.')
    })
}

fn support_level_label(level: SupportLevel) -> &'static str {
    match level {
        SupportLevel::DetectOnly => "detect-only",
        SupportLevel::ImportAware => "import-aware",
        SupportLevel::ContextAware => "context-aware",
        SupportLevel::RuleAware => "rule-aware",
    }
}

fn decision_action_label(action: RuleDecisionAction) -> &'static str {
    match action {
        RuleDecisionAction::Apply => "apply",
        RuleDecisionAction::Suppress => "suppress",
        RuleDecisionAction::Downgrade => "downgrade",
        RuleDecisionAction::Upgrade => "upgrade",
    }
}

fn explain_visibility(path: &Path, rule_id: &str, severity: Severity) -> ExplainVisibility {
    let category = lookup_rule_metadata(rule_id)
        .map(|metadata| metadata.category.clone())
        .unwrap_or(FindingCategory::CodeQuality);

    let finding = Finding {
        rule_id: rule_id.to_string(),
        category,
        severity,
        confidence: Confidence::High,
        evidence: vec![Evidence {
            path: path.to_path_buf(),
            line_start: 1,
            line_end: None,
            snippet: "synthetic finding for visibility explanation".to_string(),
        }],
        ..Default::default()
    };

    let decision = classify_visibility(&finding);

    ExplainVisibility {
        profile: "default".to_string(),
        intent: decision.intent.label().to_string(),
        visible_by_default: decision.visible_by_default,
        reason: decision.reason.to_string(),
    }
}