1use 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
13pub const DEFAULT_MAX_DEPTH: usize = 64;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum EdgeOrigin {
20 Declared {
22 range: Range,
26 },
27 Resolved,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Edge {
36 pub source: AttrPath,
39 pub follows: AttrPath,
41 pub origin: EdgeOrigin,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct StaleLockDeclaration<'a> {
50 pub declared: &'a Edge,
52 pub lock_target: Option<AttrPath>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Cycle {
60 pub edges: Vec<Edge>,
63}
64
65#[derive(Debug, Clone)]
69pub struct FollowsGraph {
70 edges: HashMap<AttrPath, Vec<Edge>>,
71 resolved_universe: HashSet<AttrPath>,
76 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 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 pub fn from_lock(lock: &FlakeLock) -> Self {
116 Self::from_nested_inputs(&lock.nested_inputs())
117 }
118
119 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 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 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 #[must_use]
207 pub fn with_max_depth(mut self, max: usize) -> Self {
208 self.max_depth = max;
209 self
210 }
211
212 pub fn outgoing(&self, src: &AttrPath) -> &[Edge] {
214 self.edges.get(src).map(Vec::as_slice).unwrap_or(&[])
215 }
216
217 pub fn edges(&self) -> impl Iterator<Item = &Edge> {
222 self.edges.values().flat_map(|v| v.iter())
223 }
224
225 pub fn declared_edges(&self) -> impl Iterator<Item = &Edge> {
227 self.edges()
228 .filter(|e| matches!(e.origin, EdgeOrigin::Declared { .. }))
229 }
230
231 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 pub fn declared_nulled(&self) -> &HashSet<AttrPath> {
251 &self.declared_nulled_sources
252 }
253
254 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 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 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 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 pub fn would_create_cycle(&self, proposed: &Edge) -> bool {
364 if is_one_step_cycle(proposed) {
365 return true;
366 }
367 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 return false;
411 }
412 if node.is_prefix_of(target) {
417 return true;
418 }
419 on_stack.insert(node.clone());
420 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 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 pub fn drop_edges_with_sources(&mut self, sources: &[AttrPath]) {
464 for src in sources {
465 self.edges.remove(src);
466 }
467 }
468
469 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 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 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 None => {
634 graph.declared_nulled_sources.insert(source);
635 }
636 }
637 }
638 }
639}
640
641fn is_one_step_cycle(edge: &Edge) -> bool {
643 edge.source == edge.follows
644}
645
646pub 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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}