Skip to main content

ailint_core/reporter/
json.rs

1//! JSON output for machine consumption.
2
3use std::collections::BTreeMap;
4use std::io::Write;
5
6use anyhow::Result;
7use chrono::{DateTime, SecondsFormat, Utc};
8use serde::Serialize;
9
10use crate::reporter::Reporter;
11use crate::rules::registry::rule_meta;
12use crate::rules::{Severity, Violation};
13use crate::VERSION;
14
15const SCHEMA_VERSION: &str = "2";
16
17/// JSON reporter emitting a versioned schema for machine consumers.
18#[derive(Debug)]
19pub struct JsonReporter {
20    now: fn() -> DateTime<Utc>,
21}
22
23impl Default for JsonReporter {
24    fn default() -> Self {
25        Self { now: Utc::now }
26    }
27}
28
29impl JsonReporter {
30    /// Override the clock — used by snapshot tests to keep output stable.
31    pub fn with_now(now: fn() -> DateTime<Utc>) -> Self {
32        Self { now }
33    }
34}
35
36impl Reporter for JsonReporter {
37    fn report(&self, violations: &[Violation], out: &mut dyn Write) -> Result<()> {
38        let report = build_report(violations, (self.now)());
39        serde_json::to_writer_pretty(&mut *out, &report)?;
40        writeln!(out)?;
41        Ok(())
42    }
43}
44
45#[derive(Serialize)]
46struct Report<'a> {
47    schema_version: &'static str,
48    tool: Tool,
49    generated_at: String,
50    summary: Summary,
51    files: Vec<FileEntry>,
52    violations: Vec<ViolationEntry<'a>>,
53}
54
55#[derive(Serialize)]
56struct Tool {
57    name: &'static str,
58    version: &'static str,
59}
60
61#[derive(Serialize)]
62struct Summary {
63    total: usize,
64    errors: usize,
65    warnings: usize,
66    info: usize,
67    file_count: usize,
68}
69
70#[derive(Serialize)]
71struct FileEntry {
72    path: String,
73    violation_count: usize,
74}
75
76#[derive(Serialize)]
77struct ViolationEntry<'a> {
78    rule: RuleEntry,
79    severity: Severity,
80    message: &'a str,
81    detail: Option<&'a str>,
82    file: String,
83    line: Option<usize>,
84    column: Option<usize>,
85    fix_hint: Option<&'a str>,
86    snippet: Option<&'a str>,
87    source_url: Option<&'a str>,
88}
89
90#[derive(Serialize)]
91struct RuleEntry {
92    code: String,
93    slug: &'static str,
94    description: &'static str,
95    fix_hint: &'static str,
96}
97
98fn build_report(violations: &[Violation], now: DateTime<Utc>) -> Report<'_> {
99    let mut errors = 0;
100    let mut warnings = 0;
101    let mut info = 0;
102    let mut per_file: BTreeMap<String, usize> = BTreeMap::new();
103
104    let mut entries = Vec::with_capacity(violations.len());
105    for v in violations {
106        match v.severity {
107            Severity::Error => errors += 1,
108            Severity::Warning => warnings += 1,
109            Severity::Info => info += 1,
110        }
111        let file = v.file.to_string_lossy().into_owned();
112        *per_file.entry(file.clone()).or_insert(0) += 1;
113        let meta = rule_meta(v.rule_id);
114        entries.push(ViolationEntry {
115            rule: RuleEntry {
116                code: v.rule_id.code_str(),
117                slug: v.rule_id.slug,
118                description: meta.map(|m| m.description).unwrap_or(""),
119                fix_hint: meta.map(|m| m.fix_hint).unwrap_or(""),
120            },
121            severity: v.severity,
122            message: &v.message,
123            detail: v.detail.as_deref(),
124            file,
125            line: v.line,
126            column: v.column,
127            fix_hint: v.fix_hint.as_deref(),
128            snippet: v.snippet.as_deref(),
129            source_url: v.source_url.as_deref(),
130        });
131    }
132
133    let files: Vec<FileEntry> = per_file
134        .into_iter()
135        .map(|(path, violation_count)| FileEntry {
136            path,
137            violation_count,
138        })
139        .collect();
140
141    Report {
142        schema_version: SCHEMA_VERSION,
143        tool: Tool {
144            name: "ailint",
145            version: VERSION,
146        },
147        generated_at: now.to_rfc3339_opts(SecondsFormat::Secs, true),
148        summary: Summary {
149            total: violations.len(),
150            errors,
151            warnings,
152            info,
153            file_count: files.len(),
154        },
155        files,
156        violations: entries,
157    }
158}