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#[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    /// Rule ids to activate regardless of `disable`, the rulesets switches,
48    /// or the rule's own `enabled: false` (opt-in rules).
49    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
107/// Overlay `over` onto `base`: nested tables are merged key by key, every
108/// other value in `over` replaces the one in `base`.
109fn 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
122/// Read one config file as a raw table. A missing file yields an empty table.
123/// The file is validated on its own so that errors name the file they come from.
124fn 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
147/// Load config with explicit paths (for testing).
148pub 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
162/// Load configuration from a single file, skipping hierarchical resolution.
163pub 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
175/// Load configuration with hierarchical resolution.
176///
177/// Resolution order (later overrides earlier):
178/// 1. `~/.config/slopguard/config.toml` (global defaults)
179/// 2. `slopguard.toml` at `project_root` (project overrides)
180pub 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        // project overrides global for slop
292        assert!(cfg.rulesets.slop);
293        // global value kept for security (not in project)
294        assert!(cfg.rulesets.security);
295        // default kept for correctness (in neither)
296        assert!(cfg.rulesets.correctness);
297        // global value kept for rules.disable (not in project)
298        assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
299        // project overrides global for format
300        assert_eq!(cfg.output.format, OutputFormat::Sarif);
301        // global value kept for colors (not in project)
302        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        // No slopguard.toml in project dir
384        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}