cargo-allow 0.1.3

Source-tree exception ledger and policy scanner for Rust repositories.
use allow_core::{AllowEntry, Finding, FindingKind};

use super::worklist_item_kind::{
    AMBIGUOUS_SELECTOR, BASELINE_DEBT, BROAD_SCOPE, BROKEN_EVIDENCE_LINK, EXPIRED_ALLOW,
    INVALID_SELECTOR, MISSING_EVIDENCE, MISSING_REQUIRED_FIELD, NEW_UNRECEIPTED_FINDING,
    OCCURRENCE_LIMIT_EXCEEDED, REVIEW_DUE, STALE_ALLOW, UNSAFE_MISSING_EVIDENCE,
    WEAK_EVIDENCE_REFERENCE,
};

pub(crate) fn suggested_actions(kind: &str) -> Vec<String> {
    match kind {
        NEW_UNRECEIPTED_FINDING => vec![
            "remove the new source exception if it is accidental".to_string(),
            "or add a reviewed allow entry with owner, reason, scope, evidence, and lifecycle"
                .to_string(),
        ],
        OCCURRENCE_LIMIT_EXCEEDED => vec![
            "reduce the current findings back to the baseline count".to_string(),
            "or split the added occurrence into a reviewed allow entry".to_string(),
        ],
        EXPIRED_ALLOW => vec![
            "remove the expired allow if the exception is gone".to_string(),
            "or re-review with fresh evidence before changing lifecycle dates".to_string(),
        ],
        STALE_ALLOW => vec![
            "remove the stale allow entry if the exception no longer exists".to_string(),
            "or narrow/update the selector if the code moved without broadening scope".to_string(),
        ],
        AMBIGUOUS_SELECTOR => vec![
            "narrow selectors so each finding matches exactly one allow entry".to_string(),
            "prefer structural fields such as container, callee, lint, and snippet hash"
                .to_string(),
        ],
        UNSAFE_MISSING_EVIDENCE => vec![
            "add unsafe-review, test, spec, or boundary evidence for the unsafe exception"
                .to_string(),
            "keep the selector scoped to the reviewed unsafe boundary".to_string(),
        ],
        MISSING_EVIDENCE => {
            vec!["add evidence that supports the exception reason".to_string()]
        }
        MISSING_REQUIRED_FIELD => vec![
            "fill the required owner, reason, classification, lifecycle, or evidence field"
                .to_string(),
        ],
        INVALID_SELECTOR => {
            vec!["replace line-only or invalid selector data with structural identity".to_string()]
        }
        BASELINE_DEBT => vec![
            "replace generated baseline debt with a reviewed allow entry".to_string(),
            "or remove the underlying exception".to_string(),
        ],
        REVIEW_DUE => {
            vec!["review the retained exception and update evidence or remove it".to_string()]
        }
        BROAD_SCOPE => vec![
            "replace the broad glob with exact paths or a narrower glob where practical"
                .to_string(),
            "keep broad source-tree scope intentional, reviewed, and evidenced".to_string(),
        ],
        BROKEN_EVIDENCE_LINK => vec![
            "restore or commit the referenced local evidence artifact".to_string(),
            "or update the evidence reference to a valid source-tree-relative path".to_string(),
        ],
        WEAK_EVIDENCE_REFERENCE => vec![
            "replace the weak evidence string with a typed evidence reference".to_string(),
            format!(
                "use a recognized prefix such as {}",
                evidence_prefix_examples()
            ),
        ],
        _ => vec!["inspect the outcome and update policy or source accordingly".to_string()],
    }
}

pub(crate) fn suggested_actions_for_context(
    kind: &str,
    finding: Option<&Finding>,
    entry: Option<&AllowEntry>,
) -> Vec<String> {
    if kind == MISSING_EVIDENCE {
        if let Some(family) = high_risk_policy_exception_family(finding, entry) {
            return high_risk_policy_missing_evidence_actions(family);
        }
    }
    if kind == WEAK_EVIDENCE_REFERENCE {
        if let Some(family) = high_risk_policy_exception_family(finding, entry) {
            return high_risk_policy_weak_evidence_actions(family);
        }
    }
    suggested_actions(kind)
}

pub(crate) fn suggested_link_actions_for_context(
    kind: &str,
    finding: Option<&Finding>,
    entry: Option<&AllowEntry>,
) -> Vec<String> {
    match kind {
        BROKEN_EVIDENCE_LINK => vec![
            "restore or commit the referenced local traceability file".to_string(),
            "or update the link reference to a valid source-tree-relative path".to_string(),
        ],
        WEAK_EVIDENCE_REFERENCE => {
            if let Some(family) = high_risk_policy_exception_family(finding, entry) {
                return high_risk_policy_weak_link_actions(family);
            }
            vec![
                "replace the weak link string with a typed traceability reference".to_string(),
                format!(
                    "use a recognized prefix such as {}",
                    evidence_prefix_examples()
                ),
            ]
        }
        _ => suggested_actions(kind),
    }
}

pub(super) fn evidence_prefix_examples() -> String {
    let prefixes = allow_policy::canonical_evidence_prefixes()
        .map(|prefix| format!("{prefix}:"))
        .collect::<Vec<_>>();
    english_join(&prefixes)
}

fn english_join(values: &[String]) -> String {
    match values {
        [] => String::new(),
        [one] => one.clone(),
        [one, two] => format!("{one} or {two}"),
        _ => {
            let mut out = String::new();
            for (index, value) in values.iter().enumerate() {
                if index > 0 {
                    if index + 1 == values.len() {
                        out.push_str(", or ");
                    } else {
                        out.push_str(", ");
                    }
                }
                out.push_str(value);
            }
            out
        }
    }
}

