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