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    /// Batch cache-miss candidates whose context windows overlap into a single
160    /// multi-noul request per cluster. `false` falls back to one call per
161    /// candidate.
162    #[default = true]
163    pub batch: bool,
164    /// Maximum number of candidates (nouls) grouped into one batched request.
165    #[default = 8]
166    pub batch_max_questions: usize,
167    /// Maximum number of source lines carried in one batched request `state`.
168    #[default = 200]
169    pub batch_max_state_lines: usize,
170}
171
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
173#[serde(default)]
174pub struct AiConfig {
175    pub enabled: bool,
176    /// Transport: `api` (HTTP) or `cli` (local `claude`).
177    pub provider: AiTransport,
178    /// Vendor used when `provider = "api"`.
179    pub vendor: AiVendor,
180    pub model: Option<String>,
181    /// Maximum number of concurrent LLM calls.
182    #[default = 4]
183    pub concurrency: usize,
184    /// API key. When omitted, read from the vendor's env var
185    /// (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`).
186    pub api_key: Option<String>,
187    /// Optional System One classifier (Jev), on a separate axis from the LLM.
188    pub classifier: ClassifierConfig,
189}
190
191/// Severity escalation: when one rule fires repeatedly in a single file, its
192/// warnings become errors. Opt-in so existing CI results do not change.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
194#[serde(default)]
195pub struct EscalationConfig {
196    /// Off by default.
197    pub enabled: bool,
198    /// Findings of the same rule in the same file needed to escalate.
199    #[default = 5]
200    pub threshold: usize,
201    /// Per-rule thresholds, overriding `threshold`. Keys are rule ids.
202    pub rules: HashMap<String, usize>,
203}
204
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
206#[serde(default)]
207pub struct Config {
208    pub rulesets: RulesetsConfig,
209    pub rules: RulesConfig,
210    pub scan: ScanConfig,
211    pub output: OutputConfig,
212    pub ai: AiConfig,
213    pub escalation: EscalationConfig,
214}
215
216#[derive(Debug, Error)]
217pub enum ConfigError {
218    #[error("failed to read config file '{path}': {source}")]
219    Io {
220        path: String,
221        #[source]
222        source: io::Error,
223    },
224
225    #[error("failed to parse config file '{path}': {source}")]
226    Parse {
227        path: String,
228        #[source]
229        source: de::Error,
230    },
231
232    #[error("invalid merged config: {0}")]
233    Invalid(#[source] de::Error),
234
235    #[error("invalid rule source: {0}")]
236    InvalidSource(String),
237}
238
239/// Validate every `[[rules.sources]]` entry, returning the first invalid one.
240fn validate_sources(config: &Config) -> Result<(), ConfigError> {
241    for source in &config.rules.sources {
242        source.validate().map_err(ConfigError::InvalidSource)?;
243    }
244    Ok(())
245}
246
247/// Overlay `over` onto `base`: nested tables are merged key by key, every
248/// other value in `over` replaces the one in `base`.
249fn merge_tables(base: &mut Table, over: Table) {
250    for (key, over_value) in over {
251        match (base.get_mut(&key), over_value) {
252            (Some(Value::Table(base_table)), Value::Table(over_table)) => {
253                merge_tables(base_table, over_table);
254            }
255            (_, over_value) => {
256                base.insert(key, over_value);
257            }
258        }
259    }
260}
261
262/// Read one config file as a raw table. A missing file yields an empty table.
263/// The file is validated on its own so that errors name the file they come from.
264fn load_table(path: &Path) -> Result<Table, ConfigError> {
265    if !path.is_file() {
266        return Ok(Table::new());
267    }
268    let display = path.display().to_string();
269    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
270        path: display.clone(),
271        source,
272    })?;
273    let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
274        path: display.clone(),
275        source,
276    })?;
277    table
278        .clone()
279        .try_into::<Config>()
280        .map_err(|source| ConfigError::Parse {
281            path: display,
282            source,
283        })?;
284    Ok(table)
285}
286
287/// Load config with explicit paths (for testing).
288pub fn load_config_from(
289    global_dir: Option<&Path>,
290    project_root: &Path,
291) -> Result<Config, ConfigError> {
292    let mut merged = Table::new();
293    if let Some(dir) = global_dir {
294        let global = load_table(&dir.join("slopguard").join("config.toml"))?;
295        merge_tables(&mut merged, global);
296    }
297    let project = load_table(&project_root.join("slopguard.toml"))?;
298    merge_tables(&mut merged, project);
299    let config: Config = merged.try_into().map_err(ConfigError::Invalid)?;
300    validate_sources(&config)?;
301    Ok(config)
302}
303
304/// Load configuration from a single file, skipping hierarchical resolution.
305pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
306    let display = path.display().to_string();
307    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
308        path: display.clone(),
309        source,
310    })?;
311    let config: Config = toml::from_str(&content).map_err(|source| ConfigError::Parse {
312        path: display,
313        source,
314    })?;
315    validate_sources(&config)?;
316    Ok(config)
317}
318
319/// Load configuration with hierarchical resolution.
320///
321/// Resolution order (later overrides earlier):
322/// 1. `~/.config/slopguard/config.toml` (global defaults)
323/// 2. `slopguard.toml` at `project_root` (project overrides)
324pub fn load_config(project_root: &Path) -> Result<Config, ConfigError> {
325    let global_dir = dirs::config_dir();
326    load_config_from(global_dir.as_deref(), project_root)
327}
328
329#[cfg(test)]
330mod tests;