cargo-allow 0.1.11

Source-tree exception ledger and policy scanner for Rust repositories.
use allow_core::{AllowEntry, Finding, MatchOutcome, MatchStatus, normalize_path};
use allow_match::finding_location;

use super::why_shell::{ProofPlan, render_proof_command};

pub(super) struct WhyCandidate<'a> {
    pub entry: &'a AllowEntry,
    pub reasons: Vec<String>,
}

pub(super) struct WhyNextSteps {
    pub suggested_actions: Vec<String>,
    pub proof_plans: Vec<ProofPlan>,
}

impl WhyNextSteps {
    pub(super) fn proof_commands(&self) -> Vec<String> {
        self.proof_plans.iter().map(render_proof_command).collect()
    }
}

pub(super) fn why_next_steps(
    finding: &Finding,
    outcome: &MatchOutcome,
    candidates: &[WhyCandidate<'_>],
) -> WhyNextSteps {
    match outcome.status {
        MatchStatus::New => {
            let mut suggested_actions = vec![
                "Receipt this occurrence with cargo-allow add.".to_string(),
                "Or repair the source so the finding disappears, then re-run cargo-allow check --mode no-new."
                    .to_string(),
            ];
            let mut proof_plans = vec![add_receipt_plan(finding), check_no_new_plan()];
            if let Some(first) = candidates.first() {
                suggested_actions.push(format!(
                    "Inspect near-miss allow entry `{}` with cargo-allow explain.",
                    first.entry.id
                ));
                proof_plans.push(explain_plan(&first.entry.id));
            }
            WhyNextSteps {
                suggested_actions,
                proof_plans,
            }
        }
        MatchStatus::Ambiguous => {
            let mut suggested_actions = Vec::new();
            let mut proof_plans = Vec::new();
            if outcome.candidate_ids.is_empty() {
                suggested_actions.push(
                    "Multiple allow entries compete, but no structured candidate IDs were recorded. \
Inspect the ledger with cargo-allow list or cargo-allow check."
                        .to_string(),
                );
            } else {
                suggested_actions.push(
                    "Multiple allow entries compete for this finding. \
Inspect every candidate with cargo-allow explain; do not treat any one as authoritative."
                        .to_string(),
                );
                for id in &outcome.candidate_ids {
                    suggested_actions.push(format!("Inspect candidate allow entry `{id}`."));
                    proof_plans.push(explain_plan(id));
                }
            }
            proof_plans.push(check_no_new_plan());
            proof_plans.push(ProofPlan::cargo_allow(["list"]));
            WhyNextSteps {
                suggested_actions,
                proof_plans,
            }
        }
        _ => {
            let mut suggested_actions = Vec::new();
            let mut proof_plans = Vec::new();
            if let Some(id) = &outcome.allow_id {
                suggested_actions.push(format!(
                    "Inspect the linked allow entry `{id}` with cargo-allow explain."
                ));
                proof_plans.push(explain_plan(id));
            } else {
                suggested_actions.push(
                    "Inspect the broader ledger with cargo-allow list or cargo-allow check."
                        .to_string(),
                );
            }
            proof_plans.push(check_no_new_plan());
            proof_plans.push(ProofPlan::cargo_allow(["list"]));
            WhyNextSteps {
                suggested_actions,
                proof_plans,
            }
        }
    }
}

fn add_receipt_plan(finding: &Finding) -> ProofPlan {
    let path = normalize_path(&finding.path);
    let line = finding
        .span
        .as_ref()
        .map(|span| span.line)
        .unwrap_or(1)
        .to_string();
    ProofPlan::cargo_allow([
        "add".to_string(),
        "--kind".to_string(),
        finding.kind.as_str().to_string(),
        "--path".to_string(),
        path,
        "--line".to_string(),
        line,
        "--owner".to_string(),
        "<owner>".to_string(),
        "--reason".to_string(),
        "...".to_string(),
        "--evidence".to_string(),
        "<ref>".to_string(),
        "--update".to_string(),
    ])
}

fn explain_plan(id: &str) -> ProofPlan {
    ProofPlan::cargo_allow(["explain".to_string(), id.to_string()])
}

fn check_no_new_plan() -> ProofPlan {
    ProofPlan::cargo_allow([
        "check".to_string(),
        "--mode".to_string(),
        "no-new".to_string(),
    ])
}

pub(super) fn render_why_text(
    finding: &Finding,
    outcome: &MatchOutcome,
    candidates: &[WhyCandidate<'_>],
) -> String {
    let next = why_next_steps(finding, outcome, candidates);
    let proof_commands = next.proof_commands();
    let mut out = String::new();
    out.push_str("# Why this finding is unreceipted\n\n");
    out.push_str("## Finding\n\n");
    out.push_str(&format!("- location: {}\n", finding_location(finding)));
    out.push_str(&format!("- kind: {}\n", finding.kind.as_str()));
    match &finding.family {
        Some(family) => out.push_str(&format!("- family: {family}\n")),
        None => out.push_str("- family: <none>\n"),
    }
    out.push_str(&format!("- message: {}\n", finding.message));
    out.push_str(&format!(
        "- identity.ast_kind: {}\n",
        finding.identity.ast_kind
    ));
    if let Some(container) = &finding.identity.container {
        out.push_str(&format!("- identity.container: {container}\n"));
    }
    if let Some(callee) = &finding.identity.callee {
        out.push_str(&format!("- identity.callee: {callee}\n"));
    }
    if let Some(hash) = &finding.identity.normalized_snippet_hash {
        out.push_str(&format!("- identity.normalized_snippet_hash: {hash}\n"));
    }
    out.push('\n');

    out.push_str("## Current posture\n\n");
    out.push_str(&format!("- status: {}\n", outcome.status.as_str()));
    match &outcome.allow_id {
        Some(id) => out.push_str(&format!("- allow_id: {id}\n")),
        None => out.push_str("- allow_id: <none>\n"),
    }
    if !outcome.candidate_ids.is_empty() {
        out.push_str(&format!(
            "- candidate_ids: {}\n",
            outcome.candidate_ids.join(", ")
        ));
    }
    if !outcome.message.is_empty() {
        out.push_str(&format!("- message: {}\n", outcome.message));
    }
    out.push('\n');

    match outcome.status {
        MatchStatus::New => {
            out.push_str("## Candidate allow entries\n\n");
            if candidates.is_empty() {
                out.push_str(
                    "No same-kind allow entries look related to this finding. \
The finding is unreceipted because no policy entry covers it.\n\n",
                );
            } else {
                out.push_str(
                    "Nearby same-kind entries that do not match, with selector mismatch reasons:\n\n",
                );
                for candidate in candidates {
                    out.push_str(&format!("### `{}`\n\n", candidate.entry.id));
                    out.push_str(&format!(
                        "- kind/family: {} / {}\n",
                        candidate.entry.kind.as_str(),
                        candidate.entry.family.as_deref().unwrap_or("<none>")
                    ));
                    out.push_str(&format!(
                        "- path: {}\n",
                        candidate
                            .entry
                            .path
                            .as_ref()
                            .map(normalize_path)
                            .unwrap_or_else(|| "<none>".to_string())
                    ));
                    if let Some(glob) = &candidate.entry.glob {
                        out.push_str(&format!("- glob: {glob}\n"));
                    }
                    if let Some(glob) = &candidate.entry.selector.glob {
                        out.push_str(&format!("- selector.glob: {glob}\n"));
                    }
                    out.push_str("- mismatch reasons:\n");
                    for reason in &candidate.reasons {
                        out.push_str(&format!("  - {reason}\n"));
                    }
                    out.push('\n');
                }
            }
        }
        MatchStatus::Matched
        | MatchStatus::LocationDrift
        | MatchStatus::ReviewDue
        | MatchStatus::BaselineDebt
        | MatchStatus::EvidenceMissing => {
            out.push_str("## Already receipted\n\n");
            if let Some(id) = &outcome.allow_id {
                out.push_str(&format!(
                    "This finding is already linked to `{id}`. \
Use `cargo-allow explain {id}` for the entry-facing inverse view.\n\n"
                ));
            } else {
                out.push_str(
                    "This finding is not in a plain `new` posture. \
Use `cargo-allow list` or `cargo-allow check` for the broader ledger view.\n\n",
                );
            }
        }
        MatchStatus::Ambiguous => {
            out.push_str("## Ambiguous match\n\n");
            out.push_str(
                "Multiple allow entries compete for this finding. \
Inspect every candidate ID with `cargo-allow explain <id>` and narrow selectors; \
no candidate is selected as authoritative.\n\n",
            );
            if !outcome.candidate_ids.is_empty() {
                out.push_str("Candidate-specific explain plans:\n\n");
                for id in &outcome.candidate_ids {
                    out.push_str(&format!("- `{id}`\n"));
                }
                out.push('\n');
            }
        }
        MatchStatus::Stale
        | MatchStatus::Expired
        | MatchStatus::InvalidSelector
        | MatchStatus::MissingRequiredField => {
            out.push_str("## Policy health issue\n\n");
            out.push_str(
                "The selected finding is blocked by a policy-health status rather than a simple unreceipted gap. \
Use `cargo-allow explain <id>` when an allow ID is present, or `cargo-allow check --mode strict` for the full gate view.\n\n",
            );
        }
    }

    out.push_str("## Suggested next steps\n\n");
    for (index, action) in next.suggested_actions.iter().enumerate() {
        out.push_str(&format!("{}. {action}\n", index + 1));
    }
    out.push('\n');
    out.push_str("## Proof commands\n\n");
    out.push_str(
        "Commands below are rendered for the current platform shell when paste-safe. \
Structured argv is authoritative in `cargo-allow.why.v1` `next.proof_plans`.\n\n",
    );
    for command in &proof_commands {
        if command.contains('\n') {
            out.push_str("- \n```\n");
            out.push_str(command);
            out.push_str("\n```\n");
        } else {
            out.push_str(&format!("- `{command}`\n"));
        }
    }
    out.push('\n');

    out.push_str("## Claim boundary\n\n");
    out.push_str(
        "`why` reports source-tree / source-syntax matching posture only. \
It does not prove that an exception is safe, that tests are adequate, or that \
macro-expanded / type-aware behavior would match. Proof commands are guidance only \
and are not executed by cargo-allow.\n",
    );
    out
}

pub(super) fn render_why_json(
    inventory: allow_report::InventoryContext<'_>,
    finding: &Finding,
    outcome: &MatchOutcome,
    candidates: &[WhyCandidate<'_>],
) -> String {
    let next = why_next_steps(finding, outcome, candidates);
    let proof_commands = next.proof_commands();
    let path_texts = candidates
        .iter()
        .map(|candidate| candidate.entry.path.as_ref().map(normalize_path))
        .collect::<Vec<_>>();
    let candidate_entries = candidates
        .iter()
        .enumerate()
        .map(|(index, candidate)| allow_report::WhyCandidateEntry {
            id: candidate.entry.id.as_str(),
            kind: candidate.entry.kind.as_str(),
            family: candidate.entry.family.as_deref(),
            path: path_texts.get(index).and_then(|path| path.as_deref()),
            glob: candidate.entry.glob.as_deref(),
            selector_glob: candidate.entry.selector.glob.as_deref(),
            mismatch_reasons: candidate.reasons.as_slice(),
        })
        .collect::<Vec<_>>();
    let proof_plans = next
        .proof_plans
        .iter()
        .map(|plan| allow_report::WhyProofPlan {
            program: plan.program.as_str(),
            args: plan.args.as_slice(),
        })
        .collect::<Vec<_>>();
    allow_report::render_why_json(allow_report::WhyReport {
        inventory,
        finding,
        outcome,
        candidate_entries: &candidate_entries,
        suggested_actions: &next.suggested_actions,
        proof_commands: &proof_commands,
        proof_plans: &proof_plans,
    })
}