allow-report 0.1.9

Report and receipt rendering for cargo-allow source exception scans.
Documentation
use crate::DiffPolicyChange;

pub(crate) fn policy_change_detail(change: &DiffPolicyChange<'_>) -> Option<String> {
    let mut details = Vec::new();

    if let Some(identity) = change.exception_identity {
        details.push(format!(
            "exception_identity.{}: {} -> {}",
            identity.field,
            option_text(identity.before),
            option_text(identity.after)
        ));
    }

    if let Some(selector_identity) = change.selector_identity {
        details.push(format!(
            "selector_identity changed: {}",
            list_text(selector_identity.changed_fields)
        ));
    }

    if let Some(selector_precision) = change.selector_precision {
        details.push(format!(
            "selector_precision: {} -> {}; removed: {}; added: {}",
            selector_precision.before,
            selector_precision.after,
            list_text(selector_precision.removed_fields),
            list_text(selector_precision.added_fields)
        ));
    }

    if let Some(scope) = change.scope {
        details.push(format!(
            "scope.{}: {} -> {}",
            scope.field,
            option_text(scope.before),
            option_text(scope.after)
        ));
    }

    if let Some(limit) = change.occurrence_limit {
        details.push(format!(
            "occurrence_limit: {} -> {}",
            option_u32_text(limit.before),
            option_u32_text(limit.after)
        ));
    }

    if let Some(lifecycle) = change.lifecycle {
        details.push(format!(
            "lifecycle.{}: {} -> {}",
            lifecycle.field,
            option_text(lifecycle.before),
            option_text(lifecycle.after)
        ));
    }

    if let Some(evidence) = change.evidence {
        details.push(format!(
            "evidence.{}: removed: {}; added: {}",
            evidence.field,
            owned_list_text(evidence.removed),
            owned_list_text(evidence.added)
        ));
    }

    if let Some(metadata) = change.metadata {
        details.push(format!(
            "metadata.{}: {} -> {}",
            metadata.field,
            option_text(metadata.before),
            option_text(metadata.after)
        ));
    }

    if let Some(requirement) = change.requirement {
        details.push(format!(
            "requirement.{}: {} -> {}",
            requirement.field, requirement.before, requirement.after
        ));
    }

    if let Some(policy_status) = change.policy_status {
        details.push(format!(
            "policy_status: {} -> {}",
            option_text(policy_status.before),
            option_text(policy_status.after)
        ));
    }

    (!details.is_empty()).then(|| details.join("; "))
}

fn option_text(value: Option<&str>) -> &str {
    value.unwrap_or("none")
}

fn option_u32_text(value: Option<u32>) -> String {
    value.map_or_else(|| "none".to_string(), |value| value.to_string())
}

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

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        DiffEvidenceChange, DiffExceptionIdentityChange, DiffLifecycleChange, DiffMetadataChange,
        DiffOccurrenceLimitChange, DiffPolicyStatusChange, DiffRequirementChange, DiffScopeChange,
        DiffSelectorIdentityChange, DiffSelectorPrecisionChange,
    };

    fn base_change() -> DiffPolicyChange<'static> {
        DiffPolicyChange {
            severity: "review",
            allow_id: "allow-0001",
            kind: "policy_changed",
            message: "allow-0001 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,
        }
    }

    #[test]
    fn policy_change_detail_returns_none_without_detail_payloads() {
        assert_eq!(policy_change_detail(&base_change()), None);
    }

    #[test]
    fn policy_change_detail_formats_all_detail_payloads_in_order() {
        let changed_fields = ["container", "normalized_snippet_hash"];
        let removed_fields = ["container"];
        let added_fields = ["normalized_snippet_hash"];
        let removed_evidence = vec!["test:old-proof".to_string()];
        let added_evidence = vec!["test:new-proof".to_string()];
        let change = DiffPolicyChange {
            exception_identity: Some(DiffExceptionIdentityChange {
                field: "kind",
                before: Some("panic"),
                after: Some("unsafe"),
            }),
            selector_identity: Some(DiffSelectorIdentityChange {
                changed_fields: &changed_fields,
            }),
            selector_precision: Some(DiffSelectorPrecisionChange {
                before: 82,
                after: 41,
                removed_fields: &removed_fields,
                added_fields: &added_fields,
            }),
            scope: Some(DiffScopeChange {
                field: "path",
                before: Some("src/lib.rs"),
                after: None,
            }),
            occurrence_limit: Some(DiffOccurrenceLimitChange {
                before: Some(1),
                after: None,
            }),
            lifecycle: Some(DiffLifecycleChange {
                field: "expires",
                before: None,
                after: Some("2026-12-01"),
            }),
            evidence: Some(DiffEvidenceChange {
                field: "evidence",
                removed: &removed_evidence,
                added: &added_evidence,
            }),
            metadata: Some(DiffMetadataChange {
                field: "owner",
                before: Some("core"),
                after: None,
            }),
            requirement: Some(DiffRequirementChange {
                field: "owner_required",
                before: true,
                after: false,
            }),
            policy_status: Some(DiffPolicyStatusChange {
                before: Some("active"),
                after: Some("advisory"),
            }),
            ..base_change()
        };

        assert_eq!(
            policy_change_detail(&change),
            Some(
                "exception_identity.kind: panic -> unsafe; \
                 selector_identity changed: container, normalized_snippet_hash; \
                 selector_precision: 82 -> 41; removed: container; added: normalized_snippet_hash; \
                 scope.path: src/lib.rs -> none; \
                 occurrence_limit: 1 -> none; \
                 lifecycle.expires: none -> 2026-12-01; \
                 evidence.evidence: removed: test:old-proof; added: test:new-proof; \
                 metadata.owner: core -> none; \
                 requirement.owner_required: true -> false; \
                 policy_status: active -> advisory"
                    .to_string()
            )
        );
    }

    #[test]
    fn option_and_list_helpers_render_none_empty_and_joined_values() {
        assert_eq!(option_text(Some("active")), "active");
        assert_eq!(option_text(None), "none");
        assert_eq!(option_u32_text(Some(7)), "7");
        assert_eq!(option_u32_text(None), "none");

        assert_eq!(list_text(&[]), "none");
        assert_eq!(list_text(&["container", "callee"]), "container, callee");

        let empty_owned: Vec<String> = Vec::new();
        assert_eq!(owned_list_text(&empty_owned), "none");

        let owned = vec!["test:old".to_string(), "test:new".to_string()];
        assert_eq!(owned_list_text(&owned), "test:old, test:new");
    }
}