1use crate::evidence_reference_human::evidence_reference_human_status;
2use crate::explain_common::{explain_report_status, finding_location_text};
3use crate::{CLAIM_BOUNDARY_TEXT, EvidenceReference, ExplainReport};
4use allow_core::{AllowEntry, MatchOutcome, MatchStatus};
5
6pub fn render_explain_human(report: ExplainReport<'_>) -> String {
7 let entry = report.entry;
8 let mut out = String::new();
9 out.push_str(&format!("{}\n", entry.id));
10 out.push_str(&format!("kind: {}\n", explain_kind_label(entry)));
11 out.push_str(&format!("scope: {}\n", entry.path_or_glob()));
12 out.push_str(&format!("owner: {}\n", empty_as_none(&entry.owner)));
13 out.push_str(&format!(
14 "classification: {}\n",
15 empty_as_none(&entry.classification)
16 ));
17 out.push_str(&format!("reason: {}\n", empty_as_none(&entry.reason)));
18 out.push_str(&format!("evidence: {}\n", list_or_none(&entry.evidence)));
19 if !report.evidence_references.is_empty() {
20 out.push_str("\nevidence diagnostics:\n");
21 for reference in report.evidence_references {
22 out.push_str(&format!("- {}\n", evidence_reference_summary(reference)));
23 out.push_str(&format!(" message: {}\n", reference.message));
24 }
25 }
26 if !entry.links.is_empty() {
27 out.push_str(&format!("links: {}\n", entry.links.join(", ")));
28 }
29 if !report.link_references.is_empty() {
30 out.push_str("\nlink diagnostics:\n");
31 for reference in report.link_references {
32 out.push_str(&format!("- {}\n", evidence_reference_summary(reference)));
33 out.push_str(&format!(" message: {}\n", reference.message));
34 }
35 }
36 if let Some(limit) = entry.occurrence_limit {
37 out.push_str(&format!("occurrence_limit: {limit}\n"));
38 }
39 if let Some(created) = &entry.lifecycle.created {
40 out.push_str(&format!("created: {created}\n"));
41 }
42 if let Some(expires) = &entry.lifecycle.expires {
43 out.push_str(&format!("expires: {expires}\n"));
44 }
45 if let Some(review_after) = &entry.lifecycle.review_after {
46 out.push_str(&format!("review_after: {review_after}\n"));
47 }
48 if let Some(last_seen) = &entry.last_seen {
49 out.push_str(&format!(
50 "last_seen: {}:{}\n",
51 last_seen.line, last_seen.column
52 ));
53 }
54 out.push_str(&format!("selector: {}\n", selector_summary(entry)));
55 out.push_str(&format!(
56 "selector_precision: {}\n",
57 report.selector_precision
58 ));
59 out.push_str(&format!("broad_scope: {}\n\n", report.broad_scope));
60 out.push_str(&format!(
61 "current_status: {}\n",
62 explain_report_status(report.match_outcomes).as_str()
63 ));
64 out.push_str(&format!(
65 "current_matches: {}\n",
66 report.current_findings.len()
67 ));
68 out.push_str(&format!(
69 "match_outcomes: {}\n",
70 outcome_summary(report.match_outcomes)
71 ));
72 if !report.current_findings.is_empty() {
73 out.push_str("\ncurrent findings:\n");
74 for (index, finding) in report.current_findings.iter().enumerate().take(20) {
75 let status = report
76 .match_outcomes
77 .iter()
78 .find(|outcome| outcome.finding_index == Some(index))
79 .map(|outcome| outcome.status.as_str())
80 .unwrap_or("unmatched");
81 let package = finding
82 .source_package_name()
83 .map(|package| format!(", source_package={package}"))
84 .unwrap_or_default();
85 out.push_str(&format!(
86 "- {status}: {} ({}{})\n",
87 finding_location_text(finding),
88 finding.identity.ast_kind,
89 package
90 ));
91 }
92 if report.current_findings.len() > 20 {
93 out.push_str(&format!(
94 "- ... {} more matching findings omitted\n",
95 report.current_findings.len() - 20
96 ));
97 }
98 }
99 let attention = report
100 .match_outcomes
101 .iter()
102 .filter(|outcome| outcome.status != MatchStatus::Matched)
103 .collect::<Vec<_>>();
104 if !attention.is_empty() {
105 out.push_str("\nattention:\n");
106 for outcome in attention.iter().take(20) {
107 out.push_str(&format!(
108 "- {}: {}\n",
109 outcome.status.as_str(),
110 outcome.message
111 ));
112 }
113 } else if entry.classification == "baseline_debt" {
114 out.push_str("\nattention:\n");
115 out.push_str(&format!(
116 "- baseline_debt: {} is generated baseline_debt and still needs human review\n",
117 entry.id
118 ));
119 }
120 if !report.suggested_actions.is_empty() || !report.proof_commands.is_empty() {
121 out.push_str("\nnext:\n");
122 for action in report.suggested_actions.iter().take(2) {
123 out.push_str(&format!("- action: {action}\n"));
124 }
125 for command in report.proof_commands.iter().take(8) {
126 out.push_str(&format!("- proof: {command}\n"));
127 }
128 }
129 out.push('\n');
130 out.push_str(CLAIM_BOUNDARY_TEXT);
131 out
132}
133
134fn explain_kind_label(entry: &AllowEntry) -> String {
135 entry
136 .family
137 .as_ref()
138 .map(|family| format!("{}.{}", entry.kind, family))
139 .unwrap_or_else(|| entry.kind.to_string())
140}
141
142fn empty_as_none(value: &str) -> &str {
143 if value.trim().is_empty() {
144 "none"
145 } else {
146 value
147 }
148}
149
150fn list_or_none(values: &[String]) -> String {
151 if values.is_empty() {
152 "none".to_string()
153 } else {
154 values.join(", ")
155 }
156}
157
158fn evidence_reference_summary(reference: &EvidenceReference<'_>) -> String {
159 let status = evidence_reference_human_status(reference);
160 format!(
161 "{}: {} (status={}, prefix={}, target={})",
162 status.label,
163 reference.raw,
164 reference.status,
165 reference.prefix.unwrap_or("-"),
166 reference.target.unwrap_or("-")
167 )
168}
169
170fn selector_summary(entry: &AllowEntry) -> String {
171 let selector = &entry.selector;
172 let mut fields = Vec::new();
173 if let Some(value) = &selector.ast_kind {
174 fields.push(format!("ast_kind={value}"));
175 }
176 if let Some(value) = &selector.container {
177 fields.push(format!("container={value}"));
178 }
179 if let Some(value) = &selector.callee {
180 fields.push(format!("callee={value}"));
181 }
182 if let Some(value) = &selector.macro_name {
183 fields.push(format!("macro_name={value}"));
184 }
185 if let Some(value) = &selector.lint {
186 fields.push(format!("lint={value}"));
187 }
188 if let Some(value) = &selector.symbol {
189 fields.push(format!("symbol={value}"));
190 }
191 if let Some(value) = &selector.receiver_fingerprint {
192 fields.push(format!("receiver={value}"));
193 }
194 if let Some(value) = &selector.target_fingerprint {
195 fields.push(format!("target={value}"));
196 }
197 if let Some(value) = &selector.normalized_snippet_hash {
198 fields.push(format!("normalized_snippet_hash={value}"));
199 }
200 if let Some(value) = selector.line_hint {
201 fields.push(format!("line_hint={value}"));
202 }
203 if let Some(value) = &selector.glob {
204 fields.push(format!("glob={value}"));
205 }
206 if fields.is_empty() {
207 "none".to_string()
208 } else {
209 fields.join(", ")
210 }
211}
212
213fn outcome_summary(outcomes: &[MatchOutcome]) -> String {
214 let parts = [
215 MatchStatus::Matched,
216 MatchStatus::New,
217 MatchStatus::Expired,
218 MatchStatus::ReviewDue,
219 MatchStatus::Stale,
220 MatchStatus::Ambiguous,
221 MatchStatus::InvalidSelector,
222 MatchStatus::MissingRequiredField,
223 MatchStatus::EvidenceMissing,
224 MatchStatus::BaselineDebt,
225 ]
226 .into_iter()
227 .filter_map(|status| {
228 let count = outcomes
229 .iter()
230 .filter(|outcome| outcome.status == status)
231 .count();
232 (count > 0).then(|| format!("{}={count}", status.as_str()))
233 })
234 .collect::<Vec<_>>();
235 if parts.is_empty() {
236 "none".to_string()
237 } else {
238 parts.join(", ")
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use allow_core::{FindingKind, Lifecycle, Selector};
246 use std::path::PathBuf;
247
248 #[test]
249 fn explain_kind_label_call_presence_observer() {
250 let entry = allow_entry(FindingKind::Unsafe, Some("unsafe_block"));
251 assert_eq!(explain_kind_label(&entry), "unsafe.unsafe_block");
252
253 let entry = allow_entry(FindingKind::Panic, None);
254 assert_eq!(explain_kind_label(&entry), "panic");
255 }
256
257 #[test]
258 fn empty_as_none_boundary_discriminator() {
259 assert_eq!(empty_as_none("owner"), "owner");
260 assert_eq!(empty_as_none(""), "none");
261 assert_eq!(empty_as_none(" "), "none");
262 }
263
264 #[test]
265 fn list_or_none_boundary_discriminator() {
266 assert_eq!(list_or_none(&[]), "none");
267 assert_eq!(list_or_none(&["doc:one".to_string()]), "doc:one");
268 assert_eq!(
269 list_or_none(&["doc:one".to_string(), "issue:two".to_string()]),
270 "doc:one, issue:two"
271 );
272 }
273
274 #[test]
275 fn evidence_reference_summary_call_presence_observer() {
276 let reference = EvidenceReference {
277 raw: "doc:docs/safety.md",
278 prefix: Some("doc"),
279 target: Some("docs/safety.md"),
280 status: "local_file_missing",
281 category: "missing",
282 message: "local evidence file is missing",
283 };
284
285 assert_eq!(
286 evidence_reference_summary(&reference),
287 "missing: doc:docs/safety.md (status=local_file_missing, prefix=doc, target=docs/safety.md)"
288 );
289 }
290
291 #[test]
292 fn evidence_reference_summary_uses_fallbacks_for_missing_prefix_and_target() {
293 let reference = EvidenceReference {
294 raw: "README.md",
295 prefix: None,
296 target: None,
297 status: "weak_reference",
298 category: "untyped",
299 message: "reference is weak",
300 };
301
302 assert_eq!(
303 evidence_reference_summary(&reference),
304 "weak: README.md (status=weak_reference, prefix=-, target=-)"
305 );
306 }
307
308 #[test]
309 fn selector_summary_boundary_discriminator() {
310 let entry = allow_entry(FindingKind::Unsafe, Some("unsafe_block"));
311 assert_eq!(selector_summary(&entry), "none");
312
313 let entry = allow_entry_with_selector(Selector {
314 ast_kind: Some("unsafe_block".to_string()),
315 container: Some("read_byte".to_string()),
316 callee: Some("read".to_string()),
317 macro_name: Some("panic".to_string()),
318 lint: Some("clippy::unwrap_used".to_string()),
319 symbol: Some("read_byte".to_string()),
320 receiver_fingerprint: Some("reader".to_string()),
321 target_fingerprint: Some("ptr".to_string()),
322 normalized_snippet_hash: Some("fnv1a64:abc".to_string()),
323 line_hint: Some(42),
324 glob: Some("src/**/*.rs".to_string()),
325 });
326
327 assert_eq!(
328 selector_summary(&entry),
329 "ast_kind=unsafe_block, container=read_byte, callee=read, macro_name=panic, lint=clippy::unwrap_used, symbol=read_byte, receiver=reader, target=ptr, normalized_snippet_hash=fnv1a64:abc, line_hint=42, glob=src/**/*.rs"
330 );
331 }
332
333 #[test]
334 fn outcome_summary_call_presence_observer() {
335 let outcomes = vec![
336 outcome(MatchStatus::Matched),
337 outcome(MatchStatus::New),
338 outcome(MatchStatus::New),
339 outcome(MatchStatus::Expired),
340 outcome(MatchStatus::ReviewDue),
341 outcome(MatchStatus::Stale),
342 outcome(MatchStatus::Ambiguous),
343 outcome(MatchStatus::InvalidSelector),
344 outcome(MatchStatus::MissingRequiredField),
345 outcome(MatchStatus::EvidenceMissing),
346 outcome(MatchStatus::BaselineDebt),
347 ];
348
349 assert_eq!(
350 outcome_summary(&outcomes),
351 "matched=1, new=2, expired=1, review_due=1, stale=1, ambiguous=1, invalid_selector=1, missing_required_field=1, evidence_missing=1, baseline_debt=1"
352 );
353 }
354
355 #[test]
356 fn outcome_summary_boundary_discriminator() {
357 assert_eq!(outcome_summary(&[]), "none");
358 }
359
360 fn allow_entry(kind: FindingKind, family: Option<&str>) -> AllowEntry {
361 let mut entry = allow_entry_with_selector(Selector::default());
362 entry.kind = kind;
363 entry.family = family.map(str::to_string);
364 entry
365 }
366
367 fn allow_entry_with_selector(selector: Selector) -> AllowEntry {
368 AllowEntry {
369 id: "allow-test".to_string(),
370 kind: FindingKind::Unsafe,
371 family: Some("unsafe_block".to_string()),
372 path: Some(PathBuf::from("src/lib.rs")),
373 glob: None,
374 owner: "owner".to_string(),
375 classification: "classification".to_string(),
376 reason: "reason".to_string(),
377 evidence: Vec::new(),
378 links: Vec::new(),
379 occurrence_limit: None,
380 lifecycle: Lifecycle::empty(),
381 selector,
382 last_seen: None,
383 }
384 }
385
386 fn outcome(status: MatchStatus) -> MatchOutcome {
387 MatchOutcome {
388 status,
389 allow_id: None,
390 finding_index: None,
391 message: String::new(),
392 score: 0,
393 }
394 }
395}