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