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