allow-report 0.1.9

Report and receipt rendering for cargo-allow source exception scans.
Documentation
use crate::evidence_reference_human::evidence_reference_human_status;
use crate::explain_common::{explain_report_status, finding_location_text};
use crate::{CLAIM_BOUNDARY_TEXT, EvidenceReference, ExplainReport};
use allow_core::{AllowEntry, MatchOutcome, MatchStatus};

pub fn render_explain_human(report: ExplainReport<'_>) -> String {
    let entry = report.entry;
    let mut out = String::new();
    out.push_str(&format!("{}\n", entry.id));
    out.push_str(&format!("kind: {}\n", explain_kind_label(entry)));
    out.push_str(&format!("scope: {}\n", entry.path_or_glob()));
    out.push_str(&format!("owner: {}\n", empty_as_none(&entry.owner)));
    out.push_str(&format!(
        "classification: {}\n",
        empty_as_none(&entry.classification)
    ));
    out.push_str(&format!("reason: {}\n", empty_as_none(&entry.reason)));
    out.push_str(&format!("evidence: {}\n", list_or_none(&entry.evidence)));
    if !report.evidence_references.is_empty() {
        out.push_str("\nevidence diagnostics:\n");
        for reference in report.evidence_references {
            out.push_str(&format!("- {}\n", evidence_reference_summary(reference)));
            out.push_str(&format!("  message: {}\n", reference.message));
        }
    }
    if !entry.links.is_empty() {
        out.push_str(&format!("links: {}\n", entry.links.join(", ")));
    }
    if !report.link_references.is_empty() {
        out.push_str("\nlink diagnostics:\n");
        for reference in report.link_references {
            out.push_str(&format!("- {}\n", evidence_reference_summary(reference)));
            out.push_str(&format!("  message: {}\n", reference.message));
        }
    }
    if let Some(limit) = entry.occurrence_limit {
        out.push_str(&format!("occurrence_limit: {limit}\n"));
    }
    if let Some(created) = &entry.lifecycle.created {
        out.push_str(&format!("created: {created}\n"));
    }
    if let Some(expires) = &entry.lifecycle.expires {
        out.push_str(&format!("expires: {expires}\n"));
    }
    if let Some(review_after) = &entry.lifecycle.review_after {
        out.push_str(&format!("review_after: {review_after}\n"));
    }
    if let Some(last_seen) = &entry.last_seen {
        out.push_str(&format!(
            "last_seen: {}:{}\n",
            last_seen.line, last_seen.column
        ));
    }
    out.push_str(&format!("selector: {}\n", selector_summary(entry)));
    out.push_str(&format!(
        "selector_precision: {}\n",
        report.selector_precision
    ));
    out.push_str(&format!("broad_scope: {}\n\n", report.broad_scope));
    out.push_str(&format!(
        "current_status: {}\n",
        explain_report_status(report.match_outcomes).as_str()
    ));
    out.push_str(&format!(
        "current_matches: {}\n",
        report.current_findings.len()
    ));
    out.push_str(&format!(
        "match_outcomes: {}\n",
        outcome_summary(report.match_outcomes)
    ));
    if !report.current_findings.is_empty() {
        out.push_str("\ncurrent findings:\n");
        for (index, finding) in report.current_findings.iter().enumerate().take(20) {
            let status = report
                .match_outcomes
                .iter()
                .find(|outcome| outcome.finding_index == Some(index))
                .map(|outcome| outcome.status.as_str())
                .unwrap_or("unmatched");
            let package = finding
                .source_package_name()
                .map(|package| format!(", source_package={package}"))
                .unwrap_or_default();
            out.push_str(&format!(
                "- {status}: {} ({}{})\n",
                finding_location_text(finding),
                finding.identity.ast_kind,
                package
            ));
        }
        if report.current_findings.len() > 20 {
            out.push_str(&format!(
                "- ... {} more matching findings omitted\n",
                report.current_findings.len() - 20
            ));
        }
    }
    let attention = report
        .match_outcomes
        .iter()
        .filter(|outcome| outcome.status != MatchStatus::Matched)
        .collect::<Vec<_>>();
    if !attention.is_empty() {
        out.push_str("\nattention:\n");
        for outcome in attention.iter().take(20) {
            out.push_str(&format!(
                "- {}: {}\n",
                outcome.status.as_str(),
                outcome.message
            ));
        }
    } else if entry.classification == "baseline_debt" {
        out.push_str("\nattention:\n");
        out.push_str(&format!(
            "- baseline_debt: {} is generated baseline_debt and still needs human review\n",
            entry.id
        ));
    }
    if !report.suggested_actions.is_empty() || !report.proof_commands.is_empty() {
        out.push_str("\nnext:\n");
        for action in report.suggested_actions.iter().take(2) {
            out.push_str(&format!("- action: {action}\n"));
        }
        for command in report.proof_commands.iter().take(8) {
            out.push_str(&format!("- proof: {command}\n"));
        }
    }
    out.push('\n');
    out.push_str(CLAIM_BOUNDARY_TEXT);
    out
}

