use crate::analysis::dead_code::Confidence;
use crate::storage::capability::Storage;
use crate::storage::error::Result as StorageResult;
use crate::storage::schema::escape_cypher_string;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
const CROSS_SERVICE_CALLS_EDGE_TYPE: &str = "CROSS_SERVICE_CALLS";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MatchType {
Exact,
Parameterized,
Wildcard,
Pattern,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct CrossServiceLink {
pub route_id: String,
pub route_pattern: String,
pub caller_id: String,
pub caller_file: String,
pub caller_line: u32,
pub match_type: MatchType,
}
pub struct CrossServiceLinker<'a> {
storage: &'a dyn Storage,
project: String,
}
impl<'a> CrossServiceLinker<'a> {
#[must_use]
pub fn new(storage: &'a dyn Storage, project: impl Into<String>) -> Self {
Self {
storage,
project: project.into(),
}
}
pub fn link(&self) -> StorageResult<Vec<CrossServiceLink>> {
let routes = self.load_routes()?;
if routes.is_empty() {
return Ok(Vec::new());
}
let callers = self.load_callers()?;
if callers.is_empty() {
return Ok(Vec::new());
}
let existing_edges = self.load_existing_edges()?;
let mut links: Vec<CrossServiceLink> = Vec::new();
let mut next_edge_id = self.next_edge_id(&existing_edges)?;
for caller in &callers {
if caller.content.is_empty() {
continue;
}
let literals = extract_string_literals(&caller.content);
for literal in &literals {
for route in &routes {
if let Some(match_type) = match_route(&route.path, literal) {
let edge_key = (caller.id.clone(), route.id.clone());
if !existing_edges.contains(&edge_key) {
self.persist_edge(&next_edge_id, &caller.id, &route.id)?;
next_edge_id += 1;
}
links.push(CrossServiceLink {
route_id: route.id.clone(),
route_pattern: route.path.clone(),
caller_id: caller.id.clone(),
caller_file: caller.file_path.clone(),
caller_line: caller.start_line,
match_type,
});
}
}
}
}
Ok(links)
}
fn load_routes(&self) -> StorageResult<Vec<RouteRow>> {
let escaped = escape_cypher_string(&self.project);
let cypher = format!(
"MATCH (r:Route) WHERE r.project = '{escaped}' \
RETURN r.id AS id, r.path AS path;"
);
let rows = self.storage.query(&cypher)?;
let mut out = Vec::new();
for row in rows {
if row.len() < 2 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let path = row[1].as_str().unwrap_or_default().to_string();
if !id.is_empty() && !path.is_empty() {
out.push(RouteRow { id, path });
}
}
Ok(out)
}
fn load_callers(&self) -> StorageResult<Vec<CallerRow>> {
let escaped = escape_cypher_string(&self.project);
let mut out = Vec::new();
for table in &["Function", "Method"] {
let cypher = format!(
"MATCH (n:{table}) WHERE n.project = '{escaped}' \
RETURN n.id AS id, n.filePath AS file_path, \
n.startLine AS start_line, n.content AS content;"
);
let rows = self.storage.query(&cypher)?;
for row in rows {
if row.len() < 4 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let file_path = row[1].as_str().unwrap_or_default().to_string();
let start_line = row[2]
.as_i64()
.map(|v| v as u32)
.or_else(|| row[2].as_u64().map(|v| v as u32))
.unwrap_or(0);
let content = row[3].as_str().unwrap_or_default().to_string();
if !id.is_empty() {
out.push(CallerRow {
id,
file_path,
start_line,
content,
});
}
}
}
Ok(out)
}
fn load_existing_edges(&self) -> StorageResult<std::collections::HashSet<(String, String)>> {
let escaped = escape_cypher_string(&self.project);
let cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'CROSS_SERVICE_CALLS' \
AND e.project = '{escaped}' \
RETURN e.source AS source, e.target AS target;"
);
let rows = self.storage.query(&cypher)?;
let mut out = std::collections::HashSet::new();
for row in rows {
if row.len() < 2 {
continue;
}
let src = row[0].as_str().unwrap_or_default().to_string();
let dst = row[1].as_str().unwrap_or_default().to_string();
if !src.is_empty() && !dst.is_empty() {
out.insert((src, dst));
}
}
Ok(out)
}
fn next_edge_id(
&self,
existing: &std::collections::HashSet<(String, String)>,
) -> StorageResult<u64> {
let escaped = escape_cypher_string(&self.project);
let cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'CROSS_SERVICE_CALLS' \
AND e.project = '{escaped}' RETURN e.id AS id;"
);
let rows = self.storage.query(&cypher)?;
let mut max_idx = 0u64;
for row in rows {
if let Some(id_str) = row.first().and_then(|v| v.as_str()) {
if let Some(suffix) = id_str.strip_prefix("csl_") {
if let Ok(n) = suffix.parse::<u64>() {
if n > max_idx {
max_idx = n;
}
}
}
}
}
let _ = existing; Ok(max_idx + 1)
}
fn persist_edge(&self, idx: &u64, caller_id: &str, route_id: &str) -> StorageResult<()> {
let edge_id = format!("csl_{idx}");
let cypher = format!(
"CREATE (:CodeRelation {{id: '{}', source: '{}', target: '{}', \
type: '{}', confidence: 1.0, confidenceTier: 'High', reason: 'route pattern match', \
startLine: 0, project: '{}'}});",
escape_cypher_string(&edge_id),
escape_cypher_string(caller_id),
escape_cypher_string(route_id),
CROSS_SERVICE_CALLS_EDGE_TYPE,
escape_cypher_string(&self.project),
);
self.storage.execute(&cypher)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServiceProtocol {
HttpRest,
Grpc,
GraphQL,
MessageQueue,
EventBus,
}
impl fmt::Display for ServiceProtocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::HttpRest => f.write_str("http_rest"),
Self::Grpc => f.write_str("grpc"),
Self::GraphQL => f.write_str("graphql"),
Self::MessageQueue => f.write_str("message_queue"),
Self::EventBus => f.write_str("event_bus"),
}
}
}
impl FromStr for ServiceProtocol {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"" => Err("protocol is empty".to_string()),
"http_rest" | "http" | "rest" => Ok(Self::HttpRest),
"grpc" => Ok(Self::Grpc),
"graphql" => Ok(Self::GraphQL),
"message_queue" | "mq" | "message" => Ok(Self::MessageQueue),
"event_bus" | "event" => Ok(Self::EventBus),
other => Err(format!("unknown protocol: {other}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CrossServiceMatch {
pub caller: String,
pub callee: String,
pub protocol: ServiceProtocol,
pub match_type: MatchType,
pub confidence: Confidence,
pub reason: String,
}
pub struct CrossServiceDetector<'a> {
storage: &'a dyn Storage,
}
impl<'a> CrossServiceDetector<'a> {
#[must_use]
pub fn new(storage: &'a dyn Storage) -> Self {
Self { storage }
}
pub fn detect_all(&self, project: &str) -> StorageResult<Vec<CrossServiceMatch>> {
let mut matches = Vec::new();
matches.extend(self.detect_http(project)?);
matches.extend(self.detect_grpc(project)?);
matches.extend(self.detect_graphql(project)?);
matches.extend(self.detect_message_queue(project)?);
matches.extend(self.detect_event_bus(project)?);
matches.sort_by_key(|m| confidence_rank(m.confidence));
Ok(matches)
}
pub fn detect_http(&self, project: &str) -> StorageResult<Vec<CrossServiceMatch>> {
let routes = self.load_routes(project)?;
if routes.is_empty() {
return Ok(Vec::new());
}
let callers = self.load_callers(project)?;
let mut matches = Vec::new();
for caller in &callers {
if caller.content.is_empty() {
continue;
}
let literals = extract_string_literals(&caller.content);
for literal in &literals {
for route in &routes {
if let Some(match_type) = match_route(&route.path, literal) {
let confidence =
self.score_confidence(&match_type, &ServiceProtocol::HttpRest);
matches.push(CrossServiceMatch {
caller: caller.id.clone(),
callee: route.id.clone(),
protocol: ServiceProtocol::HttpRest,
match_type,
confidence,
reason: format!("HTTP route pattern match: {}", route.path),
});
}
}
}
}
Ok(matches)
}
pub fn detect_grpc(&self, project: &str) -> StorageResult<Vec<CrossServiceMatch>> {
let callers = self.load_callers(project)?;
let mut matches = Vec::new();
for caller in &callers {
if caller.content.is_empty() {
continue;
}
for url in extract_grpc_urls(&caller.content) {
matches.push(CrossServiceMatch {
caller: caller.id.clone(),
callee: url.clone(),
protocol: ServiceProtocol::Grpc,
match_type: MatchType::Pattern,
confidence: self.score_confidence(&MatchType::Pattern, &ServiceProtocol::Grpc),
reason: format!("gRPC URL: {url}"),
});
}
for method in extract_grpc_client_calls(&caller.content) {
matches.push(CrossServiceMatch {
caller: caller.id.clone(),
callee: method.clone(),
protocol: ServiceProtocol::Grpc,
match_type: MatchType::Pattern,
confidence: self.score_confidence(&MatchType::Pattern, &ServiceProtocol::Grpc),
reason: format!("gRPC client call: {method}"),
});
}
}
Ok(matches)
}
pub fn detect_graphql(&self, project: &str) -> StorageResult<Vec<CrossServiceMatch>> {
let callers = self.load_callers(project)?;
let mut matches = Vec::new();
for caller in &callers {
if caller.content.is_empty() {
continue;
}
for op in extract_graphql_operations(&caller.content) {
matches.push(CrossServiceMatch {
caller: caller.id.clone(),
callee: op.clone(),
protocol: ServiceProtocol::GraphQL,
match_type: MatchType::Pattern,
confidence: self
.score_confidence(&MatchType::Pattern, &ServiceProtocol::GraphQL),
reason: format!("GraphQL {op}"),
});
}
}
Ok(matches)
}
pub fn detect_message_queue(&self, project: &str) -> StorageResult<Vec<CrossServiceMatch>> {
let callers = self.load_callers(project)?;
let mut matches = Vec::new();
for caller in &callers {
if caller.content.is_empty() {
continue;
}
for (callee, direction) in extract_mq_patterns(&caller.content) {
matches.push(CrossServiceMatch {
caller: caller.id.clone(),
callee: callee.clone(),
protocol: ServiceProtocol::MessageQueue,
match_type: MatchType::Pattern,
confidence: self
.score_confidence(&MatchType::Pattern, &ServiceProtocol::MessageQueue),
reason: format!("MessageQueue {direction}: {callee}"),
});
}
}
Ok(matches)
}
pub fn detect_event_bus(&self, project: &str) -> StorageResult<Vec<CrossServiceMatch>> {
let callers = self.load_callers(project)?;
let mut matches = Vec::new();
for caller in &callers {
if caller.content.is_empty() {
continue;
}
for (callee, direction) in extract_event_bus_patterns(&caller.content) {
matches.push(CrossServiceMatch {
caller: caller.id.clone(),
callee: callee.clone(),
protocol: ServiceProtocol::EventBus,
match_type: MatchType::Pattern,
confidence: self
.score_confidence(&MatchType::Pattern, &ServiceProtocol::EventBus),
reason: format!("EventBus {direction}: {callee}"),
});
}
}
Ok(matches)
}
fn score_confidence(&self, match_type: &MatchType, protocol: &ServiceProtocol) -> Confidence {
match (match_type, protocol) {
(MatchType::Exact, ServiceProtocol::HttpRest | ServiceProtocol::Grpc) => {
Confidence::High
}
(MatchType::Exact, _) => Confidence::Medium,
(MatchType::Parameterized, _) => Confidence::Medium,
(MatchType::Pattern, _) | (MatchType::Wildcard, _) => Confidence::Low,
}
}
fn load_routes(&self, project: &str) -> StorageResult<Vec<RouteRow>> {
let escaped = escape_cypher_string(project);
let cypher = format!(
"MATCH (r:Route) WHERE r.project = '{escaped}' \
RETURN r.id AS id, r.path AS path;"
);
let rows = self.storage.query(&cypher)?;
let mut out = Vec::new();
for row in rows {
if row.len() < 2 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let path = row[1].as_str().unwrap_or_default().to_string();
if !id.is_empty() && !path.is_empty() {
out.push(RouteRow { id, path });
}
}
Ok(out)
}
fn load_callers(&self, project: &str) -> StorageResult<Vec<CallerRow>> {
let escaped = escape_cypher_string(project);
let mut out = Vec::new();
for table in &["Function", "Method"] {
let cypher = format!(
"MATCH (n:{table}) WHERE n.project = '{escaped}' \
RETURN n.id AS id, n.filePath AS file_path, \
n.startLine AS start_line, n.content AS content;"
);
let rows = self.storage.query(&cypher)?;
for row in rows {
if row.len() < 4 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let file_path = row[1].as_str().unwrap_or_default().to_string();
let start_line = row[2]
.as_i64()
.map(|v| v as u32)
.or_else(|| row[2].as_u64().map(|v| v as u32))
.unwrap_or(0);
let content = row[3].as_str().unwrap_or_default().to_string();
if !id.is_empty() {
out.push(CallerRow {
id,
file_path,
start_line,
content,
});
}
}
}
Ok(out)
}
}
struct RouteRow {
id: String,
path: String,
}
struct CallerRow {
id: String,
file_path: String,
start_line: u32,
content: String,
}
#[must_use]
fn match_route(pattern: &str, literal: &str) -> Option<MatchType> {
if pattern == literal {
return Some(MatchType::Exact);
}
if pattern.contains(':') && match_parameterized(pattern, literal) {
return Some(MatchType::Parameterized);
}
if pattern.contains('*') && match_wildcard(pattern, literal) {
return Some(MatchType::Wildcard);
}
None
}
fn match_parameterized(pattern: &str, literal: &str) -> bool {
let pat_parts: Vec<&str> = pattern.split('/').collect();
let lit_parts: Vec<&str> = literal.split('/').collect();
if pat_parts.len() != lit_parts.len() {
return false;
}
for (p, l) in pat_parts.iter().zip(lit_parts.iter()) {
if p.starts_with(':') && !p.is_empty() {
if l.is_empty() {
return false;
}
} else if *p != *l {
return false;
}
}
true
}
fn match_wildcard(pattern: &str, literal: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == literal;
}
let first = parts[0];
if !literal.starts_with(first) {
return false;
}
let mut cursor = first.len();
for part in &parts[1..parts.len() - 1] {
if part.is_empty() {
continue;
}
let rest = &literal[cursor..];
match rest.find(part) {
Some(idx) => cursor += idx + part.len(),
None => return false,
}
}
let last = parts.last().copied().unwrap_or("");
if !last.is_empty() && !literal[cursor..].ends_with(last) {
return false;
}
true
}
fn extract_string_literals(content: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = content.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'"' {
let start = i + 1;
let mut j = start;
let mut buf = String::new();
while j < bytes.len() {
if bytes[j] == b'\\' && j + 1 < bytes.len() {
match bytes[j + 1] {
b'"' => {
buf.push('"');
j += 2;
continue;
}
b'\\' => {
buf.push('\\');
j += 2;
continue;
}
b'n' => {
buf.push('\n');
j += 2;
continue;
}
b't' => {
buf.push('\t');
j += 2;
continue;
}
_ => {
buf.push('\\');
j += 1;
continue;
}
}
}
if bytes[j] == b'"' {
break;
}
buf.push(bytes[j] as char);
j += 1;
}
if j < bytes.len() && bytes[j] == b'"' {
out.push(buf);
i = j + 1;
} else {
i = start;
}
} else {
i += 1;
}
}
out
}
fn extract_grpc_urls(content: &str) -> Vec<String> {
let mut urls = Vec::new();
let pattern = "grpc://";
let mut cursor = 0;
while let Some(idx) = content[cursor..].find(pattern) {
let start = cursor + idx + pattern.len();
let rest = &content[start..];
let end = rest
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ';' || c == ')')
.unwrap_or(rest.len());
urls.push(format!("grpc://{}", &rest[..end]));
cursor = start + end;
}
urls
}
fn extract_grpc_client_calls(content: &str) -> Vec<String> {
let mut calls = Vec::new();
let pattern = "client.";
let mut cursor = 0;
while let Some(idx) = content[cursor..].find(pattern) {
let start = cursor + idx + pattern.len();
let rest = &content[start..];
let end = rest
.find(|c: char| !c.is_alphanumeric() && c != '_')
.unwrap_or(rest.len());
if end > 0 {
let method = &rest[..end];
let starts_upper = method
.chars()
.next()
.map(|c| c.is_uppercase())
.unwrap_or(false);
if starts_upper {
let after = rest[end..].trim_start();
if after.starts_with('(') {
calls.push(format!("client.{method}"));
}
}
}
cursor = start + end;
}
calls
}
fn extract_graphql_operations(content: &str) -> Vec<String> {
let mut ops = Vec::new();
for (pattern, name) in &[
("query {", "query"),
("mutation {", "mutation"),
("subscription {", "subscription"),
] {
if content.contains(pattern) {
ops.push(name.to_string());
}
}
for lit in extract_string_literals(content) {
if lit.contains("/graphql") {
ops.push(format!("endpoint: {lit}"));
}
}
ops
}
const MQ_KEYWORDS: &[(&str, &str)] = &[
("producer.send", "EMITS"),
("consumer.subscribe", "LISTENS_ON"),
("channel.publish", "EMITS"),
("channel.consume", "LISTENS_ON"),
];
const EVENT_BUS_KEYWORDS: &[(&str, &str)] = &[
("io.emit", "EMITS"),
("socket.on", "LISTENS_ON"),
("emitter.emit", "EMITS"),
("emitter.on", "LISTENS_ON"),
];
fn extract_patterns_by_keywords(
content: &str,
keywords: &'static [(&'static str, &'static str)],
protocol: ServiceProtocol,
) -> Vec<(String, &'static str)> {
let trim_single_quote = matches!(protocol, ServiceProtocol::EventBus);
let mut results = Vec::new();
for (pattern, direction) in keywords {
let mut cursor = 0;
while let Some(idx) = content[cursor..].find(*pattern) {
let after_pattern = &content[cursor + idx + pattern.len()..];
let trimmed = after_pattern.trim_start();
if let Some(rest) = trimmed.strip_prefix('(') {
let end = rest.find([',', ')']).unwrap_or(rest.len());
let arg = if trim_single_quote {
rest[..end].trim().trim_matches(|c| c == '"' || c == '\'')
} else {
rest[..end].trim().trim_matches('"')
};
if !arg.is_empty() {
results.push((arg.to_string(), *direction));
}
}
cursor += idx + pattern.len();
}
}
results
}
fn extract_mq_patterns(content: &str) -> Vec<(String, &'static str)> {
extract_patterns_by_keywords(content, MQ_KEYWORDS, ServiceProtocol::MessageQueue)
}
fn extract_event_bus_patterns(content: &str) -> Vec<(String, &'static str)> {
extract_patterns_by_keywords(content, EVENT_BUS_KEYWORDS, ServiceProtocol::EventBus)
}
fn confidence_rank(c: Confidence) -> u8 {
match c {
Confidence::High => 0,
Confidence::Medium => 1,
Confidence::Low => 2,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kit::{build_kit, AsyncKit, AsyncReady, KitBootstrapConfig, StorageModule};
use tempfile::TempDir;
fn fresh_db_path() -> std::path::PathBuf {
let dir = TempDir::new().unwrap();
let path = dir.path().join("cross_service_testdb");
std::mem::forget(dir);
path
}
fn build_kit_for_db(db: &std::path::Path) -> AsyncKit<AsyncReady> {
let config = KitBootstrapConfig::new(db.to_path_buf());
tokio::runtime::Runtime::new()
.unwrap()
.block_on(build_kit(&config))
.expect("build_kit")
}
fn storage(
kit: &AsyncKit<AsyncReady>,
) -> std::sync::Arc<dyn crate::storage::capability::Storage> {
kit.require::<StorageModule>().expect("require_storage")
}
fn create_route(kit: &AsyncKit<AsyncReady>, id: &str, project: &str, path: &str) {
let s = storage(kit);
let cypher = format!(
"CREATE (:Route {{id: '{}', project: '{}', name: '{}', qualifiedName: '{}', \
filePath: '', startLine: 0, endLine: 0, httpMethod: 'GET', path: '{}', parentQn: ''}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(path),
escape_cypher_string(path),
escape_cypher_string(path),
);
s.execute(&cypher).expect("create route");
}
#[allow(clippy::too_many_arguments)]
fn create_function_with_content(
kit: &AsyncKit<AsyncReady>,
id: &str,
project: &str,
name: &str,
qn: &str,
file: &str,
line: u32,
content: &str,
) {
let s = storage(kit);
let end_line = line + 10;
let cypher = format!(
"CREATE (:Function {{id: '{}', project: '{}', name: '{}', qualifiedName: '{}', \
filePath: '{}', startLine: {}, endLine: {}, signature: '', returnType: '', \
isExported: false, docstring: '', content: '{}', parentQn: ''}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(name),
escape_cypher_string(qn),
escape_cypher_string(file),
line,
end_line,
escape_cypher_string(content),
);
s.execute(&cypher).expect("create function");
}
fn count_cross_service_edges(kit: &AsyncKit<AsyncReady>, project: &str) -> usize {
let s = storage(kit);
let cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'CROSS_SERVICE_CALLS' AND e.project = '{}' \
RETURN e.id AS id;",
escape_cypher_string(project),
);
let rows = s.query(&cypher).expect("query cross-service edges");
rows.len()
}
#[test]
fn match_route_exact() {
assert_eq!(
match_route("/api/users", "/api/users"),
Some(MatchType::Exact)
);
}
#[test]
fn match_route_parameterized_basic() {
assert_eq!(
match_route("/api/users/:id", "/api/users/123"),
Some(MatchType::Parameterized)
);
assert_eq!(
match_route("/api/users/:id", "/api/users/456"),
Some(MatchType::Parameterized)
);
}
#[test]
fn match_route_parameterized_rejects_empty_segment() {
assert_eq!(
match_route("/api/users/:id", "/api/users/"),
None,
"empty parameter segment should not match"
);
}
#[test]
fn match_route_parameterized_rejects_extra_segment() {
assert_eq!(
match_route("/api/users/:id", "/api/users/123/extra"),
None,
"extra segment should not match"
);
}
#[test]
fn match_route_wildcard_basic() {
assert_eq!(
match_route("/api/*", "/api/anything"),
Some(MatchType::Wildcard)
);
assert_eq!(
match_route("/api/*", "/api/foo/bar"),
Some(MatchType::Wildcard)
);
}
#[test]
fn match_route_wildcard_middle() {
assert_eq!(
match_route("/api/*/users", "/api/v2/users"),
Some(MatchType::Wildcard)
);
}
#[test]
fn match_route_no_match_for_unrelated() {
assert_eq!(match_route("/api/users", "/api/products"), None);
assert_eq!(match_route("/api/users/:id", "/products/123"), None);
assert_eq!(match_route("/api/*", "/products/foo"), None);
}
#[test]
fn match_route_exact_takes_precedence_over_parameterized() {
assert_eq!(
match_route("/api/users", "/api/users"),
Some(MatchType::Exact)
);
}
#[test]
fn extract_string_literals_basic() {
let content = r#"let url = "/api/users"; fetch("/api/users/123");"#;
let lits = extract_string_literals(content);
assert_eq!(
lits,
vec!["/api/users".to_string(), "/api/users/123".to_string()]
);
}
#[test]
fn extract_string_literals_handles_escaped_quote() {
let content = r#"let s = "he said \"hi\"";"#;
let lits = extract_string_literals(content);
assert_eq!(lits, vec!["he said \"hi\"".to_string()]);
}
#[test]
fn extract_string_literals_empty_when_no_quotes() {
let lits = extract_string_literals("fn main() { return 42; }");
assert!(lits.is_empty());
}
#[test]
fn extract_string_literals_skips_unterminated() {
let lits = extract_string_literals("let s = \"unterminated;");
assert!(
lits.is_empty(),
"unterminated string should yield no literal"
);
}
#[test]
fn link_returns_empty_for_empty_db() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let result = linker.link().expect("link");
assert!(result.is_empty(), "empty DB → empty links");
}
#[test]
fn link_matches_exact_route() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
10,
r#"let url = "/api/users"; fetch(url);"#,
);
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert_eq!(links.len(), 1, "should match 1 exact route");
assert_eq!(links[0].route_id, "r1");
assert_eq!(links[0].route_pattern, "/api/users");
assert_eq!(links[0].caller_id, "f1");
assert_eq!(links[0].caller_file, "/src/caller.rs");
assert_eq!(links[0].caller_line, 10);
assert_eq!(links[0].match_type, MatchType::Exact);
}
#[test]
fn link_matches_parameterized_route() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users/:id");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
5,
r#"let url = "/api/users/123";"#,
);
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert_eq!(links.len(), 1, "should match 1 parameterized route");
assert_eq!(links[0].match_type, MatchType::Parameterized);
}
#[test]
fn link_matches_wildcard_route() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/*");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
7,
r#"let url = "/api/anything";"#,
);
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert_eq!(links.len(), 1, "should match 1 wildcard route");
assert_eq!(links[0].match_type, MatchType::Wildcard);
}
#[test]
fn link_no_match_for_unrelated_route() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let url = "/api/products";"#,
);
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert!(links.is_empty(), "unrelated literal should not match");
}
#[test]
fn link_persists_cross_service_calls_edges() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
10,
r#"fetch("/api/users");"#,
);
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert_eq!(links.len(), 1);
assert_eq!(
count_cross_service_edges(&kit, "demo"),
1,
"CROSS_SERVICE_CALLS edge should be persisted"
);
}
#[test]
fn link_skips_empty_content_function() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
"", );
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert!(links.is_empty(), "empty content → no links");
}
#[test]
fn link_filters_by_project() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_route(&kit, "r2", "other", "/api/users");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"fetch("/api/users");"#,
);
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert_eq!(links.len(), 1, "should only match demo's route");
assert_eq!(links[0].route_id, "r1");
}
#[test]
fn link_matches_multiple_literals() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_route(&kit, "r2", "demo", "/api/products");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let a = "/api/users"; let b = "/api/products";"#,
);
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert_eq!(links.len(), 2, "should match 2 routes");
let patterns: Vec<&str> = links.iter().map(|l| l.route_pattern.as_str()).collect();
assert!(patterns.contains(&"/api/users"));
assert!(patterns.contains(&"/api/products"));
}
#[test]
fn link_idempotent_no_duplicate_edges() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"fetch("/api/users");"#,
);
let s = storage(&kit);
let links1 = CrossServiceLinker::new(&*s, "demo").link().expect("link");
assert_eq!(links1.len(), 1);
assert_eq!(count_cross_service_edges(&kit, "demo"), 1);
let links2 = CrossServiceLinker::new(&*s, "demo").link().expect("link");
assert_eq!(links2.len(), 1, "second call should still detect the link");
assert_eq!(
count_cross_service_edges(&kit, "demo"),
1,
"idempotent: no duplicate edges"
);
}
#[test]
fn link_returns_empty_when_routes_exist_but_no_callers() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert!(links.is_empty(), "routes but no callers → empty links");
}
#[test]
fn match_parameterized_returns_false_when_static_segment_differs() {
assert!(
!match_parameterized("/api/users/:id", "/api/products/123"),
"mismatched static segment should not match"
);
assert!(
!match_parameterized("/foo/:id/bar", "/foo/123/baz"),
"mismatched trailing static segment should not match"
);
}
#[test]
fn match_wildcard_skips_empty_middle_part_from_double_star() {
assert!(
match_wildcard("/api/**/end", "/api/foo/bar/end"),
"double-star should match any path between /api/ and /end"
);
}
#[test]
fn match_wildcard_finds_middle_literal_between_two_stars() {
assert!(
match_wildcard("/api/*/x/*/end", "/api/v1/x/data/end"),
"should find '/x/' between wildcards"
);
assert!(
!match_wildcard("/api/*/x/*/end", "/api/v1/y/data/end"),
"should fail when middle literal '/x/' is absent"
);
}
#[test]
fn match_wildcard_rejects_when_last_part_is_not_suffix() {
assert!(
!match_wildcard("/api/*/users", "/api/v2/products"),
"should fail when literal doesn't end with '/users'"
);
}
#[test]
fn extract_string_literals_decodes_escaped_backslash() {
let content = r#"let s = "path\\to";"#;
let lits = extract_string_literals(content);
assert_eq!(lits, vec![r"path\to".to_string()]);
}
#[test]
fn extract_string_literals_decodes_escaped_newline() {
let content = r#"let s = "line1\nline2";"#;
let lits = extract_string_literals(content);
assert_eq!(lits, vec!["line1\nline2".to_string()]);
}
#[test]
fn extract_string_literals_decodes_escaped_tab() {
let content = r#"let s = "col1\tcol2";"#;
let lits = extract_string_literals(content);
assert_eq!(lits, vec!["col1\tcol2".to_string()]);
}
#[test]
fn extract_string_literals_preserves_backslash_for_unknown_escape() {
let content = r#"let s = "x\y";"#;
let lits = extract_string_literals(content);
assert_eq!(lits, vec![r"x\y".to_string()]);
}
#[test]
fn match_wildcard_no_star_pattern_compares_literal_equality() {
assert!(
match_wildcard("/api/users", "/api/users"),
"no-star pattern equal to literal → true"
);
assert!(
!match_wildcard("/api/users", "/api/products"),
"no-star pattern different from literal → false"
);
assert!(
match_wildcard("", ""),
"empty no-star pattern matches empty literal"
);
}
#[test]
fn link_handles_route_node_with_null_optional_properties() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
let s = storage(&kit);
let cypher = "CREATE (:Route {id: 'r1', project: 'demo', path: '/api/users'});";
s.execute(cypher).expect("create minimal route");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"fetch("/api/users");"#,
);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert_eq!(links.len(), 1, "minimal Route node should still match");
assert_eq!(links[0].route_id, "r1");
assert_eq!(links[0].match_type, MatchType::Exact);
}
#[test]
fn link_handles_caller_node_with_null_optional_properties() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
let s = storage(&kit);
create_route(&kit, "r1", "demo", "/api/users");
let cypher = "CREATE (:Function {id: 'f1', project: 'demo', filePath: '/src/caller.rs', \
startLine: 5, content: 'fetch(\"/api/users\")'});";
s.execute(cypher).expect("create minimal function");
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert_eq!(links.len(), 1, "minimal Function node should still match");
assert_eq!(links[0].caller_id, "f1");
assert_eq!(links[0].caller_line, 5);
}
#[test]
fn service_protocol_serializes_all_variants() {
assert_eq!(
serde_json::to_string(&ServiceProtocol::HttpRest).unwrap(),
"\"HttpRest\""
);
assert_eq!(
serde_json::to_string(&ServiceProtocol::Grpc).unwrap(),
"\"Grpc\""
);
assert_eq!(
serde_json::to_string(&ServiceProtocol::GraphQL).unwrap(),
"\"GraphQL\""
);
assert_eq!(
serde_json::to_string(&ServiceProtocol::MessageQueue).unwrap(),
"\"MessageQueue\""
);
assert_eq!(
serde_json::to_string(&ServiceProtocol::EventBus).unwrap(),
"\"EventBus\""
);
}
#[test]
fn service_protocol_roundtrips_all_variants() {
for proto in [
ServiceProtocol::HttpRest,
ServiceProtocol::Grpc,
ServiceProtocol::GraphQL,
ServiceProtocol::MessageQueue,
ServiceProtocol::EventBus,
] {
let json = serde_json::to_string(&proto).unwrap();
let parsed: ServiceProtocol = serde_json::from_str(&json).unwrap();
assert_eq!(proto, parsed, "roundtrip failed for {json}");
}
}
#[test]
fn match_type_pattern_variant_serializes() {
assert_eq!(
serde_json::to_string(&MatchType::Pattern).unwrap(),
"\"Pattern\""
);
let json = serde_json::to_string(&MatchType::Pattern).unwrap();
let parsed: MatchType = serde_json::from_str(&json).unwrap();
assert_eq!(MatchType::Pattern, parsed);
}
#[test]
fn cross_service_match_serializes_with_all_fields() {
let m = CrossServiceMatch {
caller: "fn_1".to_string(),
callee: "route_1".to_string(),
protocol: ServiceProtocol::HttpRest,
match_type: MatchType::Exact,
confidence: Confidence::High,
reason: "HTTP route pattern match".to_string(),
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"caller\":\"fn_1\""), "{json}");
assert!(json.contains("\"callee\":\"route_1\""), "{json}");
assert!(json.contains("\"protocol\":\"HttpRest\""), "{json}");
assert!(json.contains("\"match_type\":\"Exact\""), "{json}");
assert!(json.contains("\"confidence\":\"High\""), "{json}");
assert!(json.contains("\"reason\""), "{json}");
}
#[test]
fn detect_all_returns_empty_for_empty_db() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let result = detector.detect_all("demo").expect("detect_all");
assert!(result.is_empty(), "empty DB → empty matches");
}
#[test]
fn detect_all_dispatches_to_http_detection() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
10,
r#"fetch("/api/users");"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_all("demo").expect("detect_all");
assert_eq!(matches.len(), 1, "should detect 1 HTTP match");
assert_eq!(matches[0].protocol, ServiceProtocol::HttpRest);
assert_eq!(matches[0].caller, "f1");
assert_eq!(matches[0].callee, "r1");
assert_eq!(matches[0].match_type, MatchType::Exact);
assert_eq!(matches[0].confidence, Confidence::High);
}
#[test]
fn detect_grpc_finds_grpc_url() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let url = "grpc://localhost:50051"; connect(url);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_grpc("demo").expect("detect_grpc");
assert_eq!(matches.len(), 1, "should detect grpc:// URL");
assert_eq!(matches[0].protocol, ServiceProtocol::Grpc);
assert_eq!(matches[0].callee, "grpc://localhost:50051");
assert_eq!(matches[0].match_type, MatchType::Pattern);
}
#[test]
fn detect_grpc_finds_client_method_call() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let resp = client.GetUser(GetUserRequest{id: 1});"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_grpc("demo").expect("detect_grpc");
assert_eq!(matches.len(), 1, "should detect client.GetUser call");
assert_eq!(matches[0].protocol, ServiceProtocol::Grpc);
assert_eq!(matches[0].callee, "client.GetUser");
assert_eq!(matches[0].match_type, MatchType::Pattern);
}
#[test]
fn detect_grpc_no_match_for_plain_function() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let x = 1 + 2; println!("{}", x);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_grpc("demo").expect("detect_grpc");
assert!(matches.is_empty(), "plain function should not match gRPC");
}
#[test]
fn detect_grpc_skips_lowercase_client_method() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"client.connect(url);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_grpc("demo").expect("detect_grpc");
assert!(matches.is_empty(), "lowercase method should not match gRPC");
}
#[test]
fn detect_graphql_finds_query_operation() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let q = "query { user { id name } }"; fetch(q);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_graphql("demo").expect("detect_graphql");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::GraphQL);
assert_eq!(matches[0].callee, "query");
assert_eq!(matches[0].match_type, MatchType::Pattern);
}
#[test]
fn detect_graphql_finds_mutation_operation() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let m = "mutation { createUser(input: $input) { id } }"; fetch(m);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_graphql("demo").expect("detect_graphql");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::GraphQL);
assert_eq!(matches[0].callee, "mutation");
}
#[test]
fn detect_graphql_finds_subscription_operation() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let s = "subscription { newMessages { id } }"; ws.send(s);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_graphql("demo").expect("detect_graphql");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].callee, "subscription");
assert_eq!(matches[0].protocol, ServiceProtocol::GraphQL);
}
#[test]
fn detect_graphql_finds_endpoint_url() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"fetch("/api/graphql", { method: "POST", body: query });"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_graphql("demo").expect("detect_graphql");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::GraphQL);
assert!(matches[0].callee.contains("/api/graphql"));
}
#[test]
fn detect_graphql_no_match_for_plain_function() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let x = 1 + 2; println!("{}", x);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_graphql("demo").expect("detect_graphql");
assert!(
matches.is_empty(),
"plain function should not match GraphQL"
);
}
#[test]
fn detect_message_queue_finds_producer_send() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"producer.send(topic, message);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector
.detect_message_queue("demo")
.expect("detect_message_queue");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::MessageQueue);
assert!(matches[0].reason.contains("EMITS"), "{}", matches[0].reason);
assert_eq!(matches[0].callee, "topic");
}
#[test]
fn detect_message_queue_finds_consumer_subscribe() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"consumer.subscribe(topic);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector
.detect_message_queue("demo")
.expect("detect_message_queue");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::MessageQueue);
assert!(
matches[0].reason.contains("LISTENS_ON"),
"{}",
matches[0].reason
);
}
#[test]
fn detect_message_queue_finds_channel_publish() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"channel.publish("events", msg);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector
.detect_message_queue("demo")
.expect("detect_message_queue");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::MessageQueue);
assert!(matches[0].reason.contains("EMITS"), "{}", matches[0].reason);
assert_eq!(matches[0].callee, "events");
}
#[test]
fn detect_message_queue_finds_channel_consume() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"channel.consume("tasks", handler);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector
.detect_message_queue("demo")
.expect("detect_message_queue");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].callee, "tasks");
assert!(
matches[0].reason.contains("LISTENS_ON"),
"{}",
matches[0].reason
);
}
#[test]
fn detect_message_queue_no_match_for_plain_function() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let x = 1 + 2; println!("{}", x);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector
.detect_message_queue("demo")
.expect("detect_message_queue");
assert!(matches.is_empty(), "plain function should not match MQ");
}
#[test]
fn detect_event_bus_finds_io_emit() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"io.emit('userJoined', data);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_event_bus("demo").expect("detect_event_bus");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::EventBus);
assert_eq!(matches[0].callee, "userJoined");
assert_eq!(matches[0].match_type, MatchType::Pattern);
assert!(matches[0].reason.contains("EMITS"), "{}", matches[0].reason);
}
#[test]
fn detect_event_bus_finds_socket_on() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"socket.on('message', handler);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_event_bus("demo").expect("detect_event_bus");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::EventBus);
assert_eq!(matches[0].callee, "message");
assert!(
matches[0].reason.contains("LISTENS_ON"),
"{}",
matches[0].reason
);
}
#[test]
fn detect_event_bus_finds_emitter_emit() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"emitter.emit('update', payload);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_event_bus("demo").expect("detect_event_bus");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::EventBus);
assert_eq!(matches[0].callee, "update");
assert!(matches[0].reason.contains("EMITS"), "{}", matches[0].reason);
}
#[test]
fn detect_event_bus_finds_emitter_on() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"emitter.on('change', callback);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_event_bus("demo").expect("detect_event_bus");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].protocol, ServiceProtocol::EventBus);
assert_eq!(matches[0].callee, "change");
assert!(
matches[0].reason.contains("LISTENS_ON"),
"{}",
matches[0].reason
);
}
#[test]
fn detect_event_bus_no_match_for_plain_function() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let x = 1 + 2; println!("{}", x);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_event_bus("demo").expect("detect_event_bus");
assert!(
matches.is_empty(),
"plain function should not match EventBus"
);
}
fn make_detector() -> CrossServiceDetector<'static> {
let db = fresh_db_path();
let kit = Box::leak(Box::new(build_kit_for_db(&db)));
let s = storage(kit);
let leaked = Box::leak(Box::new(s));
CrossServiceDetector::new(&**leaked)
}
#[test]
fn score_confidence_exact_http_returns_high() {
let det = make_detector();
assert_eq!(
det.score_confidence(&MatchType::Exact, &ServiceProtocol::HttpRest),
Confidence::High,
);
}
#[test]
fn score_confidence_exact_grpc_returns_high() {
let det = make_detector();
assert_eq!(
det.score_confidence(&MatchType::Exact, &ServiceProtocol::Grpc),
Confidence::High,
);
}
#[test]
fn score_confidence_parameterized_grpc_returns_medium() {
let det = make_detector();
assert_eq!(
det.score_confidence(&MatchType::Parameterized, &ServiceProtocol::Grpc),
Confidence::Medium,
);
}
#[test]
fn score_confidence_wildcard_event_bus_returns_low() {
let det = make_detector();
assert_eq!(
det.score_confidence(&MatchType::Wildcard, &ServiceProtocol::EventBus),
Confidence::Low,
);
}
#[test]
fn score_confidence_exact_graphql_returns_medium() {
let det = make_detector();
assert_eq!(
det.score_confidence(&MatchType::Exact, &ServiceProtocol::GraphQL),
Confidence::Medium,
);
}
#[test]
fn score_confidence_pattern_any_protocol_returns_low() {
let det = make_detector();
for proto in [
ServiceProtocol::HttpRest,
ServiceProtocol::Grpc,
ServiceProtocol::GraphQL,
ServiceProtocol::MessageQueue,
ServiceProtocol::EventBus,
] {
assert_eq!(
det.score_confidence(&MatchType::Pattern, &proto),
Confidence::Low,
"Pattern + {proto:?} should be Low",
);
}
}
#[test]
fn detect_all_sorts_by_confidence_descending() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"fetch("/api/users");"#,
);
create_function_with_content(
&kit,
"f2",
"demo",
"grpc_caller",
"demo.grpc_caller",
"/src/grpc.rs",
1,
r#"let url = "grpc://localhost:50051";"#,
);
create_function_with_content(
&kit,
"f3",
"demo",
"gql_caller",
"demo.gql_caller",
"/src/gql.rs",
1,
r#"let q = "query { user { id } }";"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_all("demo").expect("detect_all");
assert!(matches.len() >= 3, "should detect 3+ matches");
for window in matches.windows(2) {
assert!(
confidence_rank(window[0].confidence) <= confidence_rank(window[1].confidence),
"matches must be sorted by confidence descending; got {:?} before {:?}",
window[0].confidence,
window[1].confidence
);
}
assert_eq!(
matches[0].confidence,
Confidence::High,
"first match should be High confidence"
);
assert_eq!(
matches[0].protocol,
ServiceProtocol::HttpRest,
"first match should be HTTP"
);
}
#[test]
fn detect_http_skips_empty_content_caller() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
"",
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_http("demo").expect("detect_http");
assert!(matches.is_empty(), "empty-content caller should be skipped");
}
#[test]
fn detect_grpc_skips_empty_content_caller() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
"",
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_grpc("demo").expect("detect_grpc");
assert!(matches.is_empty(), "empty-content caller should be skipped");
}
#[test]
fn detect_graphql_skips_empty_content_caller() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
"",
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_graphql("demo").expect("detect_graphql");
assert!(matches.is_empty(), "empty-content caller should be skipped");
}
#[test]
fn detect_message_queue_skips_empty_content_caller() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
"",
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector
.detect_message_queue("demo")
.expect("detect_message_queue");
assert!(matches.is_empty(), "empty-content caller should be skipped");
}
#[test]
fn detect_event_bus_skips_empty_content_caller() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
"",
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_event_bus("demo").expect("detect_event_bus");
assert!(matches.is_empty(), "empty-content caller should be skipped");
}
#[test]
fn detect_all_includes_medium_confidence_match() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users/:id");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"fetch("/api/users/123");"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_all("demo").expect("detect_all");
assert!(
matches.iter().any(|m| m.confidence == Confidence::Medium),
"should include at least one Medium confidence match"
);
}
#[test]
fn confidence_rank_returns_correct_values() {
assert_eq!(confidence_rank(Confidence::High), 0);
assert_eq!(confidence_rank(Confidence::Medium), 1);
assert_eq!(confidence_rank(Confidence::Low), 2);
assert!(confidence_rank(Confidence::High) < confidence_rank(Confidence::Medium));
assert!(confidence_rank(Confidence::Medium) < confidence_rank(Confidence::Low));
}
#[test]
fn extract_grpc_urls_with_various_delimiters() {
let urls = extract_grpc_urls(r#"let url = "grpc://host:50051 ;"#);
assert_eq!(urls, vec!["grpc://host:50051".to_string()]);
let urls = extract_grpc_urls("connect(grpc://svc:9090);");
assert_eq!(urls, vec!["grpc://svc:9090".to_string()]);
let urls = extract_grpc_urls("dial(grpc://api:443)");
assert_eq!(urls, vec!["grpc://api:443".to_string()]);
let urls = extract_grpc_urls(r#"let u = "grpc://a:1";"#);
assert_eq!(urls, vec!["grpc://a:1".to_string()]);
}
#[test]
fn detect_grpc_finds_multiple_urls_in_same_function() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let a = "grpc://alpha:50051"; let b = "grpc://beta:50052";"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_grpc("demo").expect("detect_grpc");
assert_eq!(matches.len(), 2, "should detect 2 grpc:// URLs");
let callees: Vec<&str> = matches.iter().map(|m| m.callee.as_str()).collect();
assert!(callees.contains(&"grpc://alpha:50051"));
assert!(callees.contains(&"grpc://beta:50052"));
}
#[test]
fn detect_event_bus_patterns_with_double_quotes() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"io.emit("userLeft", data); socket.on("ping", cb);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_event_bus("demo").expect("detect_event_bus");
assert_eq!(matches.len(), 2, "should detect 2 event bus patterns");
let callees: Vec<&str> = matches.iter().map(|m| m.callee.as_str()).collect();
assert!(callees.contains(&"userLeft"), "callees: {:?}", callees);
assert!(callees.contains(&"ping"), "callees: {:?}", callees);
}
#[test]
fn detect_message_queue_multiple_patterns_in_same_function() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"producer.send("topic1", msg); channel.consume("topic2", handler);"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector
.detect_message_queue("demo")
.expect("detect_message_queue");
assert_eq!(matches.len(), 2, "should detect 2 MQ patterns");
let callees: Vec<&str> = matches.iter().map(|m| m.callee.as_str()).collect();
assert!(callees.contains(&"topic1"), "callees: {:?}", callees);
assert!(callees.contains(&"topic2"), "callees: {:?}", callees);
}
#[test]
fn detect_graphql_multiple_operations_in_same_function() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
1,
r#"let q = "query { user { id } }"; fetch("/api/graphql");"#,
);
let s = storage(&kit);
let detector = CrossServiceDetector::new(&*s);
let matches = detector.detect_graphql("demo").expect("detect_graphql");
assert_eq!(matches.len(), 2, "should detect 2 GraphQL operations");
let callees: Vec<&str> = matches.iter().map(|m| m.callee.as_str()).collect();
assert!(callees.contains(&"query"), "callees: {:?}", callees);
assert!(
callees.iter().any(|c| c.contains("/api/graphql")),
"callees: {:?}",
callees
);
}
#[test]
fn link_next_edge_id_increments_correctly() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users");
create_route(&kit, "r2", "demo", "/api/products");
create_function_with_content(
&kit,
"f1",
"demo",
"caller1",
"demo.caller1",
"/src/c1.rs",
1,
r#"fetch("/api/users"); fetch("/api/products");"#,
);
let s = storage(&kit);
let linker = CrossServiceLinker::new(&*s, "demo");
let links = linker.link().expect("link");
assert_eq!(links.len(), 2, "should match 2 routes");
assert_eq!(
count_cross_service_edges(&kit, "demo"),
2,
"should have 2 CROSS_SERVICE_CALLS edges"
);
}
#[test]
fn extract_grpc_client_calls_multiple_methods() {
let content = r#"client.GetUser(req); client.UpdateUser(req); client.DeleteUser(req);"#;
let calls = extract_grpc_client_calls(content);
assert_eq!(calls.len(), 3, "should find 3 client calls");
assert!(calls.contains(&"client.GetUser".to_string()));
assert!(calls.contains(&"client.UpdateUser".to_string()));
assert!(calls.contains(&"client.DeleteUser".to_string()));
}
#[test]
fn extract_mq_patterns_handles_empty_arg() {
let patterns = extract_mq_patterns("producer.send(, msg);");
assert!(
patterns.iter().all(|(c, _)| !c.is_empty()),
"empty args should be skipped"
);
}
#[test]
fn extract_grpc_client_calls_returns_empty_when_client_dot_followed_by_non_alphanumeric() {
let calls = extract_grpc_client_calls("let x = client.;");
assert!(
calls.is_empty(),
"client. followed by non-alphanumeric should yield no calls"
);
let calls = extract_grpc_client_calls("let x = client.");
assert!(calls.is_empty(), "client. at EOF should yield no calls");
}
#[test]
fn extract_grpc_client_calls_skips_method_not_followed_by_paren() {
let calls = extract_grpc_client_calls("let handler = client.GetUser;");
assert!(
calls.is_empty(),
"method not followed by '(' should not be detected as a call"
);
let calls = extract_grpc_client_calls("client.GetUser = fetch;");
assert!(calls.is_empty(), "method followed by '=' should be skipped");
}
#[test]
fn extract_mq_patterns_skips_pattern_not_followed_by_paren() {
let patterns = extract_mq_patterns("let x = producer.send;");
assert!(
patterns.is_empty(),
"pattern not followed by '(' should be skipped"
);
let patterns = extract_mq_patterns("producer.send topic;");
assert!(patterns.is_empty(), "pattern without '(' should be skipped");
}
#[test]
fn extract_mq_patterns_handles_arg_without_delimiter() {
let patterns = extract_mq_patterns("producer.send(mytopic");
assert_eq!(patterns.len(), 1, "should extract arg without delimiter");
assert_eq!(patterns[0].0, "mytopic");
assert_eq!(patterns[0].1, "EMITS");
}
#[test]
fn extract_event_bus_patterns_skips_pattern_not_followed_by_paren() {
let patterns = extract_event_bus_patterns("let x = io.emit;");
assert!(
patterns.is_empty(),
"event bus pattern without '(' should be skipped"
);
}
#[test]
fn extract_event_bus_patterns_trims_single_quotes_from_arg() {
let patterns = extract_event_bus_patterns("io.emit('myEvent");
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].0, "myEvent");
assert_eq!(patterns[0].1, "EMITS");
}
#[test]
fn extract_grpc_urls_handles_url_at_end_of_content() {
let urls = extract_grpc_urls("let url = grpc://host:50051");
assert_eq!(urls, vec!["grpc://host:50051".to_string()]);
let urls = extract_grpc_urls("grpc://api:443");
assert_eq!(urls, vec!["grpc://api:443".to_string()]);
}
#[test]
fn extract_string_literals_handles_backslash_at_end_of_content() {
let lits = extract_string_literals(r#"let s = "hello\"#);
assert!(
lits.is_empty(),
"string ending with backslash at EOF is unterminated → no literal"
);
}
#[test]
fn extract_string_literals_extracts_multiple_mixed_literals() {
let content = r#"let a = "foo"; let b = "bar\nbaz"; let c = "x\\y";"#;
let lits = extract_string_literals(content);
assert_eq!(lits.len(), 3);
assert_eq!(lits[0], "foo");
assert_eq!(lits[1], "bar\nbaz");
assert_eq!(lits[2], r"x\y");
}
#[test]
fn match_wildcard_empty_pattern_with_star_matches_anything() {
assert!(
match_wildcard("*", "anything"),
"single star should match any literal"
);
assert!(
match_wildcard("*", ""),
"single star should match empty string"
);
}
#[test]
fn match_wildcard_pattern_ending_with_star_skips_last_check() {
assert!(
match_wildcard("/api/v1/*", "/api/v1/users/123"),
"trailing star should match any suffix"
);
assert!(
match_wildcard("/api/*", "/api/"),
"trailing star should match empty suffix after prefix"
);
}
#[test]
fn match_parameterized_param_at_first_segment() {
assert!(
match_parameterized(":version/users", "v1/users"),
"leading parameter should match non-empty segment"
);
assert!(
!match_parameterized(":version/users", "/users"),
"leading parameter should reject empty segment"
);
}
#[test]
fn match_parameterized_all_segments_are_params() {
assert!(
match_parameterized("/:a/:b", "/foo/bar"),
"all-parameter pattern should match"
);
assert!(
!match_parameterized("/:a/:b", "/foo/"),
"trailing empty parameter segment should not match"
);
}
#[test]
fn service_protocol_display_canonical_strings() {
assert_eq!(ServiceProtocol::HttpRest.to_string(), "http_rest");
assert_eq!(ServiceProtocol::Grpc.to_string(), "grpc");
assert_eq!(ServiceProtocol::GraphQL.to_string(), "graphql");
assert_eq!(ServiceProtocol::MessageQueue.to_string(), "message_queue");
assert_eq!(ServiceProtocol::EventBus.to_string(), "event_bus");
}
#[test]
fn service_protocol_from_str_parses_canonical_forms() {
assert_eq!(
ServiceProtocol::from_str("http_rest").unwrap(),
ServiceProtocol::HttpRest
);
assert_eq!(
ServiceProtocol::from_str("grpc").unwrap(),
ServiceProtocol::Grpc
);
assert_eq!(
ServiceProtocol::from_str("graphql").unwrap(),
ServiceProtocol::GraphQL
);
assert_eq!(
ServiceProtocol::from_str("message_queue").unwrap(),
ServiceProtocol::MessageQueue
);
assert_eq!(
ServiceProtocol::from_str("event_bus").unwrap(),
ServiceProtocol::EventBus
);
}
#[test]
fn service_protocol_from_str_parses_aliases() {
assert_eq!(
ServiceProtocol::from_str("http").unwrap(),
ServiceProtocol::HttpRest
);
assert_eq!(
ServiceProtocol::from_str("rest").unwrap(),
ServiceProtocol::HttpRest
);
assert_eq!(
ServiceProtocol::from_str("mq").unwrap(),
ServiceProtocol::MessageQueue
);
assert_eq!(
ServiceProtocol::from_str("message").unwrap(),
ServiceProtocol::MessageQueue
);
assert_eq!(
ServiceProtocol::from_str("event").unwrap(),
ServiceProtocol::EventBus
);
}
#[test]
fn service_protocol_from_str_is_case_insensitive() {
assert_eq!(
ServiceProtocol::from_str("HTTP_REST").unwrap(),
ServiceProtocol::HttpRest
);
assert_eq!(
ServiceProtocol::from_str("GRPC").unwrap(),
ServiceProtocol::Grpc
);
assert_eq!(
ServiceProtocol::from_str("GraphQL").unwrap(),
ServiceProtocol::GraphQL
);
}
#[test]
fn service_protocol_from_str_trims_whitespace() {
assert_eq!(
ServiceProtocol::from_str(" grpc ").unwrap(),
ServiceProtocol::Grpc
);
assert_eq!(
ServiceProtocol::from_str("\tevent_bus\n").unwrap(),
ServiceProtocol::EventBus
);
}
#[test]
fn service_protocol_from_str_rejects_empty() {
assert!(ServiceProtocol::from_str("").is_err());
assert!(ServiceProtocol::from_str(" ").is_err());
}
#[test]
fn service_protocol_from_str_rejects_unknown() {
assert!(ServiceProtocol::from_str("ftp").is_err());
assert!(ServiceProtocol::from_str("websocket").is_err());
}
#[test]
fn service_protocol_from_str_error_contains_input() {
let err = ServiceProtocol::from_str("ftp").unwrap_err();
assert!(err.contains("ftp"), "error should contain input: {err}");
}
#[test]
fn service_protocol_display_fromstr_roundtrip() {
for variant in [
ServiceProtocol::HttpRest,
ServiceProtocol::Grpc,
ServiceProtocol::GraphQL,
ServiceProtocol::MessageQueue,
ServiceProtocol::EventBus,
] {
let s = variant.to_string();
assert_eq!(ServiceProtocol::from_str(&s).unwrap(), variant);
}
}
}