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 #[default = true]
163 pub batch: bool,
164 #[default = 8]
166 pub batch_max_questions: usize,
167 #[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 pub provider: AiTransport,
178 pub vendor: AiVendor,
180 pub model: Option<String>,
181 #[default = 4]
183 pub concurrency: usize,
184 pub api_key: Option<String>,
187 pub classifier: ClassifierConfig,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
194#[serde(default)]
195pub struct EscalationConfig {
196 pub enabled: bool,
198 #[default = 5]
200 pub threshold: usize,
201 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
239fn 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
247fn 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
262fn 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
287pub 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
304pub 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
319pub 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;