provable_contracts/query/
cross_project.rs1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct CommitRef {
54 pub project: String,
55 pub commit_hash: String,
56 pub pattern: String,
57}
58
59#[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 pub fn build(contracts_repo_root: &Path) -> Self {
76 Self::build_with_extra(contracts_repo_root, None)
77 }
78
79 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 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 pub fn call_sites_for(&self, stem: &str) -> &[CallSite] {
131 self.call_sites.get(stem).map_or(&[], Vec::as_slice)
132 }
133
134 pub fn binding_refs_for(&self, stem: &str) -> &[BindingRef] {
136 self.binding_refs.get(stem).map_or(&[], Vec::as_slice)
137 }
138
139 pub fn kaizen_refs_for(&self, pattern: &str) -> &[KaizenRef] {
141 self.kaizen_refs.get(pattern).map_or(&[], Vec::as_slice)
142 }
143
144 pub fn commit_refs_for(&self, pattern: &str) -> &[CommitRef] {
146 self.commit_refs.get(pattern).map_or(&[], Vec::as_slice)
147 }
148
149 pub fn total_call_sites(&self) -> usize {
151 self.call_sites.values().map(Vec::len).sum()
152 }
153
154 pub fn project_count(&self) -> usize {
156 self.projects.len()
157 }
158}
159
160fn 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 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
200fn find_binding_path(project_dir: &Path, name: &str) -> Option<PathBuf> {
202 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 let root_binding = project_dir.join("binding.yaml");
214 if root_binding.exists() {
215 return Some(root_binding);
216 }
217
218 None
219}
220
221fn 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
252fn parse_contract_annotation(
254 line: &str,
255 project_name: &str,
256 project_path: &Path,
257) -> Option<CallSite> {
258 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 let stem = extract_contract_stem(content)?;
269
270 let equation = extract_equation(content);
272
273 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
288fn extract_contract_stem(content: &str) -> Option<String> {
290 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 Some(stem.strip_suffix(".yaml").unwrap_or(stem).to_string())
298}
299
300fn extract_equation(content: &str) -> Option<String> {
302 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
311fn 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 ®istry.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
340fn 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 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
391fn 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 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
431fn scan_commit_refs(project: &ProjectEntry, refs: &mut HashMap<String, Vec<CommitRef>>) {
433 if !project.path.join(".git").exists() {
434 return;
435 }
436 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}