allow-report 0.1.5

Report and receipt rendering for cargo-allow source exception scans.
Documentation
use allow_core::{Finding, MatchOutcome, MatchStatus, json_escape};
use std::collections::BTreeMap;

use crate::text::{html_escape, markdown_cell};

#[derive(Debug, Clone, Default)]
struct SourceInventoryRow {
    total: usize,
    matched: usize,
    new: usize,
    review_items: usize,
}

impl SourceInventoryRow {
    fn add_finding(&mut self) {
        self.total += 1;
    }

    fn add_status(&mut self, status: MatchStatus) {
        match status {
            MatchStatus::Matched => self.matched += 1,
            MatchStatus::New => {
                self.new += 1;
                self.review_items += 1;
            }
            _ => {
                if status != MatchStatus::Matched {
                    self.review_items += 1;
                }
            }
        }
    }
}

#[derive(Debug, Clone, Default)]
struct SourceInventory {
    total: usize,
    by_kind: BTreeMap<String, SourceInventoryRow>,
    by_family: BTreeMap<SourceInventoryFamilyKey, SourceInventoryRow>,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct SourceInventoryFamilyKey {
    kind: String,
    family: String,
}

impl SourceInventoryFamilyKey {
    fn label(&self) -> String {
        format!("{}.{}", self.kind, self.family)
    }
}

impl SourceInventory {
    fn from_report(findings: &[Finding], outcomes: &[MatchOutcome]) -> Self {
        let mut inventory = Self::default();
        for finding in findings {
            inventory.total += 1;
            inventory.kind_row_mut(finding).add_finding();
            inventory.family_row_mut(finding).add_finding();
        }
        for outcome in outcomes {
            let Some(finding) = outcome.finding_index.and_then(|index| findings.get(index)) else {
                continue;
            };
            inventory.kind_row_mut(finding).add_status(outcome.status);
            inventory.family_row_mut(finding).add_status(outcome.status);
        }
        inventory
    }

    fn kind_row_mut(&mut self, finding: &Finding) -> &mut SourceInventoryRow {
        self.by_kind
            .entry(finding.kind.as_str().to_string())
            .or_default()
    }

    fn family_row_mut(&mut self, finding: &Finding) -> &mut SourceInventoryRow {
        self.by_family
            .entry(finding_family_key(finding))
            .or_default()
    }

