aprender_contracts_cli/commands/
verify_bindings.rs1use std::collections::HashSet;
10use std::path::Path;
11
12pub fn run(
13 binding_path: &Path,
14 output: Option<&Path>,
15 crate_name: Option<&str>,
16) -> Result<(), Box<dyn std::error::Error>> {
17 let content = std::fs::read_to_string(binding_path)?;
18 let label = crate_name.unwrap_or("unknown");
19
20 let expected = parse_expected_functions(&content);
21 if expected.is_empty() {
22 println!("{label}: no function names in binding — nothing to verify");
23 return Ok(());
24 }
25
26 let found = scan_all_sources(binding_path, label);
27 let missing = compute_missing(&expected, &found);
28
29 if let Some(out_path) = output {
30 write_report(out_path, label, expected.len(), found.len(), &missing)?;
31 }
32
33 let verified = expected.len() - missing.len();
34 println!(
35 "{label}: {verified}/{} binding functions verified in source",
36 expected.len()
37 );
38
39 if missing.is_empty() {
40 return Ok(());
41 }
42 report_ghost_bindings(label, &missing);
43 Err(format!("{} ghost binding(s) detected", missing.len()).into())
44}
45
46fn parse_expected_functions(content: &str) -> HashSet<String> {
48 let mut expected: HashSet<String> = HashSet::new();
49 for line in content.lines() {
50 let Some(rest) = line.trim().strip_prefix("function:") else {
51 continue;
52 };
53 let func = rest.trim().trim_matches('"').trim_matches('\'').trim();
54 if func.is_empty() || func == "N/A" {
55 continue;
56 }
57 let short = func.rsplit("::").next().unwrap_or(func).to_lowercase();
58 if !short.is_empty() {
59 expected.insert(short);
60 }
61 }
62 expected
63}
64
65fn scan_all_sources(binding_path: &Path, label: &str) -> HashSet<String> {
68 let src_dir = derive_src_root(binding_path, label);
69 let mut found: HashSet<String> = HashSet::new();
70 let src = src_dir.join("src");
71 if src.exists() {
72 scan_fns(&src, &mut found);
73 }
74 let crates = src_dir.join("crates");
75 if crates.exists() {
76 scan_fns(&crates, &mut found);
77 }
78 let local_src = Path::new("src");
79 if local_src.exists() && local_src != src {
80 scan_fns(local_src, &mut found);
81 }
82 found
83}
84
85fn derive_src_root(binding_path: &Path, label: &str) -> std::path::PathBuf {
88 let Some(parent) = binding_path.parent() else {
89 return Path::new(".").to_path_buf();
90 };
91 parent
92 .parent()
93 .and_then(|p| p.parent())
94 .map_or_else(|| Path::new(".").to_path_buf(), |p| p.join(label))
95}
96
97fn compute_missing<'a>(expected: &'a HashSet<String>, found: &HashSet<String>) -> Vec<&'a String> {
99 let mut missing: Vec<&String> = expected
100 .iter()
101 .filter(|n| !found.contains(n.as_str()))
102 .collect();
103 missing.sort();
104 missing
105}
106
107fn write_report(
109 out_path: &Path,
110 label: &str,
111 expected: usize,
112 found: usize,
113 missing: &[&String],
114) -> Result<(), Box<dyn std::error::Error>> {
115 let mut report = format!("# Binding Verification Report: {label}\n\n");
116 report.push_str(&format!("Expected: {} functions\n", expected));
117 report.push_str(&format!("Found in source: {} functions\n", found));
118 report.push_str(&format!("Missing: {}\n\n", missing.len()));
119 if !missing.is_empty() {
120 report.push_str("## Missing Functions\n\n");
121 for m in missing {
122 report.push_str(&format!("- `{m}`\n"));
123 }
124 }
125 std::fs::write(out_path, &report)?;
126 println!("Report written to {}", out_path.display());
127 Ok(())
128}
129
130fn report_ghost_bindings(label: &str, missing: &[&String]) {
131 eprintln!(
132 "{label}: {} ghost binding(s) — function not found in source:",
133 missing.len()
134 );
135 for m in missing.iter().take(20) {
136 eprintln!(" - {m}");
137 }
138 if missing.len() > 20 {
139 eprintln!(" ... and {} more", missing.len() - 20);
140 }
141}
142
143fn scan_fns(dir: &Path, found: &mut HashSet<String>) {
144 let Ok(entries) = std::fs::read_dir(dir) else {
145 return;
146 };
147 for entry in entries.flatten() {
148 let path = entry.path();
149 if path.is_dir() {
150 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
151 if name != "target" && name != ".git" && name != "tests" {
152 scan_fns(&path, found);
153 }
154 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
155 if let Ok(content) = std::fs::read_to_string(&path) {
156 extract_fn_names(&content, found);
157 }
158 }
159 }
160}
161
162fn extract_fn_names(content: &str, found: &mut HashSet<String>) {
164 for line in content.lines() {
165 let t = line.trim();
166 if !(t.starts_with("pub fn ")
167 || t.starts_with("pub async fn ")
168 || t.starts_with("pub(crate) fn ")
169 || t.starts_with("fn "))
170 {
171 continue;
172 }
173 let part = t
174 .trim_start_matches("pub async fn ")
175 .trim_start_matches("pub(crate) fn ")
176 .trim_start_matches("pub fn ")
177 .trim_start_matches("fn ");
178 let name = part
179 .split('(')
180 .next()
181 .unwrap_or("")
182 .split('<')
183 .next()
184 .unwrap_or("")
185 .trim()
186 .to_lowercase();
187 if !name.is_empty() {
188 found.insert(name);
189 }
190 }
191}