cargo-allow 0.1.11

Source-tree exception ledger and policy scanner for Rust repositories.
use super::{
    ExplainContext,
    explain_steps::{ExplainReferenceAttention, explain_next_steps},
};
use crate::evidence_inventory::{
    DEFAULT_SOURCE_TREE_INVENTORY_EVIDENCE_MESSAGE, evidence_reference_diagnostics_for_source_tree,
    policy_reference_diagnostics_for_source_tree,
};
use crate::evidence_render::evidence_reference_target_text;
use allow_core::{AllowEntry, Finding, MatchOutcome, allow_entry_broad_scope};
use allow_diff::selector_precision_score;
use std::collections::BTreeSet;
use std::path::Path;

pub(super) fn render_explain_entry(
    root: &Path,
    entry: &AllowEntry,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    evidence_source_tree_files: Option<&BTreeSet<String>>,
) -> String {
    render_explain_report(
        root,
        entry,
        findings,
        outcomes,
        evidence_source_tree_files,
        ExplainContext::default(),
        allow_report::render_explain_human,
    )
}

pub(super) fn render_explain_entry_json(
    root: &Path,
    entry: &AllowEntry,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    evidence_source_tree_files: Option<&BTreeSet<String>>,
    context: ExplainContext<'_>,
) -> String {
    render_explain_report(
        root,
        entry,
        findings,
        outcomes,
        evidence_source_tree_files,
        context,
        allow_report::render_explain_json,
    )
}

fn render_explain_report<R>(
    root: &Path,
    entry: &AllowEntry,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    evidence_source_tree_files: Option<&BTreeSet<String>>,
    context: ExplainContext<'_>,
    render: impl FnOnce(allow_report::ExplainReport<'_>) -> R,
) -> R {
    let evidence_diagnostics =
        evidence_reference_diagnostics_for_source_tree(root, entry, evidence_source_tree_files);
    let link_diagnostics =
        link_reference_diagnostics_for_source_tree(root, entry, evidence_source_tree_files);
    let references = ExplainReferenceAttention {
        has_broken_evidence: evidence_diagnostics
            .iter()
            .any(|diagnostic| diagnostic.status.is_broken_local_link()),
        has_weak_evidence: evidence_diagnostics
            .iter()
            .any(|diagnostic| diagnostic.status.is_weak_reference()),
        has_evidence_outside_default_inventory: evidence_diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message == DEFAULT_SOURCE_TREE_INVENTORY_EVIDENCE_MESSAGE),
        has_broken_link: link_diagnostics
            .iter()
            .any(|diagnostic| diagnostic.status.is_broken_local_link()),
        has_weak_link: link_diagnostics
            .iter()
            .any(|diagnostic| diagnostic.status.is_weak_reference()),
        has_link_outside_default_inventory: link_diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message == DEFAULT_SOURCE_TREE_INVENTORY_EVIDENCE_MESSAGE),
    };
    let (suggested_actions, proof_commands) =
        explain_next_steps(entry, findings, outcomes, references);
    let normalized_targets = evidence_diagnostics
        .iter()
        .map(evidence_reference_target_text)
        .collect::<Vec<_>>();
    let evidence_references = evidence_diagnostics
        .iter()
        .zip(normalized_targets.iter())
        .map(|(diagnostic, target)| allow_report::EvidenceReference {
            raw: &diagnostic.raw,
            prefix: diagnostic.prefix.as_deref(),
            target: target.as_deref(),
            status: diagnostic.status.as_str(),
            category: diagnostic.category.as_str(),
            message: &diagnostic.message,
        })
        .collect::<Vec<_>>();
    let link_normalized_targets = link_diagnostics
        .iter()
        .map(evidence_reference_target_text)
        .collect::<Vec<_>>();
    let link_messages = link_diagnostics
        .iter()
        .map(|diagnostic| link_reference_message(&diagnostic.message))
        .collect::<Vec<_>>();
    let link_references = link_diagnostics
        .iter()
        .zip(link_normalized_targets.iter())
        .zip(link_messages.iter())
        .map(
            |((diagnostic, target), message)| allow_report::EvidenceReference {
                raw: &diagnostic.raw,
                prefix: diagnostic.prefix.as_deref(),
                target: target.as_deref(),
                status: diagnostic.status.as_str(),
                category: diagnostic.category.as_str(),
                message,
            },
        )
        .collect::<Vec<_>>();

    render(allow_report::ExplainReport {
        inventory: context.inventory,
        entry,
        selector_precision: selector_precision_score(entry),
        broad_scope: allow_entry_broad_scope(entry).is_some(),
        current_findings: findings,
        match_outcomes: outcomes,
        evidence_references: &evidence_references,
        link_references: &link_references,
        suggested_actions: &suggested_actions,
        proof_commands: &proof_commands,
    })
}

