Skip to main content

allow_report/
report_json.rs

1use crate::contracts::REPORT_ARTIFACT;
2use crate::json::{
3    option_json, push_json_artifact_header, push_json_artifact_source_context,
4    push_json_status_fields, render_match_outcome_json_compact,
5};
6use crate::{
7    DiffReport, REPORT_COMMAND_DIFF, REPORT_COMMANDS, ReportContext, ReviewSignals, Summary,
8    render_count_fields_with_policy_context,
9};
10use allow_core::{Finding, MatchOutcome, MatchStatus, json_escape, normalize_path};
11
12pub fn render_json(
13    command: &str,
14    findings: &[Finding],
15    outcomes: &[MatchOutcome],
16    failed: bool,
17) -> String {
18    render_json_with_context(
19        command,
20        findings,
21        outcomes,
22        failed,
23        ReportContext::default(),
24    )
25}
26
27pub fn render_json_with_context(
28    command: &str,
29    findings: &[Finding],
30    outcomes: &[MatchOutcome],
31    failed: bool,
32    context: ReportContext<'_>,
33) -> String {
34    render_json_report(command, findings, outcomes, failed, context, None)
35}
36
37pub fn render_json_with_context_and_diff(
38    command: &str,
39    findings: &[Finding],
40    outcomes: &[MatchOutcome],
41    failed: bool,
42    context: ReportContext<'_>,
43    diff: DiffReport<'_>,
44) -> String {
45    assert_eq!(
46        command, REPORT_COMMAND_DIFF,
47        "diff report artifacts support only diff command"
48    );
49    render_json_report(command, findings, outcomes, failed, context, Some(diff))
50}
51
52fn render_json_report(
53    command: &str,
54    findings: &[Finding],
55    outcomes: &[MatchOutcome],
56    failed: bool,
57    context: ReportContext<'_>,
58    diff: Option<DiffReport<'_>>,
59) -> String {
60    assert!(
61        REPORT_COMMANDS.contains(&command),
62        "report artifacts support only audit, check, or diff commands"
63    );
64    let summary = Summary::from_outcomes(outcomes);
65    let mut out = String::new();
66    out.push_str("{\n");
67    push_json_artifact_header(&mut out, REPORT_ARTIFACT, command);
68    push_json_status_fields(&mut out, failed);
69    push_json_artifact_source_context(&mut out, context.into());
70    out.push_str("  \"summary\": {\n");
71    out.push_str(&format!("    \"findings\": {},\n", findings.len()));
72    out.push_str(&format!("    \"outcomes\": {},\n", summary.total));
73    out.push_str(&render_count_fields_with_policy_context(
74        &summary,
75        context.baseline_debt_entries,
76        context.policy_missing_evidence_entries,
77        context.broken_evidence_links,
78        context.weak_evidence_references,
79        "    ",
80    ));
81    out.push_str("  },\n");
82    out.push_str("  \"trend\": {\n");
83    out.push_str(&render_trend_fields(&summary, context, "    "));
84    out.push_str("  },\n");
85    if let Some(source_inventory) = crate::render_source_inventory_json(findings, outcomes, "  ") {
86        out.push_str("  \"source_inventory\": ");
87        out.push_str(&source_inventory);
88        out.push_str(",\n");
89    }
90    out.push_str("  \"outcomes\": [\n");
91    for (i, outcome) in outcomes.iter().enumerate() {
92        if i > 0 {
93            out.push_str(",\n");
94        }
95        out.push_str("    ");
96        out.push_str(&render_match_outcome_json_compact(outcome));
97    }
98    out.push_str("\n  ],\n");
99    out.push_str("  \"findings\": [\n");
100    for (i, finding) in findings.iter().enumerate() {
101        if i > 0 {
102            out.push_str(",\n");
103        }
104        out.push_str("    {");
105        out.push_str(&format!("\"kind\": \"{}\", ", finding.kind.as_str()));
106        out.push_str(&format!(
107            "\"family\": {}, ",
108            option_json(finding.family.as_deref())
109        ));
110        out.push_str(&format!(
111            "\"path\": \"{}\", ",
112            json_escape(&normalize_path(&finding.path))
113        ));
114        out.push_str(&format!(
115            "\"line\": {}, ",
116            finding
117                .span
118                .as_ref()
119                .map(|s| s.line.to_string())
120                .unwrap_or_else(|| "null".to_string())
121        ));
122        out.push_str(&format!(
123            "\"container\": {}, ",
124            option_json(finding.identity.container.as_deref())
125        ));
126        out.push_str(&format!(
127            "\"source_package\": {}, ",
128            option_json(finding.identity.crate_name.as_deref())
129        ));
130        out.push_str(&format!(
131            "\"ast_kind\": \"{}\"",
132            json_escape(&finding.identity.ast_kind)
133        ));
134        out.push('}');
135    }
136    match diff {
137        Some(diff) => {
138            out.push_str("\n  ],\n");
139            out.push_str("  \"diff\": ");
140            out.push_str(
141                &crate::diff_json::render_diff_posture_json_with_evidence_health(
142                    diff,
143                    context.broken_evidence_links.unwrap_or(0),
144                    context.weak_evidence_references.unwrap_or(0),
145                ),
146            );
147            out.push_str("\n}\n");
148        }
149        None => out.push_str("\n  ]\n}"),
150    }
151    out
152}
153
154fn render_trend_fields(summary: &Summary, context: ReportContext<'_>, indent: &str) -> String {
155    let signals = ReviewSignals::from_summary(summary, context);
156    let mut fields = vec![
157        ("review_items", signals.review_items),
158        ("new", summary.count(MatchStatus::New)),
159        ("expired", summary.count(MatchStatus::Expired)),
160        ("review_due", summary.count(MatchStatus::ReviewDue)),
161        ("stale", summary.count(MatchStatus::Stale)),
162        ("ambiguous", summary.count(MatchStatus::Ambiguous)),
163        (
164            "invalid_selector",
165            summary.count(MatchStatus::InvalidSelector),
166        ),
167        (
168            "missing_required_field",
169            summary.count(MatchStatus::MissingRequiredField),
170        ),
171        (
172            "evidence_missing",
173            summary.count(MatchStatus::EvidenceMissing),
174        ),
175        ("baseline_debt", signals.baseline_debt),
176    ];
177    if signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing) {
178        fields.push(("policy_missing_evidence", signals.policy_missing_evidence));
179    }
180    if signals.broken_evidence_links > 0 {
181        fields.push(("broken_evidence_links", signals.broken_evidence_links));
182    }
183    if signals.weak_evidence_references > 0 {
184        fields.push(("weak_evidence_references", signals.weak_evidence_references));
185    }
186    fields
187        .iter()
188        .enumerate()
189        .map(|(idx, (name, value))| {
190            let comma = if idx + 1 == fields.len() { "" } else { "," };
191            format!("{indent}\"{name}\": {value}{comma}\n")
192        })
193        .collect()
194}