Skip to main content

dry4rust/suppression/
baseline_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::fs;
7use std::io::Error as IoError;
8use std::io::ErrorKind;
9use std::path::Path;
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13use serde_json::from_str;
14use serde_json::to_string_pretty;
15
16use crate::analysis::AnalysisResult;
17use crate::error::Error;
18use crate::error::Result;
19use crate::grouper::DuplicateGroup;
20use crate::suppression::baseline_entry::BaselineEntry;
21use crate::suppression::baseline_kind::BaselineKind;
22
23/// What `baseline` writes when no path says otherwise.
24///
25/// JSON rather than the TOML the ignore file uses, and named for the tool the
26/// way every other file in this family is. The two suppression files differ in
27/// who writes them: an ignore entry is written by a person and carries a reason
28/// in their words, and a baseline is written by the tool and read back by it.
29pub const DEFAULT_BASELINE_FILE: &str = "dry4rust-baseline.json";
30
31/// The format this build writes, and the only one it reads.
32pub const FORMAT_VERSION: u32 = 1;
33
34/// Where the baseline lives for a given root.
35///
36/// A relative path is taken as relative to the analysed root, so a
37/// `dry4rust.toml` naming one means the same thing wherever it is run from.
38#[must_use]
39pub fn baseline_path(root: &Path, configured: Option<&Path>) -> PathBuf {
40    let named = configured.unwrap_or_else(|| Path::new(DEFAULT_BASELINE_FILE));
41    if named.is_absolute() {
42        named.to_path_buf()
43    } else {
44        root.join(named)
45    }
46}
47
48/// The duplication a codebase already had when the baseline was recorded.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct BaselineFile {
51    /// The format the entries are written in.
52    pub version: u32,
53    /// Every group that was there at the time.
54    pub entries: Vec<BaselineEntry>,
55}
56
57impl BaselineFile {
58    /// Record everything `result` found.
59    ///
60    /// Entries are ordered by kind and then by fingerprint, so re-recording an
61    /// unchanged codebase produces a byte-identical file and a diff shows only
62    /// what actually moved.
63    #[must_use]
64    pub fn record(result: &AnalysisResult) -> Self {
65        let mut entries: Vec<BaselineEntry> = [
66            (BaselineKind::Exact, &result.exact_groups),
67            (BaselineKind::Near, &result.near_groups),
68            (BaselineKind::SubExact, &result.sub_exact_groups),
69            (BaselineKind::SubNear, &result.sub_near_groups),
70        ]
71        .into_iter()
72        .flat_map(|(kind, groups)| groups.iter().map(move |g| BaselineEntry::of(kind, g)))
73        .collect();
74        entries.sort_by(|a, b| {
75            a.kind
76                .cmp(&b.kind)
77                .then_with(|| a.fingerprint.cmp(&b.fingerprint))
78        });
79        Self {
80            version: FORMAT_VERSION,
81            entries,
82        }
83    }
84
85    /// Read the baseline at `path`.
86    ///
87    /// # Errors
88    ///
89    /// Returns [`Error::Baseline`] when the file is missing, unreadable,
90    /// malformed, or written in a format this build does not read. A baseline
91    /// that cannot be applied is never treated as an empty one: that would
92    /// quietly turn every inherited finding into a new one.
93    pub fn load(path: &Path) -> Result<Self> {
94        let content = fs::read_to_string(path).map_err(|e| Error::Baseline {
95            path: path.to_path_buf(),
96            problem: if e.kind() == ErrorKind::NotFound {
97                String::from("no such file; record one with `cargo dry4rust baseline`")
98            } else {
99                e.to_string()
100            },
101        })?;
102        let file: Self = from_str(&content).map_err(|e| Error::Baseline {
103            path: path.to_path_buf(),
104            problem: e.to_string(),
105        })?;
106        if file.version == FORMAT_VERSION {
107            Ok(file)
108        } else {
109            Err(Error::Baseline {
110                path: path.to_path_buf(),
111                problem: format!(
112                    "written in format {}, this build reads format {FORMAT_VERSION}; \
113                     re-record it with `cargo dry4rust baseline`",
114                    file.version
115                ),
116            })
117        }
118    }
119
120    /// Write the baseline to `path`, creating the directory above it.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`Error::Baseline`] if the file cannot be written.
125    pub fn save(&self, path: &Path) -> Result<()> {
126        let problem = |e: IoError| Error::Baseline {
127            path: path.to_path_buf(),
128            problem: e.to_string(),
129        };
130        if let Some(parent) = path.parent()
131            && !parent.as_os_str().is_empty()
132        {
133            fs::create_dir_all(parent).map_err(problem)?;
134        }
135        let json = to_string_pretty(self).map_err(|e| Error::Baseline {
136            path: path.to_path_buf(),
137            problem: e.to_string(),
138        })?;
139        fs::write(path, format!("{json}\n")).map_err(problem)
140    }
141
142    /// How many groups this baseline records.
143    #[must_use]
144    pub const fn len(&self) -> usize {
145        self.entries.len()
146    }
147
148    /// Whether it records nothing at all.
149    #[must_use]
150    pub const fn is_empty(&self) -> bool {
151        self.entries.is_empty()
152    }
153
154    /// Whether some entry accounts for `group` in full.
155    #[must_use]
156    pub fn admits(&self, kind: BaselineKind, group: &DuplicateGroup) -> bool {
157        self.entries.iter().any(|entry| entry.admits(kind, group))
158    }
159}