allow-report 0.1.4

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;
const DIFF_MARKDOWN_CHANGE_LIMIT: usize = 120;

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()
    ));
    if broken_evidence_links > 0 || weak_evidence_references > 0 {
        out.push_str("**Evidence repair queues:**\n");
        if broken_evidence_links > 0 {
            out.push_str(
                "- `cargo-allow worklist --item-kind broken_evidence_link --format json`\n",
            );
        }
        if weak_evidence_references > 0 {
            out.push_str(
                "- `cargo-allow worklist --item-kind weak_evidence_reference --format json`\n",
            );
        }
        out.push('\n');
    }
    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<'_>]) {
    append_policy_severity_highlights(
        out,
        policy_changes,
        "fail",
        "### Policy Failures",
        "policy failure",
    );
    append_policy_severity_highlights(
        out,
        policy_changes,
        "review",
        "### Policy Review Required",
        "policy review item",
    );

    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_policy_severity_highlights(
    out: &mut String,
    policy_changes: &[DiffPolicyChange<'_>],
    severity: &str,
    heading: &str,
    singular_label: &str,
) {
    let count = policy_changes
        .iter()
        .filter(|change| change.severity == severity)
        .count();
    if count == 0 {
        return;
    }

    out.push_str(heading);
    out.push_str("\n\n");
    out.push_str("| Severity | Allow ID | Kind | Detail | Message |\n|---|---|---|---|---|\n");
    for change in policy_changes
        .iter()
        .filter(|change| change.severity == severity)
        .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
    {
        append_policy_highlight_row(out, change);
    }
    append_omitted_summary_note(out, count, singular_label);
    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;
    }
    append_finding_changes_markdown_section(&mut out, "Finding Attention", changes, "new");
    append_finding_changes_markdown_section(&mut out, "Finding Improvements", changes, "removed");
    let known_changes = ["new", "removed"];
    if changes
        .iter()
        .any(|change| !known_changes.contains(&change.change))
    {
        out.push_str("### Other Finding Changes\n\n");
        append_finding_changes_markdown_table(
            &mut out,
            changes
                .iter()
                .filter(|change| !known_changes.contains(&change.change)),
        );
    }
    out
}

fn append_finding_changes_markdown_section<'a>(
    out: &mut String,
    heading: &str,
    changes: &'a [DiffFindingChange<'a>],
    change_kind: &str,
) {
    if !changes.iter().any(|change| change.change == change_kind) {
        return;
    }
    out.push_str(&format!("### {heading}\n\n"));
    append_finding_changes_markdown_table(
        out,
        changes.iter().filter(|change| change.change == change_kind),
    );
}

fn append_finding_changes_markdown_table<'a>(
    out: &mut String,
    changes: impl Iterator<Item = &'a DiffFindingChange<'a>>,
) {
    let changes = changes.collect::<Vec<_>>();
    out.push_str("| Change | Kind | Family | Path |\n|---|---|---|---|\n");
    for change in changes.iter().take(DIFF_MARKDOWN_CHANGE_LIMIT) {
        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() > DIFF_MARKDOWN_CHANGE_LIMIT {
        out.push_str(&format!(
            "\n{} additional finding posture changes omitted.\n",
            changes.len() - DIFF_MARKDOWN_CHANGE_LIMIT
        ));
    }
    out.push('\n');
}

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;
    }
    append_policy_changes_markdown_section(&mut out, "Policy Failures", changes, "fail");
    append_policy_changes_markdown_section(&mut out, "Policy Review Required", changes, "review");
    append_policy_changes_markdown_section(&mut out, "Policy Improvements", changes, "improvement");
    let known_severities = ["fail", "review", "improvement"];
    if changes
        .iter()
        .any(|change| !known_severities.contains(&change.severity))
    {
        out.push_str("### Other Policy Changes\n\n");
        append_policy_changes_markdown_table(
            &mut out,
            changes
                .iter()
                .filter(|change| !known_severities.contains(&change.severity)),
        );
    }
    out
}

fn append_policy_changes_markdown_section<'a>(
    out: &mut String,
    heading: &str,
    changes: &'a [DiffPolicyChange<'a>],
    severity: &str,
) {
    if !changes.iter().any(|change| change.severity == severity) {
        return;
    }
    out.push_str(&format!("### {heading}\n\n"));
    append_policy_changes_markdown_table(
        out,
        changes.iter().filter(|change| change.severity == severity),
    );
}

fn append_policy_changes_markdown_table<'a>(
    out: &mut String,
    changes: impl Iterator<Item = &'a DiffPolicyChange<'a>>,
) {
    let changes = changes.collect::<Vec<_>>();
    out.push_str("| Severity | Allow ID | Kind | Detail | Message |\n|---|---|---|---|---|\n");
    for change in changes.iter().take(DIFF_MARKDOWN_CHANGE_LIMIT) {
        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)
        ));
    }
    if changes.len() > DIFF_MARKDOWN_CHANGE_LIMIT {
        out.push_str(&format!(
            "\n{} additional policy posture changes omitted.\n",
            changes.len() - DIFF_MARKDOWN_CHANGE_LIMIT
        ));
    }
    out.push('\n');
}