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#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize, EnumString, Default)]
26#[serde(rename_all = "lowercase")]
27#[strum(serialize_all = "lowercase")]
28pub enum AiTransport {
29 #[default]
31 Api,
32 Cli,
34}
35
36#[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(
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 #[default]
55 Direct,
56 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 pub enable: Vec<String>,
78 pub custom_dirs: Vec<PathBuf>,
79 pub sources: Vec<RuleSource>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
90#[serde(default)]
91pub struct RuleSource {
92 pub git: Option<String>,
94 #[serde(rename = "ref")]
97 pub git_ref: Option<String>,
98 pub path: Option<PathBuf>,
101}
102
103impl RuleSource {
104 pub fn is_git(&self) -> bool {
106 self.git.is_some()
107 }
108
109 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
146#[serde(default)]
147pub struct ClassifierConfig {
148 pub enabled: bool,
150 pub transport: ClassifierTransport,
152 pub model: Option<String>,
155 #[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 pub provider: AiTransport,
167 pub vendor: AiVendor,
169 pub model: Option<String>,
170 #[default = 4]
172 pub concurrency: usize,
173 pub api_key: Option<String>,
176 pub classifier: ClassifierConfig,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
183#[serde(default)]
184pub struct EscalationConfig {
185 pub enabled: bool,
187 #[default = 5]
189 pub threshold: usize,
190 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
228fn 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
236fn 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
251fn 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
276pub 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
293pub 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
308pub 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;