Skip to main content

infigraph_core/multi/
cross_service.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::graph::GraphQuery;
8use crate::lang::LanguageRegistry;
9use crate::Infigraph;
10
11use super::{ContractKind, Registry};
12
13/// A cross-service dependency: service A calls service B at a specific route.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CrossServiceDep {
16    pub caller_service: String,
17    pub caller_file: String,
18    pub caller_symbol: String,
19    pub target_service: String,
20    pub target_method: String,
21    pub target_path: String,
22    pub url_found: String,
23}
24
25/// Detect cross-service HTTP dependencies within a group.
26/// Scans source files for URL strings (fetch, http.get, requests.get, etc.)
27/// and matches them to known contracts/routes in other services.
28pub fn detect_cross_service_deps(
29    registry: &Registry,
30    group_name: &str,
31    build_registry: impl Fn() -> Result<LanguageRegistry>,
32) -> Result<Vec<CrossServiceDep>> {
33    let group = registry
34        .groups
35        .get(group_name)
36        .context(format!("group '{}' not found", group_name))?;
37
38    // Collect all contracts as lookup table: path → (service, method)
39    let mut route_lookup: HashMap<String, (String, String)> = HashMap::new();
40    for contract in &group.contracts {
41        if contract.kind == ContractKind::HttpRoute {
42            // Normalize path for matching (strip params)
43            let normalized = normalize_route_path(&contract.path);
44            route_lookup.insert(
45                normalized,
46                (contract.service.clone(), contract.method.clone()),
47            );
48        }
49    }
50
51    let mut deps = Vec::new();
52
53    for repo_name in &group.repos {
54        let entry = match registry.repos.get(repo_name) {
55            Some(e) => e.clone(),
56            None => continue,
57        };
58
59        let lang_registry = build_registry()?;
60        let mut prism = Infigraph::open(&entry.path, lang_registry)?;
61        prism.init()?;
62
63        let store = match prism.store() {
64            Some(s) => s,
65            None => continue,
66        };
67        let conn = match store.connection() {
68            Ok(c) => c,
69            Err(_) => continue,
70        };
71        let gq = GraphQuery::new(&conn);
72
73        // Find symbols with URL-like strings in docstrings or search source files
74        let rows = gq.raw_query(
75            "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",
76        ).unwrap_or_default();
77
78        for row in &rows {
79            let doc = row.get(3).map(|s| s.as_str()).unwrap_or("");
80            let urls = extract_api_paths(doc);
81            for url in urls {
82                let normalized = normalize_route_path(&url);
83                if let Some((target_svc, target_method)) = route_lookup.get(&normalized) {
84                    if target_svc != repo_name {
85                        deps.push(CrossServiceDep {
86                            caller_service: repo_name.clone(),
87                            caller_file: row[2].clone(),
88                            caller_symbol: row[0].clone(),
89                            target_service: target_svc.clone(),
90                            target_method: target_method.clone(),
91                            target_path: url.clone(),
92                            url_found: url,
93                        });
94                    }
95                }
96            }
97        }
98
99        // Also grep source files for URL patterns
100        let source_urls = scan_source_for_urls(&entry.path);
101        for (file, symbol_hint, url) in source_urls {
102            let normalized = normalize_route_path(&url);
103            if let Some((target_svc, target_method)) = route_lookup.get(&normalized) {
104                if target_svc != repo_name {
105                    // Try to resolve line hint to enclosing symbol ID
106                    let caller_id = if let Some(stripped) = symbol_hint.strip_prefix("line:") {
107                        let line_num: i32 = stripped.parse().unwrap_or(0);
108                        let escaped_file = file.replace('\'', "\\'");
109                        let q = format!(
110                            "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",
111                            escaped_file, line_num, line_num
112                        );
113                        gq.raw_query(&q)
114                            .ok()
115                            .and_then(|rows| rows.into_iter().next())
116                            .and_then(|row| row.into_iter().next())
117                            .unwrap_or_else(|| format!("{}:{}", file, symbol_hint))
118                    } else {
119                        symbol_hint.clone()
120                    };
121                    deps.push(CrossServiceDep {
122                        caller_service: repo_name.clone(),
123                        caller_file: file,
124                        caller_symbol: caller_id,
125                        target_service: target_svc.clone(),
126                        target_method: target_method.clone(),
127                        target_path: url.clone(),
128                        url_found: url,
129                    });
130                }
131            }
132        }
133    }
134
135    Ok(deps)
136}
137
138/// Link cross-service HTTP dependencies as CALLS_SERVICE edges in each caller's graph.
139/// Returns number of edges created.
140pub fn link_cross_service_calls(
141    registry: &Registry,
142    group_name: &str,
143    build_registry: impl Fn() -> Result<LanguageRegistry>,
144) -> Result<usize> {
145    let deps = detect_cross_service_deps(registry, group_name, &build_registry)?;
146    if deps.is_empty() {
147        return Ok(0);
148    }
149
150    // Group deps by caller service
151    let mut by_caller: HashMap<String, Vec<&CrossServiceDep>> = HashMap::new();
152    for dep in &deps {
153        by_caller
154            .entry(dep.caller_service.clone())
155            .or_default()
156            .push(dep);
157    }
158
159    let mut total = 0;
160
161    for (caller_svc, svc_deps) in &by_caller {
162        let entry = match registry.repos.get(caller_svc) {
163            Some(e) => e,
164            None => continue,
165        };
166
167        let lang_registry = build_registry()?;
168        let mut prism = Infigraph::open(&entry.path, lang_registry)?;
169        prism.init()?;
170
171        let store = match prism.store() {
172            Some(s) => s,
173            None => continue,
174        };
175        let _lock = match store.write_lock() {
176            Ok(l) => l,
177            Err(_) => continue,
178        };
179        let conn = match store.connection() {
180            Ok(c) => c,
181            Err(_) => continue,
182        };
183        let gq = GraphQuery::new(&conn);
184
185        for dep in svc_deps {
186            let target_id = format!(
187                "xsvc::{}::{}::{}",
188                dep.target_service,
189                dep.target_method,
190                dep.target_path.replace('\'', "\\'")
191            );
192            let target_name = format!(
193                "{} {} {}",
194                dep.target_service, dep.target_method, dep.target_path
195            )
196            .replace('\'', "\\'");
197            let caller_sym = dep.caller_symbol.replace('\'', "\\'");
198            let target_svc = dep.target_service.replace('\'', "\\'");
199            let target_method = dep.target_method.replace('\'', "\\'");
200            let target_path = dep.target_path.replace('\'', "\\'");
201
202            // Create ExternalService node — only use columns from Symbol schema.
203            // Use MERGE for idempotency (safe to run group_link multiple times).
204            let docstring = format!(
205                "External service: {} {} {}",
206                target_svc, target_method, target_path
207            );
208            let create_target = format!(
209                "MERGE (t:Symbol {{id: '{}'}}) \
210                 ON CREATE SET t.name = '{}', t.kind = 'ExternalService', \
211                 t.file = '(external)', t.start_line = 0, t.end_line = 0, \
212                 t.signature_hash = '', t.language = 'external', t.visibility = 'public', \
213                 t.parent = '', t.docstring = '{}', t.complexity = 0",
214                target_id, target_name, docstring,
215            );
216            let _ = gq.raw_query(&create_target);
217
218            // Check if edge already exists before creating (idempotent)
219            let check_edge = format!(
220                "MATCH (caller:Symbol {{id: '{}'}})-[:CALLS_SERVICE]->(target:Symbol {{id: '{}'}}) RETURN caller.id",
221                caller_sym, target_id,
222            );
223            let existing = gq.raw_query(&check_edge).unwrap_or_default();
224            if !existing.is_empty() {
225                continue;
226            }
227
228            let create_edge = format!(
229                "MATCH (caller:Symbol {{id: '{}'}}), (target:Symbol {{id: '{}'}}) \
230                 CREATE (caller)-[:CALLS_SERVICE {{method: '{}', path: '{}', target_service: '{}'}}]->(target)",
231                caller_sym, target_id, target_method, target_path, target_svc,
232            );
233            if gq.raw_query(&create_edge).is_ok() {
234                total += 1;
235            }
236        }
237    }
238
239    Ok(total)
240}
241
242/// Normalize a route path for matching: strip trailing slash, remove param placeholders.
243fn normalize_route_path(path: &str) -> String {
244    let path = path.trim_end_matches('/');
245    // Extract just the path portion from full URLs
246    let path = if let Some(idx) = path.find("/api/") {
247        &path[idx..]
248    } else if path.starts_with("http") {
249        path.split("//")
250            .nth(1)
251            .and_then(|s| s.find('/').map(|i| &s[i..]))
252            .unwrap_or(path)
253    } else {
254        path
255    };
256    // Normalize path params: /users/:id → /users/{id} → /users/*
257    let segments: Vec<&str> = path.split('/').collect();
258    segments
259        .iter()
260        .map(|s| {
261            if s.starts_with(':') || s.starts_with('{') || s.starts_with('<') {
262                "*"
263            } else {
264                s
265            }
266        })
267        .collect::<Vec<_>>()
268        .join("/")
269}
270
271/// Extract API paths from a string (URL literals in code).
272fn extract_api_paths(text: &str) -> Vec<String> {
273    let mut paths = Vec::new();
274    for part in text
275        .split('"')
276        .chain(text.split('\'').chain(text.split('`')))
277    {
278        let trimmed = part.trim();
279        if (trimmed.starts_with("/api/") || trimmed.starts_with("http"))
280            && trimmed.contains("/api/")
281        {
282            paths.push(trimmed.to_string());
283        }
284    }
285    paths
286}
287
288/// Scan source files for URL strings containing /api/ patterns.
289fn scan_source_for_urls(root: &Path) -> Vec<(String, String, String)> {
290    const SKIP_DIRS: &[&str] = &[
291        ".infigraph",
292        ".git",
293        "node_modules",
294        "target",
295        "build",
296        "dist",
297        "__pycache__",
298        ".venv",
299    ];
300    let mut results = Vec::new();
301    walk_for_urls(root, root, SKIP_DIRS, &mut results);
302    results
303}
304
305fn walk_for_urls(
306    base: &Path,
307    dir: &Path,
308    skip: &[&str],
309    results: &mut Vec<(String, String, String)>,
310) {
311    let entries = match std::fs::read_dir(dir) {
312        Ok(e) => e,
313        Err(_) => return,
314    };
315    for entry in entries.flatten() {
316        let path = entry.path();
317        let name = entry.file_name();
318        let name_str = name.to_string_lossy();
319
320        if path.is_dir() {
321            if !skip.contains(&name_str.as_ref()) && !name_str.starts_with('.') {
322                walk_for_urls(base, &path, skip, results);
323            }
324        } else if path.is_file() {
325            let rel = path
326                .strip_prefix(base)
327                .unwrap_or(&path)
328                .to_string_lossy()
329                .replace('\\', "/");
330            let content = match std::fs::read_to_string(&path) {
331                Ok(c) => c,
332                Err(_) => continue,
333            };
334            for (line_num, line) in content.lines().enumerate() {
335                for delim in ['"', '\'', '`'] {
336                    for part in line.split(delim) {
337                        let trimmed = part.trim();
338                        if trimmed.contains("/api/")
339                            && trimmed.len() < 200
340                            && !trimmed.contains(' ')
341                        {
342                            let path_part = if trimmed.starts_with("http") {
343                                trimmed
344                                    .split("//")
345                                    .nth(1)
346                                    .and_then(|s| s.find('/').map(|i| &s[i..]))
347                                    .unwrap_or(trimmed)
348                            } else {
349                                trimmed
350                            };
351                            if path_part.starts_with("/api/") {
352                                results.push((
353                                    rel.clone(),
354                                    format!("line:{}", line_num + 1),
355                                    path_part.to_string(),
356                                ));
357                            }
358                        }
359                    }
360                }
361            }
362        }
363    }
364}