1use crate::diff_finding_detail::structural_identity_summary;
2use crate::diff_policy_detail::policy_change_detail;
3use crate::diff_posture::{
4 diff_evidence_delta_summary, diff_net_posture, diff_posture_summary,
5 diff_structural_delta_summary,
6};
7use crate::evidence_repair::evidence_repair_queues_from_counts;
8use crate::text::markdown_cell;
9use crate::{CLAIM_BOUNDARY_TEXT, DiffFindingChange, DiffPolicyChange};
10
11const PR_SUMMARY_HIGHLIGHT_LIMIT: usize = 8;
12const DIFF_MARKDOWN_CHANGE_LIMIT: usize = 120;
13
14pub fn render_diff_pr_summary_markdown(
15 current_failures: usize,
16 finding_changes: &[DiffFindingChange<'_>],
17 policy_changes: &[DiffPolicyChange<'_>],
18) -> String {
19 render_diff_pr_summary_markdown_with_evidence_health(
20 current_failures,
21 0,
22 0,
23 finding_changes,
24 policy_changes,
25 )
26}
27
28pub fn render_diff_pr_summary_markdown_with_evidence_health_counts(
29 current_failures: usize,
30 broken_evidence_links: usize,
31 missing_evidence: usize,
32 weak_evidence_references: usize,
33 finding_changes: &[DiffFindingChange<'_>],
34 policy_changes: &[DiffPolicyChange<'_>],
35) -> String {
36 render_diff_pr_summary_markdown_with_evidence_health_counts_inner(
37 current_failures,
38 broken_evidence_links,
39 missing_evidence,
40 weak_evidence_references,
41 finding_changes,
42 policy_changes,
43 )
44}
45
46pub fn render_diff_pr_summary_markdown_with_evidence_health(
47 current_failures: usize,
48 broken_evidence_links: usize,
49 weak_evidence_references: usize,
50 finding_changes: &[DiffFindingChange<'_>],
51 policy_changes: &[DiffPolicyChange<'_>],
52) -> String {
53 render_diff_pr_summary_markdown_with_evidence_health_counts_inner(
54 current_failures,
55 broken_evidence_links,
56 0,
57 weak_evidence_references,
58 finding_changes,
59 policy_changes,
60 )
61}
62
63fn render_diff_pr_summary_markdown_with_evidence_health_counts_inner(
64 current_failures: usize,
65 broken_evidence_links: usize,
66 missing_evidence: usize,
67 weak_evidence_references: usize,
68 finding_changes: &[DiffFindingChange<'_>],
69 policy_changes: &[DiffPolicyChange<'_>],
70) -> String {
71 let summary = diff_posture_summary(current_failures, finding_changes, policy_changes);
72 let posture = diff_net_posture(summary);
73 let mut out = String::new();
74 out.push_str("## PR Summary\n\n");
75 out.push_str(&format!("**Net posture:** `{}`\n\n", posture.as_str()));
76 out.push_str("| Signal | Count |\n|---|---:|\n");
77 out.push_str(&format!(
78 "| Current check failures | {} |\n",
79 summary.current_failures
80 ));
81 if broken_evidence_links > 0 {
82 out.push_str(&format!(
83 "| Broken evidence links | {broken_evidence_links} |\n"
84 ));
85 }
86 if missing_evidence > 0 {
87 out.push_str(&format!("| Missing evidence | {missing_evidence} |\n"));
88 }
89 if weak_evidence_references > 0 {
90 out.push_str(&format!(
91 "| Weak evidence/link references | {weak_evidence_references} |\n"
92 ));
93 }
94 let structural_delta = diff_structural_delta_summary(policy_changes);
95 if structural_delta.scope_broadened > 0 {
96 out.push_str(&format!(
97 "| Scope broadened | {} |\n",
98 structural_delta.scope_broadened
99 ));
100 }
101 if structural_delta.scope_changed > 0 {
102 out.push_str(&format!(
103 "| Scope changed | {} |\n",
104 structural_delta.scope_changed
105 ));
106 }
107 if structural_delta.scope_narrowed > 0 {
108 out.push_str(&format!(
109 "| Scope narrowed | {} |\n",
110 structural_delta.scope_narrowed
111 ));
112 }
113 if structural_delta.selector_changed > 0 {
114 out.push_str(&format!(
115 "| Selector changed | {} |\n",
116 structural_delta.selector_changed
117 ));
118 }
119 if structural_delta.selector_precision_decreased > 0 {
120 out.push_str(&format!(
121 "| Selector precision decreased | {} |\n",
122 structural_delta.selector_precision_decreased
123 ));
124 }
125 if structural_delta.selector_precision_increased > 0 {
126 out.push_str(&format!(
127 "| Selector precision increased | {} |\n",
128 structural_delta.selector_precision_increased
129 ));
130 }
131 let evidence_delta = diff_evidence_delta_summary(policy_changes);
132 if evidence_delta.evidence_added > 0 {
133 out.push_str(&format!(
134 "| Evidence added | {} |\n",
135 evidence_delta.evidence_added
136 ));
137 }
138 if evidence_delta.weak_evidence_added > 0 {
139 out.push_str(&format!(
140 "| Weak evidence added | {} |\n",
141 evidence_delta.weak_evidence_added
142 ));
143 }
144 if evidence_delta.broken_evidence_added > 0 {
145 out.push_str(&format!(
146 "| Broken evidence added | {} |\n",
147 evidence_delta.broken_evidence_added
148 ));
149 }
150 if evidence_delta.evidence_removed > 0 {
151 out.push_str(&format!(
152 "| Evidence removed | {} |\n",
153 evidence_delta.evidence_removed
154 ));
155 }
156 if evidence_delta.evidence_removal_failures > 0 {
157 out.push_str(&format!(
158 "| Evidence removal failures | {} |\n",
159 evidence_delta.evidence_removal_failures
160 ));
161 }
162 if evidence_delta.evidence_removal_review_items > 0 {
163 out.push_str(&format!(
164 "| Evidence removal review items | {} |\n",
165 evidence_delta.evidence_removal_review_items
166 ));
167 }
168 if evidence_delta.evidence_removal_improvements > 0 {
169 out.push_str(&format!(
170 "| Evidence removal improvements | {} |\n",
171 evidence_delta.evidence_removal_improvements
172 ));
173 }
174 if evidence_delta.link_added > 0 {
175 out.push_str(&format!(
176 "| Links added | {} |\n",
177 evidence_delta.link_added
178 ));
179 }
180 if evidence_delta.weak_link_added > 0 {
181 out.push_str(&format!(
182 "| Weak links added | {} |\n",
183 evidence_delta.weak_link_added
184 ));
185 }
186 if evidence_delta.broken_link_added > 0 {
187 out.push_str(&format!(
188 "| Broken links added | {} |\n",
189 evidence_delta.broken_link_added
190 ));
191 }
192 if evidence_delta.link_removed > 0 {
193 out.push_str(&format!(
194 "| Links removed | {} |\n",
195 evidence_delta.link_removed
196 ));
197 }
198 if evidence_delta.link_removal_failures > 0 {
199 out.push_str(&format!(
200 "| Link removal failures | {} |\n",
201 evidence_delta.link_removal_failures
202 ));
203 }
204 if evidence_delta.link_removal_review_items > 0 {
205 out.push_str(&format!(
206 "| Link removal review items | {} |\n",
207 evidence_delta.link_removal_review_items
208 ));
209 }
210 if evidence_delta.link_removal_improvements > 0 {
211 out.push_str(&format!(
212 "| Link removal improvements | {} |\n",
213 evidence_delta.link_removal_improvements
214 ));
215 }
216 out.push_str(&format!(
217 "| New source findings | {} |\n",
218 summary.new_findings
219 ));
220 out.push_str(&format!(
221 "| Removed source findings | {} |\n",
222 summary.removed_findings
223 ));
224 out.push_str(&format!(
225 "| Policy failures | {} |\n",
226 summary.policy_failures
227 ));
228 out.push_str(&format!(
229 "| Policy review items | {} |\n",
230 summary.policy_review_items
231 ));
232 out.push_str(&format!(
233 "| Policy improvements | {} |\n",
234 summary.policy_improvements
235 ));
236 out.push_str(&format!(
237 "\n**Reviewer action:** {}\n\n",
238 posture.reviewer_action()
239 ));
240 let evidence_repair_queues = evidence_repair_queues_from_counts(
241 broken_evidence_links,
242 missing_evidence,
243 weak_evidence_references,
244 );
245 if !evidence_repair_queues.is_empty() {
246 out.push_str("**Evidence repair queues:**\n");
247 for queue in evidence_repair_queues {
248 out.push_str(&format!("- `{}`\n", queue.command));
249 }
250 out.push('\n');
251 }
252 out.push_str("> ");
253 out.push_str(CLAIM_BOUNDARY_TEXT);
254 out.push_str("\n\n");
255 append_finding_highlights(&mut out, finding_changes);
256 append_policy_highlights(&mut out, policy_changes);
257 out
258}
259
260fn append_finding_highlights(out: &mut String, finding_changes: &[DiffFindingChange<'_>]) {
261 let new_count = finding_changes
262 .iter()
263 .filter(|change| change.change == "new")
264 .count();
265 if new_count > 0 {
266 out.push_str("### Finding Attention\n\n");
267 let include_source_package = finding_changes_have_source_package(finding_changes, "new");
268 let include_identity = finding_changes_have_identity(finding_changes, "new");
269 append_finding_highlight_header(out, include_source_package, include_identity);
270 for change in finding_changes
271 .iter()
272 .filter(|change| change.change == "new")
273 .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
274 {
275 append_finding_highlight_row(out, change, include_source_package, include_identity);
276 }
277 append_omitted_summary_note(out, new_count, "new finding change");
278 out.push('\n');
279 }
280
281 let removed_count = finding_changes
282 .iter()
283 .filter(|change| change.change == "removed")
284 .count();
285 if removed_count > 0 {
286 out.push_str("### Finding Improvements\n\n");
287 let include_source_package =
288 finding_changes_have_source_package(finding_changes, "removed");
289 let include_identity = finding_changes_have_identity(finding_changes, "removed");
290 append_finding_highlight_header(out, include_source_package, include_identity);
291 for change in finding_changes
292 .iter()
293 .filter(|change| change.change == "removed")
294 .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
295 {
296 append_finding_highlight_row(out, change, include_source_package, include_identity);
297 }
298 append_omitted_summary_note(out, removed_count, "removed finding change");
299 out.push('\n');
300 }
301}
302
303fn append_finding_highlight_header(
304 out: &mut String,
305 include_source_package: bool,
306 include_identity: bool,
307) {
308 out.push_str("| Change | Kind | Family | Path |");
309 if include_source_package {
310 out.push_str(" Source Package |");
311 }
312 if include_identity {
313 out.push_str(" Identity |");
314 }
315 out.push_str("\n|---|---|---|---|");
316 if include_source_package {
317 out.push_str("---|");
318 }
319 if include_identity {
320 out.push_str("---|");
321 }
322 out.push('\n');
323}
324
325fn append_finding_highlight_row(
326 out: &mut String,
327 change: &DiffFindingChange<'_>,
328 include_source_package: bool,
329 include_identity: bool,
330) {
331 out.push_str(&format!(
332 "| `{}` | `{}` | `{}` | `{}` |",
333 markdown_cell(change.change),
334 markdown_cell(change.kind),
335 markdown_cell(change.family.unwrap_or("")),
336 markdown_cell(&finding_location(change))
337 ));
338 if include_source_package {
339 out.push_str(&format!(
340 " `{}` |",
341 markdown_cell(change.source_package.unwrap_or(""))
342 ));
343 }
344 if include_identity {
345 out.push_str(&format!(
346 " `{}` |",
347 markdown_cell(&finding_identity_summary(change))
348 ));
349 }
350 out.push('\n');
351}
352
353fn append_policy_highlights(out: &mut String, policy_changes: &[DiffPolicyChange<'_>]) {
354 append_policy_severity_highlights(
355 out,
356 policy_changes,
357 "fail",
358 "### Policy Failures",
359 "policy failure",
360 );
361 append_policy_severity_highlights(
362 out,
363 policy_changes,
364 "review",
365 "### Policy Review Required",
366 "policy review item",
367 );
368
369 let improvement_count = policy_changes
370 .iter()
371 .filter(|change| change.severity == "improvement")
372 .count();
373 if improvement_count > 0 {
374 out.push_str("### Policy Improvements\n\n");
375 out.push_str("| Allow ID | Kind | Detail | Message |\n|---|---|---|---|\n");
376 for change in policy_changes
377 .iter()
378 .filter(|change| change.severity == "improvement")
379 .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
380 {
381 let detail = policy_change_detail(change).unwrap_or_else(|| "none".to_string());
382 out.push_str(&format!(
383 "| `{}` | `{}` | {} | {} |\n",
384 markdown_cell(change.allow_id),
385 markdown_cell(change.kind),
386 markdown_cell(&detail),
387 markdown_cell(change.message)
388 ));
389 }
390 append_omitted_summary_note(out, improvement_count, "policy improvement change");
391 out.push('\n');
392 }
393}
394
395fn append_policy_severity_highlights(
396 out: &mut String,
397 policy_changes: &[DiffPolicyChange<'_>],
398 severity: &str,
399 heading: &str,
400 singular_label: &str,
401) {
402 let count = policy_changes
403 .iter()
404 .filter(|change| change.severity == severity)
405 .count();
406 if count == 0 {
407 return;
408 }
409
410 out.push_str(heading);
411 out.push_str("\n\n");
412 out.push_str("| Severity | Allow ID | Kind | Detail | Message |\n|---|---|---|---|---|\n");
413 for change in policy_changes
414 .iter()
415 .filter(|change| change.severity == severity)
416 .take(PR_SUMMARY_HIGHLIGHT_LIMIT)
417 {
418 append_policy_highlight_row(out, change);
419 }
420 append_omitted_summary_note(out, count, singular_label);
421 out.push('\n');
422}
423
424fn append_omitted_summary_note(out: &mut String, count: usize, singular_label: &str) {
425 if count > PR_SUMMARY_HIGHLIGHT_LIMIT {
426 let omitted = count - PR_SUMMARY_HIGHLIGHT_LIMIT;
427 let plural = if omitted == 1 { "" } else { "s" };
428 out.push_str(&format!(
429 "\n{omitted} additional {singular_label}{plural} omitted from this summary.\n"
430 ));
431 }
432}
433
434fn append_policy_highlight_row(out: &mut String, change: &DiffPolicyChange<'_>) {
435 let detail = policy_change_detail(change).unwrap_or_else(|| "none".to_string());
436 out.push_str(&format!(
437 "| `{}` | `{}` | `{}` | {} | {} |\n",
438 markdown_cell(change.severity),
439 markdown_cell(change.allow_id),
440 markdown_cell(change.kind),
441 markdown_cell(&detail),
442 markdown_cell(change.message)
443 ));
444}
445
446pub fn insert_markdown_pr_summary(text: &mut String, summary: &str) {
447 let marker = "Findings scanned:";
448 if let Some(index) = text.find(marker) {
449 text.insert_str(index, summary);
450 } else {
451 text.push('\n');
452 text.push_str(summary);
453 }
454}
455
456pub fn render_diff_finding_changes_markdown(changes: &[DiffFindingChange<'_>]) -> String {
457 let mut out = String::new();
458 out.push_str("\n## Finding Posture Changes\n\n");
459 if changes.is_empty() {
460 out.push_str("No source finding posture changes detected.\n");
461 return out;
462 }
463 append_finding_changes_markdown_section(&mut out, "Finding Attention", changes, "new");
464 append_finding_changes_markdown_section(&mut out, "Finding Improvements", changes, "removed");
465 let known_changes = ["new", "removed"];
466 if changes
467 .iter()
468 .any(|change| !known_changes.contains(&change.change))
469 {
470 out.push_str("### Other Finding Changes\n\n");
471 append_finding_changes_markdown_table(
472 &mut out,
473 changes
474 .iter()
475 .filter(|change| !known_changes.contains(&change.change)),
476 );
477 }
478 out
479}
480
481fn append_finding_changes_markdown_section<'a>(
482 out: &mut String,
483 heading: &str,
484 changes: &'a [DiffFindingChange<'a>],
485 change_kind: &str,
486) {
487 if !changes.iter().any(|change| change.change == change_kind) {
488 return;
489 }
490 out.push_str(&format!("### {heading}\n\n"));
491 append_finding_changes_markdown_table(
492 out,
493 changes.iter().filter(|change| change.change == change_kind),
494 );
495}
496
497fn append_finding_changes_markdown_table<'a>(
498 out: &mut String,
499 changes: impl Iterator<Item = &'a DiffFindingChange<'a>>,
500) {
501 let changes = changes.collect::<Vec<_>>();
502 let include_source_package = changes.iter().any(|change| change.source_package.is_some());
503 let include_identity = changes.iter().any(|change| change.identity.is_some());
504 append_finding_change_table_header(out, include_source_package, include_identity);
505 for change in changes.iter().take(DIFF_MARKDOWN_CHANGE_LIMIT) {
506 append_finding_change_markdown_row(out, change, include_source_package, include_identity);
507 }
508 if changes.len() > DIFF_MARKDOWN_CHANGE_LIMIT {
509 out.push_str(&format!(
510 "\n{} additional finding posture changes omitted.\n",
511 changes.len() - DIFF_MARKDOWN_CHANGE_LIMIT
512 ));
513 }
514 out.push('\n');
515}
516
517fn append_finding_change_table_header(
518 out: &mut String,
519 include_source_package: bool,
520 include_identity: bool,
521) {
522 out.push_str("| Change | Kind | Family | Path |");
523 if include_source_package {
524 out.push_str(" Source Package |");
525 }
526 if include_identity {
527 out.push_str(" Identity |");
528 }
529 out.push_str("\n|---|---|---|---|");
530 if include_source_package {
531 out.push_str("---|");
532 }
533 if include_identity {
534 out.push_str("---|");
535 }
536 out.push('\n');
537}
538
539fn append_finding_change_markdown_row(
540 out: &mut String,
541 change: &DiffFindingChange<'_>,
542 include_source_package: bool,
543 include_identity: bool,
544) {
545 out.push_str(&format!(
546 "| `{}` | `{}` | `{}` | `{}` |",
547 markdown_cell(change.change),
548 markdown_cell(change.kind),
549 markdown_cell(change.family.unwrap_or("")),
550 markdown_cell(&finding_location(change))
551 ));
552 if include_source_package {
553 out.push_str(&format!(
554 " `{}` |",
555 markdown_cell(change.source_package.unwrap_or(""))
556 ));
557 }
558 if include_identity {
559 out.push_str(&format!(
560 " `{}` |",
561 markdown_cell(&finding_identity_summary(change))
562 ));
563 }
564 out.push('\n');
565}
566
567fn finding_location(change: &DiffFindingChange<'_>) -> String {
568 match (change.line, change.column) {
569 (Some(line), Some(column)) => format!("{}:{line}:{column}", change.path),
570 (Some(line), None) => format!("{}:{line}", change.path),
571 (None, Some(column)) => format!("{} column={column}", change.path),
572 (None, None) => change.path.to_string(),
573 }
574}
575
576fn finding_identity_summary(change: &DiffFindingChange<'_>) -> String {
577 change
578 .identity
579 .map(structural_identity_summary)
580 .unwrap_or_default()
581}
582
583fn finding_changes_have_source_package(
584 changes: &[DiffFindingChange<'_>],
585 change_kind: &str,
586) -> bool {
587 changes
588 .iter()
589 .any(|change| change.change == change_kind && change.source_package.is_some())
590}
591
592fn finding_changes_have_identity(changes: &[DiffFindingChange<'_>], change_kind: &str) -> bool {
593 changes
594 .iter()
595 .any(|change| change.change == change_kind && change.identity.is_some())
596}
597
598pub fn render_diff_policy_changes_markdown(changes: &[DiffPolicyChange<'_>]) -> String {
599 let mut out = String::new();
600 out.push_str("\n## Policy Posture Changes\n\n");
601 if changes.is_empty() {
602 out.push_str("No policy weakening detected.\n");
603 return out;
604 }
605 append_policy_changes_markdown_section(&mut out, "Policy Failures", changes, "fail");
606 append_policy_changes_markdown_section(&mut out, "Policy Review Required", changes, "review");
607 append_policy_changes_markdown_section(&mut out, "Policy Improvements", changes, "improvement");
608 let known_severities = ["fail", "review", "improvement"];
609 if changes
610 .iter()
611 .any(|change| !known_severities.contains(&change.severity))
612 {
613 out.push_str("### Other Policy Changes\n\n");
614 append_policy_changes_markdown_table(
615 &mut out,
616 changes
617 .iter()
618 .filter(|change| !known_severities.contains(&change.severity)),
619 );
620 }
621 out
622}
623
624fn append_policy_changes_markdown_section<'a>(
625 out: &mut String,
626 heading: &str,
627 changes: &'a [DiffPolicyChange<'a>],
628 severity: &str,
629) {
630 if !changes.iter().any(|change| change.severity == severity) {
631 return;
632 }
633 out.push_str(&format!("### {heading}\n\n"));
634 append_policy_changes_markdown_table(
635 out,
636 changes.iter().filter(|change| change.severity == severity),
637 );
638}
639
640fn append_policy_changes_markdown_table<'a>(
641 out: &mut String,
642 changes: impl Iterator<Item = &'a DiffPolicyChange<'a>>,
643) {
644 let changes = changes.collect::<Vec<_>>();
645 out.push_str("| Severity | Allow ID | Kind | Detail | Message |\n|---|---|---|---|---|\n");
646 for change in changes.iter().take(DIFF_MARKDOWN_CHANGE_LIMIT) {
647 let detail = policy_change_detail(change).unwrap_or_else(|| "none".to_string());
648 out.push_str(&format!(
649 "| `{}` | `{}` | `{}` | {} | {} |\n",
650 markdown_cell(change.severity),
651 markdown_cell(change.allow_id),
652 markdown_cell(change.kind),
653 markdown_cell(&detail),
654 markdown_cell(change.message)
655 ));
656 }
657 if changes.len() > DIFF_MARKDOWN_CHANGE_LIMIT {
658 out.push_str(&format!(
659 "\n{} additional policy posture changes omitted.\n",
660 changes.len() - DIFF_MARKDOWN_CHANGE_LIMIT
661 ));
662 }
663 out.push('\n');
664}