1use std::io;
2
3use crate::grouper::{DuplicateGroup, DuplicationStats};
4use crate::output::{Reporter, display_path};
5
6fn format_with_commas(n: usize) -> String {
7 let s = n.to_string();
8 let mut result = String::with_capacity(s.len() + s.len() / 3);
9 for (i, c) in s.chars().enumerate() {
10 if i > 0 && (s.len() - i).is_multiple_of(3) {
11 result.push(',');
12 }
13 result.push(c);
14 }
15 result
16}
17
18pub struct TextReporter {
19 pub base_path: Option<std::path::PathBuf>,
21}
22
23impl TextReporter {
24 #[must_use]
25 pub const fn new(base_path: Option<std::path::PathBuf>) -> Self {
26 Self { base_path }
27 }
28
29 fn write_groups(
30 &self,
31 groups: &[DuplicateGroup],
32 writer: &mut dyn io::Write,
33 title: &str,
34 empty_message: Option<&str>,
35 show_similarity: bool,
36 show_parent: bool,
37 ) -> io::Result<()> {
38 if groups.is_empty() {
39 if let Some(msg) = empty_message {
40 writeln!(writer, "{msg}")?;
41 }
42 return Ok(());
43 }
44
45 writeln!(writer, "{title}")?;
46 writeln!(writer, "{}", "=".repeat(title.len()))?;
47 writeln!(writer)?;
48
49 for (i, group) in groups.iter().enumerate() {
50 let fp = group.fingerprint.to_hex();
51 if show_similarity {
52 writeln!(
53 writer,
54 "Group {} (fingerprint: {}, similarity: {:.0}%, {} members):",
55 i + 1,
56 fp,
57 group.similarity * 100.0,
58 group.members.len()
59 )?;
60 } else {
61 writeln!(
62 writer,
63 "Group {} (fingerprint: {}, {} members):",
64 i + 1,
65 fp,
66 group.members.len()
67 )?;
68 }
69 for member in &group.members {
70 let parent = if show_parent {
71 member
72 .parent_name
73 .as_deref()
74 .map(|p| format!(" in {p}"))
75 .unwrap_or_default()
76 } else {
77 String::new()
78 };
79 writeln!(
80 writer,
81 " - {} ({}){} at {}:{}-{}",
82 member.name,
83 member.kind,
84 parent,
85 display_path(self.base_path.as_deref(), &member.file),
86 member.line_start,
87 member.line_end,
88 )?;
89 }
90 writeln!(writer)?;
91 }
92 Ok(())
93 }
94}
95
96impl Reporter for TextReporter {
97 fn report_stats(&self, stats: &DuplicationStats, writer: &mut dyn io::Write) -> io::Result<()> {
98 writeln!(writer, "Duplication Statistics")?;
99 writeln!(writer, "=====================")?;
100 writeln!(
101 writer,
102 "Total code units analyzed: {}",
103 stats.total_code_units
104 )?;
105 writeln!(writer)?;
106 writeln!(
107 writer,
108 "Exact duplicates: {} groups ({} code units)",
109 stats.exact_duplicate_groups, stats.exact_duplicate_units
110 )?;
111 writeln!(
112 writer,
113 "Near duplicates: {} groups ({} code units)",
114 stats.near_duplicate_groups, stats.near_duplicate_units
115 )?;
116 writeln!(writer)?;
117 writeln!(
118 writer,
119 "Duplicated lines (exact): {}",
120 stats.exact_duplicate_lines
121 )?;
122 writeln!(
123 writer,
124 "Duplicated lines (near): {}",
125 stats.near_duplicate_lines
126 )?;
127 writeln!(
128 writer,
129 "Duplication: {:.1}% exact, {:.1}% near (of {} total lines)",
130 stats.exact_duplicate_percent(),
131 stats.near_duplicate_percent(),
132 format_with_commas(stats.total_lines),
133 )?;
134 if stats.sub_exact_groups > 0 || stats.sub_near_groups > 0 {
135 writeln!(writer)?;
136 writeln!(
137 writer,
138 "Sub-function exact: {} groups ({} units)",
139 stats.sub_exact_groups, stats.sub_exact_units
140 )?;
141 writeln!(
142 writer,
143 "Sub-function near: {} groups ({} units)",
144 stats.sub_near_groups, stats.sub_near_units
145 )?;
146 }
147 Ok(())
148 }
149
150 fn report_exact(
151 &self,
152 groups: &[DuplicateGroup],
153 writer: &mut dyn io::Write,
154 ) -> io::Result<()> {
155 self.write_groups(
156 groups,
157 writer,
158 "Exact Duplicates",
159 Some("No exact duplicates found."),
160 false,
161 false,
162 )
163 }
164
165 fn report_near(&self, groups: &[DuplicateGroup], writer: &mut dyn io::Write) -> io::Result<()> {
166 self.write_groups(
167 groups,
168 writer,
169 "Near Duplicates",
170 Some("No near duplicates found."),
171 true,
172 false,
173 )
174 }
175
176 fn report_sub_exact(
177 &self,
178 groups: &[DuplicateGroup],
179 writer: &mut dyn io::Write,
180 ) -> io::Result<()> {
181 self.write_groups(
182 groups,
183 writer,
184 "Sub-function Exact Duplicates",
185 None,
186 false,
187 true,
188 )
189 }
190
191 fn report_sub_near(
192 &self,
193 groups: &[DuplicateGroup],
194 writer: &mut dyn io::Write,
195 ) -> io::Result<()> {
196 self.write_groups(
197 groups,
198 writer,
199 "Sub-function Near Duplicates",
200 None,
201 true,
202 true,
203 )
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::code_unit::{CodeUnit, CodeUnitKind};
211 use crate::fingerprint::Fingerprint;
212 use crate::node::{NodeKind, NormalizedNode};
213 use std::path::PathBuf;
214
215 fn make_unit(name: &str, file: &str, line_start: usize, line_end: usize) -> CodeUnit {
216 CodeUnit {
217 kind: CodeUnitKind::Function,
218 name: name.to_string(),
219 file: PathBuf::from(file),
220 line_start,
221 line_end,
222 signature: NormalizedNode::leaf(NodeKind::Opaque),
223 body: NormalizedNode::with_children(NodeKind::Block, vec![]),
224 fingerprint: Fingerprint::from_node(&NormalizedNode::leaf(NodeKind::Opaque)),
225 node_count: 10,
226 parent_name: None,
227 is_test: false,
228 }
229 }
230
231 #[test]
232 fn text_report_stats() {
233 let reporter = TextReporter::new(None);
234 let stats = DuplicationStats {
235 total_code_units: 100,
236 total_lines: 1000,
237 exact_duplicate_groups: 5,
238 exact_duplicate_units: 12,
239 near_duplicate_groups: 3,
240 near_duplicate_units: 8,
241 exact_duplicate_lines: 60,
242 near_duplicate_lines: 40,
243 sub_exact_groups: 0,
244 sub_exact_units: 0,
245 sub_near_groups: 0,
246 sub_near_units: 0,
247 };
248 let mut buf = Vec::new();
249 reporter.report_stats(&stats, &mut buf).unwrap();
250 let output = String::from_utf8(buf).unwrap();
251 assert!(output.contains("100"));
252 assert!(output.contains("5 groups"));
253 assert!(output.contains("3 groups"));
254 }
255
256 #[test]
257 fn text_report_exact_empty() {
258 let reporter = TextReporter::new(None);
259 let mut buf = Vec::new();
260 reporter.report_exact(&[], &mut buf).unwrap();
261 let output = String::from_utf8(buf).unwrap();
262 assert!(output.contains("No exact duplicates"));
263 }
264
265 #[test]
266 fn text_report_exact_with_groups() {
267 let reporter = TextReporter::new(Some(PathBuf::from("/project")));
268 let group = DuplicateGroup {
269 fingerprint: Fingerprint::from_node(&NormalizedNode::leaf(NodeKind::Opaque)),
270 members: vec![
271 make_unit("foo", "/project/src/a.rs", 10, 20),
272 make_unit("bar", "/project/src/b.rs", 30, 40),
273 ],
274 similarity: 1.0,
275 };
276 let mut buf = Vec::new();
277 reporter.report_exact(&[group], &mut buf).unwrap();
278 let output = String::from_utf8(buf).unwrap();
279 assert!(output.contains("Group 1"));
280 assert!(output.contains("foo"));
281 assert!(output.contains("bar"));
282 assert!(output.contains("src/a.rs"));
283 assert!(output.contains("src/b.rs"));
284 }
285
286 #[test]
287 fn text_report_near_with_groups() {
288 let reporter = TextReporter::new(None);
289 let fp = Fingerprint::from_node(&NormalizedNode::with_children(NodeKind::Block, vec![]));
290 let group = DuplicateGroup {
291 fingerprint: fp,
292 members: vec![
293 make_unit("process", "/src/a.rs", 10, 25),
294 make_unit("compute", "/src/b.rs", 30, 45),
295 ],
296 similarity: 0.85,
297 };
298 let mut buf = Vec::new();
299 reporter.report_near(&[group], &mut buf).unwrap();
300 let output = String::from_utf8(buf).unwrap();
301 assert!(output.contains("fingerprint:"));
302 assert!(output.contains(&fp.to_hex()));
303 assert!(output.contains("85%"));
304 assert!(output.contains("process"));
305 assert!(output.contains("compute"));
306 }
307
308 #[test]
309 fn text_report_near_empty() {
310 let reporter = TextReporter::new(None);
311 let mut buf = Vec::new();
312 reporter.report_near(&[], &mut buf).unwrap();
313 let output = String::from_utf8(buf).unwrap();
314 assert!(output.contains("No near duplicates"));
315 }
316
317 #[test]
318 fn relative_path_stripping() {
319 let base = PathBuf::from("/home/user/project");
320 let result = display_path(
321 Some(base.as_path()),
322 std::path::Path::new("/home/user/project/src/main.rs"),
323 );
324 assert_eq!(result, "src/main.rs");
325 }
326}