Skip to main content

provable_contracts/lint/
config.rs

1//! `.pv.toml` configuration file for `pv lint`.
2//!
3//! Search order: `./.pv.toml`, repo root, `$HOME/.config/pv/config.toml`.
4//! CLI flags override config file values.
5//!
6//! Spec: `docs/specifications/sub/lint.md` Section 11
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use super::rules::RuleSeverity;
14
15/// Parsed `.pv.toml` configuration.
16#[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
111/// Resolved rule severity map: rule ID -> effective severity.
112pub 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    // Start with defaults from rule catalog
120    for rule in super::rules::RULES {
121        map.insert(rule.id.to_string(), rule.default_severity);
122    }
123
124    // Apply config file overrides
125    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    // Apply CLI overrides
132    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    // Strict mode: promote warnings to errors
139    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
150/// Search for `.pv.toml` in standard locations.
151pub fn find_config(start: &Path) -> Option<PathBuf> {
152    // 1. Current directory
153    let local = start.join(".pv.toml");
154    if local.is_file() {
155        return Some(local);
156    }
157
158    // 2. Git repo root
159    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    // 3. User config
174    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
184/// Load and parse config from a path.
185pub 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
191/// Parse TOML string into `PvConfig`. Avoids adding `toml` as a dependency
192/// by parsing the subset we need manually.
193fn toml_parse(content: &str) -> PvConfig {
194    // We parse a deliberately limited TOML subset.
195    // Keys we recognise: [lint], [lint.rules], [lint.suppress], [output]
196    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, &current_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    // Strip surrounding quotes
223    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;