Skip to main content

flake_edit/follows/
graph.rs

1//! [`FollowsGraph`]: follows edges with origin tracking.
2//!
3//! Each [`Edge`] records whether it came from `flake.nix`
4//! ([`EdgeOrigin::Declared`]) or `flake.lock` ([`EdgeOrigin::Resolved`]).
5//! Paths are typed [`AttrPath`]s, so equality is structural.
6use std::collections::{HashMap, HashSet};
7
8use crate::edit::InputMap;
9use crate::follows::{AttrPath, Segment};
10use crate::input::{Follows, Input, Range};
11use crate::lock::{FlakeLock, NestedInput};
12
13/// Default upper bound on graph traversal depth. The per-emission cap
14/// (`follow.max_depth` in config) is a separate, smaller knob.
15pub const DEFAULT_MAX_DEPTH: usize = 64;
16
17/// Where an [`Edge`] originated.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum EdgeOrigin {
20    /// Edge declared explicitly in `flake.nix`.
21    Declared {
22        /// Source-text byte range of the declaring `inputs...follows = "..."`
23        /// attrpath/value, when known. An empty range means no source
24        /// location is available (typical in tests and [`FollowsGraph::from_lock`]).
25        range: Range,
26    },
27    /// Edge discovered by walking `flake.lock`. The parent and target node
28    /// names are recoverable from [`Edge::source`], so the variant carries
29    /// no payload.
30    Resolved,
31}
32
33/// One follows edge in the graph.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Edge {
36    /// Where the edge starts, e.g. `crane.nixpkgs` for an
37    /// `inputs.crane.inputs.nixpkgs.follows` declaration.
38    pub source: AttrPath,
39    /// Right-hand side of the `follows`.
40    pub follows: AttrPath,
41    /// Origin metadata: declared edges carry source ranges, resolved edges
42    /// carry no payload.
43    pub origin: EdgeOrigin,
44}
45
46/// A declared follows whose lockfile resolution disagrees with `flake.nix`.
47/// Produced by [`FollowsGraph::stale_lock_declarations`].
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct StaleLockDeclaration<'a> {
50    /// The declared edge, carrying its source-text range for diagnostics.
51    pub declared: &'a Edge,
52    /// What the lockfile resolves the same source path to. `None` means the
53    /// lockfile has the path but no follows attached (override never applied).
54    pub lock_target: Option<AttrPath>,
55}
56
57/// A detected cycle, as the sequence of edges that close it.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Cycle {
60    /// Edges in traversal order. The last edge's `follows` equals the first
61    /// edge's `source`, or is a structural prefix of it.
62    pub edges: Vec<Edge>,
63}
64
65/// Index of follows [`Edge`]s keyed by their `source` path. Construct with
66/// [`Self::from_declared`], [`Self::from_lock`], or [`Self::from_flake`] for
67/// declared-only, resolved-only, or merged views respectively.
68#[derive(Debug, Clone)]
69pub struct FollowsGraph {
70    edges: HashMap<AttrPath, Vec<Edge>>,
71    /// Every nested-input path observed in the lockfile, including those
72    /// without a follows. Populated by [`Self::from_lock`] and
73    /// [`Self::from_flake`]. Consulted by [`Self::stale_edges`] to ask
74    /// whether a declared edge's source path is still represented.
75    resolved_universe: HashSet<AttrPath>,
76    /// Source paths declared as `follows = ""` in `flake.nix`. These do
77    /// not produce edges (no resolved target), but they still encode user
78    /// intent: the auto-deduplicator must treat them as already-handled
79    /// so it does not retarget them.
80    declared_nulled_sources: HashSet<AttrPath>,
81    max_depth: usize,
82}
83
84impl Default for FollowsGraph {
85    fn default() -> Self {
86        FollowsGraph {
87            edges: HashMap::new(),
88            resolved_universe: HashSet::new(),
89            declared_nulled_sources: HashSet::new(),
90            max_depth: DEFAULT_MAX_DEPTH,
91        }
92    }
93}
94
95impl FollowsGraph {
96    /// Build a graph from the declared `inputs = { ... }` block alone.
97    ///
98    /// Each [`Follows::Indirect`] in `inputs` becomes one
99    /// [`EdgeOrigin::Declared`] edge carrying [`Input::range`].
100    pub fn from_declared(inputs: &InputMap) -> Self {
101        let mut graph = FollowsGraph {
102            max_depth: DEFAULT_MAX_DEPTH,
103            ..FollowsGraph::default()
104        };
105        for input in inputs.values() {
106            collect_declared_edges(input, &mut graph);
107        }
108        graph
109    }
110
111    /// Build a graph from the lockfile alone.
112    ///
113    /// Walks `flake.lock` from the root once via [`FlakeLock::nested_inputs`]
114    /// and defers to [`Self::from_nested_inputs`].
115    pub fn from_lock(lock: &FlakeLock) -> Self {
116        Self::from_nested_inputs(&lock.nested_inputs())
117    }
118
119    /// Build the lock graph from an already-computed nested-input set.
120    ///
121    /// Emits one [`EdgeOrigin::Resolved`] edge per `inputs.X = ["a", "b", ...]`
122    /// follows override, bounded by [`DEFAULT_MAX_DEPTH`]. Every nested-input
123    /// path is recorded in `resolved_universe`, with or without a follows.
124    ///
125    /// Takes the precomputed slice so a single [`FlakeLock::nested_inputs`]
126    /// walk can feed every consumer in one invocation.
127    pub fn from_nested_inputs(nested_inputs: &[NestedInput]) -> Self {
128        let mut graph = FollowsGraph {
129            max_depth: DEFAULT_MAX_DEPTH,
130            ..FollowsGraph::default()
131        };
132        for nested in nested_inputs {
133            graph.resolved_universe.insert(nested.path.clone());
134            if let Some(target) = nested.follows.clone() {
135                graph.insert_edge(Edge {
136                    source: nested.path.clone(),
137                    follows: target,
138                    origin: EdgeOrigin::Resolved,
139                });
140            }
141        }
142        graph
143    }
144
145    /// Build the merged graph: declared edges first, then resolved edges
146    /// from the lockfile that no existing edge already covers at the same
147    /// `(source, follows)`. Every nested-input path observed in the
148    /// lockfile is recorded in `resolved_universe`.
149    ///
150    /// Dedup is by `(source, follows)`, not by `source` alone: a declared
151    /// edge and its resolved sibling can share a source but point at
152    /// different targets (the user's depth-N follow points at the user's
153    /// top-level input; the lockfile points at the upstream's intermediate
154    /// path). Both encode distinct reachability and
155    /// [`Self::lock_routes_to`] needs the resolved sibling to survive when
156    /// the declared edge is excluded as a candidate.
157    pub fn from_flake(inputs: &InputMap, lock: &FlakeLock) -> Self {
158        let mut graph = FollowsGraph::from_declared(inputs);
159        for nested in lock.nested_inputs() {
160            graph.resolved_universe.insert(nested.path.clone());
161            if let Some(target) = nested.follows {
162                let already = graph
163                    .outgoing(&nested.path)
164                    .iter()
165                    .any(|e| e.follows == target);
166                if already {
167                    continue;
168                }
169                graph.insert_edge(Edge {
170                    source: nested.path.clone(),
171                    follows: target,
172                    origin: EdgeOrigin::Resolved,
173                });
174            }
175        }
176        graph
177    }
178
179    /// Variant of [`Self::from_flake`] that takes an already-built lock graph
180    /// (typically from [`Self::from_lock`]). Use when validating successive
181    /// states against the same `flake.lock` to skip the recursive lockfile
182    /// walk per call.
183    pub(crate) fn from_declared_and_lock_graph(
184        inputs: &InputMap,
185        lock_graph: &FollowsGraph,
186    ) -> Self {
187        let mut graph = FollowsGraph::from_declared(inputs);
188        graph
189            .resolved_universe
190            .extend(lock_graph.resolved_universe.iter().cloned());
191        for edges in lock_graph.edges.values() {
192            for edge in edges {
193                let already = graph
194                    .outgoing(&edge.source)
195                    .iter()
196                    .any(|e| e.follows == edge.follows);
197                if !already {
198                    graph.insert_edge(edge.clone());
199                }
200            }
201        }
202        graph
203    }
204
205    /// Override the traversal depth bound. Default is [`DEFAULT_MAX_DEPTH`].
206    #[must_use]
207    pub fn with_max_depth(mut self, max: usize) -> Self {
208        self.max_depth = max;
209        self
210    }
211
212    /// Outgoing edges from `src`, or an empty slice.
213    pub fn outgoing(&self, src: &AttrPath) -> &[Edge] {
214        self.edges.get(src).map(Vec::as_slice).unwrap_or(&[])
215    }
216
217    /// Iterator over every edge.
218    ///
219    /// Order is unspecified (HashMap-derived). Callers that need a
220    /// deterministic order must sort after collecting.
221    pub fn edges(&self) -> impl Iterator<Item = &Edge> {
222        self.edges.values().flat_map(|v| v.iter())
223    }
224
225    /// Edges originating from `flake.nix`. See [`Self::edges`] on ordering.
226    pub fn declared_edges(&self) -> impl Iterator<Item = &Edge> {
227        self.edges()
228            .filter(|e| matches!(e.origin, EdgeOrigin::Declared { .. }))
229    }
230
231    /// Every source path the user has declared a follows for in
232    /// `flake.nix`, regardless of whether it resolves to a target. The
233    /// union of [`Self::declared_edges`] sources and the nulled
234    /// (`follows = ""`) sources tracked alongside them.
235    ///
236    /// Distinct from [`Self::declared_edges`] because the auto-follow
237    /// pipeline must treat a nulled declaration as "already user-owned"
238    /// and skip it; the edges-only view drops nulled sources because
239    /// they have no target to put on the right-hand side of an edge.
240    pub fn declared_sources(&self) -> HashSet<AttrPath> {
241        let mut out: HashSet<AttrPath> = self.declared_edges().map(|e| e.source.clone()).collect();
242        out.extend(self.declared_nulled_sources.iter().cloned());
243        out
244    }
245
246    /// Read-only view of source paths declared as `follows = ""`. The
247    /// auto-deduplicator consults this to distinguish a nulled
248    /// declaration (user explicitly opted out) from a path with a real
249    /// follows target.
250    pub fn declared_nulled(&self) -> &HashSet<AttrPath> {
251        &self.declared_nulled_sources
252    }
253
254    /// Cycles among declared edges only: detects per-segment self-cycles
255    /// where an edge's source equals its follows. Multi-hop and lockfile-only
256    /// cycle detection lives on [`Self::would_create_cycle`].
257    pub fn cycles(&self) -> Vec<Cycle> {
258        let mut found: Vec<Cycle> = Vec::new();
259        let mut seen_keys: HashSet<(AttrPath, AttrPath)> = HashSet::new();
260        let mut declared: Vec<&Edge> = self.declared_edges().collect();
261        declared.sort_by(|a, b| a.source.cmp(&b.source).then(a.follows.cmp(&b.follows)));
262        for edge in declared {
263            if !is_one_step_cycle(edge) {
264                continue;
265            }
266            let key = (edge.source.clone(), edge.follows.clone());
267            if seen_keys.insert(key) {
268                found.push(Cycle {
269                    edges: vec![edge.clone()],
270                });
271            }
272        }
273        found
274    }
275
276    /// Declared edges whose source path no longer appears in the lockfile.
277    /// A follows declaration whose nested input is gone from `flake.lock`
278    /// should be dropped on the next auto-follow pass. Lex-sorted by source.
279    pub fn stale_edges(&self) -> Vec<&Edge> {
280        let mut declared: Vec<&Edge> = self
281            .declared_edges()
282            .filter(|e| !self.resolved_universe.contains(&e.source))
283            .collect();
284        declared.sort_by(|a, b| a.source.cmp(&b.source));
285        declared
286    }
287
288    /// Sibling of [`Self::stale_edges`] for [`Self::declared_nulled`]:
289    /// nulled (`follows = ""`) declarations whose source is absent from
290    /// `resolved_universe`. A nulled declaration backed by an
291    /// `inputs.X = []` lock entry stays in `resolved_universe` and is not
292    /// reported. Lex-sorted.
293    pub fn stale_nulled_sources(&self) -> Vec<&AttrPath> {
294        let mut sources: Vec<&AttrPath> = self
295            .declared_nulled_sources
296            .iter()
297            .filter(|p| !self.resolved_universe.contains(*p))
298            .collect();
299        sources.sort();
300        sources
301    }
302
303    /// Declared follows whose target disagrees with the lockfile's
304    /// resolution for the same source path.
305    ///
306    /// A returned entry means `flake.nix` declares a follows for
307    /// `entry.declared.source` pointing at `entry.declared.follows`, but the
308    /// lock has either:
309    ///
310    /// - `lock_target = Some(other)`: a different resolved target, or
311    /// - `lock_target = None`: no follows at all (override never applied).
312    ///
313    /// Both cases call for `nix flake lock`. Sources missing from the
314    /// lockfile entirely are reported by [`Self::stale_edges`] instead.
315    ///
316    /// Lex-sorted by source.
317    pub fn stale_lock_declarations<'a>(
318        &'a self,
319        nested_inputs: &[NestedInput],
320    ) -> Vec<StaleLockDeclaration<'a>> {
321        let lock_targets: HashMap<&AttrPath, &Option<AttrPath>> = nested_inputs
322            .iter()
323            .map(|n| (&n.path, &n.follows))
324            .collect();
325
326        let mut out: Vec<StaleLockDeclaration<'a>> = Vec::new();
327        for edge in self.declared_edges() {
328            let Some(lock_target) = lock_targets.get(&edge.source) else {
329                continue;
330            };
331            let diverges = match lock_target {
332                Some(target) => target != &edge.follows,
333                None => true,
334            };
335            if diverges {
336                out.push(StaleLockDeclaration {
337                    declared: edge,
338                    lock_target: (*lock_target).clone(),
339                });
340            }
341        }
342        out.sort_by(|a, b| a.declared.source.cmp(&b.declared.source));
343        out
344    }
345
346    /// Whether adding `proposed` would close a follows cycle.
347    ///
348    /// Origin-agnostic DFS from `proposed.follows`. Reaching `proposed.source`
349    /// means the new edge closes a cycle. Beyond the trivial self-edge case,
350    /// three classes are covered:
351    ///
352    /// 1. **Dot-named ancestor.** [`AttrPath`] equality is structural, so a
353    ///    participant like `"hls-1.10"` compares by its unquoted segment
354    ///    value, not by URL prefix.
355    /// 2. **Multi-hop chains.** A cycle `A → B → C → ... → A` is found by
356    ///    walking the chain.
357    /// 3. **Lockfile-only cycles.** [`EdgeOrigin::Resolved`] edges are
358    ///    traversed alongside declared ones, so a chain closing only
359    ///    through the lockfile is still reported.
360    ///
361    /// Bounded by [`Self::with_max_depth`] for malformed graphs. Standard
362    /// visited / on-stack sets keep pre-existing cycles from wedging the walk.
363    pub fn would_create_cycle(&self, proposed: &Edge) -> bool {
364        if is_one_step_cycle(proposed) {
365            return true;
366        }
367        // Structural ancestor case: if the target's leading segment matches
368        // any ancestor segment of the source, the edge would point a nested
369        // input back at one of its own ancestors -- a self-reference Nix
370        // forbids. Catches the dot-named ancestor case even on an empty graph.
371        let target_first = proposed.follows.first();
372        let mut ancestor: Option<AttrPath> = proposed.source.parent();
373        while let Some(a) = ancestor {
374            if a.last() == target_first {
375                return true;
376            }
377            ancestor = a.parent();
378        }
379        let mut visited: HashSet<AttrPath> = HashSet::new();
380        let mut on_stack: HashSet<AttrPath> = HashSet::new();
381        self.dfs_reaches(
382            &proposed.follows,
383            &proposed.source,
384            0,
385            &mut visited,
386            &mut on_stack,
387        )
388    }
389
390    fn dfs_reaches(
391        &self,
392        node: &AttrPath,
393        target: &AttrPath,
394        depth: usize,
395        visited: &mut HashSet<AttrPath>,
396        on_stack: &mut HashSet<AttrPath>,
397    ) -> bool {
398        if depth >= self.max_depth {
399            return false;
400        }
401        if node == target {
402            return true;
403        }
404        if visited.contains(node) {
405            return false;
406        }
407        if on_stack.contains(node) {
408            // Pre-existing cycle that doesn't pass through `target`. Skip
409            // without claiming the proposed edge closes a new one.
410            return false;
411        }
412        // Cycle through structural ancestry: a target inside the current
413        // node's subtree, e.g. proposed `c.a -> a` while traversing from `a`
414        // whose subtree contains declared edges sourced at `a.x` reaching
415        // back to `c.a`.
416        if node.is_prefix_of(target) {
417            return true;
418        }
419        on_stack.insert(node.clone());
420        // Expand literal-source edges plus edges whose source starts with
421        // `node`. The second captures the implicit "parent depends on target"
422        // relation a declared `parent.child -> target` follows expresses.
423        for edge in self.expanded_outgoing(node) {
424            if self.dfs_reaches(&edge.follows, target, depth + 1, visited, on_stack) {
425                on_stack.remove(node);
426                visited.insert(node.clone());
427                return true;
428            }
429        }
430        on_stack.remove(node);
431        visited.insert(node.clone());
432        false
433    }
434
435    /// Edges describing outgoing dependencies of `node`: those with source
436    /// equal to `node`, plus those whose source has `node` as a strict
437    /// prefix. The latter encode the implicit "parent depends on target"
438    /// relation in a declared `parent.child.follows = target`.
439    fn expanded_outgoing(&self, node: &AttrPath) -> Vec<&Edge> {
440        let mut out: Vec<&Edge> = Vec::new();
441        for (source, edges) in &self.edges {
442            if source == node || node.is_prefix_of(source) {
443                out.extend(edges.iter());
444            }
445        }
446        out
447    }
448
449    fn insert_edge(&mut self, edge: Edge) {
450        self.edges
451            .entry(edge.source.clone())
452            .or_default()
453            .push(edge);
454    }
455
456    /// Drop every edge whose `source` is in `sources`.
457    ///
458    /// Hides a known set of edges from [`Self::would_create_cycle`] and
459    /// [`Self::lock_routes_to`] without re-deriving the graph from
460    /// scratch. `resolved_universe` is intentionally untouched: removing
461    /// a declared edge does not retroactively unobserve the lockfile
462    /// path the source was discovered from.
463    pub fn drop_edges_with_sources(&mut self, sources: &[AttrPath]) {
464        for src in sources {
465            self.edges.remove(src);
466        }
467    }
468
469    /// Whether the graph routes `source` transitively to `target`, ignoring
470    /// `exclude` if given. `extra_edges` are treated as additional
471    /// declared-style edges for callers staging follows not yet in the graph.
472    ///
473    /// Walks both [`EdgeOrigin::Declared`] and [`EdgeOrigin::Resolved`] edges:
474    /// [`Self::from_flake`] keeps only one when both encode the same
475    /// `(source, target)`, so restricting the walk to one variant would miss
476    /// chains closing through the deduped edge.
477    pub fn lock_routes_to(
478        &self,
479        source: &AttrPath,
480        target: &AttrPath,
481        exclude: Option<&Edge>,
482        extra_edges: &[(AttrPath, AttrPath)],
483    ) -> bool {
484        let mut visited: HashSet<AttrPath> = HashSet::new();
485        let mut on_stack: HashSet<AttrPath> = HashSet::new();
486        self.dfs_routes_to(
487            source,
488            target,
489            exclude,
490            extra_edges,
491            0,
492            &mut visited,
493            &mut on_stack,
494        )
495    }
496
497    #[expect(clippy::too_many_arguments)]
498    fn dfs_routes_to(
499        &self,
500        node: &AttrPath,
501        target: &AttrPath,
502        exclude: Option<&Edge>,
503        extra_edges: &[(AttrPath, AttrPath)],
504        depth: usize,
505        visited: &mut HashSet<AttrPath>,
506        on_stack: &mut HashSet<AttrPath>,
507    ) -> bool {
508        if depth >= self.max_depth {
509            return false;
510        }
511        if node == target {
512            return true;
513        }
514        if visited.contains(node) || on_stack.contains(node) {
515            return false;
516        }
517        if node.is_prefix_of(target) {
518            return true;
519        }
520        on_stack.insert(node.clone());
521
522        let mut candidates = self.direct_hop_targets(node, exclude);
523        candidates.extend(Self::extra_edge_targets(node, extra_edges, exclude));
524        candidates.extend(self.ancestor_rewrite_targets(node, extra_edges, exclude));
525
526        for next in candidates {
527            if self.dfs_routes_to(
528                &next,
529                target,
530                exclude,
531                extra_edges,
532                depth + 1,
533                visited,
534                on_stack,
535            ) {
536                on_stack.remove(node);
537                visited.insert(node.clone());
538                return true;
539            }
540        }
541        on_stack.remove(node);
542        visited.insert(node.clone());
543        false
544    }
545
546    /// Includes edges sourced at any descendant of `node`, since a declared
547    /// `parent.child.follows = target` encodes an implicit dependency of
548    /// `parent` on `target`.
549    fn direct_hop_targets(&self, node: &AttrPath, exclude: Option<&Edge>) -> Vec<AttrPath> {
550        self.expanded_outgoing(node)
551            .into_iter()
552            .filter(|edge| !matches_excluded(&edge.source, &edge.follows, exclude))
553            .map(|edge| edge.follows.clone())
554            .collect()
555    }
556
557    fn extra_edge_targets(
558        node: &AttrPath,
559        extra_edges: &[(AttrPath, AttrPath)],
560        exclude: Option<&Edge>,
561    ) -> Vec<AttrPath> {
562        extra_edges
563            .iter()
564            .filter(|(src, _)| src == node || node.is_prefix_of(src))
565            .filter(|(src, dst)| !matches_excluded(src, dst, exclude))
566            .map(|(_, dst)| dst.clone())
567            .collect()
568    }
569
570    /// When `A.B` follows `C`, `A.B.X` resolves to `C.X`: an edge sourced
571    /// at any ancestor of `node` rewrites the suffix after that ancestor
572    /// onto the edge's target.
573    fn ancestor_rewrite_targets(
574        &self,
575        node: &AttrPath,
576        extra_edges: &[(AttrPath, AttrPath)],
577        exclude: Option<&Edge>,
578    ) -> Vec<AttrPath> {
579        let mut out: Vec<AttrPath> = Vec::new();
580        let mut ancestor = node.parent();
581        while let Some(anc) = ancestor.clone() {
582            let suffix = &node.segments()[anc.len()..];
583            for edge in self.outgoing(&anc) {
584                if matches_excluded(&edge.source, &edge.follows, exclude) {
585                    continue;
586                }
587                out.push(splice_suffix(&edge.follows, suffix));
588            }
589            for (src, dst) in extra_edges {
590                if src != &anc || matches_excluded(src, dst, exclude) {
591                    continue;
592                }
593                out.push(splice_suffix(dst, suffix));
594            }
595            ancestor = anc.parent();
596        }
597        out
598    }
599}
600
601fn matches_excluded(source: &AttrPath, follows: &AttrPath, exclude: Option<&Edge>) -> bool {
602    matches!(exclude, Some(e) if &e.source == source && &e.follows == follows)
603}
604
605fn splice_suffix(base: &AttrPath, suffix: &[Segment]) -> AttrPath {
606    let mut out = base.clone();
607    for seg in suffix {
608        out.push(seg.clone());
609    }
610    out
611}
612
613fn collect_declared_edges(input: &Input, graph: &mut FollowsGraph) {
614    for follows in input.follows() {
615        if let Follows::Indirect { path, target } = follows {
616            let mut source = AttrPath::new(input.id().clone());
617            for seg in path.segments() {
618                source.push(seg.clone());
619            }
620            match target {
621                Some(target) => {
622                    graph.insert_edge(Edge {
623                        source,
624                        follows: target.clone(),
625                        origin: EdgeOrigin::Declared {
626                            range: input.range.clone(),
627                        },
628                    });
629                }
630                // `follows = ""`: no edge to insert, but record the
631                // source so [`FollowsGraph::declared_sources`] reports
632                // it as user-owned.
633                None => {
634                    graph.declared_nulled_sources.insert(source);
635                }
636            }
637        }
638    }
639}
640
641/// Self-cycle: source equals follows, structurally.
642fn is_one_step_cycle(edge: &Edge) -> bool {
643    edge.source == edge.follows
644}
645
646/// Whether `url` is a follows reference of the form `"<parent>/<rest>"`.
647/// Exposed for consumers that have only a URL string and no typed target.
648pub fn is_follows_reference_to_parent(url: &str, parent: &str) -> bool {
649    url.starts_with(&format!("{parent}/"))
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use crate::follows::Segment;
656    use crate::input::Range;
657
658    fn seg(s: &str) -> Segment {
659        Segment::from_unquoted(s).unwrap()
660    }
661
662    fn path(s: &str) -> AttrPath {
663        AttrPath::parse(s).unwrap()
664    }
665
666    fn declared_input(id: &str, follows: &[(&str, &str)]) -> Input {
667        let mut input = Input::new(seg(id));
668        for (parent, target) in follows {
669            input.follows.push(Follows::Indirect {
670                path: AttrPath::new(seg(parent)),
671                target: Some(path(target)),
672            });
673        }
674        input.range = Range { start: 1, end: 2 };
675        input
676    }
677
678    fn make_inputs(items: Vec<Input>) -> InputMap {
679        let mut map = InputMap::new();
680        for input in items {
681            map.insert(input.id().as_str().to_string(), input);
682        }
683        map
684    }
685
686    fn declared_edge(source: &str, follows: &str) -> Edge {
687        Edge {
688            source: path(source),
689            follows: path(follows),
690            origin: EdgeOrigin::Declared {
691                range: Range { start: 0, end: 0 },
692            },
693        }
694    }
695
696    #[test]
697    fn from_declared_emits_one_edge_per_indirect() {
698        let inputs = make_inputs(vec![declared_input(
699            "crane",
700            &[("nixpkgs", "nixpkgs"), ("flake-utils", "flake-utils")],
701        )]);
702        let g = FollowsGraph::from_declared(&inputs);
703        let mut got: Vec<(String, String)> = g
704            .edges()
705            .map(|e| (e.source.to_string(), e.follows.to_string()))
706            .collect();
707        got.sort();
708        assert_eq!(
709            got,
710            vec![
711                ("crane.flake-utils".to_string(), "flake-utils".to_string()),
712                ("crane.nixpkgs".to_string(), "nixpkgs".to_string()),
713            ]
714        );
715    }
716
717    #[test]
718    fn from_declared_marks_origin_declared() {
719        let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
720        let g = FollowsGraph::from_declared(&inputs);
721        let edge = g.edges().next().unwrap();
722        assert!(matches!(edge.origin, EdgeOrigin::Declared { .. }));
723    }
724
725    #[test]
726    fn from_lock_picks_up_nested_follows() {
727        let lock_text = r#"{
728  "nodes": {
729    "nixpkgs": {
730      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
731      "original": { "owner": "o", "repo": "r", "type": "github" }
732    },
733    "treefmt-nix": {
734      "inputs": { "nixpkgs": ["nixpkgs"] },
735      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
736      "original": { "owner": "o", "repo": "r", "type": "github" }
737    },
738    "root": {
739      "inputs": { "nixpkgs": "nixpkgs", "treefmt-nix": "treefmt-nix" }
740    }
741  },
742  "root": "root",
743  "version": 7
744}"#;
745        let lock = FlakeLock::read_from_str(lock_text).unwrap();
746        let g = FollowsGraph::from_lock(&lock);
747        let edges: Vec<&Edge> = g.edges().collect();
748        assert_eq!(edges.len(), 1);
749        assert_eq!(edges[0].source.to_string(), "treefmt-nix.nixpkgs");
750        assert_eq!(edges[0].follows.to_string(), "nixpkgs");
751        assert!(matches!(edges[0].origin, EdgeOrigin::Resolved));
752    }
753
754    #[test]
755    fn outgoing_returns_only_matching_source() {
756        let inputs = make_inputs(vec![
757            declared_input("crane", &[("nixpkgs", "nixpkgs")]),
758            declared_input("flake-utils", &[("nixpkgs", "nixpkgs")]),
759        ]);
760        let g = FollowsGraph::from_declared(&inputs);
761        let crane_out = g.outgoing(&path("crane.nixpkgs"));
762        assert_eq!(crane_out.len(), 1);
763        assert_eq!(crane_out[0].follows.to_string(), "nixpkgs");
764        assert!(g.outgoing(&path("nonexistent.nixpkgs")).is_empty());
765    }
766
767    #[test]
768    fn stale_edges_flags_declared_without_resolved() {
769        // `flake.nix` declares `home-manager.nixpkgs.follows`, but the
770        // lockfile has no nested input at that path.
771        let inputs = make_inputs(vec![declared_input(
772            "home-manager",
773            &[("nixpkgs", "nixpkgs")],
774        )]);
775        let lock_text = r#"{
776  "nodes": {
777    "nixpkgs": {
778      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
779      "original": { "owner": "o", "repo": "r", "type": "github" }
780    },
781    "home-manager": {
782      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
783      "original": { "owner": "o", "repo": "r", "type": "github" }
784    },
785    "root": {
786      "inputs": { "nixpkgs": "nixpkgs", "home-manager": "home-manager" }
787    }
788  },
789  "root": "root",
790  "version": 7
791}"#;
792        let lock = FlakeLock::read_from_str(lock_text).unwrap();
793        let g = FollowsGraph::from_flake(&inputs, &lock);
794        let stale: Vec<&Edge> = g.stale_edges();
795        assert_eq!(stale.len(), 1);
796        assert_eq!(stale[0].source.to_string(), "home-manager.nixpkgs");
797    }
798
799    #[test]
800    fn stale_nulled_sources_flags_nulled_without_resolved() {
801        let mut input = Input::new(seg("home-manager"));
802        input.follows.push(Follows::Indirect {
803            path: AttrPath::new(seg("nixpkgs")),
804            target: None,
805        });
806        input.range = Range { start: 1, end: 2 };
807        let inputs = make_inputs(vec![input]);
808
809        let lock_text = r#"{
810  "nodes": {
811    "nixpkgs": {
812      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
813      "original": { "owner": "o", "repo": "r", "type": "github" }
814    },
815    "home-manager": {
816      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
817      "original": { "owner": "o", "repo": "r", "type": "github" }
818    },
819    "root": {
820      "inputs": { "nixpkgs": "nixpkgs", "home-manager": "home-manager" }
821    }
822  },
823  "root": "root",
824  "version": 7
825}"#;
826        let lock = FlakeLock::read_from_str(lock_text).unwrap();
827        let g = FollowsGraph::from_flake(&inputs, &lock);
828        let stale: Vec<String> = g
829            .stale_nulled_sources()
830            .iter()
831            .map(|p| p.to_string())
832            .collect();
833        assert_eq!(stale, vec!["home-manager.nixpkgs"]);
834    }
835
836    /// `inputs.X = []` keeps `X` in `resolved_universe`, so a matching
837    /// `follows = ""` is in sync, not stale.
838    #[test]
839    fn stale_nulled_sources_quiet_when_lock_has_empty_indirect() {
840        let mut input = Input::new(seg("home-manager"));
841        input.follows.push(Follows::Indirect {
842            path: AttrPath::new(seg("nixpkgs")),
843            target: None,
844        });
845        input.range = Range { start: 1, end: 2 };
846        let inputs = make_inputs(vec![input]);
847
848        let lock_text = r#"{
849  "nodes": {
850    "home-manager": {
851      "inputs": { "nixpkgs": [] },
852      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
853      "original": { "owner": "o", "repo": "r", "type": "github" }
854    },
855    "root": {
856      "inputs": { "home-manager": "home-manager" }
857    }
858  },
859  "root": "root",
860  "version": 7
861}"#;
862        let lock = FlakeLock::read_from_str(lock_text).unwrap();
863        let g = FollowsGraph::from_flake(&inputs, &lock);
864        assert!(g.stale_nulled_sources().is_empty());
865    }
866
867    #[test]
868    fn self_named_three_segment_declared_round_trips_with_lock() {
869        // Regression: a `parent.parent.leaf` lock path must round-trip through
870        // the declared edge so [`FollowsGraph::stale_edges`] does not flag it.
871        // Hand-build the declared shape the parser produces for
872        // `inputs.agenix.inputs.systems.follows = "systems"` inside an
873        // `agenix = { ... }` block: `Follows::Indirect` whose `path` carries
874        // the full chain `[agenix, systems]`, attached to the owner input
875        // `agenix`. Reconstructed source is `agenix.agenix.systems` (3-seg).
876        let mut input = Input::new(seg("agenix"));
877        input.follows.push(Follows::Indirect {
878            path: AttrPath::parse("agenix.systems").unwrap(),
879            target: Some(path("systems")),
880        });
881        input.range = Range { start: 1, end: 2 };
882        let inputs = make_inputs(vec![input, declared_input("systems", &[])]);
883
884        let lock_text = r#"{
885  "nodes": {
886    "agenix": {
887      "inputs": { "agenix": "agenix_2" },
888      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "aaa", "type": "github" },
889      "original": { "owner": "o", "repo": "r", "type": "github" }
890    },
891    "agenix_2": {
892      "inputs": { "systems": "systems_2" },
893      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "bbb", "type": "github" },
894      "original": { "owner": "o", "repo": "r", "type": "github" }
895    },
896    "systems": {
897      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ccc", "type": "github" },
898      "original": { "owner": "o", "repo": "r", "type": "github" }
899    },
900    "systems_2": {
901      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
902      "original": { "owner": "o", "repo": "r", "type": "github" }
903    },
904    "root": {
905      "inputs": { "agenix": "agenix", "systems": "systems" }
906    }
907  },
908  "root": "root",
909  "version": 7
910}"#;
911        let lock = FlakeLock::read_from_str(lock_text).unwrap();
912        let g = FollowsGraph::from_flake(&inputs, &lock);
913
914        let declared: Vec<&Edge> = g.declared_edges().collect();
915        assert_eq!(declared.len(), 1);
916        assert_eq!(declared[0].source.to_string(), "agenix.agenix.systems");
917
918        let stale: Vec<&Edge> = g.stale_edges();
919        assert!(
920            stale.is_empty(),
921            "self-named 3-seg declared edge must not be flagged stale, got: {:?}",
922            stale
923                .iter()
924                .map(|e| e.source.to_string())
925                .collect::<Vec<_>>()
926        );
927    }
928
929    #[test]
930    fn would_create_cycle_self_edge() {
931        let g = FollowsGraph::default();
932        let e = declared_edge("nixpkgs", "nixpkgs");
933        assert!(g.would_create_cycle(&e));
934    }
935
936    #[test]
937    fn would_create_cycle_dot_named_ancestor() {
938        // Structural equality on the dot-named segment must catch the
939        // ancestor cycle: source `"hls-1.10".nixpkgs`, target `"hls-1.10"`.
940        let g = FollowsGraph::default();
941        let e = Edge {
942            source: AttrPath::parse("\"hls-1.10\".nixpkgs").unwrap(),
943            follows: AttrPath::parse("\"hls-1.10\"").unwrap(),
944            origin: EdgeOrigin::Declared {
945                range: Range { start: 0, end: 0 },
946            },
947        };
948        assert!(g.would_create_cycle(&e));
949    }
950
951    #[test]
952    fn would_create_cycle_no_cycle_for_distinct_target() {
953        let g = FollowsGraph::default();
954        let e = declared_edge("crane.nixpkgs", "nixpkgs");
955        assert!(!g.would_create_cycle(&e));
956    }
957
958    #[test]
959    fn would_create_cycle_ignores_unrelated_ancestor() {
960        let g = FollowsGraph::default();
961        let e = declared_edge("a.b.c", "d");
962        assert!(!g.would_create_cycle(&e));
963    }
964
965    /// Multi-hop cycle through declared edges: `A → B`, `B → C`, propose
966    /// `C → A`. DFS must traverse the chain rather than rely on a
967    /// per-ancestor check on `proposed`.
968    #[test]
969    fn would_create_cycle_multi_hop_declared() {
970        let mut g = FollowsGraph::default();
971        g.insert_edge(declared_edge("a", "b"));
972        g.insert_edge(declared_edge("b", "c"));
973        let proposed = declared_edge("c", "a");
974        assert!(g.would_create_cycle(&proposed));
975    }
976
977    /// Multi-hop with a `"hls-1.10"` participant: typed [`AttrPath`]
978    /// equality must survive the embedded dot.
979    #[test]
980    fn would_create_cycle_multi_hop_dot_named() {
981        let mut g = FollowsGraph::default();
982        g.insert_edge(declared_edge("\"hls-1.10\"", "b"));
983        g.insert_edge(declared_edge("b", "c"));
984        let proposed = declared_edge("c", "\"hls-1.10\"");
985        assert!(g.would_create_cycle(&proposed));
986    }
987
988    /// Lockfile-only cycle: a 2-hop chain closing only through resolved
989    /// edges. Origin-agnostic DFS must report it.
990    #[test]
991    fn would_create_cycle_lockfile_only() {
992        let mut g = FollowsGraph::default();
993        g.insert_edge(Edge {
994            source: AttrPath::parse("treefmt-nix.nixpkgs").unwrap(),
995            follows: AttrPath::parse("harmonia.treefmt-nix").unwrap(),
996            origin: EdgeOrigin::Resolved,
997        });
998        g.insert_edge(Edge {
999            source: AttrPath::parse("harmonia.treefmt-nix").unwrap(),
1000            follows: AttrPath::parse("treefmt-nix").unwrap(),
1001            origin: EdgeOrigin::Resolved,
1002        });
1003        let proposed = Edge {
1004            source: AttrPath::parse("treefmt-nix").unwrap(),
1005            follows: AttrPath::parse("treefmt-nix.nixpkgs").unwrap(),
1006            origin: EdgeOrigin::Declared {
1007                range: Range { start: 0, end: 0 },
1008            },
1009        };
1010        assert!(g.would_create_cycle(&proposed));
1011    }
1012
1013    /// DFS terminates and still reports a cycle-closing proposal when the
1014    /// graph already contains an unrelated cycle.
1015    #[test]
1016    fn would_create_cycle_terminates_on_existing_cycle() {
1017        let mut g = FollowsGraph::default();
1018        g.insert_edge(declared_edge("x", "x"));
1019        g.insert_edge(declared_edge("a", "b"));
1020        g.insert_edge(declared_edge("b", "c"));
1021        let proposed = declared_edge("c", "a");
1022        assert!(g.would_create_cycle(&proposed));
1023    }
1024
1025    /// `max_depth` bounds DFS so a malformed graph cannot wedge it.
1026    #[test]
1027    fn would_create_cycle_bounded_by_max_depth() {
1028        let mut g = FollowsGraph::default().with_max_depth(2);
1029        g.insert_edge(declared_edge("a", "b"));
1030        g.insert_edge(declared_edge("b", "c"));
1031        g.insert_edge(declared_edge("c", "d"));
1032        let proposed = declared_edge("d", "a");
1033        assert!(!g.would_create_cycle(&proposed));
1034    }
1035
1036    #[test]
1037    fn drop_edges_with_sources_removes_only_listed_sources() {
1038        let mut g = FollowsGraph::default();
1039        g.insert_edge(declared_edge("crane.nixpkgs", "nixpkgs"));
1040        g.insert_edge(declared_edge("crane.flake-utils", "flake-utils"));
1041        g.insert_edge(declared_edge("treefmt-nix.nixpkgs", "nixpkgs"));
1042
1043        g.drop_edges_with_sources(&[path("crane.nixpkgs")]);
1044
1045        let mut remaining: Vec<String> = g.edges().map(|e| e.source.to_string()).collect();
1046        remaining.sort();
1047        assert_eq!(
1048            remaining,
1049            vec![
1050                "crane.flake-utils".to_string(),
1051                "treefmt-nix.nixpkgs".to_string(),
1052            ],
1053            "only the listed source should be dropped; the rest survive intact"
1054        );
1055    }
1056
1057    #[test]
1058    fn drop_edges_with_sources_clears_cycle_through_dropped_edge() {
1059        // Edges `X.Y -> Y` and `Y.Z -> Z` chain via [`Self::would_create_cycle`]
1060        // to reject a `Z.X -> X` candidate (the `Z.is_prefix_of(Z.X)`
1061        // shortcut fires at the visited `Z` node). Dropping `X.Y` must
1062        // clear the path.
1063        let mut g = FollowsGraph::default();
1064        g.insert_edge(declared_edge("X.Y", "Y"));
1065        g.insert_edge(declared_edge("Y.Z", "Z"));
1066        let proposed = declared_edge("Z.X", "X");
1067        assert!(g.would_create_cycle(&proposed));
1068
1069        g.drop_edges_with_sources(&[path("X.Y")]);
1070        assert!(!g.would_create_cycle(&proposed));
1071    }
1072
1073    #[test]
1074    fn cycles_finds_self_referential_declared_edge() {
1075        let mut inputs = InputMap::new();
1076        let mut input = Input::new(seg("foo"));
1077        input.follows.push(Follows::Indirect {
1078            path: AttrPath::new(seg("foo")),
1079            target: Some(AttrPath::parse("foo.foo").unwrap()),
1080        });
1081        input.range = Range { start: 1, end: 2 };
1082        inputs.insert("foo".into(), input);
1083        let g = FollowsGraph::from_declared(&inputs);
1084        let cycles = g.cycles();
1085        assert_eq!(cycles.len(), 1);
1086        assert_eq!(cycles[0].edges.len(), 1);
1087        assert_eq!(cycles[0].edges[0].source.to_string(), "foo.foo");
1088    }
1089
1090    #[test]
1091    fn cycles_empty_for_acyclic_declared() {
1092        let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
1093        let g = FollowsGraph::from_declared(&inputs);
1094        assert!(g.cycles().is_empty());
1095    }
1096
1097    #[test]
1098    fn is_follows_reference_to_parent_url_prefix() {
1099        assert!(is_follows_reference_to_parent(
1100            "clan-core/treefmt-nix",
1101            "clan-core"
1102        ));
1103        assert!(!is_follows_reference_to_parent("github:nixos/nixpkgs", "x"));
1104        assert!(!is_follows_reference_to_parent(
1105            "clan-core-extended",
1106            "clan-core"
1107        ));
1108    }
1109
1110    #[test]
1111    fn with_max_depth_overrides_default() {
1112        let g = FollowsGraph::default().with_max_depth(7);
1113        assert_eq!(g.max_depth, 7);
1114    }
1115
1116    #[test]
1117    fn stale_lock_declarations_detects_target_mismatch() {
1118        let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
1119        let lock_text = r#"{
1120  "nodes": {
1121    "nixpkgs": {
1122      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
1123      "original": { "owner": "o", "repo": "r", "type": "github" }
1124    },
1125    "nixpkgs_2": {
1126      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
1127      "original": { "owner": "o", "repo": "r", "type": "github" }
1128    },
1129    "crane": {
1130      "inputs": { "nixpkgs": ["nixpkgs_2"] },
1131      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ggg", "type": "github" },
1132      "original": { "owner": "o", "repo": "r", "type": "github" }
1133    },
1134    "root": {
1135      "inputs": { "nixpkgs": "nixpkgs", "crane": "crane" }
1136    }
1137  },
1138  "root": "root",
1139  "version": 7
1140}"#;
1141        let lock = FlakeLock::read_from_str(lock_text).unwrap();
1142        let g = FollowsGraph::from_flake(&inputs, &lock);
1143        let overridden = g.stale_lock_declarations(&lock.nested_inputs());
1144        assert_eq!(overridden.len(), 1);
1145        assert_eq!(overridden[0].declared.source.to_string(), "crane.nixpkgs");
1146        assert_eq!(overridden[0].declared.follows.to_string(), "nixpkgs");
1147        assert_eq!(
1148            overridden[0].lock_target.as_ref().map(|p| p.to_string()),
1149            Some("nixpkgs_2".to_string())
1150        );
1151    }
1152
1153    #[test]
1154    fn stale_lock_declarations_detects_missing_follows() {
1155        let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
1156        let lock_text = r#"{
1157  "nodes": {
1158    "nixpkgs": {
1159      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
1160      "original": { "owner": "o", "repo": "r", "type": "github" }
1161    },
1162    "nixpkgs_2": {
1163      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
1164      "original": { "owner": "o", "repo": "r", "type": "github" }
1165    },
1166    "crane": {
1167      "inputs": { "nixpkgs": "nixpkgs_2" },
1168      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ggg", "type": "github" },
1169      "original": { "owner": "o", "repo": "r", "type": "github" }
1170    },
1171    "root": {
1172      "inputs": { "nixpkgs": "nixpkgs", "crane": "crane" }
1173    }
1174  },
1175  "root": "root",
1176  "version": 7
1177}"#;
1178        let lock = FlakeLock::read_from_str(lock_text).unwrap();
1179        let g = FollowsGraph::from_flake(&inputs, &lock);
1180        let overridden = g.stale_lock_declarations(&lock.nested_inputs());
1181        assert_eq!(overridden.len(), 1);
1182        assert_eq!(overridden[0].declared.source.to_string(), "crane.nixpkgs");
1183        assert!(overridden[0].lock_target.is_none());
1184    }
1185
1186    #[test]
1187    fn stale_lock_declarations_quiet_when_in_sync() {
1188        let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
1189        let lock_text = r#"{
1190  "nodes": {
1191    "nixpkgs": {
1192      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
1193      "original": { "owner": "o", "repo": "r", "type": "github" }
1194    },
1195    "crane": {
1196      "inputs": { "nixpkgs": ["nixpkgs"] },
1197      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ggg", "type": "github" },
1198      "original": { "owner": "o", "repo": "r", "type": "github" }
1199    },
1200    "root": {
1201      "inputs": { "nixpkgs": "nixpkgs", "crane": "crane" }
1202    }
1203  },
1204  "root": "root",
1205  "version": 7
1206}"#;
1207        let lock = FlakeLock::read_from_str(lock_text).unwrap();
1208        let g = FollowsGraph::from_flake(&inputs, &lock);
1209        assert!(g.stale_lock_declarations(&lock.nested_inputs()).is_empty());
1210    }
1211
1212    #[test]
1213    fn stale_lock_declarations_orthogonal_to_stale() {
1214        let inputs = make_inputs(vec![declared_input(
1215            "home-manager",
1216            &[("nixpkgs", "nixpkgs")],
1217        )]);
1218        let lock_text = r#"{
1219  "nodes": {
1220    "nixpkgs": {
1221      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
1222      "original": { "owner": "o", "repo": "r", "type": "github" }
1223    },
1224    "home-manager": {
1225      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
1226      "original": { "owner": "o", "repo": "r", "type": "github" }
1227    },
1228    "root": {
1229      "inputs": { "nixpkgs": "nixpkgs", "home-manager": "home-manager" }
1230    }
1231  },
1232  "root": "root",
1233  "version": 7
1234}"#;
1235        let lock = FlakeLock::read_from_str(lock_text).unwrap();
1236        let g = FollowsGraph::from_flake(&inputs, &lock);
1237        assert_eq!(g.stale_edges().len(), 1);
1238        assert!(g.stale_lock_declarations(&lock.nested_inputs()).is_empty());
1239    }
1240
1241    #[test]
1242    fn merged_prefers_declared_over_resolved() {
1243        let inputs = make_inputs(vec![declared_input(
1244            "treefmt-nix",
1245            &[("nixpkgs", "nixpkgs")],
1246        )]);
1247        let lock_text = r#"{
1248  "nodes": {
1249    "nixpkgs": {
1250      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
1251      "original": { "owner": "o", "repo": "r", "type": "github" }
1252    },
1253    "treefmt-nix": {
1254      "inputs": { "nixpkgs": ["nixpkgs"] },
1255      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
1256      "original": { "owner": "o", "repo": "r", "type": "github" }
1257    },
1258    "root": {
1259      "inputs": { "nixpkgs": "nixpkgs", "treefmt-nix": "treefmt-nix" }
1260    }
1261  },
1262  "root": "root",
1263  "version": 7
1264}"#;
1265        let lock = FlakeLock::read_from_str(lock_text).unwrap();
1266        let g = FollowsGraph::from_flake(&inputs, &lock);
1267        let edges: Vec<&Edge> = g.outgoing(&path("treefmt-nix.nixpkgs")).iter().collect();
1268        assert_eq!(edges.len(), 1);
1269        assert!(matches!(edges[0].origin, EdgeOrigin::Declared { .. }));
1270    }
1271
1272    #[test]
1273    fn lock_routes_to_chain_through_user_depth1() {
1274        let mut g = FollowsGraph::default();
1275        g.insert_edge(Edge {
1276            source: path("hyprland.aquamarine.nixpkgs"),
1277            follows: path("hyprland.nixpkgs"),
1278            origin: EdgeOrigin::Resolved,
1279        });
1280        g.insert_edge(declared_edge("hyprland.nixpkgs", "nixpkgs"));
1281        assert!(g.lock_routes_to(
1282            &path("hyprland.aquamarine.nixpkgs"),
1283            &path("nixpkgs"),
1284            None,
1285            &[],
1286        ));
1287    }
1288
1289    #[test]
1290    fn lock_routes_to_excludes_candidate_edge() {
1291        let mut g = FollowsGraph::default();
1292        g.insert_edge(Edge {
1293            source: path("hyprland.aquamarine.nixpkgs"),
1294            follows: path("hyprland.nixpkgs"),
1295            origin: EdgeOrigin::Resolved,
1296        });
1297        g.insert_edge(declared_edge("hyprland.nixpkgs", "nixpkgs"));
1298        let candidate = declared_edge("hyprland.aquamarine.nixpkgs", "nixpkgs");
1299        g.insert_edge(candidate.clone());
1300        assert!(g.lock_routes_to(&candidate.source, &candidate.follows, Some(&candidate), &[],));
1301    }
1302
1303    /// Auto-remove must not drop a load-bearing follow.
1304    #[test]
1305    fn lock_routes_to_no_other_path_returns_false_when_candidate_excluded() {
1306        let mut g = FollowsGraph::default();
1307        let candidate = declared_edge("a.b", "nixpkgs");
1308        g.insert_edge(candidate.clone());
1309        assert!(!g.lock_routes_to(&candidate.source, &candidate.follows, Some(&candidate), &[]));
1310    }
1311
1312    #[test]
1313    fn lock_routes_to_no_route_returns_false() {
1314        let g = FollowsGraph::default();
1315        assert!(!g.lock_routes_to(&path("a.b.c"), &path("nixpkgs"), None, &[]));
1316    }
1317
1318    /// Deep upstream chain `a.b.c.d -> a.b.c -> a.b -> a`, all Resolved.
1319    #[test]
1320    fn lock_routes_to_deep_upstream_chain() {
1321        let mut g = FollowsGraph::default();
1322        for (src, dst) in [("a.b.c.d", "a.b.c"), ("a.b.c", "a.b"), ("a.b", "a")] {
1323            g.insert_edge(Edge {
1324                source: path(src),
1325                follows: path(dst),
1326                origin: EdgeOrigin::Resolved,
1327            });
1328        }
1329        assert!(g.lock_routes_to(&path("a.b.c.d"), &path("a"), None, &[]));
1330    }
1331
1332    /// End-to-end: a depth-3 chain detected by [`FollowsGraph::lock_routes_to`]
1333    /// drives a [`Change::Remove`] through [`FlakeEdit`].
1334    #[test]
1335    fn lock_routes_to_drives_change_remove_at_depth_three() {
1336        use crate::change::{Change, ChangeId};
1337        use crate::edit::FlakeEdit;
1338
1339        let mut g = FollowsGraph::default();
1340        for (src, dst) in [
1341            ("omnibus.flops.POP.nixpkgs", "omnibus.flops.nixpkgs"),
1342            ("omnibus.flops.nixpkgs", "omnibus.nixpkgs"),
1343            ("omnibus.nixpkgs", "nixpkgs"),
1344        ] {
1345            g.insert_edge(Edge {
1346                source: path(src),
1347                follows: path(dst),
1348                origin: EdgeOrigin::Resolved,
1349            });
1350        }
1351        let candidate = declared_edge("omnibus.flops.POP.nixpkgs", "nixpkgs");
1352        g.insert_edge(candidate.clone());
1353
1354        assert!(
1355            g.lock_routes_to(&candidate.source, &candidate.follows, Some(&candidate), &[]),
1356            "depth-3 chain should be predicted as covered by upstream propagation"
1357        );
1358
1359        let flake = r#"{
1360  inputs = {
1361    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
1362    omnibus.url = "github:Lehmanator/nix-configs";
1363    omnibus.inputs.nixpkgs.follows = "nixpkgs";
1364    omnibus.inputs.flops.inputs.POP.inputs.nixpkgs.follows = "nixpkgs";
1365  };
1366
1367  outputs = { self, ... }: { };
1368}
1369"#;
1370        let mut fe = FlakeEdit::from_text(flake).expect("parses");
1371        let change = Change::Remove {
1372            ids: vec![ChangeId::new(candidate.source.clone())],
1373        };
1374        let outcome = fe.apply_change(change).expect("apply succeeds");
1375        let new_text = outcome.text.expect("walker rewrote the tree");
1376
1377        assert!(
1378            !new_text.contains("flops"),
1379            "depth-3 redundant follows should be removed, got:\n{new_text}"
1380        );
1381        assert!(
1382            new_text.contains("omnibus.inputs.nixpkgs.follows = \"nixpkgs\""),
1383            "load-bearing depth-1 follows must remain, got:\n{new_text}"
1384        );
1385    }
1386
1387    #[test]
1388    fn matches_excluded_compares_source_and_follows() {
1389        let edge = declared_edge("a.b", "nixpkgs");
1390        assert!(matches_excluded(
1391            &path("a.b"),
1392            &path("nixpkgs"),
1393            Some(&edge)
1394        ));
1395        assert!(!matches_excluded(&path("a.b"), &path("other"), Some(&edge)));
1396        assert!(!matches_excluded(
1397            &path("a.c"),
1398            &path("nixpkgs"),
1399            Some(&edge)
1400        ));
1401        assert!(!matches_excluded(&path("a.b"), &path("nixpkgs"), None));
1402    }
1403
1404    #[test]
1405    fn splice_suffix_appends_segments_in_order() {
1406        let spliced = splice_suffix(&path("c"), &[seg("x"), seg("y")]);
1407        assert_eq!(spliced, path("c.x.y"));
1408        let empty = splice_suffix(&path("c"), &[]);
1409        assert_eq!(empty, path("c"));
1410    }
1411
1412    #[test]
1413    fn direct_hop_targets_lists_outgoing_follows() {
1414        let mut g = FollowsGraph::default();
1415        g.insert_edge(declared_edge("a.b", "nixpkgs"));
1416        g.insert_edge(declared_edge("a.b", "flake-utils"));
1417        let mut got = g.direct_hop_targets(&path("a.b"), None);
1418        got.sort();
1419        assert_eq!(got, vec![path("flake-utils"), path("nixpkgs")]);
1420    }
1421
1422    #[test]
1423    fn direct_hop_targets_includes_edges_at_descendant_sources() {
1424        let mut g = FollowsGraph::default();
1425        g.insert_edge(declared_edge("a.b.c", "nixpkgs"));
1426        let got = g.direct_hop_targets(&path("a"), None);
1427        assert_eq!(got, vec![path("nixpkgs")]);
1428    }
1429
1430    #[test]
1431    fn direct_hop_targets_drops_excluded_edge() {
1432        let mut g = FollowsGraph::default();
1433        let candidate = declared_edge("a.b", "nixpkgs");
1434        g.insert_edge(candidate.clone());
1435        g.insert_edge(declared_edge("a.b", "flake-utils"));
1436        let got = g.direct_hop_targets(&path("a.b"), Some(&candidate));
1437        assert_eq!(got, vec![path("flake-utils")]);
1438    }
1439
1440    #[test]
1441    fn extra_edge_targets_matches_exact_source_or_prefix() {
1442        let extra = vec![
1443            (path("a.b"), path("dst1")),
1444            (path("a.b.x"), path("dst2")),
1445            (path("c"), path("dst3")),
1446        ];
1447        let mut got = FollowsGraph::extra_edge_targets(&path("a.b"), &extra, None);
1448        got.sort();
1449        assert_eq!(got, vec![path("dst1"), path("dst2")]);
1450    }
1451
1452    #[test]
1453    fn extra_edge_targets_drops_excluded_pair() {
1454        let extra = vec![
1455            (path("a.b"), path("nixpkgs")),
1456            (path("a.b"), path("flake-utils")),
1457        ];
1458        let exclude = declared_edge("a.b", "nixpkgs");
1459        let got = FollowsGraph::extra_edge_targets(&path("a.b"), &extra, Some(&exclude));
1460        assert_eq!(got, vec![path("flake-utils")]);
1461    }
1462
1463    #[test]
1464    fn ancestor_rewrite_targets_splices_outgoing_suffix() {
1465        let mut g = FollowsGraph::default();
1466        g.insert_edge(declared_edge("a.b", "c"));
1467        let got = g.ancestor_rewrite_targets(&path("a.b.x.y"), &[], None);
1468        assert_eq!(got, vec![path("c.x.y")]);
1469    }
1470
1471    #[test]
1472    fn ancestor_rewrite_targets_uses_extra_edges_at_ancestor() {
1473        let g = FollowsGraph::default();
1474        let extra = vec![(path("a.b"), path("c"))];
1475        let got = g.ancestor_rewrite_targets(&path("a.b.x"), &extra, None);
1476        assert_eq!(got, vec![path("c.x")]);
1477    }
1478
1479    #[test]
1480    fn ancestor_rewrite_targets_walks_to_grandparent() {
1481        let mut g = FollowsGraph::default();
1482        g.insert_edge(declared_edge("a", "z"));
1483        let got = g.ancestor_rewrite_targets(&path("a.b.c"), &[], None);
1484        assert_eq!(got, vec![path("z.b.c")]);
1485    }
1486
1487    #[test]
1488    fn ancestor_rewrite_targets_skips_excluded_ancestor_edge() {
1489        let mut g = FollowsGraph::default();
1490        let exclude = declared_edge("a.b", "c");
1491        g.insert_edge(exclude.clone());
1492        g.insert_edge(declared_edge("a.b", "d"));
1493        let got = g.ancestor_rewrite_targets(&path("a.b.x"), &[], Some(&exclude));
1494        assert_eq!(got, vec![path("d.x")]);
1495    }
1496}