1use crate::diff_policy_detail::policy_change_detail;
2use crate::diff_posture::{diff_net_posture, diff_posture_summary};
3use crate::text::markdown_cell;
4use crate::{CLAIM_BOUNDARY_TEXT, DiffFindingChange, DiffPolicyChange};
5
6const PR_SUMMARY_HIGHLIGHT_LIMIT: usize = 8;
7const DIFF_MARKDOWN_CHANGE_LIMIT: usize = 120;
8
9pub fn render_diff_pr_summary_markdown(
10 current_failures: usize,
11 finding_changes: &[DiffFindingChange<'_>],
12 policy_changes: &[DiffPolicyChange<'_>],
13) -> String {
14 render_diff_pr_summary_markdown_with_evidence_health(
15 current_failures,
16 0,
17 0,
18 finding_changes,
19 policy_changes,
20 )
21}
22
23pub fn render_diff_pr_summary_markdown_with_evidence_health(
24 current_failures: usize,
25 broken_evidence_links: usize,
26 weak_evidence_references: usize,
27 finding_changes: &[DiffFindingChange<'_>],
28 policy_changes: &[DiffPolicyChange<'_>],
29) -> String {
30 let summary = diff_posture_summary(current_failures, finding_changes, policy_changes);
31 let posture = diff_net_posture(summary);
32 let mut out = String::new();
33 out.push_str("## PR Summary\n\n");
34 out.push_str(&format!("**Net posture:** `{}`\n\n", posture.as_str()));
35 out.push_str("| Signal | Count |\n|---|---:|\n");
36 out.push_str(&format!(
37 "| Current check failures | {} |\n",
38 summary.current_failures
39 ));
40 if broken_evidence_links > 0 {
41 out.push_str(&format!(
42 "| Broken evidence links | {broken_evidence_links} |\n"
43 ));
44 }
45 if weak_evidence_references > 0 {
46 out.push_str(&format!(
47 "| Weak evidence/link references | {weak_evidence_references} |\n"
48 ));
49 }
50 out.push_str(&format!(
51 "| New source findings | {} |\n",
52 summary.new_findings
53 ));
54 out.push_str(&format!(
55 "| Removed source findings | {} |\n",
56 summary.removed_findings
57 ));
58 out.push_str(&format!(
59 "| Policy failures | {} |\n",
60 summary.policy_failures
61 ));
62 out.push_str(&format!(
63 "| Policy review items | {} |\n",
64 summary.policy_review_items
65 ));
66 out.push_str(&format!(
67 "| Policy improvements | {} |\n",
68 summary.policy_improvements
69 ));
70 out.push_str(&format!(
71 "\n**Reviewer action:** {}\n\n",
72 posture.reviewer_action()
73 ));
74 if broken_evidence_links > 0 || weak_evidence_references > 0 {
75 out.push_str("**Evidence repair queues:**\n");
76 if broken_evidence_links > 0 {
77 out.push_str(
78 "- `cargo-allow worklist --item-kind broken_evidence_link --format json`\n",
79 );
80 }
81 if weak_evidence_references > 0 {
82 out.push_str(
83 "- `cargo-allow worklist --item-kind weak_evidence_reference --format json`\n",
84 );
85 }
86 out.push('\n');
87 }
88 out.push_str("> ");
89 out.push_str(CLAIM_BOUNDARY_TEXT);
90 out.push_str("\n\n");
91 append_finding_highlights(&mut out, finding_changes);
92 append_policy_highlights(&mut out, policy_changes);
93 out
94}
95
96fn append_finding_highlights(out: &mut String, finding_changes: &[DiffFindingChange<'_>]) {
97 let new_count = finding_changes
98 .iter()
99 .filter(|change| change.change == "new")
100 .count();
101 if new_count > 0 {
102 out.push_str("### Finding Attention\n\n");
103 out.push_str("| Change | Kind | Family | Path |\n|---|---|---|---|\n");
104 for change in finding_changes
105 .iter()
106 .filter(|change| change.change == "new")
107 .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
108 {
109 append_finding_highlight_row(out, change);
110 }
111 append_omitted_summary_note(out, new_count, "new finding change");
112 out.push('\n');
113 }
114
115 let removed_count = finding_changes
116 .iter()
117 .filter(|change| change.change == "removed")
118 .count();
119 if removed_count > 0 {
120 out.push_str("### Finding Improvements\n\n");
121 out.push_str("| Change | Kind | Family | Path |\n|---|---|---|---|\n");
122 for change in finding_changes
123 .iter()
124 .filter(|change| change.change == "removed")
125 .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
126 {
127 append_finding_highlight_row(out, change);
128 }
129 append_omitted_summary_note(out, removed_count, "removed finding change");
130 out.push('\n');
131 }
132}
133
134fn append_finding_highlight_row(out: &mut String, change: &DiffFindingChange<'_>) {
135 out.push_str(&format!(
136 "| `{}` | `{}` | `{}` | `{}` |\n",
137 markdown_cell(change.change),
138 markdown_cell(change.kind),
139 markdown_cell(change.family.unwrap_or("")),
140 markdown_cell(change.path)
141 ));
142}
143
144fn append_policy_highlights(out: &mut String, policy_changes: &[DiffPolicyChange<'_>]) {
145 append_policy_severity_highlights(
146 out,
147 policy_changes,
148 "fail",
149 "### Policy Failures",
150 "policy failure",
151 );
152 append_policy_severity_highlights(
153 out,
154 policy_changes,
155 "review",
156 "### Policy Review Required",
157 "policy review item",
158 );
159
160 let improvement_count = policy_changes
161 .iter()
162 .filter(|change| change.severity == "improvement")
163 .count();
164 if improvement_count > 0 {
165 out.push_str("### Policy Improvements\n\n");
166 out.push_str("| Allow ID | Kind | Detail | Message |\n|---|---|---|---|\n");
167 for change in policy_changes
168 .iter()
169 .filter(|change| change.severity == "improvement")
170 .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
171 {
172 let detail = policy_change_detail(change).unwrap_or_else(|| "none".to_string());
173 out.push_str(&format!(
174 "| `{}` | `{}` | {} | {} |\n",
175 markdown_cell(change.allow_id),
176 markdown_cell(change.kind),
177 markdown_cell(&detail),
178 markdown_cell(change.message)
179 ));
180 }
181 append_omitted_summary_note(out, improvement_count, "policy improvement change");
182 out.push('\n');
183 }
184}
185
186fn append_policy_severity_highlights(
187 out: &mut String,
188 policy_changes: &[DiffPolicyChange<'_>],
189 severity: &str,
190 heading: &str,
191 singular_label: &str,
192) {
193 let count = policy_changes
194 .iter()
195 .filter(|change| change.severity == severity)
196 .count();
197 if count == 0 {
198 return;
199 }
200
201 out.push_str(heading);
202 out.push_str("\n\n");
203 out.push_str("| Severity | Allow ID | Kind | Detail | Message |\n|---|---|---|---|---|\n");
204 for change in policy_changes
205 .iter()
206 .filter(|change| change.severity == severity)
207 .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
208 {
209 append_policy_highlight_row(out, change);
210 }
211 append_omitted_summary_note(out, count, singular_label);
212 out.push('\n');
213}
214
215fn append_omitted_summary_note(out: &mut String, count: usize, singular_label: &str) {
216 if count > PR_SUMMARY_HIGHLIGHT_LIMIT {
217 let omitted = count - PR_SUMMARY_HIGHLIGHT_LIMIT;
218 let plural = if omitted == 1 { "" } else { "s" };
219 out.push_str(&format!(
220 "\n{omitted} additional {singular_label}{plural} omitted from this summary.\n"
221 ));
222 }
223}
224
225fn append_policy_highlight_row(out: &mut String, change: &DiffPolicyChange<'_>) {
226 let detail = policy_change_detail(change).unwrap_or_else(|| "none".to_string());
227 out.push_str(&format!(
228 "| `{}` | `{}` | `{}` | {} | {} |\n",
229 markdown_cell(change.severity),
230 markdown_cell(change.allow_id),
231 markdown_cell(change.kind),
232 markdown_cell(&detail),
233 markdown_cell(change.message)
234 ));
235}
236
237pub fn insert_markdown_pr_summary(text: &mut String, summary: &str) {
238 let marker = "Findings scanned:";
239 if let Some(index) = text.find(marker) {
240 text.insert_str(index, summary);
241 } else {
242 text.push('\n');
243 text.push_str(summary);
244 }
245}
246
247pub fn render_diff_finding_changes_markdown(changes: &[DiffFindingChange<'_>]) -> String {
248 let mut out = String::new();
249 out.push_str("\n## Finding Posture Changes\n\n");
250 if changes.is_empty() {
251 out.push_str("No source finding posture changes detected.\n");
252 return out;
253 }
254 append_finding_changes_markdown_section(&mut out, "Finding Attention", changes, "new");
255 append_finding_changes_markdown_section(&mut out, "Finding Improvements", changes, "removed");
256 let known_changes = ["new", "removed"];
257 if changes
258 .iter()
259 .any(|change| !known_changes.contains(&change.change))
260 {
261 out.push_str("### Other Finding Changes\n\n");
262 append_finding_changes_markdown_table(
263 &mut out,
264 changes
265 .iter()
266 .filter(|change| !known_changes.contains(&change.change)),
267 );
268 }
269 out
270}
271
272fn append_finding_changes_markdown_section<'a>(
273 out: &mut String,
274 heading: &str,
275 changes: &'a [DiffFindingChange<'a>],
276 change_kind: &str,
277) {
278 if !changes.iter().any(|change| change.change == change_kind) {
279 return;
280 }
281 out.push_str(&format!("### {heading}\n\n"));
282 append_finding_changes_markdown_table(
283 out,
284 changes.iter().filter(|change| change.change == change_kind),
285 );
286}
287
288fn append_finding_changes_markdown_table<'a>(
289 out: &mut String,
290 changes: impl Iterator<Item = &'a DiffFindingChange<'a>>,
291) {
292 let changes = changes.collect::<Vec<_>>();
293 out.push_str("| Change | Kind | Family | Path |\n|---|---|---|---|\n");
294 for change in changes.iter().take(DIFF_MARKDOWN_CHANGE_LIMIT) {
295 out.push_str(&format!(
296 "| `{}` | `{}` | `{}` | `{}` |\n",
297 markdown_cell(change.change),
298 markdown_cell(change.kind),
299 markdown_cell(change.family.unwrap_or("")),
300 markdown_cell(change.path)
301 ));
302 }
303 if changes.len() > DIFF_MARKDOWN_CHANGE_LIMIT {
304 out.push_str(&format!(
305 "\n{} additional finding posture changes omitted.\n",
306 changes.len() - DIFF_MARKDOWN_CHANGE_LIMIT
307 ));
308 }
309 out.push('\n');
310}
311
312pub fn render_diff_policy_changes_markdown(changes: &[DiffPolicyChange<'_>]) -> String {
313 let mut out = String::new();
314 out.push_str("\n## Policy Posture Changes\n\n");
315 if changes.is_empty() {
316 out.push_str("No policy weakening detected.\n");
317 return out;
318 }
319 append_policy_changes_markdown_section(&mut out, "Policy Failures", changes, "fail");
320 append_policy_changes_markdown_section(&mut out, "Policy Review Required", changes, "review");
321 append_policy_changes_markdown_section(&mut out, "Policy Improvements", changes, "improvement");
322 let known_severities = ["fail", "review", "improvement"];
323 if changes
324 .iter()
325 .any(|change| !known_severities.contains(&change.severity))
326 {
327 out.push_str("### Other Policy Changes\n\n");
328 append_policy_changes_markdown_table(
329 &mut out,
330 changes
331 .iter()
332 .filter(|change| !known_severities.contains(&change.severity)),
333 );
334 }
335 out
336}
337
338fn append_policy_changes_markdown_section<'a>(
339 out: &mut String,
340 heading: &str,
341 changes: &'a [DiffPolicyChange<'a>],
342 severity: &str,
343) {
344 if !changes.iter().any(|change| change.severity == severity) {
345 return;
346 }
347 out.push_str(&format!("### {heading}\n\n"));
348 append_policy_changes_markdown_table(
349 out,
350 changes.iter().filter(|change| change.severity == severity),
351 );
352}
353
354fn append_policy_changes_markdown_table<'a>(
355 out: &mut String,
356 changes: impl Iterator<Item = &'a DiffPolicyChange<'a>>,
357) {
358 let changes = changes.collect::<Vec<_>>();
359 out.push_str("| Severity | Allow ID | Kind | Detail | Message |\n|---|---|---|---|---|\n");
360 for change in changes.iter().take(DIFF_MARKDOWN_CHANGE_LIMIT) {
361 let detail = policy_change_detail(change).unwrap_or_else(|| "none".to_string());
362 out.push_str(&format!(
363 "| `{}` | `{}` | `{}` | {} | {} |\n",
364 markdown_cell(change.severity),
365 markdown_cell(change.allow_id),
366 markdown_cell(change.kind),
367 markdown_cell(&detail),
368 markdown_cell(change.message)
369 ));
370 }
371 if changes.len() > DIFF_MARKDOWN_CHANGE_LIMIT {
372 out.push_str(&format!(
373 "\n{} additional policy posture changes omitted.\n",
374 changes.len() - DIFF_MARKDOWN_CHANGE_LIMIT
375 ));
376 }
377 out.push('\n');
378}