use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum FlakeLockError {
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
#[error("unsupported lock version {found} (expected {expected})")]
UnsupportedVersion { expected: u32, found: u32 },
#[error("missing root node `{0}`")]
MissingRoot(String),
#[error("node not found: {0}")]
NodeNotFound(String),
#[error("follows resolution failed for path {path:?} starting from `{from}`")]
FollowsFailed { from: String, path: Vec<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlakeLock {
pub nodes: BTreeMap<String, FlakeNode>,
pub root: String,
pub version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlakeNode {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub inputs: BTreeMap<String, InputRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locked: Option<LockedInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original: Option<OriginalInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flake: Option<bool>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum InputRef {
Direct(String),
Follows(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockedInput {
#[serde(rename = "type")]
pub source_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(default, rename = "narHash", skip_serializing_if = "Option::is_none")]
pub nar_hash: Option<String>,
#[serde(
default,
rename = "lastModified",
skip_serializing_if = "Option::is_none"
)]
pub last_modified: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub git_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OriginalInput {
#[serde(rename = "type")]
pub source_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub git_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
const SUPPORTED_VERSION: u32 = 7;
impl FlakeLock {
pub fn parse(json: &str) -> Result<Self, FlakeLockError> {
let lock: FlakeLock = serde_json::from_str(json)?;
if lock.version != SUPPORTED_VERSION {
return Err(FlakeLockError::UnsupportedVersion {
expected: SUPPORTED_VERSION,
found: lock.version,
});
}
if !lock.nodes.contains_key(&lock.root) {
return Err(FlakeLockError::MissingRoot(lock.root.clone()));
}
Ok(lock)
}
pub fn to_json(&self) -> Result<String, FlakeLockError> {
Ok(serde_json::to_string_pretty(self)?)
}
pub fn root_node(&self) -> Result<&FlakeNode, FlakeLockError> {
self.nodes
.get(&self.root)
.ok_or_else(|| FlakeLockError::MissingRoot(self.root.clone()))
}
pub fn get_node(&self, name: &str) -> Result<&FlakeNode, FlakeLockError> {
self.nodes
.get(name)
.ok_or_else(|| FlakeLockError::NodeNotFound(name.to_string()))
}
pub fn root_inputs(&self) -> Result<Vec<(String, String)>, FlakeLockError> {
let root = self.root_node()?;
let mut out = Vec::new();
for (input_name, input_ref) in &root.inputs {
let resolved = self.resolve_ref(&self.root, input_ref)?;
out.push((input_name.clone(), resolved));
}
Ok(out)
}
pub fn resolve_ref(
&self,
_parent: &str,
input_ref: &InputRef,
) -> Result<String, FlakeLockError> {
match input_ref {
InputRef::Direct(name) => {
if self.nodes.contains_key(name) {
Ok(name.clone())
} else {
Err(FlakeLockError::NodeNotFound(name.clone()))
}
}
InputRef::Follows(path) => self.resolve_follows_path(path),
}
}
fn resolve_follows_path(&self, path: &[String]) -> Result<String, FlakeLockError> {
if path.is_empty() {
return Err(FlakeLockError::FollowsFailed {
from: self.root.clone(),
path: vec![],
});
}
let mut current_node_name = self.root.clone();
for segment in path {
let node = self.nodes.get(¤t_node_name).ok_or_else(|| {
FlakeLockError::FollowsFailed {
from: current_node_name.clone(),
path: path.to_vec(),
}
})?;
let input_ref =
node.inputs.get(segment).ok_or_else(|| FlakeLockError::FollowsFailed {
from: current_node_name.clone(),
path: path.to_vec(),
})?;
current_node_name = match input_ref {
InputRef::Direct(name) => name.clone(),
InputRef::Follows(inner_path) => self.resolve_follows_path(inner_path)?,
};
}
Ok(current_node_name)
}
pub fn resolve_input(&self, path: &[&str]) -> Result<&FlakeNode, FlakeLockError> {
let mut current_name = self.root.clone();
for &segment in path {
let node = self.nodes.get(¤t_name).ok_or_else(|| {
FlakeLockError::NodeNotFound(current_name.clone())
})?;
let input_ref = node.inputs.get(segment).ok_or_else(|| {
FlakeLockError::NodeNotFound(format!("{current_name}.inputs.{segment}"))
})?;
current_name = self.resolve_ref(¤t_name, input_ref)?;
}
self.nodes
.get(¤t_name)
.ok_or(FlakeLockError::NodeNotFound(current_name))
}
pub fn resolve_node_input(
&self,
node_name: &str,
input_name: &str,
) -> Result<String, FlakeLockError> {
let node = self
.nodes
.get(node_name)
.ok_or_else(|| FlakeLockError::NodeNotFound(node_name.to_string()))?;
let input_ref = node.inputs.get(input_name).ok_or_else(|| {
FlakeLockError::NodeNotFound(format!("{node_name}.inputs.{input_name}"))
})?;
self.resolve_ref(node_name, input_ref)
}
pub fn node_input_edges(&self, node_name: &str) -> Vec<(String, String)> {
let Some(node) = self.nodes.get(node_name) else {
return Vec::new();
};
let mut edges = Vec::new();
for input_name in node.inputs.keys() {
if let Ok(target) = self.resolve_node_input(node_name, input_name) {
edges.push((input_name.clone(), target));
}
}
edges
}
pub fn adjacency_map(&self) -> BTreeMap<String, Vec<(String, String)>> {
let mut map: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
for (node_name, node) in &self.nodes {
let mut edges = Vec::new();
for (input_name, input_ref) in &node.inputs {
if let Ok(target) = self.resolve_ref(node_name, input_ref) {
edges.push((input_name.clone(), target));
}
}
map.insert(node_name.clone(), edges);
}
map
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_lock_json() -> &'static str {
r#"{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "abc123def456abc123def456abc123def456abc1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}"#
}
fn follows_lock_json() -> &'static str {
r#"{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "abc123def456abc123def456abc123def456abc1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"utils": "utils"
}
},
"systems": {
"locked": {
"lastModified": 1699999999,
"narHash": "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
"owner": "nix-systems",
"repo": "default",
"rev": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1699999998,
"narHash": "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ccccccccccccccccccccccccccccccccccccccc1",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
"root": "root",
"version": 7
}"#
}
fn deep_follows_json() -> &'static str {
r#"{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "abc123",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"bar": "bar",
"foo": "foo",
"nixpkgs": "nixpkgs"
}
},
"foo": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1700000001,
"narHash": "sha256-FOO",
"owner": "example",
"repo": "foo",
"rev": "foofoo",
"type": "github"
},
"original": {
"owner": "example",
"repo": "foo",
"type": "github"
}
},
"bar": {
"inputs": {
"nixpkgs": [
"foo",
"nixpkgs"
]
},
"locked": {
"lastModified": 1700000002,
"narHash": "sha256-BAR",
"owner": "example",
"repo": "bar",
"rev": "barbar",
"type": "github"
},
"original": {
"owner": "example",
"repo": "bar",
"type": "github"
}
}
},
"root": "root",
"version": 7
}"#
}
#[test]
fn parse_minimal_lock() {
let lock = FlakeLock::parse(minimal_lock_json()).expect("parse failed");
assert_eq!(lock.version, 7);
assert_eq!(lock.root, "root");
assert_eq!(lock.nodes.len(), 2);
assert!(lock.nodes.contains_key("root"));
assert!(lock.nodes.contains_key("nixpkgs"));
}
#[test]
fn minimal_root_node_has_no_locked() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let root = lock.root_node().unwrap();
assert!(root.locked.is_none());
assert!(root.original.is_none());
}
#[test]
fn minimal_nixpkgs_locked_fields() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let nixpkgs = lock.get_node("nixpkgs").unwrap();
let locked = nixpkgs.locked.as_ref().expect("missing locked");
assert_eq!(locked.source_type, "github");
assert_eq!(locked.owner.as_deref(), Some("nixos"));
assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
assert_eq!(
locked.rev.as_deref(),
Some("abc123def456abc123def456abc123def456abc1"),
);
assert_eq!(locked.last_modified, Some(1_700_000_000));
assert!(locked.nar_hash.is_some());
}
#[test]
fn minimal_nixpkgs_original_fields() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let nixpkgs = lock.get_node("nixpkgs").unwrap();
let original = nixpkgs.original.as_ref().expect("missing original");
assert_eq!(original.source_type, "github");
assert_eq!(original.owner.as_deref(), Some("nixos"));
assert_eq!(original.repo.as_deref(), Some("nixpkgs"));
assert_eq!(original.git_ref.as_deref(), Some("nixos-unstable"));
}
#[test]
fn minimal_root_inputs() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let inputs = lock.root_inputs().unwrap();
assert_eq!(inputs.len(), 1);
assert_eq!(inputs[0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
}
#[test]
fn parse_follows_lock() {
let lock = FlakeLock::parse(follows_lock_json()).expect("parse failed");
assert_eq!(lock.nodes.len(), 4); }
#[test]
fn follows_utils_nixpkgs_resolves_to_root_nixpkgs() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let utils = lock.get_node("utils").unwrap();
let nixpkgs_ref = &utils.inputs["nixpkgs"];
assert_eq!(nixpkgs_ref, &InputRef::Follows(vec!["nixpkgs".to_string()]));
let resolved = lock.resolve_ref("utils", nixpkgs_ref).unwrap();
assert_eq!(resolved, "nixpkgs");
}
#[test]
fn resolve_input_walk_utils_nixpkgs() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let node = lock.resolve_input(&["utils", "nixpkgs"]).unwrap();
let locked = node.locked.as_ref().unwrap();
assert_eq!(locked.owner.as_deref(), Some("nixos"));
assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
}
#[test]
fn resolve_input_walk_utils_systems() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let node = lock.resolve_input(&["utils", "systems"]).unwrap();
let locked = node.locked.as_ref().unwrap();
assert_eq!(locked.owner.as_deref(), Some("nix-systems"));
assert_eq!(locked.repo.as_deref(), Some("default"));
}
#[test]
fn deep_follows_bar_nixpkgs_resolves_through_foo() {
let lock = FlakeLock::parse(deep_follows_json()).unwrap();
let node = lock.resolve_input(&["bar", "nixpkgs"]).unwrap();
let locked = node.locked.as_ref().unwrap();
assert_eq!(locked.owner.as_deref(), Some("nixos"));
assert_eq!(locked.rev.as_deref(), Some("abc123"));
}
#[test]
fn deep_follows_foo_nixpkgs_resolves_to_root() {
let lock = FlakeLock::parse(deep_follows_json()).unwrap();
let node = lock.resolve_input(&["foo", "nixpkgs"]).unwrap();
let locked = node.locked.as_ref().unwrap();
assert_eq!(locked.owner.as_deref(), Some("nixos"));
}
#[test]
fn resolve_node_input_follows_lands_on_root_target() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let target = lock.resolve_node_input("utils", "nixpkgs").unwrap();
assert_eq!(target, "nixpkgs");
let target = lock.resolve_node_input("utils", "systems").unwrap();
assert_eq!(target, "systems");
}
#[test]
fn node_input_edges_resolves_follows_for_subflake() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let mut edges = lock.node_input_edges("utils");
edges.sort();
assert_eq!(
edges,
vec![
("nixpkgs".to_string(), "nixpkgs".to_string()),
("systems".to_string(), "systems".to_string()),
],
"utils' follows edge must resolve to the ROOT nixpkgs node graph"
);
}
#[test]
fn node_input_edges_deep_follows_chain() {
let lock = FlakeLock::parse(deep_follows_json()).unwrap();
let target = lock.resolve_node_input("bar", "nixpkgs").unwrap();
assert_eq!(target, "nixpkgs");
}
#[test]
fn node_input_edges_unknown_node_is_empty() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
assert!(lock.node_input_edges("does-not-exist").is_empty());
}
#[test]
fn resolve_node_input_unknown_input_errors() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
assert!(lock.resolve_node_input("utils", "ghost").is_err());
}
#[test]
fn adjacency_map_follows_lock() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let adj = lock.adjacency_map();
let root_edges = &adj["root"];
assert_eq!(root_edges.len(), 2);
assert!(root_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
assert!(root_edges.contains(&("utils".to_string(), "utils".to_string())));
let utils_edges = &adj["utils"];
assert_eq!(utils_edges.len(), 2);
assert!(utils_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
assert!(utils_edges.contains(&("systems".to_string(), "systems".to_string())));
assert!(adj["nixpkgs"].is_empty());
assert!(adj["systems"].is_empty());
}
#[test]
fn rejects_unsupported_version() {
let json = r#"{ "nodes": { "root": { "inputs": {} } }, "root": "root", "version": 6 }"#;
let err = FlakeLock::parse(json).unwrap_err();
assert!(matches!(err, FlakeLockError::UnsupportedVersion { found: 6, .. }));
}
#[test]
fn rejects_missing_root_node() {
let json = r#"{ "nodes": { "x": {} }, "root": "root", "version": 7 }"#;
let err = FlakeLock::parse(json).unwrap_err();
assert!(matches!(err, FlakeLockError::MissingRoot(_)));
}
#[test]
fn get_node_missing_returns_error() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
assert!(lock.get_node("nonexistent").is_err());
}
#[test]
fn resolve_input_missing_segment_returns_error() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let result = lock.resolve_input(&["nonexistent"]);
assert!(result.is_err());
}
#[test]
fn resolve_ref_direct_missing_node_returns_error() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let result = lock.resolve_ref("root", &InputRef::Direct("ghost".to_string()));
assert!(result.is_err());
}
#[test]
fn resolve_follows_empty_path_returns_error() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let result = lock.resolve_ref("root", &InputRef::Follows(vec![]));
assert!(result.is_err());
}
#[test]
fn resolve_follows_bad_segment_returns_error() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let result = lock.resolve_ref(
"utils",
&InputRef::Follows(vec!["nonexistent".to_string()]),
);
assert!(result.is_err());
}
#[test]
fn roundtrip_minimal() {
let original = FlakeLock::parse(minimal_lock_json()).unwrap();
let json = original.to_json().unwrap();
let reparsed = FlakeLock::parse(&json).unwrap();
assert_eq!(reparsed.version, original.version);
assert_eq!(reparsed.root, original.root);
assert_eq!(reparsed.nodes.len(), original.nodes.len());
let np = reparsed.get_node("nixpkgs").unwrap();
let locked = np.locked.as_ref().unwrap();
assert_eq!(locked.rev.as_deref(), Some("abc123def456abc123def456abc123def456abc1"));
}
#[test]
fn roundtrip_with_follows() {
let original = FlakeLock::parse(follows_lock_json()).unwrap();
let json = original.to_json().unwrap();
let reparsed = FlakeLock::parse(&json).unwrap();
assert_eq!(reparsed.nodes.len(), original.nodes.len());
let utils = reparsed.get_node("utils").unwrap();
assert_eq!(
utils.inputs["nixpkgs"],
InputRef::Follows(vec!["nixpkgs".to_string()]),
);
let node = reparsed.resolve_input(&["utils", "nixpkgs"]).unwrap();
assert_eq!(
node.locked.as_ref().unwrap().owner.as_deref(),
Some("nixos"),
);
}
#[test]
fn parse_non_flake_input() {
let json = r#"{
"nodes": {
"data": {
"flake": false,
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-DATA",
"owner": "someone",
"repo": "data-files",
"rev": "deadbeef",
"type": "github"
},
"original": {
"owner": "someone",
"repo": "data-files",
"type": "github"
}
},
"root": {
"inputs": {
"data": "data"
}
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let data = lock.get_node("data").unwrap();
assert_eq!(data.flake, Some(false));
}
#[test]
fn input_ref_direct_deserialize() {
let v: InputRef = serde_json::from_str(r#""nixpkgs""#).unwrap();
assert_eq!(v, InputRef::Direct("nixpkgs".to_string()));
}
#[test]
fn input_ref_follows_deserialize() {
let v: InputRef = serde_json::from_str(r#"["nixpkgs"]"#).unwrap();
assert_eq!(v, InputRef::Follows(vec!["nixpkgs".to_string()]));
}
#[test]
fn input_ref_follows_multi_segment_deserialize() {
let v: InputRef = serde_json::from_str(r#"["foo", "nixpkgs"]"#).unwrap();
assert_eq!(
v,
InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
);
}
#[test]
fn input_ref_direct_roundtrip() {
let original = InputRef::Direct("nixpkgs".to_string());
let json = serde_json::to_string(&original).unwrap();
let reparsed: InputRef = serde_json::from_str(&json).unwrap();
assert_eq!(original, reparsed);
}
#[test]
fn input_ref_follows_roundtrip() {
let original = InputRef::Follows(vec!["foo".to_string(), "bar".to_string()]);
let json = serde_json::to_string(&original).unwrap();
let reparsed: InputRef = serde_json::from_str(&json).unwrap();
assert_eq!(original, reparsed);
}
#[test]
fn parse_path_type_locked_input() {
let json = r#"{
"nodes": {
"local": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-PATH",
"path": "/home/user/my-flake",
"type": "path"
},
"original": {
"type": "path",
"url": "/home/user/my-flake"
}
},
"root": {
"inputs": {
"local": "local"
}
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let local = lock.get_node("local").unwrap();
let locked = local.locked.as_ref().unwrap();
assert_eq!(locked.source_type, "path");
assert_eq!(locked.path.as_deref(), Some("/home/user/my-flake"));
}
#[test]
fn multiple_inputs_follow_same_target() {
let json = r#"{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-NP",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "aaa",
"type": "github"
},
"original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
},
"root": {
"inputs": {
"a": "a",
"b": "b",
"nixpkgs": "nixpkgs"
}
},
"a": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": {
"lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github"
},
"original": { "owner": "x", "repo": "a", "type": "github" }
},
"b": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": {
"lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github"
},
"original": { "owner": "x", "repo": "b", "type": "github" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let a_np = lock.resolve_input(&["a", "nixpkgs"]).unwrap();
let b_np = lock.resolve_input(&["b", "nixpkgs"]).unwrap();
assert_eq!(
a_np.locked.as_ref().unwrap().rev.as_deref(),
Some("aaa"),
);
assert_eq!(
b_np.locked.as_ref().unwrap().rev.as_deref(),
Some("aaa"),
);
}
#[test]
fn invalid_json_returns_error() {
assert!(FlakeLock::parse("not json").is_err());
}
#[test]
fn empty_object_returns_error() {
assert!(FlakeLock::parse("{}").is_err());
}
#[test]
fn flake_false_node_default_is_none() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let nixpkgs = lock.get_node("nixpkgs").unwrap();
assert_eq!(nixpkgs.flake, None);
}
#[test]
fn flake_false_roundtrips_through_json() {
let json = r#"{
"nodes": {
"data-files": {
"flake": false,
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-DATA",
"owner": "example",
"repo": "data",
"rev": "abc123",
"type": "github"
},
"original": {
"owner": "example",
"repo": "data",
"type": "github"
}
},
"root": {
"inputs": {
"data-files": "data-files"
}
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let data = lock.get_node("data-files").unwrap();
assert_eq!(data.flake, Some(false));
let reserialized = lock.to_json().unwrap();
let reparsed = FlakeLock::parse(&reserialized).unwrap();
let data2 = reparsed.get_node("data-files").unwrap();
assert_eq!(data2.flake, Some(false));
}
#[test]
fn follows_of_follows_three_levels() {
let json = r#"{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-NP",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "final",
"type": "github"
},
"original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
},
"root": {
"inputs": {
"a": "a",
"b": "b",
"c": "c",
"nixpkgs": "nixpkgs"
}
},
"a": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": { "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
"original": { "owner": "x", "repo": "a", "type": "github" }
},
"b": {
"inputs": { "nixpkgs": ["a", "nixpkgs"] },
"locked": { "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
"original": { "owner": "x", "repo": "b", "type": "github" }
},
"c": {
"inputs": { "nixpkgs": ["b", "nixpkgs"] },
"locked": { "lastModified": 3, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
"original": { "owner": "x", "repo": "c", "type": "github" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let node = lock.resolve_input(&["c", "nixpkgs"]).unwrap();
assert_eq!(
node.locked.as_ref().unwrap().rev.as_deref(),
Some("final"),
);
}
#[test]
fn malformed_version_string() {
let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": "seven" }"#;
assert!(FlakeLock::parse(json).is_err());
}
#[test]
fn malformed_input_ref_integer() {
let json = r#"{
"nodes": {
"root": {
"inputs": { "x": 42 }
}
},
"root": "root",
"version": 7
}"#;
assert!(FlakeLock::parse(json).is_err());
}
#[test]
fn missing_version_field() {
let json = r#"{ "nodes": { "root": {} }, "root": "root" }"#;
assert!(FlakeLock::parse(json).is_err());
}
#[test]
fn null_root_field() {
let json = r#"{ "nodes": { "root": {} }, "root": null, "version": 7 }"#;
assert!(FlakeLock::parse(json).is_err());
}
#[test]
fn roundtrip_deep_follows() {
let original = FlakeLock::parse(deep_follows_json()).unwrap();
let json = original.to_json().unwrap();
let reparsed = FlakeLock::parse(&json).unwrap();
assert_eq!(reparsed.nodes.len(), original.nodes.len());
let bar = reparsed.get_node("bar").unwrap();
assert_eq!(
bar.inputs["nixpkgs"],
InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
);
}
#[test]
fn adjacency_map_deep_follows() {
let lock = FlakeLock::parse(deep_follows_json()).unwrap();
let adj = lock.adjacency_map();
let root_edges = &adj["root"];
assert_eq!(root_edges.len(), 3);
let bar_edges = &adj["bar"];
assert_eq!(bar_edges.len(), 1);
assert!(bar_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
}
#[test]
fn follows_chain_four_levels_deep() {
let json = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "sha256-NP", "owner": "n", "repo": "p", "rev": "final", "type": "github" },
"original": { "owner": "n", "repo": "p", "type": "github" }
},
"root": {
"inputs": { "a": "a", "b": "b", "c": "c", "d": "d", "nixpkgs": "nixpkgs" }
},
"a": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": { "lastModified": 2, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
"original": { "owner": "x", "repo": "a", "type": "github" }
},
"b": {
"inputs": { "nixpkgs": ["a", "nixpkgs"] },
"locked": { "lastModified": 3, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
"original": { "owner": "x", "repo": "b", "type": "github" }
},
"c": {
"inputs": { "nixpkgs": ["b", "nixpkgs"] },
"locked": { "lastModified": 4, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
"original": { "owner": "x", "repo": "c", "type": "github" }
},
"d": {
"inputs": { "nixpkgs": ["c", "nixpkgs"] },
"locked": { "lastModified": 5, "narHash": "sha256-D", "owner": "x", "repo": "d", "rev": "d1", "type": "github" },
"original": { "owner": "x", "repo": "d", "type": "github" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let node = lock.resolve_input(&["d", "nixpkgs"]).unwrap();
assert_eq!(node.locked.as_ref().unwrap().rev.as_deref(), Some("final"));
}
#[test]
fn flake_node_extra_field_roundtrips() {
let json = r#"{
"nodes": {
"root": {
"inputs": { "self-ref": "self-ref" }
},
"self-ref": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-X",
"path": "/tmp/foo",
"type": "path"
},
"original": {
"type": "path",
"url": "/tmp/foo"
},
"parent": ["root"]
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let node = lock.get_node("self-ref").unwrap();
assert!(node.extra.contains_key("parent"));
let reserialized = lock.to_json().unwrap();
let reparsed = FlakeLock::parse(&reserialized).unwrap();
let node2 = reparsed.get_node("self-ref").unwrap();
assert!(node2.extra.contains_key("parent"));
}
#[test]
fn original_input_extra_fields_roundtrip() {
let json = r#"{
"nodes": {
"root": { "inputs": { "x": "x" } },
"x": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-X",
"owner": "o",
"repo": "r",
"rev": "abc",
"type": "github"
},
"original": {
"owner": "o",
"repo": "r",
"type": "github",
"submodules": true,
"shallow": false
}
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let x = lock.get_node("x").unwrap();
let original = x.original.as_ref().unwrap();
assert_eq!(original.extra.get("submodules"), Some(&serde_json::json!(true)));
assert_eq!(original.extra.get("shallow"), Some(&serde_json::json!(false)));
let reserialized = lock.to_json().unwrap();
let reparsed = FlakeLock::parse(&reserialized).unwrap();
let x2 = reparsed.get_node("x").unwrap();
let orig2 = x2.original.as_ref().unwrap();
assert_eq!(orig2.extra.get("submodules"), Some(&serde_json::json!(true)));
}
#[test]
fn unsupported_version_error_includes_found() {
let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 99 }"#;
let err = FlakeLock::parse(json).unwrap_err();
match err {
FlakeLockError::UnsupportedVersion { expected, found } => {
assert_eq!(expected, 7);
assert_eq!(found, 99);
}
other => panic!("expected UnsupportedVersion, got {other:?}"),
}
}
#[test]
fn missing_root_error_includes_name() {
let json = r#"{ "nodes": { "x": {} }, "root": "missing-root", "version": 7 }"#;
match FlakeLock::parse(json).unwrap_err() {
FlakeLockError::MissingRoot(name) => assert_eq!(name, "missing-root"),
other => panic!("expected MissingRoot, got {other:?}"),
}
}
#[test]
fn get_node_returns_node_not_found_with_name() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
match lock.get_node("nope") {
Err(FlakeLockError::NodeNotFound(n)) => assert_eq!(n, "nope"),
other => panic!("expected NodeNotFound, got {other:?}"),
}
}
#[test]
fn version_as_float_rejected() {
let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 7.5 }"#;
assert!(FlakeLock::parse(json).is_err());
}
#[test]
fn adjacency_map_minimal() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let adj = lock.adjacency_map();
assert_eq!(adj.len(), 2);
assert_eq!(adj["root"].len(), 1);
assert_eq!(adj["root"][0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
assert!(adj["nixpkgs"].is_empty());
}
#[test]
fn adjacency_map_skips_unresolvable() {
let mut nodes = BTreeMap::new();
nodes.insert("root".to_string(), FlakeNode {
inputs: {
let mut m = BTreeMap::new();
m.insert("ghost".to_string(), InputRef::Direct("nonexistent".to_string()));
m
},
locked: None,
original: None,
flake: None,
extra: BTreeMap::new(),
});
let lock = FlakeLock {
nodes,
root: "root".to_string(),
version: 7,
};
let adj = lock.adjacency_map();
assert!(adj["root"].is_empty());
}
#[test]
fn resolve_input_empty_path_returns_root() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let node = lock.resolve_input(&[]).unwrap();
assert!(node.inputs.contains_key("nixpkgs"));
}
#[test]
fn root_inputs_sorted_alphabetically() {
let lock = FlakeLock::parse(deep_follows_json()).unwrap();
let inputs = lock.root_inputs().unwrap();
let names: Vec<&str> = inputs.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, vec!["bar", "foo", "nixpkgs"]);
}
#[test]
fn input_ref_direct_serialize_to_string_literal() {
let r = InputRef::Direct("nixpkgs".to_string());
let json = serde_json::to_string(&r).unwrap();
assert_eq!(json, r#""nixpkgs""#);
}
#[test]
fn input_ref_follows_serialize_to_array() {
let r = InputRef::Follows(vec!["a".to_string(), "b".to_string()]);
let json = serde_json::to_string(&r).unwrap();
assert_eq!(json, r#"["a","b"]"#);
}
#[test]
fn tarball_type_input_with_url() {
let json = r#"{
"nodes": {
"root": { "inputs": { "src": "src" } },
"src": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-X",
"type": "tarball",
"url": "https://example.com/v1.0.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://example.com/latest.tar.gz"
}
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let src = lock.get_node("src").unwrap();
let locked = src.locked.as_ref().unwrap();
assert_eq!(locked.source_type, "tarball");
assert_eq!(locked.url.as_deref(), Some("https://example.com/v1.0.tar.gz"));
}
#[test]
fn git_ref_input_preserved() {
let json = r#"{
"nodes": {
"root": { "inputs": { "deps": "deps" } },
"deps": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-X",
"owner": "o",
"repo": "r",
"rev": "abc",
"ref": "refs/heads/main",
"type": "github"
},
"original": {
"owner": "o",
"repo": "r",
"ref": "main",
"type": "github"
}
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let deps = lock.get_node("deps").unwrap();
let locked = deps.locked.as_ref().unwrap();
assert_eq!(locked.git_ref.as_deref(), Some("refs/heads/main"));
let orig = deps.original.as_ref().unwrap();
assert_eq!(orig.git_ref.as_deref(), Some("main"));
}
#[test]
fn git_dir_subdirectory_field() {
let json = r#"{
"nodes": {
"root": { "inputs": { "subdir": "subdir" } },
"subdir": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-X",
"owner": "o",
"repo": "r",
"rev": "abc",
"type": "github",
"dir": "subdir/inside"
},
"original": {
"owner": "o",
"repo": "r",
"type": "github",
"dir": "subdir/inside"
}
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let s = lock.get_node("subdir").unwrap();
let locked = s.locked.as_ref().unwrap();
assert_eq!(locked.dir.as_deref(), Some("subdir/inside"));
let orig = s.original.as_ref().unwrap();
assert_eq!(orig.dir.as_deref(), Some("subdir/inside"));
}
#[test]
fn indirect_id_input() {
let json = r#"{
"nodes": {
"root": { "inputs": { "nixpkgs": "nixpkgs" } },
"nixpkgs": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-X",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "abc",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let np = lock.get_node("nixpkgs").unwrap();
let orig = np.original.as_ref().unwrap();
assert_eq!(orig.source_type, "indirect");
assert_eq!(orig.id.as_deref(), Some("nixpkgs"));
}
#[test]
fn resolve_follows_when_target_segment_is_direct() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let node = lock.resolve_input(&["utils", "systems"]).unwrap();
let locked = node.locked.as_ref().unwrap();
assert_eq!(locked.source_type, "github");
}
#[test]
fn trailing_whitespace_in_json_ok() {
let json = format!("{}\n\n \n", minimal_lock_json());
let lock = FlakeLock::parse(&json).unwrap();
assert_eq!(lock.version, 7);
}
#[test]
fn two_nodes_with_same_underlying_rev() {
let json = r#"{
"nodes": {
"root": { "inputs": { "a": "a", "b": "b" } },
"a": {
"locked": {
"lastModified": 1, "narHash": "sha256-X",
"owner": "n", "repo": "p", "rev": "abc",
"type": "github"
},
"original": { "owner": "n", "repo": "p", "type": "github" }
},
"b": {
"locked": {
"lastModified": 1, "narHash": "sha256-X",
"owner": "n", "repo": "p", "rev": "abc",
"type": "github"
},
"original": { "owner": "n", "repo": "p", "type": "github" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
assert_eq!(lock.nodes.len(), 3);
let a = lock.get_node("a").unwrap();
let b = lock.get_node("b").unwrap();
assert_eq!(
a.locked.as_ref().unwrap().rev,
b.locked.as_ref().unwrap().rev
);
}
#[test]
fn flake_lock_error_display_includes_context() {
let err = FlakeLockError::FollowsFailed {
from: "node-x".to_string(),
path: vec!["a".to_string(), "b".to_string()],
};
let s = format!("{err}");
assert!(s.contains("node-x"));
}
#[test]
fn extra_fields_roundtrip() {
let json = r#"{
"nodes": {
"local": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-X",
"path": "/home/user/proj",
"type": "path",
"revCount": 42,
"submodules": true
},
"original": {
"type": "path",
"url": "/home/user/proj"
}
},
"root": {
"inputs": { "local": "local" }
}
},
"root": "root",
"version": 7
}"#;
let lock = FlakeLock::parse(json).unwrap();
let local = lock.get_node("local").unwrap();
let locked = local.locked.as_ref().unwrap();
assert_eq!(locked.extra.get("revCount"), Some(&serde_json::json!(42)));
assert_eq!(locked.extra.get("submodules"), Some(&serde_json::json!(true)));
let reserialized = lock.to_json().unwrap();
let reparsed = FlakeLock::parse(&reserialized).unwrap();
let local2 = reparsed.get_node("local").unwrap();
let locked2 = local2.locked.as_ref().unwrap();
assert_eq!(locked2.extra.get("revCount"), Some(&serde_json::json!(42)));
}
}