use crate::error::Error;
use crate::follows::{AttrPath, Segment};
use serde::{Deserialize, Deserializer};
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LockError {
#[error("failed to parse flake.lock as json")]
Parse(#[from] serde_json::Error),
#[error("flake.lock is missing the root node")]
MissingRoot,
#[error("flake.lock root has no inputs")]
RootHasNoInputs,
#[error("input '{path}' has no sub-inputs in flake.lock")]
InputHasNoSubInputs { path: String },
#[error("input '{path}' not found in flake.lock")]
InputNotFound { path: String },
#[error("input '{path}' has no follows target")]
FollowsTargetMissing { path: String },
#[error("could not find lockfile node '{node}' referenced by input '{path}'")]
NodeMissingForPath { node: String, path: String },
#[error("could not find lockfile node '{node}'")]
NodeMissing { node: String },
#[error("cycle while resolving follows path")]
FollowsCycle,
#[error("lockfile node has no locked information")]
NodeNotLocked,
#[error("locked node has no rev")]
LockedHasNoRev,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NestedInput {
pub path: AttrPath,
pub follows: Option<AttrPath>,
pub url: Option<String>,
}
impl NestedInput {
pub fn to_display_string(&self) -> String {
match &self.follows {
Some(target) => format!("{}\t{}", self.path, target),
None => self.path.to_string(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct FlakeLock {
nodes: HashMap<String, Node>,
root: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct Node {
inputs: Option<HashMap<String, Input>>,
locked: Option<Locked>,
original: Option<Original>,
}
impl Node {
fn rev(&self) -> Result<String, LockError> {
self.locked.as_ref().ok_or(LockError::NodeNotLocked)?.rev()
}
}
#[derive(Debug, Clone)]
pub enum Input {
Direct(String),
Indirect(Option<AttrPath>),
}
impl<'de> Deserialize<'de> for Input {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{Error, SeqAccess, Visitor};
use std::fmt;
struct InputVisitor;
impl<'de> Visitor<'de> for InputVisitor {
type Value = Input;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(
"a node name string, an empty array, or an array of \
non-empty segment names",
)
}
fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(Input::Direct(v.to_string()))
}
fn visit_string<E: Error>(self, v: String) -> Result<Self::Value, E> {
Ok(Input::Direct(v))
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let Some(first) = seq.next_element::<String>()? else {
return Ok(Input::Indirect(None));
};
let first_seg = Segment::from_unquoted(first).map_err(A::Error::custom)?;
let mut path = AttrPath::new(first_seg);
while let Some(raw) = seq.next_element::<String>()? {
let seg = Segment::from_unquoted(raw).map_err(A::Error::custom)?;
path.push(seg);
}
Ok(Input::Indirect(Some(path)))
}
}
deserializer.deserialize_any(InputVisitor)
}
}
#[derive(Debug, Deserialize, Clone)]
pub(crate) struct Locked {
rev: Option<String>,
}
impl Locked {
fn rev(&self) -> Result<String, LockError> {
self.rev.clone().ok_or(LockError::LockedHasNoRev)
}
}
#[derive(Debug)]
pub(crate) enum Original {
Github {
owner: String,
repo: String,
ref_field: Option<String>,
},
Gitlab {
owner: String,
repo: String,
ref_field: Option<String>,
},
Sourcehut {
owner: String,
repo: String,
ref_field: Option<String>,
},
Git {
url: String,
ref_field: Option<String>,
},
Hg {
url: String,
ref_field: Option<String>,
},
Tarball {
url: String,
},
File {
url: String,
},
Path {
path: String,
},
Indirect {
id: String,
ref_field: Option<String>,
},
Unknown {
node_type: String,
},
}
impl<'de> Deserialize<'de> for Original {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
#[derive(Deserialize)]
struct ForgePayload {
owner: String,
repo: String,
#[serde(rename = "ref")]
ref_field: Option<String>,
}
#[derive(Deserialize)]
struct VcsPayload {
url: String,
#[serde(rename = "ref")]
ref_field: Option<String>,
}
#[derive(Deserialize)]
struct UrlPayload {
url: String,
}
#[derive(Deserialize)]
struct PathPayload {
path: String,
}
#[derive(Deserialize)]
struct IndirectPayload {
id: String,
#[serde(rename = "ref")]
ref_field: Option<String>,
}
let value = serde_json::Value::deserialize(deserializer)?;
let node_type = value
.get("type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| D::Error::missing_field("type"))?
.to_string();
fn payload<T: for<'a> Deserialize<'a>, E: Error>(value: serde_json::Value) -> Result<T, E> {
serde_json::from_value(value).map_err(E::custom)
}
Ok(match node_type.as_str() {
"github" => {
let ForgePayload {
owner,
repo,
ref_field,
} = payload(value)?;
Original::Github {
owner,
repo,
ref_field,
}
}
"gitlab" => {
let ForgePayload {
owner,
repo,
ref_field,
} = payload(value)?;
Original::Gitlab {
owner,
repo,
ref_field,
}
}
"sourcehut" => {
let ForgePayload {
owner,
repo,
ref_field,
} = payload(value)?;
Original::Sourcehut {
owner,
repo,
ref_field,
}
}
"git" => {
let VcsPayload { url, ref_field } = payload(value)?;
Original::Git { url, ref_field }
}
"hg" => {
let VcsPayload { url, ref_field } = payload(value)?;
Original::Hg { url, ref_field }
}
"tarball" => {
let UrlPayload { url } = payload(value)?;
Original::Tarball { url }
}
"file" => {
let UrlPayload { url } = payload(value)?;
Original::File { url }
}
"path" => {
let PathPayload { path } = payload(value)?;
Original::Path { path }
}
"indirect" => {
let IndirectPayload { id, ref_field } = payload(value)?;
Original::Indirect { id, ref_field }
}
_ => Original::Unknown { node_type },
})
}
}
impl Original {
fn to_flake_url(&self) -> Option<String> {
match self {
Original::Github {
owner,
repo,
ref_field,
} => Some(forge_flake_url("github", owner, repo, ref_field.as_deref())),
Original::Gitlab {
owner,
repo,
ref_field,
} => Some(forge_flake_url("gitlab", owner, repo, ref_field.as_deref())),
Original::Sourcehut {
owner,
repo,
ref_field,
} => Some(forge_flake_url(
"sourcehut",
owner,
repo,
ref_field.as_deref(),
)),
Original::Git { url, ref_field } => {
Some(prefixed_vcs_url("git+", url, ref_field.as_deref()))
}
Original::Hg { url, ref_field } => {
Some(prefixed_vcs_url("hg+", url, ref_field.as_deref()))
}
Original::Tarball { url } | Original::File { url } => Some(url.clone()),
Original::Path { path } => Some(format!("path:{path}")),
Original::Indirect { id, ref_field } => {
Some(indirect_flake_url(id, ref_field.as_deref()))
}
Original::Unknown { node_type } => {
tracing::warn!(
"Unknown flake.lock node type '{node_type}'; cannot reconstruct flake URL"
);
None
}
}
}
}
fn forge_flake_url(scheme: &str, owner: &str, repo: &str, ref_field: Option<&str>) -> String {
let mut url = format!("{scheme}:{owner}/{repo}");
if let Some(r) = ref_field {
url.push('/');
url.push_str(r);
}
url
}
fn prefixed_vcs_url(scheme_prefix: &str, url: &str, ref_field: Option<&str>) -> String {
let Some(r) = ref_field else {
return format!("{scheme_prefix}{url}");
};
let separator = if url.contains('?') { '&' } else { '?' };
format!("{scheme_prefix}{url}{separator}ref={r}")
}
fn indirect_flake_url(id: &str, ref_field: Option<&str>) -> String {
match ref_field {
Some(r) => format!("flake:{id}/{r}"),
None => format!("flake:{id}"),
}
}
#[cfg(test)]
thread_local! {
pub(crate) static NESTED_INPUTS_CALLS: std::cell::Cell<usize> =
const { std::cell::Cell::new(0) };
}
impl FlakeLock {
const LOCK: &'static str = "flake.lock";
pub fn from_default_path() -> Result<Self, Error> {
let path = PathBuf::from(Self::LOCK);
Self::from_file(path)
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
let path = path.as_ref();
let mut file = File::open(path).map_err(|source| Error::Read {
path: path.to_path_buf(),
source,
})?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|source| Error::Read {
path: path.to_path_buf(),
source,
})?;
Self::read_from_str(&contents)
}
pub fn read_from_str(str: &str) -> Result<Self, Error> {
serde_json::from_str(str).map_err(|e| Error::Lock(LockError::Parse(e)))
}
pub fn root(&self) -> &str {
&self.root
}
fn resolve_input_path(&self, path: &AttrPath) -> Result<String, LockError> {
const MAX_HOPS: usize = 64;
self.resolve_input_path_inner(path.segments(), MAX_HOPS)
}
fn resolve_input_path_inner(
&self,
segments: &[Segment],
budget: usize,
) -> Result<String, LockError> {
if budget == 0 {
return Err(LockError::FollowsCycle);
}
let mut current_key = self.root.clone();
let mut current_node = self.nodes.get(self.root()).ok_or(LockError::MissingRoot)?;
for (i, segment) in segments.iter().enumerate() {
let inputs = current_node.inputs.as_ref().ok_or_else(|| {
if i == 0 {
LockError::RootHasNoInputs
} else {
let prefix: Vec<_> = segments[..i].iter().map(|s| s.as_str()).collect();
LockError::InputHasNoSubInputs {
path: prefix.join("."),
}
}
})?;
let resolved = inputs.get(segment.as_str()).ok_or_else(|| {
let prefix: Vec<_> = segments[..=i].iter().map(|s| s.as_str()).collect();
LockError::InputNotFound {
path: prefix.join("."),
}
})?;
match resolved {
Input::Direct(node_key) => {
current_key = node_key.clone();
}
Input::Indirect(Some(follows_path)) => {
let mut new_path: Vec<Segment> = follows_path.segments().to_vec();
new_path.extend(segments[i + 1..].iter().cloned());
return self.resolve_input_path_inner(&new_path, budget - 1);
}
Input::Indirect(None) => {
let prefix: Vec<_> = segments[..=i].iter().map(|s| s.as_str()).collect();
return Err(LockError::FollowsTargetMissing {
path: prefix.join("."),
});
}
}
if i + 1 < segments.len() {
current_node = self.nodes.get(¤t_key).ok_or_else(|| {
let prefix: Vec<_> = segments[..=i].iter().map(|s| s.as_str()).collect();
LockError::NodeMissingForPath {
node: current_key.clone(),
path: prefix.join("."),
}
})?;
}
}
Ok(current_key)
}
pub fn rev_for(&self, path: &AttrPath) -> Result<String, Error> {
let node_name = self.resolve_input_path(path)?;
let node = self
.nodes
.get(&node_name)
.ok_or_else(|| LockError::NodeMissing {
node: node_name.clone(),
})?;
Ok(node.rev()?)
}
pub fn nested_inputs(&self) -> Vec<NestedInput> {
#[cfg(test)]
NESTED_INPUTS_CALLS.with(|c| c.set(c.get() + 1));
let mut inputs = Vec::new();
let Some(root_node) = self.nodes.get(&self.root) else {
return inputs;
};
let Some(root_inputs) = &root_node.inputs else {
return inputs;
};
for (top_level_name, top_level_ref) in root_inputs {
let node_name = match top_level_ref {
Input::Direct(name) => name.clone(),
Input::Indirect(_) => continue,
};
let Ok(parent_seg) = Segment::from_unquoted(top_level_name.clone()) else {
continue;
};
let path = AttrPath::new(parent_seg);
let mut visited: HashMap<String, ()> = HashMap::new();
visited.insert(node_name.clone(), ());
self.collect_nested_inputs_recursive(&node_name, &path, 1, &mut visited, &mut inputs);
}
inputs.sort_by(|a, b| a.path.cmp(&b.path));
inputs
}
fn collect_nested_inputs_recursive(
&self,
node_name: &str,
parent_path: &AttrPath,
depth: usize,
visited: &mut HashMap<String, ()>,
out: &mut Vec<NestedInput>,
) {
if depth >= NESTED_INPUTS_MAX_DEPTH {
return;
}
let Some(node) = self.nodes.get(node_name) else {
return;
};
let Some(node_inputs) = &node.inputs else {
return;
};
let mut keys: Vec<&String> = node_inputs.keys().collect();
keys.sort();
for nested_name in keys {
let nested_ref = node_inputs.get(nested_name).unwrap();
let Ok(nested_seg) = Segment::from_unquoted(nested_name.clone()) else {
continue;
};
let mut path = parent_path.clone();
path.push(nested_seg);
let (follows, url, descend_into) = match nested_ref {
Input::Indirect(Some(target)) => (Some(target.clone()), None, None),
Input::Indirect(None) => (None, None, None),
Input::Direct(child_node_name) => {
let url = self
.nodes
.get(child_node_name.as_str())
.and_then(|n| n.original.as_ref())
.and_then(|o| o.to_flake_url());
(None, url, Some(child_node_name.clone()))
}
};
out.push(NestedInput {
path: path.clone(),
follows,
url,
});
if let Some(child) = descend_into {
if visited.contains_key(&child) {
continue;
}
visited.insert(child.clone(), ());
self.collect_nested_inputs_recursive(&child, &path, depth + 1, visited, out);
visited.remove(&child);
}
}
}
}
pub const NESTED_INPUTS_MAX_DEPTH: usize = 64;
#[cfg(test)]
mod tests {
use super::*;
fn minimal_lock() -> &'static str {
r#"
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1718714799,
"narHash": "sha256-FUZpz9rg3gL8NVPKbqU8ei1VkPLsTIfAJ2fdAf5qjak=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
"#
}
fn minimal_independent_lock_no_overrides() -> &'static str {
r#"
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1721138476,
"narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1719690277,
"narHash": "sha256-0xSej1g7eP2kaUF+JQp8jdyNmpmCJKRpO12mKl/36Kc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2741b4b489b55df32afac57bc4bfd220e8bf617e",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1721382922,
"narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "50104496fb55c9140501ea80d183f3223d13ff65",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
"#
}
fn minimal_independent_lock_nixpkgs_overridden() -> &'static str {
r#"
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1721138476,
"narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1721382922,
"narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "50104496fb55c9140501ea80d183f3223d13ff65",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
"#
}
#[test]
fn parse_minimal() {
let minimal_lock = minimal_lock();
FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
}
#[test]
fn parse_ignores_unknown_version() {
let lock = r#"{
"nodes": { "root": { "inputs": {} } },
"root": "root",
"version": 99
}"#;
FlakeLock::read_from_str(lock).expect("unknown version must still parse");
}
#[test]
fn parse_minimal_root() {
let minimal_lock = minimal_lock();
let parsed_lock =
FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
assert_eq!("root", parsed_lock.root);
}
#[test]
fn minimal_ref() {
let minimal_lock = minimal_lock();
let parsed_lock =
FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
assert_eq!(
"c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
parsed_lock
.rev_for(&"nixpkgs".parse().unwrap())
.expect("Id: nixpkgs is in the lockfile.")
);
}
#[test]
fn parse_minimal_independent_lock_no_overrides() {
let minimal_lock = minimal_independent_lock_no_overrides();
FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
}
#[test]
fn minimal_independent_lock_no_overrides_ref() {
let minimal_lock = minimal_independent_lock_no_overrides();
let parsed_lock =
FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
assert_eq!(
"ad0b5eed1b6031efaed382844806550c3dcb4206",
parsed_lock
.rev_for(&"nixpkgs".parse().unwrap())
.expect("Id: nixpkgs is in the lockfile.")
);
}
#[test]
fn parse_minimal_independent_lock_nixpkgs_overridden() {
let minimal_lock = minimal_independent_lock_nixpkgs_overridden();
FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
}
#[test]
fn rev_for_sub_input_path_missing_parent_returns_error() {
let minimal_lock = minimal_lock();
let parsed_lock =
FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
assert!(
parsed_lock
.rev_for(&"browseros.nixpkgs".parse().unwrap())
.is_err()
);
}
#[test]
fn rev_for_sub_input_path_resolves() {
let lock = minimal_independent_lock_no_overrides();
let parsed = FlakeLock::read_from_str(lock).expect("Should be parsed correctly.");
assert_eq!(
"2741b4b489b55df32afac57bc4bfd220e8bf617e",
parsed
.rev_for(&"treefmt-nix.nixpkgs".parse().unwrap())
.expect("Should resolve sub-input path")
);
}
#[test]
fn rev_for_sub_input_follows_resolves() {
let lock = minimal_independent_lock_nixpkgs_overridden();
let parsed = FlakeLock::read_from_str(lock).expect("Should be parsed correctly.");
assert_eq!(
parsed.rev_for(&"nixpkgs".parse().unwrap()).unwrap(),
parsed
.rev_for(&"treefmt-nix.nixpkgs".parse().unwrap())
.expect("Should resolve followed sub-input")
);
}
#[test]
fn rev_for_quoted_id() {
let minimal_lock = minimal_lock();
let parsed_lock =
FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
assert_eq!(
parsed_lock.rev_for(&"nixpkgs".parse().unwrap()).unwrap(),
parsed_lock
.rev_for(&"\"nixpkgs\"".parse().unwrap())
.unwrap(),
);
}
#[test]
fn rev_for_node_without_locked_returns_error() {
let lock = r#"{
"nodes": {
"root": {
"inputs": { "bare": "bare" }
},
"bare": {
"original": { "owner": "o", "repo": "r", "type": "github" }
}
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).unwrap();
assert!(parsed.rev_for(&"bare".parse().unwrap()).is_err());
}
#[test]
fn rev_for_node_without_rev_returns_error() {
let lock = r#"{
"nodes": {
"root": {
"inputs": { "norev": "norev" }
},
"norev": {
"locked": { "lastModified": 1, "narHash": "", "type": "path" },
"original": { "type": "path", "path": "/tmp/norev" }
}
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).unwrap();
assert!(parsed.rev_for(&"norev".parse().unwrap()).is_err());
}
#[test]
fn nested_input_path_quotes_dots() {
let lock = r#"{
"nodes": {
"hls-1.10": {
"inputs": { "nixpkgs": "nixpkgs_2" },
"flake": false,
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"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" }
},
"root": {
"inputs": { "hls-1.10": "hls-1.10", "nixpkgs": "nixpkgs" }
}
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).unwrap();
let nested = parsed.nested_inputs();
assert_eq!(nested.len(), 1);
assert_eq!(nested[0].path.to_string(), "\"hls-1.10\".nixpkgs");
}
#[test]
fn nested_inputs_recurses_to_grandchild() {
let lock = r#"{
"nodes": {
"flake-parts": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "a", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"flake-parts_2": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "b", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"neovim": {
"inputs": { "nixvim": "nixvim" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "c", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"nixvim": {
"inputs": { "flake-parts": "flake-parts_2" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "d", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "flake-parts": "flake-parts", "neovim": "neovim" }
}
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).unwrap();
let nested = parsed.nested_inputs();
let paths: Vec<String> = nested.iter().map(|n| n.path.to_string()).collect();
assert!(
paths.contains(&"neovim.nixvim".to_string()),
"depth-1 path missing, got: {paths:?}"
);
assert!(
paths.contains(&"neovim.nixvim.flake-parts".to_string()),
"depth-2 path missing, got: {paths:?}"
);
}
#[test]
fn nested_inputs_terminates_on_cyclic_lockfile() {
let lock = r#"{
"nodes": {
"a": {
"inputs": { "b": "b" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "a", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"b": {
"inputs": { "a": "a" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "b", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": { "inputs": { "a": "a" } }
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).unwrap();
let nested = parsed.nested_inputs();
assert!(!nested.is_empty());
assert!(
nested
.iter()
.all(|n| n.path.len() <= NESTED_INPUTS_MAX_DEPTH)
);
}
#[test]
fn rev_for_quoted_sub_input_path() {
let lock = r#"{
"nodes": {
"hls-1.10": {
"inputs": { "nixpkgs": "nixpkgs_2" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"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" }
},
"root": {
"inputs": { "hls-1.10": "hls-1.10", "nixpkgs": "nixpkgs" }
}
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).unwrap();
assert_eq!(
"def",
parsed
.rev_for(&"\"hls-1.10\".nixpkgs".parse().unwrap())
.expect("Should resolve quoted sub-input path")
);
}
#[test]
fn rev_for_indirect_resolves_via_root_inputs() {
let lock = r#"{
"nodes": {
"nixpkgs_2": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"treefmt-nix": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs_2", "treefmt-nix": "treefmt-nix" }
}
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).unwrap();
assert_eq!(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
parsed
.rev_for(&"treefmt-nix.nixpkgs".parse().unwrap())
.expect("indirect follows must resolve through root.inputs, not by node name")
);
}
#[test]
fn rev_for_indirect_multi_segment_path() {
let lock = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "1111111111111111111111111111111111111111", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"nixpkgs_2": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "2222222222222222222222222222222222222222", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"crane": {
"inputs": { "nixpkgs": "nixpkgs_2" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "cccccccccccccccccccccccccccccccccccccccc", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"devshell": {
"inputs": { "nixpkgs": ["crane", "nixpkgs"] },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "dddddddddddddddddddddddddddddddddddddddd", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "crane": "crane", "devshell": "devshell" }
}
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).unwrap();
assert_eq!(
"2222222222222222222222222222222222222222",
parsed
.rev_for(&"devshell.nixpkgs".parse().unwrap())
.expect("multi-segment indirect follows must be walked from root")
);
}
fn collect_indirect_targets(lock: &FlakeLock) -> Vec<(String, String, Vec<String>)> {
let mut out: Vec<(String, String, Vec<String>)> = Vec::new();
for (node_name, node) in &lock.nodes {
let Some(inputs) = node.inputs.as_ref() else {
continue;
};
for (input_name, input_ref) in inputs {
if let Input::Indirect(Some(path)) = input_ref {
let segs: Vec<String> = path
.segments()
.iter()
.map(|s| s.as_str().to_string())
.collect();
out.push((node_name.clone(), input_name.clone(), segs));
}
}
}
out.sort();
out
}
#[test]
fn fixture_depth_upstream_redundant_depth3_parses_indirects() {
let lock_text =
std::fs::read_to_string("tests/fixtures/depth_upstream_redundant_depth3.flake.lock")
.expect("fixture present");
let lock = FlakeLock::read_from_str(&lock_text).expect("fixture parses");
let mut segs_only: Vec<Vec<String>> = collect_indirect_targets(&lock)
.into_iter()
.map(|(_, _, segs)| segs)
.collect();
segs_only.sort();
assert_eq!(
segs_only,
vec![
vec!["nixpkgs".to_string()],
vec![
"omnibus".to_string(),
"flops".to_string(),
"nixpkgs".to_string()
],
vec!["omnibus".to_string(), "nixpkgs".to_string()],
],
"Indirect entries must be decoded with their full structural depth",
);
}
#[test]
fn fixture_depth_upstream_partial_parses_indirects() {
let lock_text = std::fs::read_to_string("tests/fixtures/depth_upstream_partial.flake.lock")
.expect("fixture present");
let lock = FlakeLock::read_from_str(&lock_text).expect("fixture parses");
let entries = collect_indirect_targets(&lock);
assert!(
entries.len() >= 3,
"fixture has at least three Indirect arrays, got {}",
entries.len()
);
for (node, input, segs) in &entries {
assert!(
!segs.is_empty(),
"{node}.{input}: Indirect path must be non-empty",
);
for seg in segs {
assert!(
!seg.is_empty() && !seg.contains('"'),
"{node}.{input}: segment `{seg}` must be a valid Nix name",
);
}
}
}
#[test]
fn fixture_dot_ancestor_cycle_parses_indirects_with_dotted_node() {
let lock_text = std::fs::read_to_string("tests/fixtures/dot_ancestor_cycle.flake.lock")
.expect("fixture present");
let lock = FlakeLock::read_from_str(&lock_text).expect("fixture parses");
let hls = lock.nodes.get("hls-1.10").expect("hls-1.10 node");
let inputs = hls.inputs.as_ref().expect("hls-1.10 has inputs");
match inputs.get("helper").expect("helper input present") {
Input::Indirect(Some(path)) => {
let segs: Vec<&str> = path.segments().iter().map(|s| s.as_str()).collect();
assert_eq!(segs, vec!["helper"]);
}
Input::Indirect(None) => panic!("expected Indirect(Some), got Indirect(None)"),
Input::Direct(name) => panic!("expected Indirect, got Direct({name})"),
}
}
#[test]
fn indirect_empty_array_is_accepted_as_none() {
let lock = r#"{
"nodes": {
"child": {
"inputs": { "disabled": [] },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "x", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": { "inputs": { "child": "child" } }
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).expect("empty Indirect must parse");
let child = parsed.nodes.get("child").expect("child node");
let inputs = child.inputs.as_ref().expect("child has inputs");
match inputs.get("disabled").expect("disabled input present") {
Input::Indirect(None) => {}
other => panic!("expected Indirect(None), got {other:?}"),
}
}
#[test]
fn nested_inputs_handles_mixed_direct_indirect_and_empty() {
let lock = r#"{
"nodes": {
"flake-parts": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "fp", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"nix": {
"inputs": {
"flake-compat": [],
"flake-parts": "flake-parts",
"nixpkgs": ["nixpkgs"]
},
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "n", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "np", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": { "inputs": { "nix": "nix", "nixpkgs": "nixpkgs" } }
},
"root": "root",
"version": 7
}"#;
let parsed = FlakeLock::read_from_str(lock).expect("mixed-shape lock parses");
let nested = parsed.nested_inputs();
let by_path: std::collections::HashMap<String, &NestedInput> =
nested.iter().map(|n| (n.path.to_string(), n)).collect();
let disabled = by_path
.get("nix.flake-compat")
.expect("empty Indirect emitted as nested input");
assert!(
disabled.follows.is_none(),
"empty `[]` must surface as follows: None, got {:?}",
disabled.follows
);
let resolved = by_path
.get("nix.nixpkgs")
.expect("non-empty Indirect emitted as nested input");
assert_eq!(
resolved.follows.as_ref().map(|p| p.to_string()),
Some("nixpkgs".to_string()),
"non-empty Indirect must surface its follows target",
);
}
#[test]
fn to_flake_url_git_type_prepends_scheme() {
let o = Original::Git {
url: "https://git.clan.lol/clan/munix".to_string(),
ref_field: None,
};
assert_eq!(
o.to_flake_url().as_deref(),
Some("git+https://git.clan.lol/clan/munix"),
);
}
#[test]
fn to_flake_url_git_with_ref_appends_query() {
let o = Original::Git {
url: "https://git.example.com/repo".to_string(),
ref_field: Some("main".to_string()),
};
assert_eq!(
o.to_flake_url().as_deref(),
Some("git+https://git.example.com/repo?ref=main"),
);
}
#[test]
fn to_flake_url_hg_type_prepends_scheme() {
let o = Original::Hg {
url: "https://hg.example.com/repo".to_string(),
ref_field: None,
};
assert_eq!(
o.to_flake_url().as_deref(),
Some("hg+https://hg.example.com/repo"),
);
}
#[test]
fn to_flake_url_git_with_existing_query_appends_with_ampersand() {
let o = Original::Git {
url: "https://git.example.com/repo?dir=subdir".to_string(),
ref_field: Some("main".to_string()),
};
assert_eq!(
o.to_flake_url().as_deref(),
Some("git+https://git.example.com/repo?dir=subdir&ref=main"),
);
}
#[test]
fn to_flake_url_tarball_returns_url_unchanged() {
let o = Original::Tarball {
url: "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz".to_string(),
};
assert_eq!(
o.to_flake_url().as_deref(),
Some("https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz"),
);
}
#[test]
fn to_flake_url_path_uses_path_field() {
let o = Original::Path {
path: "/etc/nixos".to_string(),
};
assert_eq!(o.to_flake_url().as_deref(), Some("path:/etc/nixos"));
}
#[test]
fn to_flake_url_indirect_uses_id_field() {
let o = Original::Indirect {
id: "nixpkgs".to_string(),
ref_field: None,
};
assert_eq!(o.to_flake_url().as_deref(), Some("flake:nixpkgs"));
}
#[test]
fn to_flake_url_indirect_with_ref_appends_path_component() {
let o = Original::Indirect {
id: "nixpkgs".to_string(),
ref_field: Some("nixos-25.05".to_string()),
};
assert_eq!(
o.to_flake_url().as_deref(),
Some("flake:nixpkgs/nixos-25.05"),
);
}
#[test]
fn to_flake_url_unknown_type_returns_none() {
let o: Original = serde_json::from_str(r#"{"type": "future-type"}"#).unwrap();
assert!(matches!(&o, Original::Unknown { node_type } if node_type == "future-type"));
assert_eq!(o.to_flake_url(), None);
}
#[test]
fn malformed_known_type_is_a_parse_error() {
let err = serde_json::from_str::<Original>(r#"{"type": "github"}"#).unwrap_err();
assert!(
err.to_string().contains("owner"),
"error must name the missing field, got: {err}",
);
}
#[test]
fn missing_type_is_a_parse_error() {
let err = serde_json::from_str::<Original>(r#"{}"#).unwrap_err();
assert!(
err.to_string().contains("type"),
"error must name the missing field, got: {err}",
);
}
}