1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7use smart_default::SmartDefault;
8use strum::{Display, EnumString};
9use thiserror::Error;
10use toml::de;
11use toml::{Table, Value};
12
13#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
14#[serde(rename_all = "lowercase")]
15#[strum(serialize_all = "lowercase")]
16pub enum OutputFormat {
17 #[default]
18 Text,
19 Json,
20 Sarif,
21 Html,
22}
23
24#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
26#[serde(rename_all = "lowercase")]
27#[strum(serialize_all = "lowercase")]
28pub enum AiTransport {
29 #[default]
31 Api,
32 Cli,
34}
35
36#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
38#[serde(rename_all = "lowercase")]
39#[strum(serialize_all = "lowercase")]
40pub enum AiVendor {
41 #[default]
42 Anthropic,
43 OpenAI,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
47#[serde(default)]
48pub struct RulesetsConfig {
49 #[default = true]
50 pub slop: bool,
51 #[default = true]
52 pub security: bool,
53 #[default = true]
54 pub correctness: bool,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
58#[serde(default)]
59pub struct RulesConfig {
60 pub disable: Vec<String>,
61 pub enable: Vec<String>,
64 pub custom_dirs: Vec<PathBuf>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
68#[serde(default)]
69pub struct ScanConfig {
70 pub ignores: Vec<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub cache_dir: Option<PathBuf>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
76#[serde(default)]
77pub struct OutputConfig {
78 pub format: OutputFormat,
79 #[default = true]
80 pub colors: bool,
81}
82
83fn default_concurrency() -> usize {
84 4
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
88#[serde(default)]
89pub struct AiConfig {
90 pub enabled: bool,
91 pub provider: AiTransport,
93 pub vendor: AiVendor,
95 pub model: Option<String>,
96 #[default = 4]
98 #[serde(default = "default_concurrency")]
99 pub concurrency: usize,
100 pub api_key: Option<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
108#[serde(default)]
109pub struct EscalationConfig {
110 pub enabled: bool,
112 #[default = 5]
114 pub threshold: usize,
115 pub rules: HashMap<String, usize>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
120#[serde(default)]
121pub struct Config {
122 pub rulesets: RulesetsConfig,
123 pub rules: RulesConfig,
124 pub scan: ScanConfig,
125 pub output: OutputConfig,
126 pub ai: AiConfig,
127 pub escalation: EscalationConfig,
128}
129
130#[derive(Debug, Error)]
131pub enum ConfigError {
132 #[error("failed to read config file '{path}': {source}")]
133 Io {
134 path: String,
135 #[source]
136 source: io::Error,
137 },
138
139 #[error("failed to parse config file '{path}': {source}")]
140 Parse {
141 path: String,
142 #[source]
143 source: de::Error,
144 },
145
146 #[error("invalid merged config: {0}")]
147 Invalid(#[source] de::Error),
148}
149
150fn merge_tables(base: &mut Table, over: Table) {
153 for (key, over_value) in over {
154 match (base.get_mut(&key), over_value) {
155 (Some(Value::Table(base_table)), Value::Table(over_table)) => {
156 merge_tables(base_table, over_table);
157 }
158 (_, over_value) => {
159 base.insert(key, over_value);
160 }
161 }
162 }
163}
164
165fn load_table(path: &Path) -> Result<Table, ConfigError> {
168 if !path.is_file() {
169 return Ok(Table::new());
170 }
171 let display = path.display().to_string();
172 let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
173 path: display.clone(),
174 source,
175 })?;
176 let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
177 path: display.clone(),
178 source,
179 })?;
180 table
181 .clone()
182 .try_into::<Config>()
183 .map_err(|source| ConfigError::Parse {
184 path: display,
185 source,
186 })?;
187 Ok(table)
188}
189
190pub fn load_config_from(
192 global_dir: Option<&Path>,
193 project_root: &Path,
194) -> Result<Config, ConfigError> {
195 let mut merged = Table::new();
196 if let Some(dir) = global_dir {
197 let global = load_table(&dir.join("slopguard").join("config.toml"))?;
198 merge_tables(&mut merged, global);
199 }
200 let project = load_table(&project_root.join("slopguard.toml"))?;
201 merge_tables(&mut merged, project);
202 merged.try_into().map_err(ConfigError::Invalid)
203}
204
205pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
207 let display = path.display().to_string();
208 let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
209 path: display.clone(),
210 source,
211 })?;
212 toml::from_str(&content).map_err(|source| ConfigError::Parse {
213 path: display,
214 source,
215 })
216}
217
218pub fn load_config(project_root: &Path) -> Result<Config, ConfigError> {
224 let global_dir = dirs::config_dir();
225 load_config_from(global_dir.as_deref(), project_root)
226}
227
228#[cfg(test)]
229mod tests {
230 use std::fs::{create_dir_all, write};
231
232 use tempfile::tempdir;
233
234 use super::*;
235
236 #[test]
237 fn parse_complete_config() {
238 let dir = tempdir().unwrap();
239 let toml = r#"
240[rulesets]
241slop = false
242security = true
243correctness = false
244
245[rules]
246disable = ["pub-fn-needs-tracing", "no-glob-reexport"]
247enable = ["test-needs-timeout"]
248custom_dirs = ["./my-rules"]
249
250[scan]
251ignores = ["target/", "generated/"]
252
253[output]
254format = "json"
255colors = false
256
257[ai]
258enabled = true
259provider = "cli"
260vendor = "openai"
261model = "claude-sonnet-5"
262concurrency = 8
263api_key = "sk-test"
264
265[escalation]
266enabled = true
267threshold = 3
268
269[escalation.rules]
270no-magic-number = 2
271"#;
272 write(dir.path().join("slopguard.toml"), toml).unwrap();
273
274 let cfg = load_config_from(None, dir.path()).unwrap();
275 assert!(!cfg.rulesets.slop);
276 assert!(cfg.rulesets.security);
277 assert!(!cfg.rulesets.correctness);
278 assert_eq!(
279 cfg.rules.disable,
280 vec!["pub-fn-needs-tracing", "no-glob-reexport"]
281 );
282 assert_eq!(cfg.rules.enable, vec!["test-needs-timeout"]);
283 assert_eq!(cfg.rules.custom_dirs, vec![PathBuf::from("./my-rules")]);
284 assert_eq!(cfg.scan.ignores, vec!["target/", "generated/"]);
285 assert_eq!(cfg.output.format, OutputFormat::Json);
286 assert!(!cfg.output.colors);
287 assert!(cfg.ai.enabled);
288 assert_eq!(cfg.ai.provider, AiTransport::Cli);
289 assert_eq!(cfg.ai.vendor, AiVendor::OpenAI);
290 assert_eq!(cfg.ai.model.as_deref(), Some("claude-sonnet-5"));
291 assert_eq!(cfg.ai.concurrency, 8);
292 assert_eq!(cfg.ai.api_key.as_deref(), Some("sk-test"));
293 assert!(cfg.escalation.enabled);
294 assert_eq!(cfg.escalation.threshold, 3);
295 assert_eq!(cfg.escalation.rules.get("no-magic-number"), Some(&2));
296 }
297
298 #[test]
299 fn parse_partial_config() {
300 let dir = tempdir().unwrap();
301 let toml = r#"
302[rulesets]
303slop = false
304"#;
305 write(dir.path().join("slopguard.toml"), toml).unwrap();
306
307 let cfg = load_config_from(None, dir.path()).unwrap();
308 assert!(!cfg.rulesets.slop);
309 assert!(cfg.rulesets.security);
310 assert!(cfg.rulesets.correctness);
311 assert!(cfg.rules.disable.is_empty());
312 assert_eq!(cfg.output.format, OutputFormat::Text);
313 assert!(cfg.output.colors);
314 assert!(!cfg.ai.enabled);
315 assert_eq!(cfg.ai.provider, AiTransport::Api);
317 assert_eq!(cfg.ai.vendor, AiVendor::Anthropic);
318 assert_eq!(cfg.ai.concurrency, 4);
319 assert!(!cfg.escalation.enabled);
321 assert_eq!(cfg.escalation.threshold, 5);
322 assert!(cfg.escalation.rules.is_empty());
323 }
324
325 #[test]
326 fn merge_project_overrides_global() {
327 let global_dir = tempdir().unwrap();
328 let project_dir = tempdir().unwrap();
329
330 let slopguard_config_dir = global_dir.path().join("slopguard");
331 create_dir_all(&slopguard_config_dir).unwrap();
332
333 let global_toml = r#"
334[rulesets]
335slop = false
336security = true
337
338[rules]
339disable = ["no-glob-reexport"]
340
341[output]
342format = "json"
343colors = false
344"#;
345 write(slopguard_config_dir.join("config.toml"), global_toml).unwrap();
346
347 let project_toml = r#"
348[rulesets]
349slop = true
350
351[output]
352format = "sarif"
353"#;
354 write(project_dir.path().join("slopguard.toml"), project_toml).unwrap();
355
356 let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
357
358 assert!(cfg.rulesets.slop);
360 assert!(cfg.rulesets.security);
362 assert!(cfg.rulesets.correctness);
364 assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
366 assert_eq!(cfg.output.format, OutputFormat::Sarif);
368 assert!(!cfg.output.colors);
370 }
371
372 #[test]
373 fn invalid_toml_error() {
374 let dir = tempdir().unwrap();
375 let bad_toml = "this is not [valid toml {{{";
376 write(dir.path().join("slopguard.toml"), bad_toml).unwrap();
377
378 let err = load_config_from(None, dir.path()).unwrap_err();
379 let msg = err.to_string();
380 assert!(
381 msg.contains("slopguard.toml"),
382 "error should mention the file path, got: {msg}"
383 );
384 assert!(
385 matches!(err, ConfigError::Parse { .. }),
386 "expected Parse error, got: {err:?}"
387 );
388 }
389
390 #[test]
391 fn unknown_format_error() {
392 let dir = tempdir().unwrap();
393 let toml = r#"
394[output]
395format = "xml"
396"#;
397 write(dir.path().join("slopguard.toml"), toml).unwrap();
398
399 let err = load_config_from(None, dir.path()).unwrap_err();
400 let msg = err.to_string();
401 assert!(
402 matches!(err, ConfigError::Parse { .. }),
403 "expected Parse error for unknown format, got: {msg}"
404 );
405 assert!(
406 msg.contains("slopguard.toml") && msg.contains("output.format"),
407 "error should name the file and the key, got: {msg}"
408 );
409 }
410
411 #[test]
412 fn load_config_with_temp_dirs() {
413 let global_dir = tempdir().unwrap();
414 let project_dir = tempdir().unwrap();
415
416 let slopguard_config_dir = global_dir.path().join("slopguard");
417 create_dir_all(&slopguard_config_dir).unwrap();
418
419 write(
420 slopguard_config_dir.join("config.toml"),
421 "[rulesets]\nslop = false\n",
422 )
423 .unwrap();
424 write(
425 project_dir.path().join("slopguard.toml"),
426 "[output]\nformat = \"json\"\n",
427 )
428 .unwrap();
429
430 let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
431 assert!(!cfg.rulesets.slop);
432 assert_eq!(cfg.output.format, OutputFormat::Json);
433 assert!(cfg.output.colors);
434 }
435
436 #[test]
437 fn load_config_global_only() {
438 let global_dir = tempdir().unwrap();
439 let project_dir = tempdir().unwrap();
440
441 let slopguard_config_dir = global_dir.path().join("slopguard");
442 create_dir_all(&slopguard_config_dir).unwrap();
443
444 write(
445 slopguard_config_dir.join("config.toml"),
446 "[rulesets]\ncorrectness = false\n\n[output]\nformat = \"sarif\"\n",
447 )
448 .unwrap();
449
450 let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
452 assert!(cfg.rulesets.slop);
453 assert!(cfg.rulesets.security);
454 assert!(!cfg.rulesets.correctness);
455 assert_eq!(cfg.output.format, OutputFormat::Sarif);
456 assert!(cfg.output.colors);
457 }
458
459 #[test]
460 fn default_config() {
461 let cfg = Config::default();
462 assert!(cfg.rulesets.slop);
463 assert!(cfg.rulesets.security);
464 assert!(cfg.rulesets.correctness);
465 assert!(cfg.rules.disable.is_empty());
466 assert!(cfg.rules.custom_dirs.is_empty());
467 assert!(cfg.scan.ignores.is_empty());
468 assert_eq!(cfg.output.format, OutputFormat::Text);
469 assert!(cfg.output.colors);
470 assert!(!cfg.ai.enabled);
471 assert_eq!(cfg.ai.provider, AiTransport::Api);
472 assert_eq!(cfg.ai.vendor, AiVendor::Anthropic);
473 assert_eq!(cfg.ai.concurrency, 4);
474 assert!(cfg.ai.model.is_none());
475 assert!(cfg.ai.api_key.is_none());
476 assert!(!cfg.escalation.enabled);
477 assert_eq!(cfg.escalation.threshold, 5);
478 assert!(cfg.escalation.rules.is_empty());
479 }
480
481 #[test]
482 fn escalation_section_defaults_threshold() {
483 let dir = tempdir().unwrap();
484 write(
485 dir.path().join("slopguard.toml"),
486 "[escalation]\nenabled = true\n",
487 )
488 .unwrap();
489
490 let cfg = load_config_from(None, dir.path()).unwrap();
491 assert!(cfg.escalation.enabled);
492 assert_eq!(cfg.escalation.threshold, 5);
493 }
494
495 #[test]
496 fn escalation_rules_merge_global_and_project() {
497 let global_dir = tempdir().unwrap();
498 let project_dir = tempdir().unwrap();
499
500 let slopguard_config_dir = global_dir.path().join("slopguard");
501 create_dir_all(&slopguard_config_dir).unwrap();
502
503 write(
504 slopguard_config_dir.join("config.toml"),
505 "[escalation]\nenabled = true\n\n[escalation.rules]\na = 3\n",
506 )
507 .unwrap();
508 write(
509 project_dir.path().join("slopguard.toml"),
510 "[escalation.rules]\nb = 7\n",
511 )
512 .unwrap();
513
514 let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
515 assert!(cfg.escalation.enabled);
516 assert_eq!(cfg.escalation.rules.get("a"), Some(&3));
517 assert_eq!(cfg.escalation.rules.get("b"), Some(&7));
518 }
519
520 #[test]
521 fn invalid_escalation_threshold_is_an_error() {
522 let dir = tempdir().unwrap();
523 write(
524 dir.path().join("slopguard.toml"),
525 "[escalation]\nthreshold = \"five\"\n",
526 )
527 .unwrap();
528
529 let err = load_config_from(None, dir.path()).unwrap_err();
530 let msg = err.to_string();
531 assert!(
532 matches!(err, ConfigError::Parse { .. }),
533 "expected Parse error for a non-numeric threshold, got: {msg}"
534 );
535 assert!(
536 msg.contains("slopguard.toml"),
537 "error should mention the file path, got: {msg}"
538 );
539 }
540
541 #[test]
542 fn parse_cache_dir() {
543 let dir = tempdir().unwrap();
544 let toml = r#"
545[scan]
546cache_dir = ".cache/slopguard"
547"#;
548 write(dir.path().join("slopguard.toml"), toml).unwrap();
549
550 let cfg = load_config_from(None, dir.path()).unwrap();
551 assert_eq!(cfg.scan.cache_dir, Some(PathBuf::from(".cache/slopguard")));
552 }
553
554 #[test]
555 fn parse_cache_dir_absent() {
556 let dir = tempdir().unwrap();
557 let toml = r#"
558[scan]
559ignores = ["target/"]
560"#;
561 write(dir.path().join("slopguard.toml"), toml).unwrap();
562
563 let cfg = load_config_from(None, dir.path()).unwrap();
564 assert!(cfg.scan.cache_dir.is_none());
565 }
566
567 #[test]
568 fn parses_html_output_format() {
569 let dir = tempdir().unwrap();
570 write(
571 dir.path().join("slopguard.toml"),
572 "[output]\nformat = \"html\"\n",
573 )
574 .unwrap();
575
576 let cfg = load_config_from(None, dir.path()).unwrap();
577 assert_eq!(cfg.output.format, OutputFormat::Html);
578 }
579
580 #[test]
581 fn enum_string_roundtrip() {
582 assert_eq!(OutputFormat::Sarif.to_string(), "sarif");
583 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
584 assert_eq!(AiVendor::OpenAI.to_string(), "openai");
585 assert_eq!("openai".parse::<AiVendor>().unwrap(), AiVendor::OpenAI);
586 assert_eq!(AiTransport::Cli.to_string(), "cli");
587 assert_eq!("api".parse::<AiTransport>().unwrap(), AiTransport::Api);
588 }
589}