Skip to main content

code_system_graph_core/
linker.rs

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/// Error returned by deterministic boundary linking.
15#[derive(Debug, Error, PartialEq, Eq)]
16pub enum LinkError {
17    /// More than one provider has the same exact contract identity.
18    #[error("ambiguous HTTP provider for {method} {path}: {candidates:?}")]
19    AmbiguousProvider {
20        /// Canonical HTTP method.
21        method: String,
22        /// Canonical path template.
23        path: String,
24        /// Stable candidate node identifiers in deterministic order.
25        candidates: Vec<String>,
26    },
27}
28
29/// Endpoint field being resolved for a manual relationship.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ManualLinkEndpoint {
32    /// The `from` endpoint.
33    From,
34    /// The `to` endpoint.
35    To,
36}
37
38impl std::fmt::Display for ManualLinkEndpoint {
39    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            Self::From => formatter.write_str("from"),
42            Self::To => formatter.write_str("to"),
43        }
44    }
45}
46
47/// Error returned while resolving exact manual relationships.
48#[derive(Debug, Error)]
49pub enum ManualLinkError {
50    /// A manually constructed declaration violates manifest invariants.
51    #[error(transparent)]
52    InvalidDeclaration(#[from] ManifestError),
53    /// No node has the exact requested identifier or stable key.
54    #[error(
55        "manualLinks[{index}].{endpoint} `{value}` did not match any exact node ID or stable key"
56    )]
57    MissingEndpoint {
58        /// Zero-based declaration index.
59        index: usize,
60        /// Endpoint field.
61        endpoint: ManualLinkEndpoint,
62        /// Requested exact identity.
63        value: String,
64    },
65    /// More than one node has the exact requested identifier or stable key.
66    #[error(
67        "manualLinks[{index}].{endpoint} `{value}` is ambiguous across exact candidates {candidates:?}"
68    )]
69    AmbiguousEndpoint {
70        /// Zero-based declaration index.
71        index: usize,
72        /// Endpoint field.
73        endpoint: ManualLinkEndpoint,
74        /// Requested exact identity.
75        value: String,
76        /// Stable candidate identities in deterministic order.
77        candidates: Vec<NodeId>,
78    },
79    /// Distinct endpoint literals resolved to the same node.
80    #[error("manualLinks[{index}] resolves both endpoints to `{node}`")]
81    ResolvedSelfLink {
82        /// Zero-based declaration index.
83        index: usize,
84        /// Resolved node identity.
85        node: String,
86    },
87    /// Distinct declarations resolved to the same graph relationship.
88    #[error(
89        "manualLinks[{duplicate}] resolves to the same relationship as manualLinks[{first}]: {source_node:?} -> {target_node:?} ({relation:?})"
90    )]
91    DuplicateResolvedLink {
92        /// Index of the first declaration.
93        first: usize,
94        /// Index of the repeated declaration.
95        duplicate: usize,
96        /// Resolved source node.
97        source_node: NodeId,
98        /// Resolved target node.
99        target_node: NodeId,
100        /// Repeated concrete relationship.
101        relation: EdgeKind,
102    },
103    /// A suppression did not identify an existing exact automatic relationship.
104    #[error(
105        "manualLinks[{index}] cannot suppress missing automatic relationship {source_node:?} -> {target_node:?} ({relation:?})"
106    )]
107    MissingSuppressionTarget {
108        /// Zero-based declaration index.
109        index: usize,
110        /// Resolved source node.
111        source_node: NodeId,
112        /// Resolved target node.
113        target_node: NodeId,
114        /// Requested concrete relationship.
115        relation: EdgeKind,
116    },
117}
118
119/// Graph facts and audit records produced after applying manual declarations.
120#[derive(Debug, Clone, PartialEq)]
121pub struct ManualLinkResolution {
122    /// Automatic edges after exact suppressions and manual replacements.
123    pub edges: Vec<Edge>,
124    /// Manual evidence emitted for created edges and successful suppressions.
125    pub evidence: Vec<Evidence>,
126    /// Versioned decisions in deterministic relationship order.
127    pub decisions: Vec<LinkDecision>,
128}
129
130/// Links HTTP consumers to providers by exact canonical method and path.
131///
132/// Consumers without an observed provider remain unlinked. Callers must represent that as a
133/// coverage gap rather than concluding that no dependency exists.
134///
135/// # Errors
136///
137/// Returns [`LinkError::AmbiguousProvider`] instead of selecting among duplicate providers.
138pub fn link_http_boundaries(boundaries: &[HttpBoundary]) -> Result<Vec<Edge>, LinkError> {
139    let mut providers: BTreeMap<(&str, &str), Vec<&HttpBoundary>> = BTreeMap::new();
140    for boundary in boundaries {
141        if boundary.role == BoundaryRole::Provider {
142            let candidates = providers
143                .entry((&boundary.method, &boundary.path))
144                .or_default();
145            if let Some(existing) = candidates
146                .iter()
147                .position(|candidate| candidate.node.id == boundary.node.id)
148            {
149                if boundary.evidence.confidence > candidates[existing].evidence.confidence {
150                    candidates[existing] = boundary;
151                }
152            } else {
153                candidates.push(boundary);
154            }
155        }
156    }
157
158    let mut edges = Vec::new();
159    for consumer in boundaries
160        .iter()
161        .filter(|boundary| boundary.role == BoundaryRole::Consumer)
162    {
163        let Some(candidates) = providers.get(&(consumer.method.as_str(), consumer.path.as_str()))
164        else {
165            continue;
166        };
167        if candidates.len() > 1 {
168            let mut candidate_ids = candidates
169                .iter()
170                .map(|candidate| candidate.node.id.as_str().to_owned())
171                .collect::<Vec<_>>();
172            candidate_ids.sort();
173            return Err(LinkError::AmbiguousProvider {
174                method: consumer.method.clone(),
175                path: consumer.path.clone(),
176                candidates: candidate_ids,
177            });
178        }
179        let provider = candidates[0];
180        let edge_key = format!(
181            "{}:calls_remote:{}",
182            consumer.node.id.as_str(),
183            provider.node.id.as_str()
184        );
185        edges.push(Edge {
186            id: EdgeId::new(stable_id("edge", &edge_key)),
187            source: consumer.node.id.clone(),
188            target: provider.node.id.clone(),
189            kind: EdgeKind::CallsRemote,
190            confidence: consumer
191                .evidence
192                .confidence
193                .min(provider.evidence.confidence),
194            status: consensus_status(
195                consumer
196                    .evidence
197                    .confidence
198                    .min(provider.evidence.confidence),
199            ),
200            evidence: vec![consumer.evidence.id.clone(), provider.evidence.id.clone()],
201        });
202    }
203    edges.sort_by(|left, right| left.id.cmp(&right.id));
204    Ok(edges)
205}
206
207fn consensus_status(confidence: f32) -> EpistemicStatus {
208    if confidence >= 1.0 {
209        EpistemicStatus::Confirmed
210    } else {
211        EpistemicStatus::Inferred
212    }
213}
214
215/// Resolves and applies exact manual relationships to an automatic edge set.
216///
217/// Endpoint values match only [`NodeId`] text or [`Node::stable_key`] text. A creation replaces an
218/// automatic edge with the same source, target, and relation so the resulting edge is backed only
219/// by explicit manual evidence. A suppression removes every automatic edge with that exact triple;
220/// the optional contract is audit context and never broadens or narrows edge matching.
221///
222/// # Errors
223///
224/// Returns [`ManualLinkError`] for invalid declarations, missing or ambiguous endpoints, resolved
225/// self-links, duplicate resolved relationships, or suppressions that match no automatic edge.
226pub fn resolve_manual_links(
227    links: &[ManualLinkConfig],
228    nodes: &[Node],
229    automatic_edges: &[Edge],
230) -> Result<ManualLinkResolution, ManualLinkError> {
231    validate_manual_links(links)?;
232    let mut resolved = Vec::with_capacity(links.len());
233    let mut identities = BTreeMap::new();
234    for (index, link) in links.iter().enumerate() {
235        let source = resolve_manual_endpoint(nodes, index, ManualLinkEndpoint::From, &link.from)?;
236        let target = resolve_manual_endpoint(nodes, index, ManualLinkEndpoint::To, &link.to)?;
237        if source == target {
238            return Err(ManualLinkError::ResolvedSelfLink {
239                index,
240                node: source.as_str().to_owned(),
241            });
242        }
243        let identity = (source.clone(), target.clone(), link.relation);
244        if let Some(first) = identities.insert(identity, index) {
245            return Err(ManualLinkError::DuplicateResolvedLink {
246                first,
247                duplicate: index,
248                source_node: source,
249                target_node: target,
250                relation: link.relation,
251            });
252        }
253        resolved.push(ResolvedManualLink {
254            index,
255            link,
256            source,
257            target,
258        });
259    }
260    resolved.sort_by(|left, right| {
261        (
262            &left.source,
263            &left.target,
264            left.link.relation,
265            left.link.suppress,
266        )
267            .cmp(&(
268                &right.source,
269                &right.target,
270                right.link.relation,
271                right.link.suppress,
272            ))
273    });
274
275    let mut edges = automatic_edges.to_vec();
276    edges.sort_by(|left, right| left.id.cmp(&right.id));
277    let mut evidence = Vec::with_capacity(resolved.len());
278    let mut decisions = Vec::with_capacity(resolved.len());
279    for item in resolved {
280        let manual_evidence = manual_link_evidence(&item);
281        let evidence_ref = EvidenceRef {
282            id: manual_evidence.id.clone(),
283            provenance: Provenance::Manual,
284        };
285        let status = if item.link.suppress {
286            let before = edges.len();
287            edges.retain(|edge| !edge_matches(&item, edge));
288            let removed = before - edges.len();
289            if removed == 0 {
290                return Err(ManualLinkError::MissingSuppressionTarget {
291                    index: item.index,
292                    source_node: item.source,
293                    target_node: item.target,
294                    relation: item.link.relation,
295                });
296            }
297            LinkStatus::Suppressed
298        } else {
299            edges.retain(|edge| !edge_matches(&item, edge));
300            edges.push(manual_link_edge(&item, &manual_evidence));
301            LinkStatus::Confirmed
302        };
303        decisions.push(manual_link_decision(&item, evidence_ref, status));
304        evidence.push(manual_evidence);
305    }
306    edges.sort_by(|left, right| left.id.cmp(&right.id));
307    evidence.sort_by(|left, right| left.id.cmp(&right.id));
308    decisions.sort_by(|left, right| {
309        (&left.source, &left.target, left.relation, left.status).cmp(&(
310            &right.source,
311            &right.target,
312            right.relation,
313            right.status,
314        ))
315    });
316    Ok(ManualLinkResolution {
317        edges,
318        evidence,
319        decisions,
320    })
321}
322
323#[derive(Debug)]
324struct ResolvedManualLink<'a> {
325    index: usize,
326    link: &'a ManualLinkConfig,
327    source: NodeId,
328    target: NodeId,
329}
330
331fn resolve_manual_endpoint(
332    nodes: &[Node],
333    index: usize,
334    endpoint: ManualLinkEndpoint,
335    value: &str,
336) -> Result<NodeId, ManualLinkError> {
337    let candidates = nodes
338        .iter()
339        .filter(|node| node.id.as_str() == value || node.stable_key == value)
340        .map(|node| node.id.clone())
341        .collect::<BTreeSet<_>>();
342    match candidates.len() {
343        0 => Err(ManualLinkError::MissingEndpoint {
344            index,
345            endpoint,
346            value: value.to_owned(),
347        }),
348        1 => candidates
349            .into_iter()
350            .next()
351            .ok_or_else(|| ManualLinkError::MissingEndpoint {
352                index,
353                endpoint,
354                value: value.to_owned(),
355            }),
356        _ => Err(ManualLinkError::AmbiguousEndpoint {
357            index,
358            endpoint,
359            value: value.to_owned(),
360            candidates: candidates.into_iter().collect(),
361        }),
362    }
363}
364
365fn edge_matches(link: &ResolvedManualLink<'_>, edge: &Edge) -> bool {
366    edge.source == link.source && edge.target == link.target && edge.kind == link.link.relation
367}
368
369fn manual_link_edge(link: &ResolvedManualLink<'_>, evidence: &Evidence) -> Edge {
370    let key = format!(
371        "{}:{:?}:{}",
372        link.source.as_str(),
373        link.link.relation,
374        link.target.as_str()
375    );
376    Edge {
377        id: EdgeId::new(stable_id("edge", &key)),
378        source: link.source.clone(),
379        target: link.target.clone(),
380        kind: link.link.relation,
381        confidence: 1.0,
382        status: EpistemicStatus::Confirmed,
383        evidence: vec![evidence.id.clone()],
384    }
385}
386
387fn manual_link_evidence(link: &ResolvedManualLink<'_>) -> Evidence {
388    let contract = link.link.contract.as_deref().unwrap_or("");
389    let key = format!(
390        "{}:{}:{:?}:{contract}:{}:{}",
391        link.source.as_str(),
392        link.target.as_str(),
393        link.link.relation,
394        link.link.suppress,
395        link.link.reason
396    );
397    Evidence {
398        id: EvidenceId::new(stable_id("evidence", &key)),
399        repo_id: None,
400        file_path: None,
401        start_line: None,
402        end_line: None,
403        extractor: MANUAL_LINK_EXTRACTOR.to_owned(),
404        extractor_version: "1.0.0".to_owned(),
405        provenance: Provenance::Manual,
406        confidence: 1.0,
407        observed_at_commit: None,
408        content_hash: Some(stable_id("manual-link-declaration", &key)),
409        note: Some(link.link.reason.clone()),
410    }
411}
412
413fn manual_link_decision(
414    link: &ResolvedManualLink<'_>,
415    evidence: EvidenceRef,
416    status: LinkStatus,
417) -> LinkDecision {
418    let mut reasons = vec![
419        link.link.reason.clone(),
420        "both endpoints matched an exact node ID or stable key".to_owned(),
421    ];
422    if let Some(contract) = &link.link.contract {
423        reasons.push(format!("declared contract: {contract}"));
424    }
425    reasons.push(match status {
426        LinkStatus::Confirmed => "manual declaration created the exact relationship".to_owned(),
427        LinkStatus::Suppressed => {
428            "manual declaration removed the exact automatic relationship".to_owned()
429        }
430        LinkStatus::Ambiguous | LinkStatus::Rejected => {
431            "manual declaration was not applied".to_owned()
432        }
433    });
434    LinkDecision {
435        source: link.source.clone(),
436        target: link.target.clone(),
437        relation: link.link.relation,
438        matcher: MANUAL_LINK_MATCHER.to_owned(),
439        matcher_version: Version::new(1, 0, 0),
440        score: 1.0,
441        confidence: 1.0,
442        reasons,
443        rejected_alternatives: Vec::new(),
444        evidence: vec![evidence],
445        status,
446    }
447}
448
449/// Reuses unaffected edges and atomically replaces every affected link neighborhood.
450///
451/// Both the previous and current node-to-key maps are required because replacements may change
452/// their canonical key. Edges referencing removed nodes are never reused.
453#[must_use]
454pub fn merge_affected_link_neighborhoods<K>(
455    previous_edges: &[Edge],
456    recomputed_edges: &[Edge],
457    affected_keys: &BTreeSet<K>,
458    previous_node_keys: &BTreeMap<NodeId, K>,
459    current_node_keys: &BTreeMap<NodeId, K>,
460    current_node_ids: &BTreeSet<NodeId>,
461) -> Vec<Edge>
462where
463    K: Ord,
464{
465    let mut merged = BTreeMap::new();
466    for edge in previous_edges {
467        let endpoints_exist =
468            current_node_ids.contains(&edge.source) && current_node_ids.contains(&edge.target);
469        if endpoints_exist && !edge_touches_keys(edge, previous_node_keys, affected_keys) {
470            merged.insert(edge.id.clone(), edge.clone());
471        }
472    }
473    for edge in recomputed_edges {
474        if edge_touches_keys(edge, current_node_keys, affected_keys) {
475            merged.insert(edge.id.clone(), edge.clone());
476        }
477    }
478    merged.into_values().collect()
479}
480
481fn edge_touches_keys<K>(
482    edge: &Edge,
483    node_keys: &BTreeMap<NodeId, K>,
484    affected_keys: &BTreeSet<K>,
485) -> bool
486where
487    K: Ord,
488{
489    [&edge.source, &edge.target]
490        .into_iter()
491        .filter_map(|node| node_keys.get(node))
492        .any(|key| affected_keys.contains(key))
493}
494
495#[cfg(test)]
496mod tests {
497    use std::collections::{BTreeMap, BTreeSet};
498
499    use code_system_graph_model::{
500        Edge, EdgeId, EdgeKind, EpistemicStatus, LinkStatus, Node, NodeId, NodeKind, Provenance, RepoId
501    };
502
503    use super::{
504        LinkError, ManualLinkEndpoint, ManualLinkError, link_http_boundaries, merge_affected_link_neighborhoods, resolve_manual_links
505    };
506    use crate::{HttpConsumerConfig, ManualLinkConfig, extract_openapi};
507
508    fn consumer() -> crate::HttpBoundary {
509        crate::HttpBoundary::consumer(
510            RepoId::new("repo:web"),
511            &HttpConsumerConfig {
512                method: "POST".to_owned(),
513                path: "/api/orders".to_owned(),
514                source: "src/checkout.ts".to_owned(),
515            },
516        )
517    }
518
519    fn provider(repo: &str) -> crate::HttpBoundary {
520        let input = r"
521openapi: 3.0.3
522paths:
523  /api/orders:
524    post:
525      operationId: createOrder
526";
527        let result = extract_openapi(&RepoId::new(repo), "openapi.yaml", input);
528        match result {
529            Ok(mut boundaries) => boundaries.remove(0),
530            Err(error) => panic!("test fixture must be valid: {error}"),
531        }
532    }
533
534    fn graph_node(id: &str, stable_key: &str) -> Node {
535        Node {
536            id: NodeId::new(id),
537            kind: NodeKind::Service,
538            repo_id: None,
539            stable_key: stable_key.to_owned(),
540            label: stable_key.to_owned(),
541        }
542    }
543
544    fn manual_link(from: &str, to: &str, relation: EdgeKind, suppress: bool) -> ManualLinkConfig {
545        ManualLinkConfig {
546            from: from.to_owned(),
547            to: to.to_owned(),
548            relation,
549            contract: Some("POST /orders".to_owned()),
550            reason: "Explicit manual boundary".to_owned(),
551            suppress,
552        }
553    }
554
555    fn automatic_edge(id: &str, source: &str, target: &str, kind: EdgeKind) -> Edge {
556        Edge {
557            id: EdgeId::new(id),
558            source: NodeId::new(source),
559            target: NodeId::new(target),
560            kind,
561            confidence: 0.8,
562            status: EpistemicStatus::Inferred,
563            evidence: Vec::new(),
564        }
565    }
566
567    #[test]
568    fn link_http_boundaries_should_require_bilateral_evidence() {
569        let result = link_http_boundaries(&[consumer(), provider("repo:api")]);
570        let evidence_count = result.map(|edges| edges[0].evidence.len());
571
572        assert_eq!(evidence_count, Ok(2));
573    }
574
575    #[test]
576    fn link_http_boundaries_should_reject_duplicate_providers() {
577        let result =
578            link_http_boundaries(&[consumer(), provider("repo:api-a"), provider("repo:api-b")]);
579
580        assert!(matches!(result, Err(LinkError::AmbiguousProvider { .. })));
581    }
582
583    #[test]
584    fn resolve_manual_links_should_create_confirmed_exact_edge_with_manual_evidence() {
585        let nodes = [
586            graph_node("node:web", "service:web"),
587            graph_node("node:api", "service:api"),
588        ];
589        let links = [manual_link(
590            "node:web",
591            "service:api",
592            EdgeKind::Consumes,
593            false,
594        )];
595
596        let result = resolve_manual_links(&links, &nodes, &[])
597            .unwrap_or_else(|error| panic!("manual link should resolve: {error}"));
598
599        assert!(
600            result.edges.len() == 1
601                && result.edges[0].source == NodeId::new("node:web")
602                && result.edges[0].target == NodeId::new("node:api")
603                && result.edges[0].kind == EdgeKind::Consumes
604                && (result.edges[0].confidence - 1.0).abs() < f32::EPSILON
605                && result.edges[0].status == EpistemicStatus::Confirmed
606                && result.evidence.len() == 1
607                && result.evidence[0].provenance == Provenance::Manual
608                && result.decisions.len() == 1
609                && result.decisions[0].status == LinkStatus::Confirmed
610                && (result.decisions[0].score - 1.0).abs() < f32::EPSILON
611        );
612    }
613
614    #[test]
615    fn resolve_manual_links_should_suppress_only_exact_automatic_relationship() {
616        let nodes = [
617            graph_node("node:web", "service:web"),
618            graph_node("node:api", "service:api"),
619        ];
620        let links = [manual_link(
621            "node:web",
622            "node:api",
623            EdgeKind::CallsRemote,
624            true,
625        )];
626        let automatic = [
627            automatic_edge("edge:exact", "node:web", "node:api", EdgeKind::CallsRemote),
628            automatic_edge(
629                "edge:reverse",
630                "node:api",
631                "node:web",
632                EdgeKind::CallsRemote,
633            ),
634            automatic_edge("edge:relation", "node:web", "node:api", EdgeKind::Consumes),
635        ];
636
637        let result = resolve_manual_links(&links, &nodes, &automatic)
638            .unwrap_or_else(|error| panic!("suppression should resolve: {error}"));
639
640        assert!(
641            result.edges.len() == 2
642                && result
643                    .edges
644                    .iter()
645                    .all(|edge| edge.id.as_str() != "edge:exact")
646                && result
647                    .edges
648                    .iter()
649                    .any(|edge| edge.id.as_str() == "edge:reverse")
650                && result
651                    .edges
652                    .iter()
653                    .any(|edge| edge.id.as_str() == "edge:relation")
654                && result.decisions[0].status == LinkStatus::Suppressed
655        );
656    }
657
658    #[test]
659    fn resolve_manual_links_should_fail_for_missing_endpoint() {
660        let nodes = [graph_node("node:web", "service:web")];
661        let links = [manual_link(
662            "node:web",
663            "service:missing",
664            EdgeKind::Consumes,
665            false,
666        )];
667
668        let result = resolve_manual_links(&links, &nodes, &[]);
669
670        assert!(matches!(
671            result,
672            Err(ManualLinkError::MissingEndpoint {
673                endpoint: ManualLinkEndpoint::To,
674                value,
675                ..
676            }) if value == "service:missing"
677        ));
678    }
679
680    #[test]
681    fn resolve_manual_links_should_fail_for_ambiguous_exact_endpoint() {
682        let nodes = [
683            graph_node("node:web", "service:web"),
684            graph_node("node:api-b", "service:shared"),
685            graph_node("node:api-a", "service:shared"),
686        ];
687        let links = [manual_link(
688            "node:web",
689            "service:shared",
690            EdgeKind::Consumes,
691            false,
692        )];
693
694        let result = resolve_manual_links(&links, &nodes, &[]);
695
696        assert!(matches!(
697            result,
698            Err(ManualLinkError::AmbiguousEndpoint { candidates, .. })
699                if candidates
700                    == vec![NodeId::new("node:api-a"), NodeId::new("node:api-b")]
701        ));
702    }
703
704    #[test]
705    fn resolve_manual_links_should_be_independent_of_input_order() {
706        let nodes = vec![
707            graph_node("node:web", "service:web"),
708            graph_node("node:api", "service:api"),
709            graph_node("node:worker", "service:worker"),
710        ];
711        let links = vec![
712            manual_link("service:web", "service:api", EdgeKind::CallsRemote, false),
713            manual_link("service:worker", "service:api", EdgeKind::Consumes, false),
714        ];
715        let mut reversed_nodes = nodes.clone();
716        reversed_nodes.reverse();
717        let mut reversed_links = links.clone();
718        reversed_links.reverse();
719
720        let first = resolve_manual_links(&links, &nodes, &[]);
721        let second = resolve_manual_links(&reversed_links, &reversed_nodes, &[]);
722
723        assert_eq!(
724            first.unwrap_or_else(|error| panic!("first resolution should succeed: {error}")),
725            second.unwrap_or_else(|error| panic!("second resolution should succeed: {error}"))
726        );
727    }
728
729    #[test]
730    fn relinking_should_replace_only_affected_neighborhoods() {
731        let orders = link_http_boundaries(&[consumer(), provider("repo:api")])
732            .unwrap_or_else(|error| panic!("fixture must link: {error}"));
733        let mut health_consumer = consumer();
734        health_consumer.method = "GET".to_owned();
735        health_consumer.path = "/health".to_owned();
736        let mut health_provider = provider("repo:health");
737        health_provider.method = "GET".to_owned();
738        health_provider.path = "/health".to_owned();
739        let health = link_http_boundaries(&[health_consumer, health_provider])
740            .unwrap_or_else(|error| panic!("fixture must link: {error}"));
741        let mut previous = orders.clone();
742        previous.extend(health.clone());
743        let affected = BTreeSet::from(["POST:/api/orders".to_owned()]);
744        let previous_keys = BTreeMap::from([
745            (orders[0].source.clone(), "POST:/api/orders".to_owned()),
746            (orders[0].target.clone(), "POST:/api/orders".to_owned()),
747            (health[0].source.clone(), "GET:/health".to_owned()),
748            (health[0].target.clone(), "GET:/health".to_owned()),
749        ]);
750        let current_ids = previous_keys.keys().cloned().collect::<BTreeSet<NodeId>>();
751        let result = merge_affected_link_neighborhoods(
752            &previous,
753            &orders,
754            &affected,
755            &previous_keys,
756            &previous_keys,
757            &current_ids,
758        );
759
760        assert_eq!(result.len(), 2);
761        assert!(result.contains(&health[0]));
762        assert!(result.contains(&orders[0]));
763    }
764}