1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use smart_default::SmartDefault;
7use strum::{Display, EnumString};
8use thiserror::Error;
9use toml::de;
10use toml::{Table, Value};
11
12#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
13#[serde(rename_all = "lowercase")]
14#[strum(serialize_all = "lowercase")]
15pub enum OutputFormat {
16 #[default]
17 Text,
18 Json,
19 Sarif,
20}
21
22#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
23#[serde(rename_all = "lowercase")]
24#[strum(serialize_all = "lowercase")]
25pub enum AiProvider {
26 #[default]
27 Anthropic,
28 OpenAI,
29 Ollama,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
33#[serde(default)]
34pub struct RulesetsConfig {
35 #[default = true]
36 pub slop: bool,
37 #[default = true]
38 pub security: bool,
39 #[default = true]
40 pub correctness: bool,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
44#[serde(default)]
45pub struct RulesConfig {
46 pub disable: Vec<String>,
47 pub enable: Vec<String>,
50 pub custom_dirs: Vec<PathBuf>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
54#[serde(default)]
55pub struct ScanConfig {
56 pub ignores: Vec<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub cache_dir: Option<PathBuf>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
62#[serde(default)]
63pub struct OutputConfig {
64 pub format: OutputFormat,
65 #[default = true]
66 pub colors: bool,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
70#[serde(default)]
71pub struct AiConfig {
72 pub enabled: bool,
73 pub provider: Option<AiProvider>,
74 pub model: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
78#[serde(default)]
79pub struct Config {
80 pub rulesets: RulesetsConfig,
81 pub rules: RulesConfig,
82 pub scan: ScanConfig,
83 pub output: OutputConfig,
84 pub ai: AiConfig,
85}
86
87#[derive(Debug, Error)]
88pub enum ConfigError {
89 #[error("failed to read config file '{path}': {source}")]
90 Io {
91 path: String,
92 #[source]
93 source: io::Error,
94 },
95
96 #[error("failed to parse config file '{path}': {source}")]
97 Parse {
98 path: String,
99 #[source]
100 source: de::Error,
101 },
102
103 #[error("invalid merged config: {0}")]
104 Invalid(#[source] de::Error),
105}
106
107fn merge_tables(base: &mut Table, over: Table) {
110 for (key, over_value) in over {
111 match (base.get_mut(&key), over_value) {
112 (Some(Value::Table(base_table)), Value::Table(over_table)) => {
113 merge_tables(base_table, over_table);
114 }
115 (_, over_value) => {
116 base.insert(key, over_value);
117 }
118 }
119 }
120}
121
122fn load_table(path: &Path) -> Result<Table, ConfigError> {
125 if !path.is_file() {
126 return Ok(Table::new());
127 }
128 let display = path.display().to_string();
129 let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
130 path: display.clone(),
131 source,
132 })?;
133 let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
134 path: display.clone(),
135 source,
136 })?;
137 table
138 .clone()
139 .try_into::<Config>()
140 .map_err(|source| ConfigError::Parse {
141 path: display,
142 source,
143 })?;
144 Ok(table)
145}
146
147pub fn load_config_from(
149 global_dir: Option<&Path>,
150 project_root: &Path,
151) -> Result<Config, ConfigError> {
152 let mut merged = Table::new();
153 if let Some(dir) = global_dir {
154 let global = load_table(&dir.join("slopguard").join("config.toml"))?;
155 merge_tables(&mut merged, global);
156 }
157 let project = load_table(&project_root.join("slopguard.toml"))?;
158 merge_tables(&mut merged, project);
159 merged.try_into().map_err(ConfigError::Invalid)
160}
161
162pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
164 let display = path.display().to_string();
165 let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
166 path: display.clone(),
167 source,
168 })?;
169 toml::from_str(&content).map_err(|source| ConfigError::Parse {
170 path: display,
171 source,
172 })
173}
174
175pub fn load_config(project_root: &Path) -> Result<Config, ConfigError> {
181 let global_dir = dirs::config_dir();
182 load_config_from(global_dir.as_deref(), project_root)
183}
184
185#[cfg(test)]
186mod tests {
187 use std::fs::{create_dir_all, write};
188
189 use tempfile::tempdir;
190
191 use super::*;
192
193 #[test]
194 fn parse_complete_config() {
195 let dir = tempdir().unwrap();
196 let toml = r#"
197[rulesets]
198slop = false
199security = true
200correctness = false
201
202[rules]
203disable = ["pub-fn-needs-tracing", "no-glob-reexport"]
204enable = ["test-needs-timeout"]
205custom_dirs = ["./my-rules"]
206
207[scan]
208ignores = ["target/", "generated/"]
209
210[output]
211format = "json"
212colors = false
213
214[ai]
215enabled = true
216provider = "anthropic"
217model = "claude-sonnet-5"
218"#;
219 write(dir.path().join("slopguard.toml"), toml).unwrap();
220
221 let cfg = load_config_from(None, dir.path()).unwrap();
222 assert!(!cfg.rulesets.slop);
223 assert!(cfg.rulesets.security);
224 assert!(!cfg.rulesets.correctness);
225 assert_eq!(
226 cfg.rules.disable,
227 vec!["pub-fn-needs-tracing", "no-glob-reexport"]
228 );
229 assert_eq!(cfg.rules.enable, vec!["test-needs-timeout"]);
230 assert_eq!(cfg.rules.custom_dirs, vec![PathBuf::from("./my-rules")]);
231 assert_eq!(cfg.scan.ignores, vec!["target/", "generated/"]);
232 assert_eq!(cfg.output.format, OutputFormat::Json);
233 assert!(!cfg.output.colors);
234 assert!(cfg.ai.enabled);
235 assert_eq!(cfg.ai.provider, Some(AiProvider::Anthropic));
236 assert_eq!(cfg.ai.model.as_deref(), Some("claude-sonnet-5"));
237 }
238
239 #[test]
240 fn parse_partial_config() {
241 let dir = tempdir().unwrap();
242 let toml = r#"
243[rulesets]
244slop = false
245"#;
246 write(dir.path().join("slopguard.toml"), toml).unwrap();
247
248 let cfg = load_config_from(None, dir.path()).unwrap();
249 assert!(!cfg.rulesets.slop);
250 assert!(cfg.rulesets.security);
251 assert!(cfg.rulesets.correctness);
252 assert!(cfg.rules.disable.is_empty());
253 assert_eq!(cfg.output.format, OutputFormat::Text);
254 assert!(cfg.output.colors);
255 assert!(!cfg.ai.enabled);
256 }
257
258 #[test]
259 fn merge_project_overrides_global() {
260 let global_dir = tempdir().unwrap();
261 let project_dir = tempdir().unwrap();
262
263 let slopguard_config_dir = global_dir.path().join("slopguard");
264 create_dir_all(&slopguard_config_dir).unwrap();
265
266 let global_toml = r#"
267[rulesets]
268slop = false
269security = true
270
271[rules]
272disable = ["no-glob-reexport"]
273
274[output]
275format = "json"
276colors = false
277"#;
278 write(slopguard_config_dir.join("config.toml"), global_toml).unwrap();
279
280 let project_toml = r#"
281[rulesets]
282slop = true
283
284[output]
285format = "sarif"
286"#;
287 write(project_dir.path().join("slopguard.toml"), project_toml).unwrap();
288
289 let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
290
291 assert!(cfg.rulesets.slop);
293 assert!(cfg.rulesets.security);
295 assert!(cfg.rulesets.correctness);
297 assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
299 assert_eq!(cfg.output.format, OutputFormat::Sarif);
301 assert!(!cfg.output.colors);
303 }
304
305 #[test]
306 fn invalid_toml_error() {
307 let dir = tempdir().unwrap();
308 let bad_toml = "this is not [valid toml {{{";
309 write(dir.path().join("slopguard.toml"), bad_toml).unwrap();
310
311 let err = load_config_from(None, dir.path()).unwrap_err();
312 let msg = err.to_string();
313 assert!(
314 msg.contains("slopguard.toml"),
315 "error should mention the file path, got: {msg}"
316 );
317 assert!(
318 matches!(err, ConfigError::Parse { .. }),
319 "expected Parse error, got: {err:?}"
320 );
321 }
322
323 #[test]
324 fn unknown_format_error() {
325 let dir = tempdir().unwrap();
326 let toml = r#"
327[output]
328format = "xml"
329"#;
330 write(dir.path().join("slopguard.toml"), toml).unwrap();
331
332 let err = load_config_from(None, dir.path()).unwrap_err();
333 let msg = err.to_string();
334 assert!(
335 matches!(err, ConfigError::Parse { .. }),
336 "expected Parse error for unknown format, got: {msg}"
337 );
338 assert!(
339 msg.contains("slopguard.toml") && msg.contains("output.format"),
340 "error should name the file and the key, got: {msg}"
341 );
342 }
343
344 #[test]
345 fn load_config_with_temp_dirs() {
346 let global_dir = tempdir().unwrap();
347 let project_dir = tempdir().unwrap();
348
349 let slopguard_config_dir = global_dir.path().join("slopguard");
350 create_dir_all(&slopguard_config_dir).unwrap();
351
352 write(
353 slopguard_config_dir.join("config.toml"),
354 "[rulesets]\nslop = false\n",
355 )
356 .unwrap();
357 write(
358 project_dir.path().join("slopguard.toml"),
359 "[output]\nformat = \"json\"\n",
360 )
361 .unwrap();
362
363 let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
364 assert!(!cfg.rulesets.slop);
365 assert_eq!(cfg.output.format, OutputFormat::Json);
366 assert!(cfg.output.colors);
367 }
368
369 #[test]
370 fn load_config_global_only() {
371 let global_dir = tempdir().unwrap();
372 let project_dir = tempdir().unwrap();
373
374 let slopguard_config_dir = global_dir.path().join("slopguard");
375 create_dir_all(&slopguard_config_dir).unwrap();
376
377 write(
378 slopguard_config_dir.join("config.toml"),
379 "[rulesets]\ncorrectness = false\n\n[output]\nformat = \"sarif\"\n",
380 )
381 .unwrap();
382
383 let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
385 assert!(cfg.rulesets.slop);
386 assert!(cfg.rulesets.security);
387 assert!(!cfg.rulesets.correctness);
388 assert_eq!(cfg.output.format, OutputFormat::Sarif);
389 assert!(cfg.output.colors);
390 }
391
392 #[test]
393 fn default_config() {
394 let cfg = Config::default();
395 assert!(cfg.rulesets.slop);
396 assert!(cfg.rulesets.security);
397 assert!(cfg.rulesets.correctness);
398 assert!(cfg.rules.disable.is_empty());
399 assert!(cfg.rules.custom_dirs.is_empty());
400 assert!(cfg.scan.ignores.is_empty());
401 assert_eq!(cfg.output.format, OutputFormat::Text);
402 assert!(cfg.output.colors);
403 assert!(!cfg.ai.enabled);
404 assert!(cfg.ai.provider.is_none());
405 assert!(cfg.ai.model.is_none());
406 }
407
408 #[test]
409 fn parse_cache_dir() {
410 let dir = tempdir().unwrap();
411 let toml = r#"
412[scan]
413cache_dir = ".cache/slopguard"
414"#;
415 write(dir.path().join("slopguard.toml"), toml).unwrap();
416
417 let cfg = load_config_from(None, dir.path()).unwrap();
418 assert_eq!(cfg.scan.cache_dir, Some(PathBuf::from(".cache/slopguard")));
419 }
420
421 #[test]
422 fn parse_cache_dir_absent() {
423 let dir = tempdir().unwrap();
424 let toml = r#"
425[scan]
426ignores = ["target/"]
427"#;
428 write(dir.path().join("slopguard.toml"), toml).unwrap();
429
430 let cfg = load_config_from(None, dir.path()).unwrap();
431 assert!(cfg.scan.cache_dir.is_none());
432 }
433
434 #[test]
435 fn enum_string_roundtrip() {
436 assert_eq!(OutputFormat::Sarif.to_string(), "sarif");
437 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
438 assert_eq!(AiProvider::OpenAI.to_string(), "openai");
439 assert_eq!("openai".parse::<AiProvider>().unwrap(), AiProvider::OpenAI);
440 }
441}