Skip to main content

allow_report/
explain_human.rs

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}