Skip to main content

graphrefly/
diagnostics.rs

1//! Pure graph diagnostics over `DescribeSnapshot` (D39/R-describe).
2//!
3//! These helpers are product-surface catch-up with the TypeScript clean-slate graph
4//! diagnostics. They never read live nodes or mutate graph state; `describe()` remains
5//! the single topology source of truth.
6
7use std::collections::{BTreeMap, BTreeSet, VecDeque};
8
9use crate::graph::{DescribeEdge, DescribeNode, DescribeSnapshot, DescribeValue, Graph, Profile};
10use crate::node::Status;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13/// `ReachableDirection` variants.
14pub enum ReachableDirection {
15    /// `Upstream` variant.
16    Upstream,
17    /// `Downstream` variant.
18    Downstream,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22/// `ReachableOptions` data container.
23pub struct ReachableOptions {
24    /// `max_depth` field for max depth.
25    pub max_depth: Option<usize>,
26    /// `both` field for both.
27    pub both: bool,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31/// `ReachableResult` data container.
32pub struct ReachableResult {
33    /// `paths` field for paths.
34    pub paths: Vec<String>,
35    /// `depths` field for depths.
36    pub depths: BTreeMap<String, usize>,
37    /// `truncated` field for truncated.
38    pub truncated: bool,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42/// `ExplainPathReason` variants.
43pub enum ExplainPathReason {
44    /// `Ok` variant.
45    Ok,
46    /// `NoSuchFrom` variant.
47    NoSuchFrom,
48    /// `NoSuchTo` variant.
49    NoSuchTo,
50    /// `NoPath` variant.
51    NoPath,
52    /// `MaxDepthExceeded` variant.
53    MaxDepthExceeded,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57/// `ExplainPathOptions` data container.
58pub struct ExplainPathOptions {
59    /// `max_depth` field for max depth.
60    pub max_depth: Option<usize>,
61    /// `find_cycle` field for find cycle.
62    pub find_cycle: bool,
63}
64
65#[derive(Debug, Clone, PartialEq)]
66/// `CausalStep` data container.
67pub struct CausalStep {
68    /// `id` field for id.
69    pub id: String,
70    /// `factory` field for factory.
71    pub factory: String,
72    /// `status` field for status.
73    pub status: Status,
74    /// `value` field for value.
75    pub value: Option<DescribeValue>,
76    /// `hop` field for hop.
77    pub hop: usize,
78    /// `dep_index` field for dep index.
79    pub dep_index: Option<usize>,
80    /// `dep_indices` field for dep indices.
81    pub dep_indices: Option<Vec<usize>>,
82}
83
84#[derive(Debug, Clone, PartialEq)]
85/// `CausalChain` data container.
86pub struct CausalChain {
87    /// `from` field for from.
88    pub from: String,
89    /// `to` field for to.
90    pub to: String,
91    /// `found` field for found.
92    pub found: bool,
93    /// `reason` field for reason.
94    pub reason: ExplainPathReason,
95    /// `steps` field for steps.
96    pub steps: Vec<CausalStep>,
97    /// `text` field for text.
98    pub text: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102/// `IslandReport` data container.
103pub struct IslandReport {
104    /// `id` field for id.
105    pub id: String,
106    /// `factory` field for factory.
107    pub factory: String,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111/// `ValidateNoIslandsResult` data container.
112pub struct ValidateNoIslandsResult {
113    /// `ok` field for ok.
114    pub ok: bool,
115    /// `orphans` field for orphans.
116    pub orphans: Vec<IslandReport>,
117}
118
119#[derive(Debug, Clone, PartialEq)]
120/// `DescribeEvent` variants.
121pub enum DescribeEvent {
122    /// `NodeAdded` variant.
123    NodeAdded {
124        /// `id` field for id.
125        id: String,
126        /// `node` field for node.
127        node: DescribeNode,
128    },
129    /// `NodeRemoved` variant.
130    NodeRemoved {
131        /// `id` field for id.
132        id: String,
133    },
134    /// `NodeMetaChanged` variant.
135    NodeMetaChanged {
136        /// `id` field for id.
137        id: String,
138        /// `prev_meta` field for prev meta.
139        prev_meta: BTreeMap<String, String>,
140        /// `next_meta` field for next meta.
141        next_meta: BTreeMap<String, String>,
142    },
143    /// `EdgeAdded` variant.
144    EdgeAdded {
145        /// `from` field for from.
146        from: String,
147        /// `to` field for to.
148        to: String,
149    },
150    /// `EdgeRemoved` variant.
151    EdgeRemoved {
152        /// `from` field for from.
153        from: String,
154        /// `to` field for to.
155        to: String,
156    },
157    /// `SubgraphMounted` variant.
158    SubgraphMounted {
159        /// `path` field for path.
160        path: String,
161    },
162    /// `SubgraphUnmounted` variant.
163    SubgraphUnmounted {
164        /// `path` field for path.
165        path: String,
166    },
167}
168
169#[derive(Debug, Clone, Default, PartialEq)]
170/// `DescribeChangeset` data container.
171pub struct DescribeChangeset {
172    /// `events` field for events.
173    pub events: Vec<DescribeEvent>,
174}
175
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177/// `ProfileSummaryOptions` data container.
178pub struct ProfileSummaryOptions {
179    /// `limit` field for limit.
180    pub limit: Option<usize>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184/// `ProfileSummaryNode` data container.
185pub struct ProfileSummaryNode {
186    /// `path` field for path.
187    pub path: String,
188    /// `invokes` field for invokes.
189    pub invokes: u64,
190    /// `total_duration_ns` field for total duration ns.
191    pub total_duration_ns: u128,
192    /// `last_duration_ns` field for last duration ns.
193    pub last_duration_ns: u128,
194    /// `status` field for status.
195    pub status: Status,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199/// `ProfileSummaryStatus` data container.
200pub struct ProfileSummaryStatus {
201    /// `status` field for status.
202    pub status: Status,
203    /// `count` field for count.
204    pub count: usize,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208/// `ProfileSummary` data container.
209pub struct ProfileSummary {
210    /// `node_count` field for node count.
211    pub node_count: usize,
212    /// `total_invokes` field for total invokes.
213    pub total_invokes: u64,
214    /// `by_status` field for by status.
215    pub by_status: Vec<ProfileSummaryStatus>,
216    /// `hot_nodes` field for hot nodes.
217    pub hot_nodes: Vec<ProfileSummaryNode>,
218}
219
220impl ValidateNoIslandsResult {
221    /// Updates or reads `summary`.
222    pub fn summary(&self) -> String {
223        if self.orphans.is_empty() {
224            return "validate_no_islands: ok (no islands)".to_owned();
225        }
226        let head = self
227            .orphans
228            .iter()
229            .take(3)
230            .map(|o| format!("{} ({})", o.id, o.factory))
231            .collect::<Vec<_>>()
232            .join(", ");
233        format!(
234            "validate_no_islands: {} island node(s) - {}{}",
235            self.orphans.len(),
236            head,
237            if self.orphans.len() > 3 { ", ..." } else { "" }
238        )
239    }
240}
241
242#[derive(Debug, Default)]
243struct FlatSnapshot {
244    nodes: BTreeMap<String, DescribeNode>,
245    edges: BTreeMap<(String, String), DescribeEdge>,
246    subgraphs: BTreeSet<String>,
247}
248
249#[derive(Debug, Default)]
250struct SnapshotIndex {
251    nodes: BTreeMap<String, DescribeNode>,
252    outgoing: BTreeMap<String, BTreeSet<String>>,
253    incoming: BTreeMap<String, BTreeSet<String>>,
254}
255
256fn flatten(snapshot: &DescribeSnapshot, nodes: &mut Vec<DescribeNode>) {
257    nodes.extend(snapshot.nodes.clone());
258    for child in snapshot.subgraphs.iter().flatten() {
259        flatten(child, nodes);
260    }
261}
262
263fn flatten_for_diff(snapshot: &DescribeSnapshot) -> FlatSnapshot {
264    fn visit(snapshot: &DescribeSnapshot, index_path: &str, flat: &mut FlatSnapshot) {
265        for node in &snapshot.nodes {
266            flat.nodes.insert(node.id.clone(), node.clone());
267        }
268        for edge in &snapshot.edges {
269            flat.edges
270                .insert((edge.from.clone(), edge.to.clone()), edge.clone());
271        }
272        if let Some(children) = &snapshot.subgraphs {
273            for (index, child) in children.iter().enumerate() {
274                let prefixed = child.nodes.iter().find_map(|node| {
275                    node.id
276                        .rsplit_once("::")
277                        .map(|(prefix, _)| prefix.to_owned())
278                });
279                let key = prefixed.unwrap_or_else(|| {
280                    child
281                        .name
282                        .clone()
283                        .unwrap_or_else(|| format!("{index_path}{index}"))
284                });
285                flat.subgraphs.insert(key.clone());
286                visit(child, &format!("{key}/"), flat);
287            }
288        }
289    }
290
291    let mut flat = FlatSnapshot::default();
292    visit(snapshot, "", &mut flat);
293    flat
294}
295
296fn meta_or_empty(node: &DescribeNode) -> BTreeMap<String, String> {
297    node.meta.clone().unwrap_or_default()
298}
299
300/// Pure topology delta from one D39 `describe()` snapshot to another.
301///
302/// Values/status changes are intentionally not topology events; use observe/profile for runtime
303/// data. This helper never reads live nodes and does not create a topology stream.
304pub fn topology_diff(prev: &DescribeSnapshot, next: &DescribeSnapshot) -> DescribeChangeset {
305    let prev = flatten_for_diff(prev);
306    let next = flatten_for_diff(next);
307    let mut events = Vec::new();
308
309    for path in next.subgraphs.difference(&prev.subgraphs) {
310        events.push(DescribeEvent::SubgraphMounted { path: path.clone() });
311    }
312    for (id, node) in next
313        .nodes
314        .iter()
315        .filter(|(id, _)| !prev.nodes.contains_key(*id))
316    {
317        events.push(DescribeEvent::NodeAdded {
318            id: id.clone(),
319            node: node.clone(),
320        });
321    }
322    for (id, node) in next
323        .nodes
324        .iter()
325        .filter(|(id, _)| prev.nodes.contains_key(*id))
326    {
327        let prev_meta = meta_or_empty(
328            prev.nodes
329                .get(id)
330                .expect("filtered to nodes present in the previous snapshot"),
331        );
332        let next_meta = meta_or_empty(node);
333        if prev_meta != next_meta {
334            events.push(DescribeEvent::NodeMetaChanged {
335                id: id.clone(),
336                prev_meta,
337                next_meta,
338            });
339        }
340    }
341    for ((from, to), _) in next
342        .edges
343        .iter()
344        .filter(|(key, _)| !prev.edges.contains_key(*key))
345    {
346        events.push(DescribeEvent::EdgeAdded {
347            from: from.clone(),
348            to: to.clone(),
349        });
350    }
351    for ((from, to), _) in prev
352        .edges
353        .iter()
354        .filter(|(key, _)| !next.edges.contains_key(*key))
355    {
356        events.push(DescribeEvent::EdgeRemoved {
357            from: from.clone(),
358            to: to.clone(),
359        });
360    }
361    for id in prev.nodes.keys().filter(|id| !next.nodes.contains_key(*id)) {
362        events.push(DescribeEvent::NodeRemoved { id: id.clone() });
363    }
364    for path in prev.subgraphs.difference(&next.subgraphs) {
365        events.push(DescribeEvent::SubgraphUnmounted { path: path.clone() });
366    }
367
368    DescribeChangeset { events }
369}
370
371/// Summarize an opt-in D39/R-profile snapshot using `describe()` for node cardinality.
372///
373/// This helper does not subscribe, emit, or create topology. The graph wrapper only reads the
374/// existing `describe()` and `profile()` snapshots; the actual rollup is pure over those facts.
375#[must_use]
376pub fn profile_summary(graph: &Graph, options: ProfileSummaryOptions) -> ProfileSummary {
377    let snapshot = graph.describe();
378    let profile = graph.profile();
379    profile_summary_from_snapshots(&snapshot, &profile, options)
380}
381
382/// Pure profile rollup over an already-captured describe/profile pair.
383#[must_use]
384pub fn profile_summary_from_snapshots(
385    snapshot: &DescribeSnapshot,
386    profile: &Profile,
387    options: ProfileSummaryOptions,
388) -> ProfileSummary {
389    let mut node_ids = BTreeSet::new();
390    collect_describe_ids(snapshot, &mut node_ids);
391    node_ids.extend(profile.nodes.keys().cloned());
392
393    let mut by_status_counts = BTreeMap::<usize, ProfileSummaryStatus>::new();
394    let mut hot_nodes = Vec::new();
395    for path in &node_ids {
396        let Some(node_profile) = profile.nodes.get(path) else {
397            continue;
398        };
399        let rank = status_rank(node_profile.status);
400        by_status_counts
401            .entry(rank)
402            .and_modify(|summary| summary.count += 1)
403            .or_insert(ProfileSummaryStatus {
404                status: node_profile.status,
405                count: 1,
406            });
407        hot_nodes.push(ProfileSummaryNode {
408            path: path.clone(),
409            invokes: node_profile.invokes,
410            total_duration_ns: node_profile.total_duration_ns,
411            last_duration_ns: node_profile.last_duration_ns,
412            status: node_profile.status,
413        });
414    }
415
416    hot_nodes.sort_by(|a, b| b.invokes.cmp(&a.invokes).then_with(|| a.path.cmp(&b.path)));
417    if let Some(limit) = options.limit {
418        hot_nodes.truncate(limit);
419    }
420
421    ProfileSummary {
422        node_count: node_ids.len(),
423        total_invokes: profile.total_invokes,
424        by_status: by_status_counts.into_values().collect(),
425        hot_nodes,
426    }
427}
428
429fn collect_describe_ids(snapshot: &DescribeSnapshot, ids: &mut BTreeSet<String>) {
430    ids.extend(snapshot.nodes.iter().map(|node| node.id.clone()));
431    for child in snapshot.subgraphs.iter().flatten() {
432        collect_describe_ids(child, ids);
433    }
434}
435
436fn status_rank(status: Status) -> usize {
437    match status {
438        Status::Sentinel => 0,
439        Status::Pending => 1,
440        Status::Dirty => 2,
441        Status::Settled => 3,
442        Status::Resolved => 4,
443        Status::Completed => 5,
444        Status::Errored => 6,
445    }
446}
447
448fn add_edge(map: &mut BTreeMap<String, BTreeSet<String>>, from: &str, to: &str) {
449    map.entry(from.to_owned())
450        .or_default()
451        .insert(to.to_owned());
452}
453
454fn index_snapshot(snapshot: &DescribeSnapshot) -> SnapshotIndex {
455    let mut flat_nodes = Vec::new();
456    flatten(snapshot, &mut flat_nodes);
457    let mut idx = SnapshotIndex::default();
458    for node in flat_nodes {
459        idx.outgoing.entry(node.id.clone()).or_default();
460        idx.incoming.entry(node.id.clone()).or_default();
461        idx.nodes.insert(node.id.clone(), node);
462    }
463
464    let nodes = idx.nodes.clone();
465    for node in nodes.values() {
466        for dep in &node.deps {
467            if idx.nodes.contains_key(dep) {
468                add_edge(&mut idx.outgoing, dep, &node.id);
469                add_edge(&mut idx.incoming, &node.id, dep);
470            }
471        }
472    }
473
474    index_snapshot_edges(snapshot, &mut idx);
475    idx
476}
477
478fn index_snapshot_edges(snapshot: &DescribeSnapshot, idx: &mut SnapshotIndex) {
479    for edge in &snapshot.edges {
480        if idx.nodes.contains_key(&edge.from) && idx.nodes.contains_key(&edge.to) {
481            add_edge(&mut idx.outgoing, &edge.from, &edge.to);
482            add_edge(&mut idx.incoming, &edge.to, &edge.from);
483        }
484    }
485    for child in snapshot.subgraphs.iter().flatten() {
486        index_snapshot_edges(child, idx);
487    }
488}
489
490fn adjacent(
491    idx: &SnapshotIndex,
492    id: &str,
493    direction: ReachableDirection,
494    both: bool,
495) -> BTreeSet<String> {
496    if both {
497        let mut out = idx.incoming.get(id).cloned().unwrap_or_default();
498        out.extend(idx.outgoing.get(id).cloned().unwrap_or_default());
499        return out;
500    }
501    match direction {
502        ReachableDirection::Upstream => idx.incoming.get(id).cloned().unwrap_or_default(),
503        ReachableDirection::Downstream => idx.outgoing.get(id).cloned().unwrap_or_default(),
504    }
505}
506
507/// Creates or computes `reachable`.
508pub fn reachable(
509    snapshot: &DescribeSnapshot,
510    from: &str,
511    direction: ReachableDirection,
512    options: ReachableOptions,
513) -> ReachableResult {
514    if from.is_empty() {
515        return ReachableResult::default();
516    }
517    let idx = index_snapshot(snapshot);
518    if !idx.nodes.contains_key(from) {
519        return ReachableResult::default();
520    }
521    if options.max_depth == Some(0) {
522        return ReachableResult {
523            truncated: !adjacent(&idx, from, direction, options.both).is_empty(),
524            ..ReachableResult::default()
525        };
526    }
527
528    let mut depths = BTreeMap::new();
529    let mut seen = BTreeSet::from([from.to_owned()]);
530    let mut queue = VecDeque::from([(from.to_owned(), 0usize)]);
531    let mut truncated = false;
532    while let Some((id, depth)) = queue.pop_front() {
533        let next = adjacent(&idx, &id, direction, options.both);
534        if options.max_depth.is_some_and(|max| depth >= max) {
535            if !next.is_empty() {
536                truncated = true;
537            }
538            continue;
539        }
540        for next_id in next {
541            if seen.insert(next_id.clone()) {
542                let next_depth = depth + 1;
543                depths.insert(next_id.clone(), next_depth);
544                queue.push_back((next_id, next_depth));
545            }
546        }
547    }
548    ReachableResult {
549        paths: depths.keys().cloned().collect(),
550        depths,
551        truncated,
552    }
553}
554
555fn dep_indices(next: Option<&DescribeNode>, prev_id: &str) -> Option<Vec<usize>> {
556    let next = next?;
557    let indices = next
558        .deps
559        .iter()
560        .enumerate()
561        .filter_map(|(i, dep)| (dep == prev_id).then_some(i))
562        .collect::<Vec<_>>();
563    (!indices.is_empty()).then_some(indices)
564}
565
566fn step_for(
567    node: &DescribeNode,
568    hop: usize,
569    edge_to_next: Option<(&DescribeNode, &str)>,
570) -> CausalStep {
571    let indices = edge_to_next.and_then(|(next, prev_id)| dep_indices(Some(next), prev_id));
572    CausalStep {
573        id: node.id.clone(),
574        factory: node.factory.clone(),
575        status: node.status,
576        value: node.value.clone(),
577        hop,
578        dep_index: indices.as_ref().and_then(|xs| xs.first().copied()),
579        dep_indices: indices.as_ref().filter(|xs| xs.len() > 1).cloned(),
580    }
581}
582
583fn make_chain(
584    from: &str,
585    to: &str,
586    reason: ExplainPathReason,
587    steps: Vec<CausalStep>,
588) -> CausalChain {
589    let found = reason == ExplainPathReason::Ok;
590    let text = if found {
591        steps
592            .iter()
593            .map(|s| s.id.as_str())
594            .collect::<Vec<_>>()
595            .join(" -> ")
596    } else {
597        format!("explain_path: {reason:?} from '{from}' to '{to}'")
598    };
599    CausalChain {
600        from: from.to_owned(),
601        to: to.to_owned(),
602        found,
603        reason,
604        steps,
605        text,
606    }
607}
608
609fn shortest_path(
610    idx: &SnapshotIndex,
611    from: &str,
612    to: &str,
613    max_depth: Option<usize>,
614) -> Option<(Vec<String>, bool)> {
615    let mut pred = BTreeMap::<String, String>::new();
616    let mut seen = BTreeSet::from([from.to_owned()]);
617    let mut queue = VecDeque::from([(from.to_owned(), 0usize)]);
618    let mut truncated = false;
619    while let Some((id, depth)) = queue.pop_front() {
620        let next = idx.outgoing.get(&id).cloned().unwrap_or_default();
621        if max_depth.is_some_and(|max| depth >= max) {
622            if !next.is_empty() {
623                truncated = true;
624            }
625            continue;
626        }
627        for next_id in next {
628            if seen.contains(&next_id) {
629                continue;
630            }
631            seen.insert(next_id.clone());
632            pred.insert(next_id.clone(), id.clone());
633            if next_id == to {
634                let mut path = vec![to.to_owned()];
635                let mut p = to.to_owned();
636                while p != from {
637                    p = pred.get(&p).expect("predecessor exists").clone();
638                    path.push(p.clone());
639                }
640                path.reverse();
641                return Some((path, truncated));
642            }
643            queue.push_back((next_id, depth + 1));
644        }
645    }
646    truncated.then_some((Vec::new(), true))
647}
648
649fn shortest_cycle(
650    idx: &SnapshotIndex,
651    from: &str,
652    max_depth: Option<usize>,
653) -> Option<(Vec<String>, bool)> {
654    let first = idx.outgoing.get(from).cloned().unwrap_or_default();
655    if max_depth == Some(0) {
656        return (!first.is_empty()).then_some((Vec::new(), true));
657    }
658    let mut queue = VecDeque::<(String, usize, Vec<String>)>::new();
659    let mut seen = BTreeSet::new();
660    for id in first {
661        if id == from {
662            return Some((vec![from.to_owned(), from.to_owned()], false));
663        }
664        seen.insert(id.clone());
665        queue.push_back((id.clone(), 1, vec![from.to_owned(), id]));
666    }
667
668    let mut truncated = false;
669    while let Some((id, depth, path)) = queue.pop_front() {
670        let next = idx.outgoing.get(&id).cloned().unwrap_or_default();
671        if max_depth.is_some_and(|max| depth >= max) {
672            if !next.is_empty() {
673                truncated = true;
674            }
675            continue;
676        }
677        for next_id in next {
678            if next_id == from {
679                let mut found = path;
680                found.push(from.to_owned());
681                return Some((found, false));
682            }
683            if seen.insert(next_id.clone()) {
684                let mut next_path = path.clone();
685                next_path.push(next_id.clone());
686                queue.push_back((next_id, depth + 1, next_path));
687            }
688        }
689    }
690    truncated.then_some((Vec::new(), true))
691}
692
693fn materialize_path(idx: &SnapshotIndex, path: &[String]) -> Vec<CausalStep> {
694    path.iter()
695        .enumerate()
696        .map(|(i, id)| {
697            let node = idx.nodes.get(id).expect("path node exists");
698            let next = path.get(i + 1).and_then(|next_id| idx.nodes.get(next_id));
699            step_for(node, i, next.map(|next| (next, id.as_str())))
700        })
701        .collect()
702}
703
704/// Creates or computes `explain_path`.
705pub fn explain_path(
706    snapshot: &DescribeSnapshot,
707    from: &str,
708    to: &str,
709    options: ExplainPathOptions,
710) -> CausalChain {
711    let idx = index_snapshot(snapshot);
712    if !idx.nodes.contains_key(from) {
713        return make_chain(from, to, ExplainPathReason::NoSuchFrom, Vec::new());
714    }
715    if !idx.nodes.contains_key(to) {
716        return make_chain(from, to, ExplainPathReason::NoSuchTo, Vec::new());
717    }
718    if options.max_depth == Some(0) && from != to {
719        return make_chain(from, to, ExplainPathReason::NoPath, Vec::new());
720    }
721
722    if from == to && !options.find_cycle {
723        let step = step_for(idx.nodes.get(from).expect("node exists"), 0, None);
724        return make_chain(from, to, ExplainPathReason::Ok, vec![step]);
725    }
726    if from == to && options.find_cycle {
727        match shortest_cycle(&idx, from, options.max_depth) {
728            Some((path, false)) if !path.is_empty() => {
729                return make_chain(
730                    from,
731                    to,
732                    ExplainPathReason::Ok,
733                    materialize_path(&idx, &path),
734                );
735            }
736            Some((_, true)) => {
737                return make_chain(from, to, ExplainPathReason::MaxDepthExceeded, Vec::new());
738            }
739            _ => {
740                let step = step_for(idx.nodes.get(from).expect("node exists"), 0, None);
741                return make_chain(from, to, ExplainPathReason::Ok, vec![step]);
742            }
743        }
744    }
745
746    match shortest_path(&idx, from, to, options.max_depth) {
747        Some((path, _)) if !path.is_empty() => make_chain(
748            from,
749            to,
750            ExplainPathReason::Ok,
751            materialize_path(&idx, &path),
752        ),
753        Some((_, true)) => make_chain(from, to, ExplainPathReason::MaxDepthExceeded, Vec::new()),
754        _ => make_chain(from, to, ExplainPathReason::NoPath, Vec::new()),
755    }
756}
757
758/// Creates or computes `validate_no_islands`.
759pub fn validate_no_islands(snapshot: &DescribeSnapshot) -> ValidateNoIslandsResult {
760    let idx = index_snapshot(snapshot);
761    let mut orphans = Vec::new();
762    for node in idx.nodes.values() {
763        let has_deps =
764            !node.deps.is_empty() || !idx.incoming.get(&node.id).is_none_or(BTreeSet::is_empty);
765        let has_dependents = !idx.outgoing.get(&node.id).is_none_or(BTreeSet::is_empty);
766        if !has_deps && !has_dependents && !node.id.starts_with("__internal__/") {
767            orphans.push(IslandReport {
768                id: node.id.clone(),
769                factory: node.factory.clone(),
770            });
771        }
772    }
773    orphans.sort_by(|a, b| a.id.cmp(&b.id));
774    ValidateNoIslandsResult {
775        ok: orphans.is_empty(),
776        orphans,
777    }
778}