Skip to main content

allow_report/
diff_markdown.rs

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}