use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Transport {
Http,
PubSub,
DataFlow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
Inbound,
Outbound,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServiceEndpoint {
pub transport: Transport,
pub direction: Direction,
pub contract: String,
pub repo_id: String,
pub symbol_id: String,
pub qname: String,
pub file: String,
pub line: u32,
pub confidence: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EndpointRef {
pub repo_id: String,
pub symbol_id: String,
pub qname: String,
pub file: String,
pub line: u32,
}
impl From<&ServiceEndpoint> for EndpointRef {
fn from(e: &ServiceEndpoint) -> Self {
EndpointRef {
repo_id: e.repo_id.clone(),
symbol_id: e.symbol_id.clone(),
qname: e.qname.clone(),
file: e.file.clone(),
line: e.line,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CrossServiceEdge {
pub transport: Transport,
pub contract: String,
pub from: EndpointRef,
pub to: EndpointRef,
pub confidence: f64,
pub cross_repo: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceManifest {
pub repo_id: String,
pub endpoints: Vec<ServiceEndpoint>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DetectedEndpoint {
pub transport: Transport,
pub direction: Direction,
pub contract: String,
pub owner_qname: String,
pub file: String,
pub line: u32,
pub confidence: f64,
pub note: Option<String>,
}
impl DetectedEndpoint {
pub fn into_endpoint(self, repo_id: &str, symbol_id: &str) -> ServiceEndpoint {
ServiceEndpoint {
transport: self.transport,
direction: self.direction,
contract: self.contract,
repo_id: repo_id.to_string(),
symbol_id: symbol_id.to_string(),
qname: self.owner_qname,
file: self.file,
line: self.line,
confidence: self.confidence,
note: self.note,
}
}
}
pub fn http_contract(method: &str, path_or_url: &str) -> String {
format!(
"http:{} {}",
method.trim().to_uppercase(),
normalize_http_path(path_or_url)
)
}
fn normalize_http_path(raw: &str) -> String {
let after_host = match raw.find("://") {
Some(i) => {
let rest = &raw[i + 3..];
match rest.find('/') {
Some(p) => &rest[p..],
None => "/",
}
}
None => raw,
};
let path = after_host
.split(['?', '#'])
.next()
.unwrap_or(after_host)
.trim()
.trim_end_matches('/');
let segs: Vec<String> = path
.split('/')
.filter(|s| !s.is_empty())
.map(|seg| {
if is_path_param(seg) {
"{}".to_string()
} else {
seg.to_lowercase()
}
})
.collect();
if segs.is_empty() {
"/".to_string()
} else {
format!("/{}", segs.join("/"))
}
}
fn is_path_param(seg: &str) -> bool {
(seg.starts_with('{') && seg.ends_with('}'))
|| (seg.starts_with("${") && seg.ends_with('}'))
|| seg.starts_with(':')
|| (seg.starts_with('<') && seg.ends_with('>'))
}
pub fn pubsub_contract(topic: &str) -> String {
format!("topic:{}", topic.trim().to_lowercase())
}
pub fn contract_hash(contract: &str) -> String {
blake3::hash(contract.as_bytes()).to_hex()[..16].to_string()
}
pub fn normalize_repo_id(remote_url: &str) -> String {
let mut s = remote_url.trim().to_string();
if let Some(i) = s.find("://") {
s = s[i + 3..].to_string();
}
if let Some(at) = s.find('@') {
let host_boundary = s.find('/').unwrap_or(s.len());
if at < host_boundary {
s = s[at + 1..].to_string();
}
}
if let Some(colon) = s.find(':') {
let next_is_digit = s[colon + 1..]
.chars()
.next()
.map_or(false, |c| c.is_ascii_digit());
if !next_is_digit {
s.replace_range(colon..colon + 1, "/");
}
}
let s = s.trim_end_matches('/');
let s = s.strip_suffix(".git").unwrap_or(s);
s.trim_end_matches('/').to_lowercase()
}
pub fn resolve_repo_id(override_id: Option<&str>, cwd: &std::path::Path) -> String {
if let Some(id) = override_id {
let id = id.trim();
if !id.is_empty() {
return id.to_string();
}
}
if let Some(url) = git_origin_url(cwd) {
let norm = normalize_repo_id(&url);
if !norm.is_empty() {
return norm;
}
}
cwd.file_name()
.and_then(|n| n.to_str())
.map(|n| n.to_lowercase())
.unwrap_or_else(|| "unknown-repo".to_string())
}
fn git_origin_url(cwd: &std::path::Path) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(["remote", "get-url", "origin"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
pub fn endpoints_from_tree(tree: &serde_json::Value) -> Vec<ServiceEndpoint> {
let mut out = Vec::new();
let Some(by_contract) = tree.as_object() else {
return out;
};
for by_repo in by_contract.values() {
let Some(by_repo) = by_repo.as_object() else {
continue;
};
for by_sym in by_repo.values() {
let Some(by_sym) = by_sym.as_object() else {
continue;
};
for ep in by_sym.values() {
if let Ok(e) = serde_json::from_value::<ServiceEndpoint>(ep.clone()) {
out.push(e);
}
}
}
}
out
}
pub fn match_edges(endpoints: &[ServiceEndpoint]) -> Vec<CrossServiceEdge> {
use std::collections::HashMap;
let mut inbound_by_contract: HashMap<&str, Vec<&ServiceEndpoint>> = HashMap::new();
for e in endpoints
.iter()
.filter(|e| e.direction == Direction::Inbound)
{
inbound_by_contract
.entry(e.contract.as_str())
.or_default()
.push(e);
}
let mut edges = Vec::new();
for out in endpoints
.iter()
.filter(|e| e.direction == Direction::Outbound)
{
let Some(ins) = inbound_by_contract.get(out.contract.as_str()) else {
continue;
};
for inb in ins {
if inb.transport != out.transport {
continue;
}
edges.push(CrossServiceEdge {
transport: out.transport,
contract: out.contract.clone(),
from: EndpointRef::from(out),
to: EndpointRef::from(*inb),
confidence: out.confidence.min(inb.confidence),
cross_repo: out.repo_id != inb.repo_id,
});
}
}
edges
}
pub fn federated_edges(
manifests: &[ServiceManifest],
cross_repo_only: bool,
) -> Vec<CrossServiceEdge> {
let all: Vec<ServiceEndpoint> = manifests
.iter()
.flat_map(|m| m.endpoints.iter().cloned())
.collect();
let mut edges = match_edges(&all);
if cross_repo_only {
edges.retain(|e| e.cross_repo);
}
edges
}
#[cfg(test)]
mod tests {
use super::*;
fn endpoint(repo: &str, dir: Direction, contract: &str) -> ServiceEndpoint {
ServiceEndpoint {
transport: Transport::Http,
direction: dir,
contract: contract.to_string(),
repo_id: repo.to_string(),
symbol_id: format!("{repo}:sym"),
qname: format!("{repo}.fn"),
file: format!("{repo}/f.py"),
line: 1,
confidence: 0.9,
note: None,
}
}
#[test]
fn federated_edges_match_client_in_a_to_route_in_b() {
let manifests = vec![
ServiceManifest {
repo_id: "frontend".into(),
endpoints: vec![
endpoint("frontend", Direction::Outbound, "http:GET /api/orders/{}"),
endpoint("frontend", Direction::Outbound, "http:GET /api/ghost"),
],
},
ServiceManifest {
repo_id: "backend".into(),
endpoints: vec![endpoint(
"backend",
Direction::Inbound,
"http:GET /api/orders/{}",
)],
},
];
let edges = federated_edges(&manifests, true);
assert_eq!(edges.len(), 1, "{edges:?}");
assert_eq!(edges[0].from.repo_id, "frontend");
assert_eq!(edges[0].to.repo_id, "backend");
assert_eq!(edges[0].contract, "http:GET /api/orders/{}");
assert!(edges[0].cross_repo);
let same = vec![ServiceManifest {
repo_id: "solo".into(),
endpoints: vec![
endpoint("solo", Direction::Outbound, "http:GET /api/x"),
endpoint("solo", Direction::Inbound, "http:GET /api/x"),
],
}];
assert_eq!(federated_edges(&same, true).len(), 0);
assert_eq!(federated_edges(&same, false).len(), 1);
}
#[test]
fn http_method_uppercased_and_path_lowercased() {
assert_eq!(http_contract("post", "/Charge"), "http:POST /charge");
}
#[test]
fn http_path_params_collapse_across_framework_spellings() {
let want = "http:GET /users/{}/posts/{}";
assert_eq!(http_contract("get", "/users/{id}/posts/{postId}"), want);
assert_eq!(http_contract("get", "/users/:id/posts/:postId"), want);
assert_eq!(http_contract("get", "/users/<id>/posts/<int:postId>"), want);
}
#[test]
fn http_client_url_and_server_route_match() {
let client = http_contract("GET", "https://payments.svc/users/:id?expand=1#frag");
let server = http_contract("get", "/users/{id}");
assert_eq!(client, server);
assert_eq!(server, "http:GET /users/{}");
}
#[test]
fn http_trailing_slash_and_root_normalized() {
assert_eq!(http_contract("GET", "/charge/"), "http:GET /charge");
assert_eq!(http_contract("GET", "/"), "http:GET /");
assert_eq!(http_contract("GET", ""), "http:GET /");
}
#[test]
fn pubsub_contract_normalizes_case() {
assert_eq!(
pubsub_contract(" Payments.Charged "),
"topic:payments.charged"
);
}
#[test]
fn contract_hash_is_stable_and_path_safe() {
let c = http_contract("POST", "/charge");
let h = contract_hash(&c);
assert_eq!(h, contract_hash(&c), "hash must be deterministic");
assert_eq!(h.len(), 16);
assert!(h.chars().all(|ch| ch.is_ascii_hexdigit()));
}
#[test]
fn repo_id_scp_and_https_collapse() {
assert_eq!(
normalize_repo_id("git@github.com:Org/Repo.git"),
"github.com/org/repo"
);
assert_eq!(
normalize_repo_id("https://github.com/Org/Repo.git"),
"github.com/org/repo"
);
assert_eq!(
normalize_repo_id("https://github.com/Org/Repo"),
"github.com/org/repo"
);
}
#[test]
fn repo_id_strips_userinfo_keeps_port() {
assert_eq!(
normalize_repo_id("ssh://git@host.example:22/team/svc.git"),
"host.example:22/team/svc"
);
assert_eq!(
normalize_repo_id("https://user@gitlab.com/team/svc.git"),
"gitlab.com/team/svc"
);
}
#[test]
fn repo_id_override_wins_and_blank_ignored() {
let cwd = std::path::Path::new("/tmp/whatever");
assert_eq!(resolve_repo_id(Some("payments-svc"), cwd), "payments-svc");
assert_eq!(resolve_repo_id(Some(" "), cwd), "whatever");
}
fn ep(dir: Direction, contract: &str, repo: &str, conf: f64) -> ServiceEndpoint {
ServiceEndpoint {
transport: Transport::Http,
direction: dir,
contract: contract.to_string(),
repo_id: repo.to_string(),
symbol_id: format!("sym_{repo}_{dir:?}"),
qname: "q".into(),
file: "f".into(),
line: 1,
confidence: conf,
note: None,
}
}
#[test]
fn match_pairs_outbound_to_inbound_on_contract() {
let c = http_contract("POST", "/charge");
let eps = vec![
ep(Direction::Outbound, &c, "billing", 0.9),
ep(Direction::Inbound, &c, "payments", 0.8),
];
let edges = match_edges(&eps);
assert_eq!(edges.len(), 1);
let e = &edges[0];
assert_eq!(e.from.repo_id, "billing");
assert_eq!(e.to.repo_id, "payments");
assert!(e.cross_repo);
assert_eq!(e.confidence, 0.8);
}
#[test]
fn match_marks_same_repo_edges_not_cross_repo() {
let c = http_contract("GET", "/health");
let eps = vec![
ep(Direction::Outbound, &c, "svc", 1.0),
ep(Direction::Inbound, &c, "svc", 1.0),
];
let edges = match_edges(&eps);
assert_eq!(edges.len(), 1);
assert!(!edges[0].cross_repo);
}
#[test]
fn no_edge_without_a_matching_inbound() {
let c = http_contract("POST", "/charge");
let edges = match_edges(&[ep(Direction::Outbound, &c, "billing", 0.9)]);
assert!(edges.is_empty());
}
#[test]
fn different_contracts_do_not_match() {
let eps = vec![
ep(
Direction::Outbound,
&http_contract("POST", "/charge"),
"a",
1.0,
),
ep(
Direction::Inbound,
&http_contract("POST", "/refund"),
"b",
1.0,
),
];
assert!(match_edges(&eps).is_empty());
}
#[test]
fn one_inbound_matches_many_outbound_consumers() {
let c = http_contract("GET", "/users/{id}");
let eps = vec![
ep(Direction::Inbound, &c, "users", 0.9),
ep(Direction::Outbound, &c, "web", 0.7),
ep(Direction::Outbound, &c, "mobile", 0.6),
];
let edges = match_edges(&eps);
assert_eq!(edges.len(), 2, "both consumers link to the one handler");
}
#[test]
fn endpoints_from_tree_flattens_registry() {
let e = ep(
Direction::Inbound,
&http_contract("POST", "/charge"),
"pay",
0.9,
);
let tree = serde_json::json!({
contract_hash(&e.contract): { "pay": { "sym_1": serde_json::to_value(&e).unwrap() } }
});
let got = endpoints_from_tree(&tree);
assert_eq!(got.len(), 1);
assert_eq!(got[0].contract, "http:POST /charge");
assert!(endpoints_from_tree(&serde_json::Value::Null).is_empty());
assert!(endpoints_from_tree(&serde_json::json!({"h": {"r": {"s": 42}}})).is_empty());
}
#[test]
fn manifest_round_trips() {
let m = ServiceManifest {
repo_id: "payments".into(),
endpoints: vec![ep(
Direction::Inbound,
&http_contract("POST", "/charge"),
"payments",
0.9,
)],
};
let back: ServiceManifest =
serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(back.repo_id, "payments");
assert_eq!(back.endpoints.len(), 1);
assert_eq!(back.endpoints[0].contract, "http:POST /charge");
}
}