allow-report 0.1.3

Report and receipt rendering for cargo-allow source exception scans.
Documentation
use crate::diff_policy_detail::policy_change_detail;
use crate::diff_posture::{diff_net_posture, diff_posture_summary};
use crate::text::markdown_cell;
use crate::{CLAIM_BOUNDARY_TEXT, DiffFindingChange, DiffPolicyChange};

const PR_SUMMARY_HIGHLIGHT_LIMIT: usize = 8;

pub fn render_diff_pr_summary_markdown(
    current_failures: usize,
    finding_changes: &[DiffFindingChange<'_>],
    policy_changes: &[DiffPolicyChange<'_>],
) -> String {
    render_diff_pr_summary_markdown_with_evidence_health(
        current_failures,
        0,
        0,
        finding_changes,
        policy_changes,
    )
}

pub fn render_diff_pr_summary_markdown_with_evidence_health(
    current_failures: usize,
    broken_evidence_links: usize,
    weak_evidence_references: usize,
    finding_changes: &[DiffFindingChange<'_>],
    policy_changes: &[DiffPolicyChange<'_>],
) -> String {
    let summary = diff_posture_summary(current_failures, finding_changes, policy_changes);
    let posture = diff_net_posture(summary);
    let mut out = String::new();
    out.push_str("## PR Summary\n\n");
    out.push_str(&format!("**Net posture:** `{}`\n\n", posture.as_str()));
    out.push_str("| Signal | Count |\n|---|---:|\n");
    out.push_str(&format!(
        "| Current check failures | {} |\n",
        summary.current_failures
    ));
    if broken_evidence_links > 0 {
        out.push_str(&format!(
            "| Broken evidence links | {broken_evidence_links} |\n"
        ));
    }
    if weak_evidence_references > 0 {
        out.push_str(&format!(
            "| Weak evidence/link references | {weak_evidence_references} |\n"
        ));
    }
    out.push_str(&format!(
        "| New source findings | {} |\n",
        summary.new_findings
    ));
    out.push_str(&format!(
        "| Removed source findings | {} |\n",
        summary.removed_findings
    ));
    out.push_str(&format!(
        "| Policy failures | {} |\n",
        summary.policy_failures
    ));
    out.push_str(&format!(
        "| Policy review items | {} |\n",
        summary.policy_review_items
    ));
    out.push_str(&format!(
        "| Policy improvements | {} |\n",
        summary.policy_improvements
    ));
    out.push_str(&format!(
        "\n**Reviewer action:** {}\n\n",
        posture.reviewer_action()
    ));
    out.push_str("> ");
    out.push_str(CLAIM_BOUNDARY_TEXT);
    out.push_str("\n\n");
    append_finding_highlights(&mut out, finding_changes);
    append_policy_highlights(&mut out, policy_changes);
    out
}

fn append_finding_highlights(out: &mut String, finding_changes: &[DiffFindingChange<'_>]) {
    let new_count = finding_changes
        .iter()
        .filter(|change| change.change == "new")
        .count();
    if new_count > 0 {
        out.push_str("### Finding Attention\n\n");
        out.push_str("| Change | Kind | Family | Path |\n|---|---|---|---|\n");
        for change in finding_changes
            .iter()
            .filter(|change| change.change == "new")
            .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
        {
            append_finding_highlight_row(out, change);
        }
        append_omitted_summary_note(out, new_count, "new finding change");
        out.push('\n');
    }

    let removed_count = finding_changes
        .iter()
        .filter(|change| change.change == "removed")
        .count();
    if removed_count > 0 {
        out.push_str("### Finding Improvements\n\n");
        out.push_str("| Change | Kind | Family | Path |\n|---|---|---|---|\n");
        for change in finding_changes
            .iter()
            .filter(|change| change.change == "removed")
            .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
        {
            append_finding_highlight_row(out, change);
        }
        append_omitted_summary_note(out, removed_count, "removed finding change");
        out.push('\n');
    }
}

fn append_finding_highlight_row(out: &mut String, change: &DiffFindingChange<'_>) {
    out.push_str(&format!(
        "| `{}` | `{}` | `{}` | `{}` |\n",
        markdown_cell(change.change),
        markdown_cell(change.kind),
        markdown_cell(change.family.unwrap_or("")),
        markdown_cell(change.path)
    ));
}

fn append_policy_highlights(out: &mut String, policy_changes: &[DiffPolicyChange<'_>]) {
    let attention_count = policy_changes
        .iter()
        .filter(|change| change.severity != "improvement")
        .count();
    if attention_count > 0 {
        out.push_str("### Policy Attention\n\n");
        out.push_str("| Severity | Allow ID | Kind | Detail | Message |\n|---|---|---|---|---|\n");
        for change in policy_changes
            .iter()
            .filter(|change| change.severity != "improvement")
            .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
        {
            append_policy_highlight_row(out, change);
        }
        append_omitted_summary_note(out, attention_count, "policy attention change");
        out.push('\n');
    }

    let improvement_count = policy_changes
        .iter()
        .filter(|change| change.severity == "improvement")
        .count();
    if improvement_count > 0 {
        out.push_str("### Policy Improvements\n\n");
        out.push_str("| Allow ID | Kind | Detail | Message |\n|---|---|---|---|\n");
        for change in policy_changes
            .iter()
            .filter(|change| change.severity == "improvement")
            .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
        {
            let detail = policy_change_detail(change).unwrap_or_else(|| "none".to_string());
            out.push_str(&format!(
                "| `{}` | `{}` | {} | {} |\n",
                markdown_cell(change.allow_id),
                markdown_cell(change.kind),
                markdown_cell(&detail),
                markdown_cell(change.message)
            ));
        }
        append_omitted_summary_note(out, improvement_count, "policy improvement change");
        out.push('\n');
    }
}

fn append_omitted_summary_note(out: &mut String, count: usize, singular_label: &str) {
    if count > PR_SUMMARY_HIGHLIGHT_LIMIT {
        let omitted = count - PR_SUMMARY_HIGHLIGHT_LIMIT;
        let plural = if omitted == 1 { "" } else { "s" };
        out.push_str(&format!(
            "\n{omitted} additional {singular_label}{plural} omitted from this summary.\n"
        ));
    }
}

fn append_policy_highlight_row(out: &mut String, change: &DiffPolicyChange<'_>) {
    let detail = policy_change_detail(change).unwrap_or_else(|| "none".to_string());
    out.push_str(&format!(
        "| `{}` | `{}` | `{}` | {} | {} |\n",
        markdown_cell(change.severity),
        markdown_cell(change.allow_id),
        markdown_cell(change.kind),
        markdown_cell(&detail),
        markdown_cell(change.message)
    ));
}

pub fn insert_markdown_pr_summary(text: &mut String, summary: &str) {
    let marker = "Findings scanned:";
    if let Some(index) = text.find(marker) {
        text.insert_str(index, summary);
    } else {
        text.push('\n');
        text.push_str(summary);
    }
}

pub fn render_diff_finding_changes_markdown(changes: &[DiffFindingChange<'_>]) -> String {
    let mut out = String::new();
    out.push_str("\n## Finding Posture Changes\n\n");
    if changes.is_empty() {
        out.push_str("No source finding posture changes detected.\n");
        return out;
    }
    out.push_str("| Change | Kind | Family | Path |\n|---|---|---|---|\n");
    for change in changes.iter().take(120) {
        out.push_str(&format!(
            "| `{}` | `{}` | `{}` | `{}` |\n",
            markdown_cell(change.change),
            markdown_cell(change.kind),
            markdown_cell(change.family.unwrap_or("")),
            markdown_cell(change.path)
        ));
    }
    if changes.len() > 120 {
        out.push_str(&format!(
            "\n{} additional finding posture changes omitted.\n",
            changes.len() - 120
        ));
    }
    out
}

pub fn render_diff_policy_changes_markdown(changes: &[DiffPolicyChange<'_>]) -> String {
    let mut out = String::new();
    out.push_str("\n## Policy Posture Changes\n\n");
    if changes.is_empty() {
        out.push_str("No policy weakening detected.\n");
        return out;
    }
    out.push_str("| Severity | Allow ID | Kind | Detail | Message |\n|---|---|---|---|---|\n");
    for change in changes {
        let detail = policy_change_detail(change).unwrap_or_else(|| "none".to_string());
        out.push_str(&format!(
            "| `{}` | `{}` | `{}` | {} | {} |\n",
            markdown_cell(change.severity),
            markdown_cell(change.allow_id),
            markdown_cell(change.kind),
            markdown_cell(&detail),
            markdown_cell(change.message)
        ));
    }
    out
}