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<'_>) -> io::Result<String> {
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)
109    }
110
111    fn report_check(
112        &self,
113        stats: &DuplicationStats,
114        breaches: &[CheckBreach<'_>],
115    ) -> io::Result<String> {
116        let groups_of = |exact: bool| {
117            breaches
118                .iter()
119                .find(|breach| breach.is_of_exact() == exact)
120                .map_or_else(Vec::new, |breach| self.to_json_groups(breach.groups()))
121        };
122        let document = JsonCheck {
123            stats: Self::to_json_stats(stats),
124            passed: breaches.is_empty(),
125            breaches: breaches
126                .iter()
127                .map(|breach| breach.message().to_owned())
128                .collect(),
129            exact: groups_of(true),
130            near: groups_of(false),
131        };
132        Self::write_document(&document)
133    }
134
135    fn report_stats(&self, stats: &DuplicationStats) -> io::Result<String> {
136        Self::write_document(&Self::to_json_stats(stats))
137    }
138
139    fn report_exact(&self, groups: &[DuplicateGroup]) -> io::Result<String> {
140        self.write_groups(groups)
141    }
142
143    fn report_near(&self, groups: &[DuplicateGroup]) -> io::Result<String> {
144        self.write_groups(groups)
145    }
146
147    fn report_sub_exact(&self, groups: &[DuplicateGroup]) -> io::Result<String> {
148        self.write_groups(groups)
149    }
150
151    fn report_sub_near(&self, groups: &[DuplicateGroup]) -> io::Result<String> {
152        self.write_groups(groups)
153    }
154}
155
156impl JsonReporter {
157    fn write_document<T: serde::Serialize>(document: &T) -> io::Result<String> {
158        let json = to_string_pretty(document).map_err(io::Error::other)?;
159        Ok(format!("{json}\n"))
160    }
161
162    fn write_groups(&self, groups: &[DuplicateGroup]) -> io::Result<String> {
163        Self::write_document(&self.to_json_groups(groups))
164    }
165
166    fn to_json_groups(&self, groups: &[DuplicateGroup]) -> Vec<JsonGroup> {
167        groups.iter().map(|g| self.to_json_group(g)).collect()
168    }
169
170    fn to_json_stats(stats: &DuplicationStats) -> JsonStats {
171        JsonStats {
172            total_code_units: stats.total_code_units,
173            total_lines: stats.total_lines,
174            exact_duplicate_groups: stats.exact_duplicate_groups,
175            exact_duplicate_units: stats.exact_duplicate_units,
176            near_duplicate_groups: stats.near_duplicate_groups,
177            near_duplicate_units: stats.near_duplicate_units,
178            exact_duplicate_lines: stats.exact_duplicate_lines,
179            near_duplicate_lines: stats.near_duplicate_lines,
180            exact_duplicate_percent: stats.exact_duplicate_percent(),
181            near_duplicate_percent: stats.near_duplicate_percent(),
182            sub_exact_groups: stats.sub_exact_groups,
183            sub_exact_units: stats.sub_exact_units,
184            sub_near_groups: stats.sub_near_groups,
185            sub_near_units: stats.sub_near_units,
186            baseline_suppressed: stats.baseline_suppressed,
187        }
188    }
189
190    fn to_json_group(&self, group: &DuplicateGroup) -> JsonGroup {
191        JsonGroup {
192            fingerprint: group.fingerprint.to_hex(),
193            similarity: group.similarity,
194            members: group
195                .members
196                .iter()
197                .map(|m| JsonMember {
198                    name: m.name.clone(),
199                    kind: m.kind.to_string(),
200                    file: display_path(self.base_path.as_deref(), &m.file).into_owned(),
201                    line_start: m.line_start,
202                    line_end: m.line_end,
203                })
204                .collect(),
205        }
206    }
207}