fn high_risk_policy_exception_family<'a>(
    finding: Option<&'a Finding>,
    entry: Option<&'a AllowEntry>,
) -> Option<&'a str> {
    let exception_kind = finding
        .map(|finding| finding.kind)
        .or_else(|| entry.map(|entry| entry.kind));
    if exception_kind != Some(FindingKind::PolicyException) {
        return None;
    }
    let family = finding
        .and_then(|finding| finding.family.as_deref())
        .or_else(|| entry.and_then(|entry| entry.family.as_deref()))?;
    matches!(family, "process_spawn" | "network_destination").then_some(family)
}

fn high_risk_policy_missing_evidence_actions(family: &str) -> Vec<String> {
    vec![
        format!("add typed evidence for the policy_exception.{family} exception"),
        "review whether the policy exception can be removed or narrowed before retaining it"
            .to_string(),
    ]
}

fn high_risk_policy_weak_evidence_actions(family: &str) -> Vec<String> {
    vec![
        format!("replace weak evidence with typed evidence for policy_exception.{family}"),
        "keep custom legacy facts only as supporting context after a typed receipt exists"
            .to_string(),
        "review whether the policy exception can be removed or narrowed before retaining it"
            .to_string(),
    ]
}

fn high_risk_policy_weak_link_actions(family: &str) -> Vec<String> {
    vec![
        format!(
            "replace weak traceability with typed traceability for policy_exception.{family}"
        ),
        "keep custom legacy notes only as supporting context after a typed traceability reference exists"
            .to_string(),
        "review whether the policy exception can be removed or narrowed before retaining it"
            .to_string(),
    ]
}

pub(crate) fn proof_commands(
    kind: &str,
    finding: Option<&Finding>,
    entry: Option<&AllowEntry>,
) -> Vec<String> {
    let mut commands = Vec::new();
    if let Some(allow_id) = entry.map(|entry| entry.id.as_str()) {
        commands.push(format!("cargo-allow explain {allow_id}"));
        commands.push(format!(
            "cargo-allow list --allow-id {allow_id} --format json"
        ));
        commands.push(format!(
            "cargo-allow worklist --allow-id {allow_id} --format json"
        ));
    }
    let kind_arg = worklist_kind_arg(finding, entry);
    let has_unsafe_kind_check = kind_arg == Some("unsafe");
    let shortcut_arg = worklist_shortcut_arg(kind);
    if let Some(kind_arg) = kind_arg {
        if let Some(shortcut_arg) = shortcut_arg {
            commands.push(format!(
                "cargo-allow worklist --{shortcut_arg} --format json"
            ));
        }
        commands.push(format!("cargo-allow check --kind {kind_arg} --mode no-new"));
        commands.push(format!(
            "cargo-allow worklist --item-kind {kind} --format json"
        ));
        if let Some(list_shortcut_arg) = list_shortcut_arg(kind) {
            commands.push(format!(
                "cargo-allow list --{list_shortcut_arg} --format json"
            ));
        }
        commands.push(format!(
            "cargo-allow worklist --kind {kind_arg} --format json"
        ));
    } else {
        if let Some(shortcut_arg) = shortcut_arg {
            commands.push(format!(
                "cargo-allow worklist --{shortcut_arg} --format json"
            ));
        }
        commands.push("cargo-allow check --mode no-new".to_string());
        commands.push(format!(
            "cargo-allow worklist --item-kind {kind} --format json"
        ));
        if let Some(list_shortcut_arg) = list_shortcut_arg(kind) {
            commands.push(format!(
                "cargo-allow list --{list_shortcut_arg} --format json"
            ));
        }
        commands.push("cargo-allow worklist --format json".to_string());
    }
    if kind == UNSAFE_MISSING_EVIDENCE && !has_unsafe_kind_check {
        commands.push("cargo-allow check --kind unsafe --mode no-new".to_string());
    }
    commands
}

fn list_shortcut_arg(kind: &str) -> Option<&'static str> {
    match kind {
        BROKEN_EVIDENCE_LINK => Some("broken-evidence"),
        WEAK_EVIDENCE_REFERENCE => Some("weak-evidence"),
        _ => None,
    }
}

fn worklist_shortcut_arg(kind: &str) -> Option<&'static str> {
    match kind {
        BASELINE_DEBT => Some("baseline-debt"),
        BROAD_SCOPE => Some("broad-scope"),
        MISSING_EVIDENCE | UNSAFE_MISSING_EVIDENCE => Some("missing-evidence"),
        _ => None,
    }
}

fn worklist_kind_arg(
    finding: Option<&Finding>,
    entry: Option<&AllowEntry>,
) -> Option<&'static str> {
    let exception_kind = finding
        .map(|finding| finding.kind)
        .or_else(|| entry.map(|entry| entry.kind))?;
    match exception_kind {
        FindingKind::Panic => Some("panic"),
        FindingKind::Unsafe => Some("unsafe"),
        FindingKind::LintException => Some("lint-exception"),
        FindingKind::NonRustFile => Some("non-rust"),
        FindingKind::GeneratedCode => Some("generated"),
        FindingKind::PolicyException => policy_exception_kind_arg(
            finding
                .and_then(|finding| finding.family.as_deref())
                .or_else(|| entry.and_then(|entry| entry.family.as_deref())),
        ),
    }
}

fn policy_exception_kind_arg(family: Option<&str>) -> Option<&'static str> {
    match family {
        Some("executable_file") => Some("executable"),
        Some("github_workflow" | "workflow_external_action") => Some("workflow"),
        Some("dependency_surface") => Some("dependency-surface"),
        Some("process_spawn") => Some("process"),
        Some("network_destination") => Some("network"),
        _ => None,
    }
}