Skip to main content

dry4rust/suppression/
ignore_file.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::collections::HashSet;
7use std::fs;
8use std::io;
9use std::io::Error as IoError;
10use std::io::ErrorKind;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14use toml::from_str;
15use toml::to_string_pretty;
16
17use crate::fingerprint::Fingerprint;
18use crate::grouper::DuplicateGroup;
19use crate::suppression::ignore_entry::IgnoreEntry;
20
21const IGNORE_FILE_NAME: &str = ".dry4rust-ignore.toml";
22
23/// The suppressions a project has decided to keep.
24///
25/// Every operation that changes the file takes it by value and hands back the
26/// changed one, so a caller cannot half-apply a change and cannot be handed a
27/// file that something else is still writing to.
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct IgnoreFile {
30    #[serde(default)]
31    pub ignore: Vec<IgnoreEntry>,
32}
33
34impl IgnoreFile {
35    /// Where the ignore file lives for a project root.
36    #[must_use]
37    pub fn path_in(root: &Path) -> PathBuf {
38        root.join(IGNORE_FILE_NAME)
39    }
40
41    /// Read it, or an empty one if there is nothing readable there.
42    ///
43    /// Unlike a baseline, an absent ignore file is the ordinary case: most
44    /// projects suppress nothing, and that is indistinguishable from a project
45    /// that has not started yet.
46    #[must_use]
47    pub fn load(root: &Path) -> Self {
48        let path = Self::path_in(root);
49        if !path.exists() {
50            return Self::default();
51        }
52        fs::read_to_string(&path).map_or_else(
53            |_| Self::default(),
54            |content| from_str(&content).unwrap_or_default(),
55        )
56    }
57
58    /// Write it to the project root, or take the file away when there is
59    /// nothing left to record.
60    ///
61    /// An empty suppression list makes exactly the claim that no suppression
62    /// list makes, and `load` cannot tell them apart. `cleanup` pruning the
63    /// last entry is the one path that produces one, and a file reading
64    /// `ignore = []` is residue rather than a record.
65    ///
66    /// # Errors
67    ///
68    /// Returns the I/O error if the file cannot be serialized, written or
69    /// removed.
70    pub fn save(&self, root: &Path) -> io::Result<()> {
71        if self.ignore.is_empty() {
72            return Self::remove_from(root);
73        }
74        let content = to_string_pretty(self)
75            .map_err(|e| IoError::other(format!("Failed to serialize ignore file: {e}")))?;
76        fs::write(Self::path_in(root), content)
77    }
78
79    /// Take the ignore file away, and say nothing if it was not there.
80    ///
81    /// # Errors
82    ///
83    /// Returns the I/O error if the file exists and cannot be removed.
84    pub fn remove_from(root: &Path) -> io::Result<()> {
85        match fs::remove_file(Self::path_in(root)) {
86            Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
87            outcome => outcome,
88        }
89    }
90
91    /// The same file with `fingerprint` suppressed, or unchanged if it already
92    /// was.
93    #[must_use]
94    pub fn with_ignored(
95        self,
96        fingerprint: &Fingerprint,
97        reason: Option<String>,
98        members: Vec<String>,
99    ) -> Self {
100        let hex = fingerprint.to_hex();
101        if self.ignore.iter().any(|entry| entry.fingerprint == hex) {
102            return self;
103        }
104        let mut file = self;
105        file.ignore.push(IgnoreEntry {
106            fingerprint: hex,
107            reason,
108            members,
109        });
110        file
111    }
112
113    /// The same file without `fingerprint`, and whether it was there to remove.
114    #[must_use]
115    pub fn without(self, fingerprint: &str) -> (Self, bool) {
116        let mut file = self;
117        let before = file.ignore.len();
118        file.ignore.retain(|entry| entry.fingerprint != fingerprint);
119        let removed = file.ignore.len() < before;
120        (file, removed)
121    }
122
123    /// Whether this fingerprint is suppressed.
124    #[must_use]
125    pub fn contains(&self, fingerprint: &Fingerprint) -> bool {
126        let hex = fingerprint.to_hex();
127        self.ignore.iter().any(|entry| entry.fingerprint == hex)
128    }
129
130    /// The groups this file does not suppress.
131    #[must_use]
132    pub fn retain_unsuppressed(&self, groups: Vec<DuplicateGroup>) -> Vec<DuplicateGroup> {
133        groups
134            .into_iter()
135            .filter(|group| !self.contains(&group.fingerprint))
136            .collect()
137    }
138
139    /// The entries whose fingerprint no longer matches anything live.
140    ///
141    /// An entry whose fingerprint is not valid hex is always stale: nothing
142    /// this tool produces could match it.
143    #[must_use]
144    pub fn stale(&self, live: &HashSet<Fingerprint>) -> Vec<&IgnoreEntry> {
145        self.ignore
146            .iter()
147            .filter(|entry| !Self::is_live(entry, live))
148            .collect()
149    }
150
151    /// The same file with the stale entries taken out, and those entries.
152    #[must_use]
153    pub fn without_stale(self, live: &HashSet<Fingerprint>) -> (Self, Vec<IgnoreEntry>) {
154        let (kept, stale) = self
155            .ignore
156            .into_iter()
157            .partition(|entry| Self::is_live(entry, live));
158        (Self { ignore: kept }, stale)
159    }
160
161    fn is_live(entry: &IgnoreEntry, live: &HashSet<Fingerprint>) -> bool {
162        Fingerprint::from_hex(&entry.fingerprint).is_some_and(|fp| live.contains(&fp))
163    }
164}