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#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
25#[serde(rename_all = "lowercase")]
26#[strum(serialize_all = "lowercase")]
27pub enum AiTransport {
28 #[default]
30 Api,
31 Cli,
33}
34
35#[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 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 pub provider: AiTransport,
92 pub vendor: AiVendor,
94 pub model: Option<String>,
95 #[default = 4]
97 #[serde(default = "default_concurrency")]
98 pub concurrency: usize,
99 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
134fn 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
149fn 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
174pub 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
189pub 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
202pub 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 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 assert!(cfg.rulesets.slop);
330 assert!(cfg.rulesets.security);
332 assert!(cfg.rulesets.correctness);
334 assert_eq!(cfg.rules.disable, vec!["no-glob-reexport"]);
336 assert_eq!(cfg.output.format, OutputFormat::Sarif);
338 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 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}