Skip to main content

aprender_contracts_cli/commands/
coverage.rs

1use 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
40/// Run `--reverse` mode: list public functions not covered by `binding.yaml`.
41fn 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
89/// Load, parse, and sort all contract `.yaml` files under `contract_dir`.
90fn 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
150/// Scan crate source for contract call sites and classify enforcement quality.
151fn 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    // Find all contract_pre_* call sites in .rs files
162    let mut call_sites: Vec<CallSite> = Vec::new();
163    scan_call_sites(&src_dir, &mut call_sites);
164
165    // Read generated_contracts.rs to classify macro quality
166    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    // Classify each call site
170    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
254/// Recursively scan `.rs` files for `contract_pre_*` and `contract_post_*` invocations.
255fn 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
286/// Classify a macro's enforcement level by inspecting its body in `generated_contracts.rs`.
287fn classify_macro(macro_name: &str, gen_content: &str) -> EnforcementLevel {
288    // Find the macro definition
289    let pattern = format!("macro_rules! {macro_name} {{");
290    let Some(start) = gen_content.find(&pattern) else {
291        return EnforcementLevel::E0;
292    };
293    // Extract the macro body (next ~20 lines)
294    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    // Check if there's a corresponding post macro
307    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
319/// Recursively collect `.yaml` contract files, skipping non-contract directories.
320fn 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}