Skip to main content

slopguard_core/config/
mod.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::path::{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}
83
84/// One external rule source declared as `[[rules.sources]]`.
85///
86/// A source is either a git repository (`git` set, optional `ref` and `path`
87/// sub-directory) or a local directory (`path` set, no `git`). `ref` is only
88/// meaningful for a git source.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
90#[serde(default)]
91pub struct RuleSource {
92    /// Git URL to clone. `None` for a local source.
93    pub git: Option<String>,
94    /// Tag, branch or sha to check out. Defaults to the repository's default
95    /// branch. Only valid alongside `git`.
96    #[serde(rename = "ref")]
97    pub git_ref: Option<String>,
98    /// For a git source, the sub-directory inside the repository to load rules
99    /// from (defaults to the root). For a local source, the directory to load.
100    pub path: Option<PathBuf>,
101}
102
103impl RuleSource {
104    /// Whether this source is a git repository (as opposed to a local path).
105    pub fn is_git(&self) -> bool {
106        self.git.is_some()
107    }
108
109    /// Reject a source that declares neither `git` nor `path`, or a `ref`
110    /// without a `git` (a ref is meaningless for a local path).
111    fn validate(&self) -> Result<(), String> {
112        match (&self.git, &self.path, &self.git_ref) {
113            (None, None, _) => Err("a rule source needs either 'git' or 'path'".to_string()),
114            (None, Some(_), Some(_)) => {
115                Err("'ref' is only valid on a git source, not a local 'path'".to_string())
116            }
117            _ => Ok(()),
118        }
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
123#[serde(default)]
124pub struct ScanConfig {
125    pub ignores: Vec<String>,
126    /// Extra glob patterns marking whole files as test code, on top of the
127    /// built-in layout heuristic. Findings from `skip_test_code` rules are
128    /// suppressed in matching files.
129    pub test_paths: Vec<String>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub cache_dir: Option<PathBuf>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
135#[serde(default)]
136pub struct OutputConfig {
137    pub format: OutputFormat,
138    #[default = true]
139    pub colors: bool,
140}
141
142/// The optional System One classifier (Jev / TypeSafe), on a separate axis from
143/// the generative LLM. Off by default: opt-in strict, since Jev is a proprietary
144/// SaaS and slopguard's deterministic AST mode stays the offline socle.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
146#[serde(default)]
147pub struct ClassifierConfig {
148    /// Whether the classifier is active. Never on by default.
149    pub enabled: bool,
150    /// How to reach TypeSafe: `direct` or `openrouter`.
151    pub transport: ClassifierTransport,
152    /// Model route (e.g. `jev-latest` direct, `typesafe/jev-1.13` on OpenRouter).
153    /// When omitted, the transport's default slug is used.
154    pub model: Option<String>,
155    /// Global probability threshold: a candidate fires when `p >= threshold`.
156    /// Overridden per rule by `ai_check.threshold`.
157    #[default = 0.7]
158    pub threshold: f64,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
162#[serde(default)]
163pub struct AiConfig {
164    pub enabled: bool,
165    /// Transport: `api` (HTTP) or `cli` (local `claude`).
166    pub provider: AiTransport,
167    /// Vendor used when `provider = "api"`.
168    pub vendor: AiVendor,
169    pub model: Option<String>,
170    /// Maximum number of concurrent LLM calls.
171    #[default = 4]
172    pub concurrency: usize,
173    /// API key. When omitted, read from the vendor's env var
174    /// (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`).
175    pub api_key: Option<String>,
176    /// Optional System One classifier (Jev), on a separate axis from the LLM.
177    pub classifier: ClassifierConfig,
178}
179
180/// Severity escalation: when one rule fires repeatedly in a single file, its
181/// warnings become errors. Opt-in so existing CI results do not change.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
183#[serde(default)]
184pub struct EscalationConfig {
185    /// Off by default.
186    pub enabled: bool,
187    /// Findings of the same rule in the same file needed to escalate.
188    #[default = 5]
189    pub threshold: usize,
190    /// Per-rule thresholds, overriding `threshold`. Keys are rule ids.
191    pub rules: HashMap<String, usize>,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
195#[serde(default)]
196pub struct Config {
197    pub rulesets: RulesetsConfig,
198    pub rules: RulesConfig,
199    pub scan: ScanConfig,
200    pub output: OutputConfig,
201    pub ai: AiConfig,
202    pub escalation: EscalationConfig,
203}
204
205#[derive(Debug, Error)]
206pub enum ConfigError {
207    #[error("failed to read config file '{path}': {source}")]
208    Io {
209        path: String,
210        #[source]
211        source: io::Error,
212    },
213
214    #[error("failed to parse config file '{path}': {source}")]
215    Parse {
216        path: String,
217        #[source]
218        source: de::Error,
219    },
220
221    #[error("invalid merged config: {0}")]
222    Invalid(#[source] de::Error),
223
224    #[error("invalid rule source: {0}")]
225    InvalidSource(String),
226}
227
228/// Validate every `[[rules.sources]]` entry, returning the first invalid one.
229fn validate_sources(config: &Config) -> Result<(), ConfigError> {
230    for source in &config.rules.sources {
231        source.validate().map_err(ConfigError::InvalidSource)?;
232    }
233    Ok(())
234}
235
236/// Overlay `over` onto `base`: nested tables are merged key by key, every
237/// other value in `over` replaces the one in `base`.
238fn merge_tables(base: &mut Table, over: Table) {
239    for (key, over_value) in over {
240        match (base.get_mut(&key), over_value) {
241            (Some(Value::Table(base_table)), Value::Table(over_table)) => {
242                merge_tables(base_table, over_table);
243            }
244            (_, over_value) => {
245                base.insert(key, over_value);
246            }
247        }
248    }
249}
250
251/// Read one config file as a raw table. A missing file yields an empty table.
252/// The file is validated on its own so that errors name the file they come from.
253fn load_table(path: &Path) -> Result<Table, ConfigError> {
254    if !path.is_file() {
255        return Ok(Table::new());
256    }
257    let display = path.display().to_string();
258    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
259        path: display.clone(),
260        source,
261    })?;
262    let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
263        path: display.clone(),
264        source,
265    })?;
266    table
267        .clone()
268        .try_into::<Config>()
269        .map_err(|source| ConfigError::Parse {
270            path: display,
271            source,
272        })?;
273    Ok(table)
274}
275
276/// Load config with explicit paths (for testing).
277pub fn load_config_from(
278    global_dir: Option<&Path>,
279    project_root: &Path,
280) -> Result<Config, ConfigError> {
281    let mut merged = Table::new();
282    if let Some(dir) = global_dir {
283        let global = load_table(&dir.join("slopguard").join("config.toml"))?;
284        merge_tables(&mut merged, global);
285    }
286    let project = load_table(&project_root.join("slopguard.toml"))?;
287    merge_tables(&mut merged, project);
288    let config: Config = merged.try_into().map_err(ConfigError::Invalid)?;
289    validate_sources(&config)?;
290    Ok(config)
291}
292
293/// Load configuration from a single file, skipping hierarchical resolution.
294pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
295    let display = path.display().to_string();
296    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
297        path: display.clone(),
298        source,
299    })?;
300    let config: Config = toml::from_str(&content).map_err(|source| ConfigError::Parse {
301        path: display,
302        source,
303    })?;
304    validate_sources(&config)?;
305    Ok(config)
306}
307
308/// Load configuration with hierarchical resolution.
309///
310/// Resolution order (later overrides earlier):
311/// 1. `~/.config/slopguard/config.toml` (global defaults)
312/// 2. `slopguard.toml` at `project_root` (project overrides)
313pub fn load_config(project_root: &Path) -> Result<Config, ConfigError> {
314    let global_dir = dirs::config_dir();
315    load_config_from(global_dir.as_deref(), project_root)
316}
317
318#[cfg(test)]
319mod tests;