cargo-allow 0.1.11

Source-tree exception ledger and policy scanner for Rust repositories.
use super::why_render::why_next_steps;
use super::*;
use crate::{CargoAllowCli, CargoAllowCommand, RootArgs};
use allow_core::{
    AllowEntry, Finding, FindingKind, Lifecycle, MatchOutcome, MatchStatus, Selector, Span,
    StructuralIdentity, normalize_path,
};
use clap::Parser;
use serde_json::Value;
use std::path::PathBuf;

#[test]
fn clap_parses_why_finding_location() {
    let parsed = CargoAllowCli::try_parse_from(argv(vec![
        "cargo-allow",
        "why",
        "--kind",
        "panic",
        "--path",
        "src/lib.rs",
        "--line",
        "42",
        "--format",
        "json",
        "--root",
        ".",
    ]))
    .unwrap_or_else(|err| std::panic::panic_any(format!("parse why: {err}")));

    match parsed.command {
        Some(CargoAllowCommand::Why(args)) => {
            assert_eq!(args.kind, "panic");
            assert_eq!(args.path, PathBuf::from("src/lib.rs"));
            assert_eq!(args.line, 42);
            assert_eq!(args.format, WhyFormat::Json);
            assert_eq!(args.root.root.as_deref(), Some(std::path::Path::new(".")));
        }
        other => std::panic::panic_any(format!("expected Why command, got {other:?}")),
    }
}

#[test]
fn render_why_lists_mismatch_reasons_for_new_findings() {
    let finding = sample_finding_at("src/lib.rs", 10);
    let entry = near_miss_entry();
    let outcome = MatchOutcome {
        status: MatchStatus::New,
        allow_id: None,
        candidate_ids: Vec::new(),
        finding_index: Some(0),
        message: "unreceipted panic.unwrap at src/lib.rs:10:1".to_string(),
        score: 0,
    };
    let reasons = explain_match_failure(&entry, &finding);
    assert!(
        reasons
            .iter()
            .any(|reason| reason.contains("callee mismatch")),
        "expected callee mismatch, got {reasons:?}"
    );
    let text = render_why_text(
        &finding,
        &outcome,
        &[WhyCandidate {
            entry: &entry,
            reasons,
        }],
    );

    assert!(text.contains("# Why this finding is unreceipted"));
    assert!(text.contains("src/lib.rs:10:1"));
    assert!(text.contains("### `allow-near-miss`"));
    assert!(text.contains("callee mismatch"));
    assert!(text.contains("cargo-allow"));
    assert!(text.contains("Claim boundary"));
}

#[test]
fn render_why_json_deserializes_and_asserts_semantic_paths() {
    let finding = sample_finding_at("src/lib.rs", 10);
    let entry = near_miss_entry();
    let outcome = MatchOutcome {
        status: MatchStatus::New,
        allow_id: None,
        candidate_ids: Vec::new(),
        finding_index: Some(0),
        message: "unreceipted panic.unwrap at src/lib.rs:10:1".to_string(),
        score: 0,
    };
    let reasons = explain_match_failure(&entry, &finding);
    let json = render_why_json(
        allow_report::InventoryContext::source_syntax("git_tracked", Some("H:/repo"), Some(3)),
        &finding,
        &outcome,
        &[WhyCandidate {
            entry: &entry,
            reasons,
        }],
    );
    let value = parse_why_json(&json);
    assert_eq!(
        value.pointer("/schema_id").and_then(Value::as_str),
        Some("cargo-allow.why.v1")
    );
    assert_eq!(
        value.pointer("/command").and_then(Value::as_str),
        Some("why")
    );
    assert_eq!(
        value
            .pointer("/candidate_entries/0/id")
            .and_then(Value::as_str),
        Some("allow-near-miss")
    );
    assert!(
        value
            .pointer("/candidate_entries/0/mismatch_reasons/0")
            .and_then(Value::as_str)
            .is_some_and(|reason| reason.contains("callee mismatch"))
    );
    assert_eq!(
        value.pointer("/outcome/status").and_then(Value::as_str),
        Some("new")
    );
    assert_eq!(
        value
            .pointer("/next/proof_plans/0/program")
            .and_then(Value::as_str),
        Some("cargo-allow")
    );
    assert_eq!(
        path_arg_from_plan(&value, 0),
        Some("src/lib.rs".to_string())
    );
}

#[test]
fn proof_plans_preserve_argument_identity_for_hostile_paths() {
    let fixtures = [
        "src/ordinary.rs",
        "src/has space.rs",
        "src/quote'and\"double.rs",
        "src/$(touch pwned).rs",
        "src/a;echo injected.rs",
        "-leading-dash.rs",
        "src/line\nbreak.rs",
        "src/ユニコード.rs",
    ];
    for path in fixtures {
        let finding = sample_finding_at(path, 7);
        let outcome = MatchOutcome {
            status: MatchStatus::New,
            allow_id: None,
            candidate_ids: Vec::new(),
            finding_index: Some(0),
            message: format!("unreceipted at {path}"),
            score: 0,
        };
        let expected = normalize_path(std::path::Path::new(path));
        let next = why_next_steps(&finding, &outcome, &[]);
        let add = next
            .proof_plans
            .first()
            .unwrap_or_else(|| std::panic::panic_any("new findings need an add plan"));
        let path_arg = add
            .args
            .iter()
            .position(|arg| arg == "--path")
            .and_then(|index| add.args.get(index + 1))
            .map(String::as_str);
        assert_eq!(
            path_arg,
            Some(expected.as_str()),
            "path fixture `{path}` must remain one argv element"
        );

        let json = render_why_json(
            allow_report::InventoryContext::source_syntax("git_tracked", Some("H:/repo"), Some(1)),
            &finding,
            &outcome,
            &[],
        );
        let value = parse_why_json(&json);
        assert_eq!(
            path_arg_from_plan(&value, 0).as_deref(),
            Some(expected.as_str()),
            "JSON argv must keep exact path for `{path}`"
        );
        // Guidance must never execute payloads; we only assert structure.
        assert!(
            !std::path::Path::new("pwned").exists(),
            "fixture must not create sidecar files"
        );
    }
}

