Skip to main content

provable_contracts/query/
cross_project.rs

1//! Cross-project contract usage index.
2//!
3//! Discovers sibling projects that consume provable-contracts and indexes
4//! contract references: `#[contract(...)]` annotations, binding.yaml entries,
5//! and KAIZEN/contract-ID patterns in code and git commits.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12/// A discovered sibling project that consumes contracts.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ProjectEntry {
15    pub name: String,
16    pub path: PathBuf,
17    pub has_cargo_toml: bool,
18    pub has_binding: bool,
19    pub binding_path: Option<PathBuf>,
20}
21
22/// A `#[contract("...", equation = "...")]` annotation in consumer code.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct CallSite {
25    pub project: String,
26    pub file: String,
27    pub line: u32,
28    pub contract_stem: String,
29    pub equation: Option<String>,
30}
31
32/// A binding.yaml reference from a consumer project.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct BindingRef {
35    pub project: String,
36    pub binding_path: String,
37    pub contract: String,
38    pub equation: String,
39    pub status: String,
40}
41
42/// A KAIZEN-NNN or C-UPPER-DIGITS reference in code or commits.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct KaizenRef {
45    pub project: String,
46    pub file: String,
47    pub line: u32,
48    pub pattern: String,
49}
50
51/// A contract or KAIZEN reference found in git commit messages.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct CommitRef {
54    pub project: String,
55    pub commit_hash: String,
56    pub pattern: String,
57}
58
59/// Cross-project index aggregating all contract references across the stack.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CrossProjectIndex {
62    pub projects: Vec<ProjectEntry>,
63    pub call_sites: HashMap<String, Vec<CallSite>>,
64    pub binding_refs: HashMap<String, Vec<BindingRef>>,
65    pub kaizen_refs: HashMap<String, Vec<KaizenRef>>,
66    pub commit_refs: HashMap<String, Vec<CommitRef>>,
67}
68
69impl CrossProjectIndex {
70    /// Build a cross-project index by discovering sibling projects.
71    ///
72    /// Starts from `contracts_repo_root` (the provable-contracts repo),
73    /// walks `../` for sibling projects with `Cargo.toml`, and scans them
74    /// for contract references.
75    pub fn build(contracts_repo_root: &Path) -> Self {
76        Self::build_with_extra(contracts_repo_root, None)
77    }
78
79    /// Build with an optional extra project path to include.
80    pub fn build_with_extra(contracts_repo_root: &Path, extra_path: Option<&Path>) -> Self {
81        let root = contracts_repo_root
82            .canonicalize()
83            .unwrap_or_else(|_| contracts_repo_root.to_path_buf());
84        let parent = root.parent().unwrap_or(&root);
85
86        let mut projects = discover_projects(parent, &root);
87
88        // Add explicit extra project if provided and not already discovered
89        if let Some(extra) = extra_path {
90            let extra_canon = extra.canonicalize().unwrap_or_else(|_| extra.to_path_buf());
91            if extra_canon.is_dir() && !projects.iter().any(|p| p.path == extra_canon) {
92                let name = extra_canon
93                    .file_name()
94                    .and_then(|n| n.to_str())
95                    .unwrap_or("unknown")
96                    .to_string();
97                let binding_path = find_binding_path(&extra_canon, &name);
98                projects.push(ProjectEntry {
99                    name,
100                    has_cargo_toml: extra_canon.join("Cargo.toml").exists(),
101                    has_binding: binding_path.is_some(),
102                    binding_path,
103                    path: extra_canon,
104                });
105            }
106        }
107
108        let mut call_sites: HashMap<String, Vec<CallSite>> = HashMap::new();
109        let mut binding_refs: HashMap<String, Vec<BindingRef>> = HashMap::new();
110        let mut kaizen_refs: HashMap<String, Vec<KaizenRef>> = HashMap::new();
111        let mut commit_refs: HashMap<String, Vec<CommitRef>> = HashMap::new();
112
113        for project in &projects {
114            scan_contract_annotations(project, &mut call_sites);
115            scan_binding_refs(project, &mut binding_refs);
116            scan_kaizen_refs(project, &mut kaizen_refs);
117            scan_commit_refs(project, &mut commit_refs);
118        }
119
120        Self {
121            projects,
122            call_sites,
123            binding_refs,
124            kaizen_refs,
125            commit_refs,
126        }
127    }
128
129    /// Get all call sites for a given contract stem.
130    pub fn call_sites_for(&self, stem: &str) -> &[CallSite] {
131        self.call_sites.get(stem).map_or(&[], Vec::as_slice)
132    }
133
134    /// Get all binding references for a given contract stem.
135    pub fn binding_refs_for(&self, stem: &str) -> &[BindingRef] {
136        self.binding_refs.get(stem).map_or(&[], Vec::as_slice)
137    }
138
139    /// Get all KAIZEN references for a given pattern.
140    pub fn kaizen_refs_for(&self, pattern: &str) -> &[KaizenRef] {
141        self.kaizen_refs.get(pattern).map_or(&[], Vec::as_slice)
142    }
143
144    /// Get all commit message references for a given pattern.
145    pub fn commit_refs_for(&self, pattern: &str) -> &[CommitRef] {
146        self.commit_refs.get(pattern).map_or(&[], Vec::as_slice)
147    }
148
149    /// Total call sites across all contracts.
150    pub fn total_call_sites(&self) -> usize {
151        self.call_sites.values().map(Vec::len).sum()
152    }
153
154    /// Total projects discovered.
155    pub fn project_count(&self) -> usize {
156        self.projects.len()
157    }
158}
159
160/// Discover sibling projects that might consume contracts.
161fn discover_projects(parent_dir: &Path, self_dir: &Path) -> Vec<ProjectEntry> {
162    let mut projects = Vec::new();
163    let Ok(entries) = std::fs::read_dir(parent_dir) else {
164        return projects;
165    };
166
167    for entry in entries.flatten() {
168        let path = entry.path();
169        if !path.is_dir() || path == self_dir {
170            continue;
171        }
172
173        let has_cargo_toml = path.join("Cargo.toml").exists();
174        if !has_cargo_toml {
175            continue;
176        }
177
178        let name = path
179            .file_name()
180            .and_then(|n| n.to_str())
181            .unwrap_or("unknown")
182            .to_string();
183
184        // Look for binding.yaml in contracts/<project>/ or at the root
185        let binding_path = find_binding_path(&path, &name);
186
187        projects.push(ProjectEntry {
188            name,
189            has_cargo_toml,
190            has_binding: binding_path.is_some(),
191            binding_path,
192            path,
193        });
194    }
195
196    projects.sort_by(|a, b| a.name.cmp(&b.name));
197    projects
198}
199
200/// Find binding.yaml for a project — check common locations.
201fn find_binding_path(project_dir: &Path, name: &str) -> Option<PathBuf> {
202    // Check contracts/<name>/binding.yaml (in provable-contracts repo)
203    let sibling_binding = project_dir
204        .parent()?
205        .join("provable-contracts/contracts")
206        .join(name)
207        .join("binding.yaml");
208    if sibling_binding.exists() {
209        return Some(sibling_binding);
210    }
211
212    // Check binding.yaml at project root
213    let root_binding = project_dir.join("binding.yaml");
214    if root_binding.exists() {
215        return Some(root_binding);
216    }
217
218    None
219}
220
221/// Scan .rs files for `#[contract("...", equation = "...")]` annotations.
222fn scan_contract_annotations(
223    project: &ProjectEntry,
224    call_sites: &mut HashMap<String, Vec<CallSite>>,
225) {
226    let src_dir = project.path.join("src");
227    if !src_dir.exists() {
228        return;
229    }
230
231    let output = std::process::Command::new("grep")
232        .args(["-rn", "contract(\"", "--include=*.rs"])
233        .arg(&src_dir)
234        .output();
235
236    let Ok(output) = output else { return };
237    if !output.status.success() {
238        return;
239    }
240
241    let stdout = String::from_utf8_lossy(&output.stdout);
242    for line in stdout.lines() {
243        if let Some(site) = parse_contract_annotation(line, &project.name, &project.path) {
244            call_sites
245                .entry(site.contract_stem.clone())
246                .or_default()
247                .push(site);
248        }
249    }
250}
251
252/// Parse a grep output line into a `CallSite`.
253fn parse_contract_annotation(
254    line: &str,
255    project_name: &str,
256    project_path: &Path,
257) -> Option<CallSite> {
258    // grep output: /abs/path/file.rs:42:content
259    let parts: Vec<&str> = line.splitn(3, ':').collect();
260    if parts.len() < 3 {
261        return None;
262    }
263    let file_path = parts[0];
264    let line_no: u32 = parts[1].parse().ok()?;
265    let content = parts[2];
266
267    // Extract contract stem from content like: contract("stem-v1", ...)
268    let stem = extract_contract_stem(content)?;
269
270    // Extract equation if present
271    let equation = extract_equation(content);
272
273    // Make file path relative to project
274    let relative = file_path
275        .strip_prefix(project_path.to_string_lossy().as_ref())
276        .unwrap_or(file_path)
277        .trim_start_matches('/');
278
279    Some(CallSite {
280        project: project_name.to_string(),
281        file: relative.to_string(),
282        line: line_no,
283        contract_stem: stem,
284        equation,
285    })
286}
287
288/// Extract contract stem from annotation content.
289fn extract_contract_stem(content: &str) -> Option<String> {
290    // Look for contract("stem-v1" or contract("stem-v1.yaml"
291    let idx = content.find("contract(\"")?;
292    let start = idx + "contract(\"".len();
293    let rest = &content[start..];
294    let end = rest.find('"')?;
295    let stem = &rest[..end];
296    // Strip .yaml extension if present
297    Some(stem.strip_suffix(".yaml").unwrap_or(stem).to_string())
298}
299
300/// Extract equation from annotation content.
301fn extract_equation(content: &str) -> Option<String> {
302    // Look for equation = "eq_name"
303    let idx = content.find("equation")?;
304    let rest = &content[idx..];
305    let q_start = rest.find('"')? + 1;
306    let q_rest = &rest[q_start..];
307    let q_end = q_rest.find('"')?;
308    Some(q_rest[..q_end].to_string())
309}
310
311/// Scan binding.yaml files for contract references.
312fn scan_binding_refs(project: &ProjectEntry, refs: &mut HashMap<String, Vec<BindingRef>>) {
313    let Some(binding_path) = &project.binding_path else {
314        return;
315    };
316    let Ok(content) = std::fs::read_to_string(binding_path) else {
317        return;
318    };
319    let Ok(registry) = crate::binding::parse_binding_str(&content) else {
320        return;
321    };
322
323    for binding in &registry.bindings {
324        let stem = binding
325            .contract
326            .strip_suffix(".yaml")
327            .unwrap_or(&binding.contract)
328            .to_string();
329
330        refs.entry(stem).or_default().push(BindingRef {
331            project: project.name.clone(),
332            binding_path: binding_path.display().to_string(),
333            contract: binding.contract.clone(),
334            equation: binding.equation.clone(),
335            status: binding.status.to_string(),
336        });
337    }
338}
339
340/// Scan .rs files for KAIZEN-NNN and C-UPPER-DIGITS patterns.
341fn scan_kaizen_refs(project: &ProjectEntry, refs: &mut HashMap<String, Vec<KaizenRef>>) {
342    let src_dir = project.path.join("src");
343    if !src_dir.exists() {
344        return;
345    }
346
347    let output = std::process::Command::new("grep")
348        .args([
349            "-rn",
350            "-E",
351            r"KAIZEN-[0-9]+|C-[A-Z]+-[0-9]+",
352            "--include=*.rs",
353        ])
354        .arg(&src_dir)
355        .output();
356
357    let Ok(output) = output else { return };
358    if !output.status.success() {
359        return;
360    }
361
362    let stdout = String::from_utf8_lossy(&output.stdout);
363    for line in stdout.lines() {
364        let parts: Vec<&str> = line.splitn(3, ':').collect();
365        if parts.len() < 3 {
366            continue;
367        }
368        let line_no: u32 = match parts[1].parse() {
369            Ok(n) => n,
370            Err(_) => continue,
371        };
372        let content = parts[2];
373
374        let relative = parts[0]
375            .strip_prefix(project.path.to_string_lossy().as_ref())
376            .unwrap_or(parts[0])
377            .trim_start_matches('/');
378
379        // Extract all KAIZEN-NNN and C-UPPER-DIGITS patterns
380        for pattern in extract_patterns(content) {
381            refs.entry(pattern.clone()).or_default().push(KaizenRef {
382                project: project.name.clone(),
383                file: relative.to_string(),
384                line: line_no,
385                pattern,
386            });
387        }
388    }
389}
390
391/// Extract KAIZEN-NNN and C-UPPER-DIGITS patterns from a line.
392fn extract_patterns(content: &str) -> Vec<String> {
393    let mut patterns = Vec::new();
394    let mut rest = content;
395    while let Some(idx) = rest.find("KAIZEN-") {
396        let start = idx;
397        let after = &rest[idx + 7..];
398        let end = after
399            .find(|c: char| !c.is_ascii_digit())
400            .unwrap_or(after.len());
401        if end > 0 {
402            patterns.push(rest[start..start + 7 + end].to_string());
403        }
404        rest = &rest[start + 7 + end..];
405    }
406
407    rest = content;
408    while let Some(idx) = rest.find("C-") {
409        let after_c = &rest[idx + 2..];
410        // Must match C-<UPPER>+-<DIGIT>+
411        let alpha_end = after_c.find(|c: char| !c.is_ascii_uppercase()).unwrap_or(0);
412        if alpha_end > 0 && after_c.get(alpha_end..=alpha_end) == Some("-") {
413            let digit_start = alpha_end + 1;
414            let digit_rest = &after_c[digit_start..];
415            let digit_end = digit_rest
416                .find(|c: char| !c.is_ascii_digit())
417                .unwrap_or(digit_rest.len());
418            if digit_end > 0 {
419                let full_end = idx + 2 + digit_start + digit_end;
420                patterns.push(rest[idx..full_end].to_string());
421                rest = &rest[full_end..];
422                continue;
423            }
424        }
425        rest = &rest[idx + 2..];
426    }
427
428    patterns
429}
430
431/// Scan recent git commit messages for KAIZEN-NNN and C-UPPER-DIGITS patterns.
432fn scan_commit_refs(project: &ProjectEntry, refs: &mut HashMap<String, Vec<CommitRef>>) {
433    if !project.path.join(".git").exists() {
434        return;
435    }
436    // Get recent commit messages (last 200 commits)
437    let output = std::process::Command::new("git")
438        .args(["log", "--oneline", "-200", "--format=%H %s"])
439        .current_dir(&project.path)
440        .output();
441
442    let Ok(output) = output else { return };
443    if !output.status.success() {
444        return;
445    }
446
447    let stdout = String::from_utf8_lossy(&output.stdout);
448    for line in stdout.lines() {
449        let Some((hash, subject)) = line.split_once(' ') else {
450            continue;
451        };
452        let short_hash = &hash[..hash.len().min(12)];
453        for pattern in extract_patterns(subject) {
454            refs.entry(pattern.clone()).or_default().push(CommitRef {
455                project: project.name.clone(),
456                commit_hash: short_hash.to_string(),
457                pattern,
458            });
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    include!("cross_project_tests.rs");
466}