1pub mod combined;
2pub mod grpc;
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10use crate::graph::{store::GraphStore, GraphQuery};
11use crate::lang::LanguageRegistry;
12use crate::Infigraph;
13
14#[derive(Debug, Default, Serialize, Deserialize)]
16pub struct Registry {
17 pub repos: HashMap<String, RepoEntry>,
18 pub groups: HashMap<String, Group>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct RepoEntry {
24 pub name: String,
25 pub path: PathBuf,
26 pub languages: Vec<String>,
27 pub symbol_count: u64,
28 pub module_count: u64,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Group {
34 pub name: String,
35 pub repos: Vec<String>,
36 pub contracts: Vec<Contract>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Contract {
42 pub kind: ContractKind,
43 pub service: String,
44 pub method: String,
45 pub path: String,
46 pub symbol_id: String,
47 pub file: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub enum ContractKind {
52 HttpRoute,
53 GrpcService,
54 EventPublish,
55 EventSubscribe,
56}
57
58impl Registry {
59 pub fn load() -> Result<Self> {
61 let path = registry_path()?;
62 if !path.exists() {
63 return Ok(Self::default());
64 }
65 let data = std::fs::read_to_string(&path)?;
66 let registry: Registry = serde_json::from_str(&data)?;
67 Ok(registry)
68 }
69
70 pub fn save(&self) -> Result<()> {
72 let path = registry_path()?;
73 if let Some(parent) = path.parent() {
74 std::fs::create_dir_all(parent)?;
75 }
76 let data = serde_json::to_string_pretty(self)?;
77 std::fs::write(&path, data)?;
78 Ok(())
79 }
80
81 pub fn register_repo(&mut self, name: &str, path: &Path, prism: &Infigraph) -> Result<()> {
83 let stats = prism.stats()?;
84 let langs: Vec<String> = prism
85 .registry()
86 .languages()
87 .map(|p| p.name.clone())
88 .collect();
89
90 let abs_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
91
92 self.repos.insert(
93 name.to_string(),
94 RepoEntry {
95 name: name.to_string(),
96 path: abs_path,
97 languages: langs,
98 symbol_count: stats.symbols,
99 module_count: stats.modules,
100 },
101 );
102 self.save()
103 }
104
105 pub fn create_group(&mut self, name: &str) -> Result<()> {
107 if self.groups.contains_key(name) {
108 anyhow::bail!("group '{}' already exists", name);
109 }
110 self.groups.insert(
111 name.to_string(),
112 Group {
113 name: name.to_string(),
114 repos: Vec::new(),
115 contracts: Vec::new(),
116 },
117 );
118 self.save()
119 }
120
121 pub fn group_add(&mut self, group_name: &str, repo_name: &str) -> Result<()> {
123 let group = self
124 .groups
125 .get_mut(group_name)
126 .context(format!("group '{}' not found", group_name))?;
127 if !self.repos.contains_key(repo_name) {
128 anyhow::bail!("repo '{}' not registered. Run index first.", repo_name);
129 }
130 if !group.repos.contains(&repo_name.to_string()) {
131 group.repos.push(repo_name.to_string());
132 }
133 self.save()
134 }
135
136 pub fn group_remove(&mut self, group_name: &str, repo_name: &str) -> Result<()> {
138 let group = self
139 .groups
140 .get_mut(group_name)
141 .context(format!("group '{}' not found", group_name))?;
142 group.repos.retain(|r| r != repo_name);
143 self.save()
144 }
145
146 pub fn group_query(
148 &self,
149 group_name: &str,
150 cypher: &str,
151 build_registry: impl Fn() -> Result<LanguageRegistry>,
152 ) -> Result<Vec<(String, Vec<Vec<String>>)>> {
153 let group = self
154 .groups
155 .get(group_name)
156 .context(format!("group '{}' not found", group_name))?;
157
158 let mut results = Vec::new();
159 for repo_name in &group.repos {
160 let entry = self
161 .repos
162 .get(repo_name)
163 .context(format!("repo '{}' not in registry", repo_name))?;
164
165 let registry = build_registry()?;
166 let mut prism = Infigraph::open(&entry.path, registry)?;
167 prism.init()?;
168
169 let store = prism.store().context("graph not initialized")?;
170 let conn = store.connection()?;
171 let gq = GraphQuery::new(&conn);
172
173 match gq.raw_query(cypher) {
174 Ok(rows) => {
175 if !rows.is_empty() {
176 results.push((repo_name.clone(), rows));
177 }
178 }
179 Err(e) => {
180 eprintln!("warning: query failed for repo '{}': {}", repo_name, e);
181 }
182 }
183 }
184 Ok(results)
185 }
186}
187
188pub fn extract_contracts(prism: &Infigraph, service_name: &str) -> Result<Vec<Contract>> {
195 let store = prism.store().context("graph not initialized")?;
196 let conn = store.connection()?;
197 let gq = GraphQuery::new(&conn);
198
199 let mut contracts = Vec::new();
200 let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
201
202 let route_rows = gq.raw_query(
204 "MATCH (s:Symbol) WHERE s.kind = 'Route' RETURN s.id, s.name, s.kind, s.file, s.docstring",
205 )?;
206 for row in &route_rows {
207 let (method, path) = parse_route_name(&row[1]);
208 let key = format!("{} {}", method, path);
209 if seen_paths.insert(key) {
210 contracts.push(Contract {
211 kind: ContractKind::HttpRoute,
212 service: service_name.to_string(),
213 method,
214 path,
215 symbol_id: row[0].clone(),
216 file: row[3].clone(),
217 });
218 }
219 }
220
221 let decorated_rows = gq.raw_query(
223 "MATCH (s:Symbol) WHERE s.kind IN ['Function', 'Method'] AND s.docstring IS NOT NULL AND (s.docstring CONTAINS '@app.route' OR s.docstring CONTAINS '@app.get' OR s.docstring CONTAINS '@app.post' OR s.docstring CONTAINS '#[get' OR s.docstring CONTAINS '#[post' OR s.docstring CONTAINS '@GetMapping' OR s.docstring CONTAINS '@PostMapping' OR s.docstring CONTAINS '@RequestMapping' OR s.docstring CONTAINS 'MapGet' OR s.docstring CONTAINS 'MapPost') RETURN s.id, s.name, s.kind, s.file, s.docstring",
224 )?;
225 for row in &decorated_rows {
226 let doc = row.get(4).map(|s| s.as_str()).unwrap_or("");
227 let (method, path) = parse_route_from_docstring(doc);
228 if !path.is_empty() {
229 let key = format!("{} {}", method, path);
230 if seen_paths.insert(key) {
231 contracts.push(Contract {
232 kind: ContractKind::HttpRoute,
233 service: service_name.to_string(),
234 method,
235 path,
236 symbol_id: row[0].clone(),
237 file: row[3].clone(),
238 });
239 }
240 }
241 }
242
243 Ok(contracts)
244}
245
246fn parse_route_name(name: &str) -> (String, String) {
248 let parts: Vec<&str> = name.splitn(2, ' ').collect();
249 if parts.len() == 2 {
250 let method = parts[0].trim().to_uppercase();
251 let method = if method.starts_with("MAP") {
253 method.trim_start_matches("MAP").to_string()
254 } else {
255 method
256 };
257 (method, parts[1].trim().to_string())
258 } else {
259 ("UNKNOWN".to_string(), name.to_string())
260 }
261}
262
263fn parse_route_from_docstring(doc: &str) -> (String, String) {
265 let doc_lower = doc.to_lowercase();
270
271 let path = doc
273 .split('"')
274 .chain(doc.split('\''))
275 .find(|s| s.starts_with('/'))
276 .unwrap_or("")
277 .to_string();
278
279 let method = if doc_lower.contains("methods") {
281 if doc_lower.contains("\"get\"") || doc_lower.contains("'get'") {
283 "GET"
284 } else if doc_lower.contains("\"post\"") || doc_lower.contains("'post'") {
285 "POST"
286 } else if doc_lower.contains("\"put\"") || doc_lower.contains("'put'") {
287 "PUT"
288 } else if doc_lower.contains("\"delete\"") || doc_lower.contains("'delete'") {
289 "DELETE"
290 } else if doc_lower.contains("\"patch\"") || doc_lower.contains("'patch'") {
291 "PATCH"
292 } else {
293 "UNKNOWN"
294 }
295 } else if doc_lower.contains("@app.get")
296 || doc_lower.contains("#[get")
297 || doc_lower.contains("getmapping")
298 || doc_lower.contains("mapget")
299 {
300 "GET"
301 } else if doc_lower.contains("@app.post")
302 || doc_lower.contains("#[post")
303 || doc_lower.contains("postmapping")
304 || doc_lower.contains("mappost")
305 {
306 "POST"
307 } else if doc_lower.contains("@app.put")
308 || doc_lower.contains("#[put")
309 || doc_lower.contains("putmapping")
310 || doc_lower.contains("mapput")
311 {
312 "PUT"
313 } else if doc_lower.contains("@app.delete")
314 || doc_lower.contains("#[delete")
315 || doc_lower.contains("deletemapping")
316 || doc_lower.contains("mapdelete")
317 {
318 "DELETE"
319 } else if doc_lower.contains("@app.patch")
320 || doc_lower.contains("#[patch")
321 || doc_lower.contains("patchmapping")
322 || doc_lower.contains("mappatch")
323 {
324 "PATCH"
325 } else {
326 "UNKNOWN"
327 };
328
329 (method.to_string(), path)
330}
331
332pub fn sync_group_contracts(
334 registry: &mut Registry,
335 group_name: &str,
336 build_registry: impl Fn() -> Result<LanguageRegistry>,
337) -> Result<usize> {
338 let group = registry
339 .groups
340 .get(group_name)
341 .context(format!("group '{}' not found", group_name))?
342 .clone();
343
344 let mut all_contracts = Vec::new();
345
346 for repo_name in &group.repos {
347 let entry = registry
348 .repos
349 .get(repo_name)
350 .context(format!("repo '{}' not in registry", repo_name))?
351 .clone();
352
353 let lang_registry = build_registry()?;
354 let mut prism = Infigraph::open(&entry.path, lang_registry)?;
355 prism.init()?;
356
357 let contracts = extract_contracts(&prism, repo_name)?;
358 all_contracts.extend(contracts);
359 }
360
361 let count = all_contracts.len();
362 let group = registry
363 .groups
364 .get_mut(group_name)
365 .context("group not found")?;
366 group.contracts = all_contracts;
367 registry.save()?;
368
369 Ok(count)
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct CrossServiceDep {
375 pub caller_service: String,
376 pub caller_file: String,
377 pub caller_symbol: String,
378 pub target_service: String,
379 pub target_method: String,
380 pub target_path: String,
381 pub url_found: String,
382}
383
384pub fn detect_cross_service_deps(
388 registry: &Registry,
389 group_name: &str,
390 build_registry: impl Fn() -> Result<LanguageRegistry>,
391) -> Result<Vec<CrossServiceDep>> {
392 let group = registry
393 .groups
394 .get(group_name)
395 .context(format!("group '{}' not found", group_name))?;
396
397 let mut route_lookup: HashMap<String, (String, String)> = HashMap::new();
399 for contract in &group.contracts {
400 if contract.kind == ContractKind::HttpRoute {
401 let normalized = normalize_route_path(&contract.path);
403 route_lookup.insert(
404 normalized,
405 (contract.service.clone(), contract.method.clone()),
406 );
407 }
408 }
409
410 let mut deps = Vec::new();
411
412 for repo_name in &group.repos {
413 let entry = match registry.repos.get(repo_name) {
414 Some(e) => e.clone(),
415 None => continue,
416 };
417
418 let lang_registry = build_registry()?;
419 let mut prism = Infigraph::open(&entry.path, lang_registry)?;
420 prism.init()?;
421
422 let store = match prism.store() {
423 Some(s) => s,
424 None => continue,
425 };
426 let conn = match store.connection() {
427 Ok(c) => c,
428 Err(_) => continue,
429 };
430 let gq = GraphQuery::new(&conn);
431
432 let rows = gq.raw_query(
434 "MATCH (s:Symbol) WHERE s.docstring IS NOT NULL AND (s.docstring CONTAINS '/api/' OR s.docstring CONTAINS 'http://' OR s.docstring CONTAINS 'https://') RETURN s.id, s.name, s.file, s.docstring",
435 ).unwrap_or_default();
436
437 for row in &rows {
438 let doc = row.get(3).map(|s| s.as_str()).unwrap_or("");
439 let urls = extract_api_paths(doc);
440 for url in urls {
441 let normalized = normalize_route_path(&url);
442 if let Some((target_svc, target_method)) = route_lookup.get(&normalized) {
443 if target_svc != repo_name {
444 deps.push(CrossServiceDep {
445 caller_service: repo_name.clone(),
446 caller_file: row[2].clone(),
447 caller_symbol: row[0].clone(),
448 target_service: target_svc.clone(),
449 target_method: target_method.clone(),
450 target_path: url.clone(),
451 url_found: url,
452 });
453 }
454 }
455 }
456 }
457
458 let source_urls = scan_source_for_urls(&entry.path);
460 for (file, symbol_hint, url) in source_urls {
461 let normalized = normalize_route_path(&url);
462 if let Some((target_svc, target_method)) = route_lookup.get(&normalized) {
463 if target_svc != repo_name {
464 let caller_id = if let Some(stripped) = symbol_hint.strip_prefix("line:") {
466 let line_num: i32 = stripped.parse().unwrap_or(0);
467 let escaped_file = file.replace('\'', "\\'");
468 let q = format!(
469 "MATCH (s:Symbol) WHERE s.file = '{}' AND s.start_line <= {} AND s.end_line >= {} RETURN s.id ORDER BY (s.end_line - s.start_line) ASC LIMIT 1",
470 escaped_file, line_num, line_num
471 );
472 gq.raw_query(&q)
473 .ok()
474 .and_then(|rows| rows.into_iter().next())
475 .and_then(|row| row.into_iter().next())
476 .unwrap_or_else(|| format!("{}:{}", file, symbol_hint))
477 } else {
478 symbol_hint.clone()
479 };
480 deps.push(CrossServiceDep {
481 caller_service: repo_name.clone(),
482 caller_file: file,
483 caller_symbol: caller_id,
484 target_service: target_svc.clone(),
485 target_method: target_method.clone(),
486 target_path: url.clone(),
487 url_found: url,
488 });
489 }
490 }
491 }
492 }
493
494 Ok(deps)
495}
496
497fn normalize_route_path(path: &str) -> String {
499 let path = path.trim_end_matches('/');
500 let path = if let Some(idx) = path.find("/api/") {
502 &path[idx..]
503 } else if path.starts_with("http") {
504 path.split("//")
505 .nth(1)
506 .and_then(|s| s.find('/').map(|i| &s[i..]))
507 .unwrap_or(path)
508 } else {
509 path
510 };
511 let segments: Vec<&str> = path.split('/').collect();
513 segments
514 .iter()
515 .map(|s| {
516 if s.starts_with(':') || s.starts_with('{') || s.starts_with('<') {
517 "*"
518 } else {
519 s
520 }
521 })
522 .collect::<Vec<_>>()
523 .join("/")
524}
525
526fn extract_api_paths(text: &str) -> Vec<String> {
528 let mut paths = Vec::new();
529 for part in text
530 .split('"')
531 .chain(text.split('\'').chain(text.split('`')))
532 {
533 let trimmed = part.trim();
534 if (trimmed.starts_with("/api/") || trimmed.starts_with("http"))
535 && trimmed.contains("/api/")
536 {
537 paths.push(trimmed.to_string());
538 }
539 }
540 paths
541}
542
543fn scan_source_for_urls(root: &Path) -> Vec<(String, String, String)> {
545 const SKIP_DIRS: &[&str] = &[
546 ".infigraph",
547 ".git",
548 "node_modules",
549 "target",
550 "build",
551 "dist",
552 "__pycache__",
553 ".venv",
554 ];
555 let mut results = Vec::new();
556 walk_for_urls(root, root, SKIP_DIRS, &mut results);
557 results
558}
559
560fn walk_for_urls(
561 base: &Path,
562 dir: &Path,
563 skip: &[&str],
564 results: &mut Vec<(String, String, String)>,
565) {
566 let entries = match std::fs::read_dir(dir) {
567 Ok(e) => e,
568 Err(_) => return,
569 };
570 for entry in entries.flatten() {
571 let path = entry.path();
572 let name = entry.file_name();
573 let name_str = name.to_string_lossy();
574
575 if path.is_dir() {
576 if !skip.contains(&name_str.as_ref()) && !name_str.starts_with('.') {
577 walk_for_urls(base, &path, skip, results);
578 }
579 } else if path.is_file() {
580 let rel = path
581 .strip_prefix(base)
582 .unwrap_or(&path)
583 .to_string_lossy()
584 .replace('\\', "/");
585 let content = match std::fs::read_to_string(&path) {
586 Ok(c) => c,
587 Err(_) => continue,
588 };
589 for (line_num, line) in content.lines().enumerate() {
590 for delim in ['"', '\'', '`'] {
591 for part in line.split(delim) {
592 let trimmed = part.trim();
593 if trimmed.contains("/api/")
594 && trimmed.len() < 200
595 && !trimmed.contains(' ')
596 {
597 let path_part = if trimmed.starts_with("http") {
598 trimmed
599 .split("//")
600 .nth(1)
601 .and_then(|s| s.find('/').map(|i| &s[i..]))
602 .unwrap_or(trimmed)
603 } else {
604 trimmed
605 };
606 if path_part.starts_with("/api/") {
607 results.push((
608 rel.clone(),
609 format!("line:{}", line_num + 1),
610 path_part.to_string(),
611 ));
612 }
613 }
614 }
615 }
616 }
617 }
618 }
619}
620
621pub fn link_cross_service_calls(
624 registry: &Registry,
625 group_name: &str,
626 build_registry: impl Fn() -> Result<LanguageRegistry>,
627) -> Result<usize> {
628 let deps = detect_cross_service_deps(registry, group_name, &build_registry)?;
629 if deps.is_empty() {
630 return Ok(0);
631 }
632
633 let mut by_caller: HashMap<String, Vec<&CrossServiceDep>> = HashMap::new();
635 for dep in &deps {
636 by_caller
637 .entry(dep.caller_service.clone())
638 .or_default()
639 .push(dep);
640 }
641
642 let mut total = 0;
643
644 for (caller_svc, svc_deps) in &by_caller {
645 let entry = match registry.repos.get(caller_svc) {
646 Some(e) => e,
647 None => continue,
648 };
649
650 let lang_registry = build_registry()?;
651 let mut prism = Infigraph::open(&entry.path, lang_registry)?;
652 prism.init()?;
653
654 let store = match prism.store() {
655 Some(s) => s,
656 None => continue,
657 };
658 let conn = match store.connection() {
659 Ok(c) => c,
660 Err(_) => continue,
661 };
662 let gq = GraphQuery::new(&conn);
663
664 for dep in svc_deps {
665 let target_id = format!(
666 "xsvc::{}::{}::{}",
667 dep.target_service,
668 dep.target_method,
669 dep.target_path.replace('\'', "\\'")
670 );
671 let target_name = format!(
672 "{} {} {}",
673 dep.target_service, dep.target_method, dep.target_path
674 )
675 .replace('\'', "\\'");
676 let caller_sym = dep.caller_symbol.replace('\'', "\\'");
677 let target_svc = dep.target_service.replace('\'', "\\'");
678 let target_method = dep.target_method.replace('\'', "\\'");
679 let target_path = dep.target_path.replace('\'', "\\'");
680
681 let docstring = format!(
684 "External service: {} {} {}",
685 target_svc, target_method, target_path
686 );
687 let create_target = format!(
688 "MERGE (t:Symbol {{id: '{}'}}) \
689 ON CREATE SET t.name = '{}', t.kind = 'ExternalService', \
690 t.file = '(external)', t.start_line = 0, t.end_line = 0, \
691 t.signature_hash = '', t.language = 'external', t.visibility = 'public', \
692 t.parent = '', t.docstring = '{}', t.complexity = 0",
693 target_id, target_name, docstring,
694 );
695 let _ = gq.raw_query(&create_target);
696
697 let check_edge = format!(
699 "MATCH (caller:Symbol {{id: '{}'}})-[:CALLS_SERVICE]->(target:Symbol {{id: '{}'}}) RETURN caller.id",
700 caller_sym, target_id,
701 );
702 let existing = gq.raw_query(&check_edge).unwrap_or_default();
703 if !existing.is_empty() {
704 continue;
705 }
706
707 let create_edge = format!(
708 "MATCH (caller:Symbol {{id: '{}'}}), (target:Symbol {{id: '{}'}}) \
709 CREATE (caller)-[:CALLS_SERVICE {{method: '{}', path: '{}', target_service: '{}'}}]->(target)",
710 caller_sym, target_id, target_method, target_path, target_svc,
711 );
712 if gq.raw_query(&create_edge).is_ok() {
713 total += 1;
714 }
715 }
716 }
717
718 Ok(total)
719}
720
721pub fn index_group(
723 registry: &mut Registry,
724 group_name: &str,
725 full: bool,
726 build_registry: impl Fn() -> Result<LanguageRegistry>,
727) -> Result<Vec<(String, usize, usize)>> {
728 let group = registry
729 .groups
730 .get(group_name)
731 .context(format!("group '{}' not found", group_name))?
732 .clone();
733
734 let mut results = Vec::new();
735
736 for repo_name in &group.repos {
737 let entry = registry
738 .repos
739 .get(repo_name)
740 .context(format!("repo '{}' not in registry", repo_name))?
741 .clone();
742
743 if full {
744 let tg_dir = entry.path.join(".infigraph");
745 if tg_dir.exists() {
746 std::fs::remove_dir_all(&tg_dir)?;
747 }
748 }
749
750 let lang_registry = build_registry()?;
751 let mut prism = Infigraph::open(&entry.path, lang_registry)?;
752 prism.init()?;
753 let result = prism.index()?;
754 results.push((repo_name.clone(), result.indexed_files, result.total_files));
755
756 registry.register_repo(repo_name, &entry.path, &prism)?;
757 }
758
759 Ok(results)
760}
761
762pub fn promote_bridges_to_calls(store: &GraphStore) -> Result<usize> {
763 let conn = store.connection()?;
764 let gq = GraphQuery::new(&conn);
765
766 let query = "MATCH (a:Symbol)-[b:BRIDGE_TO]->(t:Symbol) RETURN a.id, t.id, b.bridge_kind";
767 let bridges = gq.raw_query(query)?;
768
769 let mut promoted = 0;
770 for row in &bridges {
771 if row.len() < 2 {
772 continue;
773 }
774 let source_id = &row[0];
775 let target_id = &row[1];
776
777 let check = format!(
778 "MATCH (a:Symbol {{id: '{}'}})-[:CALLS]->(b:Symbol {{id: '{}'}}) RETURN a.id",
779 source_id.replace('\'', "\\'"),
780 target_id.replace('\'', "\\'"),
781 );
782 let existing = gq.raw_query(&check).unwrap_or_default();
783 if !existing.is_empty() {
784 continue;
785 }
786
787 let insert = format!(
788 "MATCH (a:Symbol {{id: '{}'}}), (b:Symbol {{id: '{}'}}) CREATE (a)-[:CALLS]->(b)",
789 source_id.replace('\'', "\\'"),
790 target_id.replace('\'', "\\'"),
791 );
792 if gq.raw_query(&insert).is_ok() {
793 promoted += 1;
794 }
795 }
796 Ok(promoted)
797}
798
799fn registry_path() -> Result<PathBuf> {
800 let home = dirs_next::home_dir().context("cannot determine home directory")?;
801 Ok(home.join(".infigraph").join("registry.json"))
802}