Skip to main content

scute_config/
lib.rs

1//! Configuration loading for Scute checks.
2//!
3//! Finds and parses `.scute.yml`, walking up from the project root to the
4//! nearest git boundary. Each check can pull its own typed [`Definition`](ScuteConfig::definition)
5//! from the shared config file.
6
7#![allow(clippy::missing_errors_doc)]
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12use serde::Deserialize;
13use serde::de::DeserializeOwned;
14
15const CONFIG_FILE: &str = ".scute.yml";
16
17#[derive(Debug)]
18pub enum ConfigError {
19    Io(std::io::Error),
20    Parse(String),
21    InvalidCheckConfig(String),
22}
23
24impl std::fmt::Display for ConfigError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::Io(e) => write!(f, "{e}"),
28            Self::Parse(msg) | Self::InvalidCheckConfig(msg) => write!(f, "{msg}"),
29        }
30    }
31}
32
33impl std::error::Error for ConfigError {}
34
35#[derive(Default, Deserialize)]
36struct RawConfig {
37    #[serde(default)]
38    checks: HashMap<String, serde_yml::Value>,
39}
40
41pub struct ScuteConfig {
42    checks: HashMap<String, serde_yml::Value>,
43}
44
45impl ScuteConfig {
46    pub fn load(dir: &Path) -> Result<Self, ConfigError> {
47        let Some(path) = find_config_file(dir) else {
48            return Ok(Self {
49                checks: HashMap::new(),
50            });
51        };
52        let contents = std::fs::read_to_string(&path).map_err(ConfigError::Io)?;
53        let raw: RawConfig = serde_yml::from_str(&contents)
54            .map_err(|e| ConfigError::Parse(format_config_error(&e)))?;
55        Ok(Self { checks: raw.checks })
56    }
57
58    pub fn definition<D: Default + DeserializeOwned>(&self, name: &str) -> Result<D, ConfigError> {
59        match self.checks.get(name) {
60            Some(value) => serde_yml::from_value(value.clone())
61                .map_err(|e| ConfigError::InvalidCheckConfig(format_config_error(&e))),
62            None => Ok(D::default()),
63        }
64    }
65}
66
67fn is_search_boundary(dir: &Path, home: Option<&Path>) -> bool {
68    dir.join(".git").exists() || home == Some(dir)
69}
70
71fn find_config_file(start: &Path) -> Option<PathBuf> {
72    let home = dirs::home_dir();
73    let mut searching = true;
74    start
75        .ancestors()
76        .take_while(move |dir| {
77            let allowed = searching;
78            searching = !is_search_boundary(dir, home.as_deref());
79            allowed
80        })
81        .map(|dir| dir.join(CONFIG_FILE))
82        .find(|candidate| candidate.exists())
83}
84
85fn format_config_error(err: &serde_yml::Error) -> String {
86    let mut msg = strip_internal_types(&err.to_string());
87    if !msg.contains("at line ")
88        && let Some(loc) = err.location()
89    {
90        msg = format!("{msg} at line {} column {}", loc.line(), loc.column());
91    }
92    msg
93}
94
95fn strip_internal_types(msg: &str) -> String {
96    for keyword in [", expected struct ", ", expected enum "] {
97        if let Some(start) = msg.find(keyword) {
98            let after_type_keyword = &msg[start + keyword.len()..];
99            let type_name_end = after_type_keyword
100                .find(|c: char| !c.is_alphanumeric() && c != '_')
101                .unwrap_or(after_type_keyword.len());
102            return format!("{}{}", &msg[..start], &after_type_keyword[type_name_end..]);
103        }
104    }
105    msg.to_string()
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use scute_core::{Thresholds, code_similarity, commit_message, dependency_freshness};
112
113    fn config_from_yaml(yaml: &str) -> ScuteConfig {
114        let checks: HashMap<String, serde_yml::Value> = serde_yml::from_str(yaml).unwrap();
115        ScuteConfig { checks }
116    }
117
118    fn definition<D: Default + DeserializeOwned>(yaml: &str, check: &str) -> D {
119        config_from_yaml(yaml).definition(check).unwrap()
120    }
121
122    #[test]
123    fn no_entry_returns_default_freshness_definition() {
124        let def: dependency_freshness::Definition =
125            definition("{}", dependency_freshness::CHECK_NAME);
126
127        assert_eq!(def.level, None);
128        assert_eq!(def.thresholds, None);
129    }
130
131    #[test]
132    fn freshness_reads_level() {
133        let def: dependency_freshness::Definition = definition(
134            r"
135            dependency-freshness:
136              level: minor
137            ",
138            dependency_freshness::CHECK_NAME,
139        );
140
141        assert_eq!(def.level, Some(dependency_freshness::Level::Minor));
142    }
143
144    #[test]
145    fn freshness_reads_thresholds() {
146        let def: dependency_freshness::Definition = definition(
147            r"
148            dependency-freshness:
149              thresholds:
150                fail: 5
151            ",
152            dependency_freshness::CHECK_NAME,
153        );
154
155        assert_eq!(
156            def.thresholds,
157            Some(Thresholds {
158                warn: None,
159                fail: Some(5),
160            })
161        );
162    }
163
164    #[test]
165    fn freshness_rejects_invalid_level() {
166        let config = config_from_yaml(
167            r"
168            dependency-freshness:
169              level: bananas
170            ",
171        );
172
173        assert!(
174            config
175                .definition::<dependency_freshness::Definition>(dependency_freshness::CHECK_NAME)
176                .is_err()
177        );
178    }
179
180    #[test]
181    fn no_entry_returns_default_code_similarity_definition() {
182        let def: code_similarity::Definition = definition("{}", code_similarity::CHECK_NAME);
183
184        assert_eq!(def.min_tokens, None);
185        assert_eq!(def.thresholds, None);
186    }
187
188    #[test]
189    fn code_similarity_reads_min_tokens_with_kebab_case() {
190        let def: code_similarity::Definition = definition(
191            r"
192            code-similarity:
193              min-tokens: 10
194            ",
195            code_similarity::CHECK_NAME,
196        );
197
198        assert_eq!(def.min_tokens, Some(10));
199    }
200
201    #[test]
202    fn code_similarity_reads_min_tokens_with_snake_case() {
203        let def: code_similarity::Definition = definition(
204            r"
205            code-similarity:
206              min_tokens: 10
207            ",
208            code_similarity::CHECK_NAME,
209        );
210
211        assert_eq!(def.min_tokens, Some(10));
212    }
213
214    #[test]
215    fn code_similarity_reads_thresholds() {
216        let def: code_similarity::Definition = definition(
217            r"
218            code-similarity:
219              thresholds:
220                warn: 20
221                fail: 50
222            ",
223            code_similarity::CHECK_NAME,
224        );
225
226        assert_eq!(
227            def.thresholds,
228            Some(Thresholds {
229                warn: Some(20),
230                fail: Some(50),
231            })
232        );
233    }
234
235    #[test]
236    fn code_similarity_reads_test_thresholds_with_kebab_case() {
237        let def: code_similarity::Definition = definition(
238            r"
239            code-similarity:
240              test-thresholds:
241                warn: 100
242                fail: 200
243            ",
244            code_similarity::CHECK_NAME,
245        );
246
247        assert_eq!(
248            def.test_thresholds,
249            Some(Thresholds {
250                warn: Some(100),
251                fail: Some(200),
252            })
253        );
254    }
255
256    #[test]
257    fn code_similarity_reads_exclude_patterns() {
258        let def: code_similarity::Definition = definition(
259            r"
260            code-similarity:
261              exclude:
262                - '*.d.ts'
263                - 'generated/**'
264            ",
265            code_similarity::CHECK_NAME,
266        );
267
268        assert_eq!(
269            def.exclude,
270            Some(vec!["*.d.ts".into(), "generated/**".into()])
271        );
272    }
273
274    #[test]
275    fn rejects_unknown_fields_in_check_definition() {
276        let config = config_from_yaml(
277            r"
278            code-similarity:
279              config:
280                min-tokens: 25
281            ",
282        );
283
284        assert!(
285            config
286                .definition::<code_similarity::Definition>(code_similarity::CHECK_NAME)
287                .is_err()
288        );
289    }
290
291    #[test]
292    fn commit_message_reads_types() {
293        let def: commit_message::Definition = definition(
294            r"
295            commit-message:
296              types: [hotfix, deploy]
297            ",
298            commit_message::CHECK_NAME,
299        );
300
301        assert_eq!(def.types, Some(vec!["hotfix".into(), "deploy".into()]));
302    }
303
304    mod find_config_file {
305        use super::super::*;
306
307        #[test]
308        fn finds_config_in_start_directory() {
309            let dir = tempfile::tempdir().unwrap();
310            std::fs::write(dir.path().join(CONFIG_FILE), "").unwrap();
311
312            assert_eq!(
313                find_config_file(dir.path()),
314                Some(dir.path().join(CONFIG_FILE))
315            );
316        }
317
318        #[test]
319        fn finds_config_in_parent_directory() {
320            let dir = tempfile::tempdir().unwrap();
321            std::fs::write(dir.path().join(CONFIG_FILE), "").unwrap();
322            let child = dir.path().join("sub");
323            std::fs::create_dir(&child).unwrap();
324
325            assert_eq!(find_config_file(&child), Some(dir.path().join(CONFIG_FILE)));
326        }
327
328        #[test]
329        fn stops_at_git_boundary() {
330            let dir = tempfile::tempdir().unwrap();
331            std::fs::write(dir.path().join(CONFIG_FILE), "").unwrap();
332            let project = dir.path().join("project");
333            std::fs::create_dir(&project).unwrap();
334            std::fs::create_dir(project.join(".git")).unwrap();
335            let child = project.join("sub");
336            std::fs::create_dir(&child).unwrap();
337
338            assert_eq!(find_config_file(&child), None);
339        }
340
341        #[test]
342        fn returns_none_when_no_config_found() {
343            let dir = tempfile::tempdir().unwrap();
344            let child = dir.path().join("a/b/c");
345            std::fs::create_dir_all(&child).unwrap();
346
347            assert_eq!(find_config_file(&child), None);
348        }
349    }
350}