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