#[test]
fn render_why_points_matched_findings_to_explain() {
    let finding = sample_finding_at("src/lib.rs", 10);
    let outcome = MatchOutcome {
        status: MatchStatus::Matched,
        allow_id: Some("allow-0007".to_string()),
        candidate_ids: vec!["allow-0007".to_string()],
        finding_index: Some(0),
        message: "matched".to_string(),
        score: 200,
    };
    let text = render_why_text(&finding, &outcome, &[]);
    assert!(text.contains("Already receipted"));
    assert!(text.contains("cargo-allow explain allow-0007"));
}

#[test]
fn ambiguous_why_emits_explain_plan_for_every_candidate_id() {
    let finding = sample_finding_at("src/lib.rs", 10);
    let outcome = MatchOutcome {
        status: MatchStatus::Ambiguous,
        allow_id: None,
        candidate_ids: vec!["allow-a".to_string(), "allow-b".to_string()],
        finding_index: Some(0),
        message: "ambiguous".to_string(),
        score: 0,
    };
    let next = why_next_steps(&finding, &outcome, &[]);
    let explain_ids: Vec<&str> = next
        .proof_plans
        .iter()
        .filter(|plan| plan.args.first().map(String::as_str) == Some("explain"))
        .filter_map(|plan| plan.args.get(1).map(String::as_str))
        .collect();
    assert_eq!(explain_ids, vec!["allow-a", "allow-b"]);

    let json = render_why_json(
        allow_report::InventoryContext::source_syntax("git_tracked", Some("H:/repo"), Some(1)),
        &finding,
        &outcome,
        &[],
    );
    let value = parse_why_json(&json);
    assert_eq!(
        value.pointer("/outcome/status").and_then(Value::as_str),
        Some("ambiguous")
    );
    assert_eq!(
        value
            .pointer("/next/proof_plans/0/args/1")
            .and_then(Value::as_str),
        Some("allow-a")
    );
    assert_eq!(
        value
            .pointer("/next/proof_plans/1/args/1")
            .and_then(Value::as_str),
        Some("allow-b")
    );
    let text = render_why_text(&finding, &outcome, &[]);
    assert!(text.contains("allow-a"));
    assert!(text.contains("allow-b"));
    assert!(text.contains("no candidate is selected as authoritative"));
}

fn sample_finding_at(path: &str, line: u32) -> Finding {
    let mut identity = StructuralIdentity::new("rust", "method_call");
    identity.container = Some("load".to_string());
    identity.callee = Some("unwrap".to_string());
    Finding {
        kind: FindingKind::Panic,
        family: Some("unwrap".to_string()),
        path: PathBuf::from(path),
        span: Some(Span { line, column: 1 }),
        identity,
        message: "unwrap call".to_string(),
        ledger: None,
    }
}

fn near_miss_entry() -> AllowEntry {
    AllowEntry {
        id: "allow-near-miss".to_string(),
        kind: FindingKind::Panic,
        family: Some("unwrap".to_string()),
        path: Some(PathBuf::from("src/lib.rs")),
        glob: None,
        owner: "core".to_string(),
        classification: "reviewed_exception".to_string(),
        reason: "near miss fixture".to_string(),
        evidence: Vec::new(),
        links: Vec::new(),
        occurrence_limit: None,
        lifecycle: Lifecycle::empty(),
        selector: Selector {
            ast_kind: Some("method_call".to_string()),
            container: Some("load".to_string()),
            callee: Some("expect".to_string()),
            ..Selector::default()
        },
        last_seen: None,
    }
}

fn argv(items: Vec<&str>) -> Vec<String> {
    items.into_iter().map(String::from).collect()
}

fn parse_why_json(json: &str) -> Value {
    serde_json::from_str(json).unwrap_or_else(|err| {
        std::panic::panic_any(format!("why JSON should deserialize: {err}\n{json}"))
    })
}

fn path_arg_from_plan(value: &Value, plan_index: usize) -> Option<String> {
    let args = value.pointer(&format!("/next/proof_plans/{plan_index}/args"))?;
    let arr = args.as_array()?;
    let path_index = arr
        .iter()
        .position(|item| item.as_str() == Some("--path"))?;
    arr.get(path_index + 1)?.as_str().map(str::to_string)
}

#[test]
fn why_args_round_trip_through_root_args() {
    let args = WhyArgs {
        root: RootArgs { root: None },
        config: None,
        kind: "unsafe".to_string(),
        path: PathBuf::from("crates/foo/src/lib.rs"),
        line: 3,
        include_untracked: true,
        format: WhyFormat::Human,
        output: Some(PathBuf::from("target/why.md")),
    };
    assert_eq!(args.line, 3);
    assert!(args.include_untracked);
}