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
102fn 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
117fn 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
142pub 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
157pub 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
170pub 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 assert!(cfg.rulesets.slop);
286 assert!(cfg.rulesets.security);
288 assert!(cfg.rulesets.correctness);
290 assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
292 assert_eq!(cfg.output.format, OutputFormat::Sarif);
294 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 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}