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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
47#[serde(default)]
48pub struct RulesetsConfig {
49    #[default = true]
50    pub slop: bool,
51    #[default = true]
52    pub security: bool,
53    #[default = true]
54    pub correctness: bool,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
58#[serde(default)]
59pub struct RulesConfig {
60    pub disable: Vec<String>,
61    /// Rule ids to activate regardless of `disable`, the rulesets switches,
62    /// or the rule's own `enabled: false` (opt-in rules).
63    pub enable: Vec<String>,
64    pub custom_dirs: Vec<PathBuf>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
68#[serde(default)]
69pub struct ScanConfig {
70    pub ignores: Vec<String>,
71    /// Extra glob patterns marking whole files as test code, on top of the
72    /// built-in layout heuristic. Findings from `skip_test_code` rules are
73    /// suppressed in matching files.
74    pub test_paths: Vec<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub cache_dir: Option<PathBuf>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
80#[serde(default)]
81pub struct OutputConfig {
82    pub format: OutputFormat,
83    #[default = true]
84    pub colors: bool,
85}
86
87fn default_concurrency() -> usize {
88    4
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
92#[serde(default)]
93pub struct AiConfig {
94    pub enabled: bool,
95    /// Transport: `api` (HTTP) or `cli` (local `claude`).
96    pub provider: AiTransport,
97    /// Vendor used when `provider = "api"`.
98    pub vendor: AiVendor,
99    pub model: Option<String>,
100    /// Maximum number of concurrent LLM calls.
101    #[default = 4]
102    #[serde(default = "default_concurrency")]
103    pub concurrency: usize,
104    /// API key. When omitted, read from the vendor's env var
105    /// (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`).
106    pub api_key: Option<String>,
107}
108
109/// Severity escalation: when one rule fires repeatedly in a single file, its
110/// warnings become errors. Opt-in so existing CI results do not change.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
112#[serde(default)]
113pub struct EscalationConfig {
114    /// Off by default.
115    pub enabled: bool,
116    /// Findings of the same rule in the same file needed to escalate.
117    #[default = 5]
118    pub threshold: usize,
119    /// Per-rule thresholds, overriding `threshold`. Keys are rule ids.
120    pub rules: HashMap<String, usize>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
124#[serde(default)]
125pub struct Config {
126    pub rulesets: RulesetsConfig,
127    pub rules: RulesConfig,
128    pub scan: ScanConfig,
129    pub output: OutputConfig,
130    pub ai: AiConfig,
131    pub escalation: EscalationConfig,
132}
133
134#[derive(Debug, Error)]
135pub enum ConfigError {
136    #[error("failed to read config file '{path}': {source}")]
137    Io {
138        path: String,
139        #[source]
140        source: io::Error,
141    },
142
143    #[error("failed to parse config file '{path}': {source}")]
144    Parse {
145        path: String,
146        #[source]
147        source: de::Error,
148    },
149
150    #[error("invalid merged config: {0}")]
151    Invalid(#[source] de::Error),
152}
153
154/// Overlay `over` onto `base`: nested tables are merged key by key, every
155/// other value in `over` replaces the one in `base`.
156fn merge_tables(base: &mut Table, over: Table) {
157    for (key, over_value) in over {
158        match (base.get_mut(&key), over_value) {
159            (Some(Value::Table(base_table)), Value::Table(over_table)) => {
160                merge_tables(base_table, over_table);
161            }
162            (_, over_value) => {
163                base.insert(key, over_value);
164            }
165        }
166    }
167}
168
169/// Read one config file as a raw table. A missing file yields an empty table.
170/// The file is validated on its own so that errors name the file they come from.
171fn load_table(path: &Path) -> Result<Table, ConfigError> {
172    if !path.is_file() {
173        return Ok(Table::new());
174    }
175    let display = path.display().to_string();
176    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
177        path: display.clone(),
178        source,
179    })?;
180    let table: Table = toml::from_str(&content).map_err(|source| ConfigError::Parse {
181        path: display.clone(),
182        source,
183    })?;
184    table
185        .clone()
186        .try_into::<Config>()
187        .map_err(|source| ConfigError::Parse {
188            path: display,
189            source,
190        })?;
191    Ok(table)
192}
193
194/// Load config with explicit paths (for testing).
195pub fn load_config_from(
196    global_dir: Option<&Path>,
197    project_root: &Path,
198) -> Result<Config, ConfigError> {
199    let mut merged = Table::new();
200    if let Some(dir) = global_dir {
201        let global = load_table(&dir.join("slopguard").join("config.toml"))?;
202        merge_tables(&mut merged, global);
203    }
204    let project = load_table(&project_root.join("slopguard.toml"))?;
205    merge_tables(&mut merged, project);
206    merged.try_into().map_err(ConfigError::Invalid)
207}
208
209/// Load configuration from a single file, skipping hierarchical resolution.
210pub fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
211    let display = path.display().to_string();
212    let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
213        path: display.clone(),
214        source,
215    })?;
216    toml::from_str(&content).map_err(|source| ConfigError::Parse {
217        path: display,
218        source,
219    })
220}
221
222/// Load configuration with hierarchical resolution.
223///
224/// Resolution order (later overrides earlier):
225/// 1. `~/.config/slopguard/config.toml` (global defaults)
226/// 2. `slopguard.toml` at `project_root` (project overrides)
227pub fn load_config(project_root: &Path) -> Result<Config, ConfigError> {
228    let global_dir = dirs::config_dir();
229    load_config_from(global_dir.as_deref(), project_root)
230}
231
232#[cfg(test)]
233mod tests;