Skip to main content

allow_report/
report_json.rs

1use crate::audit_remediation::audit_remediation_items;
2use crate::contracts::REPORT_ARTIFACT;
3use crate::evidence_repair::{evidence_repair_queues, push_evidence_repair_queue_json_fields};
4use crate::json::{
5    option_json, push_json_artifact_header, push_json_artifact_source_context,
6    push_json_status_fields, render_match_outcome_json_compact,
7};
8use crate::{
9    DiffReport, REPORT_COMMAND_DIFF, REPORT_COMMANDS, ReportContext, ReviewSignals, Summary,
10    render_count_fields_with_policy_context,
11};
12use allow_core::{Finding, MatchOutcome, MatchStatus, json_escape, normalize_path};
13
14pub fn render_json(
15    command: &str,
16    findings: &[Finding],
17    outcomes: &[MatchOutcome],
18    failed: bool,
19) -> String {
20    render_json_with_context(
21        command,
22        findings,
23        outcomes,
24        failed,
25        ReportContext::default(),
26    )
27}
28
29pub fn render_json_with_context(
30    command: &str,
31    findings: &[Finding],
32    outcomes: &[MatchOutcome],
33    failed: bool,
34    context: ReportContext<'_>,
35) -> String {
36    render_json_report(command, findings, outcomes, failed, context, None)
37}
38
39pub fn render_json_with_context_and_diff(
40    command: &str,
41    findings: &[Finding],
42    outcomes: &[MatchOutcome],
43    failed: bool,
44    context: ReportContext<'_>,
45    diff: DiffReport<'_>,
46) -> String {
47    assert_eq!(
48        command, REPORT_COMMAND_DIFF,
49        "diff report artifacts support only diff command"
50    );
51    render_json_report(command, findings, outcomes, failed, context, Some(diff))
52}
53
54fn render_json_report(
55    command: &str,
56    findings: &[Finding],
57    outcomes: &[MatchOutcome],
58    failed: bool,
59    context: ReportContext<'_>,
60    diff: Option<DiffReport<'_>>,
61) -> String {
62    assert!(
63        REPORT_COMMANDS.contains(&command),
64        "report artifacts support only audit, check, or diff commands"
65    );
66    let summary = Summary::from_outcomes(outcomes);
67    let mut out = String::new();
68    out.push_str("{\n");
69    push_json_artifact_header(&mut out, REPORT_ARTIFACT, command);
70    push_json_status_fields(&mut out, failed);
71    push_json_artifact_source_context(&mut out, context.into());
72    out.push_str("  \"summary\": {\n");
73    out.push_str(&format!("    \"findings\": {},\n", findings.len()));
74    out.push_str(&format!("    \"outcomes\": {},\n", summary.total));
75    out.push_str(&render_count_fields_with_policy_context(
76        &summary,
77        context.baseline_debt_entries,
78        context.policy_missing_evidence_entries,
79        context.broken_evidence_links,
80        context.weak_evidence_references,
81        "    ",
82    ));
83    out.push_str("  },\n");
84    out.push_str("  \"trend\": {\n");
85    out.push_str(&render_trend_fields(&summary, context, "    "));
86    out.push_str("  },\n");
87    append_audit_remediation_roadmap_json(command, &summary, context, &mut out);
88    append_evidence_repair_queues_json(&summary, context, &mut out);
89    if let Some(source_inventory) = crate::render_source_inventory_json(findings, outcomes, "  ") {
90        out.push_str("  \"source_inventory\": ");
91        out.push_str(&source_inventory);
92        out.push_str(",\n");
93    }
94    out.push_str("  \"outcomes\": [\n");
95    for (i, outcome) in outcomes.iter().enumerate() {
96        if i > 0 {
97            out.push_str(",\n");
98        }
99        out.push_str("    ");
100        out.push_str(&render_match_outcome_json_compact(outcome));
101    }
102    out.push_str("\n  ],\n");
103    out.push_str("  \"findings\": [\n");
104    for (i, finding) in findings.iter().enumerate() {
105        if i > 0 {
106            out.push_str(",\n");
107        }
108        out.push_str("    {");
109        out.push_str(&format!("\"kind\": \"{}\", ", finding.kind.as_str()));
110        out.push_str(&format!(
111            "\"family\": {}, ",
112            option_json(finding.family.as_deref())
113        ));
114        out.push_str(&format!(
115            "\"path\": \"{}\", ",
116            json_escape(&normalize_path(&finding.path))
117        ));
118        out.push_str(&format!(
119            "\"line\": {}, ",
120            finding
121                .span
122                .as_ref()
123                .map(|s| s.line.to_string())
124                .unwrap_or_else(|| "null".to_string())
125        ));
126        out.push_str(&format!(
127            "\"container\": {}, ",
128            option_json(finding.identity.container.as_deref())
129        ));
130        out.push_str(&format!(
131            "\"source_package\": {}, ",
132            option_json(finding.identity.crate_name.as_deref())
133        ));
134        out.push_str(&format!(
135            "\"ast_kind\": \"{}\"",
136            json_escape(&finding.identity.ast_kind)
137        ));
138        out.push('}');
139    }
140    match diff {
141        Some(diff) => {
142            out.push_str("\n  ],\n");
143            out.push_str("  \"diff\": ");
144            out.push_str(
145                &crate::diff_json::render_diff_posture_json_with_evidence_health(
146                    diff,
147                    context.broken_evidence_links.unwrap_or(0),
148                    context
149                        .policy_missing_evidence_entries
150                        .unwrap_or(0)
151                        .max(summary.count(MatchStatus::EvidenceMissing)),
152                    context.weak_evidence_references.unwrap_or(0),
153                ),
154            );
155            out.push_str("\n}\n");
156        }
157        None => out.push_str("\n  ]\n}"),
158    }
159    out
160}
161
162fn append_evidence_repair_queues_json(
163    summary: &Summary,
164    context: ReportContext<'_>,
165    out: &mut String,
166) {
167    let queues = evidence_repair_queues(summary, ReviewSignals::from_summary(summary, context));
168    if queues.is_empty() {
169        return;
170    }
171
172    out.push_str("  \"evidence_repair_queues\": [\n");
173    for (index, queue) in queues.iter().enumerate() {
174        if index > 0 {
175            out.push_str(",\n");
176        }
177        out.push_str("    {\n");
178        push_evidence_repair_queue_json_fields(out, queue, "      ");
179        out.push_str("    }");
180    }
181    out.push_str("\n  ],\n");
182}
183
184fn append_audit_remediation_roadmap_json(
185    command: &str,
186    summary: &Summary,
187    context: ReportContext<'_>,
188    out: &mut String,
189) {
190    if command != "audit" {
191        return;
192    }
193    let roadmap = audit_remediation_items(summary, ReviewSignals::from_summary(summary, context));
194    if roadmap.is_empty() {
195        return;
196    }
197
198    out.push_str("  \"audit_remediation_roadmap\": [\n");
199    for (index, item) in roadmap.iter().enumerate() {
200        if index > 0 {
201            out.push_str(",\n");
202        }
203        out.push_str("    {\n");
204        out.push_str(&format!(
205            "      \"signal\": \"{}\",\n",
206            json_escape(item.signal)
207        ));
208        out.push_str(&format!(
209            "      \"label\": \"{}\",\n",
210            json_escape(item.label)
211        ));
212        out.push_str(&format!(
213            "      \"route_kind\": \"{}\",\n",
214            json_escape(item.route.route_kind)
215        ));
216        if let Some(item_kind) = item.route.item_kind {
217            out.push_str(&format!(
218                "      \"item_kind\": \"{}\",\n",
219                json_escape(item_kind)
220            ));
221        }
222        if let Some(worklist_status) = item.route.worklist_status {
223            out.push_str(&format!(
224                "      \"worklist_status\": \"{}\",\n",
225                json_escape(worklist_status)
226            ));
227        }
228        if let Some(worklist_filter) = item.route.worklist_filter {
229            out.push_str(&format!(
230                "      \"worklist_filter\": \"{}\",\n",
231                json_escape(worklist_filter)
232            ));
233        }
234        out.push_str(&format!("      \"count\": {},\n", item.count));
235        out.push_str(&format!(
236            "      \"command\": \"{}\"\n",
237            json_escape(item.command)
238        ));
239        out.push_str("    }");
240    }
241    out.push_str("\n  ],\n");
242}
243
244fn render_trend_fields(summary: &Summary, context: ReportContext<'_>, indent: &str) -> String {
245    let signals = ReviewSignals::from_summary(summary, context);
246    let mut fields = vec![
247        ("review_items", signals.review_items),
248        ("new", summary.count(MatchStatus::New)),
249        ("expired", summary.count(MatchStatus::Expired)),
250        ("review_due", summary.count(MatchStatus::ReviewDue)),
251        ("stale", summary.count(MatchStatus::Stale)),
252        ("ambiguous", summary.count(MatchStatus::Ambiguous)),
253        (
254            "invalid_selector",
255            summary.count(MatchStatus::InvalidSelector),
256        ),
257        (
258            "missing_required_field",
259            summary.count(MatchStatus::MissingRequiredField),
260        ),
261        (
262            "evidence_missing",
263            summary.count(MatchStatus::EvidenceMissing),
264        ),
265        ("baseline_debt", signals.baseline_debt),
266    ];
267    if signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing) {
268        fields.push(("policy_missing_evidence", signals.policy_missing_evidence));
269    }
270    if signals.broken_evidence_links > 0 {
271        fields.push(("broken_evidence_links", signals.broken_evidence_links));
272    }
273    if signals.weak_evidence_references > 0 {
274        fields.push(("weak_evidence_references", signals.weak_evidence_references));
275    }
276    fields
277        .iter()
278        .enumerate()
279        .map(|(idx, (name, value))| {
280            let comma = if idx + 1 == fields.len() { "" } else { "," };
281            format!("{indent}\"{name}\": {value}{comma}\n")
282        })
283        .collect()
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn audit_remediation_json_returns_for_non_audit_and_clean_audit() {
292        let summary = Summary::from_outcomes(&[test_outcome(MatchStatus::New)]);
293        let mut non_audit = String::new();
294
295        append_audit_remediation_roadmap_json(
296            "check",
297            &summary,
298            ReportContext::default(),
299            &mut non_audit,
300        );
301
302        assert_eq!(non_audit, "");
303
304        let clean_summary = Summary::default();
305        let mut clean_audit = String::new();
306        append_audit_remediation_roadmap_json(
307            "audit",
308            &clean_summary,
309            ReportContext::default(),
310            &mut clean_audit,
311        );
312
313        assert_eq!(clean_audit, "");
314    }
315
316    #[test]
317    fn audit_remediation_json_writes_multiple_route_shapes() {
318        let outcomes = [
319            test_outcome(MatchStatus::New),
320            test_outcome(MatchStatus::Stale),
321            test_outcome(MatchStatus::EvidenceMissing),
322        ];
323        let summary = Summary::from_outcomes(&outcomes);
324        let mut context = ReportContext::source_syntax("git_tracked", None, None, Some(2));
325        context.policy_missing_evidence_entries = Some(4);
326        context.broken_evidence_links = Some(1);
327        context.weak_evidence_references = Some(3);
328        let mut out = String::new();
329
330        append_audit_remediation_roadmap_json("audit", &summary, context, &mut out);
331
332        assert!(out.starts_with("  \"audit_remediation_roadmap\": [\n"));
333        assert!(out.ends_with("\n  ],\n"));
334        assert!(out.contains("    },\n    {\n"));
335        assert!(out.contains("\"signal\": \"new_unreceipted\""));
336        assert!(out.contains("\"route_kind\": \"worklist_status\""));
337        assert!(out.contains("\"item_kind\": \"new_unreceipted_finding\""));
338        assert!(out.contains("\"worklist_status\": \"new\""));
339        assert!(out.contains("\"signal\": \"stale\""));
340        assert!(out.contains("\"route_kind\": \"prune_stale\""));
341        assert!(out.contains("\"item_kind\": \"stale_allow\""));
342        assert!(out.contains("\"signal\": \"missing_evidence\""));
343        assert!(out.contains("\"route_kind\": \"worklist_filter\""));
344        assert!(out.contains("\"worklist_filter\": \"missing_evidence\""));
345        assert!(out.contains("\"count\": 4"));
346        assert!(out.contains("cargo-allow worklist --missing-evidence --format json"));
347        assert!(out.contains("\"signal\": \"broken_evidence_links\""));
348        assert!(out.contains("\"count\": 1"));
349        assert!(out.contains("\"signal\": \"weak_evidence_references\""));
350        assert!(out.contains("\"count\": 3"));
351        assert!(out.contains("\"signal\": \"baseline_debt\""));
352        assert!(out.contains("\"count\": 2"));
353    }
354
355    #[test]
356    fn trend_fields_include_optional_evidence_signals_when_nonzero() {
357        let outcomes = [
358            test_outcome(MatchStatus::Matched),
359            test_outcome(MatchStatus::New),
360            test_outcome(MatchStatus::Expired),
361            test_outcome(MatchStatus::ReviewDue),
362            test_outcome(MatchStatus::Stale),
363            test_outcome(MatchStatus::Ambiguous),
364            test_outcome(MatchStatus::InvalidSelector),
365            test_outcome(MatchStatus::MissingRequiredField),
366            test_outcome(MatchStatus::EvidenceMissing),
367            test_outcome(MatchStatus::BaselineDebt),
368        ];
369        let summary = Summary::from_outcomes(&outcomes);
370        let mut context = ReportContext::source_syntax("git_tracked", None, None, Some(5));
371        context.policy_missing_evidence_entries = Some(4);
372        context.broken_evidence_links = Some(2);
373        context.weak_evidence_references = Some(3);
374
375        let fields = render_trend_fields(&summary, context, "    ");
376
377        for expected in [
378            "\"review_items\": 21,",
379            "\"new\": 1,",
380            "\"expired\": 1,",
381            "\"review_due\": 1,",
382            "\"stale\": 1,",
383            "\"ambiguous\": 1,",
384            "\"invalid_selector\": 1,",
385            "\"missing_required_field\": 1,",
386            "\"evidence_missing\": 1,",
387            "\"baseline_debt\": 5,",
388            "\"policy_missing_evidence\": 4,",
389            "\"broken_evidence_links\": 2,",
390            "\"weak_evidence_references\": 3",
391        ] {
392            assert!(fields.contains(expected), "{expected}\n{fields}");
393        }
394        assert!(!fields.contains("\"weak_evidence_references\": 3,"));
395    }
396
397    fn test_outcome(status: MatchStatus) -> MatchOutcome {
398        MatchOutcome {
399            status,
400            allow_id: None,
401            finding_index: None,
402            message: status.as_str().to_string(),
403            score: 100,
404        }
405    }
406}