allow-report 0.1.9

Report and receipt rendering for cargo-allow source exception scans.
Documentation
use crate::{DiffFindingChange, DiffPolicyChange, DiffPostureSummary};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffNetPosture {
    Worse,
    ReviewRequired,
    Improved,
    Unchanged,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct DiffEvidenceDeltaSummary {
    pub(crate) evidence_added: usize,
    pub(crate) weak_evidence_added: usize,
    pub(crate) broken_evidence_added: usize,
    pub(crate) evidence_removed: usize,
    pub(crate) evidence_removal_failures: usize,
    pub(crate) evidence_removal_review_items: usize,
    pub(crate) evidence_removal_improvements: usize,
    pub(crate) link_added: usize,
    pub(crate) weak_link_added: usize,
    pub(crate) broken_link_added: usize,
    pub(crate) link_removed: usize,
    pub(crate) link_removal_failures: usize,
    pub(crate) link_removal_review_items: usize,
    pub(crate) link_removal_improvements: usize,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct DiffStructuralDeltaSummary {
    pub(crate) scope_broadened: usize,
    pub(crate) scope_changed: usize,
    pub(crate) scope_narrowed: usize,
    pub(crate) selector_changed: usize,
    pub(crate) selector_precision_decreased: usize,
    pub(crate) selector_precision_increased: usize,
}

impl DiffNetPosture {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Worse => "worse",
            Self::ReviewRequired => "review-required",
            Self::Improved => "improved",
            Self::Unchanged => "unchanged",
        }
    }

    pub fn reviewer_action(self) -> &'static str {
        match self {
            Self::Worse => {
                "block until failing source exception changes are fixed, narrowed, or receipted."
            }
            Self::ReviewRequired => "review the source exception posture change before merging.",
            Self::Improved => "verify the cleanup was intentional and keep the narrower posture.",
            Self::Unchanged => "no source exception posture change detected.",
        }
    }
}

