Skip to main content

cargo_crap/
config.rs

1//! Optional persistent configuration via `.cargo-crap.toml`.
2//!
3//! The file is searched for by walking up from the current working directory.
4//! CLI flags always take precedence over values in the config file — the
5//! config only fills in values the user did not explicitly provide.
6//!
7//! ## Example `.cargo-crap.toml`
8//!
9//! ```toml
10//! threshold = 30.0
11//! fail-above = true
12//! missing = "pessimistic"
13//! # Appends to the default exclusions (tests/**, benches/**, examples/**).
14//! exclude = ["src/generated/**"]
15//! # Replaces the default-exclude list. `[]` disables it entirely.
16//! default-excludes = ["benches/**", "examples/**"]
17//! # `allow` accepts both function-name globs and path globs (any entry
18//! # containing `/` or `**` is treated as a path glob).
19//! allow = ["generated::*", "src/generated/**"]
20//! # Final entry ordering: "crap" (default) or "file" (stable for baselines).
21//! sort = "file"
22//! # Show Unchanged rows in --baseline mode (human / markdown).
23//! show_unchanged = true
24//! ```
25
26use crate::merge::{MissingCoveragePolicy, SortOrder};
27use anyhow::{Context, Result};
28use serde::Deserialize;
29use std::fs;
30use std::path::Path;
31
32/// Persistent settings loaded from `.cargo-crap.toml`.
33///
34/// All fields are optional — only the keys present in the config file override
35/// the built-in defaults. CLI flags take precedence over every field here.
36#[derive(Debug, Default, Deserialize)]
37#[serde(deny_unknown_fields, rename_all = "kebab-case")]
38pub struct Config {
39    /// CRAP score above which a function is considered "crappy".
40    pub threshold: Option<f64>,
41
42    /// Exit non-zero if any function's CRAP score exceeds `threshold`.
43    pub fail_above: Option<bool>,
44
45    /// How to handle functions with no coverage data.
46    /// One of `"pessimistic"` (default), `"optimistic"`, or `"skip"`.
47    pub missing: Option<MissingCoveragePolicy>,
48
49    /// Glob patterns for source files to skip (relative to `--path`).
50    #[serde(default)]
51    pub exclude: Vec<String>,
52
53    /// Replaces the built-in default-exclude list (`tests/**`, `benches/**`,
54    /// `examples/**`) wholesale. `[]` disables default exclusions; a subset
55    /// re-includes some directories; a superset extends the defaults.
56    /// Accepted as `default-excludes` (house style) or `default_excludes`.
57    /// Unlike `exclude`, which appends, this key replaces.
58    #[serde(alias = "default_excludes")]
59    pub default_excludes: Option<Vec<String>>,
60
61    /// Only show the top N crappiest functions.
62    pub top: Option<usize>,
63
64    /// Only show functions with a CRAP score at or above this value.
65    pub min: Option<f64>,
66
67    /// Glob patterns for function names to suppress from the report.
68    /// Supports `*` (matches any chars including `::`) and `?`.
69    /// Example: `"Foo::*"` suppresses all methods on `Foo`.
70    #[serde(default)]
71    pub allow: Vec<String>,
72
73    /// Exit non-zero if any function regressed since `--baseline`.
74    pub fail_regression: Option<bool>,
75
76    /// Maximum number of threads used by `analyze_tree` for parallel file
77    /// analysis. `None` lets rayon size the pool to the host. Must be
78    /// non-zero when set.
79    pub jobs: Option<usize>,
80
81    /// Tolerance for the regression detector. Score deltas with absolute
82    /// value at or below this are reported as `Unchanged`. Must be
83    /// non-negative when set.
84    pub epsilon: Option<f64>,
85
86    /// Final ordering of report entries. One of `"crap"` (default, CRAP score
87    /// descending) or `"file"` (`(file, function, line)` ascending).
88    pub sort: Option<SortOrder>,
89
90    /// In `--baseline` mode, show `Unchanged` rows in the human and markdown
91    /// tables. Defaults to false: only changed functions are listed.
92    /// Accepted as `show-unchanged` (house style) or `show_unchanged`.
93    #[serde(alias = "show_unchanged")]
94    pub show_unchanged: Option<bool>,
95}
96
97/// Walk up from `start` until `.cargo-crap.toml` is found.
98///
99/// Returns [`Config::default`] when no config file exists anywhere in the
100/// directory hierarchy — this means the tool works without any config file.
101pub fn load(start: &Path) -> Result<Config> {
102    let mut dir = if start.is_file() {
103        start.parent().unwrap_or(start)
104    } else {
105        start
106    };
107
108    loop {
109        let candidate = dir.join(".cargo-crap.toml");
110        if candidate.exists() {
111            let raw = fs::read_to_string(&candidate)
112                .with_context(|| format!("reading {}", candidate.display()))?;
113            let cfg: Config =
114                toml::from_str(&raw).with_context(|| format!("parsing {}", candidate.display()))?;
115            return Ok(cfg);
116        }
117        match dir.parent() {
118            Some(p) => dir = p,
119            None => return Ok(Config::default()),
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::io::Write;
128
129    fn write_config(
130        dir: &Path,
131        content: &str,
132    ) {
133        let mut f = fs::File::create(dir.join(".cargo-crap.toml")).unwrap();
134        f.write_all(content.as_bytes()).unwrap();
135    }
136
137    #[test]
138    fn missing_config_returns_defaults() {
139        let dir = tempfile::tempdir().unwrap();
140        let cfg = load(dir.path()).unwrap();
141        assert!(cfg.threshold.is_none());
142        assert!(cfg.fail_above.is_none());
143        assert!(cfg.missing.is_none());
144        assert!(cfg.exclude.is_empty());
145        assert!(cfg.allow.is_empty());
146    }
147
148    #[test]
149    fn config_file_is_parsed() {
150        let dir = tempfile::tempdir().unwrap();
151        write_config(
152            dir.path(),
153            r#"
154threshold = 20.0
155fail-above = true
156missing = "optimistic"
157exclude = ["tests/**"]
158allow = ["Foo::*"]
159"#,
160        );
161        let cfg = load(dir.path()).unwrap();
162        assert_eq!(cfg.threshold, Some(20.0));
163        assert_eq!(cfg.fail_above, Some(true));
164        assert_eq!(cfg.missing, Some(MissingCoveragePolicy::Optimistic));
165        assert_eq!(cfg.exclude, ["tests/**"]);
166        assert_eq!(cfg.allow, ["Foo::*"]);
167    }
168
169    #[test]
170    fn default_excludes_absent_means_none() {
171        let dir = tempfile::tempdir().unwrap();
172        write_config(dir.path(), "threshold = 20.0\n");
173        let cfg = load(dir.path()).unwrap();
174        assert!(cfg.default_excludes.is_none());
175    }
176
177    #[test]
178    fn default_excludes_kebab_case_is_parsed() {
179        let dir = tempfile::tempdir().unwrap();
180        write_config(
181            dir.path(),
182            "default-excludes = [\"benches/**\", \"examples/**\"]\n",
183        );
184        let cfg = load(dir.path()).unwrap();
185        assert_eq!(
186            cfg.default_excludes.as_deref(),
187            Some(&["benches/**".to_string(), "examples/**".to_string()][..])
188        );
189    }
190
191    #[test]
192    fn default_excludes_snake_case_alias_is_parsed() {
193        // Spec 14 scenarios write the key as `default_excludes`; both
194        // spellings must work despite `deny_unknown_fields`.
195        let dir = tempfile::tempdir().unwrap();
196        write_config(dir.path(), "default_excludes = [\"tests/**\"]\n");
197        let cfg = load(dir.path()).unwrap();
198        assert_eq!(
199            cfg.default_excludes.as_deref(),
200            Some(&["tests/**".to_string()][..])
201        );
202    }
203
204    #[test]
205    fn default_excludes_empty_list_is_some_empty() {
206        // `[]` must be distinguishable from "key absent": it disables the
207        // built-in defaults rather than falling back to them.
208        let dir = tempfile::tempdir().unwrap();
209        write_config(dir.path(), "default-excludes = []\n");
210        let cfg = load(dir.path()).unwrap();
211        assert_eq!(cfg.default_excludes.as_deref(), Some(&[][..]));
212    }
213
214    #[test]
215    fn config_is_found_by_walking_up() {
216        let dir = tempfile::tempdir().unwrap();
217        write_config(dir.path(), "threshold = 15.0\n");
218        let subdir = dir.path().join("src");
219        fs::create_dir(&subdir).unwrap();
220        // Start from a subdirectory — should walk up and find the config.
221        let cfg = load(&subdir).unwrap();
222        assert_eq!(cfg.threshold, Some(15.0));
223    }
224
225    #[test]
226    fn sort_and_show_unchanged_are_parsed() {
227        let dir = tempfile::tempdir().unwrap();
228        write_config(dir.path(), "sort = \"file\"\nshow_unchanged = true\n");
229        let cfg = load(dir.path()).unwrap();
230        assert_eq!(cfg.sort, Some(SortOrder::File));
231        assert_eq!(cfg.show_unchanged, Some(true));
232    }
233
234    #[test]
235    fn sort_and_show_unchanged_absent_means_none() {
236        let dir = tempfile::tempdir().unwrap();
237        write_config(dir.path(), "threshold = 20.0\n");
238        let cfg = load(dir.path()).unwrap();
239        assert!(cfg.sort.is_none());
240        assert!(cfg.show_unchanged.is_none());
241    }
242
243    #[test]
244    fn unknown_key_returns_error() {
245        let dir = tempfile::tempdir().unwrap();
246        write_config(dir.path(), "unknown-key = true\n");
247        let err = load(dir.path()).unwrap_err();
248        assert!(
249            err.to_string().contains("parsing"),
250            "expected parse error, got: {err}"
251        );
252    }
253}