Skip to main content

sentinel_core/report/
json.rs

1//! JSON report sink: serializes the report as structured JSON to stdout.
2
3use crate::report::{Report, ReportSink};
4
5/// Outputs reports as JSON to stdout.
6pub struct JsonReportSink;
7
8impl ReportSink for JsonReportSink {
9    type Error = JsonReportError;
10
11    fn emit(&self, report: &Report) -> Result<(), Self::Error> {
12        use std::io::Write as _;
13        let stdout = std::io::stdout();
14        let mut lock = stdout.lock();
15        serde_json::to_writer_pretty(&mut lock, report)
16            .map_err(|e| JsonReportError(e.to_string()))?;
17        lock.write_all(b"\n")
18            .map_err(|e| JsonReportError(e.to_string()))?;
19        Ok(())
20    }
21}
22
23/// Errors that can occur during JSON report output.
24#[derive(Debug, thiserror::Error)]
25#[error("JSON report error: {0}")]
26pub struct JsonReportError(String);
27
28#[cfg(test)]
29mod tests {
30    use super::*;
31    use crate::report::{Analysis, QualityGate, Report};
32    use crate::test_helpers::empty_report;
33
34    #[test]
35    fn emit_empty_report() {
36        let sink = JsonReportSink;
37        let report = empty_report();
38        assert!(sink.emit(&report).is_ok());
39    }
40
41    #[test]
42    fn error_display() {
43        let err = JsonReportError("test".to_string());
44        assert_eq!(format!("{err}"), "JSON report error: test");
45    }
46
47    #[test]
48    fn emit_report_with_findings() {
49        use crate::detect::{Finding, FindingType, Pattern, Severity};
50
51        let report = Report {
52            analysis: Analysis {
53                duration_ms: 42,
54                events_processed: 10,
55                traces_analyzed: 1,
56            },
57            findings: vec![Finding {
58                finding_type: FindingType::NPlusOneSql,
59                severity: Severity::Warning,
60                trace_id: "trace-1".to_string(),
61                service: "order-svc".to_string(),
62                source_endpoint: "POST /api/orders/42/submit".to_string(),
63                pattern: Pattern {
64                    template: "SELECT * FROM order_item WHERE order_id = ?".to_string(),
65                    occurrences: 6,
66                    window_ms: 250,
67                    distinct_params: 6,
68                    ..Default::default()
69                },
70                suggestion: "Use WHERE ... IN (?) to batch 6 queries into one".to_string(),
71                first_timestamp: "2025-07-10T14:32:01.000Z".to_string(),
72                last_timestamp: "2025-07-10T14:32:01.250Z".to_string(),
73                green_impact: None,
74                confidence: crate::detect::Confidence::default(),
75                classification_method: None,
76                code_location: None,
77                instrumentation_scopes: Vec::new(),
78                suggested_fix: None,
79                signature: String::new(),
80            }],
81            green_summary: crate::test_helpers::make_test_green_summary(10, 5, 0.5),
82            quality_gate: QualityGate {
83                passed: true,
84                rules: vec![],
85            },
86            per_endpoint_io_ops: vec![],
87            correlations: vec![],
88            warnings: vec![],
89            warning_details: vec![],
90            acknowledged_findings: vec![],
91            binary_version: String::new(),
92            disclosure_waste: None,
93        };
94
95        let json = serde_json::to_string_pretty(&report).unwrap();
96        assert!(json.contains("n_plus_one_sql"));
97        assert!(json.contains("trace-1"));
98        assert!(json.contains("order_id"));
99        assert!(json.contains("\"occurrences\": 6"));
100        assert!(json.contains("\"io_waste_ratio\": 0.5"));
101        assert!(json.contains("\"first_timestamp\""));
102        assert!(json.contains("\"last_timestamp\""));
103
104        // Interpretation band fields are part of the stable JSON schema
105        // (see `crates/sentinel-core/src/report/interpret.rs` stability
106        // contract). Asserting their presence here guards against an
107        // accidental `#[serde(skip)]` or a rename that would silently
108        // break downstream consumers (SARIF, Grafana, perf-lint).
109        //
110        // With `io_waste_ratio = 0.5`, the band MUST be "critical"
111        // (>= WASTE_RATIO_CRITICAL = 0.50).
112        assert!(
113            json.contains("\"io_waste_ratio_band\": \"critical\""),
114            "io_waste_ratio_band missing or not `critical` for 0.5 waste: {json}"
115        );
116    }
117}