Skip to main content

aprender_contracts_cli/commands/
verify_bindings.rs

1//! Verify that functions named in binding.yaml exist in crate source.
2//!
3//! Layer 2 enforcement: cross-references function names from binding.yaml
4//! against `pub fn` declarations found in the crate's `src/` directory.
5//! Ghost bindings (claimed implemented but function missing) are reported.
6//!
7//! This runs in CI as a test — no build.rs modification needed.
8
9use 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
46/// Extract lowercased short-function-names from `function:` lines in a binding yaml.
47fn 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
65/// Scan the crate's `src/`, `crates/`, and the current-dir `src/` (if different)
66/// for `fn` declarations.
67fn 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
85/// binding.yaml lives in `contracts/<repo>/` — source is `../../<repo>/`.
86/// Falls back to `.` when the path has no usable parent chain.
87fn 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
97/// Sort the expected names missing from `found` for stable reporting.
98fn 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
107/// Write the binding-verification markdown report.
108fn 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
162/// Extract lowercased `fn`/`pub fn`/`pub async fn`/`pub(crate) fn` names from source.
163fn 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}