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 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
105fn 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
120fn 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
145pub 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
160pub 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
173pub 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 assert!(cfg.rulesets.slop);
291 assert!(cfg.rulesets.security);
293 assert!(cfg.rulesets.correctness);
295 assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
297 assert_eq!(cfg.output.format, OutputFormat::Sarif);
299 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 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}