cargo-allow 0.1.11

Source-tree exception ledger and policy scanner for Rust repositories.
use super::WorkItem;
use super::worklist_actions::{proof_commands, suggested_actions_for_context};
use super::worklist_scoring::{
    exception_family, work_item_difficulty, work_item_kind_for_status, work_item_risk,
};
use super::worklist_types::WorkItemLedger;
use allow_core::{AllowConfig, AllowEntry, Finding, MatchOutcome, MatchStatus, normalize_path};
use allow_diff::selector_precision_score;

pub(super) fn work_items_from_outcomes(
    cfg: &AllowConfig,
    findings: &[Finding],
    outcomes: &[MatchOutcome],
) -> Vec<WorkItem> {
    let projected_statuses = allow_report::ledger_read_statuses(
        cfg,
        outcomes,
        allow_core::SimpleDate::today_utc_approx(),
    );

    outcomes
        .iter()
        .filter_map(|outcome| {
            let status = outcome
                .allow_id
                .as_deref()
                .and_then(|id| projected_statuses.get(id).copied())
                .unwrap_or(outcome.status);
            let actionable = outcome.status != MatchStatus::Matched
                || matches!(status, MatchStatus::Expired | MatchStatus::ReviewDue);
            actionable.then_some((status, outcome))
        })
        .enumerate()
        .map(|(index, (status, outcome))| {
            work_item_from_outcome_with_status(index + 1, cfg, findings, outcome, status)
        })
        .collect()
}

#[cfg(test)]
fn work_item_from_outcome(
    item_index: usize,
    cfg: &AllowConfig,
    findings: &[Finding],
    outcome: &MatchOutcome,
) -> WorkItem {
    work_item_from_outcome_with_status(item_index, cfg, findings, outcome, outcome.status)
}

fn work_item_from_outcome_with_status(
    item_index: usize,
    cfg: &AllowConfig,
    findings: &[Finding],
    outcome: &MatchOutcome,
    status: MatchStatus,
) -> WorkItem {
    let finding = outcome.finding_index.and_then(|idx| findings.get(idx));
    let entry = outcome
        .allow_id
        .as_deref()
        .and_then(|id| cfg.allow.iter().find(|entry| entry.id == id));
    let kind = work_item_kind_for_status(status, outcome, finding, entry);
    let path = finding
        .map(|finding| normalize_path(&finding.path))
        .or_else(|| entry.map(|entry| entry.path_or_glob()));
    let source_package =
        finding.and_then(|finding| finding.source_package_name().map(ToOwned::to_owned));
    let exception_kind = work_item_exception_kind(finding, entry);
    let family = exception_family(finding, entry).map(ToOwned::to_owned);
    let mut suggested_actions = suggested_actions_for_context(&kind, finding, entry);
    if let Some(package) = &source_package {
        suggested_actions.push(format!(
            "focus source-tree review on package `{package}` without assuming Cargo metadata"
        ));
    }
    WorkItem {
        id: format!("work-{}-{item_index:04}", kind.replace('_', "-")),
        exception_kind,
        family,
        owner: entry.map(|entry| entry.owner.clone()),
        classification: entry.map(|entry| entry.classification.clone()),
        reason: entry.map(|entry| entry.reason.clone()),
        created: entry.and_then(|entry| entry.lifecycle.created.clone()),
        review_after: entry.and_then(|entry| entry.lifecycle.review_after.clone()),
        expires: entry.and_then(|entry| entry.lifecycle.expires.clone()),
        evidence_count: entry.map(|entry| entry.evidence.len()),
        selector_precision: entry.map(selector_precision_score),
        risk: work_item_risk(&kind, status, finding, entry),
        difficulty: work_item_difficulty(&kind, finding, entry),
        status,
        allow_id: outcome.allow_id.clone(),
        candidate_ids: outcome.candidate_ids.clone(),
        finding_index: outcome.finding_index,
        path,
        evidence_reference: None,
        source_package,
        message: if status == outcome.status {
            outcome.message.clone()
        } else {
            format!(
                "{} is {}",
                outcome.allow_id.as_deref().unwrap_or("policy entry"),
                status.as_str()
            )
        },
        suggested_actions,
        proof_commands: proof_commands(&kind, finding, entry),
        ledger: WorkItemLedger::from_finding(finding),
        kind,
    }
}

fn work_item_exception_kind(
    finding: Option<&Finding>,
    entry: Option<&AllowEntry>,
) -> Option<String> {
    finding
        .map(|finding| finding.kind.as_str())
        .or_else(|| entry.map(|entry| entry.kind.as_str()))
        .map(ToOwned::to_owned)
}

#[cfg(test)]
mod tests {
    use super::super::test_support::{test_entry, test_finding, test_outcome};
    use super::work_item_from_outcome;
    use allow_core::{AllowConfig, FindingKind, Lifecycle, MatchStatus};
    use std::path::PathBuf;

