aprender_contracts_cli/commands/
coverage.rs1use std::path::Path;
2
3use provable_contracts::binding::parse_binding;
4use provable_contracts::coverage::{coverage_report, overall_percentage, CoverageReport};
5use provable_contracts::reverse_coverage::reverse_coverage;
6use provable_contracts::schema::{parse_contract, Contract};
7
8pub fn run(
9 contract_dir: &Path,
10 binding_path: Option<&Path>,
11 _show_fuzz: bool,
12 reverse_crate: Option<&Path>,
13 enforcement_crate: Option<&Path>,
14) -> Result<(), Box<dyn std::error::Error>> {
15 if let Some(crate_dir) = reverse_crate {
16 return run_reverse_coverage(crate_dir, binding_path);
17 }
18
19 let binding = match binding_path {
20 Some(bp) => Some(parse_binding(bp)?),
21 None => None,
22 };
23
24 let contracts = load_yaml_contracts(contract_dir);
25 let refs: Vec<(String, &Contract)> = contracts.iter().map(|(s, c)| (s.clone(), c)).collect();
26 let report = coverage_report(&refs, binding.as_ref());
27 let pct = overall_percentage(&report);
28
29 print_coverage_report(&report, pct, binding_path.is_some());
30
31 if let Some(crate_dir) = enforcement_crate {
32 let bp = binding_path.ok_or("--enforcement requires --binding <path>")?;
33 let binding = provable_contracts::binding::parse_binding(bp)?;
34 print_enforcement_report(crate_dir, &binding);
35 }
36
37 Ok(())
38}
39
40fn run_reverse_coverage(
42 crate_dir: &Path,
43 binding_path: Option<&Path>,
44) -> Result<(), Box<dyn std::error::Error>> {
45 let bp = binding_path.ok_or("--reverse requires --binding <path>")?;
46 let report = reverse_coverage(crate_dir, bp);
47 println!("Reverse Coverage Report");
48 println!("=======================");
49 println!(" Public functions: {}", report.total_pub_fns);
50 println!(" Bound (in binding.yaml): {}", report.bound_fns);
51 println!(" Annotated (#[contract]): {}", report.annotated_fns);
52 println!(" Auto-exempt (trivial): {}", report.exempt_fns);
53 println!(" Unbound: {}", report.unbound.len());
54 println!(
55 " Coverage: {:.1}% (bound + exempt / total)",
56 report.coverage_pct
57 );
58
59 let gated_count = report
60 .unbound
61 .iter()
62 .filter(|f| f.feature_gate.is_some())
63 .count();
64 if gated_count > 0 {
65 println!(" Feature-gated: {gated_count} (require --features to test)");
66 }
67
68 print_unbound_functions(&report.unbound);
69 Ok(())
70}
71
72fn print_unbound_functions(unbound: &[provable_contracts::reverse_coverage::PubFn]) {
73 if unbound.is_empty() {
74 return;
75 }
76 println!("\nUnbound functions:");
77 for f in unbound.iter().take(20) {
78 let gate_note = match &f.feature_gate {
79 Some(feat) => format!(" [requires --features {feat}]"),
80 None => String::new(),
81 };
82 println!(" {} ({}:{}){gate_note}", f.path, f.file, f.line);
83 }
84 if unbound.len() > 20 {
85 println!(" ... and {} more", unbound.len() - 20);
86 }
87}
88
89fn load_yaml_contracts(contract_dir: &Path) -> Vec<(String, Contract)> {
91 let mut yaml_paths = Vec::new();
92 collect_yaml_files(contract_dir, &mut yaml_paths);
93
94 let mut contracts = Vec::new();
95 for path in &yaml_paths {
96 let stem = path
97 .file_stem()
98 .and_then(|s| s.to_str())
99 .unwrap_or("unknown")
100 .to_string();
101 match parse_contract(path) {
102 Ok(c) => contracts.push((stem, c)),
103 Err(e) => eprintln!("warning: skipping {}: {e}", path.display()),
104 }
105 }
106 contracts.sort_by(|a, b| a.0.cmp(&b.0));
107 contracts
108}
109
110fn print_coverage_report(report: &CoverageReport, pct: f64, has_binding: bool) {
111 println!("Obligation Coverage Report");
112 println!("==========================");
113 println!();
114
115 for cc in &report.contracts {
116 println!(
117 " {:<35} eq={} ob={} ft={} kani={} impl={}/{}",
118 cc.stem,
119 cc.equations,
120 cc.obligations,
121 cc.falsification_covered,
122 cc.kani_covered,
123 cc.binding_implemented,
124 cc.equations,
125 );
126 }
127
128 println!();
129 println!("Totals:");
130 println!(" Contracts: {}", report.totals.contracts);
131 println!(" Equations: {}", report.totals.equations);
132 println!(" Obligations: {}", report.totals.obligations);
133 println!(
134 " Falsification tests: {}",
135 report.totals.falsification_tests
136 );
137 println!(" Kani harnesses: {}", report.totals.kani_harnesses);
138 if has_binding {
139 println!(
140 " Binding implemented: {}",
141 report.totals.binding_implemented
142 );
143 println!(" Binding partial: {}", report.totals.binding_partial);
144 println!(" Binding missing: {}", report.totals.binding_missing);
145 }
146 println!();
147 println!("Overall obligation coverage: {pct:.1}%");
148}
149
150fn print_enforcement_report(
152 crate_dir: &Path,
153 binding: &provable_contracts::binding::BindingRegistry,
154) {
155 let src_dir = crate_dir.join("src");
156 if !src_dir.exists() {
157 eprintln!("warning: {}/src not found", crate_dir.display());
158 return;
159 }
160
161 let mut call_sites: Vec<CallSite> = Vec::new();
163 scan_call_sites(&src_dir, &mut call_sites);
164
165 let gen_path = src_dir.join("generated_contracts.rs");
167 let gen_content = std::fs::read_to_string(&gen_path).unwrap_or_default();
168
169 for site in &mut call_sites {
171 site.level = classify_macro(&site.macro_name, &gen_content);
172 }
173
174 let total_bindings = binding.bindings.len();
175 let total_sites = call_sites.len();
176 let e0_count = call_sites
177 .iter()
178 .filter(|s| s.level == EnforcementLevel::E0)
179 .count();
180 let e1_count = call_sites
181 .iter()
182 .filter(|s| s.level == EnforcementLevel::E1)
183 .count();
184 let e2_count = call_sites
185 .iter()
186 .filter(|s| s.level == EnforcementLevel::E2)
187 .count();
188
189 #[allow(clippy::cast_precision_loss)]
190 let penetration = if total_bindings > 0 {
191 total_sites as f64 / total_bindings as f64
192 } else {
193 0.0
194 };
195
196 #[allow(clippy::cast_precision_loss)]
197 let quality_score = if total_sites > 0 {
198 (e0_count as f64 * 0.1 + e1_count as f64 * 0.5 + e2_count as f64 * 1.0) / total_sites as f64
199 } else {
200 0.0
201 };
202
203 let enforcement_score = penetration * quality_score;
204
205 println!();
206 println!("Enforcement Quality Report");
207 println!("==========================");
208 println!();
209 println!(" Bindings declared: {total_bindings}");
210 println!(" Call sites found: {total_sites}");
211 println!(
212 " Penetration: {:.1}% ({total_sites}/{total_bindings})",
213 penetration * 100.0
214 );
215 println!();
216 println!(" E0 (generic !is_empty): {e0_count}");
217 println!(" E1 (domain pre-checks): {e1_count}");
218 println!(" E2 (pre + post checks): {e2_count}");
219 println!(" Quality score: {quality_score:.2} (E0=0.1, E1=0.5, E2=1.0)");
220 println!();
221 println!(" Enforcement score: {enforcement_score:.4} (penetration × quality)");
222 println!();
223
224 if !call_sites.is_empty() {
225 println!(" Call sites:");
226 for site in &call_sites {
227 let level_str = match site.level {
228 EnforcementLevel::E0 => "E0",
229 EnforcementLevel::E1 => "E1",
230 EnforcementLevel::E2 => "E2",
231 };
232 println!(
233 " [{level_str}] {}:{} — {}",
234 site.file, site.line, site.macro_name
235 );
236 }
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241enum EnforcementLevel {
242 E0,
243 E1,
244 E2,
245}
246
247struct CallSite {
248 file: String,
249 line: usize,
250 macro_name: String,
251 level: EnforcementLevel,
252}
253
254fn scan_call_sites(dir: &Path, sites: &mut Vec<CallSite>) {
256 let Ok(entries) = std::fs::read_dir(dir) else {
257 return;
258 };
259 for entry in entries.flatten() {
260 let path = entry.path();
261 if path.is_dir() {
262 scan_call_sites(&path, sites);
263 } else if path.extension().and_then(|e| e.to_str()) == Some("rs")
264 && path.file_name().and_then(|n| n.to_str()) != Some("generated_contracts.rs")
265 {
266 let Ok(content) = std::fs::read_to_string(&path) else {
267 continue;
268 };
269 for (i, line) in content.lines().enumerate() {
270 if let Some(pos) = line.find("contract_pre_") {
271 let rest = &line[pos..];
272 let end = rest.find('!').unwrap_or(rest.len());
273 let macro_name = rest[..end].to_string();
274 sites.push(CallSite {
275 file: path.display().to_string(),
276 line: i + 1,
277 macro_name,
278 level: EnforcementLevel::E0,
279 });
280 }
281 }
282 }
283 }
284}
285
286fn classify_macro(macro_name: &str, gen_content: &str) -> EnforcementLevel {
288 let pattern = format!("macro_rules! {macro_name} {{");
290 let Some(start) = gen_content.find(&pattern) else {
291 return EnforcementLevel::E0;
292 };
293 let body: String = gen_content[start..]
295 .lines()
296 .take(20)
297 .collect::<Vec<_>>()
298 .join("\n");
299
300 let has_domain_pre = body.contains("is_finite")
301 || body.contains("len() >")
302 || body.contains("len() %")
303 || body.contains("len() ==")
304 || body.contains("is_empty()");
305
306 let post_name = macro_name.replace("contract_pre_", "contract_post_");
308 let has_post = gen_content.contains(&format!("macro_rules! {post_name} {{"));
309
310 if has_domain_pre && has_post {
311 EnforcementLevel::E2
312 } else if has_domain_pre {
313 EnforcementLevel::E1
314 } else {
315 EnforcementLevel::E0
316 }
317}
318
319fn collect_yaml_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
321 let Ok(entries) = std::fs::read_dir(dir) else {
322 return;
323 };
324 for entry in entries.flatten() {
325 let path = entry.path();
326 if path.is_dir() {
327 let dirname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
328 if dirname == "kaizen" || dirname == "legacy" || dirname == "pipelines" {
329 continue;
330 }
331 collect_yaml_files(&path, out);
332 } else if path.extension().and_then(|e| e.to_str()) == Some("yaml")
333 && path.file_name().and_then(|n| n.to_str()) != Some("binding.yaml")
334 {
335 out.push(path);
336 }
337 }
338}