1use std::path::Path;
2
3use provable_contracts::lint::config::{find_config, load_config};
4use provable_contracts::lint::rules::RuleSeverity;
5use provable_contracts::lint::trend;
6use provable_contracts::lint::{run_lint, GateDetail, LintConfig, LintReport};
7
8#[path = "lint_render.rs"]
9mod lint_render;
10
11#[path = "lint_html.rs"]
12mod lint_html;
13
14pub fn explain_rule(rule_id: &str) {
16 lint_render::print_explain(rule_id);
17}
18
19#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
20pub fn run(
21 contract_dir: &Path,
22 binding_path: Option<&Path>,
23 min_score: f64,
24 format: Option<&str>,
25 severity: Option<&str>,
26 strict: bool,
27 suppress: Option<&str>,
28 suppress_rule: Option<&str>,
29 suppress_file: Option<&str>,
30 rule_overrides: &[String],
31 config_path: Option<&Path>,
32 diff_ref: Option<&str>,
33 do_trend: bool,
34 show_trend: bool,
35 no_cache: bool,
36 cache_stats: bool,
37 coverage: bool,
38 min_coverage: Option<f64>,
39 crate_dir: Option<&Path>,
40 min_level: Option<&str>,
41 watch: bool,
42 strict_test_binding: bool,
43) -> Result<(), Box<dyn std::error::Error>> {
44 if watch {
45 return run_watch(
46 contract_dir,
47 binding_path,
48 min_score,
49 format,
50 severity,
51 strict,
52 suppress,
53 suppress_rule,
54 suppress_file,
55 rule_overrides,
56 config_path,
57 no_cache,
58 cache_stats,
59 crate_dir,
60 min_level,
61 strict_test_binding,
62 );
63 }
64
65 if show_trend {
66 show_trend_history(contract_dir);
67 return Ok(());
68 }
69
70 if let Some(base) = diff_ref {
71 if let Some(result) = run_diff_check(contract_dir, base) {
72 return result;
73 }
74 }
75
76 let config = build_config(
77 contract_dir,
78 binding_path,
79 min_score,
80 format,
81 severity,
82 strict,
83 suppress,
84 suppress_rule,
85 suppress_file,
86 rule_overrides,
87 config_path,
88 no_cache,
89 cache_stats,
90 crate_dir,
91 min_level,
92 strict_test_binding,
93 );
94
95 let report = run_lint(&config);
96
97 if cache_stats {
98 print_cache_stats(&report);
99 }
100 if do_trend {
101 record_trend(contract_dir, &report);
102 }
103
104 let effective_format = resolve_format(format, config_path, contract_dir);
105 print_report(&effective_format, &report)?;
106
107 if coverage {
109 let coverage_result = compute_contract_coverage(contract_dir);
110 println!(
111 "\nContract Coverage: {}/{} at Standard+ ({:.1}%)",
112 coverage_result.standard_plus, coverage_result.total, coverage_result.percentage,
113 );
114 if let Some(threshold) = min_coverage {
115 if coverage_result.percentage < threshold {
116 return Err(format!(
117 "contract coverage {:.1}% is below minimum {:.1}%",
118 coverage_result.percentage, threshold,
119 )
120 .into());
121 }
122 }
123 }
124
125 if report.passed {
126 Ok(())
127 } else {
128 let passed_count = report.gates.iter().filter(|g| g.passed).count();
129 Err(format!(
130 "lint failed ({}/{} gates passed)",
131 passed_count,
132 report.gates.len()
133 )
134 .into())
135 }
136}
137
138struct CoverageResult {
139 standard_plus: usize,
140 total: usize,
141 percentage: f64,
142}
143
144fn compute_contract_coverage(contract_dir: &Path) -> CoverageResult {
146 let mut total = 0usize;
147 let mut standard_plus = 0usize;
148
149 let mut yaml_paths = Vec::new();
150 collect_yaml_files_lint(contract_dir, &mut yaml_paths);
151
152 for path in &yaml_paths {
153 let Ok(contract) = provable_contracts::schema::parse_contract(path) else {
154 continue;
155 };
156 if !contract.requires_proofs() {
159 continue;
160 }
161 total += 1;
162 if !contract.falsification_tests.is_empty() && !contract.kani_harnesses.is_empty() {
163 standard_plus += 1;
164 }
165 }
166
167 #[allow(clippy::cast_precision_loss)]
168 let percentage = if total > 0 {
169 (standard_plus as f64 / total as f64) * 100.0
170 } else {
171 100.0
172 };
173
174 CoverageResult {
175 standard_plus,
176 total,
177 percentage,
178 }
179}
180
181fn show_trend_history(contract_dir: &Path) {
182 let trend_root = trend::trend_dir(contract_dir);
183 let snapshots = trend::load_snapshots(&trend_root);
184 if snapshots.is_empty() {
185 println!("No trend data. Run `pv lint --trend` to record snapshots.");
186 } else {
187 println!("{}", trend::format_trend(&snapshots, 30));
188 }
189}
190
191fn run_diff_check(
194 contract_dir: &Path,
195 base: &str,
196) -> Option<Result<(), Box<dyn std::error::Error>>> {
197 match provable_contracts::lint::diff::changed_contracts(contract_dir, base) {
198 Ok(changed) if changed.is_empty() => {
199 println!("No contracts changed since {base}. Nothing to lint.");
200 Some(Ok(()))
201 }
202 Ok(changed) => {
203 println!(
204 "Diff-aware: {} contracts changed since {base}",
205 changed.len()
206 );
207 for stem in &changed {
208 println!(" {stem}");
209 }
210 println!();
211 None
212 }
213 Err(e) => {
214 eprintln!("Warning: diff-aware mode failed ({e}), linting all contracts");
215 None
216 }
217 }
218}
219
220fn print_cache_stats(report: &LintReport) {
221 eprintln!(
222 "Cache: {} total, {} hits, {} misses ({:.0}% hit rate)",
223 report.cache_stats.total,
224 report.cache_stats.hits,
225 report.cache_stats.misses,
226 report.cache_stats.hit_rate() * 100.0,
227 );
228}
229
230fn record_trend(contract_dir: &Path, report: &LintReport) {
231 let trend_root = trend::trend_dir(contract_dir);
232 let contracts_count = count_contracts(report);
233 match trend::record_snapshot(&trend_root, report, contracts_count) {
234 Ok(path) => eprintln!("Trend snapshot saved: {}", path.display()),
235 Err(e) => eprintln!("Warning: failed to save trend snapshot: {e}"),
236 }
237 let snapshots = trend::load_snapshots(&trend_root);
238 if let Some(drop) = trend::detect_drift(&snapshots, 0.05) {
239 eprintln!("Warning: quality drift detected (score dropped {drop:.3})");
240 }
241}
242
243fn print_report(format: &str, report: &LintReport) -> Result<(), Box<dyn std::error::Error>> {
244 match format {
245 "json" => lint_render::print_json(report)?,
246 "sarif" => lint_render::print_sarif(report),
247 "github" => lint_render::print_github(report),
248 "html" => println!("{}", lint_html::render_html(report)),
249 _ => lint_render::print_text(report),
250 }
251 Ok(())
252}
253
254fn count_contracts(report: &LintReport) -> usize {
255 for gate in &report.gates {
256 match &gate.detail {
257 GateDetail::Validate { contracts, .. }
258 | GateDetail::Audit { contracts, .. }
259 | GateDetail::Score { contracts, .. } => return *contracts,
260 GateDetail::Verify { .. }
261 | GateDetail::Enforce { .. }
262 | GateDetail::ReverseCoverage { .. }
263 | GateDetail::Composition { .. }
264 | GateDetail::Skipped { .. } => {}
265 }
266 }
267 0
268}
269
270#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
272fn run_watch(
273 contract_dir: &Path,
274 binding_path: Option<&Path>,
275 min_score: f64,
276 format: Option<&str>,
277 severity: Option<&str>,
278 strict: bool,
279 suppress: Option<&str>,
280 suppress_rule: Option<&str>,
281 suppress_file: Option<&str>,
282 rule_overrides: &[String],
283 config_path: Option<&Path>,
284 no_cache: bool,
285 cache_stats: bool,
286 crate_dir: Option<&Path>,
287 min_level: Option<&str>,
288 strict_test_binding: bool,
289) -> Result<(), Box<dyn std::error::Error>> {
290 loop {
291 let config = build_config(
292 contract_dir,
293 binding_path,
294 min_score,
295 format,
296 severity,
297 strict,
298 suppress,
299 suppress_rule,
300 suppress_file,
301 rule_overrides,
302 config_path,
303 no_cache,
304 cache_stats,
305 crate_dir,
306 min_level,
307 strict_test_binding,
308 );
309
310 let report = run_lint(&config);
311
312 if cache_stats {
313 print_cache_stats(&report);
314 }
315
316 let effective_format = resolve_format(format, config_path, contract_dir);
317 print_report(&effective_format, &report)?;
318
319 println!("\n--- Watching for changes (Ctrl+C to stop) ---\n");
320 std::thread::sleep(std::time::Duration::from_secs(5));
321 }
322}
323
324fn resolve_format(format: Option<&str>, config_path: Option<&Path>, contract_dir: &Path) -> String {
325 if let Some(f) = format {
327 return f.to_string();
328 }
329 let pv_config = config_path
331 .and_then(|cp| load_config(cp).ok())
332 .or_else(|| find_config(contract_dir).and_then(|p| load_config(&p).ok()))
333 .unwrap_or_default();
334 pv_config.output.format.unwrap_or_else(|| "text".into())
335}
336
337#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
338fn build_config<'a>(
339 contract_dir: &'a Path,
340 binding_path: Option<&'a Path>,
341 min_score: f64,
342 _format: Option<&str>,
343 severity: Option<&str>,
344 strict: bool,
345 suppress: Option<&str>,
346 suppress_rule: Option<&str>,
347 suppress_file: Option<&str>,
348 rule_overrides: &[String],
349 config_path: Option<&Path>,
350 no_cache: bool,
351 cache_stats: bool,
352 crate_dir: Option<&'a Path>,
353 min_level: Option<&str>,
354 strict_test_binding: bool,
355) -> LintConfig<'a> {
356 let pv_config = config_path
357 .and_then(|cp| match load_config(cp) {
358 Ok(c) => {
359 if c.lint.min_score.is_none()
360 && !c.lint.strict
361 && c.lint.severity.is_none()
362 && c.lint.rules.is_empty()
363 && c.output.format.is_none()
364 {
365 eprintln!(
366 "Warning: config {} parsed but no lint settings found",
367 cp.display()
368 );
369 }
370 Some(c)
371 }
372 Err(e) => {
373 eprintln!("Warning: failed to load config {}: {e}", cp.display());
374 None
375 }
376 })
377 .or_else(|| find_config(contract_dir).and_then(|p| load_config(&p).ok()))
378 .unwrap_or_default();
379
380 let effective_min_score = if min_score > 0.0 {
381 min_score
382 } else {
383 pv_config.lint.min_score.unwrap_or(0.0)
384 };
385
386 let severity_filter = severity
387 .or(pv_config.lint.severity.as_deref())
388 .and_then(RuleSeverity::from_str_opt);
389
390 let effective_strict = strict || pv_config.lint.strict;
391
392 let suppressed_findings: Vec<String> = parse_csv(suppress)
393 .into_iter()
394 .chain(pv_config.lint.suppress.findings.iter().cloned())
395 .collect();
396 let suppressed_rules: Vec<String> = parse_csv(suppress_rule)
397 .into_iter()
398 .chain(pv_config.lint.suppress.rules.iter().cloned())
399 .collect();
400 let suppressed_files: Vec<String> = parse_csv(suppress_file)
401 .into_iter()
402 .chain(pv_config.lint.suppress.files.iter().cloned())
403 .collect();
404
405 let mut severity_overrides = std::collections::HashMap::new();
406 for entry in &pv_config.lint.rules {
407 if let Some(sev) = RuleSeverity::from_str_opt(entry.1) {
408 severity_overrides.insert(entry.0.clone(), sev);
409 }
410 }
411 for r in rule_overrides {
412 if let Some((id, sev_str)) = r.split_once('=') {
413 if let Some(sev) = RuleSeverity::from_str_opt(sev_str) {
414 severity_overrides.insert(id.to_string(), sev);
415 }
416 }
417 }
418
419 LintConfig {
420 contract_dir,
421 binding_path,
422 min_score: effective_min_score,
423 severity_filter,
424 severity_overrides,
425 suppressed_findings,
426 suppressed_rules,
427 suppressed_files,
428 strict: effective_strict,
429 no_cache,
430 cache_stats,
431 crate_dir,
432 min_level: min_level.and_then(parse_enforcement_level),
433 strict_test_binding,
434 }
435}
436
437fn parse_enforcement_level(s: &str) -> Option<provable_contracts::schema::EnforcementLevel> {
438 use provable_contracts::schema::EnforcementLevel;
439 match s.to_lowercase().as_str() {
440 "basic" => Some(EnforcementLevel::Basic),
441 "standard" => Some(EnforcementLevel::Standard),
442 "strict" => Some(EnforcementLevel::Strict),
443 "proven" => Some(EnforcementLevel::Proven),
444 _ => None,
445 }
446}
447
448fn parse_csv(s: Option<&str>) -> Vec<String> {
449 s.map(|v| {
450 v.split(',')
451 .map(|s| s.trim().to_string())
452 .filter(|s| !s.is_empty())
453 .collect()
454 })
455 .unwrap_or_default()
456}
457
458fn collect_yaml_files_lint(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
460 let Ok(entries) = std::fs::read_dir(dir) else {
461 return;
462 };
463 for entry in entries.flatten() {
464 let path = entry.path();
465 if path.is_dir() {
466 let dirname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
467 if dirname == "kaizen" || dirname == "legacy" || dirname == "pipelines" {
468 continue;
469 }
470 collect_yaml_files_lint(&path, out);
471 } else if path.extension().and_then(|e| e.to_str()) == Some("yaml")
472 && !matches!(
473 path.file_name().and_then(|n| n.to_str()),
474 Some("binding.yaml" | "binding.yml")
475 )
476 {
477 out.push(path);
478 }
479 }
480}