allow_report/
explain_human.rs1use 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}