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#[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
25pub 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 let mut route_lookup: HashMap<String, (String, String)> = HashMap::new();
40 for contract in &group.contracts {
41 if contract.kind == ContractKind::HttpRoute {
42 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 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 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 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
138pub 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 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 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 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
242fn normalize_route_path(path: &str) -> String {
244 let path = path.trim_end_matches('/');
245 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 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
271fn 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
288fn 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}