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