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}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
60#[serde(default)]
61pub struct OutputConfig {
62    pub format: OutputFormat,
63    #[default = true]
64    pub colors: bool,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
68#[serde(default)]
69pub struct AiConfig {
70    pub enabled: bool,
71    pub provider: Option<AiProvider>,
72    pub model: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
76#[serde(default)]
77pub struct Config {
78    pub rulesets: RulesetsConfig,
79    pub rules: RulesConfig,
80    pub scan: ScanConfig,
81    pub output: OutputConfig,
82    pub ai: AiConfig,
83}
84
85#[derive(Debug, Error)]
86pub enum ConfigError {
87    #[error("failed to read config file '{path}': {source}")]
88    Io {
89        path: String,
90        #[source]
91        source: io::Error,
92    },
93
94    #[error("failed to parse config file '{path}': {source}")]
95    Parse {
96        path: String,
97        #[source]
98        source: de::Error,
99    },
100
101    #[error("invalid merged config: {0}")]
102    Invalid(#[source] de::Error),
103}
104
105/// Overlay `over` onto `base`: nested tables are merged key by key, every
106/// other value in `over` replaces the one in `base`.
107fn merge_tables(base: &mut Table, over: Table) {
108    for (key, over_value) in over {
109        match (base.get_mut(&key), over_value) {
110            (Some(Value::Table(base_table)), Value::Table(over_table)) => {
111                merge_tables(base_table, over_table);
112            }
113            (_, over_value) => {
114                base.insert(key, over_value);
115            }
116        }
117    }
118}
119
120/// Read one config file as a raw table. A missing file yields an empty table.
121/// The file is validated on its own so that errors name the file they come from.
122fn load_table(path: &Path) -> Result<Table, ConfigError> {
123    if !path.is_file() {
124        return Ok(Table::new());
125    }
126    let display = path.display().to_string();
127    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
128        path: display.clone(),
129        source,
130    })?;
131    let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
132        path: display.clone(),
133        source,
134    })?;
135    table
136        .clone()
137        .try_into::<Config>()
138        .map_err(|source| ConfigError::Parse {
139            path: display,
140            source,
141        })?;
142    Ok(table)
143}
144
145/// Load config with explicit paths (for testing).
146pub fn load_config_from(
147    global_dir: Option<&Path>,
148    project_root: &Path,
149) -> Result<Config, ConfigError> {
150    let mut merged = Table::new();
151    if let Some(dir) = global_dir {
152        let global = load_table(&dir.join("slopguard").join("config.toml"))?;
153        merge_tables(&mut merged, global);
154    }
155    let project = load_table(&project_root.join("slopguard.toml"))?;
156    merge_tables(&mut merged, project);
157    merged.try_into().map_err(ConfigError::Invalid)
158}
159
160/// Load configuration from a single file, skipping hierarchical resolution.
161pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
162    let display = path.display().to_string();
163    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
164        path: display.clone(),
165        source,
166    })?;
167    toml::from_str(&content).map_err(|source| ConfigError::Parse {
168        path: display,
169        source,
170    })
171}
172
173/// Load configuration with hierarchical resolution.
174///
175/// Resolution order (later overrides earlier):
176/// 1. `~/.config/slopguard/config.toml` (global defaults)
177/// 2. `slopguard.toml` at `project_root` (project overrides)
178pub fn load_config(project_root: &Path) -> Result<Config, ConfigError> {
179    let global_dir = dirs::config_dir();
180    load_config_from(global_dir.as_deref(), project_root)
181}
182
183#[cfg(test)]
184mod tests {
185    use std::fs::{create_dir_all, write};
186
187    use tempfile::tempdir;
188
189    use super::*;
190
191    #[test]
192    fn parse_complete_config() {
193        let dir = tempdir().unwrap();
194        let toml = r#"
195[rulesets]
196slop = false
197security = true
198correctness = false
199
200[rules]
201disable = ["pub-fn-needs-tracing", "no-glob-reexport"]
202enable = ["test-needs-timeout"]
203custom_dirs = ["./my-rules"]
204
205[scan]
206ignores = ["target/", "generated/"]
207
208[output]
209format = "json"
210colors = false
211
212[ai]
213enabled = true
214provider = "anthropic"
215model = "claude-sonnet-5"
216"#;
217        write(dir.path().join("slopguard.toml"), toml).unwrap();
218
219        let cfg = load_config_from(None, dir.path()).unwrap();
220        assert!(!cfg.rulesets.slop);
221        assert!(cfg.rulesets.security);
222        assert!(!cfg.rulesets.correctness);
223        assert_eq!(
224            cfg.rules.disable,
225            vec!["pub-fn-needs-tracing", "no-glob-reexport"]
226        );
227        assert_eq!(cfg.rules.enable, vec!["test-needs-timeout"]);
228        assert_eq!(cfg.rules.custom_dirs, vec![PathBuf::from("./my-rules")]);
229        assert_eq!(cfg.scan.ignores, vec!["target/", "generated/"]);
230        assert_eq!(cfg.output.format, OutputFormat::Json);
231        assert!(!cfg.output.colors);
232        assert!(cfg.ai.enabled);
233        assert_eq!(cfg.ai.provider, Some(AiProvider::Anthropic));
234        assert_eq!(cfg.ai.model.as_deref(), Some("claude-sonnet-5"));
235    }
236
237    #[test]
238    fn parse_partial_config() {
239        let dir = tempdir().unwrap();
240        let toml = r#"
241[rulesets]
242slop = false
243"#;
244        write(dir.path().join("slopguard.toml"), toml).unwrap();
245
246        let cfg = load_config_from(None, dir.path()).unwrap();
247        assert!(!cfg.rulesets.slop);
248        assert!(cfg.rulesets.security);
249        assert!(cfg.rulesets.correctness);
250        assert!(cfg.rules.disable.is_empty());
251        assert_eq!(cfg.output.format, OutputFormat::Text);
252        assert!(cfg.output.colors);
253        assert!(!cfg.ai.enabled);
254    }
255
256    #[test]
257    fn merge_project_overrides_global() {
258        let global_dir = tempdir().unwrap();
259        let project_dir = tempdir().unwrap();
260
261        let slopguard_config_dir = global_dir.path().join("slopguard");
262        create_dir_all(&slopguard_config_dir).unwrap();
263
264        let global_toml = r#"
265[rulesets]
266slop = false
267security = true
268
269[rules]
270disable = ["no-glob-reexport"]
271
272[output]
273format = "json"
274colors = false
275"#;
276        write(slopguard_config_dir.join("config.toml"), global_toml).unwrap();
277
278        let project_toml = r#"
279[rulesets]
280slop = true
281
282[output]
283format = "sarif"
284"#;
285        write(project_dir.path().join("slopguard.toml"), project_toml).unwrap();
286
287        let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
288
289        // project overrides global for slop
290        assert!(cfg.rulesets.slop);
291        // global value kept for security (not in project)
292        assert!(cfg.rulesets.security);
293        // default kept for correctness (in neither)
294        assert!(cfg.rulesets.correctness);
295        // global value kept for rules.disable (not in project)
296        assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
297        // project overrides global for format
298        assert_eq!(cfg.output.format, OutputFormat::Sarif);
299        // global value kept for colors (not in project)
300        assert!(!cfg.output.colors);
301    }
302
303    #[test]
304    fn invalid_toml_error() {
305        let dir = tempdir().unwrap();
306        let bad_toml = "this is not [valid toml {{{";
307        write(dir.path().join("slopguard.toml"), bad_toml).unwrap();
308
309        let err = load_config_from(None, dir.path()).unwrap_err();
310        let msg = err.to_string();
311        assert!(
312            msg.contains("slopguard.toml"),
313            "error should mention the file path, got: {msg}"
314        );
315        assert!(
316            matches!(err, ConfigError::Parse { .. }),
317            "expected Parse error, got: {err:?}"
318        );
319    }
320
321    #[test]
322    fn unknown_format_error() {
323        let dir = tempdir().unwrap();
324        let toml = r#"
325[output]
326format = "xml"
327"#;
328        write(dir.path().join("slopguard.toml"), toml).unwrap();
329
330        let err = load_config_from(None, dir.path()).unwrap_err();
331        let msg = err.to_string();
332        assert!(
333            matches!(err, ConfigError::Parse { .. }),
334            "expected Parse error for unknown format, got: {msg}"
335        );
336        assert!(
337            msg.contains("slopguard.toml") && msg.contains("output.format"),
338            "error should name the file and the key, got: {msg}"
339        );
340    }
341
342    #[test]
343    fn load_config_with_temp_dirs() {
344        let global_dir = tempdir().unwrap();
345        let project_dir = tempdir().unwrap();
346
347        let slopguard_config_dir = global_dir.path().join("slopguard");
348        create_dir_all(&slopguard_config_dir).unwrap();
349
350        write(
351            slopguard_config_dir.join("config.toml"),
352            "[rulesets]\nslop = false\n",
353        )
354        .unwrap();
355        write(
356            project_dir.path().join("slopguard.toml"),
357            "[output]\nformat = \"json\"\n",
358        )
359        .unwrap();
360
361        let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
362        assert!(!cfg.rulesets.slop);
363        assert_eq!(cfg.output.format, OutputFormat::Json);
364        assert!(cfg.output.colors);
365    }
366
367    #[test]
368    fn load_config_global_only() {
369        let global_dir = tempdir().unwrap();
370        let project_dir = tempdir().unwrap();
371
372        let slopguard_config_dir = global_dir.path().join("slopguard");
373        create_dir_all(&slopguard_config_dir).unwrap();
374
375        write(
376            slopguard_config_dir.join("config.toml"),
377            "[rulesets]\ncorrectness = false\n\n[output]\nformat = \"sarif\"\n",
378        )
379        .unwrap();
380
381        // No slopguard.toml in project dir
382        let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
383        assert!(cfg.rulesets.slop);
384        assert!(cfg.rulesets.security);
385        assert!(!cfg.rulesets.correctness);
386        assert_eq!(cfg.output.format, OutputFormat::Sarif);
387        assert!(cfg.output.colors);
388    }
389
390    #[test]
391    fn default_config() {
392        let cfg = Config::default();
393        assert!(cfg.rulesets.slop);
394        assert!(cfg.rulesets.security);
395        assert!(cfg.rulesets.correctness);
396        assert!(cfg.rules.disable.is_empty());
397        assert!(cfg.rules.custom_dirs.is_empty());
398        assert!(cfg.scan.ignores.is_empty());
399        assert_eq!(cfg.output.format, OutputFormat::Text);
400        assert!(cfg.output.colors);
401        assert!(!cfg.ai.enabled);
402        assert!(cfg.ai.provider.is_none());
403        assert!(cfg.ai.model.is_none());
404    }
405
406    #[test]
407    fn enum_string_roundtrip() {
408        assert_eq!(OutputFormat::Sarif.to_string(), "sarif");
409        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
410        assert_eq!(AiProvider::OpenAI.to_string(), "openai");
411        assert_eq!("openai".parse::<AiProvider>().unwrap(), AiProvider::OpenAI);
412    }
413}