Skip to main content

dry4rust/output/
group_section.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;
7use std::path::Path;
8
9use crate::code_unit::CodeUnit;
10use crate::grouper::DuplicateGroup;
11use crate::output::reporter::display_path;
12
13/// One headed section of a text report, and how the groups under it read.
14///
15/// The four sections differ in four ways and in nothing else: what they are
16/// called, whether they say anything when empty, whether a group states its
17/// similarity, and whether a member names the function it came from. Those
18/// four differences were four parameters on one private function, which is
19/// how a formatter acquires branches it does not need.
20pub struct GroupSection {
21    title: &'static str,
22    empty_message: Option<&'static str>,
23    show_similarity: bool,
24    show_parent: bool,
25}
26
27impl GroupSection {
28    /// Exact duplicates: says so in words when there are none.
29    #[must_use]
30    pub const fn exact() -> Self {
31        Self {
32            title: "Exact Duplicates",
33            empty_message: Some("No exact duplicates found."),
34            show_similarity: false,
35            show_parent: false,
36        }
37    }
38
39    /// Near duplicates: scored, and says so in words when there are none.
40    #[must_use]
41    pub const fn near() -> Self {
42        Self {
43            title: "Near Duplicates",
44            empty_message: Some("No near duplicates found."),
45            show_similarity: true,
46            show_parent: false,
47        }
48    }
49
50    /// Sub-function exact duplicates: silent when empty, because the analysis
51    /// they come from is opt-in and saying "none found" would suggest it ran.
52    #[must_use]
53    pub const fn sub_exact() -> Self {
54        Self {
55            title: "Sub-function Exact Duplicates",
56            empty_message: None,
57            show_similarity: false,
58            show_parent: true,
59        }
60    }
61
62    /// Sub-function near duplicates: scored, and silent when empty.
63    #[must_use]
64    pub const fn sub_near() -> Self {
65        Self {
66            title: "Sub-function Near Duplicates",
67            empty_message: None,
68            show_similarity: true,
69            show_parent: true,
70        }
71    }
72
73    /// Write the section under its heading, or its empty message, or nothing.
74    ///
75    /// # Errors
76    ///
77    /// Returns whatever the writer returns.
78    pub fn write(
79        &self,
80        groups: &[DuplicateGroup],
81        base: Option<&Path>,
82        writer: &mut dyn io::Write,
83    ) -> io::Result<()> {
84        if groups.is_empty() {
85            return self.write_empty(writer);
86        }
87        writeln!(writer, "{}", self.title)?;
88        writeln!(writer, "{}", "=".repeat(self.title.len()))?;
89        writeln!(writer)?;
90        for (index, group) in groups.iter().enumerate() {
91            self.write_group(index + 1, group, base, writer)?;
92        }
93        Ok(())
94    }
95
96    fn write_empty(&self, writer: &mut dyn io::Write) -> io::Result<()> {
97        match self.empty_message {
98            Some(message) => writeln!(writer, "{message}"),
99            None => Ok(()),
100        }
101    }
102
103    fn write_group(
104        &self,
105        number: usize,
106        group: &DuplicateGroup,
107        base: Option<&Path>,
108        writer: &mut dyn io::Write,
109    ) -> io::Result<()> {
110        writeln!(writer, "{}", self.heading(number, group))?;
111        for member in &group.members {
112            writeln!(writer, "{}", self.member_line(member, base))?;
113        }
114        writeln!(writer)
115    }
116
117    fn heading(&self, number: usize, group: &DuplicateGroup) -> String {
118        let fingerprint = group.fingerprint.to_hex();
119        let members = group.members.len();
120        if self.show_similarity {
121            format!(
122                "Group {number} (fingerprint: {fingerprint}, similarity: {:.0}%, {members} members):",
123                group.similarity * 100.0
124            )
125        } else {
126            format!("Group {number} (fingerprint: {fingerprint}, {members} members):")
127        }
128    }
129
130    fn member_line(&self, member: &CodeUnit, base: Option<&Path>) -> String {
131        format!(
132            "  - {} ({}){} at {}:{}-{}",
133            member.name,
134            member.kind,
135            self.parent_of(member),
136            display_path(base, &member.file),
137            member.line_start,
138            member.line_end,
139        )
140    }
141
142    fn parent_of(&self, member: &CodeUnit) -> String {
143        if self.show_parent {
144            member
145                .parent_name
146                .as_deref()
147                .map_or_else(String::new, |parent| format!(" in {parent}"))
148        } else {
149            String::new()
150        }
151    }
152}