1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7use smart_default::SmartDefault;
8use strum::{Display, EnumString};
9use thiserror::Error;
10use toml::de;
11use toml::{Table, Value};
12
13#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
14#[serde(rename_all = "lowercase")]
15#[strum(serialize_all = "lowercase")]
16pub enum OutputFormat {
17 #[default]
18 Text,
19 Json,
20 Sarif,
21 Html,
22}
23
24#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
26#[serde(rename_all = "lowercase")]
27#[strum(serialize_all = "lowercase")]
28pub enum AiTransport {
29 #[default]
31 Api,
32 Cli,
34}
35
36#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
38#[serde(rename_all = "lowercase")]
39#[strum(serialize_all = "lowercase")]
40pub enum AiVendor {
41 #[default]
42 Anthropic,
43 OpenAI,
44}
45
46#[derive(
48 Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumString, Default,
49)]
50#[serde(rename_all = "lowercase")]
51#[strum(serialize_all = "lowercase")]
52pub enum ClassifierTransport {
53 #[default]
55 Direct,
56 Openrouter,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
61#[serde(default)]
62pub struct RulesetsConfig {
63 #[default = true]
64 pub slop: bool,
65 #[default = true]
66 pub security: bool,
67 #[default = true]
68 pub correctness: bool,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
72#[serde(default)]
73pub struct RulesConfig {
74 pub disable: Vec<String>,
75 pub enable: Vec<String>,
78 pub custom_dirs: Vec<PathBuf>,
79 pub sources: Vec<RuleSource>,
82 pub options: RuleOptions,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
89#[serde(default, deny_unknown_fields)]
90pub struct RuleOptions {
91 #[serde(rename = "no-assertion-free-test")]
92 pub no_assertion_free_test: AssertionFreeTestOptions,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
97#[serde(default, deny_unknown_fields)]
98pub struct AssertionFreeTestOptions {
99 pub assert_functions: Vec<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
111#[serde(default)]
112pub struct RuleSource {
113 pub git: Option<String>,
115 #[serde(rename = "ref")]
118 pub git_ref: Option<String>,
119 pub path: Option<PathBuf>,
122}
123
124impl RuleSource {
125 pub fn is_git(&self) -> bool {
127 self.git.is_some()
128 }
129
130 fn validate(&self) -> Result<(), String> {
133 match (&self.git, &self.path, &self.git_ref) {
134 (None, None, _) => Err("a rule source needs either 'git' or 'path'".to_string()),
135 (None, Some(_), Some(_)) => {
136 Err("'ref' is only valid on a git source, not a local 'path'".to_string())
137 }
138 _ => Ok(()),
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
144#[serde(default)]
145pub struct ScanConfig {
146 pub ignores: Vec<String>,
147 pub test_paths: Vec<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub cache_dir: Option<PathBuf>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
156#[serde(default)]
157pub struct OutputConfig {
158 pub format: OutputFormat,
159 #[default = true]
160 pub colors: bool,
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
167#[serde(default)]
168pub struct ClassifierConfig {
169 pub enabled: bool,
171 pub transport: ClassifierTransport,
173 pub model: Option<String>,
176 #[default = 0.7]
179 pub threshold: f64,
180 #[default = true]
184 pub batch: bool,
185 #[default = 8]
187 pub batch_max_questions: usize,
188 #[default = 200]
190 pub batch_max_state_lines: usize,
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
194#[serde(default)]
195pub struct AiConfig {
196 pub enabled: bool,
197 pub provider: AiTransport,
199 pub vendor: AiVendor,
201 pub model: Option<String>,
202 #[default = 4]
204 pub concurrency: usize,
205 pub api_key: Option<String>,
208 pub classifier: ClassifierConfig,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
215#[serde(default)]
216pub struct EscalationConfig {
217 pub enabled: bool,
219 #[default = 5]
221 pub threshold: usize,
222 pub rules: HashMap<String, usize>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
232#[serde(default, deny_unknown_fields)]
233pub struct FixConfig {
234 pub allow_external: Vec<String>,
236}
237
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
239#[serde(default)]
240pub struct Config {
241 pub rulesets: RulesetsConfig,
242 pub rules: RulesConfig,
243 pub scan: ScanConfig,
244 pub output: OutputConfig,
245 pub ai: AiConfig,
246 pub escalation: EscalationConfig,
247 pub fix: FixConfig,
248}
249
250#[derive(Debug, Error)]
251pub enum ConfigError {
252 #[error("failed to read config file '{path}': {source}")]
253 Io {
254 path: String,
255 #[source]
256 source: io::Error,
257 },
258
259 #[error("failed to parse config file '{path}': {source}")]
260 Parse {
261 path: String,
262 #[source]
263 source: de::Error,
264 },
265
266 #[error("invalid merged config: {0}")]
267 Invalid(#[source] de::Error),
268
269 #[error("invalid rule source: {0}")]
270 InvalidSource(String),
271
272 #[error("[ai].concurrency must be between 1 and {max}, got {0}", max = MAX_AI_CONCURRENCY)]
273 InvalidConcurrency(usize),
274
275 #[error("'{key}' = '{path}' resolves outside the repository root '{root}'")]
276 PathOutsideRepo {
277 key: String,
278 path: String,
279 root: String,
280 },
281}
282
283pub const MAX_AI_CONCURRENCY: usize = 64;
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
292pub enum ProjectTrust {
293 #[default]
294 Untrusted,
295 Trusted,
296}
297
298#[derive(Debug, Clone)]
301pub struct LoadedConfig {
302 pub config: Config,
303 pub warnings: Vec<String>,
304}
305
306fn validate_concurrency(config: &Config) -> Result<(), ConfigError> {
308 let concurrency = config.ai.concurrency;
309 if (1..=MAX_AI_CONCURRENCY).contains(&concurrency) {
310 Ok(())
311 } else {
312 Err(ConfigError::InvalidConcurrency(concurrency))
313 }
314}
315
316const RESERVED_KEYS: [&str; 3] = ["ai", "fix", "scan.cache_dir"];
318
319fn strip_reserved_keys(project: &mut Table) -> Option<String> {
323 let mut removed = Vec::new();
324 for key in RESERVED_KEYS {
325 if let Some(value) = remove_dotted(project, key) {
326 push_leaf_keys(key.to_string(), value, &mut removed);
327 }
328 }
329 if removed.is_empty() {
330 return None;
331 }
332 removed.sort();
333 let keys = removed
334 .iter()
335 .map(|key| format!("'{key}'"))
336 .collect::<Vec<_>>()
337 .join(", ");
338 Some(format!(
339 "ignored {keys} in slopguard.toml: reserved to the user config \
340 (set them in ~/.config/slopguard/config.toml or with CLI flags, or pass --trust-repo-config)"
341 ))
342}
343
344fn remove_dotted(table: &mut Table, path: &str) -> Option<Value> {
346 match path.split_once('.') {
347 None => table.remove(path),
348 Some((head, rest)) => match table.get_mut(head)? {
349 Value::Table(sub) => remove_dotted(sub, rest),
350 _ => None,
351 },
352 }
353}
354
355fn push_leaf_keys(prefix: String, value: Value, out: &mut Vec<String>) {
357 match value {
358 Value::Table(table) => {
359 for (key, sub) in table {
360 push_leaf_keys(format!("{prefix}.{key}"), sub, out);
361 }
362 }
363 _ => out.push(prefix),
364 }
365}
366
367fn check_repo_paths(rules: &RulesConfig, root: &Path) -> Result<(), ConfigError> {
373 let custom_dirs = rules
374 .custom_dirs
375 .iter()
376 .map(|dir| ("rules.custom_dirs", dir.as_path()));
377 let sources = rules
378 .sources
379 .iter()
380 .filter(|source| !source.is_git())
381 .filter_map(|source| source.path.as_deref())
382 .map(|path| ("rules.sources.path", path));
383 let mut paths = custom_dirs.chain(sources).peekable();
384 if paths.peek().is_none() {
385 return Ok(());
386 }
387 let canonical_root = root.canonicalize().map_err(|source| ConfigError::Io {
388 path: root.display().to_string(),
389 source,
390 })?;
391 for (key, path) in paths {
392 let inside = resolves_inside(path, &canonical_root).map_err(|source| ConfigError::Io {
393 path: canonical_root.join(path).display().to_string(),
394 source,
395 })?;
396 if !inside {
397 return Err(ConfigError::PathOutsideRepo {
398 key: key.to_string(),
399 path: path.display().to_string(),
400 root: canonical_root.display().to_string(),
401 });
402 }
403 }
404 Ok(())
405}
406
407fn resolves_inside(path: &Path, root: &Path) -> io::Result<bool> {
413 let joined = root.join(path);
414 match joined.canonicalize() {
415 Ok(canonical) => Ok(canonical.starts_with(root)),
416 Err(err) if err.kind() == io::ErrorKind::NotFound => {
417 let has_parent = path.components().any(|c| c == Component::ParentDir);
418 Ok(!has_parent && joined.starts_with(root))
419 }
420 Err(err) => Err(err),
421 }
422}
423
424fn validate_sources(config: &Config) -> Result<(), ConfigError> {
426 for source in &config.rules.sources {
427 source.validate().map_err(ConfigError::InvalidSource)?;
428 }
429 Ok(())
430}
431
432fn merge_tables(base: &mut Table, over: Table) {
435 for (key, over_value) in over {
436 match (base.get_mut(&key), over_value) {
437 (Some(Value::Table(base_table)), Value::Table(over_table)) => {
438 merge_tables(base_table, over_table);
439 }
440 (_, over_value) => {
441 base.insert(key, over_value);
442 }
443 }
444 }
445}
446
447fn load_table(path: &Path) -> Result<(Table, Config), ConfigError> {
451 if !path.is_file() {
452 return Ok((Table::new(), Config::default()));
453 }
454 let display = path.display().to_string();
455 let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
456 path: display.clone(),
457 source,
458 })?;
459 let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
460 path: display.clone(),
461 source,
462 })?;
463 let config = table
464 .clone()
465 .try_into::<Config>()
466 .map_err(|source| ConfigError::Parse {
467 path: display,
468 source,
469 })?;
470 Ok((table, config))
471}
472
473pub fn load_config_from(
479 global_dir: Option<&Path>,
480 project_root: &Path,
481 trust: ProjectTrust,
482) -> Result<LoadedConfig, ConfigError> {
483 let mut merged = Table::new();
484 if let Some(dir) = global_dir {
485 let (global, _) = load_table(&dir.join("slopguard").join("config.toml"))?;
486 merge_tables(&mut merged, global);
487 }
488 let (mut project, project_config) = load_table(&project_root.join("slopguard.toml"))?;
489 let warnings = match trust {
490 ProjectTrust::Untrusted => {
491 check_repo_paths(&project_config.rules, project_root)?;
492 strip_reserved_keys(&mut project).into_iter().collect()
493 }
494 ProjectTrust::Trusted => Vec::new(),
495 };
496 merge_tables(&mut merged, project);
497 let config: Config = merged.try_into().map_err(ConfigError::Invalid)?;
498 validate_sources(&config)?;
499 validate_concurrency(&config)?;
500 Ok(LoadedConfig { config, warnings })
501}
502
503pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
508 let display = path.display().to_string();
509 let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
510 path: display.clone(),
511 source,
512 })?;
513 let config: Config = toml::from_str(&content).map_err(|source| ConfigError::Parse {
514 path: display,
515 source,
516 })?;
517 validate_sources(&config)?;
518 validate_concurrency(&config)?;
519 Ok(config)
520}
521
522pub fn load_config(project_root: &Path, trust: ProjectTrust) -> Result<LoadedConfig, ConfigError> {
532 let global_dir = dirs::config_dir();
533 load_config_from(global_dir.as_deref(), project_root, trust)
534}
535
536#[cfg(test)]
537mod tests;