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}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(default)]
83pub struct ScanConfig {
84 pub ignores: Vec<String>,
85 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SmartDefault)]
105#[serde(default)]
106pub struct ClassifierConfig {
107 pub enabled: bool,
109 pub transport: ClassifierTransport,
111 pub model: Option<String>,
114 #[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 pub provider: AiTransport,
126 pub vendor: AiVendor,
128 pub model: Option<String>,
129 #[default = 4]
131 pub concurrency: usize,
132 pub api_key: Option<String>,
135 pub classifier: ClassifierConfig,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
142#[serde(default)]
143pub struct EscalationConfig {
144 pub enabled: bool,
146 #[default = 5]
148 pub threshold: usize,
149 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
184fn 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
199fn 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
224pub 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
239pub 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
252pub 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;