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::fmt::Write as _;
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    /// Build the section under its heading, or its empty message, or nothing.
74    #[must_use]
75    pub fn write(&self, groups: &[DuplicateGroup], base: Option<&Path>) -> String {
76        if groups.is_empty() {
77            return self.write_empty();
78        }
79        let mut out = String::new();
80        let _ = writeln!(out, "{}", self.title);
81        let _ = writeln!(out, "{}", "=".repeat(self.title.len()));
82        let _ = writeln!(out);
83        for (index, group) in groups.iter().enumerate() {
84            out.push_str(&self.write_group(index + 1, group, base));
85        }
86        out
87    }
88
89    fn write_empty(&self) -> String {
90        match self.empty_message {
91            Some(message) => format!("{message}\n"),
92            None => String::new(),
93        }
94    }
95
96    fn write_group(&self, number: usize, group: &DuplicateGroup, base: Option<&Path>) -> String {
97        let mut out = String::new();
98        let _ = writeln!(out, "{}", self.heading(number, group));
99        for member in &group.members {
100            let _ = writeln!(out, "{}", self.member_line(member, base));
101        }
102        let _ = writeln!(out);
103        out
104    }
105
106    fn heading(&self, number: usize, group: &DuplicateGroup) -> String {
107        let fingerprint = group.fingerprint.to_hex();
108        let members = group.members.len();
109        if self.show_similarity {
110            format!(
111                "Group {number} (fingerprint: {fingerprint}, similarity: {:.0}%, {members} members):",
112                group.similarity * 100.0
113            )
114        } else {
115            format!("Group {number} (fingerprint: {fingerprint}, {members} members):")
116        }
117    }
118
119    fn member_line(&self, member: &CodeUnit, base: Option<&Path>) -> String {
120        format!(
121            "  - {} ({}){} at {}:{}-{}",
122            member.name,
123            member.kind,
124            self.parent_of(member),
125            display_path(base, &member.file),
126            member.line_start,
127            member.line_end,
128        )
129    }
130
131    fn parent_of(&self, member: &CodeUnit) -> String {
132        if self.show_parent {
133            member
134                .parent_name
135                .as_deref()
136                .map_or_else(String::new, |parent| format!(" in {parent}"))
137        } else {
138            String::new()
139        }
140    }
141}