fn explain_kind_label(entry: &AllowEntry) -> String {
    entry
        .family
        .as_ref()
        .map(|family| format!("{}.{}", entry.kind, family))
        .unwrap_or_else(|| entry.kind.to_string())
}

fn empty_as_none(value: &str) -> &str {
    if value.trim().is_empty() {
        "none"
    } else {
        value
    }
}

fn list_or_none(values: &[String]) -> String {
    if values.is_empty() {
        "none".to_string()
    } else {
        values.join(", ")
    }
}

fn evidence_reference_summary(reference: &EvidenceReference<'_>) -> String {
    let status = evidence_reference_human_status(reference);
    format!(
        "{}: {} (status={}, prefix={}, target={})",
        status.label,
        reference.raw,
        reference.status,
        reference.prefix.unwrap_or("-"),
        reference.target.unwrap_or("-")
    )
}

fn selector_summary(entry: &AllowEntry) -> String {
    let selector = &entry.selector;
    let mut fields = Vec::new();
    if let Some(value) = &selector.ast_kind {
        fields.push(format!("ast_kind={value}"));
    }
    if let Some(value) = &selector.container {
        fields.push(format!("container={value}"));
    }
    if let Some(value) = &selector.callee {
        fields.push(format!("callee={value}"));
    }
    if let Some(value) = &selector.macro_name {
        fields.push(format!("macro_name={value}"));
    }
    if let Some(value) = &selector.lint {
        fields.push(format!("lint={value}"));
    }
    if let Some(value) = &selector.symbol {
        fields.push(format!("symbol={value}"));
    }
    if let Some(value) = &selector.receiver_fingerprint {
        fields.push(format!("receiver={value}"));
    }
    if let Some(value) = &selector.target_fingerprint {
        fields.push(format!("target={value}"));
    }
    if let Some(value) = &selector.normalized_snippet_hash {
        fields.push(format!("normalized_snippet_hash={value}"));
    }
    if let Some(value) = selector.line_hint {
        fields.push(format!("line_hint={value}"));
    }
    if let Some(value) = &selector.glob {
        fields.push(format!("glob={value}"));
    }
    if fields.is_empty() {
        "none".to_string()
    } else {
        fields.join(", ")
    }
}

