1use std::io;
2
3use crate::grouper::{DuplicateGroup, DuplicationStats};
4use crate::output::{Reporter, display_path};
5
6pub struct JsonReporter {
7 pub base_path: Option<std::path::PathBuf>,
8}
9
10impl JsonReporter {
11 #[must_use]
12 pub const fn new(base_path: Option<std::path::PathBuf>) -> Self {
13 Self { base_path }
14 }
15}
16
17#[derive(serde::Serialize)]
18struct JsonStats {
19 total_code_units: usize,
20 total_lines: usize,
21 exact_duplicate_groups: usize,
22 exact_duplicate_units: usize,
23 near_duplicate_groups: usize,
24 near_duplicate_units: usize,
25 exact_duplicate_lines: usize,
26 near_duplicate_lines: usize,
27 exact_duplicate_percent: f64,
28 near_duplicate_percent: f64,
29 #[serde(skip_serializing_if = "is_zero")]
30 sub_exact_groups: usize,
31 #[serde(skip_serializing_if = "is_zero")]
32 sub_exact_units: usize,
33 #[serde(skip_serializing_if = "is_zero")]
34 sub_near_groups: usize,
35 #[serde(skip_serializing_if = "is_zero")]
36 sub_near_units: usize,
37}
38
39#[allow(clippy::trivially_copy_pass_by_ref)] const fn is_zero(v: &usize) -> bool {
41 *v == 0
42}
43
44#[derive(serde::Serialize)]
45struct JsonGroup {
46 fingerprint: String,
47 similarity: f64,
48 members: Vec<JsonMember>,
49}
50
51#[derive(serde::Serialize)]
52struct JsonMember {
53 name: String,
54 kind: String,
55 file: String,
56 line_start: usize,
57 line_end: usize,
58}
59
60impl Reporter for JsonReporter {
61 fn report_stats(&self, stats: &DuplicationStats, writer: &mut dyn io::Write) -> io::Result<()> {
62 let json_stats = JsonStats {
63 total_code_units: stats.total_code_units,
64 total_lines: stats.total_lines,
65 exact_duplicate_groups: stats.exact_duplicate_groups,
66 exact_duplicate_units: stats.exact_duplicate_units,
67 near_duplicate_groups: stats.near_duplicate_groups,
68 near_duplicate_units: stats.near_duplicate_units,
69 exact_duplicate_lines: stats.exact_duplicate_lines,
70 near_duplicate_lines: stats.near_duplicate_lines,
71 exact_duplicate_percent: stats.exact_duplicate_percent(),
72 near_duplicate_percent: stats.near_duplicate_percent(),
73 sub_exact_groups: stats.sub_exact_groups,
74 sub_exact_units: stats.sub_exact_units,
75 sub_near_groups: stats.sub_near_groups,
76 sub_near_units: stats.sub_near_units,
77 };
78 let json = serde_json::to_string_pretty(&json_stats).map_err(io::Error::other)?;
79 writeln!(writer, "{json}")
80 }
81
82 fn report_exact(
83 &self,
84 groups: &[DuplicateGroup],
85 writer: &mut dyn io::Write,
86 ) -> io::Result<()> {
87 self.write_groups(groups, writer)
88 }
89
90 fn report_near(&self, groups: &[DuplicateGroup], writer: &mut dyn io::Write) -> io::Result<()> {
91 self.write_groups(groups, writer)
92 }
93
94 fn report_sub_exact(
95 &self,
96 groups: &[DuplicateGroup],
97 writer: &mut dyn io::Write,
98 ) -> io::Result<()> {
99 self.write_groups(groups, writer)
100 }
101
102 fn report_sub_near(
103 &self,
104 groups: &[DuplicateGroup],
105 writer: &mut dyn io::Write,
106 ) -> io::Result<()> {
107 self.write_groups(groups, writer)
108 }
109}
110
111impl JsonReporter {
112 fn write_groups(
113 &self,
114 groups: &[DuplicateGroup],
115 writer: &mut dyn io::Write,
116 ) -> io::Result<()> {
117 let json_groups: Vec<JsonGroup> = groups.iter().map(|g| self.to_json_group(g)).collect();
118 let json = serde_json::to_string_pretty(&json_groups).map_err(io::Error::other)?;
119 writeln!(writer, "{json}")
120 }
121
122 fn to_json_group(&self, group: &DuplicateGroup) -> JsonGroup {
123 JsonGroup {
124 fingerprint: group.fingerprint.to_hex(),
125 similarity: group.similarity,
126 members: group
127 .members
128 .iter()
129 .map(|m| JsonMember {
130 name: m.name.clone(),
131 kind: m.kind.to_string(),
132 file: display_path(self.base_path.as_deref(), &m.file).into_owned(),
133 line_start: m.line_start,
134 line_end: m.line_end,
135 })
136 .collect(),
137 }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::code_unit::{CodeUnit, CodeUnitKind};
145 use crate::fingerprint::Fingerprint;
146 use crate::node::{NodeKind, NormalizedNode};
147 use std::path::PathBuf;
148
149 fn make_unit(name: &str, file: &str, line_start: usize, line_end: usize) -> CodeUnit {
150 CodeUnit {
151 kind: CodeUnitKind::Function,
152 name: name.to_string(),
153 file: PathBuf::from(file),
154 line_start,
155 line_end,
156 signature: NormalizedNode::leaf(NodeKind::Opaque),
157 body: NormalizedNode::with_children(NodeKind::Block, vec![]),
158 fingerprint: Fingerprint::from_node(&NormalizedNode::leaf(NodeKind::Opaque)),
159 node_count: 10,
160 parent_name: None,
161 is_test: false,
162 }
163 }
164
165 #[test]
166 fn json_report_stats() {
167 let reporter = JsonReporter::new(None);
168 let stats = DuplicationStats {
169 total_code_units: 50,
170 total_lines: 500,
171 exact_duplicate_groups: 3,
172 exact_duplicate_units: 8,
173 near_duplicate_groups: 2,
174 near_duplicate_units: 5,
175 exact_duplicate_lines: 30,
176 near_duplicate_lines: 20,
177 sub_exact_groups: 0,
178 sub_exact_units: 0,
179 sub_near_groups: 0,
180 sub_near_units: 0,
181 };
182 let mut buf = Vec::new();
183 reporter.report_stats(&stats, &mut buf).unwrap();
184 let output = String::from_utf8(buf).unwrap();
185 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
186 assert_eq!(parsed["total_code_units"], 50);
187 assert_eq!(parsed["exact_duplicate_groups"], 3);
188 }
189
190 #[test]
191 fn json_report_exact_empty() {
192 let reporter = JsonReporter::new(None);
193 let mut buf = Vec::new();
194 reporter.report_exact(&[], &mut buf).unwrap();
195 let output = String::from_utf8(buf).unwrap();
196 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
197 assert!(parsed.as_array().unwrap().is_empty());
198 }
199
200 #[test]
201 fn json_report_exact_with_groups() {
202 let reporter = JsonReporter::new(Some(PathBuf::from("/project")));
203 let group = DuplicateGroup {
204 fingerprint: Fingerprint::from_node(&NormalizedNode::leaf(NodeKind::Opaque)),
205 members: vec![
206 make_unit("foo", "/project/src/a.rs", 10, 20),
207 make_unit("bar", "/project/src/b.rs", 30, 40),
208 ],
209 similarity: 1.0,
210 };
211 let mut buf = Vec::new();
212 reporter.report_exact(&[group], &mut buf).unwrap();
213 let output = String::from_utf8(buf).unwrap();
214 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
215 let groups = parsed.as_array().unwrap();
216 assert_eq!(groups.len(), 1);
217 assert_eq!(groups[0]["members"].as_array().unwrap().len(), 2);
218 assert_eq!(groups[0]["similarity"], 1.0);
219 assert!(groups[0]["fingerprint"].is_string());
220 }
221
222 #[test]
223 fn json_report_near_with_groups() {
224 let reporter = JsonReporter::new(None);
225 let fp = Fingerprint::from_node(&NormalizedNode::with_children(NodeKind::Block, vec![]));
226 let group = DuplicateGroup {
227 fingerprint: fp,
228 members: vec![
229 make_unit("process", "/src/a.rs", 10, 25),
230 make_unit("compute", "/src/b.rs", 30, 45),
231 ],
232 similarity: 0.85,
233 };
234 let mut buf = Vec::new();
235 reporter.report_near(&[group], &mut buf).unwrap();
236 let output = String::from_utf8(buf).unwrap();
237 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
238 let groups = parsed.as_array().unwrap();
239 assert_eq!(groups.len(), 1);
240 assert_eq!(groups[0]["fingerprint"].as_str().unwrap(), fp.to_hex());
241 assert_eq!(groups[0]["similarity"], 0.85);
242 }
243
244 #[test]
245 fn json_is_valid() {
246 let reporter = JsonReporter::new(Some(PathBuf::from("/project")));
247 let group = DuplicateGroup {
248 fingerprint: Fingerprint::from_node(&NormalizedNode::leaf(NodeKind::Opaque)),
249 members: vec![make_unit("foo", "/project/src/a.rs", 10, 20)],
250 similarity: 1.0,
251 };
252 let mut buf = Vec::new();
253 reporter.report_exact(&[group], &mut buf).unwrap();
254 let output = String::from_utf8(buf).unwrap();
255 assert!(serde_json::from_str::<serde_json::Value>(&output).is_ok());
257 }
258
259 #[test]
260 fn json_relative_paths() {
261 let reporter = JsonReporter::new(Some(PathBuf::from("/home/user/project")));
262 let fp = Fingerprint::from_node(&NormalizedNode::with_children(NodeKind::Block, vec![]));
263 let group = DuplicateGroup {
264 fingerprint: fp,
265 members: vec![make_unit("foo", "/home/user/project/src/main.rs", 1, 10)],
266 similarity: 0.9,
267 };
268 let mut buf = Vec::new();
269 reporter.report_near(&[group], &mut buf).unwrap();
270 let output = String::from_utf8(buf).unwrap();
271 assert!(output.contains("src/main.rs"));
272 assert!(!output.contains("/home/user/project"));
273 }
274}