1use std::collections::{BTreeMap, BTreeSet};
2
3use code_system_graph_model::{
4 Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, EvidenceRef, LinkDecision, LinkStatus, Node, NodeId, Provenance, stable_id
5};
6use semver::Version;
7use thiserror::Error;
8
9use crate::{BoundaryRole, HttpBoundary, ManifestError, ManualLinkConfig, validate_manual_links};
10
11const MANUAL_LINK_MATCHER: &str = "manual_exact";
12const MANUAL_LINK_EXTRACTOR: &str = "code-system-graph.manual-link";
13
14#[derive(Debug, Error, PartialEq, Eq)]
16pub enum LinkError {
17 #[error("ambiguous HTTP provider for {method} {path}: {candidates:?}")]
19 AmbiguousProvider {
20 method: String,
22 path: String,
24 candidates: Vec<String>,
26 },
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct HttpLinkAmbiguity {
32 pub method: String,
34 pub path: String,
36 pub candidates: Vec<String>,
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub struct HttpLinkResolution {
43 pub edges: Vec<Edge>,
45 pub ambiguities: Vec<HttpLinkAmbiguity>,
47}
48
49impl HttpLinkResolution {
50 pub(crate) fn into_legacy_result(self) -> Result<Vec<Edge>, LinkError> {
51 let Self { edges, ambiguities } = self;
52 let Some(ambiguity) = ambiguities.into_iter().next() else {
53 return Ok(edges);
54 };
55 Err(LinkError::AmbiguousProvider {
56 method: ambiguity.method,
57 path: ambiguity.path,
58 candidates: ambiguity.candidates,
59 })
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ManualLinkEndpoint {
66 From,
68 To,
70}
71
72impl std::fmt::Display for ManualLinkEndpoint {
73 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::From => formatter.write_str("from"),
76 Self::To => formatter.write_str("to"),
77 }
78 }
79}
80
81#[derive(Debug, Error)]
83pub enum ManualLinkError {
84 #[error(transparent)]
86 InvalidDeclaration(#[from] ManifestError),
87 #[error(
89 "manualLinks[{index}].{endpoint} `{value}` did not match any exact node ID or stable key"
90 )]
91 MissingEndpoint {
92 index: usize,
94 endpoint: ManualLinkEndpoint,
96 value: String,
98 },
99 #[error(
101 "manualLinks[{index}].{endpoint} `{value}` is ambiguous across exact candidates {candidates:?}"
102 )]
103 AmbiguousEndpoint {
104 index: usize,
106 endpoint: ManualLinkEndpoint,
108 value: String,
110 candidates: Vec<NodeId>,
112 },
113 #[error("manualLinks[{index}] resolves both endpoints to `{node}`")]
115 ResolvedSelfLink {
116 index: usize,
118 node: String,
120 },
121 #[error(
123 "manualLinks[{duplicate}] resolves to the same relationship as manualLinks[{first}]: {source_node:?} -> {target_node:?} ({relation:?})"
124 )]
125 DuplicateResolvedLink {
126 first: usize,
128 duplicate: usize,
130 source_node: NodeId,
132 target_node: NodeId,
134 relation: EdgeKind,
136 },
137 #[error(
139 "manualLinks[{index}] cannot suppress missing automatic relationship {source_node:?} -> {target_node:?} ({relation:?})"
140 )]
141 MissingSuppressionTarget {
142 index: usize,
144 source_node: NodeId,
146 target_node: NodeId,
148 relation: EdgeKind,
150 },
151}
152
153#[derive(Debug, Clone, PartialEq)]
155pub struct ManualLinkResolution {
156 pub edges: Vec<Edge>,
158 pub evidence: Vec<Evidence>,
160 pub decisions: Vec<LinkDecision>,
162}
163
164pub fn link_http_boundaries(boundaries: &[HttpBoundary]) -> Result<Vec<Edge>, LinkError> {
177 link_http_boundaries_with_ambiguities(boundaries).into_legacy_result()
178}
179
180#[must_use]
182pub fn link_http_boundaries_with_ambiguities(boundaries: &[HttpBoundary]) -> HttpLinkResolution {
183 let mut providers: BTreeMap<(&str, &str), Vec<&HttpBoundary>> = BTreeMap::new();
184 for boundary in boundaries {
185 if boundary.role == BoundaryRole::Provider {
186 let candidates = providers
187 .entry((&boundary.method, &boundary.path))
188 .or_default();
189 if let Some(existing) = candidates
190 .iter()
191 .position(|candidate| candidate.node.id == boundary.node.id)
192 {
193 if boundary.evidence.confidence > candidates[existing].evidence.confidence {
194 candidates[existing] = boundary;
195 }
196 } else {
197 candidates.push(boundary);
198 }
199 }
200 }
201
202 let mut edges = Vec::new();
203 let mut ambiguities = Vec::new();
204 for consumer in boundaries
205 .iter()
206 .filter(|boundary| boundary.role == BoundaryRole::Consumer)
207 {
208 let Some(candidates) = providers.get(&(consumer.method.as_str(), consumer.path.as_str()))
209 else {
210 continue;
211 };
212 if candidates.len() > 1 {
213 ambiguities.push(http_link_ambiguity(
214 &consumer.method,
215 &consumer.path,
216 candidates.iter().map(|candidate| &candidate.node.id),
217 ));
218 continue;
219 }
220 let provider = candidates[0];
221 let edge_key = format!(
222 "{}:calls_remote:{}",
223 consumer.node.id.as_str(),
224 provider.node.id.as_str()
225 );
226 edges.push(Edge {
227 id: EdgeId::new(stable_id("edge", &edge_key)),
228 source: consumer.node.id.clone(),
229 target: provider.node.id.clone(),
230 kind: EdgeKind::CallsRemote,
231 confidence: consumer
232 .evidence
233 .confidence
234 .min(provider.evidence.confidence),
235 status: consensus_status(
236 consumer
237 .evidence
238 .confidence
239 .min(provider.evidence.confidence),
240 ),
241 evidence: vec![consumer.evidence.id.clone(), provider.evidence.id.clone()],
242 });
243 }
244 edges.sort_by(|left, right| left.id.cmp(&right.id));
245 sort_http_ambiguities(&mut ambiguities);
246 HttpLinkResolution { edges, ambiguities }
247}
248
249pub(crate) fn http_link_ambiguity<'a>(
250 method: &str,
251 path: &str,
252 candidates: impl IntoIterator<Item = &'a NodeId>,
253) -> HttpLinkAmbiguity {
254 let mut candidates = candidates
255 .into_iter()
256 .map(|candidate| candidate.as_str().to_owned())
257 .collect::<Vec<_>>();
258 candidates.sort();
259 candidates.dedup();
260 HttpLinkAmbiguity {
261 method: method.to_owned(),
262 path: path.to_owned(),
263 candidates,
264 }
265}
266
267pub(crate) fn sort_http_ambiguities(ambiguities: &mut Vec<HttpLinkAmbiguity>) {
268 ambiguities.sort_by(|left, right| {
269 (&left.method, &left.path, &left.candidates).cmp(&(
270 &right.method,
271 &right.path,
272 &right.candidates,
273 ))
274 });
275 ambiguities.dedup();
276}
277
278fn consensus_status(confidence: f32) -> EpistemicStatus {
279 if confidence >= 1.0 {
280 EpistemicStatus::Confirmed
281 } else {
282 EpistemicStatus::Inferred
283 }
284}
285
286pub fn resolve_manual_links(
298 links: &[ManualLinkConfig],
299 nodes: &[Node],
300 automatic_edges: &[Edge],
301) -> Result<ManualLinkResolution, ManualLinkError> {
302 validate_manual_links(links)?;
303 let mut resolved = Vec::with_capacity(links.len());
304 let mut identities = BTreeMap::new();
305 for (index, link) in links.iter().enumerate() {
306 let source = resolve_manual_endpoint(nodes, index, ManualLinkEndpoint::From, &link.from)?;
307 let target = resolve_manual_endpoint(nodes, index, ManualLinkEndpoint::To, &link.to)?;
308 if source == target {
309 return Err(ManualLinkError::ResolvedSelfLink {
310 index,
311 node: source.as_str().to_owned(),
312 });
313 }
314 let identity = (source.clone(), target.clone(), link.relation);
315 if let Some(first) = identities.insert(identity, index) {
316 return Err(ManualLinkError::DuplicateResolvedLink {
317 first,
318 duplicate: index,
319 source_node: source,
320 target_node: target,
321 relation: link.relation,
322 });
323 }
324 resolved.push(ResolvedManualLink {
325 index,
326 link,
327 source,
328 target,
329 });
330 }
331 resolved.sort_by(|left, right| {
332 (
333 &left.source,
334 &left.target,
335 left.link.relation,
336 left.link.suppress,
337 )
338 .cmp(&(
339 &right.source,
340 &right.target,
341 right.link.relation,
342 right.link.suppress,
343 ))
344 });
345
346 let mut edges = automatic_edges.to_vec();
347 edges.sort_by(|left, right| left.id.cmp(&right.id));
348 let mut evidence = Vec::with_capacity(resolved.len());
349 let mut decisions = Vec::with_capacity(resolved.len());
350 for item in resolved {
351 let manual_evidence = manual_link_evidence(&item);
352 let evidence_ref = EvidenceRef {
353 id: manual_evidence.id.clone(),
354 provenance: Provenance::Manual,
355 };
356 let status = if item.link.suppress {
357 let before = edges.len();
358 edges.retain(|edge| !edge_matches(&item, edge));
359 let removed = before - edges.len();
360 if removed == 0 {
361 return Err(ManualLinkError::MissingSuppressionTarget {
362 index: item.index,
363 source_node: item.source,
364 target_node: item.target,
365 relation: item.link.relation,
366 });
367 }
368 LinkStatus::Suppressed
369 } else {
370 edges.retain(|edge| !edge_matches(&item, edge));
371 edges.push(manual_link_edge(&item, &manual_evidence));
372 LinkStatus::Confirmed
373 };
374 decisions.push(manual_link_decision(&item, evidence_ref, status));
375 evidence.push(manual_evidence);
376 }
377 edges.sort_by(|left, right| left.id.cmp(&right.id));
378 evidence.sort_by(|left, right| left.id.cmp(&right.id));
379 decisions.sort_by(|left, right| {
380 (&left.source, &left.target, left.relation, left.status).cmp(&(
381 &right.source,
382 &right.target,
383 right.relation,
384 right.status,
385 ))
386 });
387 Ok(ManualLinkResolution {
388 edges,
389 evidence,
390 decisions,
391 })
392}
393
394#[derive(Debug)]
395struct ResolvedManualLink<'a> {
396 index: usize,
397 link: &'a ManualLinkConfig,
398 source: NodeId,
399 target: NodeId,
400}
401
402fn resolve_manual_endpoint(
403 nodes: &[Node],
404 index: usize,
405 endpoint: ManualLinkEndpoint,
406 value: &str,
407) -> Result<NodeId, ManualLinkError> {
408 let candidates = nodes
409 .iter()
410 .filter(|node| node.id.as_str() == value || node.stable_key == value)
411 .map(|node| node.id.clone())
412 .collect::<BTreeSet<_>>();
413 match candidates.len() {
414 0 => Err(ManualLinkError::MissingEndpoint {
415 index,
416 endpoint,
417 value: value.to_owned(),
418 }),
419 1 => candidates
420 .into_iter()
421 .next()
422 .ok_or_else(|| ManualLinkError::MissingEndpoint {
423 index,
424 endpoint,
425 value: value.to_owned(),
426 }),
427 _ => Err(ManualLinkError::AmbiguousEndpoint {
428 index,
429 endpoint,
430 value: value.to_owned(),
431 candidates: candidates.into_iter().collect(),
432 }),
433 }
434}
435
436fn edge_matches(link: &ResolvedManualLink<'_>, edge: &Edge) -> bool {
437 edge.source == link.source && edge.target == link.target && edge.kind == link.link.relation
438}
439
440fn manual_link_edge(link: &ResolvedManualLink<'_>, evidence: &Evidence) -> Edge {
441 let key = format!(
442 "{}:{:?}:{}",
443 link.source.as_str(),
444 link.link.relation,
445 link.target.as_str()
446 );
447 Edge {
448 id: EdgeId::new(stable_id("edge", &key)),
449 source: link.source.clone(),
450 target: link.target.clone(),
451 kind: link.link.relation,
452 confidence: 1.0,
453 status: EpistemicStatus::Confirmed,
454 evidence: vec![evidence.id.clone()],
455 }
456}
457
458fn manual_link_evidence(link: &ResolvedManualLink<'_>) -> Evidence {
459 let contract = link.link.contract.as_deref().unwrap_or("");
460 let key = format!(
461 "{}:{}:{:?}:{contract}:{}:{}",
462 link.source.as_str(),
463 link.target.as_str(),
464 link.link.relation,
465 link.link.suppress,
466 link.link.reason
467 );
468 Evidence {
469 id: EvidenceId::new(stable_id("evidence", &key)),
470 repo_id: None,
471 file_path: None,
472 start_line: None,
473 end_line: None,
474 extractor: MANUAL_LINK_EXTRACTOR.to_owned(),
475 extractor_version: "1.0.0".to_owned(),
476 provenance: Provenance::Manual,
477 confidence: 1.0,
478 observed_at_commit: None,
479 content_hash: Some(stable_id("manual-link-declaration", &key)),
480 note: Some(link.link.reason.clone()),
481 }
482}
483
484fn manual_link_decision(
485 link: &ResolvedManualLink<'_>,
486 evidence: EvidenceRef,
487 status: LinkStatus,
488) -> LinkDecision {
489 let mut reasons = vec![
490 link.link.reason.clone(),
491 "both endpoints matched an exact node ID or stable key".to_owned(),
492 ];
493 if let Some(contract) = &link.link.contract {
494 reasons.push(format!("declared contract: {contract}"));
495 }
496 reasons.push(match status {
497 LinkStatus::Confirmed => "manual declaration created the exact relationship".to_owned(),
498 LinkStatus::Suppressed => {
499 "manual declaration removed the exact automatic relationship".to_owned()
500 }
501 LinkStatus::Ambiguous | LinkStatus::Rejected => {
502 "manual declaration was not applied".to_owned()
503 }
504 });
505 LinkDecision {
506 source: link.source.clone(),
507 target: link.target.clone(),
508 relation: link.link.relation,
509 matcher: MANUAL_LINK_MATCHER.to_owned(),
510 matcher_version: Version::new(1, 0, 0),
511 score: 1.0,
512 confidence: 1.0,
513 reasons,
514 rejected_alternatives: Vec::new(),
515 evidence: vec![evidence],
516 status,
517 }
518}
519
520#[must_use]
525pub fn merge_affected_link_neighborhoods<K>(
526 previous_edges: &[Edge],
527 recomputed_edges: &[Edge],
528 affected_keys: &BTreeSet<K>,
529 previous_node_keys: &BTreeMap<NodeId, K>,
530 current_node_keys: &BTreeMap<NodeId, K>,
531 current_node_ids: &BTreeSet<NodeId>,
532) -> Vec<Edge>
533where
534 K: Ord,
535{
536 let mut merged = BTreeMap::new();
537 for edge in previous_edges {
538 let endpoints_exist =
539 current_node_ids.contains(&edge.source) && current_node_ids.contains(&edge.target);
540 if endpoints_exist && !edge_touches_keys(edge, previous_node_keys, affected_keys) {
541 merged.insert(edge.id.clone(), edge.clone());
542 }
543 }
544 for edge in recomputed_edges {
545 if edge_touches_keys(edge, current_node_keys, affected_keys) {
546 merged.insert(edge.id.clone(), edge.clone());
547 }
548 }
549 merged.into_values().collect()
550}
551
552fn edge_touches_keys<K>(
553 edge: &Edge,
554 node_keys: &BTreeMap<NodeId, K>,
555 affected_keys: &BTreeSet<K>,
556) -> bool
557where
558 K: Ord,
559{
560 [&edge.source, &edge.target]
561 .into_iter()
562 .filter_map(|node| node_keys.get(node))
563 .any(|key| affected_keys.contains(key))
564}
565
566#[cfg(test)]
567mod tests {
568 use std::collections::{BTreeMap, BTreeSet};
569
570 use code_system_graph_model::{
571 Edge, EdgeId, EdgeKind, EpistemicStatus, LinkStatus, Node, NodeId, NodeKind, Provenance, RepoId
572 };
573
574 use super::{
575 LinkError, ManualLinkEndpoint, ManualLinkError, link_http_boundaries, link_http_boundaries_with_ambiguities, merge_affected_link_neighborhoods, resolve_manual_links
576 };
577 use crate::{HttpConsumerConfig, ManualLinkConfig, extract_openapi};
578
579 fn consumer() -> crate::HttpBoundary {
580 crate::HttpBoundary::consumer(
581 RepoId::new("repo:web"),
582 &HttpConsumerConfig {
583 method: "POST".to_owned(),
584 path: "/api/orders".to_owned(),
585 source: "src/checkout.ts".to_owned(),
586 },
587 )
588 }
589
590 fn provider(repo: &str) -> crate::HttpBoundary {
591 let input = r"
592openapi: 3.0.3
593paths:
594 /api/orders:
595 post:
596 operationId: createOrder
597";
598 let result = extract_openapi(&RepoId::new(repo), "openapi.yaml", input);
599 match result {
600 Ok(mut boundaries) => boundaries.remove(0),
601 Err(error) => panic!("test fixture must be valid: {error}"),
602 }
603 }
604
605 fn graph_node(id: &str, stable_key: &str) -> Node {
606 Node {
607 id: NodeId::new(id),
608 kind: NodeKind::Service,
609 repo_id: None,
610 stable_key: stable_key.to_owned(),
611 label: stable_key.to_owned(),
612 }
613 }
614
615 fn manual_link(from: &str, to: &str, relation: EdgeKind, suppress: bool) -> ManualLinkConfig {
616 ManualLinkConfig {
617 from: from.to_owned(),
618 to: to.to_owned(),
619 relation,
620 contract: Some("POST /orders".to_owned()),
621 reason: "Explicit manual boundary".to_owned(),
622 suppress,
623 }
624 }
625
626 fn automatic_edge(id: &str, source: &str, target: &str, kind: EdgeKind) -> Edge {
627 Edge {
628 id: EdgeId::new(id),
629 source: NodeId::new(source),
630 target: NodeId::new(target),
631 kind,
632 confidence: 0.8,
633 status: EpistemicStatus::Inferred,
634 evidence: Vec::new(),
635 }
636 }
637
638 #[test]
639 fn link_http_boundaries_should_require_bilateral_evidence() {
640 let result = link_http_boundaries(&[consumer(), provider("repo:api")]);
641 let evidence_count = result.map(|edges| edges[0].evidence.len());
642
643 assert_eq!(evidence_count, Ok(2));
644 }
645
646 #[test]
647 fn link_http_boundaries_should_preserve_duplicate_providers_as_ambiguity() {
648 let result = link_http_boundaries_with_ambiguities(&[
649 consumer(),
650 provider("repo:api-a"),
651 provider("repo:api-b"),
652 ]);
653
654 assert!(result.edges.is_empty());
655 assert_eq!(result.ambiguities.len(), 1);
656 assert_eq!(result.ambiguities[0].method, "POST");
657 assert_eq!(result.ambiguities[0].path, "/api/orders");
658 assert_eq!(result.ambiguities[0].candidates.len(), 2);
659 }
660
661 #[test]
662 fn link_http_boundaries_compatibility_wrapper_should_reject_duplicate_providers() {
663 let result =
664 link_http_boundaries(&[consumer(), provider("repo:api-a"), provider("repo:api-b")]);
665
666 assert!(matches!(result, Err(LinkError::AmbiguousProvider { .. })));
667 }
668
669 #[test]
670 fn resolve_manual_links_should_create_confirmed_exact_edge_with_manual_evidence() {
671 let nodes = [
672 graph_node("node:web", "service:web"),
673 graph_node("node:api", "service:api"),
674 ];
675 let links = [manual_link(
676 "node:web",
677 "service:api",
678 EdgeKind::Consumes,
679 false,
680 )];
681
682 let result = resolve_manual_links(&links, &nodes, &[])
683 .unwrap_or_else(|error| panic!("manual link should resolve: {error}"));
684
685 assert!(
686 result.edges.len() == 1
687 && result.edges[0].source == NodeId::new("node:web")
688 && result.edges[0].target == NodeId::new("node:api")
689 && result.edges[0].kind == EdgeKind::Consumes
690 && (result.edges[0].confidence - 1.0).abs() < f32::EPSILON
691 && result.edges[0].status == EpistemicStatus::Confirmed
692 && result.evidence.len() == 1
693 && result.evidence[0].provenance == Provenance::Manual
694 && result.decisions.len() == 1
695 && result.decisions[0].status == LinkStatus::Confirmed
696 && (result.decisions[0].score - 1.0).abs() < f32::EPSILON
697 );
698 }
699
700 #[test]
701 fn resolve_manual_links_should_suppress_only_exact_automatic_relationship() {
702 let nodes = [
703 graph_node("node:web", "service:web"),
704 graph_node("node:api", "service:api"),
705 ];
706 let links = [manual_link(
707 "node:web",
708 "node:api",
709 EdgeKind::CallsRemote,
710 true,
711 )];
712 let automatic = [
713 automatic_edge("edge:exact", "node:web", "node:api", EdgeKind::CallsRemote),
714 automatic_edge(
715 "edge:reverse",
716 "node:api",
717 "node:web",
718 EdgeKind::CallsRemote,
719 ),
720 automatic_edge("edge:relation", "node:web", "node:api", EdgeKind::Consumes),
721 ];
722
723 let result = resolve_manual_links(&links, &nodes, &automatic)
724 .unwrap_or_else(|error| panic!("suppression should resolve: {error}"));
725
726 assert!(
727 result.edges.len() == 2
728 && result
729 .edges
730 .iter()
731 .all(|edge| edge.id.as_str() != "edge:exact")
732 && result
733 .edges
734 .iter()
735 .any(|edge| edge.id.as_str() == "edge:reverse")
736 && result
737 .edges
738 .iter()
739 .any(|edge| edge.id.as_str() == "edge:relation")
740 && result.decisions[0].status == LinkStatus::Suppressed
741 );
742 }
743
744 #[test]
745 fn resolve_manual_links_should_fail_for_missing_endpoint() {
746 let nodes = [graph_node("node:web", "service:web")];
747 let links = [manual_link(
748 "node:web",
749 "service:missing",
750 EdgeKind::Consumes,
751 false,
752 )];
753
754 let result = resolve_manual_links(&links, &nodes, &[]);
755
756 assert!(matches!(
757 result,
758 Err(ManualLinkError::MissingEndpoint {
759 endpoint: ManualLinkEndpoint::To,
760 value,
761 ..
762 }) if value == "service:missing"
763 ));
764 }
765
766 #[test]
767 fn resolve_manual_links_should_fail_for_ambiguous_exact_endpoint() {
768 let nodes = [
769 graph_node("node:web", "service:web"),
770 graph_node("node:api-b", "service:shared"),
771 graph_node("node:api-a", "service:shared"),
772 ];
773 let links = [manual_link(
774 "node:web",
775 "service:shared",
776 EdgeKind::Consumes,
777 false,
778 )];
779
780 let result = resolve_manual_links(&links, &nodes, &[]);
781
782 assert!(matches!(
783 result,
784 Err(ManualLinkError::AmbiguousEndpoint { candidates, .. })
785 if candidates
786 == vec![NodeId::new("node:api-a"), NodeId::new("node:api-b")]
787 ));
788 }
789
790 #[test]
791 fn resolve_manual_links_should_be_independent_of_input_order() {
792 let nodes = vec![
793 graph_node("node:web", "service:web"),
794 graph_node("node:api", "service:api"),
795 graph_node("node:worker", "service:worker"),
796 ];
797 let links = vec![
798 manual_link("service:web", "service:api", EdgeKind::CallsRemote, false),
799 manual_link("service:worker", "service:api", EdgeKind::Consumes, false),
800 ];
801 let mut reversed_nodes = nodes.clone();
802 reversed_nodes.reverse();
803 let mut reversed_links = links.clone();
804 reversed_links.reverse();
805
806 let first = resolve_manual_links(&links, &nodes, &[]);
807 let second = resolve_manual_links(&reversed_links, &reversed_nodes, &[]);
808
809 assert_eq!(
810 first.unwrap_or_else(|error| panic!("first resolution should succeed: {error}")),
811 second.unwrap_or_else(|error| panic!("second resolution should succeed: {error}"))
812 );
813 }
814
815 #[test]
816 fn relinking_should_replace_only_affected_neighborhoods() {
817 let orders = link_http_boundaries(&[consumer(), provider("repo:api")])
818 .unwrap_or_else(|error| panic!("fixture must link: {error}"));
819 let mut health_consumer = consumer();
820 health_consumer.method = "GET".to_owned();
821 health_consumer.path = "/health".to_owned();
822 let mut health_provider = provider("repo:health");
823 health_provider.method = "GET".to_owned();
824 health_provider.path = "/health".to_owned();
825 let health = link_http_boundaries(&[health_consumer, health_provider])
826 .unwrap_or_else(|error| panic!("fixture must link: {error}"));
827 let mut previous = orders.clone();
828 previous.extend(health.clone());
829 let affected = BTreeSet::from(["POST:/api/orders".to_owned()]);
830 let previous_keys = BTreeMap::from([
831 (orders[0].source.clone(), "POST:/api/orders".to_owned()),
832 (orders[0].target.clone(), "POST:/api/orders".to_owned()),
833 (health[0].source.clone(), "GET:/health".to_owned()),
834 (health[0].target.clone(), "GET:/health".to_owned()),
835 ]);
836 let current_ids = previous_keys.keys().cloned().collect::<BTreeSet<NodeId>>();
837 let result = merge_affected_link_neighborhoods(
838 &previous,
839 &orders,
840 &affected,
841 &previous_keys,
842 &previous_keys,
843 ¤t_ids,
844 );
845
846 assert_eq!(result.len(), 2);
847 assert!(result.contains(&health[0]));
848 assert!(result.contains(&orders[0]));
849 }
850}