Skip to main content

slopguard_core/
config.rs

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/// Transport used to reach the LLM.
25#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
26#[serde(rename_all = "lowercase")]
27#[strum(serialize_all = "lowercase")]
28pub enum AiTransport {
29    /// Direct HTTP calls to a provider API (default).
30    #[default]
31    Api,
32    /// Shell out to the local `claude` CLI (uses the Claude subscription).
33    Cli,
34}
35
36/// Vendor used when `provider = "api"`.
37#[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    /// Rule ids to activate regardless of `disable`, the rulesets switches,
62    /// or the rule's own `enabled: false` (opt-in rules).
63    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    /// Transport: `api` (HTTP) or `cli` (local `claude`).
92    pub provider: AiTransport,
93    /// Vendor used when `provider = "api"`.
94    pub vendor: AiVendor,
95    pub model: Option<String>,
96    /// Maximum number of concurrent LLM calls.
97    #[default = 4]
98    #[serde(default = "default_concurrency")]
99    pub concurrency: usize,
100    /// API key. When omitted, read from the vendor's env var
101    /// (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`).
102    pub api_key: Option<String>,
103}
104
105/// Severity escalation: when one rule fires repeatedly in a single file, its
106/// warnings become errors. Opt-in so existing CI results do not change.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
108#[serde(default)]
109pub struct EscalationConfig {
110    /// Off by default.
111    pub enabled: bool,
112    /// Findings of the same rule in the same file needed to escalate.
113    #[default = 5]
114    pub threshold: usize,
115    /// Per-rule thresholds, overriding `threshold`. Keys are rule ids.
116    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
150/// Overlay `over` onto `base`: nested tables are merged key by key, every
151/// other value in `over` replaces the one in `base`.
152fn 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
165/// Read one config file as a raw table. A missing file yields an empty table.
166/// The file is validated on its own so that errors name the file they come from.
167fn 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
190/// Load config with explicit paths (for testing).
191pub 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
205/// Load configuration from a single file, skipping hierarchical resolution.
206pub 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
218/// Load configuration with hierarchical resolution.
219///
220/// Resolution order (later overrides earlier):
221/// 1. `~/.config/slopguard/config.toml` (global defaults)
222/// 2. `slopguard.toml` at `project_root` (project overrides)
223pub 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        // defaults hold when [ai] is absent
316        assert_eq!(cfg.ai.provider, AiTransport::Api);
317        assert_eq!(cfg.ai.vendor, AiVendor::Anthropic);
318        assert_eq!(cfg.ai.concurrency, 4);
319        // defaults hold when [escalation] is absent
320        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        // project overrides global for slop
359        assert!(cfg.rulesets.slop);
360        // global value kept for security (not in project)
361        assert!(cfg.rulesets.security);
362        // default kept for correctness (in neither)
363        assert!(cfg.rulesets.correctness);
364        // global value kept for rules.disable (not in project)
365        assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
366        // project overrides global for format
367        assert_eq!(cfg.output.format, OutputFormat::Sarif);
368        // global value kept for colors (not in project)
369        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        // No slopguard.toml in project dir
451        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}