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/// One exact HTTP contract that could not be linked because several providers matched.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct HttpLinkAmbiguity {
32    /// Canonical HTTP method.
33    pub method: String,
34    /// Canonical path template.
35    pub path: String,
36    /// Stable candidate node identifiers in deterministic order.
37    pub candidates: Vec<String>,
38}
39
40/// Deterministic automatic HTTP links plus unresolved exact-provider ambiguities.
41#[derive(Debug, Clone, PartialEq)]
42pub struct HttpLinkResolution {
43    /// Unambiguous automatic relationships.
44    pub edges: Vec<Edge>,
45    /// Source-free ambiguous contract identities omitted from the edge set.
46    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/// Endpoint field being resolved for a manual relationship.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ManualLinkEndpoint {
66    /// The `from` endpoint.
67    From,
68    /// The `to` endpoint.
69    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/// Error returned while resolving exact manual relationships.
82#[derive(Debug, Error)]
83pub enum ManualLinkError {
84    /// A manually constructed declaration violates manifest invariants.
85    #[error(transparent)]
86    InvalidDeclaration(#[from] ManifestError),
87    /// No node has the exact requested identifier or stable key.
88    #[error(
89        "manualLinks[{index}].{endpoint} `{value}` did not match any exact node ID or stable key"
90    )]
91    MissingEndpoint {
92        /// Zero-based declaration index.
93        index: usize,
94        /// Endpoint field.
95        endpoint: ManualLinkEndpoint,
96        /// Requested exact identity.
97        value: String,
98    },
99    /// More than one node has the exact requested identifier or stable key.
100    #[error(
101        "manualLinks[{index}].{endpoint} `{value}` is ambiguous across exact candidates {candidates:?}"
102    )]
103    AmbiguousEndpoint {
104        /// Zero-based declaration index.
105        index: usize,
106        /// Endpoint field.
107        endpoint: ManualLinkEndpoint,
108        /// Requested exact identity.
109        value: String,
110        /// Stable candidate identities in deterministic order.
111        candidates: Vec<NodeId>,
112    },
113    /// Distinct endpoint literals resolved to the same node.
114    #[error("manualLinks[{index}] resolves both endpoints to `{node}`")]
115    ResolvedSelfLink {
116        /// Zero-based declaration index.
117        index: usize,
118        /// Resolved node identity.
119        node: String,
120    },
121    /// Distinct declarations resolved to the same graph relationship.
122    #[error(
123        "manualLinks[{duplicate}] resolves to the same relationship as manualLinks[{first}]: {source_node:?} -> {target_node:?} ({relation:?})"
124    )]
125    DuplicateResolvedLink {
126        /// Index of the first declaration.
127        first: usize,
128        /// Index of the repeated declaration.
129        duplicate: usize,
130        /// Resolved source node.
131        source_node: NodeId,
132        /// Resolved target node.
133        target_node: NodeId,
134        /// Repeated concrete relationship.
135        relation: EdgeKind,
136    },
137    /// A suppression did not identify an existing exact automatic relationship.
138    #[error(
139        "manualLinks[{index}] cannot suppress missing automatic relationship {source_node:?} -> {target_node:?} ({relation:?})"
140    )]
141    MissingSuppressionTarget {
142        /// Zero-based declaration index.
143        index: usize,
144        /// Resolved source node.
145        source_node: NodeId,
146        /// Resolved target node.
147        target_node: NodeId,
148        /// Requested concrete relationship.
149        relation: EdgeKind,
150    },
151}
152
153/// Graph facts and audit records produced after applying manual declarations.
154#[derive(Debug, Clone, PartialEq)]
155pub struct ManualLinkResolution {
156    /// Automatic edges after exact suppressions and manual replacements.
157    pub edges: Vec<Edge>,
158    /// Manual evidence emitted for created edges and successful suppressions.
159    pub evidence: Vec<Evidence>,
160    /// Versioned decisions in deterministic relationship order.
161    pub decisions: Vec<LinkDecision>,
162}
163
164/// Links HTTP consumers to providers by exact canonical method and path.
165///
166/// Consumers without an observed provider remain unlinked. Callers must represent that as a
167/// coverage gap rather than concluding that no dependency exists.
168///
169/// Duplicate providers remain fail-closed for compatibility. Use
170/// [`link_http_boundaries_with_ambiguities`] to preserve ambiguities as data while continuing with
171/// unrelated contracts.
172///
173/// # Errors
174///
175/// Returns [`LinkError::AmbiguousProvider`] instead of silently omitting an ambiguous relationship.
176pub fn link_http_boundaries(boundaries: &[HttpBoundary]) -> Result<Vec<Edge>, LinkError> {
177    link_http_boundaries_with_ambiguities(boundaries).into_legacy_result()
178}
179
180/// Links exact HTTP boundaries while preserving duplicate-provider decisions.
181#[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
286/// Resolves and applies exact manual relationships to an automatic edge set.
287///
288/// Endpoint values match only [`NodeId`] text or [`Node::stable_key`] text. A creation replaces an
289/// automatic edge with the same source, target, and relation so the resulting edge is backed only
290/// by explicit manual evidence. A suppression removes every automatic edge with that exact triple;
291/// the optional contract is audit context and never broadens or narrows edge matching.
292///
293/// # Errors
294///
295/// Returns [`ManualLinkError`] for invalid declarations, missing or ambiguous endpoints, resolved
296/// self-links, duplicate resolved relationships, or suppressions that match no automatic edge.
297pub 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/// Reuses unaffected edges and atomically replaces every affected link neighborhood.
521///
522/// Both the previous and current node-to-key maps are required because replacements may change
523/// their canonical key. Edges referencing removed nodes are never reused.
524#[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            &current_ids,
844        );
845
846        assert_eq!(result.len(), 2);
847        assert!(result.contains(&health[0]));
848        assert!(result.contains(&orders[0]));
849    }
850}