    fn has_findings(&self) -> bool {
        self.total > 0
    }
}

pub(crate) fn render_source_inventory_human(
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    out: &mut String,
) {
    let inventory = SourceInventory::from_report(findings, outcomes);
    if !inventory.has_findings() {
        return;
    }
    out.push('\n');
    out.push_str("Source exception inventory:\n");
    out.push_str(&format!("  findings                 {}\n", inventory.total));
    out.push_str("  by kind:\n");
    for (kind, row) in &inventory.by_kind {
        append_human_row(out, kind, row);
    }
    out.push_str("  by family:\n");
    for (family, row) in &inventory.by_family {
        append_human_row(out, &family.label(), row);
    }
}

fn append_human_row(out: &mut String, label: &str, row: &SourceInventoryRow) {
    out.push_str(&format!(
        "    {:24} total={} matched={} new={} review_items={}\n",
        label, row.total, row.matched, row.new, row.review_items
    ));
}

pub(crate) fn render_source_inventory_markdown(
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    out: &mut String,
) {
    let inventory = SourceInventory::from_report(findings, outcomes);
    if !inventory.has_findings() {
        return;
    }
    out.push_str("\n## Source Exception Inventory\n\n");
    out.push_str(&format!("Findings inventoried: `{}`\n\n", inventory.total));
    out.push_str("| Kind | Total | Matched | New | Review items |\n|---|---:|---:|---:|---:|\n");
    for (kind, row) in &inventory.by_kind {
        append_markdown_row(out, kind, row);
    }
    out.push_str(
        "\n| Family | Total | Matched | New | Review items |\n|---|---:|---:|---:|---:|\n",
    );
    for (family, row) in &inventory.by_family {
        append_markdown_row(out, &family.label(), row);
    }
}

fn append_markdown_row(out: &mut String, label: &str, row: &SourceInventoryRow) {
    out.push_str(&format!(
        "| `{}` | {} | {} | {} | {} |\n",
        markdown_cell(label),
        row.total,
        row.matched,
        row.new,
        row.review_items
    ));
}

pub(crate) fn render_source_inventory_html(
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    out: &mut String,
) {
    let inventory = SourceInventory::from_report(findings, outcomes);
    if !inventory.has_findings() {
        return;
    }
    out.push_str("<h2>Source Exception Inventory</h2>\n");
    out.push_str(&format!(
        "<p>Findings inventoried: <code>{}</code></p>\n",
        inventory.total
    ));
    out.push_str("<table><thead><tr><th>Kind</th><th>Total</th><th>Matched</th><th>New</th><th>Review items</th></tr></thead><tbody>\n");
    for (kind, row) in &inventory.by_kind {
        append_html_row(out, "kind", kind, row);
    }
    out.push_str("</tbody></table>\n");
    out.push_str("<table><thead><tr><th>Family</th><th>Total</th><th>Matched</th><th>New</th><th>Review items</th></tr></thead><tbody>\n");
    for (family, row) in &inventory.by_family {
        append_html_row(out, "family", &family.label(), row);
    }
    out.push_str("</tbody></table>\n");
}

fn append_html_row(out: &mut String, class: &str, label: &str, row: &SourceInventoryRow) {
    out.push_str(&format!(
        "<tr><td><code class=\"{}\">{}</code></td><td class=\"count\">{}</td><td class=\"count\">{}</td><td class=\"count\">{}</td><td class=\"count\">{}</td></tr>\n",
        html_escape(class),
        html_escape(label),
        row.total,
        row.matched,
        row.new,
        row.review_items
    ));
}

pub(crate) fn render_source_inventory_json(
    findings: &[Finding],
    outcomes: &[MatchOutcome],
    indent: &str,
) -> Option<String> {
    let inventory = SourceInventory::from_report(findings, outcomes);
    if !inventory.has_findings() {
        return None;
    }

    let mut out = String::new();
    out.push_str("{\n");
    out.push_str(&format!("{indent}  \"findings\": {},\n", inventory.total));
    out.push_str(&format!("{indent}  \"by_kind\": [\n"));
    for (index, (kind, row)) in inventory.by_kind.iter().enumerate() {
        if index > 0 {
            out.push_str(",\n");
        }
        out.push_str(&format!(
            "{indent}    {{\"kind\": \"{}\", \"total\": {}, \"matched\": {}, \"new\": {}, \"review_items\": {}}}",
            json_escape(kind),
            row.total,
            row.matched,
            row.new,
            row.review_items
        ));
    }
    out.push_str(&format!("\n{indent}  ],\n"));
    out.push_str(&format!("{indent}  \"by_family\": [\n"));
    for (index, (family, row)) in inventory.by_family.iter().enumerate() {
        if index > 0 {
            out.push_str(",\n");
        }
        out.push_str(&format!(
            "{indent}    {{\"kind\": \"{}\", \"family\": \"{}\", \"label\": \"{}\", \"total\": {}, \"matched\": {}, \"new\": {}, \"review_items\": {}}}",
            json_escape(&family.kind),
            json_escape(&family.family),
            json_escape(&family.label()),
            row.total,
            row.matched,
            row.new,
            row.review_items
        ));
    }
    out.push_str(&format!("\n{indent}  ]\n"));
    out.push_str(&format!("{indent}}}"));
    Some(out)
}

fn finding_family_key(finding: &Finding) -> SourceInventoryFamilyKey {
    SourceInventoryFamilyKey {
        kind: finding.kind.as_str().to_string(),
        family: finding
            .family
            .as_deref()
            .map(str::trim)
            .filter(|family| !family.is_empty())
            .unwrap_or("unknown")
            .to_string(),
    }
}