fn outcome_summary(outcomes: &[MatchOutcome]) -> String {
    let parts = [
        MatchStatus::Matched,
        MatchStatus::New,
        MatchStatus::Expired,
        MatchStatus::ReviewDue,
        MatchStatus::Stale,
        MatchStatus::Ambiguous,
        MatchStatus::InvalidSelector,
        MatchStatus::MissingRequiredField,
        MatchStatus::EvidenceMissing,
        MatchStatus::BaselineDebt,
    ]
    .into_iter()
    .filter_map(|status| {
        let count = outcomes
            .iter()
            .filter(|outcome| outcome.status == status)
            .count();
        (count > 0).then(|| format!("{}={count}", status.as_str()))
    })
    .collect::<Vec<_>>();
    if parts.is_empty() {
        "none".to_string()
    } else {
        parts.join(", ")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use allow_core::{FindingKind, Lifecycle, Selector};
    use std::path::PathBuf;

    #[test]
    fn explain_kind_label_call_presence_observer() {
        let entry = allow_entry(FindingKind::Unsafe, Some("unsafe_block"));
        assert_eq!(explain_kind_label(&entry), "unsafe.unsafe_block");

        let entry = allow_entry(FindingKind::Panic, None);
        assert_eq!(explain_kind_label(&entry), "panic");
    }

    #[test]
    fn empty_as_none_boundary_discriminator() {
        assert_eq!(empty_as_none("owner"), "owner");
        assert_eq!(empty_as_none(""), "none");
        assert_eq!(empty_as_none("   "), "none");
    }

    #[test]
    fn list_or_none_boundary_discriminator() {
        assert_eq!(list_or_none(&[]), "none");
        assert_eq!(list_or_none(&["doc:one".to_string()]), "doc:one");
        assert_eq!(
            list_or_none(&["doc:one".to_string(), "issue:two".to_string()]),
            "doc:one, issue:two"
        );
    }

    #[test]
    fn evidence_reference_summary_call_presence_observer() {
        let reference = EvidenceReference {
            raw: "doc:docs/safety.md",
            prefix: Some("doc"),
            target: Some("docs/safety.md"),
            status: "local_file_missing",
            category: "missing",
            message: "local evidence file is missing",
        };

        assert_eq!(
            evidence_reference_summary(&reference),
            "missing: doc:docs/safety.md (status=local_file_missing, prefix=doc, target=docs/safety.md)"
        );
    }

    #[test]
    fn evidence_reference_summary_uses_fallbacks_for_missing_prefix_and_target() {
        let reference = EvidenceReference {
            raw: "README.md",
            prefix: None,
            target: None,
            status: "weak_reference",
            category: "untyped",
            message: "reference is weak",
        };

        assert_eq!(
            evidence_reference_summary(&reference),
            "weak: README.md (status=weak_reference, prefix=-, target=-)"
        );
    }

    #[test]
    fn selector_summary_boundary_discriminator() {
        let entry = allow_entry(FindingKind::Unsafe, Some("unsafe_block"));
        assert_eq!(selector_summary(&entry), "none");

        let entry = allow_entry_with_selector(Selector {
            ast_kind: Some("unsafe_block".to_string()),
            container: Some("read_byte".to_string()),
            callee: Some("read".to_string()),
            macro_name: Some("panic".to_string()),
            lint: Some("clippy::unwrap_used".to_string()),
            symbol: Some("read_byte".to_string()),
            receiver_fingerprint: Some("reader".to_string()),
            target_fingerprint: Some("ptr".to_string()),
            normalized_snippet_hash: Some("fnv1a64:abc".to_string()),
            line_hint: Some(42),
            glob: Some("src/**/*.rs".to_string()),
        });

        assert_eq!(
            selector_summary(&entry),
            "ast_kind=unsafe_block, container=read_byte, callee=read, macro_name=panic, lint=clippy::unwrap_used, symbol=read_byte, receiver=reader, target=ptr, normalized_snippet_hash=fnv1a64:abc, line_hint=42, glob=src/**/*.rs"
        );
    }

    #[test]
    fn outcome_summary_call_presence_observer() {
        let outcomes = vec![
            outcome(MatchStatus::Matched),
            outcome(MatchStatus::New),
            outcome(MatchStatus::New),
            outcome(MatchStatus::Expired),
            outcome(MatchStatus::ReviewDue),
            outcome(MatchStatus::Stale),
            outcome(MatchStatus::Ambiguous),
            outcome(MatchStatus::InvalidSelector),
            outcome(MatchStatus::MissingRequiredField),
            outcome(MatchStatus::EvidenceMissing),
            outcome(MatchStatus::BaselineDebt),
        ];

        assert_eq!(
            outcome_summary(&outcomes),
            "matched=1, new=2, expired=1, review_due=1, stale=1, ambiguous=1, invalid_selector=1, missing_required_field=1, evidence_missing=1, baseline_debt=1"
        );
    }

    #[test]
    fn outcome_summary_boundary_discriminator() {
        assert_eq!(outcome_summary(&[]), "none");
    }

    fn allow_entry(kind: FindingKind, family: Option<&str>) -> AllowEntry {
        let mut entry = allow_entry_with_selector(Selector::default());
        entry.kind = kind;
        entry.family = family.map(str::to_string);
        entry
    }

    fn allow_entry_with_selector(selector: Selector) -> AllowEntry {
        AllowEntry {
            id: "allow-test".to_string(),
            kind: FindingKind::Unsafe,
            family: Some("unsafe_block".to_string()),
            path: Some(PathBuf::from("src/lib.rs")),
            glob: None,
            owner: "owner".to_string(),
            classification: "classification".to_string(),
            reason: "reason".to_string(),
            evidence: Vec::new(),
            links: Vec::new(),
            occurrence_limit: None,
            lifecycle: Lifecycle::empty(),
            selector,
            last_seen: None,
        }
    }

    fn outcome(status: MatchStatus) -> MatchOutcome {
        MatchOutcome {
            status,
            allow_id: None,
            finding_index: None,
            message: String::new(),
            score: 0,
        }
    }
}