1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use super::rules::RuleSeverity;
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct PvConfig {
18 #[serde(default)]
19 pub lint: LintSection,
20 #[serde(default)]
21 pub output: OutputSection,
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct LintSection {
26 pub min_score: Option<f64>,
27 pub severity: Option<String>,
28 #[serde(default)]
29 pub strict: bool,
30 pub contracts_dir: Option<String>,
31 pub binding: Option<String>,
32 #[serde(default)]
33 pub rules: HashMap<String, String>,
34 #[serde(default)]
35 pub suppress: SuppressSection,
36 #[serde(default)]
37 pub diff: DiffSection,
38 #[serde(default)]
39 pub trend: TrendSection,
40 #[serde(default)]
41 pub cache: CacheSection,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct SuppressSection {
46 #[serde(default)]
47 pub findings: Vec<String>,
48 #[serde(default)]
49 pub rules: Vec<String>,
50 #[serde(default)]
51 pub files: Vec<String>,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct DiffSection {
56 pub base_ref: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct TrendSection {
61 #[serde(default = "default_true")]
62 pub enabled: bool,
63 #[serde(default = "default_retention_days")]
64 pub retention_days: u32,
65 #[serde(default = "default_drift_threshold")]
66 pub drift_threshold: f64,
67}
68
69impl Default for TrendSection {
70 fn default() -> Self {
71 Self {
72 enabled: true,
73 retention_days: 90,
74 drift_threshold: 0.05,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct CacheSection {
81 #[serde(default = "default_true")]
82 pub enabled: bool,
83 pub dir: Option<String>,
84}
85
86impl Default for CacheSection {
87 fn default() -> Self {
88 Self {
89 enabled: true,
90 dir: None,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct OutputSection {
97 pub format: Option<String>,
98 pub color: Option<String>,
99}
100
101fn default_true() -> bool {
102 true
103}
104fn default_retention_days() -> u32 {
105 90
106}
107fn default_drift_threshold() -> f64 {
108 0.05
109}
110
111pub fn resolve_rule_severities(
113 config: &PvConfig,
114 cli_overrides: &[(String, String)],
115 strict: bool,
116) -> HashMap<String, RuleSeverity> {
117 let mut map = HashMap::new();
118
119 for rule in super::rules::RULES {
121 map.insert(rule.id.to_string(), rule.default_severity);
122 }
123
124 for (id, sev_str) in &config.lint.rules {
126 if let Some(sev) = RuleSeverity::from_str_opt(sev_str) {
127 map.insert(id.clone(), sev);
128 }
129 }
130
131 for (id, sev_str) in cli_overrides {
133 if let Some(sev) = RuleSeverity::from_str_opt(sev_str) {
134 map.insert(id.clone(), sev);
135 }
136 }
137
138 if strict || config.lint.strict {
140 for sev in map.values_mut() {
141 if *sev == RuleSeverity::Warning {
142 *sev = RuleSeverity::Error;
143 }
144 }
145 }
146
147 map
148}
149
150pub fn find_config(start: &Path) -> Option<PathBuf> {
152 let local = start.join(".pv.toml");
154 if local.is_file() {
155 return Some(local);
156 }
157
158 if let Ok(output) = std::process::Command::new("git")
160 .args(["rev-parse", "--show-toplevel"])
161 .current_dir(start)
162 .output()
163 {
164 if output.status.success() {
165 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
166 let repo_config = PathBuf::from(&root).join(".pv.toml");
167 if repo_config.is_file() {
168 return Some(repo_config);
169 }
170 }
171 }
172
173 if let Some(home) = std::env::var_os("HOME") {
175 let user_config = PathBuf::from(home).join(".config/pv/config.toml");
176 if user_config.is_file() {
177 return Some(user_config);
178 }
179 }
180
181 None
182}
183
184pub fn load_config(path: &Path) -> Result<PvConfig, String> {
186 let content = std::fs::read_to_string(path)
187 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
188 Ok(toml_parse(&content))
189}
190
191fn toml_parse(content: &str) -> PvConfig {
194 let mut config = PvConfig::default();
197 let mut current_section = String::new();
198
199 for line in content.lines() {
200 let trimmed = line.trim();
201 if trimmed.is_empty() || trimmed.starts_with('#') {
202 continue;
203 }
204
205 if trimmed.starts_with('[') && trimmed.ends_with(']') {
206 current_section = trimmed[1..trimmed.len() - 1].to_string();
207 continue;
208 }
209
210 if let Some((key, val)) = parse_kv(trimmed) {
211 apply_kv(&mut config, ¤t_section, &key, &val);
212 }
213 }
214
215 config
216}
217
218fn parse_kv(line: &str) -> Option<(String, String)> {
219 let mut parts = line.splitn(2, '=');
220 let key = parts.next()?.trim().to_string();
221 let val = parts.next()?.trim().to_string();
222 let val = val.trim_matches('"').trim_matches('\'').to_string();
224 Some((key, val))
225}
226
227fn apply_kv(config: &mut PvConfig, section: &str, key: &str, val: &str) {
228 match section {
229 "lint" => match key {
230 "min_score" => config.lint.min_score = val.parse().ok(),
231 "severity" => config.lint.severity = Some(val.to_string()),
232 "strict" => config.lint.strict = val == "true",
233 "contracts_dir" => config.lint.contracts_dir = Some(val.to_string()),
234 "binding" => config.lint.binding = Some(val.to_string()),
235 _ => {}
236 },
237 "lint.rules" => {
238 config.lint.rules.insert(key.to_string(), val.to_string());
239 }
240 "lint.suppress" => match key {
241 "findings" => config.lint.suppress.findings = parse_toml_array(val),
242 "rules" => config.lint.suppress.rules = parse_toml_array(val),
243 "files" => config.lint.suppress.files = parse_toml_array(val),
244 _ => {}
245 },
246 "lint.diff" => {
247 if key == "base_ref" {
248 config.lint.diff.base_ref = Some(val.to_string());
249 }
250 }
251 "lint.trend" => match key {
252 "enabled" => config.lint.trend.enabled = val == "true",
253 "retention_days" => config.lint.trend.retention_days = val.parse().unwrap_or(90),
254 "drift_threshold" => config.lint.trend.drift_threshold = val.parse().unwrap_or(0.05),
255 _ => {}
256 },
257 "lint.cache" => match key {
258 "enabled" => config.lint.cache.enabled = val == "true",
259 "dir" => config.lint.cache.dir = Some(val.to_string()),
260 _ => {}
261 },
262 "output" => match key {
263 "format" => config.output.format = Some(val.to_string()),
264 "color" => config.output.color = Some(val.to_string()),
265 _ => {}
266 },
267 _ => {}
268 }
269}
270
271fn parse_toml_array(val: &str) -> Vec<String> {
272 let trimmed = val.trim_start_matches('[').trim_end_matches(']');
273 trimmed
274 .split(',')
275 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
276 .filter(|s| !s.is_empty())
277 .collect()
278}
279
280#[cfg(test)]
281#[path = "config_tests.rs"]
282mod tests;