1#![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}