Skip to main content

infigraph_core/multi/
mod.rs

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/// Global registry stored at ~/.infigraph/registry.json
15#[derive(Debug, Default, Serialize, Deserialize)]
16pub struct Registry {
17    pub repos: HashMap<String, RepoEntry>,
18    pub groups: HashMap<String, Group>,
19}
20
21/// A registered repository.
22#[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/// A group of repositories (e.g., microservice architecture).
32#[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/// A contract extracted from a service (HTTP route, gRPC endpoint, etc.).
40#[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    /// Load registry from ~/.infigraph/registry.json
60    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    /// Save registry to disk.
71    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    /// Register a repository after indexing.
82    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    /// Create a new group.
106    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    /// Add a repo to a group.
122    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    /// Remove a repo from a group.
137    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    /// Search across all repos in a group.
147    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
188/// Extract HTTP route contracts from a project's graph.
189///
190/// Sources (in priority order):
191/// 1. Route symbols (kind='Route') — from call-expression routing (Express, Gin, etc.)
192/// 2. Decorated functions — docstring contains route decorator (@app.route, #[get], etc.)
193/// 3. Heuristic detect_routes fallback
194pub 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    // 1. Route symbols (call-expression routes: Express, Gin, Django, etc.)
203    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    // 2. Decorated functions with route info in docstring
222    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
246/// Parse "GET /api/users" or "MAPGET /api/users" into (method, path).
247fn 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        // Normalize MapGet -> GET, MapPost -> POST, etc.
252        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
263/// Extract method and path from decorator docstrings.
264fn parse_route_from_docstring(doc: &str) -> (String, String) {
265    // @app.route("/api/users", methods=["GET"])
266    // @app.get("/api/users")
267    // #[get("/api/payments")]
268    // @GetMapping("/api/users")
269    let doc_lower = doc.to_lowercase();
270
271    // Extract path from quotes
272    let path = doc
273        .split('"')
274        .chain(doc.split('\''))
275        .find(|s| s.starts_with('/'))
276        .unwrap_or("")
277        .to_string();
278
279    // Extract method
280    let method = if doc_lower.contains("methods") {
281        // methods=["GET", "POST"] — take first
282        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
332/// Sync contracts for all repos in a group.
333pub 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/// A cross-service dependency: service A calls service B at a specific route.
373#[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
384/// Detect cross-service HTTP dependencies within a group.
385/// Scans source files for URL strings (fetch, http.get, requests.get, etc.)
386/// and matches them to known contracts/routes in other services.
387pub 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    // Collect all contracts as lookup table: path → (service, method)
398    let mut route_lookup: HashMap<String, (String, String)> = HashMap::new();
399    for contract in &group.contracts {
400        if contract.kind == ContractKind::HttpRoute {
401            // Normalize path for matching (strip params)
402            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        // Find symbols with URL-like strings in docstrings or search source files
433        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        // Also grep source files for URL patterns
459        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                    // Try to resolve line hint to enclosing symbol ID
465                    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
497/// Normalize a route path for matching: strip trailing slash, remove param placeholders.
498fn normalize_route_path(path: &str) -> String {
499    let path = path.trim_end_matches('/');
500    // Extract just the path portion from full URLs
501    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    // Normalize path params: /users/:id → /users/{id} → /users/*
512    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
526/// Extract API paths from a string (URL literals in code).
527fn 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
543/// Scan source files for URL strings containing /api/ patterns.
544fn 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
621/// Link cross-service HTTP dependencies as CALLS_SERVICE edges in each caller's graph.
622/// Returns number of edges created.
623pub 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    // Group deps by caller service
634    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            // Create ExternalService node — only use columns from Symbol schema.
682            // Use MERGE for idempotency (safe to run group_link multiple times).
683            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            // Check if edge already exists before creating (idempotent)
698            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
721/// Index all repos in a group. Returns Vec of (repo_name, indexed_files, total_files).
722pub 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}