pub(crate) fn diff_structural_delta_summary(
    policy_changes: &[DiffPolicyChange<'_>],
) -> DiffStructuralDeltaSummary {
    let mut summary = DiffStructuralDeltaSummary::default();
    for change in policy_changes {
        match change.kind {
            "scope_broadened" => summary.scope_broadened += 1,
            "scope_changed" => summary.scope_changed += 1,
            "scope_narrowed" => summary.scope_narrowed += 1,
            "selector_changed" => summary.selector_changed += 1,
            "selector_precision_decreased" => summary.selector_precision_decreased += 1,
            "selector_precision_increased" => summary.selector_precision_increased += 1,
            _ => {}
        }
    }
    summary
}

pub(crate) fn diff_evidence_delta_summary(
    policy_changes: &[DiffPolicyChange<'_>],
) -> DiffEvidenceDeltaSummary {
    let mut summary = DiffEvidenceDeltaSummary::default();
    for change in policy_changes {
        match change.kind {
            "evidence_added" => {
                summary.evidence_added += 1;
                match change.severity {
                    "review" => summary.weak_evidence_added += 1,
                    "fail" => summary.broken_evidence_added += 1,
                    _ => {}
                }
            }
            "evidence_removed" => {
                summary.evidence_removed += 1;
                match change.severity {
                    "fail" => summary.evidence_removal_failures += 1,
                    "review" => summary.evidence_removal_review_items += 1,
                    "improvement" => summary.evidence_removal_improvements += 1,
                    _ => {}
                }
            }
            "link_added" => {
                summary.link_added += 1;
                match change.severity {
                    "review" => summary.weak_link_added += 1,
                    "fail" => summary.broken_link_added += 1,
                    _ => {}
                }
            }
            "link_removed" => {
                summary.link_removed += 1;
                match change.severity {
                    "fail" => summary.link_removal_failures += 1,
                    "review" => summary.link_removal_review_items += 1,
                    "improvement" => summary.link_removal_improvements += 1,
                    _ => {}
                }
            }
            _ => {}
        }
    }
    summary
}

pub fn diff_posture_summary(
    current_failures: usize,
    finding_changes: &[DiffFindingChange<'_>],
    policy_changes: &[DiffPolicyChange<'_>],
) -> DiffPostureSummary {
    DiffPostureSummary {
        current_failures,
        new_findings: finding_changes
            .iter()
            .filter(|change| change.change == "new")
            .count(),
        removed_findings: finding_changes
            .iter()
            .filter(|change| change.change == "removed")
            .count(),
        policy_failures: policy_changes
            .iter()
            .filter(|change| change.severity == "fail")
            .count(),
        policy_review_items: policy_changes
            .iter()
            .filter(|change| change.severity == "review")
            .count(),
        policy_improvements: policy_changes
            .iter()
            .filter(|change| change.severity == "improvement")
            .count(),
    }
}

pub fn diff_net_posture(summary: DiffPostureSummary) -> DiffNetPosture {
    if summary.current_failures > 0 || summary.policy_failures > 0 {
        return DiffNetPosture::Worse;
    }
    if summary.new_findings > 0 || summary.policy_review_items > 0 {
        return DiffNetPosture::ReviewRequired;
    }
    if summary.removed_findings > 0 || summary.policy_improvements > 0 {
        return DiffNetPosture::Improved;
    }
    DiffNetPosture::Unchanged
}

#[cfg(test)]
mod tests {
    use super::*;

    fn policy_change<'a>(severity: &'a str, kind: &'a str) -> DiffPolicyChange<'a> {
        DiffPolicyChange {
            severity,
            allow_id: "allow-test",
            kind,
            message: "policy changed",
            exception_identity: None,
            selector_identity: None,
            selector_precision: None,
            scope: None,
            occurrence_limit: None,
            lifecycle: None,
            evidence: None,
            metadata: None,
            requirement: None,
            policy_status: None,
        }
    }

    fn finding_change<'a>(change: &'a str) -> DiffFindingChange<'a> {
        DiffFindingChange {
            change,
            key: "panic|unwrap|src/lib.rs",
            kind: "panic",
            family: Some("unwrap"),
            path: "src/lib.rs",
            line: Some(1),
            column: Some(1),
            source_package: Some("allow-report"),
            identity: None,
        }
    }

    fn summary(
        current_failures: usize,
        new_findings: usize,
        removed_findings: usize,
        policy_failures: usize,
        policy_review_items: usize,
        policy_improvements: usize,
    ) -> DiffPostureSummary {
        DiffPostureSummary {
            current_failures,
            new_findings,
            removed_findings,
            policy_failures,
            policy_review_items,
            policy_improvements,
        }
    }

    #[test]
    fn net_posture_strings_and_reviewer_actions_cover_all_variants() {
        let cases = [
            (
                DiffNetPosture::Worse,
                "worse",
                "block until failing source exception changes are fixed, narrowed, or receipted.",
            ),
            (
                DiffNetPosture::ReviewRequired,
                "review-required",
                "review the source exception posture change before merging.",
            ),
            (
                DiffNetPosture::Improved,
                "improved",
                "verify the cleanup was intentional and keep the narrower posture.",
            ),
            (
                DiffNetPosture::Unchanged,
                "unchanged",
                "no source exception posture change detected.",
            ),
        ];

        for (posture, as_str, action) in cases {
            assert_eq!(posture.as_str(), as_str);
            assert_eq!(posture.reviewer_action(), action);
        }
    }

    #[test]
    fn structural_delta_summary_counts_known_kinds_and_ignores_unknowns() {
        let changes = [
            policy_change("fail", "scope_broadened"),
            policy_change("review", "scope_broadened"),
            policy_change("review", "scope_changed"),
            policy_change("improvement", "scope_narrowed"),
            policy_change("review", "selector_changed"),
            policy_change("fail", "selector_precision_decreased"),
            policy_change("improvement", "selector_precision_increased"),
            policy_change("review", "evidence_added"),
        ];

        assert_eq!(
            diff_structural_delta_summary(&changes),
            DiffStructuralDeltaSummary {
                scope_broadened: 2,
                scope_changed: 1,
                scope_narrowed: 1,
                selector_changed: 1,
                selector_precision_decreased: 1,
                selector_precision_increased: 1,
            }
        );
    }

    #[test]
    fn evidence_delta_summary_counts_severity_buckets_for_evidence_and_links() {
        let changes = [
            policy_change("review", "evidence_added"),
            policy_change("fail", "evidence_added"),
            policy_change("improvement", "evidence_added"),
            policy_change("fail", "evidence_removed"),
            policy_change("review", "evidence_removed"),
            policy_change("improvement", "evidence_removed"),
            policy_change("review", "link_added"),
            policy_change("fail", "link_added"),
            policy_change("improvement", "link_added"),
            policy_change("fail", "link_removed"),
            policy_change("review", "link_removed"),
            policy_change("improvement", "link_removed"),
            policy_change("review", "scope_changed"),
        ];

        assert_eq!(
            diff_evidence_delta_summary(&changes),
            DiffEvidenceDeltaSummary {
                evidence_added: 3,
                weak_evidence_added: 1,
                broken_evidence_added: 1,
                evidence_removed: 3,
                evidence_removal_failures: 1,
                evidence_removal_review_items: 1,
                evidence_removal_improvements: 1,
                link_added: 3,
                weak_link_added: 1,
                broken_link_added: 1,
                link_removed: 3,
                link_removal_failures: 1,
                link_removal_review_items: 1,
                link_removal_improvements: 1,
            }
        );
    }

    #[test]
    fn posture_summary_counts_finding_and_policy_statuses() {
        let finding_changes = [
            finding_change("new"),
            finding_change("new"),
            finding_change("removed"),
            finding_change("unchanged"),
        ];
        let policy_changes = [
            policy_change("fail", "scope_broadened"),
            policy_change("review", "selector_changed"),
            policy_change("improvement", "scope_narrowed"),
            policy_change("info", "metadata_changed"),
        ];

        assert_eq!(
            diff_posture_summary(7, &finding_changes, &policy_changes),
            DiffPostureSummary {
                current_failures: 7,
                new_findings: 2,
                removed_findings: 1,
                policy_failures: 1,
                policy_review_items: 1,
                policy_improvements: 1,
            }
        );
    }

    #[test]
    fn net_posture_prioritizes_failures_then_review_then_improvement() {
        let cases = [
            (summary(1, 0, 0, 0, 0, 0), DiffNetPosture::Worse),
            (summary(0, 0, 0, 1, 1, 1), DiffNetPosture::Worse),
            (summary(0, 1, 1, 0, 0, 1), DiffNetPosture::ReviewRequired),
            (summary(0, 0, 1, 0, 1, 1), DiffNetPosture::ReviewRequired),
            (summary(0, 0, 1, 0, 0, 0), DiffNetPosture::Improved),
            (summary(0, 0, 0, 0, 0, 1), DiffNetPosture::Improved),
            (summary(0, 0, 0, 0, 0, 0), DiffNetPosture::Unchanged),
        ];

        for (summary, expected) in cases {
            assert_eq!(diff_net_posture(summary), expected);
        }
    }
}