Skip to main content

slopguard_core/
config.rs

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/// Transport used to reach the LLM.
23#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
24#[serde(rename_all = "lowercase")]
25#[strum(serialize_all = "lowercase")]
26pub enum AiTransport {
27    /// Direct HTTP calls to a provider API (default).
28    #[default]
29    Api,
30    /// Shell out to the local `claude` CLI (uses the Claude subscription).
31    Cli,
32}
33
34/// Vendor used when `provider = "api"`.
35#[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    /// Rule ids to activate regardless of `disable`, the rulesets switches,
60    /// or the rule's own `enabled: false` (opt-in rules).
61    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    /// Transport: `api` (HTTP) or `cli` (local `claude`).
90    pub provider: AiTransport,
91    /// Vendor used when `provider = "api"`.
92    pub vendor: AiVendor,
93    pub model: Option<String>,
94    /// Maximum number of concurrent LLM calls.
95    #[default = 4]
96    #[serde(default = "default_concurrency")]
97    pub concurrency: usize,
98    /// API key. When omitted, read from the vendor's env var
99    /// (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`).
100    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
133/// Overlay `over` onto `base`: nested tables are merged key by key, every
134/// other value in `over` replaces the one in `base`.
135fn 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
148/// Read one config file as a raw table. A missing file yields an empty table.
149/// The file is validated on its own so that errors name the file they come from.
150fn 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
173/// Load config with explicit paths (for testing).
174pub 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
188/// Load configuration from a single file, skipping hierarchical resolution.
189pub 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
201/// Load configuration with hierarchical resolution.
202///
203/// Resolution order (later overrides earlier):
204/// 1. `~/.config/slopguard/config.toml` (global defaults)
205/// 2. `slopguard.toml` at `project_root` (project overrides)
206pub 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        // defaults hold when [ai] is absent
289        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        // project overrides global for slop
328        assert!(cfg.rulesets.slop);
329        // global value kept for security (not in project)
330        assert!(cfg.rulesets.security);
331        // default kept for correctness (in neither)
332        assert!(cfg.rulesets.correctness);
333        // global value kept for rules.disable (not in project)
334        assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
335        // project overrides global for format
336        assert_eq!(cfg.output.format, OutputFormat::Sarif);
337        // global value kept for colors (not in project)
338        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        // No slopguard.toml in project dir
420        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}