1use serde::{Deserialize, Serialize};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum Transport {
30 Http,
31 PubSub,
32 DataFlow,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Direction {
41 Inbound,
43 Outbound,
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct ServiceEndpoint {
50 pub transport: Transport,
51 pub direction: Direction,
52 pub contract: String,
55 pub repo_id: String,
56 pub symbol_id: String,
57 pub qname: String,
58 pub file: String,
59 pub line: u32,
60 pub confidence: f64,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub note: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct EndpointRef {
70 pub repo_id: String,
71 pub symbol_id: String,
72 pub qname: String,
73 pub file: String,
74 pub line: u32,
75}
76
77impl From<&ServiceEndpoint> for EndpointRef {
78 fn from(e: &ServiceEndpoint) -> Self {
79 EndpointRef {
80 repo_id: e.repo_id.clone(),
81 symbol_id: e.symbol_id.clone(),
82 qname: e.qname.clone(),
83 file: e.file.clone(),
84 line: e.line,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct CrossServiceEdge {
93 pub transport: Transport,
94 pub contract: String,
95 pub from: EndpointRef,
97 pub to: EndpointRef,
99 pub confidence: f64,
101 pub cross_repo: bool,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ServiceManifest {
108 pub repo_id: String,
109 pub endpoints: Vec<ServiceEndpoint>,
110}
111
112#[derive(Debug, Clone, PartialEq)]
116pub struct DetectedEndpoint {
117 pub transport: Transport,
118 pub direction: Direction,
119 pub contract: String,
120 pub owner_qname: String,
123 pub file: String,
124 pub line: u32,
125 pub confidence: f64,
126 pub note: Option<String>,
127}
128
129impl DetectedEndpoint {
130 pub fn into_endpoint(self, repo_id: &str, symbol_id: &str) -> ServiceEndpoint {
132 ServiceEndpoint {
133 transport: self.transport,
134 direction: self.direction,
135 contract: self.contract,
136 repo_id: repo_id.to_string(),
137 symbol_id: symbol_id.to_string(),
138 qname: self.owner_qname,
139 file: self.file,
140 line: self.line,
141 confidence: self.confidence,
142 note: self.note,
143 }
144 }
145}
146
147pub fn http_contract(method: &str, path_or_url: &str) -> String {
163 format!(
164 "http:{} {}",
165 method.trim().to_uppercase(),
166 normalize_http_path(path_or_url)
167 )
168}
169
170fn normalize_http_path(raw: &str) -> String {
171 let after_host = match raw.find("://") {
173 Some(i) => {
174 let rest = &raw[i + 3..];
175 match rest.find('/') {
176 Some(p) => &rest[p..],
177 None => "/",
178 }
179 }
180 None => raw,
181 };
182 let path = after_host
184 .split(['?', '#'])
185 .next()
186 .unwrap_or(after_host)
187 .trim()
188 .trim_end_matches('/');
189
190 let segs: Vec<String> = path
191 .split('/')
192 .filter(|s| !s.is_empty())
193 .map(|seg| {
194 if is_path_param(seg) {
195 "{}".to_string()
196 } else {
197 seg.to_lowercase()
198 }
199 })
200 .collect();
201
202 if segs.is_empty() {
203 "/".to_string()
204 } else {
205 format!("/{}", segs.join("/"))
206 }
207}
208
209fn is_path_param(seg: &str) -> bool {
213 (seg.starts_with('{') && seg.ends_with('}'))
214 || (seg.starts_with("${") && seg.ends_with('}'))
215 || seg.starts_with(':')
216 || (seg.starts_with('<') && seg.ends_with('>'))
217}
218
219pub fn pubsub_contract(topic: &str) -> String {
221 format!("topic:{}", topic.trim().to_lowercase())
222}
223
224pub fn contract_hash(contract: &str) -> String {
227 blake3::hash(contract.as_bytes()).to_hex()[..16].to_string()
228}
229
230pub fn normalize_repo_id(remote_url: &str) -> String {
243 let mut s = remote_url.trim().to_string();
244
245 if let Some(i) = s.find("://") {
247 s = s[i + 3..].to_string();
248 }
249 if let Some(at) = s.find('@') {
251 let host_boundary = s.find('/').unwrap_or(s.len());
252 if at < host_boundary {
253 s = s[at + 1..].to_string();
254 }
255 }
256 if let Some(colon) = s.find(':') {
258 let next_is_digit = s[colon + 1..]
259 .chars()
260 .next()
261 .map_or(false, |c| c.is_ascii_digit());
262 if !next_is_digit {
263 s.replace_range(colon..colon + 1, "/");
264 }
265 }
266 let s = s.trim_end_matches('/');
268 let s = s.strip_suffix(".git").unwrap_or(s);
269 s.trim_end_matches('/').to_lowercase()
270}
271
272pub fn resolve_repo_id(override_id: Option<&str>, cwd: &std::path::Path) -> String {
275 if let Some(id) = override_id {
276 let id = id.trim();
277 if !id.is_empty() {
278 return id.to_string();
279 }
280 }
281 if let Some(url) = git_origin_url(cwd) {
282 let norm = normalize_repo_id(&url);
283 if !norm.is_empty() {
284 return norm;
285 }
286 }
287 cwd.file_name()
288 .and_then(|n| n.to_str())
289 .map(|n| n.to_lowercase())
290 .unwrap_or_else(|| "unknown-repo".to_string())
291}
292
293fn git_origin_url(cwd: &std::path::Path) -> Option<String> {
294 let out = std::process::Command::new("git")
295 .arg("-C")
296 .arg(cwd)
297 .args(["remote", "get-url", "origin"])
298 .output()
299 .ok()?;
300 if !out.status.success() {
301 return None;
302 }
303 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
304 if url.is_empty() { None } else { Some(url) }
305}
306
307pub fn endpoints_from_tree(tree: &serde_json::Value) -> Vec<ServiceEndpoint> {
311 let mut out = Vec::new();
312 let Some(by_contract) = tree.as_object() else {
313 return out;
314 };
315 for by_repo in by_contract.values() {
316 let Some(by_repo) = by_repo.as_object() else {
317 continue;
318 };
319 for by_sym in by_repo.values() {
320 let Some(by_sym) = by_sym.as_object() else {
321 continue;
322 };
323 for ep in by_sym.values() {
324 if let Ok(e) = serde_json::from_value::<ServiceEndpoint>(ep.clone()) {
325 out.push(e);
326 }
327 }
328 }
329 }
330 out
331}
332
333pub fn match_edges(endpoints: &[ServiceEndpoint]) -> Vec<CrossServiceEdge> {
342 use std::collections::HashMap;
343
344 let mut inbound_by_contract: HashMap<&str, Vec<&ServiceEndpoint>> = HashMap::new();
345 for e in endpoints
346 .iter()
347 .filter(|e| e.direction == Direction::Inbound)
348 {
349 inbound_by_contract
350 .entry(e.contract.as_str())
351 .or_default()
352 .push(e);
353 }
354
355 let mut edges = Vec::new();
356 for out in endpoints
357 .iter()
358 .filter(|e| e.direction == Direction::Outbound)
359 {
360 let Some(ins) = inbound_by_contract.get(out.contract.as_str()) else {
361 continue;
362 };
363 for inb in ins {
364 if inb.transport != out.transport {
366 continue;
367 }
368 edges.push(CrossServiceEdge {
369 transport: out.transport,
370 contract: out.contract.clone(),
371 from: EndpointRef::from(out),
372 to: EndpointRef::from(*inb),
373 confidence: out.confidence.min(inb.confidence),
374 cross_repo: out.repo_id != inb.repo_id,
375 });
376 }
377 }
378 edges
379}
380
381pub fn federated_edges(
387 manifests: &[ServiceManifest],
388 cross_repo_only: bool,
389) -> Vec<CrossServiceEdge> {
390 let all: Vec<ServiceEndpoint> = manifests
391 .iter()
392 .flat_map(|m| m.endpoints.iter().cloned())
393 .collect();
394 let mut edges = match_edges(&all);
395 if cross_repo_only {
396 edges.retain(|e| e.cross_repo);
397 }
398 edges
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 fn endpoint(repo: &str, dir: Direction, contract: &str) -> ServiceEndpoint {
406 ServiceEndpoint {
407 transport: Transport::Http,
408 direction: dir,
409 contract: contract.to_string(),
410 repo_id: repo.to_string(),
411 symbol_id: format!("{repo}:sym"),
412 qname: format!("{repo}.fn"),
413 file: format!("{repo}/f.py"),
414 line: 1,
415 confidence: 0.9,
416 note: None,
417 }
418 }
419
420 #[test]
421 fn federated_edges_match_client_in_a_to_route_in_b() {
422 let manifests = vec![
423 ServiceManifest {
424 repo_id: "frontend".into(),
425 endpoints: vec![
426 endpoint("frontend", Direction::Outbound, "http:GET /api/orders/{}"),
427 endpoint("frontend", Direction::Outbound, "http:GET /api/ghost"),
429 ],
430 },
431 ServiceManifest {
432 repo_id: "backend".into(),
433 endpoints: vec![endpoint(
434 "backend",
435 Direction::Inbound,
436 "http:GET /api/orders/{}",
437 )],
438 },
439 ];
440 let edges = federated_edges(&manifests, true);
441 assert_eq!(edges.len(), 1, "{edges:?}");
442 assert_eq!(edges[0].from.repo_id, "frontend");
443 assert_eq!(edges[0].to.repo_id, "backend");
444 assert_eq!(edges[0].contract, "http:GET /api/orders/{}");
445 assert!(edges[0].cross_repo);
446
447 let same = vec![ServiceManifest {
449 repo_id: "solo".into(),
450 endpoints: vec![
451 endpoint("solo", Direction::Outbound, "http:GET /api/x"),
452 endpoint("solo", Direction::Inbound, "http:GET /api/x"),
453 ],
454 }];
455 assert_eq!(federated_edges(&same, true).len(), 0);
456 assert_eq!(federated_edges(&same, false).len(), 1);
457 }
458
459 #[test]
462 fn http_method_uppercased_and_path_lowercased() {
463 assert_eq!(http_contract("post", "/Charge"), "http:POST /charge");
464 }
465
466 #[test]
467 fn http_path_params_collapse_across_framework_spellings() {
468 let want = "http:GET /users/{}/posts/{}";
469 assert_eq!(http_contract("get", "/users/{id}/posts/{postId}"), want);
470 assert_eq!(http_contract("get", "/users/:id/posts/:postId"), want);
471 assert_eq!(http_contract("get", "/users/<id>/posts/<int:postId>"), want);
472 }
473
474 #[test]
475 fn http_client_url_and_server_route_match() {
476 let client = http_contract("GET", "https://payments.svc/users/:id?expand=1#frag");
479 let server = http_contract("get", "/users/{id}");
480 assert_eq!(client, server);
481 assert_eq!(server, "http:GET /users/{}");
482 }
483
484 #[test]
485 fn http_trailing_slash_and_root_normalized() {
486 assert_eq!(http_contract("GET", "/charge/"), "http:GET /charge");
487 assert_eq!(http_contract("GET", "/"), "http:GET /");
488 assert_eq!(http_contract("GET", ""), "http:GET /");
489 }
490
491 #[test]
492 fn pubsub_contract_normalizes_case() {
493 assert_eq!(
494 pubsub_contract(" Payments.Charged "),
495 "topic:payments.charged"
496 );
497 }
498
499 #[test]
500 fn contract_hash_is_stable_and_path_safe() {
501 let c = http_contract("POST", "/charge");
502 let h = contract_hash(&c);
503 assert_eq!(h, contract_hash(&c), "hash must be deterministic");
504 assert_eq!(h.len(), 16);
505 assert!(h.chars().all(|ch| ch.is_ascii_hexdigit()));
506 }
507
508 #[test]
511 fn repo_id_scp_and_https_collapse() {
512 assert_eq!(
513 normalize_repo_id("git@github.com:Org/Repo.git"),
514 "github.com/org/repo"
515 );
516 assert_eq!(
517 normalize_repo_id("https://github.com/Org/Repo.git"),
518 "github.com/org/repo"
519 );
520 assert_eq!(
521 normalize_repo_id("https://github.com/Org/Repo"),
522 "github.com/org/repo"
523 );
524 }
525
526 #[test]
527 fn repo_id_strips_userinfo_keeps_port() {
528 assert_eq!(
531 normalize_repo_id("ssh://git@host.example:22/team/svc.git"),
532 "host.example:22/team/svc"
533 );
534 assert_eq!(
535 normalize_repo_id("https://user@gitlab.com/team/svc.git"),
536 "gitlab.com/team/svc"
537 );
538 }
539
540 #[test]
541 fn repo_id_override_wins_and_blank_ignored() {
542 let cwd = std::path::Path::new("/tmp/whatever");
543 assert_eq!(resolve_repo_id(Some("payments-svc"), cwd), "payments-svc");
544 assert_eq!(resolve_repo_id(Some(" "), cwd), "whatever");
546 }
547
548 fn ep(dir: Direction, contract: &str, repo: &str, conf: f64) -> ServiceEndpoint {
551 ServiceEndpoint {
552 transport: Transport::Http,
553 direction: dir,
554 contract: contract.to_string(),
555 repo_id: repo.to_string(),
556 symbol_id: format!("sym_{repo}_{dir:?}"),
557 qname: "q".into(),
558 file: "f".into(),
559 line: 1,
560 confidence: conf,
561 note: None,
562 }
563 }
564
565 #[test]
566 fn match_pairs_outbound_to_inbound_on_contract() {
567 let c = http_contract("POST", "/charge");
568 let eps = vec![
569 ep(Direction::Outbound, &c, "billing", 0.9),
570 ep(Direction::Inbound, &c, "payments", 0.8),
571 ];
572 let edges = match_edges(&eps);
573 assert_eq!(edges.len(), 1);
574 let e = &edges[0];
575 assert_eq!(e.from.repo_id, "billing");
576 assert_eq!(e.to.repo_id, "payments");
577 assert!(e.cross_repo);
578 assert_eq!(e.confidence, 0.8);
580 }
581
582 #[test]
583 fn match_marks_same_repo_edges_not_cross_repo() {
584 let c = http_contract("GET", "/health");
585 let eps = vec![
586 ep(Direction::Outbound, &c, "svc", 1.0),
587 ep(Direction::Inbound, &c, "svc", 1.0),
588 ];
589 let edges = match_edges(&eps);
590 assert_eq!(edges.len(), 1);
591 assert!(!edges[0].cross_repo);
592 }
593
594 #[test]
595 fn no_edge_without_a_matching_inbound() {
596 let c = http_contract("POST", "/charge");
597 let edges = match_edges(&[ep(Direction::Outbound, &c, "billing", 0.9)]);
598 assert!(edges.is_empty());
599 }
600
601 #[test]
602 fn different_contracts_do_not_match() {
603 let eps = vec![
604 ep(
605 Direction::Outbound,
606 &http_contract("POST", "/charge"),
607 "a",
608 1.0,
609 ),
610 ep(
611 Direction::Inbound,
612 &http_contract("POST", "/refund"),
613 "b",
614 1.0,
615 ),
616 ];
617 assert!(match_edges(&eps).is_empty());
618 }
619
620 #[test]
621 fn one_inbound_matches_many_outbound_consumers() {
622 let c = http_contract("GET", "/users/{id}");
623 let eps = vec![
624 ep(Direction::Inbound, &c, "users", 0.9),
625 ep(Direction::Outbound, &c, "web", 0.7),
626 ep(Direction::Outbound, &c, "mobile", 0.6),
627 ];
628 let edges = match_edges(&eps);
629 assert_eq!(edges.len(), 2, "both consumers link to the one handler");
630 }
631
632 #[test]
633 fn endpoints_from_tree_flattens_registry() {
634 let e = ep(
636 Direction::Inbound,
637 &http_contract("POST", "/charge"),
638 "pay",
639 0.9,
640 );
641 let tree = serde_json::json!({
642 contract_hash(&e.contract): { "pay": { "sym_1": serde_json::to_value(&e).unwrap() } }
643 });
644 let got = endpoints_from_tree(&tree);
645 assert_eq!(got.len(), 1);
646 assert_eq!(got[0].contract, "http:POST /charge");
647 assert!(endpoints_from_tree(&serde_json::Value::Null).is_empty());
649 assert!(endpoints_from_tree(&serde_json::json!({"h": {"r": {"s": 42}}})).is_empty());
650 }
651
652 #[test]
653 fn manifest_round_trips() {
654 let m = ServiceManifest {
655 repo_id: "payments".into(),
656 endpoints: vec![ep(
657 Direction::Inbound,
658 &http_contract("POST", "/charge"),
659 "payments",
660 0.9,
661 )],
662 };
663 let back: ServiceManifest =
664 serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
665 assert_eq!(back.repo_id, "payments");
666 assert_eq!(back.endpoints.len(), 1);
667 assert_eq!(back.endpoints[0].contract, "http:POST /charge");
668 }
669}