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> {
135 if self.git.as_deref().is_some_and(|git| git.starts_with('-')) {
136 return Err("'git' must not start with '-'".to_string());
137 }
138 if self.git_ref.as_deref().is_some_and(|r| r.starts_with('-')) {
139 return Err("'ref' must not start with '-'".to_string());
140 }
141 match (&self.git, &self.path, &self.git_ref) {
142 (None, None, _) => Err("a rule source needs either 'git' or 'path'".to_string()),
143 (None, Some(_), Some(_)) => {
144 Err("'ref' is only valid on a git source, not a local 'path'".to_string())
145 }
146 _ => Ok(()),
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
152#[serde(default)]
153pub struct ScanConfig {
154 pub ignores: Vec<String>,
155 pub test_paths: Vec<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub cache_dir: Option<PathBuf>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
164#[serde(default)]
165pub struct OutputConfig {
166 pub format: OutputFormat,
167 #[default = true]
168 pub colors: bool,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
175#[serde(default)]
176pub struct ClassifierConfig {
177 pub enabled: bool,
179 pub transport: ClassifierTransport,
181 pub model: Option<String>,
184 #[default = 0.7]
187 pub threshold: f64,
188 #[default = true]
192 pub batch: bool,
193 #[default = 8]
195 pub batch_max_questions: usize,
196 #[default = 200]
198 pub batch_max_state_lines: usize,
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
202#[serde(default)]
203pub struct AiConfig {
204 pub enabled: bool,
205 pub provider: AiTransport,
207 pub vendor: AiVendor,
209 pub model: Option<String>,
210 #[default = 4]
212 pub concurrency: usize,
213 pub api_key: Option<String>,
216 pub classifier: ClassifierConfig,
218 pub allow_external_rules: bool,
222 #[default = 200]
225 pub max_calls: usize,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
230#[serde(default)]
231pub struct GitConfig {
232 pub token_host: Option<String>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
240#[serde(default)]
241pub struct EscalationConfig {
242 pub enabled: bool,
244 #[default = 5]
246 pub threshold: usize,
247 pub rules: HashMap<String, usize>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
257#[serde(default, deny_unknown_fields)]
258pub struct FixConfig {
259 pub allow_external: Vec<String>,
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
264#[serde(default)]
265pub struct Config {
266 pub rulesets: RulesetsConfig,
267 pub rules: RulesConfig,
268 pub scan: ScanConfig,
269 pub output: OutputConfig,
270 pub ai: AiConfig,
271 pub escalation: EscalationConfig,
272 pub git: GitConfig,
273 pub fix: FixConfig,
274}
275
276#[derive(Debug, Error)]
277pub enum ConfigError {
278 #[error("failed to read config file '{path}': {source}")]
279 Io {
280 path: String,
281 #[source]
282 source: io::Error,
283 },
284
285 #[error("failed to parse config file '{path}': {source}")]
286 Parse {
287 path: String,
288 #[source]
289 source: de::Error,
290 },
291
292 #[error("invalid merged config: {0}")]
293 Invalid(#[source] de::Error),
294
295 #[error("invalid rule source: {0}")]
296 InvalidSource(String),
297
298 #[error("[ai].concurrency must be between 1 and {max}, got {0}", max = MAX_AI_CONCURRENCY)]
299 InvalidConcurrency(usize),
300
301 #[error("'{key}' = '{path}' resolves outside the repository root '{root}'")]
302 PathOutsideRepo {
303 key: String,
304 path: String,
305 root: String,
306 },
307}
308
309pub const MAX_AI_CONCURRENCY: usize = 64;
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
318pub enum ProjectTrust {
319 #[default]
320 Untrusted,
321 Trusted,
322}
323
324#[derive(Debug, Clone)]
327pub struct LoadedConfig {
328 pub config: Config,
329 pub warnings: Vec<String>,
330}
331
332fn validate_concurrency(config: &Config) -> Result<(), ConfigError> {
334 let concurrency = config.ai.concurrency;
335 if (1..=MAX_AI_CONCURRENCY).contains(&concurrency) {
336 Ok(())
337 } else {
338 Err(ConfigError::InvalidConcurrency(concurrency))
339 }
340}
341
342const RESERVED_KEYS: [&str; 4] = ["ai", "git", "fix", "scan.cache_dir"];
344
345fn strip_reserved_keys(project: &mut Table) -> Option<String> {
349 let mut removed = Vec::new();
350 for key in RESERVED_KEYS {
351 if let Some(value) = remove_dotted(project, key) {
352 push_leaf_keys(key.to_string(), value, &mut removed);
353 }
354 }
355 if removed.is_empty() {
356 return None;
357 }
358 removed.sort();
359 let keys = removed
360 .iter()
361 .map(|key| format!("'{key}'"))
362 .collect::<Vec<_>>()
363 .join(", ");
364 Some(format!(
365 "ignored {keys} in slopguard.toml: reserved to the user config \
366 (set them in ~/.config/slopguard/config.toml or with CLI flags, or pass --trust-repo-config)"
367 ))
368}
369
370fn remove_dotted(table: &mut Table, path: &str) -> Option<Value> {
372 match path.split_once('.') {
373 None => table.remove(path),
374 Some((head, rest)) => match table.get_mut(head)? {
375 Value::Table(sub) => remove_dotted(sub, rest),
376 _ => None,
377 },
378 }
379}
380
381fn push_leaf_keys(prefix: String, value: Value, out: &mut Vec<String>) {
383 match value {
384 Value::Table(table) => {
385 for (key, sub) in table {
386 push_leaf_keys(format!("{prefix}.{key}"), sub, out);
387 }
388 }
389 _ => out.push(prefix),
390 }
391}
392
393fn check_repo_paths(rules: &RulesConfig, root: &Path) -> Result<(), ConfigError> {
399 let custom_dirs = rules
400 .custom_dirs
401 .iter()
402 .map(|dir| ("rules.custom_dirs", dir.as_path()));
403 let sources = rules
404 .sources
405 .iter()
406 .filter(|source| !source.is_git())
407 .filter_map(|source| source.path.as_deref())
408 .map(|path| ("rules.sources.path", path));
409 let mut paths = custom_dirs.chain(sources).peekable();
410 if paths.peek().is_none() {
411 return Ok(());
412 }
413 let canonical_root = root.canonicalize().map_err(|source| ConfigError::Io {
414 path: root.display().to_string(),
415 source,
416 })?;
417 for (key, path) in paths {
418 let inside = resolves_inside(path, &canonical_root).map_err(|source| ConfigError::Io {
419 path: canonical_root.join(path).display().to_string(),
420 source,
421 })?;
422 if !inside {
423 return Err(ConfigError::PathOutsideRepo {
424 key: key.to_string(),
425 path: path.display().to_string(),
426 root: canonical_root.display().to_string(),
427 });
428 }
429 }
430 Ok(())
431}
432
433fn resolves_inside(path: &Path, root: &Path) -> io::Result<bool> {
439 let joined = root.join(path);
440 match joined.canonicalize() {
441 Ok(canonical) => Ok(canonical.starts_with(root)),
442 Err(err) if err.kind() == io::ErrorKind::NotFound => {
443 let has_parent = path.components().any(|c| c == Component::ParentDir);
444 Ok(!has_parent && joined.starts_with(root))
445 }
446 Err(err) => Err(err),
447 }
448}
449
450fn validate_sources(config: &Config) -> Result<(), ConfigError> {
452 for source in &config.rules.sources {
453 source.validate().map_err(ConfigError::InvalidSource)?;
454 }
455 Ok(())
456}
457
458fn merge_tables(base: &mut Table, over: Table) {
461 for (key, over_value) in over {
462 match (base.get_mut(&key), over_value) {
463 (Some(Value::Table(base_table)), Value::Table(over_table)) => {
464 merge_tables(base_table, over_table);
465 }
466 (_, over_value) => {
467 base.insert(key, over_value);
468 }
469 }
470 }
471}
472
473fn load_table(path: &Path) -> Result<(Table, Config), ConfigError> {
477 if !path.is_file() {
478 return Ok((Table::new(), Config::default()));
479 }
480 let display = path.display().to_string();
481 let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
482 path: display.clone(),
483 source,
484 })?;
485 let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
486 path: display.clone(),
487 source,
488 })?;
489 let config = table
490 .clone()
491 .try_into::<Config>()
492 .map_err(|source| ConfigError::Parse {
493 path: display,
494 source,
495 })?;
496 Ok((table, config))
497}
498
499pub fn load_config_from(
505 global_dir: Option<&Path>,
506 project_root: &Path,
507 trust: ProjectTrust,
508) -> Result<LoadedConfig, ConfigError> {
509 let mut merged = Table::new();
510 if let Some(dir) = global_dir {
511 let (global, _) = load_table(&dir.join("slopguard").join("config.toml"))?;
512 merge_tables(&mut merged, global);
513 }
514 let (mut project, project_config) = load_table(&project_root.join("slopguard.toml"))?;
515 let warnings = match trust {
516 ProjectTrust::Untrusted => {
517 check_repo_paths(&project_config.rules, project_root)?;
518 strip_reserved_keys(&mut project).into_iter().collect()
519 }
520 ProjectTrust::Trusted => Vec::new(),
521 };
522 merge_tables(&mut merged, project);
523 let config: Config = merged.try_into().map_err(ConfigError::Invalid)?;
524 validate_sources(&config)?;
525 validate_concurrency(&config)?;
526 Ok(LoadedConfig { config, warnings })
527}
528
529pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
534 let display = path.display().to_string();
535 let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
536 path: display.clone(),
537 source,
538 })?;
539 let config: Config = toml::from_str(&content).map_err(|source| ConfigError::Parse {
540 path: display,
541 source,
542 })?;
543 validate_sources(&config)?;
544 validate_concurrency(&config)?;
545 Ok(config)
546}
547
548pub fn load_config(project_root: &Path, trust: ProjectTrust) -> Result<LoadedConfig, ConfigError> {
558 let global_dir = dirs::config_dir();
559 load_config_from(global_dir.as_deref(), project_root, trust)
560}
561
562#[cfg(test)]
563mod tests;