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    Html,
21}
22
23/// Transport used to reach the LLM.
24#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
25#[serde(rename_all = "lowercase")]
26#[strum(serialize_all = "lowercase")]
27pub enum AiTransport {
28    /// Direct HTTP calls to a provider API (default).
29    #[default]
30    Api,
31    /// Shell out to the local `claude` CLI (uses the Claude subscription).
32    Cli,
33}
34
35/// Vendor used when `provider = "api"`.
36#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
37#[serde(rename_all = "lowercase")]
38#[strum(serialize_all = "lowercase")]
39pub enum AiVendor {
40    #[default]
41    Anthropic,
42    OpenAI,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
46#[serde(default)]
47pub struct RulesetsConfig {
48    #[default = true]
49    pub slop: bool,
50    #[default = true]
51    pub security: bool,
52    #[default = true]
53    pub correctness: bool,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
57#[serde(default)]
58pub struct RulesConfig {
59    pub disable: Vec<String>,
60    /// Rule ids to activate regardless of `disable`, the rulesets switches,
61    /// or the rule's own `enabled: false` (opt-in rules).
62    pub enable: Vec<String>,
63    pub custom_dirs: Vec<PathBuf>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
67#[serde(default)]
68pub struct ScanConfig {
69    pub ignores: Vec<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub cache_dir: Option<PathBuf>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
75#[serde(default)]
76pub struct OutputConfig {
77    pub format: OutputFormat,
78    #[default = true]
79    pub colors: bool,
80}
81
82fn default_concurrency() -> usize {
83    4
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
87#[serde(default)]
88pub struct AiConfig {
89    pub enabled: bool,
90    /// Transport: `api` (HTTP) or `cli` (local `claude`).
91    pub provider: AiTransport,
92    /// Vendor used when `provider = "api"`.
93    pub vendor: AiVendor,
94    pub model: Option<String>,
95    /// Maximum number of concurrent LLM calls.
96    #[default = 4]
97    #[serde(default = "default_concurrency")]
98    pub concurrency: usize,
99    /// API key. When omitted, read from the vendor's env var
100    /// (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`).
101    pub api_key: Option<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
105#[serde(default)]
106pub struct Config {
107    pub rulesets: RulesetsConfig,
108    pub rules: RulesConfig,
109    pub scan: ScanConfig,
110    pub output: OutputConfig,
111    pub ai: AiConfig,
112}
113
114#[derive(Debug, Error)]
115pub enum ConfigError {
116    #[error("failed to read config file '{path}': {source}")]
117    Io {
118        path: String,
119        #[source]
120        source: io::Error,
121    },
122
123    #[error("failed to parse config file '{path}': {source}")]
124    Parse {
125        path: String,
126        #[source]
127        source: de::Error,
128    },
129
130    #[error("invalid merged config: {0}")]
131    Invalid(#[source] de::Error),
132}
133
134/// Overlay `over` onto `base`: nested tables are merged key by key, every
135/// other value in `over` replaces the one in `base`.
136fn merge_tables(base: &mut Table, over: Table) {
137    for (key, over_value) in over {
138        match (base.get_mut(&key), over_value) {
139            (Some(Value::Table(base_table)), Value::Table(over_table)) => {
140                merge_tables(base_table, over_table);
141            }
142            (_, over_value) => {
143                base.insert(key, over_value);
144            }
145        }
146    }
147}
148
149/// Read one config file as a raw table. A missing file yields an empty table.
150/// The file is validated on its own so that errors name the file they come from.
151fn load_table(path: &Path) -> Result<Table, ConfigError> {
152    if !path.is_file() {
153        return Ok(Table::new());
154    }
155    let display = path.display().to_string();
156    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
157        path: display.clone(),
158        source,
159    })?;
160    let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
161        path: display.clone(),
162        source,
163    })?;
164    table
165        .clone()
166        .try_into::<Config>()
167        .map_err(|source| ConfigError::Parse {
168            path: display,
169            source,
170        })?;
171    Ok(table)
172}
173
174/// Load config with explicit paths (for testing).
175pub fn load_config_from(
176    global_dir: Option<&Path>,
177    project_root: &Path,
178) -> Result<Config, ConfigError> {
179    let mut merged = Table::new();
180    if let Some(dir) = global_dir {
181        let global = load_table(&dir.join("slopguard").join("config.toml"))?;
182        merge_tables(&mut merged, global);
183    }
184    let project = load_table(&project_root.join("slopguard.toml"))?;
185    merge_tables(&mut merged, project);
186    merged.try_into().map_err(ConfigError::Invalid)
187}
188
189/// Load configuration from a single file, skipping hierarchical resolution.
190pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
191    let display = path.display().to_string();
192    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
193        path: display.clone(),
194        source,
195    })?;
196    toml::from_str(&content).map_err(|source| ConfigError::Parse {
197        path: display,
198        source,
199    })
200}
201
202/// Load configuration with hierarchical resolution.
203///
204/// Resolution order (later overrides earlier):
205/// 1. `~/.config/slopguard/config.toml` (global defaults)
206/// 2. `slopguard.toml` at `project_root` (project overrides)
207pub fn load_config(project_root: &Path) -> Result<Config, ConfigError> {
208    let global_dir = dirs::config_dir();
209    load_config_from(global_dir.as_deref(), project_root)
210}
211
212#[cfg(test)]
213mod tests {
214    use std::fs::{create_dir_all, write};
215
216    use tempfile::tempdir;
217
218    use super::*;
219
220    #[test]
221    fn parse_complete_config() {
222        let dir = tempdir().unwrap();
223        let toml = r#"
224[rulesets]
225slop = false
226security = true
227correctness = false
228
229[rules]
230disable = ["pub-fn-needs-tracing", "no-glob-reexport"]
231enable = ["test-needs-timeout"]
232custom_dirs = ["./my-rules"]
233
234[scan]
235ignores = ["target/", "generated/"]
236
237[output]
238format = "json"
239colors = false
240
241[ai]
242enabled = true
243provider = "cli"
244vendor = "openai"
245model = "claude-sonnet-5"
246concurrency = 8
247api_key = "sk-test"
248"#;
249        write(dir.path().join("slopguard.toml"), toml).unwrap();
250
251        let cfg = load_config_from(None, dir.path()).unwrap();
252        assert!(!cfg.rulesets.slop);
253        assert!(cfg.rulesets.security);
254        assert!(!cfg.rulesets.correctness);
255        assert_eq!(
256            cfg.rules.disable,
257            vec!["pub-fn-needs-tracing", "no-glob-reexport"]
258        );
259        assert_eq!(cfg.rules.enable, vec!["test-needs-timeout"]);
260        assert_eq!(cfg.rules.custom_dirs, vec![PathBuf::from("./my-rules")]);
261        assert_eq!(cfg.scan.ignores, vec!["target/", "generated/"]);
262        assert_eq!(cfg.output.format, OutputFormat::Json);
263        assert!(!cfg.output.colors);
264        assert!(cfg.ai.enabled);
265        assert_eq!(cfg.ai.provider, AiTransport::Cli);
266        assert_eq!(cfg.ai.vendor, AiVendor::OpenAI);
267        assert_eq!(cfg.ai.model.as_deref(), Some("claude-sonnet-5"));
268        assert_eq!(cfg.ai.concurrency, 8);
269        assert_eq!(cfg.ai.api_key.as_deref(), Some("sk-test"));
270    }
271
272    #[test]
273    fn parse_partial_config() {
274        let dir = tempdir().unwrap();
275        let toml = r#"
276[rulesets]
277slop = false
278"#;
279        write(dir.path().join("slopguard.toml"), toml).unwrap();
280
281        let cfg = load_config_from(None, dir.path()).unwrap();
282        assert!(!cfg.rulesets.slop);
283        assert!(cfg.rulesets.security);
284        assert!(cfg.rulesets.correctness);
285        assert!(cfg.rules.disable.is_empty());
286        assert_eq!(cfg.output.format, OutputFormat::Text);
287        assert!(cfg.output.colors);
288        assert!(!cfg.ai.enabled);
289        // defaults hold when [ai] is absent
290        assert_eq!(cfg.ai.provider, AiTransport::Api);
291        assert_eq!(cfg.ai.vendor, AiVendor::Anthropic);
292        assert_eq!(cfg.ai.concurrency, 4);
293    }
294
295    #[test]
296    fn merge_project_overrides_global() {
297        let global_dir = tempdir().unwrap();
298        let project_dir = tempdir().unwrap();
299
300        let slopguard_config_dir = global_dir.path().join("slopguard");
301        create_dir_all(&slopguard_config_dir).unwrap();
302
303        let global_toml = r#"
304[rulesets]
305slop = false
306security = true
307
308[rules]
309disable = ["no-glob-reexport"]
310
311[output]
312format = "json"
313colors = false
314"#;
315        write(slopguard_config_dir.join("config.toml"), global_toml).unwrap();
316
317        let project_toml = r#"
318[rulesets]
319slop = true
320
321[output]
322format = "sarif"
323"#;
324        write(project_dir.path().join("slopguard.toml"), project_toml).unwrap();
325
326        let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
327
328        // project overrides global for slop
329        assert!(cfg.rulesets.slop);
330        // global value kept for security (not in project)
331        assert!(cfg.rulesets.security);
332        // default kept for correctness (in neither)
333        assert!(cfg.rulesets.correctness);
334        // global value kept for rules.disable (not in project)
335        assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
336        // project overrides global for format
337        assert_eq!(cfg.output.format, OutputFormat::Sarif);
338        // global value kept for colors (not in project)
339        assert!(!cfg.output.colors);
340    }
341
342    #[test]
343    fn invalid_toml_error() {
344        let dir = tempdir().unwrap();
345        let bad_toml = "this is not [valid toml {{{";
346        write(dir.path().join("slopguard.toml"), bad_toml).unwrap();
347
348        let err = load_config_from(None, dir.path()).unwrap_err();
349        let msg = err.to_string();
350        assert!(
351            msg.contains("slopguard.toml"),
352            "error should mention the file path, got: {msg}"
353        );
354        assert!(
355            matches!(err, ConfigError::Parse { .. }),
356            "expected Parse error, got: {err:?}"
357        );
358    }
359
360    #[test]
361    fn unknown_format_error() {
362        let dir = tempdir().unwrap();
363        let toml = r#"
364[output]
365format = "xml"
366"#;
367        write(dir.path().join("slopguard.toml"), toml).unwrap();
368
369        let err = load_config_from(None, dir.path()).unwrap_err();
370        let msg = err.to_string();
371        assert!(
372            matches!(err, ConfigError::Parse { .. }),
373            "expected Parse error for unknown format, got: {msg}"
374        );
375        assert!(
376            msg.contains("slopguard.toml") && msg.contains("output.format"),
377            "error should name the file and the key, got: {msg}"
378        );
379    }
380
381    #[test]
382    fn load_config_with_temp_dirs() {
383        let global_dir = tempdir().unwrap();
384        let project_dir = tempdir().unwrap();
385
386        let slopguard_config_dir = global_dir.path().join("slopguard");
387        create_dir_all(&slopguard_config_dir).unwrap();
388
389        write(
390            slopguard_config_dir.join("config.toml"),
391            "[rulesets]\nslop = false\n",
392        )
393        .unwrap();
394        write(
395            project_dir.path().join("slopguard.toml"),
396            "[output]\nformat = \"json\"\n",
397        )
398        .unwrap();
399
400        let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
401        assert!(!cfg.rulesets.slop);
402        assert_eq!(cfg.output.format, OutputFormat::Json);
403        assert!(cfg.output.colors);
404    }
405
406    #[test]
407    fn load_config_global_only() {
408        let global_dir = tempdir().unwrap();
409        let project_dir = tempdir().unwrap();
410
411        let slopguard_config_dir = global_dir.path().join("slopguard");
412        create_dir_all(&slopguard_config_dir).unwrap();
413
414        write(
415            slopguard_config_dir.join("config.toml"),
416            "[rulesets]\ncorrectness = false\n\n[output]\nformat = \"sarif\"\n",
417        )
418        .unwrap();
419
420        // No slopguard.toml in project dir
421        let cfg = load_config_from(Some(global_dir.path()), project_dir.path()).unwrap();
422        assert!(cfg.rulesets.slop);
423        assert!(cfg.rulesets.security);
424        assert!(!cfg.rulesets.correctness);
425        assert_eq!(cfg.output.format, OutputFormat::Sarif);
426        assert!(cfg.output.colors);
427    }
428
429    #[test]
430    fn default_config() {
431        let cfg = Config::default();
432        assert!(cfg.rulesets.slop);
433        assert!(cfg.rulesets.security);
434        assert!(cfg.rulesets.correctness);
435        assert!(cfg.rules.disable.is_empty());
436        assert!(cfg.rules.custom_dirs.is_empty());
437        assert!(cfg.scan.ignores.is_empty());
438        assert_eq!(cfg.output.format, OutputFormat::Text);
439        assert!(cfg.output.colors);
440        assert!(!cfg.ai.enabled);
441        assert_eq!(cfg.ai.provider, AiTransport::Api);
442        assert_eq!(cfg.ai.vendor, AiVendor::Anthropic);
443        assert_eq!(cfg.ai.concurrency, 4);
444        assert!(cfg.ai.model.is_none());
445        assert!(cfg.ai.api_key.is_none());
446    }
447
448    #[test]
449    fn parse_cache_dir() {
450        let dir = tempdir().unwrap();
451        let toml = r#"
452[scan]
453cache_dir = ".cache/slopguard"
454"#;
455        write(dir.path().join("slopguard.toml"), toml).unwrap();
456
457        let cfg = load_config_from(None, dir.path()).unwrap();
458        assert_eq!(cfg.scan.cache_dir, Some(PathBuf::from(".cache/slopguard")));
459    }
460
461    #[test]
462    fn parse_cache_dir_absent() {
463        let dir = tempdir().unwrap();
464        let toml = r#"
465[scan]
466ignores = ["target/"]
467"#;
468        write(dir.path().join("slopguard.toml"), toml).unwrap();
469
470        let cfg = load_config_from(None, dir.path()).unwrap();
471        assert!(cfg.scan.cache_dir.is_none());
472    }
473
474    #[test]
475    fn parses_html_output_format() {
476        let dir = tempdir().unwrap();
477        write(
478            dir.path().join("slopguard.toml"),
479            "[output]\nformat = \"html\"\n",
480        )
481        .unwrap();
482
483        let cfg = load_config_from(None, dir.path()).unwrap();
484        assert_eq!(cfg.output.format, OutputFormat::Html);
485    }
486
487    #[test]
488    fn enum_string_roundtrip() {
489        assert_eq!(OutputFormat::Sarif.to_string(), "sarif");
490        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
491        assert_eq!(AiVendor::OpenAI.to_string(), "openai");
492        assert_eq!("openai".parse::<AiVendor>().unwrap(), AiVendor::OpenAI);
493        assert_eq!(AiTransport::Cli.to_string(), "cli");
494        assert_eq!("api".parse::<AiTransport>().unwrap(), AiTransport::Api);
495    }
496}