fn link_reference_diagnostics_for_source_tree(
    root: &Path,
    entry: &AllowEntry,
    evidence_source_tree_files: Option<&BTreeSet<String>>,
) -> Vec<allow_policy::EvidenceReferenceDiagnostic> {
    policy_reference_diagnostics_for_source_tree(root, entry, evidence_source_tree_files)
        .into_iter()
        .filter(|reference| reference.source == crate::evidence_inventory::ReferenceSource::Link)
        .map(|reference| reference.diagnostic)
        .collect()
}

fn link_reference_message(message: &str) -> String {
    message.replace("evidence", "link")
}

#[cfg(test)]
mod tests {
    use super::super::test_support::{test_entry, test_finding};
    use super::*;
    use allow_core::{FindingKind, MatchOutcome, MatchStatus};

    #[test]
    fn render_explain_entry_json_uses_json_renderer_with_context() {
        let mut entry = test_entry("allow-json-render", FindingKind::NonRustFile);
        entry.evidence = vec!["test:manual-review".to_string()];
        let json = render_explain_entry_json(
            Path::new("."),
            &entry,
            &[],
            &[],
            None,
            ExplainContext {
                inventory: allow_report::InventoryContext::source_syntax(
                    "git_tracked",
                    Some("H:/Code/Rust/cargo-allow"),
                    Some(47),
                ),
            },
        );

        assert!(json.contains("\"schema_id\": \"cargo-allow.explain.v1\""));
        assert!(json.contains("\"command\": \"explain\""));
        assert!(json.contains("\"id\": \"allow-json-render\""));
        assert!(json.contains("\"source\": \"git_tracked\""));
        assert!(json.contains("\"files_scanned\": 47"));
    }

    #[test]
    fn render_explain_report_collects_reference_diagnostics_and_next_steps() {
        let mut entry = test_entry("allow-reference-diagnostics", FindingKind::NonRustFile);
        entry.evidence = vec![
            "doc:Cargo.toml".to_string(),
            "doc:docs/missing-evidence.md".to_string(),
            "test:manual-review".to_string(),
        ];
        entry.links = vec![
            "doc:Cargo.toml".to_string(),
            "doc:docs/missing-link.md".to_string(),
            "issue:123".to_string(),
        ];
        let source_tree_files = BTreeSet::new();
        let captured = render_explain_report(
            Path::new("."),
            &entry,
            &[],
            &[],
            Some(&source_tree_files),
            ExplainContext::default(),
            capture_explain_report,
        );

        assert_eq!(captured.entry_id, "allow-reference-diagnostics");
        assert_eq!(captured.evidence_references.len(), 3);
        assert_eq!(captured.link_references.len(), 3);
        let cargo_toml_evidence =
            captured.reference_by_raw(&captured.evidence_references, "doc:Cargo.toml");
        assert_eq!(cargo_toml_evidence.target.as_deref(), Some("Cargo.toml"));
        assert_eq!(cargo_toml_evidence.status, "local_file_missing");
        assert_eq!(
            cargo_toml_evidence.message,
            DEFAULT_SOURCE_TREE_INVENTORY_EVIDENCE_MESSAGE
        );
        let missing_evidence = captured.reference_by_raw(
            &captured.evidence_references,
            "doc:docs/missing-evidence.md",
        );
        assert_eq!(missing_evidence.status, "local_file_missing");
        assert_eq!(missing_evidence.category, "missing");
        let traceability_evidence =
            captured.reference_by_raw(&captured.evidence_references, "test:manual-review");
        assert_eq!(traceability_evidence.status, "traceability_only");

        let cargo_toml_link =
            captured.reference_by_raw(&captured.link_references, "doc:Cargo.toml");
        assert_eq!(cargo_toml_link.target.as_deref(), Some("Cargo.toml"));
        assert_eq!(cargo_toml_link.status, "local_file_missing");
        assert_eq!(
            cargo_toml_link.message,
            "local link file exists but is not in the default source-tree inventory"
        );
        let missing_link =
            captured.reference_by_raw(&captured.link_references, "doc:docs/missing-link.md");
        assert_eq!(missing_link.status, "local_file_missing");
        assert_eq!(missing_link.message, "local link file is missing");
        let issue_link = captured.reference_by_raw(&captured.link_references, "issue:123");
        assert_eq!(issue_link.status, "traceability_only");

        assert!(captured.suggested_actions.iter().any(|action| {
            action == "commit the referenced evidence file if it should support repository policy"
        }));
        assert!(captured.proof_commands.iter().any(|command| {
            command == "cargo-allow explain allow-reference-diagnostics --include-untracked"
        }));
    }

