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}`"
);
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);
}