Skip to main content

slopguard_core/config/
mod.rs

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/// Transport used to reach the LLM.
25#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
26#[serde(rename_all = "lowercase")]
27#[strum(serialize_all = "lowercase")]
28pub enum AiTransport {
29    /// Direct HTTP calls to a provider API (default).
30    #[default]
31    Api,
32    /// Shell out to the local `claude` CLI (uses the Claude subscription).
33    Cli,
34}
35
36/// Vendor used when `provider = "api"`.
37#[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/// How the System One classifier (Jev) reaches the TypeSafe API.
47#[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    /// Native TypeSafe endpoint, authenticated with `TYPESAFE_API_KEY`.
54    #[default]
55    Direct,
56    /// OpenRouter's Decisions endpoint, authenticated with `OPENROUTER_API_KEY`.
57    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    /// Rule ids to activate regardless of `disable`, the rulesets switches,
76    /// or the rule's own `enabled: false` (opt-in rules).
77    pub enable: Vec<String>,
78    pub custom_dirs: Vec<PathBuf>,
79    /// External rule sources: git repositories (cloned and cached) and extra
80    /// local paths. Loaded as custom rules, with their provenance tracked.
81    pub sources: Vec<RuleSource>,
82    /// Per-rule options, one `[rules.options.<id>]` table per rule that reads
83    /// any. An unknown rule id or key is a config error, not a silent no-op.
84    pub options: RuleOptions,
85}
86
87/// The options of the builtin rules that take some, keyed by rule id.
88#[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/// `[rules.options.no-assertion-free-test]`.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
97#[serde(default, deny_unknown_fields)]
98pub struct AssertionFreeTestOptions {
99    /// Bare names of functions, methods or macros that assert but live outside
100    /// the scanned project (test support from a dependency). `*` is a wildcard:
101    /// `["run", "check_*"]`. A test calling one of them is not reported.
102    pub assert_functions: Vec<String>,
103}
104
105/// One external rule source declared as `[[rules.sources]]`.
106///
107/// A source is either a git repository (`git` set, optional `ref` and `path`
108/// sub-directory) or a local directory (`path` set, no `git`). `ref` is only
109/// meaningful for a git source.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
111#[serde(default)]
112pub struct RuleSource {
113    /// Git URL to clone. `None` for a local source.
114    pub git: Option<String>,
115    /// Tag, branch or sha to check out. Defaults to the repository's default
116    /// branch. Only valid alongside `git`.
117    #[serde(rename = "ref")]
118    pub git_ref: Option<String>,
119    /// For a git source, the sub-directory inside the repository to load rules
120    /// from (defaults to the root). For a local source, the directory to load.
121    pub path: Option<PathBuf>,
122}
123
124impl RuleSource {
125    /// Whether this source is a git repository (as opposed to a local path).
126    pub fn is_git(&self) -> bool {
127        self.git.is_some()
128    }
129
130    /// Reject a source that declares neither `git` nor `path`, a `ref`
131    /// without a `git` (a ref is meaningless for a local path), or a `git` or
132    /// `ref` starting with `-`, which git would parse as an option
133    /// (`--upload-pack=...`).
134    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    /// Extra glob patterns marking whole files as test code, on top of the
156    /// built-in layout heuristic. Findings from `skip_test_code` rules are
157    /// suppressed in matching files.
158    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/// The optional System One classifier (Jev / TypeSafe), on a separate axis from
172/// the generative LLM. Off by default: opt-in strict, since Jev is a proprietary
173/// SaaS and slopguard's deterministic AST mode stays the offline socle.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
175#[serde(default)]
176pub struct ClassifierConfig {
177    /// Whether the classifier is active. Never on by default.
178    pub enabled: bool,
179    /// How to reach TypeSafe: `direct` or `openrouter`.
180    pub transport: ClassifierTransport,
181    /// Model route (e.g. `jev-latest` direct, `typesafe/jev-1.13` on OpenRouter).
182    /// When omitted, the transport's default slug is used.
183    pub model: Option<String>,
184    /// Global probability threshold: a candidate fires when `p >= threshold`.
185    /// Overridden per rule by `ai_check.threshold`.
186    #[default = 0.7]
187    pub threshold: f64,
188    /// Batch cache-miss candidates whose context windows overlap into a single
189    /// multi-noul request per cluster. `false` falls back to one call per
190    /// candidate.
191    #[default = true]
192    pub batch: bool,
193    /// Maximum number of candidates (nouls) grouped into one batched request.
194    #[default = 8]
195    pub batch_max_questions: usize,
196    /// Maximum number of source lines carried in one batched request `state`.
197    #[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    /// Transport: `api` (HTTP) or `cli` (local `claude`).
206    pub provider: AiTransport,
207    /// Vendor used when `provider = "api"`.
208    pub vendor: AiVendor,
209    pub model: Option<String>,
210    /// Maximum number of concurrent LLM calls.
211    #[default = 4]
212    pub concurrency: usize,
213    /// API key. When omitted, read from the vendor's env var
214    /// (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`).
215    pub api_key: Option<String>,
216    /// Optional System One classifier (Jev), on a separate axis from the LLM.
217    pub classifier: ClassifierConfig,
218    /// Let `ai_check` rules from external sources (`rules.custom_dirs`,
219    /// `[[rules.sources]]`) send code to the provider. Off by default: such
220    /// rules are skipped, since their prompt is not reviewed by the user.
221    pub allow_external_rules: bool,
222    /// Maximum number of AI candidates verified per scan (cache hits count).
223    /// Candidates beyond it are reported without AI verification.
224    #[default = 200]
225    pub max_calls: usize,
226}
227
228/// `[git]`: how external git rule sources are fetched. User config only.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
230#[serde(default)]
231pub struct GitConfig {
232    /// The only host `SLOPGUARD_GIT_TOKEN` is sent to (overridden by
233    /// `SLOPGUARD_GIT_TOKEN_HOST`). Without a host, no token is injected.
234    pub token_host: Option<String>,
235}
236
237/// Severity escalation: when one rule fires repeatedly in a single file, its
238/// warnings become errors. Opt-in so existing CI results do not change.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
240#[serde(default)]
241pub struct EscalationConfig {
242    /// Off by default.
243    pub enabled: bool,
244    /// Findings of the same rule in the same file needed to escalate.
245    #[default = 5]
246    pub threshold: usize,
247    /// Per-rule thresholds, overriding `threshold`. Keys are rule ids.
248    pub rules: HashMap<String, usize>,
249}
250
251/// `scan --fix` policy. Builtin rules marked `autofix_safe` are always
252/// rewritten; a rule from `rules.custom_dirs` or `[[rules.sources]]` is
253/// rewritten only when its id is listed in `allow_external`, so a third-party
254/// ruleset cannot rewrite source files on its own say-so. User-only: an
255/// untrusted repo `slopguard.toml` cannot set it.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
257#[serde(default, deny_unknown_fields)]
258pub struct FixConfig {
259    /// Ids of external rules whose `autofix_safe` rewrite `--fix` may apply.
260    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
309/// Upper bound of `[ai].concurrency`. Keeps the AI pass far below the
310/// semaphore permit limit and the provider's rate limits.
311pub const MAX_AI_CONCURRENCY: usize = 64;
312
313/// Whether the scanned repository's `slopguard.toml` is trusted.
314///
315/// An untrusted repo file cannot set the user-only keys (`[ai]`, `[git]`, `[fix]`,
316/// `scan.cache_dir`) and its rule paths must stay inside the repository.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
318pub enum ProjectTrust {
319    #[default]
320    Untrusted,
321    Trusted,
322}
323
324/// A resolved config plus the warnings raised while loading it (reserved
325/// keys dropped from an untrusted repo file).
326#[derive(Debug, Clone)]
327pub struct LoadedConfig {
328    pub config: Config,
329    pub warnings: Vec<String>,
330}
331
332/// Reject an `[ai].concurrency` outside `1..=MAX_AI_CONCURRENCY`.
333fn 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
342/// Keys an untrusted repo file cannot set, as dotted paths into its table.
343const RESERVED_KEYS: [&str; 4] = ["ai", "git", "fix", "scan.cache_dir"];
344
345/// Remove the user-only keys from an untrusted repo table and return a single
346/// warning naming every removed leaf key, sorted. Values are never echoed:
347/// `api_key` is a secret.
348fn 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
370/// Remove the value at a dotted `path` (`"scan.cache_dir"`) from `table`.
371fn 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
381/// Push the dotted path of every leaf under `value` (itself when not a table).
382fn 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
393/// Reject a repo-supplied rule path that resolves outside `root`.
394///
395/// Only `rules.custom_dirs` and local `[[rules.sources]]` are checked: the
396/// `path` of a git source is a sub-directory of the clone, not of the
397/// filesystem.
398fn 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
433/// Whether `path`, taken relative to the canonical `root`, stays inside it.
434///
435/// An existing path is canonicalized, so a symlink escaping the root is caught.
436/// A missing one (skipped at load time) gets a lexical check: no `..`
437/// component, and an absolute path must sit under the root.
438fn 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
450/// Validate every `[[rules.sources]]` entry, returning the first invalid one.
451fn 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
458/// Overlay `over` onto `base`: nested tables are merged key by key, every
459/// other value in `over` replaces the one in `base`.
460fn 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
473/// Read one config file as a raw table, plus the config it alone describes.
474/// A missing file yields an empty table and the default config. The file is
475/// validated on its own so that errors name the file they come from.
476fn 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
499/// Load config with explicit paths (for testing).
500///
501/// With [`ProjectTrust::Untrusted`], the repo file's rule paths must stay
502/// inside `project_root` and its user-only keys (`[ai]`, `[git]`, `[fix]`, `scan.cache_dir`)
503/// are dropped with a warning. The global file is always trusted.
504pub 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
529/// Load configuration from a single file, skipping hierarchical resolution.
530///
531/// The file is chosen explicitly by the user (`--config`), so it is trusted:
532/// no key is reserved and rule paths may point anywhere.
533pub 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
548/// Load configuration with hierarchical resolution.
549///
550/// Resolution order (later overrides earlier):
551/// 1. `~/.config/slopguard/config.toml` (global defaults)
552/// 2. `slopguard.toml` at `project_root` (project overrides)
553///
554/// Unless `trust` is [`ProjectTrust::Trusted`], the project file cannot set
555/// the user-only keys (`[ai]`, `[git]`, `[fix]`, `scan.cache_dir`) and its rule paths must stay
556/// inside `project_root`. See [`load_config_from`].
557pub 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;