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 let Some(limit) = entry.occurrence_limit {
30        out.push_str(&format!("occurrence_limit: {limit}\n"));
31    }
32    if let Some(created) = &entry.lifecycle.created {
33        out.push_str(&format!("created: {created}\n"));
34    }
35    if let Some(expires) = &entry.lifecycle.expires {
36        out.push_str(&format!("expires: {expires}\n"));
37    }
38    if let Some(review_after) = &entry.lifecycle.review_after {
39        out.push_str(&format!("review_after: {review_after}\n"));
40    }
41    if let Some(last_seen) = &entry.last_seen {
42        out.push_str(&format!(
43            "last_seen: {}:{}\n",
44            last_seen.line, last_seen.column
45        ));
46    }
47    out.push_str(&format!("selector: {}\n", selector_summary(entry)));
48    out.push_str(&format!(
49        "selector_precision: {}\n",
50        report.selector_precision
51    ));
52    out.push_str(&format!("broad_scope: {}\n\n", report.broad_scope));
53    out.push_str(&format!(
54        "current_status: {}\n",
55        explain_report_status(report.match_outcomes).as_str()
56    ));
57    out.push_str(&format!(
58        "current_matches: {}\n",
59        report.current_findings.len()
60    ));
61    out.push_str(&format!(
62        "match_outcomes: {}\n",
63        outcome_summary(report.match_outcomes)
64    ));
65    if !report.current_findings.is_empty() {
66        out.push_str("\ncurrent findings:\n");
67        for (index, finding) in report.current_findings.iter().enumerate().take(20) {
68            let status = report
69                .match_outcomes
70                .iter()
71                .find(|outcome| outcome.finding_index == Some(index))
72                .map(|outcome| outcome.status.as_str())
73                .unwrap_or("unmatched");
74            let package = finding
75                .source_package_name()
76                .map(|package| format!(", source_package={package}"))
77                .unwrap_or_default();
78            out.push_str(&format!(
79                "- {status}: {} ({}{})\n",
80                finding_location_text(finding),
81                finding.identity.ast_kind,
82                package
83            ));
84        }
85        if report.current_findings.len() > 20 {
86            out.push_str(&format!(
87                "- ... {} more matching findings omitted\n",
88                report.current_findings.len() - 20
89            ));
90        }
91    }
92    let attention = report
93        .match_outcomes
94        .iter()
95        .filter(|outcome| outcome.status != MatchStatus::Matched)
96        .collect::<Vec<_>>();
97    if !attention.is_empty() {
98        out.push_str("\nattention:\n");
99        for outcome in attention.iter().take(20) {
100            out.push_str(&format!(
101                "- {}: {}\n",
102                outcome.status.as_str(),
103                outcome.message
104            ));
105        }
106    } else if entry.classification == "baseline_debt" {
107        out.push_str("\nattention:\n");
108        out.push_str(&format!(
109            "- baseline_debt: {} is generated baseline_debt and still needs human review\n",
110            entry.id
111        ));
112    }
113    if !report.suggested_actions.is_empty() || !report.proof_commands.is_empty() {
114        out.push_str("\nnext:\n");
115        for action in report.suggested_actions.iter().take(2) {
116            out.push_str(&format!("- action: {action}\n"));
117        }
118        for command in report.proof_commands.iter().take(5) {
119            out.push_str(&format!("- proof: {command}\n"));
120        }
121    }
122    out.push('\n');
123    out.push_str(CLAIM_BOUNDARY_TEXT);
124    out
125}
126
127fn explain_kind_label(entry: &AllowEntry) -> String {
128    entry
129        .family
130        .as_ref()
131        .map(|family| format!("{}.{}", entry.kind, family))
132        .unwrap_or_else(|| entry.kind.to_string())
133}
134
135fn empty_as_none(value: &str) -> &str {
136    if value.trim().is_empty() {
137        "none"
138    } else {
139        value
140    }
141}
142
143fn list_or_none(values: &[String]) -> String {
144    if values.is_empty() {
145        "none".to_string()
146    } else {
147        values.join(", ")
148    }
149}
150
151fn evidence_reference_summary(reference: &EvidenceReference<'_>) -> String {
152    let status = evidence_reference_human_status(reference);
153    format!(
154        "[{}] {}: {} (prefix={}, target={})",
155        status.marker,
156        status.label,
157        reference.raw,
158        reference.prefix.unwrap_or("-"),
159        reference.target.unwrap_or("-")
160    )
161}
162
163fn selector_summary(entry: &AllowEntry) -> String {
164    let selector = &entry.selector;
165    let mut fields = Vec::new();
166    if let Some(value) = &selector.ast_kind {
167        fields.push(format!("ast_kind={value}"));
168    }
169    if let Some(value) = &selector.container {
170        fields.push(format!("container={value}"));
171    }
172    if let Some(value) = &selector.callee {
173        fields.push(format!("callee={value}"));
174    }
175    if let Some(value) = &selector.macro_name {
176        fields.push(format!("macro_name={value}"));
177    }
178    if let Some(value) = &selector.lint {
179        fields.push(format!("lint={value}"));
180    }
181    if let Some(value) = &selector.symbol {
182        fields.push(format!("symbol={value}"));
183    }
184    if let Some(value) = &selector.receiver_fingerprint {
185        fields.push(format!("receiver={value}"));
186    }
187    if let Some(value) = &selector.target_fingerprint {
188        fields.push(format!("target={value}"));
189    }
190    if let Some(value) = &selector.normalized_snippet_hash {
191        fields.push(format!("normalized_snippet_hash={value}"));
192    }
193    if let Some(value) = selector.line_hint {
194        fields.push(format!("line_hint={value}"));
195    }
196    if let Some(value) = &selector.glob {
197        fields.push(format!("glob={value}"));
198    }
199    if fields.is_empty() {
200        "none".to_string()
201    } else {
202        fields.join(", ")
203    }
204}
205
206fn outcome_summary(outcomes: &[MatchOutcome]) -> String {
207    let parts = [
208        MatchStatus::Matched,
209        MatchStatus::New,
210        MatchStatus::Expired,
211        MatchStatus::ReviewDue,
212        MatchStatus::Stale,
213        MatchStatus::Ambiguous,
214        MatchStatus::InvalidSelector,
215        MatchStatus::MissingRequiredField,
216        MatchStatus::EvidenceMissing,
217        MatchStatus::BaselineDebt,
218    ]
219    .into_iter()
220    .filter_map(|status| {
221        let count = outcomes
222            .iter()
223            .filter(|outcome| outcome.status == status)
224            .count();
225        (count > 0).then(|| format!("{}={count}", status.as_str()))
226    })
227    .collect::<Vec<_>>();
228    if parts.is_empty() {
229        "none".to_string()
230    } else {
231        parts.join(", ")
232    }
233}