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 pub options: RuleOptions,
85}
86
87#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
97#[serde(default, deny_unknown_fields)]
98pub struct AssertionFreeTestOptions {
99 pub assert_functions: Vec<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
111#[serde(default)]
112pub struct RuleSource {
113 pub git: Option<String>,
115 #[serde(rename = "ref")]
118 pub git_ref: Option<String>,
119 pub path: Option<PathBuf>,
122}
123
124impl RuleSource {
125 pub fn is_git(&self) -> bool {
127 self.git.is_some()
128 }
129
130 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
167#[serde(default)]
168pub struct ClassifierConfig {
169 pub enabled: bool,
171 pub transport: ClassifierTransport,
173 pub model: Option<String>,
176 #[default = 0.7]
179 pub threshold: f64,
180 #[default = true]
184 pub batch: bool,
185 #[default = 8]
187 pub batch_max_questions: usize,
188 #[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 pub provider: AiTransport,
199 pub vendor: AiVendor,
201 pub model: Option<String>,
202 #[default = 4]
204 pub concurrency: usize,
205 pub api_key: Option<String>,
208 pub classifier: ClassifierConfig,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
215#[serde(default)]
216pub struct EscalationConfig {
217 pub enabled: bool,
219 #[default = 5]
221 pub threshold: usize,
222 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
260fn 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
268fn 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
283fn 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
308pub 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
325pub 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
340pub 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;