Skip to main content

dupes_core/output/
json.rs

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)] // serde skip_serializing_if requires &T
40const 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        // Should be valid JSON
256        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}