Skip to main content

infigraph_core/multi/
mod.rs

1mod bridge;
2pub mod combined;
3mod cross_service;
4pub mod grpc;
5
6pub use bridge::*;
7pub use cross_service::*;
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14
15use crate::graph::GraphQuery;
16use crate::lang::LanguageRegistry;
17use crate::Infigraph;
18
19/// Global registry stored at ~/.infigraph/registry.json
20#[derive(Debug, Default, Serialize, Deserialize)]
21pub struct Registry {
22    pub repos: HashMap<String, RepoEntry>,
23    pub groups: HashMap<String, Group>,
24}
25
26/// A registered repository.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RepoEntry {
29    pub name: String,
30    pub path: PathBuf,
31    pub languages: Vec<String>,
32    pub symbol_count: u64,
33    pub module_count: u64,
34}
35
36/// A group of repositories (e.g., microservice architecture).
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Group {
39    pub name: String,
40    pub repos: Vec<String>,
41    pub contracts: Vec<Contract>,
42}
43
44/// A contract extracted from a service (HTTP route, gRPC endpoint, etc.).
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Contract {
47    pub kind: ContractKind,
48    pub service: String,
49    pub method: String,
50    pub path: String,
51    pub symbol_id: String,
52    pub file: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub enum ContractKind {
57    HttpRoute,
58    GrpcService,
59    EventPublish,
60    EventSubscribe,
61}
62
63impl Registry {
64    /// Load registry from ~/.infigraph/registry.json
65    pub fn load() -> Result<Self> {
66        let path = registry_path()?;
67        if !path.exists() {
68            return Ok(Self::default());
69        }
70        let data = std::fs::read_to_string(&path)?;
71        let registry: Registry = serde_json::from_str(&data)?;
72        Ok(registry)
73    }
74
75    /// Save registry to disk.
76    pub fn save(&self) -> Result<()> {
77        let path = registry_path()?;
78        if let Some(parent) = path.parent() {
79            std::fs::create_dir_all(parent)?;
80        }
81        let data = serde_json::to_string_pretty(self)?;
82        std::fs::write(&path, data)?;
83        Ok(())
84    }
85
86    /// Register a repository after indexing.
87    pub fn register_repo(&mut self, name: &str, path: &Path, prism: &Infigraph) -> Result<()> {
88        let stats = prism.stats()?;
89        let langs: Vec<String> = prism
90            .registry()
91            .languages()
92            .map(|p| p.name.clone())
93            .collect();
94
95        let abs_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
96
97        self.repos.insert(
98            name.to_string(),
99            RepoEntry {
100                name: name.to_string(),
101                path: abs_path,
102                languages: langs,
103                symbol_count: stats.symbols,
104                module_count: stats.modules,
105            },
106        );
107        self.save()
108    }
109
110    /// Create a new group.
111    pub fn create_group(&mut self, name: &str) -> Result<()> {
112        if self.groups.contains_key(name) {
113            anyhow::bail!("group '{}' already exists", name);
114        }
115        self.groups.insert(
116            name.to_string(),
117            Group {
118                name: name.to_string(),
119                repos: Vec::new(),
120                contracts: Vec::new(),
121            },
122        );
123        self.save()
124    }
125
126    /// Add a repo to a group.
127    pub fn group_add(&mut self, group_name: &str, repo_name: &str) -> Result<()> {
128        let group = self
129            .groups
130            .get_mut(group_name)
131            .context(format!("group '{}' not found", group_name))?;
132        if !self.repos.contains_key(repo_name) {
133            anyhow::bail!("repo '{}' not registered. Run index first.", repo_name);
134        }
135        if !group.repos.contains(&repo_name.to_string()) {
136            group.repos.push(repo_name.to_string());
137        }
138        self.save()
139    }
140
141    /// Remove a repo from a group.
142    pub fn group_remove(&mut self, group_name: &str, repo_name: &str) -> Result<()> {
143        let group = self
144            .groups
145            .get_mut(group_name)
146            .context(format!("group '{}' not found", group_name))?;
147        group.repos.retain(|r| r != repo_name);
148        self.save()
149    }
150
151    /// Search across all repos in a group.
152    pub fn group_query(
153        &self,
154        group_name: &str,
155        cypher: &str,
156        build_registry: impl Fn() -> Result<LanguageRegistry>,
157    ) -> Result<Vec<(String, Vec<Vec<String>>)>> {
158        let group = self
159            .groups
160            .get(group_name)
161            .context(format!("group '{}' not found", group_name))?;
162
163        let mut results = Vec::new();
164        for repo_name in &group.repos {
165            let entry = self
166                .repos
167                .get(repo_name)
168                .context(format!("repo '{}' not in registry", repo_name))?;
169
170            let registry = build_registry()?;
171            let mut prism = Infigraph::open(&entry.path, registry)?;
172            prism.init()?;
173
174            let store = prism.store().context("graph not initialized")?;
175            let conn = store.connection()?;
176            let gq = GraphQuery::new(&conn);
177
178            match gq.raw_query(cypher) {
179                Ok(rows) => {
180                    if !rows.is_empty() {
181                        results.push((repo_name.clone(), rows));
182                    }
183                }
184                Err(e) => {
185                    eprintln!("warning: query failed for repo '{}': {}", repo_name, e);
186                }
187            }
188        }
189        Ok(results)
190    }
191}
192
193/// Extract HTTP route contracts from a project's graph.
194///
195/// Sources (in priority order):
196/// 1. Route symbols (kind='Route') — from call-expression routing (Express, Gin, etc.)
197/// 2. Decorated functions — docstring contains route decorator (@app.route, #[get], etc.)
198/// 3. Heuristic detect_routes fallback
199pub fn extract_contracts(prism: &Infigraph, service_name: &str) -> Result<Vec<Contract>> {
200    let store = prism.store().context("graph not initialized")?;
201    let conn = store.connection()?;
202    let gq = GraphQuery::new(&conn);
203
204    let mut contracts = Vec::new();
205    let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
206
207    // 1. Route symbols (call-expression routes: Express, Gin, Django, etc.)
208    let route_rows = gq.raw_query(
209        "MATCH (s:Symbol) WHERE s.kind = 'Route' RETURN s.id, s.name, s.kind, s.file, s.docstring",
210    )?;
211    for row in &route_rows {
212        let (method, path) = parse_route_name(&row[1]);
213        let key = format!("{} {}", method, path);
214        if seen_paths.insert(key) {
215            contracts.push(Contract {
216                kind: ContractKind::HttpRoute,
217                service: service_name.to_string(),
218                method,
219                path,
220                symbol_id: row[0].clone(),
221                file: row[3].clone(),
222            });
223        }
224    }
225
226    // 2. Decorated functions with route info in docstring
227    let decorated_rows = gq.raw_query(
228        "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",
229    )?;
230    for row in &decorated_rows {
231        let doc = row.get(4).map(|s| s.as_str()).unwrap_or("");
232        let (method, path) = parse_route_from_docstring(doc);
233        if !path.is_empty() {
234            let key = format!("{} {}", method, path);
235            if seen_paths.insert(key) {
236                contracts.push(Contract {
237                    kind: ContractKind::HttpRoute,
238                    service: service_name.to_string(),
239                    method,
240                    path,
241                    symbol_id: row[0].clone(),
242                    file: row[3].clone(),
243                });
244            }
245        }
246    }
247
248    Ok(contracts)
249}
250
251/// Parse "GET /api/users" or "MAPGET /api/users" into (method, path).
252fn parse_route_name(name: &str) -> (String, String) {
253    let parts: Vec<&str> = name.splitn(2, ' ').collect();
254    if parts.len() == 2 {
255        let method = parts[0].trim().to_uppercase();
256        // Normalize MapGet -> GET, MapPost -> POST, etc.
257        let method = if method.starts_with("MAP") {
258            method.trim_start_matches("MAP").to_string()
259        } else {
260            method
261        };
262        (method, parts[1].trim().to_string())
263    } else {
264        ("UNKNOWN".to_string(), name.to_string())
265    }
266}
267
268/// Extract method and path from decorator docstrings.
269fn parse_route_from_docstring(doc: &str) -> (String, String) {
270    // @app.route("/api/users", methods=["GET"])
271    // @app.get("/api/users")
272    // #[get("/api/payments")]
273    // @GetMapping("/api/users")
274    let doc_lower = doc.to_lowercase();
275
276    // Extract path from quotes
277    let path = doc
278        .split('"')
279        .chain(doc.split('\''))
280        .find(|s| s.starts_with('/'))
281        .unwrap_or("")
282        .to_string();
283
284    // Extract method
285    let method = if doc_lower.contains("methods") {
286        // methods=["GET", "POST"] — take first
287        if doc_lower.contains("\"get\"") || doc_lower.contains("'get'") {
288            "GET"
289        } else if doc_lower.contains("\"post\"") || doc_lower.contains("'post'") {
290            "POST"
291        } else if doc_lower.contains("\"put\"") || doc_lower.contains("'put'") {
292            "PUT"
293        } else if doc_lower.contains("\"delete\"") || doc_lower.contains("'delete'") {
294            "DELETE"
295        } else if doc_lower.contains("\"patch\"") || doc_lower.contains("'patch'") {
296            "PATCH"
297        } else {
298            "UNKNOWN"
299        }
300    } else if doc_lower.contains("@app.get")
301        || doc_lower.contains("#[get")
302        || doc_lower.contains("getmapping")
303        || doc_lower.contains("mapget")
304    {
305        "GET"
306    } else if doc_lower.contains("@app.post")
307        || doc_lower.contains("#[post")
308        || doc_lower.contains("postmapping")
309        || doc_lower.contains("mappost")
310    {
311        "POST"
312    } else if doc_lower.contains("@app.put")
313        || doc_lower.contains("#[put")
314        || doc_lower.contains("putmapping")
315        || doc_lower.contains("mapput")
316    {
317        "PUT"
318    } else if doc_lower.contains("@app.delete")
319        || doc_lower.contains("#[delete")
320        || doc_lower.contains("deletemapping")
321        || doc_lower.contains("mapdelete")
322    {
323        "DELETE"
324    } else if doc_lower.contains("@app.patch")
325        || doc_lower.contains("#[patch")
326        || doc_lower.contains("patchmapping")
327        || doc_lower.contains("mappatch")
328    {
329        "PATCH"
330    } else {
331        "UNKNOWN"
332    };
333
334    (method.to_string(), path)
335}
336
337/// Sync contracts for all repos in a group.
338pub fn sync_group_contracts(
339    registry: &mut Registry,
340    group_name: &str,
341    build_registry: impl Fn() -> Result<LanguageRegistry>,
342) -> Result<usize> {
343    let group = registry
344        .groups
345        .get(group_name)
346        .context(format!("group '{}' not found", group_name))?
347        .clone();
348
349    let mut all_contracts = Vec::new();
350
351    for repo_name in &group.repos {
352        let entry = registry
353            .repos
354            .get(repo_name)
355            .context(format!("repo '{}' not in registry", repo_name))?
356            .clone();
357
358        let lang_registry = build_registry()?;
359        let mut prism = Infigraph::open(&entry.path, lang_registry)?;
360        prism.init()?;
361
362        let contracts = extract_contracts(&prism, repo_name)?;
363        all_contracts.extend(contracts);
364    }
365
366    let count = all_contracts.len();
367    let group = registry
368        .groups
369        .get_mut(group_name)
370        .context("group not found")?;
371    group.contracts = all_contracts;
372    registry.save()?;
373
374    Ok(count)
375}
376
377/// Index all repos in a group. Returns Vec of (repo_name, indexed_files, total_files).
378pub fn index_group(
379    registry: &mut Registry,
380    group_name: &str,
381    full: bool,
382    build_registry: impl Fn() -> Result<LanguageRegistry>,
383) -> Result<Vec<(String, usize, usize)>> {
384    let group = registry
385        .groups
386        .get(group_name)
387        .context(format!("group '{}' not found", group_name))?
388        .clone();
389
390    let mut results = Vec::new();
391
392    for repo_name in &group.repos {
393        let entry = registry
394            .repos
395            .get(repo_name)
396            .context(format!("repo '{}' not in registry", repo_name))?
397            .clone();
398
399        if full {
400            let tg_dir = entry.path.join(".infigraph");
401            if tg_dir.exists() {
402                std::fs::remove_dir_all(&tg_dir)?;
403            }
404        }
405
406        let lang_registry = build_registry()?;
407        let mut prism = Infigraph::open(&entry.path, lang_registry)?;
408        prism.init()?;
409        let result = prism.index()?;
410        results.push((repo_name.clone(), result.indexed_files, result.total_files));
411
412        registry.register_repo(repo_name, &entry.path, &prism)?;
413    }
414
415    Ok(results)
416}
417
418fn registry_path() -> Result<PathBuf> {
419    let home = dirs_next::home_dir().context("cannot determine home directory")?;
420    Ok(home.join(".infigraph").join("registry.json"))
421}