Skip to main content

dry4rust/output/
json.rs

1// Copyright (c) 2026 Matjaz Domen Pecan
2// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
3// Licensed under the MIT License
4// SPDX-License-Identifier: MIT
5
6use std::io;
7
8use crate::grouper::{DuplicateGroup, DuplicationStats};
9use crate::output::check_breach::CheckBreach;
10use crate::output::report::Report;
11use crate::output::reporter::Reporter;
12use crate::output::reporter::display_path;
13use serde_json::to_string_pretty;
14use std::path::PathBuf;
15
16pub struct JsonReporter {
17    pub base_path: Option<PathBuf>,
18}
19
20impl JsonReporter {
21    #[must_use]
22    pub const fn new(base_path: Option<PathBuf>) -> Self {
23        Self { base_path }
24    }
25}
26
27#[derive(serde::Serialize)]
28struct JsonStats {
29    total_code_units: usize,
30    total_lines: usize,
31    exact_duplicate_groups: usize,
32    exact_duplicate_units: usize,
33    near_duplicate_groups: usize,
34    near_duplicate_units: usize,
35    exact_duplicate_lines: usize,
36    near_duplicate_lines: usize,
37    exact_duplicate_percent: f64,
38    near_duplicate_percent: f64,
39    #[serde(skip_serializing_if = "is_zero")]
40    sub_exact_groups: usize,
41    #[serde(skip_serializing_if = "is_zero")]
42    sub_exact_units: usize,
43    #[serde(skip_serializing_if = "is_zero")]
44    sub_near_groups: usize,
45    #[serde(skip_serializing_if = "is_zero")]
46    sub_near_units: usize,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    baseline_suppressed: Option<usize>,
49}
50
51#[allow(clippy::trivially_copy_pass_by_ref)] // serde skip_serializing_if requires &T
52const fn is_zero(v: &usize) -> bool {
53    *v == 0
54}
55
56/// A full report as one document, so a reader can parse it in one call and
57/// reach a section by name.
58#[derive(serde::Serialize)]
59struct JsonReport {
60    stats: JsonStats,
61    exact: Vec<JsonGroup>,
62    near: Vec<JsonGroup>,
63    #[serde(skip_serializing_if = "Vec::is_empty")]
64    sub_exact: Vec<JsonGroup>,
65    #[serde(skip_serializing_if = "Vec::is_empty")]
66    sub_near: Vec<JsonGroup>,
67}
68
69/// A check verdict as one document. The breach sentences are inside it rather
70/// than printed between the sections, which is what stopped the old output
71/// being JSON at all.
72#[derive(serde::Serialize)]
73struct JsonCheck {
74    stats: JsonStats,
75    passed: bool,
76    breaches: Vec<String>,
77    #[serde(skip_serializing_if = "Vec::is_empty")]
78    exact: Vec<JsonGroup>,
79    #[serde(skip_serializing_if = "Vec::is_empty")]
80    near: Vec<JsonGroup>,
81}
82
83#[derive(serde::Serialize)]
84struct JsonGroup {
85    fingerprint: String,
86    similarity: f64,
87    members: Vec<JsonMember>,
88}
89
90#[derive(serde::Serialize)]
91struct JsonMember {
92    name: String,
93    kind: String,
94    file: String,
95    line_start: usize,
96    line_end: usize,
97}
98
99impl Reporter for JsonReporter {
100    fn report(&self, report: &Report<'_>, writer: &mut dyn io::Write) -> io::Result<()> {
101        let document = JsonReport {
102            stats: Self::to_json_stats(report.stats),
103            exact: self.to_json_groups(report.exact),
104            near: self.to_json_groups(report.near),
105            sub_exact: self.to_json_groups(report.sub_exact),
106            sub_near: self.to_json_groups(report.sub_near),
107        };
108        Self::write_document(&document, writer)
109    }
110
111    fn report_check(
112        &self,
113        stats: &DuplicationStats,
114        breaches: &[CheckBreach<'_>],
115        writer: &mut dyn io::Write,
116    ) -> io::Result<()> {
117        let groups_of = |exact: bool| {
118            breaches
119                .iter()
120                .find(|breach| breach.is_of_exact() == exact)
121                .map_or_else(Vec::new, |breach| self.to_json_groups(breach.groups()))
122        };
123        let document = JsonCheck {
124            stats: Self::to_json_stats(stats),
125            passed: breaches.is_empty(),
126            breaches: breaches
127                .iter()
128                .map(|breach| breach.message().to_owned())
129                .collect(),
130            exact: groups_of(true),
131            near: groups_of(false),
132        };
133        Self::write_document(&document, writer)
134    }
135
136    fn report_stats(&self, stats: &DuplicationStats, writer: &mut dyn io::Write) -> io::Result<()> {
137        Self::write_document(&Self::to_json_stats(stats), writer)
138    }
139
140    fn report_exact(
141        &self,
142        groups: &[DuplicateGroup],
143        writer: &mut dyn io::Write,
144    ) -> io::Result<()> {
145        self.write_groups(groups, writer)
146    }
147
148    fn report_near(&self, groups: &[DuplicateGroup], writer: &mut dyn io::Write) -> io::Result<()> {
149        self.write_groups(groups, writer)
150    }
151
152    fn report_sub_exact(
153        &self,
154        groups: &[DuplicateGroup],
155        writer: &mut dyn io::Write,
156    ) -> io::Result<()> {
157        self.write_groups(groups, writer)
158    }
159
160    fn report_sub_near(
161        &self,
162        groups: &[DuplicateGroup],
163        writer: &mut dyn io::Write,
164    ) -> io::Result<()> {
165        self.write_groups(groups, writer)
166    }
167}
168
169impl JsonReporter {
170    fn write_document<T: serde::Serialize>(
171        document: &T,
172        writer: &mut dyn io::Write,
173    ) -> io::Result<()> {
174        let json = to_string_pretty(document).map_err(io::Error::other)?;
175        writeln!(writer, "{json}")
176    }
177
178    fn write_groups(
179        &self,
180        groups: &[DuplicateGroup],
181        writer: &mut dyn io::Write,
182    ) -> io::Result<()> {
183        Self::write_document(&self.to_json_groups(groups), writer)
184    }
185
186    fn to_json_groups(&self, groups: &[DuplicateGroup]) -> Vec<JsonGroup> {
187        groups.iter().map(|g| self.to_json_group(g)).collect()
188    }
189
190    fn to_json_stats(stats: &DuplicationStats) -> JsonStats {
191        JsonStats {
192            total_code_units: stats.total_code_units,
193            total_lines: stats.total_lines,
194            exact_duplicate_groups: stats.exact_duplicate_groups,
195            exact_duplicate_units: stats.exact_duplicate_units,
196            near_duplicate_groups: stats.near_duplicate_groups,
197            near_duplicate_units: stats.near_duplicate_units,
198            exact_duplicate_lines: stats.exact_duplicate_lines,
199            near_duplicate_lines: stats.near_duplicate_lines,
200            exact_duplicate_percent: stats.exact_duplicate_percent(),
201            near_duplicate_percent: stats.near_duplicate_percent(),
202            sub_exact_groups: stats.sub_exact_groups,
203            sub_exact_units: stats.sub_exact_units,
204            sub_near_groups: stats.sub_near_groups,
205            sub_near_units: stats.sub_near_units,
206            baseline_suppressed: stats.baseline_suppressed,
207        }
208    }
209
210    fn to_json_group(&self, group: &DuplicateGroup) -> JsonGroup {
211        JsonGroup {
212            fingerprint: group.fingerprint.to_hex(),
213            similarity: group.similarity,
214            members: group
215                .members
216                .iter()
217                .map(|m| JsonMember {
218                    name: m.name.clone(),
219                    kind: m.kind.to_string(),
220                    file: display_path(self.base_path.as_deref(), &m.file).into_owned(),
221                    line_start: m.line_start,
222                    line_end: m.line_end,
223                })
224                .collect(),
225        }
226    }
227}