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#[derive(Debug, Default, Serialize, Deserialize)]
21pub struct Registry {
22 pub repos: HashMap<String, RepoEntry>,
23 pub groups: HashMap<String, Group>,
24}
25
26#[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#[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#[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 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 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 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 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 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 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 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
193pub 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 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 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
251fn 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 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
268fn parse_route_from_docstring(doc: &str) -> (String, String) {
270 let doc_lower = doc.to_lowercase();
275
276 let path = doc
278 .split('"')
279 .chain(doc.split('\''))
280 .find(|s| s.starts_with('/'))
281 .unwrap_or("")
282 .to_string();
283
284 let method = if doc_lower.contains("methods") {
286 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
337pub 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
377pub 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}