use std::collections::{HashMap, HashSet};
use crate::edit::InputMap;
use crate::follows::{AttrPath, Segment};
use crate::input::{Follows, Input, Range};
use crate::lock::{FlakeLock, NestedInput};
pub const DEFAULT_MAX_DEPTH: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EdgeOrigin {
Declared {
range: Range,
},
Resolved,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edge {
pub source: AttrPath,
pub follows: AttrPath,
pub origin: EdgeOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StaleLockDeclaration<'a> {
pub declared: &'a Edge,
pub lock_target: Option<AttrPath>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cycle {
pub edges: Vec<Edge>,
}
#[derive(Debug, Clone)]
pub struct FollowsGraph {
edges: HashMap<AttrPath, Vec<Edge>>,
resolved_universe: HashSet<AttrPath>,
declared_nulled_sources: HashSet<AttrPath>,
max_depth: usize,
}
impl Default for FollowsGraph {
fn default() -> Self {
FollowsGraph {
edges: HashMap::new(),
resolved_universe: HashSet::new(),
declared_nulled_sources: HashSet::new(),
max_depth: DEFAULT_MAX_DEPTH,
}
}
}
impl FollowsGraph {
pub fn from_declared(inputs: &InputMap) -> Self {
let mut graph = FollowsGraph {
max_depth: DEFAULT_MAX_DEPTH,
..FollowsGraph::default()
};
for input in inputs.values() {
collect_declared_edges(input, &mut graph);
}
graph
}
pub fn from_lock(lock: &FlakeLock) -> Self {
Self::from_nested_inputs(&lock.nested_inputs())
}
pub fn from_nested_inputs(nested_inputs: &[NestedInput]) -> Self {
let mut graph = FollowsGraph {
max_depth: DEFAULT_MAX_DEPTH,
..FollowsGraph::default()
};
for nested in nested_inputs {
graph.resolved_universe.insert(nested.path.clone());
if let Some(target) = nested.follows.clone() {
graph.insert_edge(Edge {
source: nested.path.clone(),
follows: target,
origin: EdgeOrigin::Resolved,
});
}
}
graph
}
pub fn from_flake(inputs: &InputMap, lock: &FlakeLock) -> Self {
let mut graph = FollowsGraph::from_declared(inputs);
for nested in lock.nested_inputs() {
graph.resolved_universe.insert(nested.path.clone());
if let Some(target) = nested.follows {
let already = graph
.outgoing(&nested.path)
.iter()
.any(|e| e.follows == target);
if already {
continue;
}
graph.insert_edge(Edge {
source: nested.path.clone(),
follows: target,
origin: EdgeOrigin::Resolved,
});
}
}
graph
}
pub(crate) fn from_declared_and_lock_graph(
inputs: &InputMap,
lock_graph: &FollowsGraph,
) -> Self {
let mut graph = FollowsGraph::from_declared(inputs);
graph
.resolved_universe
.extend(lock_graph.resolved_universe.iter().cloned());
for edges in lock_graph.edges.values() {
for edge in edges {
let already = graph
.outgoing(&edge.source)
.iter()
.any(|e| e.follows == edge.follows);
if !already {
graph.insert_edge(edge.clone());
}
}
}
graph
}
#[must_use]
pub fn with_max_depth(mut self, max: usize) -> Self {
self.max_depth = max;
self
}
pub fn outgoing(&self, src: &AttrPath) -> &[Edge] {
self.edges.get(src).map(Vec::as_slice).unwrap_or(&[])
}
pub fn edges(&self) -> impl Iterator<Item = &Edge> {
self.edges.values().flat_map(|v| v.iter())
}
pub fn declared_edges(&self) -> impl Iterator<Item = &Edge> {
self.edges()
.filter(|e| matches!(e.origin, EdgeOrigin::Declared { .. }))
}
pub fn declared_sources(&self) -> HashSet<AttrPath> {
let mut out: HashSet<AttrPath> = self.declared_edges().map(|e| e.source.clone()).collect();
out.extend(self.declared_nulled_sources.iter().cloned());
out
}
pub fn declared_nulled(&self) -> &HashSet<AttrPath> {
&self.declared_nulled_sources
}
pub fn cycles(&self) -> Vec<Cycle> {
let mut found: Vec<Cycle> = Vec::new();
let mut seen_keys: HashSet<(AttrPath, AttrPath)> = HashSet::new();
let mut declared: Vec<&Edge> = self.declared_edges().collect();
declared.sort_by(|a, b| a.source.cmp(&b.source).then(a.follows.cmp(&b.follows)));
for edge in declared {
if !is_one_step_cycle(edge) {
continue;
}
let key = (edge.source.clone(), edge.follows.clone());
if seen_keys.insert(key) {
found.push(Cycle {
edges: vec![edge.clone()],
});
}
}
found
}
pub fn stale_edges(&self) -> Vec<&Edge> {
let mut declared: Vec<&Edge> = self
.declared_edges()
.filter(|e| !self.resolved_universe.contains(&e.source))
.collect();
declared.sort_by(|a, b| a.source.cmp(&b.source));
declared
}
pub fn stale_nulled_sources(&self) -> Vec<&AttrPath> {
let mut sources: Vec<&AttrPath> = self
.declared_nulled_sources
.iter()
.filter(|p| !self.resolved_universe.contains(*p))
.collect();
sources.sort();
sources
}
pub fn stale_lock_declarations<'a>(
&'a self,
nested_inputs: &[NestedInput],
) -> Vec<StaleLockDeclaration<'a>> {
let lock_targets: HashMap<&AttrPath, &Option<AttrPath>> = nested_inputs
.iter()
.map(|n| (&n.path, &n.follows))
.collect();
let mut out: Vec<StaleLockDeclaration<'a>> = Vec::new();
for edge in self.declared_edges() {
let Some(lock_target) = lock_targets.get(&edge.source) else {
continue;
};
let diverges = match lock_target {
Some(target) => target != &edge.follows,
None => true,
};
if diverges {
out.push(StaleLockDeclaration {
declared: edge,
lock_target: (*lock_target).clone(),
});
}
}
out.sort_by(|a, b| a.declared.source.cmp(&b.declared.source));
out
}
pub fn would_create_cycle(&self, proposed: &Edge) -> bool {
if is_one_step_cycle(proposed) {
return true;
}
let target_first = proposed.follows.first();
let mut ancestor: Option<AttrPath> = proposed.source.parent();
while let Some(a) = ancestor {
if a.last() == target_first {
return true;
}
ancestor = a.parent();
}
let mut visited: HashSet<AttrPath> = HashSet::new();
let mut on_stack: HashSet<AttrPath> = HashSet::new();
self.dfs_reaches(
&proposed.follows,
&proposed.source,
0,
&mut visited,
&mut on_stack,
)
}
fn dfs_reaches(
&self,
node: &AttrPath,
target: &AttrPath,
depth: usize,
visited: &mut HashSet<AttrPath>,
on_stack: &mut HashSet<AttrPath>,
) -> bool {
if depth >= self.max_depth {
return false;
}
if node == target {
return true;
}
if visited.contains(node) {
return false;
}
if on_stack.contains(node) {
return false;
}
if node.is_prefix_of(target) {
return true;
}
on_stack.insert(node.clone());
for edge in self.expanded_outgoing(node) {
if self.dfs_reaches(&edge.follows, target, depth + 1, visited, on_stack) {
on_stack.remove(node);
visited.insert(node.clone());
return true;
}
}
on_stack.remove(node);
visited.insert(node.clone());
false
}
fn expanded_outgoing(&self, node: &AttrPath) -> Vec<&Edge> {
let mut out: Vec<&Edge> = Vec::new();
for (source, edges) in &self.edges {
if source == node || node.is_prefix_of(source) {
out.extend(edges.iter());
}
}
out
}
fn insert_edge(&mut self, edge: Edge) {
self.edges
.entry(edge.source.clone())
.or_default()
.push(edge);
}
pub fn drop_edges_with_sources(&mut self, sources: &[AttrPath]) {
for src in sources {
self.edges.remove(src);
}
}
pub fn lock_routes_to(
&self,
source: &AttrPath,
target: &AttrPath,
exclude: Option<&Edge>,
extra_edges: &[(AttrPath, AttrPath)],
) -> bool {
let mut visited: HashSet<AttrPath> = HashSet::new();
let mut on_stack: HashSet<AttrPath> = HashSet::new();
self.dfs_routes_to(
source,
target,
exclude,
extra_edges,
0,
&mut visited,
&mut on_stack,
)
}
#[expect(clippy::too_many_arguments)]
fn dfs_routes_to(
&self,
node: &AttrPath,
target: &AttrPath,
exclude: Option<&Edge>,
extra_edges: &[(AttrPath, AttrPath)],
depth: usize,
visited: &mut HashSet<AttrPath>,
on_stack: &mut HashSet<AttrPath>,
) -> bool {
if depth >= self.max_depth {
return false;
}
if node == target {
return true;
}
if visited.contains(node) || on_stack.contains(node) {
return false;
}
if node.is_prefix_of(target) {
return true;
}
on_stack.insert(node.clone());
let mut candidates = self.direct_hop_targets(node, exclude);
candidates.extend(Self::extra_edge_targets(node, extra_edges, exclude));
candidates.extend(self.ancestor_rewrite_targets(node, extra_edges, exclude));
for next in candidates {
if self.dfs_routes_to(
&next,
target,
exclude,
extra_edges,
depth + 1,
visited,
on_stack,
) {
on_stack.remove(node);
visited.insert(node.clone());
return true;
}
}
on_stack.remove(node);
visited.insert(node.clone());
false
}
fn direct_hop_targets(&self, node: &AttrPath, exclude: Option<&Edge>) -> Vec<AttrPath> {
self.expanded_outgoing(node)
.into_iter()
.filter(|edge| !matches_excluded(&edge.source, &edge.follows, exclude))
.map(|edge| edge.follows.clone())
.collect()
}
fn extra_edge_targets(
node: &AttrPath,
extra_edges: &[(AttrPath, AttrPath)],
exclude: Option<&Edge>,
) -> Vec<AttrPath> {
extra_edges
.iter()
.filter(|(src, _)| src == node || node.is_prefix_of(src))
.filter(|(src, dst)| !matches_excluded(src, dst, exclude))
.map(|(_, dst)| dst.clone())
.collect()
}
fn ancestor_rewrite_targets(
&self,
node: &AttrPath,
extra_edges: &[(AttrPath, AttrPath)],
exclude: Option<&Edge>,
) -> Vec<AttrPath> {
let mut out: Vec<AttrPath> = Vec::new();
let mut ancestor = node.parent();
while let Some(anc) = ancestor.clone() {
let suffix = &node.segments()[anc.len()..];
for edge in self.outgoing(&anc) {
if matches_excluded(&edge.source, &edge.follows, exclude) {
continue;
}
out.push(splice_suffix(&edge.follows, suffix));
}
for (src, dst) in extra_edges {
if src != &anc || matches_excluded(src, dst, exclude) {
continue;
}
out.push(splice_suffix(dst, suffix));
}
ancestor = anc.parent();
}
out
}
}
fn matches_excluded(source: &AttrPath, follows: &AttrPath, exclude: Option<&Edge>) -> bool {
matches!(exclude, Some(e) if &e.source == source && &e.follows == follows)
}
fn splice_suffix(base: &AttrPath, suffix: &[Segment]) -> AttrPath {
let mut out = base.clone();
for seg in suffix {
out.push(seg.clone());
}
out
}
fn collect_declared_edges(input: &Input, graph: &mut FollowsGraph) {
for follows in input.follows() {
if let Follows::Indirect { path, target } = follows {
let mut source = AttrPath::new(input.id().clone());
for seg in path.segments() {
source.push(seg.clone());
}
match target {
Some(target) => {
graph.insert_edge(Edge {
source,
follows: target.clone(),
origin: EdgeOrigin::Declared {
range: input.range.clone(),
},
});
}
None => {
graph.declared_nulled_sources.insert(source);
}
}
}
}
}
fn is_one_step_cycle(edge: &Edge) -> bool {
edge.source == edge.follows
}
pub fn is_follows_reference_to_parent(url: &str, parent: &str) -> bool {
url.starts_with(&format!("{parent}/"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::follows::Segment;
use crate::input::Range;
fn seg(s: &str) -> Segment {
Segment::from_unquoted(s).unwrap()
}
fn path(s: &str) -> AttrPath {
AttrPath::parse(s).unwrap()
}
fn declared_input(id: &str, follows: &[(&str, &str)]) -> Input {
let mut input = Input::new(seg(id));
for (parent, target) in follows {
input.follows.push(Follows::Indirect {
path: AttrPath::new(seg(parent)),
target: Some(path(target)),
});
}
input.range = Range { start: 1, end: 2 };
input
}
fn make_inputs(items: Vec<Input>) -> InputMap {
let mut map = InputMap::new();
for input in items {
map.insert(input.id().as_str().to_string(), input);
}
map
}
fn declared_edge(source: &str, follows: &str) -> Edge {
Edge {
source: path(source),
follows: path(follows),
origin: EdgeOrigin::Declared {
range: Range { start: 0, end: 0 },
},
}
}
#[test]
fn from_declared_emits_one_edge_per_indirect() {
let inputs = make_inputs(vec![declared_input(
"crane",
&[("nixpkgs", "nixpkgs"), ("flake-utils", "flake-utils")],
)]);
let g = FollowsGraph::from_declared(&inputs);
let mut got: Vec<(String, String)> = g
.edges()
.map(|e| (e.source.to_string(), e.follows.to_string()))
.collect();
got.sort();
assert_eq!(
got,
vec![
("crane.flake-utils".to_string(), "flake-utils".to_string()),
("crane.nixpkgs".to_string(), "nixpkgs".to_string()),
]
);
}
#[test]
fn from_declared_marks_origin_declared() {
let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
let g = FollowsGraph::from_declared(&inputs);
let edge = g.edges().next().unwrap();
assert!(matches!(edge.origin, EdgeOrigin::Declared { .. }));
}
#[test]
fn from_lock_picks_up_nested_follows() {
let lock_text = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"treefmt-nix": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "treefmt-nix": "treefmt-nix" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_lock(&lock);
let edges: Vec<&Edge> = g.edges().collect();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].source.to_string(), "treefmt-nix.nixpkgs");
assert_eq!(edges[0].follows.to_string(), "nixpkgs");
assert!(matches!(edges[0].origin, EdgeOrigin::Resolved));
}
#[test]
fn outgoing_returns_only_matching_source() {
let inputs = make_inputs(vec![
declared_input("crane", &[("nixpkgs", "nixpkgs")]),
declared_input("flake-utils", &[("nixpkgs", "nixpkgs")]),
]);
let g = FollowsGraph::from_declared(&inputs);
let crane_out = g.outgoing(&path("crane.nixpkgs"));
assert_eq!(crane_out.len(), 1);
assert_eq!(crane_out[0].follows.to_string(), "nixpkgs");
assert!(g.outgoing(&path("nonexistent.nixpkgs")).is_empty());
}
#[test]
fn stale_edges_flags_declared_without_resolved() {
let inputs = make_inputs(vec![declared_input(
"home-manager",
&[("nixpkgs", "nixpkgs")],
)]);
let lock_text = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"home-manager": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "home-manager": "home-manager" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_flake(&inputs, &lock);
let stale: Vec<&Edge> = g.stale_edges();
assert_eq!(stale.len(), 1);
assert_eq!(stale[0].source.to_string(), "home-manager.nixpkgs");
}
#[test]
fn stale_nulled_sources_flags_nulled_without_resolved() {
let mut input = Input::new(seg("home-manager"));
input.follows.push(Follows::Indirect {
path: AttrPath::new(seg("nixpkgs")),
target: None,
});
input.range = Range { start: 1, end: 2 };
let inputs = make_inputs(vec![input]);
let lock_text = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"home-manager": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "home-manager": "home-manager" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_flake(&inputs, &lock);
let stale: Vec<String> = g
.stale_nulled_sources()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(stale, vec!["home-manager.nixpkgs"]);
}
#[test]
fn stale_nulled_sources_quiet_when_lock_has_empty_indirect() {
let mut input = Input::new(seg("home-manager"));
input.follows.push(Follows::Indirect {
path: AttrPath::new(seg("nixpkgs")),
target: None,
});
input.range = Range { start: 1, end: 2 };
let inputs = make_inputs(vec![input]);
let lock_text = r#"{
"nodes": {
"home-manager": {
"inputs": { "nixpkgs": [] },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "home-manager": "home-manager" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_flake(&inputs, &lock);
assert!(g.stale_nulled_sources().is_empty());
}
#[test]
fn self_named_three_segment_declared_round_trips_with_lock() {
let mut input = Input::new(seg("agenix"));
input.follows.push(Follows::Indirect {
path: AttrPath::parse("agenix.systems").unwrap(),
target: Some(path("systems")),
});
input.range = Range { start: 1, end: 2 };
let inputs = make_inputs(vec![input, declared_input("systems", &[])]);
let lock_text = r#"{
"nodes": {
"agenix": {
"inputs": { "agenix": "agenix_2" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "aaa", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"agenix_2": {
"inputs": { "systems": "systems_2" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "bbb", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"systems": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ccc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"systems_2": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "agenix": "agenix", "systems": "systems" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_flake(&inputs, &lock);
let declared: Vec<&Edge> = g.declared_edges().collect();
assert_eq!(declared.len(), 1);
assert_eq!(declared[0].source.to_string(), "agenix.agenix.systems");
let stale: Vec<&Edge> = g.stale_edges();
assert!(
stale.is_empty(),
"self-named 3-seg declared edge must not be flagged stale, got: {:?}",
stale
.iter()
.map(|e| e.source.to_string())
.collect::<Vec<_>>()
);
}
#[test]
fn would_create_cycle_self_edge() {
let g = FollowsGraph::default();
let e = declared_edge("nixpkgs", "nixpkgs");
assert!(g.would_create_cycle(&e));
}
#[test]
fn would_create_cycle_dot_named_ancestor() {
let g = FollowsGraph::default();
let e = Edge {
source: AttrPath::parse("\"hls-1.10\".nixpkgs").unwrap(),
follows: AttrPath::parse("\"hls-1.10\"").unwrap(),
origin: EdgeOrigin::Declared {
range: Range { start: 0, end: 0 },
},
};
assert!(g.would_create_cycle(&e));
}
#[test]
fn would_create_cycle_no_cycle_for_distinct_target() {
let g = FollowsGraph::default();
let e = declared_edge("crane.nixpkgs", "nixpkgs");
assert!(!g.would_create_cycle(&e));
}
#[test]
fn would_create_cycle_ignores_unrelated_ancestor() {
let g = FollowsGraph::default();
let e = declared_edge("a.b.c", "d");
assert!(!g.would_create_cycle(&e));
}
#[test]
fn would_create_cycle_multi_hop_declared() {
let mut g = FollowsGraph::default();
g.insert_edge(declared_edge("a", "b"));
g.insert_edge(declared_edge("b", "c"));
let proposed = declared_edge("c", "a");
assert!(g.would_create_cycle(&proposed));
}
#[test]
fn would_create_cycle_multi_hop_dot_named() {
let mut g = FollowsGraph::default();
g.insert_edge(declared_edge("\"hls-1.10\"", "b"));
g.insert_edge(declared_edge("b", "c"));
let proposed = declared_edge("c", "\"hls-1.10\"");
assert!(g.would_create_cycle(&proposed));
}
#[test]
fn would_create_cycle_lockfile_only() {
let mut g = FollowsGraph::default();
g.insert_edge(Edge {
source: AttrPath::parse("treefmt-nix.nixpkgs").unwrap(),
follows: AttrPath::parse("harmonia.treefmt-nix").unwrap(),
origin: EdgeOrigin::Resolved,
});
g.insert_edge(Edge {
source: AttrPath::parse("harmonia.treefmt-nix").unwrap(),
follows: AttrPath::parse("treefmt-nix").unwrap(),
origin: EdgeOrigin::Resolved,
});
let proposed = Edge {
source: AttrPath::parse("treefmt-nix").unwrap(),
follows: AttrPath::parse("treefmt-nix.nixpkgs").unwrap(),
origin: EdgeOrigin::Declared {
range: Range { start: 0, end: 0 },
},
};
assert!(g.would_create_cycle(&proposed));
}
#[test]
fn would_create_cycle_terminates_on_existing_cycle() {
let mut g = FollowsGraph::default();
g.insert_edge(declared_edge("x", "x"));
g.insert_edge(declared_edge("a", "b"));
g.insert_edge(declared_edge("b", "c"));
let proposed = declared_edge("c", "a");
assert!(g.would_create_cycle(&proposed));
}
#[test]
fn would_create_cycle_bounded_by_max_depth() {
let mut g = FollowsGraph::default().with_max_depth(2);
g.insert_edge(declared_edge("a", "b"));
g.insert_edge(declared_edge("b", "c"));
g.insert_edge(declared_edge("c", "d"));
let proposed = declared_edge("d", "a");
assert!(!g.would_create_cycle(&proposed));
}
#[test]
fn drop_edges_with_sources_removes_only_listed_sources() {
let mut g = FollowsGraph::default();
g.insert_edge(declared_edge("crane.nixpkgs", "nixpkgs"));
g.insert_edge(declared_edge("crane.flake-utils", "flake-utils"));
g.insert_edge(declared_edge("treefmt-nix.nixpkgs", "nixpkgs"));
g.drop_edges_with_sources(&[path("crane.nixpkgs")]);
let mut remaining: Vec<String> = g.edges().map(|e| e.source.to_string()).collect();
remaining.sort();
assert_eq!(
remaining,
vec![
"crane.flake-utils".to_string(),
"treefmt-nix.nixpkgs".to_string(),
],
"only the listed source should be dropped; the rest survive intact"
);
}
#[test]
fn drop_edges_with_sources_clears_cycle_through_dropped_edge() {
let mut g = FollowsGraph::default();
g.insert_edge(declared_edge("X.Y", "Y"));
g.insert_edge(declared_edge("Y.Z", "Z"));
let proposed = declared_edge("Z.X", "X");
assert!(g.would_create_cycle(&proposed));
g.drop_edges_with_sources(&[path("X.Y")]);
assert!(!g.would_create_cycle(&proposed));
}
#[test]
fn cycles_finds_self_referential_declared_edge() {
let mut inputs = InputMap::new();
let mut input = Input::new(seg("foo"));
input.follows.push(Follows::Indirect {
path: AttrPath::new(seg("foo")),
target: Some(AttrPath::parse("foo.foo").unwrap()),
});
input.range = Range { start: 1, end: 2 };
inputs.insert("foo".into(), input);
let g = FollowsGraph::from_declared(&inputs);
let cycles = g.cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].edges.len(), 1);
assert_eq!(cycles[0].edges[0].source.to_string(), "foo.foo");
}
#[test]
fn cycles_empty_for_acyclic_declared() {
let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
let g = FollowsGraph::from_declared(&inputs);
assert!(g.cycles().is_empty());
}
#[test]
fn is_follows_reference_to_parent_url_prefix() {
assert!(is_follows_reference_to_parent(
"clan-core/treefmt-nix",
"clan-core"
));
assert!(!is_follows_reference_to_parent("github:nixos/nixpkgs", "x"));
assert!(!is_follows_reference_to_parent(
"clan-core-extended",
"clan-core"
));
}
#[test]
fn with_max_depth_overrides_default() {
let g = FollowsGraph::default().with_max_depth(7);
assert_eq!(g.max_depth, 7);
}
#[test]
fn stale_lock_declarations_detects_target_mismatch() {
let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
let lock_text = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"nixpkgs_2": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"crane": {
"inputs": { "nixpkgs": ["nixpkgs_2"] },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ggg", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "crane": "crane" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_flake(&inputs, &lock);
let overridden = g.stale_lock_declarations(&lock.nested_inputs());
assert_eq!(overridden.len(), 1);
assert_eq!(overridden[0].declared.source.to_string(), "crane.nixpkgs");
assert_eq!(overridden[0].declared.follows.to_string(), "nixpkgs");
assert_eq!(
overridden[0].lock_target.as_ref().map(|p| p.to_string()),
Some("nixpkgs_2".to_string())
);
}
#[test]
fn stale_lock_declarations_detects_missing_follows() {
let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
let lock_text = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"nixpkgs_2": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"crane": {
"inputs": { "nixpkgs": "nixpkgs_2" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ggg", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "crane": "crane" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_flake(&inputs, &lock);
let overridden = g.stale_lock_declarations(&lock.nested_inputs());
assert_eq!(overridden.len(), 1);
assert_eq!(overridden[0].declared.source.to_string(), "crane.nixpkgs");
assert!(overridden[0].lock_target.is_none());
}
#[test]
fn stale_lock_declarations_quiet_when_in_sync() {
let inputs = make_inputs(vec![declared_input("crane", &[("nixpkgs", "nixpkgs")])]);
let lock_text = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"crane": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ggg", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "crane": "crane" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_flake(&inputs, &lock);
assert!(g.stale_lock_declarations(&lock.nested_inputs()).is_empty());
}
#[test]
fn stale_lock_declarations_orthogonal_to_stale() {
let inputs = make_inputs(vec![declared_input(
"home-manager",
&[("nixpkgs", "nixpkgs")],
)]);
let lock_text = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"home-manager": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "ddd", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "home-manager": "home-manager" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_flake(&inputs, &lock);
assert_eq!(g.stale_edges().len(), 1);
assert!(g.stale_lock_declarations(&lock.nested_inputs()).is_empty());
}
#[test]
fn merged_prefers_declared_over_resolved() {
let inputs = make_inputs(vec![declared_input(
"treefmt-nix",
&[("nixpkgs", "nixpkgs")],
)]);
let lock_text = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"treefmt-nix": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "treefmt-nix": "treefmt-nix" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::read_from_str(lock_text).unwrap();
let g = FollowsGraph::from_flake(&inputs, &lock);
let edges: Vec<&Edge> = g.outgoing(&path("treefmt-nix.nixpkgs")).iter().collect();
assert_eq!(edges.len(), 1);
assert!(matches!(edges[0].origin, EdgeOrigin::Declared { .. }));
}
#[test]
fn lock_routes_to_chain_through_user_depth1() {
let mut g = FollowsGraph::default();
g.insert_edge(Edge {
source: path("hyprland.aquamarine.nixpkgs"),
follows: path("hyprland.nixpkgs"),
origin: EdgeOrigin::Resolved,
});
g.insert_edge(declared_edge("hyprland.nixpkgs", "nixpkgs"));
assert!(g.lock_routes_to(
&path("hyprland.aquamarine.nixpkgs"),
&path("nixpkgs"),
None,
&[],
));
}
#[test]
fn lock_routes_to_excludes_candidate_edge() {
let mut g = FollowsGraph::default();
g.insert_edge(Edge {
source: path("hyprland.aquamarine.nixpkgs"),
follows: path("hyprland.nixpkgs"),
origin: EdgeOrigin::Resolved,
});
g.insert_edge(declared_edge("hyprland.nixpkgs", "nixpkgs"));
let candidate = declared_edge("hyprland.aquamarine.nixpkgs", "nixpkgs");
g.insert_edge(candidate.clone());
assert!(g.lock_routes_to(&candidate.source, &candidate.follows, Some(&candidate), &[],));
}
#[test]
fn lock_routes_to_no_other_path_returns_false_when_candidate_excluded() {
let mut g = FollowsGraph::default();
let candidate = declared_edge("a.b", "nixpkgs");
g.insert_edge(candidate.clone());
assert!(!g.lock_routes_to(&candidate.source, &candidate.follows, Some(&candidate), &[]));
}
#[test]
fn lock_routes_to_no_route_returns_false() {
let g = FollowsGraph::default();
assert!(!g.lock_routes_to(&path("a.b.c"), &path("nixpkgs"), None, &[]));
}
#[test]
fn lock_routes_to_deep_upstream_chain() {
let mut g = FollowsGraph::default();
for (src, dst) in [("a.b.c.d", "a.b.c"), ("a.b.c", "a.b"), ("a.b", "a")] {
g.insert_edge(Edge {
source: path(src),
follows: path(dst),
origin: EdgeOrigin::Resolved,
});
}
assert!(g.lock_routes_to(&path("a.b.c.d"), &path("a"), None, &[]));
}
#[test]
fn lock_routes_to_drives_change_remove_at_depth_three() {
use crate::change::{Change, ChangeId};
use crate::edit::FlakeEdit;
let mut g = FollowsGraph::default();
for (src, dst) in [
("omnibus.flops.POP.nixpkgs", "omnibus.flops.nixpkgs"),
("omnibus.flops.nixpkgs", "omnibus.nixpkgs"),
("omnibus.nixpkgs", "nixpkgs"),
] {
g.insert_edge(Edge {
source: path(src),
follows: path(dst),
origin: EdgeOrigin::Resolved,
});
}
let candidate = declared_edge("omnibus.flops.POP.nixpkgs", "nixpkgs");
g.insert_edge(candidate.clone());
assert!(
g.lock_routes_to(&candidate.source, &candidate.follows, Some(&candidate), &[]),
"depth-3 chain should be predicted as covered by upstream propagation"
);
let flake = r#"{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
omnibus.url = "github:Lehmanator/nix-configs";
omnibus.inputs.nixpkgs.follows = "nixpkgs";
omnibus.inputs.flops.inputs.POP.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, ... }: { };
}
"#;
let mut fe = FlakeEdit::from_text(flake).expect("parses");
let change = Change::Remove {
ids: vec![ChangeId::new(candidate.source.clone())],
};
let outcome = fe.apply_change(change).expect("apply succeeds");
let new_text = outcome.text.expect("walker rewrote the tree");
assert!(
!new_text.contains("flops"),
"depth-3 redundant follows should be removed, got:\n{new_text}"
);
assert!(
new_text.contains("omnibus.inputs.nixpkgs.follows = \"nixpkgs\""),
"load-bearing depth-1 follows must remain, got:\n{new_text}"
);
}
#[test]
fn matches_excluded_compares_source_and_follows() {
let edge = declared_edge("a.b", "nixpkgs");
assert!(matches_excluded(
&path("a.b"),
&path("nixpkgs"),
Some(&edge)
));
assert!(!matches_excluded(&path("a.b"), &path("other"), Some(&edge)));
assert!(!matches_excluded(
&path("a.c"),
&path("nixpkgs"),
Some(&edge)
));
assert!(!matches_excluded(&path("a.b"), &path("nixpkgs"), None));
}
#[test]
fn splice_suffix_appends_segments_in_order() {
let spliced = splice_suffix(&path("c"), &[seg("x"), seg("y")]);
assert_eq!(spliced, path("c.x.y"));
let empty = splice_suffix(&path("c"), &[]);
assert_eq!(empty, path("c"));
}
#[test]
fn direct_hop_targets_lists_outgoing_follows() {
let mut g = FollowsGraph::default();
g.insert_edge(declared_edge("a.b", "nixpkgs"));
g.insert_edge(declared_edge("a.b", "flake-utils"));
let mut got = g.direct_hop_targets(&path("a.b"), None);
got.sort();
assert_eq!(got, vec![path("flake-utils"), path("nixpkgs")]);
}
#[test]
fn direct_hop_targets_includes_edges_at_descendant_sources() {
let mut g = FollowsGraph::default();
g.insert_edge(declared_edge("a.b.c", "nixpkgs"));
let got = g.direct_hop_targets(&path("a"), None);
assert_eq!(got, vec![path("nixpkgs")]);
}
#[test]
fn direct_hop_targets_drops_excluded_edge() {
let mut g = FollowsGraph::default();
let candidate = declared_edge("a.b", "nixpkgs");
g.insert_edge(candidate.clone());
g.insert_edge(declared_edge("a.b", "flake-utils"));
let got = g.direct_hop_targets(&path("a.b"), Some(&candidate));
assert_eq!(got, vec![path("flake-utils")]);
}
#[test]
fn extra_edge_targets_matches_exact_source_or_prefix() {
let extra = vec![
(path("a.b"), path("dst1")),
(path("a.b.x"), path("dst2")),
(path("c"), path("dst3")),
];
let mut got = FollowsGraph::extra_edge_targets(&path("a.b"), &extra, None);
got.sort();
assert_eq!(got, vec![path("dst1"), path("dst2")]);
}
#[test]
fn extra_edge_targets_drops_excluded_pair() {
let extra = vec![
(path("a.b"), path("nixpkgs")),
(path("a.b"), path("flake-utils")),
];
let exclude = declared_edge("a.b", "nixpkgs");
let got = FollowsGraph::extra_edge_targets(&path("a.b"), &extra, Some(&exclude));
assert_eq!(got, vec![path("flake-utils")]);
}
#[test]
fn ancestor_rewrite_targets_splices_outgoing_suffix() {
let mut g = FollowsGraph::default();
g.insert_edge(declared_edge("a.b", "c"));
let got = g.ancestor_rewrite_targets(&path("a.b.x.y"), &[], None);
assert_eq!(got, vec![path("c.x.y")]);
}
#[test]
fn ancestor_rewrite_targets_uses_extra_edges_at_ancestor() {
let g = FollowsGraph::default();
let extra = vec![(path("a.b"), path("c"))];
let got = g.ancestor_rewrite_targets(&path("a.b.x"), &extra, None);
assert_eq!(got, vec![path("c.x")]);
}
#[test]
fn ancestor_rewrite_targets_walks_to_grandparent() {
let mut g = FollowsGraph::default();
g.insert_edge(declared_edge("a", "z"));
let got = g.ancestor_rewrite_targets(&path("a.b.c"), &[], None);
assert_eq!(got, vec![path("z.b.c")]);
}
#[test]
fn ancestor_rewrite_targets_skips_excluded_ancestor_edge() {
let mut g = FollowsGraph::default();
let exclude = declared_edge("a.b", "c");
g.insert_edge(exclude.clone());
g.insert_edge(declared_edge("a.b", "d"));
let got = g.ancestor_rewrite_targets(&path("a.b.x"), &[], Some(&exclude));
assert_eq!(got, vec![path("d.x")]);
}
}