Skip to main content

provable_contracts/
reverse_coverage.rs

1//! Reverse coverage: detect public functions without contract bindings.
2//!
3//! Forward coverage checks: does every binding have an implementation?
4//! Reverse coverage checks: does every implementation have a binding?
5//!
6//! This closes the "whack-a-mole" gap where new functions escape the
7//! contract system silently.
8
9use std::collections::HashSet;
10use std::path::Path;
11
12/// A public function found in a crate's source code.
13#[derive(Debug, Clone)]
14pub struct PubFn {
15    /// Fully qualified path (e.g., `aprender::nn::ssm::ssm_scan`)
16    pub path: String,
17    /// File where the function is defined
18    pub file: String,
19    /// Line number
20    pub line: usize,
21    /// Whether it has a #[contract] annotation
22    pub has_contract_macro: bool,
23    /// Feature gate if the function is behind `#[cfg(feature = "...")]`
24    pub feature_gate: Option<String>,
25}
26
27/// Result of reverse coverage analysis.
28#[derive(Debug)]
29pub struct ReverseCoverageReport {
30    /// Total public functions found in the crate
31    pub total_pub_fns: usize,
32    /// Functions that have a binding entry
33    pub bound_fns: usize,
34    /// Functions that have a #[contract] annotation
35    pub annotated_fns: usize,
36    /// Functions marked exempt (trivial, don't need contracts)
37    pub exempt_fns: usize,
38    /// Functions without any binding
39    pub unbound: Vec<PubFn>,
40    /// Reverse coverage percentage (bound + exempt / total)
41    pub coverage_pct: f64,
42}
43
44/// Scan a crate directory for `pub fn` declarations and diff against binding.yaml.
45pub fn reverse_coverage(crate_dir: &Path, binding_path: &Path) -> ReverseCoverageReport {
46    let bound_names = extract_bound_functions(binding_path);
47    let pub_fns = scan_pub_fns(crate_dir);
48
49    let total = pub_fns.len();
50    let mut bound = 0usize;
51    let mut annotated = 0usize;
52    let mut exempt = 0usize;
53    let mut unbound = Vec::new();
54
55    for f in &pub_fns {
56        let fn_name = f.path.rsplit("::").next().unwrap_or(&f.path).to_lowercase();
57
58        if f.has_contract_macro {
59            annotated += 1;
60            bound += 1;
61        } else if bound_names.contains(&fn_name) {
62            bound += 1;
63        } else if crate::auto_exempt::is_auto_exempt(&fn_name) {
64            exempt += 1;
65        } else {
66            unbound.push(f.clone());
67        }
68    }
69
70    let covered = bound + exempt;
71    let coverage_pct = if total > 0 {
72        #[allow(clippy::cast_precision_loss)]
73        {
74            (covered as f64 / total as f64) * 100.0
75        }
76    } else {
77        100.0
78    };
79
80    ReverseCoverageReport {
81        total_pub_fns: total,
82        bound_fns: bound,
83        annotated_fns: annotated,
84        exempt_fns: exempt,
85        unbound,
86        coverage_pct,
87    }
88}
89
90/// Extract function names from binding.yaml.
91fn extract_bound_functions(binding_path: &Path) -> HashSet<String> {
92    let mut names = HashSet::new();
93    if let Ok(content) = std::fs::read_to_string(binding_path) {
94        for line in content.lines() {
95            let trimmed = line.trim();
96            let func_line = trimmed.strip_prefix("- ").unwrap_or(trimmed);
97            if let Some(rest) = func_line.strip_prefix("function:") {
98                let fname = rest.trim().trim_matches('"').trim_matches('\'').trim();
99                let short = fname.rsplit("::").next().unwrap_or(fname).to_lowercase();
100                names.insert(short);
101            }
102        }
103    }
104    names
105}
106
107/// Scan .rs files for `pub fn` declarations.
108fn scan_pub_fns(crate_dir: &Path) -> Vec<PubFn> {
109    let mut results = Vec::new();
110    let src_dirs = [crate_dir.join("src"), crate_dir.join("crates")];
111    for dir in &src_dirs {
112        if dir.exists() {
113            scan_dir(dir, &mut results);
114        }
115    }
116    results
117}
118
119fn scan_dir(dir: &Path, results: &mut Vec<PubFn>) {
120    let Ok(entries) = std::fs::read_dir(dir) else {
121        return;
122    };
123    for entry in entries.flatten() {
124        let path = entry.path();
125        if path.is_dir() {
126            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
127            if name != "target" && name != "tests" && name != ".git" {
128                scan_dir(&path, results);
129            }
130        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
131            scan_file(&path, results);
132        }
133    }
134}
135
136fn scan_file(path: &Path, results: &mut Vec<PubFn>) {
137    let Ok(content) = std::fs::read_to_string(path) else {
138        return;
139    };
140    let file_str = path.display().to_string();
141    let mut prev_line_has_contract = false;
142    // Track feature gates from #[cfg(feature = "...")] or #[cfg(all(test, feature = "..."))]
143    let mut module_feature_gate: Option<String> = None;
144    let mut pending_feature_gate: Option<String> = None;
145    let mut brace_depth: usize = 0;
146    let mut gate_depth: Option<usize> = None;
147
148    for (i, line) in content.lines().enumerate() {
149        let trimmed = line.trim();
150
151        // Track brace depth for module-level cfg gates
152        for ch in trimmed.chars() {
153            if ch == '{' {
154                brace_depth += 1;
155            } else if ch == '}' {
156                brace_depth = brace_depth.saturating_sub(1);
157                // If we exit the gated block, clear the module gate
158                if gate_depth.is_some_and(|d| brace_depth < d) {
159                    module_feature_gate = None;
160                    gate_depth = None;
161                }
162            }
163        }
164
165        // Detect #[cfg(feature = "...")] on functions or modules
166        if trimmed.starts_with("#[cfg(") {
167            if let Some(feat) = extract_feature_gate(trimmed) {
168                pending_feature_gate = Some(feat);
169                continue;
170            }
171        }
172
173        // Detect cfg-gated module declarations: `mod foo {`
174        if trimmed.starts_with("mod ") && pending_feature_gate.is_some() {
175            if trimmed.contains('{') {
176                module_feature_gate = pending_feature_gate.take();
177                gate_depth = Some(brace_depth);
178            }
179            pending_feature_gate = None;
180            continue;
181        }
182
183        if trimmed.contains("#[contract(") {
184            prev_line_has_contract = true;
185            continue;
186        }
187
188        if trimmed.starts_with("pub fn ") || trimmed.starts_with("pub async fn ") {
189            let fn_part = trimmed
190                .trim_start_matches("pub async fn ")
191                .trim_start_matches("pub fn ");
192            let fn_name = fn_part
193                .split('(')
194                .next()
195                .unwrap_or("")
196                .split('<')
197                .next()
198                .unwrap_or("")
199                .trim();
200
201            if !fn_name.is_empty() && fn_name != "main" && fn_name != "new" {
202                let gate = pending_feature_gate
203                    .take()
204                    .or_else(|| module_feature_gate.clone());
205                results.push(PubFn {
206                    path: fn_name.to_string(),
207                    file: file_str.clone(),
208                    line: i + 1,
209                    has_contract_macro: prev_line_has_contract,
210                    feature_gate: gate,
211                });
212            }
213            prev_line_has_contract = false;
214            pending_feature_gate = None;
215        } else if !trimmed.starts_with("//") && !trimmed.starts_with('#') {
216            prev_line_has_contract = false;
217            pending_feature_gate = None;
218        }
219    }
220}
221
222/// Extract the feature name from a `#[cfg(feature = "...")]` or
223/// `#[cfg(all(test, feature = "..."))]` attribute.
224fn extract_feature_gate(cfg_line: &str) -> Option<String> {
225    let feat_pos = cfg_line.find("feature")?;
226    let rest = &cfg_line[feat_pos..];
227    let quote_start = rest.find('"')?;
228    let after_quote = &rest[quote_start + 1..];
229    let quote_end = after_quote.find('"')?;
230    Some(after_quote[..quote_end].to_string())
231}
232
233#[cfg(test)]
234#[allow(clippy::all)]
235#[path = "reverse_coverage_tests.rs"]
236mod tests;