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::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}