    #[test]
    fn work_item_from_outcome_carries_entry_metadata_and_finding_context() {
        let mut cfg = AllowConfig::empty();
        let mut entry = test_entry("allow-process", FindingKind::PolicyException);
        entry.family = Some("process_spawn".to_string());
        entry.path = Some(PathBuf::from(".github/workflows/ci.yml"));
        entry.owner = "security".to_string();
        entry.classification = "reviewed_exception".to_string();
        entry.reason = "CI workflow needs a process policy exception.".to_string();
        entry.evidence = vec![
            "spec:CARGO-ALLOW-SPEC-0001".to_string(),
            "test:ci-policy".to_string(),
        ];
        entry.lifecycle = Lifecycle {
            created: Some("2026-06-01".to_string()),
            review_after: Some("2026-07-01".to_string()),
            expires: Some("2026-09-01".to_string()),
        };
        cfg.allow.push(entry);

        let mut finding = test_finding(
            FindingKind::PolicyException,
            Some("process_spawn"),
            ".github/workflows/ci.yml",
            "process_spawn",
        );
        finding.identity.crate_name = Some("workflow".to_string());
        let findings = vec![finding];
        let mut outcome = test_outcome(
            MatchStatus::EvidenceMissing,
            Some("allow-process"),
            Some(0),
            "allow-process is missing typed evidence",
        );
        outcome.candidate_ids = vec!["allow-process".to_string()];

        let item = work_item_from_outcome(7, &cfg, &findings, &outcome);

        assert_eq!(item.id, "work-missing-evidence-0007");
        assert_eq!(item.kind, "missing_evidence");
        assert_eq!(item.exception_kind.as_deref(), Some("policy_exception"));
        assert_eq!(item.family.as_deref(), Some("process_spawn"));
        assert_eq!(item.owner.as_deref(), Some("security"));
        assert_eq!(item.classification.as_deref(), Some("reviewed_exception"));
        assert_eq!(
            item.reason.as_deref(),
            Some("CI workflow needs a process policy exception.")
        );
        assert_eq!(item.created.as_deref(), Some("2026-06-01"));
        assert_eq!(item.review_after.as_deref(), Some("2026-07-01"));
        assert_eq!(item.expires.as_deref(), Some("2026-09-01"));
        assert_eq!(item.evidence_count, Some(2));
        assert_eq!(item.risk, "high");
        assert_eq!(item.difficulty, "small");
        assert_eq!(item.status, MatchStatus::EvidenceMissing);
        assert_eq!(item.allow_id.as_deref(), Some("allow-process"));
        assert_eq!(item.candidate_ids, vec!["allow-process".to_string()]);
        assert_eq!(item.finding_index, Some(0));
        assert_eq!(item.path.as_deref(), Some(".github/workflows/ci.yml"));
        assert_eq!(item.source_package.as_deref(), Some("workflow"));
        assert_eq!(item.message, "allow-process is missing typed evidence");
        assert!(
            item.suggested_actions
                .iter()
                .any(|action| action.contains("policy_exception.process_spawn"))
        );
        assert!(
            item.suggested_actions
                .iter()
                .any(|action| action.contains("package `workflow`"))
        );
    }

    #[test]
    fn work_item_from_outcome_uses_entry_path_when_finding_is_absent() {
        let mut cfg = AllowConfig::empty();
        let mut entry = test_entry("allow-stale", FindingKind::GeneratedCode);
        entry.family = Some("checked_in_fixture".to_string());
        entry.path = None;
        entry.glob = Some("fixtures/generated/**".to_string());
        cfg.allow.push(entry);
        let outcome = test_outcome(
            MatchStatus::Stale,
            Some("allow-stale"),
            None,
            "allow-stale is stale",
        );

        let item = work_item_from_outcome(2, &cfg, &[], &outcome);

        assert_eq!(item.id, "work-stale-allow-0002");
        assert_eq!(item.kind, "stale_allow");
        assert_eq!(item.exception_kind.as_deref(), Some("generated_code"));
        assert_eq!(item.family.as_deref(), Some("checked_in_fixture"));
        assert_eq!(item.risk, "low");
        assert_eq!(item.difficulty, "small");
        assert_eq!(item.status, MatchStatus::Stale);
        assert_eq!(item.allow_id.as_deref(), Some("allow-stale"));
        assert_eq!(item.finding_index, None);
        assert_eq!(item.path.as_deref(), Some("fixtures/generated/**"));
        assert_eq!(item.source_package, None);
        assert_eq!(item.message, "allow-stale is stale");
    }

    #[test]
    fn matched_outcome_for_expired_entry_remains_actionable() {
        let mut cfg = AllowConfig::empty();
        let mut entry = test_entry("allow-expired", FindingKind::Panic);
        entry.lifecycle.expires = Some("2020-01-01".to_string());
        cfg.allow.push(entry);
        let outcome = test_outcome(
            MatchStatus::Matched,
            Some("allow-expired"),
            Some(0),
            "allow-expired matched",
        );

        let items = super::super::work_items_from_outcomes(
            &cfg,
            &[test_finding(
                FindingKind::Panic,
                Some("unwrap"),
                "tracked.file",
                "unwrap",
            )],
            &[outcome],
        );

        match items.as_slice() {
            [item] => {
                assert_eq!(item.status, MatchStatus::Expired);
                assert_eq!(item.kind, "expired_allow");
            }
            items => assert_eq!(items.len(), 1),
        }
    }

    #[test]
    fn matched_outcome_for_review_due_entry_remains_actionable() {
        let mut cfg = AllowConfig::empty();
        let mut entry = test_entry("allow-review", FindingKind::Panic);
        entry.lifecycle.review_after = Some("2020-01-01".to_string());
        cfg.allow.push(entry);
        let outcome = test_outcome(
            MatchStatus::Matched,
            Some("allow-review"),
            Some(0),
            "allow-review matched",
        );

        let items = super::super::work_items_from_outcomes(
            &cfg,
            &[test_finding(
                FindingKind::Panic,
                Some("unwrap"),
                "tracked.file",
                "unwrap",
            )],
            &[outcome],
        );

        match items.as_slice() {
            [item] => {
                assert_eq!(item.status, MatchStatus::ReviewDue);
                assert_eq!(item.kind, "review_due");
            }
            items => assert_eq!(items.len(), 1),
        }
    }
}