provable_contracts/
reverse_coverage.rs1use std::collections::HashSet;
10use std::path::Path;
11
12#[derive(Debug, Clone)]
14pub struct PubFn {
15 pub path: String,
17 pub file: String,
19 pub line: usize,
21 pub has_contract_macro: bool,
23 pub feature_gate: Option<String>,
25}
26
27#[derive(Debug)]
29pub struct ReverseCoverageReport {
30 pub total_pub_fns: usize,
32 pub bound_fns: usize,
34 pub annotated_fns: usize,
36 pub exempt_fns: usize,
38 pub unbound: Vec<PubFn>,
40 pub coverage_pct: f64,
42}
43
44pub 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
90fn 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
107fn 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 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 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 gate_depth.is_some_and(|d| brace_depth < d) {
159 module_feature_gate = None;
160 gate_depth = None;
161 }
162 }
163 }
164
165 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 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
222fn 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;