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(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 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 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 pub provider: AiTransport,
97 pub vendor: AiVendor,
99 pub model: Option<String>,
100 #[default = 4]
102 #[serde(default = "default_concurrency")]
103 pub concurrency: usize,
104 pub api_key: Option<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)]
112#[serde(default)]
113pub struct EscalationConfig {
114 pub enabled: bool,
116 #[default = 5]
118 pub threshold: usize,
119 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
154fn 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
169fn 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
194pub 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
209pub 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
222pub 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;