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//! exclude = ["tests/**", "benches/**"]
14//! # `allow` accepts both function-name globs and path globs (any entry
15//! # containing `/` or `**` is treated as a path glob).
16//! allow = ["generated::*", "src/generated/**"]
17//! ```
18
19use crate::merge::MissingCoveragePolicy;
20use anyhow::{Context, Result};
21use serde::Deserialize;
22use std::fs;
23use std::path::Path;
24
25/// Persistent settings loaded from `.cargo-crap.toml`.
26///
27/// All fields are optional — only the keys present in the config file override
28/// the built-in defaults. CLI flags take precedence over every field here.
29#[derive(Debug, Default, Deserialize)]
30#[serde(deny_unknown_fields, rename_all = "kebab-case")]
31pub struct Config {
32    /// CRAP score above which a function is considered "crappy".
33    pub threshold: Option<f64>,
34
35    /// Exit non-zero if any function's CRAP score exceeds `threshold`.
36    pub fail_above: Option<bool>,
37
38    /// How to handle functions with no coverage data.
39    /// One of `"pessimistic"` (default), `"optimistic"`, or `"skip"`.
40    pub missing: Option<MissingCoveragePolicy>,
41
42    /// Glob patterns for source files to skip (relative to `--path`).
43    #[serde(default)]
44    pub exclude: Vec<String>,
45
46    /// Only show the top N crappiest functions.
47    pub top: Option<usize>,
48
49    /// Only show functions with a CRAP score at or above this value.
50    pub min: Option<f64>,
51
52    /// Glob patterns for function names to suppress from the report.
53    /// Supports `*` (matches any chars including `::`) and `?`.
54    /// Example: `"Foo::*"` suppresses all methods on `Foo`.
55    #[serde(default)]
56    pub allow: Vec<String>,
57
58    /// Exit non-zero if any function regressed since `--baseline`.
59    pub fail_regression: Option<bool>,
60
61    /// Maximum number of threads used by `analyze_tree` for parallel file
62    /// analysis. `None` lets rayon size the pool to the host. Must be
63    /// non-zero when set.
64    pub jobs: Option<usize>,
65
66    /// Tolerance for the regression detector. Score deltas with absolute
67    /// value at or below this are reported as `Unchanged`. Must be
68    /// non-negative when set.
69    pub epsilon: Option<f64>,
70}
71
72/// Walk up from `start` until `.cargo-crap.toml` is found.
73///
74/// Returns [`Config::default`] when no config file exists anywhere in the
75/// directory hierarchy — this means the tool works without any config file.
76pub fn load(start: &Path) -> Result<Config> {
77    let mut dir = if start.is_file() {
78        start.parent().unwrap_or(start)
79    } else {
80        start
81    };
82
83    loop {
84        let candidate = dir.join(".cargo-crap.toml");
85        if candidate.exists() {
86            let raw = fs::read_to_string(&candidate)
87                .with_context(|| format!("reading {}", candidate.display()))?;
88            let cfg: Config =
89                toml::from_str(&raw).with_context(|| format!("parsing {}", candidate.display()))?;
90            return Ok(cfg);
91        }
92        match dir.parent() {
93            Some(p) => dir = p,
94            None => return Ok(Config::default()),
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use std::io::Write;
103
104    fn write_config(
105        dir: &Path,
106        content: &str,
107    ) {
108        let mut f = fs::File::create(dir.join(".cargo-crap.toml")).unwrap();
109        f.write_all(content.as_bytes()).unwrap();
110    }
111
112    #[test]
113    fn missing_config_returns_defaults() {
114        let dir = tempfile::tempdir().unwrap();
115        let cfg = load(dir.path()).unwrap();
116        assert!(cfg.threshold.is_none());
117        assert!(cfg.fail_above.is_none());
118        assert!(cfg.missing.is_none());
119        assert!(cfg.exclude.is_empty());
120        assert!(cfg.allow.is_empty());
121    }
122
123    #[test]
124    fn config_file_is_parsed() {
125        let dir = tempfile::tempdir().unwrap();
126        write_config(
127            dir.path(),
128            r#"
129threshold = 20.0
130fail-above = true
131missing = "optimistic"
132exclude = ["tests/**"]
133allow = ["Foo::*"]
134"#,
135        );
136        let cfg = load(dir.path()).unwrap();
137        assert_eq!(cfg.threshold, Some(20.0));
138        assert_eq!(cfg.fail_above, Some(true));
139        assert_eq!(cfg.missing, Some(MissingCoveragePolicy::Optimistic));
140        assert_eq!(cfg.exclude, ["tests/**"]);
141        assert_eq!(cfg.allow, ["Foo::*"]);
142    }
143
144    #[test]
145    fn config_is_found_by_walking_up() {
146        let dir = tempfile::tempdir().unwrap();
147        write_config(dir.path(), "threshold = 15.0\n");
148        let subdir = dir.path().join("src");
149        fs::create_dir(&subdir).unwrap();
150        // Start from a subdirectory — should walk up and find the config.
151        let cfg = load(&subdir).unwrap();
152        assert_eq!(cfg.threshold, Some(15.0));
153    }
154
155    #[test]
156    fn unknown_key_returns_error() {
157        let dir = tempfile::tempdir().unwrap();
158        write_config(dir.path(), "unknown-key = true\n");
159        let err = load(dir.path()).unwrap_err();
160        assert!(
161            err.to_string().contains("parsing"),
162            "expected parse error, got: {err}"
163        );
164    }
165}