Skip to main content

allow_report/
diff_markdown.rs

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}