    #[test]
    fn render_explain_report_maps_live_state_and_scope_metrics() {
        let mut entry = test_entry("allow-broad", FindingKind::Panic);
        entry.path = None;
        entry.glob = Some("src/**".to_string());
        entry.family = Some("unwrap".to_string());
        entry.selector.callee = Some("unwrap".to_string());
        let finding = test_finding(
            FindingKind::Panic,
            Some("unwrap"),
            "src/lib.rs",
            "method_call",
        );
        let outcomes = vec![MatchOutcome {
            status: MatchStatus::New,
            allow_id: Some(entry.id.clone()),
            candidate_ids: Vec::new(),
            finding_index: Some(0),
            message: "new panic finding".to_string(),
            score: 72,
        }];

        let captured = render_explain_report(
            Path::new("."),
            &entry,
            std::slice::from_ref(&finding),
            &outcomes,
            None,
            ExplainContext::default(),
            capture_explain_report,
        );

        assert!(captured.broad_scope);
        assert_eq!(captured.current_findings, 1);
        assert_eq!(captured.match_outcomes, 1);
        assert!(captured.selector_precision > 0);
        assert!(!captured.suggested_actions.is_empty());
        assert!(
            captured
                .proof_commands
                .iter()
                .any(|command| command == "cargo-allow explain allow-broad")
        );
    }

    #[test]
    fn link_reference_diagnostics_filters_link_sources_only() {
        let mut entry = test_entry("allow-links-only", FindingKind::NonRustFile);
        entry.evidence = vec!["doc:docs/missing-evidence.md".to_string()];
        entry.links = vec![
            "doc:docs/missing-link.md".to_string(),
            "issue:123".to_string(),
        ];

        let diagnostics = link_reference_diagnostics_for_source_tree(Path::new("."), &entry, None);

        assert_eq!(diagnostics.len(), 2);
        assert!(
            diagnostics
                .iter()
                .any(|diagnostic| diagnostic.raw == "doc:docs/missing-link.md")
        );
        assert!(
            diagnostics
                .iter()
                .any(|diagnostic| diagnostic.raw == "issue:123")
        );
        assert!(
            !diagnostics
                .iter()
                .any(|diagnostic| diagnostic.raw == "doc:docs/missing-evidence.md")
        );
    }

    #[derive(Debug)]
    struct CapturedExplainReport {
        entry_id: String,
        selector_precision: u32,
        broad_scope: bool,
        current_findings: usize,
        match_outcomes: usize,
        evidence_references: Vec<CapturedReference>,
        link_references: Vec<CapturedReference>,
        suggested_actions: Vec<String>,
        proof_commands: Vec<String>,
    }

    impl CapturedExplainReport {
        fn reference_by_raw<'a>(
            &'a self,
            references: &'a [CapturedReference],
            raw: &str,
        ) -> &'a CapturedReference {
            let Some(reference) = references.iter().find(|reference| reference.raw == raw) else {
                std::panic::panic_any(format!("expected reference `{raw}` in {references:?}"));
            };
            reference
        }
    }

    #[derive(Debug)]
    struct CapturedReference {
        raw: String,
        target: Option<String>,
        status: String,
        category: String,
        message: String,
    }

    fn capture_explain_report(report: allow_report::ExplainReport<'_>) -> CapturedExplainReport {
        CapturedExplainReport {
            entry_id: report.entry.id.clone(),
            selector_precision: report.selector_precision,
            broad_scope: report.broad_scope,
            current_findings: report.current_findings.len(),
            match_outcomes: report.match_outcomes.len(),
            evidence_references: capture_references(report.evidence_references),
            link_references: capture_references(report.link_references),
            suggested_actions: report.suggested_actions.to_vec(),
            proof_commands: report.proof_commands.to_vec(),
        }
    }

    fn capture_references(
        references: &[allow_report::EvidenceReference<'_>],
    ) -> Vec<CapturedReference> {
        references
            .iter()
            .map(|reference| CapturedReference {
                raw: reference.raw.to_string(),
                target: reference.target.map(str::to_string),
                status: reference.status.to_string(),
                category: reference.category.to_string(),
                message: reference.message.to_string(),
            })
            .collect()
    }
}