Skip to main content

dupes_core/output/
text.rs

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    /// Base path for displaying relative paths.
20    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}