use anyhow::{Context, Result};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fmt::Debug,
fs::File,
io::{BufReader, Write},
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct LockFile {
#[serde(skip)]
pub(crate) path: PathBuf,
nodes: IndexMap<String, LockNode>,
root: String,
version: usize,
}
impl LockFile {
pub(crate) fn from_root(root: &Path) -> Result<Self> {
let path = root.join("flake.lock");
let file =
File::open(&path).with_context(|| format!("Failed to open LockFile at {path:?}"))?;
let reader = BufReader::new(file);
let mut lockfile: LockFile = serde_json::from_reader(reader)?;
lockfile.path = path;
Ok(lockfile)
}
pub(crate) fn get_node_via_root(&self, input_name: &str) -> Option<&LockNode> {
self.nodes.get(self.resolve_node_name_via_root(input_name)?)
}
pub(crate) fn update_node_via_root(&self, input_name: &str, new_node: &LockNode) -> Self {
let node_name = self.resolve_node_name_via_root(input_name).unwrap();
let mut modified = self.clone();
_ = modified
.nodes
.insert(node_name.to_string(), new_node.clone());
modified
}
pub(crate) fn write(&self) -> Result<()> {
let json = serde_json::to_string_pretty(self)?;
let mut f = File::create(&self.path)
.with_context(|| format!("Failed to create LockFile at {:?}", self.path))?;
f.write_all(json.trim_end().as_bytes())?;
f.write_all(b"\n")?;
f.flush()?;
Ok(())
}
fn resolve_node_name_via_root(&self, input_name: &str) -> Option<&str> {
let Input::Direct(node_name) = self
.nodes
.get(&self.root)
.unwrap()
.inputs
.as_ref()
.unwrap()
.get(input_name)?
else {
return None;
};
Some(node_name)
}
pub(crate) fn get_inputs(&self) -> Vec<String> {
self.nodes
.get(&self.root)
.unwrap()
.inputs
.as_ref()
.unwrap()
.keys()
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct LockNode {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) flake: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
inputs: Option<BTreeMap<String, Input>>,
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "sorted_json_object"
)]
pub(crate) locked: Option<LockNodeLocked>,
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "sorted_json_object"
)]
pub(crate) original: Option<LockNodeOriginal>,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
enum Input {
Direct(String),
Indirect(Vec<String>),
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase", tag = "type")]
pub(crate) enum LockNodeLocked {
Git(LockNodeLockedGit),
GitHub(LockNodeLockedGitHub),
Sourcehut(LockNodeLockedSourcehut),
GitLab(LockNodeLockedGitLab),
File(LockNodeLockedFile),
Tarball(LockNodeLockedTarball),
}
impl LockNodeLocked {
pub(crate) fn last_modified(&self) -> Option<usize> {
match self {
LockNodeLocked::Git(locked) => Some(locked.last_modified),
LockNodeLocked::GitHub(locked) => Some(locked.last_modified),
LockNodeLocked::Sourcehut(locked) => Some(locked.last_modified),
LockNodeLocked::GitLab(locked) => Some(locked.last_modified),
LockNodeLocked::File(_locked) => None,
LockNodeLocked::Tarball(locked) => Some(locked.last_modified),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockNodeLockedGit {
last_modified: usize,
r#ref: String,
rev: String,
url: String,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockNodeLockedGitHub {
pub(crate) last_modified: usize,
owner: String,
repo: String,
pub(crate) rev: String,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockNodeLockedSourcehut {
last_modified: usize,
owner: String,
repo: String,
rev: String,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockNodeLockedGitLab {
last_modified: usize,
owner: String,
repo: String,
rev: String,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockNodeLockedFile {
url: String,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockNodeLockedTarball {
pub(crate) last_modified: usize,
pub(crate) nar_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) rev: Option<String>,
pub(crate) url: String,
#[serde(flatten)]
pub(crate) unknown: HashMap<String, Value>,
}
#[derive(Deserialize, Clone, Serialize, PartialEq)]
#[serde(rename_all = "lowercase", tag = "type")]
pub(crate) enum LockNodeOriginal {
Git(LockNodeOriginalGit),
GitHub(LockNodeOriginalGitHub),
Sourcehut(LockNodeOriginalSourcehut),
GitLab(LockNodeOriginalGitLab),
Indirect(LockNodeOriginalIndirect),
File(LockNodeOriginalFile),
Tarball(LockNodeOriginalTarball),
}
impl Debug for LockNodeOriginal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LockNodeOriginal::Git(git) => write!(f, "{git:?}"),
LockNodeOriginal::GitHub(github) => write!(f, "{github:?}"),
LockNodeOriginal::Sourcehut(sourcehut) => write!(f, "{sourcehut:?}"),
LockNodeOriginal::GitLab(gitlab) => write!(f, "{gitlab:?}"),
LockNodeOriginal::Indirect(indirect) => write!(f, "{indirect:?}"),
LockNodeOriginal::File(file) => write!(f, "{file:?}"),
LockNodeOriginal::Tarball(tarball) => write!(f, "{tarball:?}"),
}
}
}
#[derive(Clone, Deserialize, Serialize, PartialEq)]
pub(crate) struct LockNodeOriginalGit {
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
r#ref: Option<String>,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
impl Debug for LockNodeOriginalGit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "git+{}", self.url)?;
match (&self.r#ref, &self.rev) {
(Some(r#ref), None) => write!(f, "?ref={ref}")?,
(None, Some(rev)) => write!(f, "?rev={rev}")?,
(Some(r#ref), Some(rev)) => write!(f, "?ref={ref}&rev={rev}")?,
(None, None) => {}
}
Ok(())
}
}
#[derive(Clone, Deserialize, Serialize)]
pub(crate) struct LockNodeOriginalGitHub {
pub(crate) owner: String,
pub(crate) repo: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) r#ref: Option<String>,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
impl std::fmt::Debug for LockNodeOriginalGitHub {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "github:{}/{}", self.owner, self.repo)?;
match (&self.rev, &self.r#ref) {
(Some(rev), None) => write!(f, "/{rev}")?,
(None, Some(r#ref)) => write!(f, "/{ref}")?,
(Some(rev), Some(r#ref)) => write!(f, "/{rev}?ref={ref}")?,
(None, None) => {}
}
Ok(())
}
}
impl PartialEq for LockNodeOriginalGitHub {
fn eq(&self, other: &Self) -> bool {
self.owner.eq_ignore_ascii_case(&other.owner)
&& self.repo.eq_ignore_ascii_case(&other.repo)
&& self.rev == other.rev
&& self.r#ref == other.r#ref
}
}
#[derive(Clone, Deserialize, Serialize)]
pub(crate) struct LockNodeOriginalSourcehut {
owner: String,
repo: String,
#[serde(skip_serializing_if = "Option::is_none")]
rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
r#ref: Option<String>,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
impl std::fmt::Debug for LockNodeOriginalSourcehut {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "sourcehut:{}/{}", self.owner, self.repo)?;
match (&self.rev, &self.r#ref) {
(Some(rev), None) => write!(f, "/{rev}")?,
(None, Some(r#ref)) => write!(f, "/{ref}")?,
(Some(rev), Some(r#ref)) => write!(f, "/{rev}?ref={ref}")?,
(None, None) => {}
}
Ok(())
}
}
impl PartialEq for LockNodeOriginalSourcehut {
fn eq(&self, other: &Self) -> bool {
self.owner.eq_ignore_ascii_case(&other.owner)
&& self.repo.eq_ignore_ascii_case(&other.repo)
&& self.rev == other.rev
&& self.r#ref == other.r#ref
}
}
#[derive(Clone, Deserialize, Serialize)]
pub(crate) struct LockNodeOriginalGitLab {
owner: String,
repo: String,
#[serde(skip_serializing_if = "Option::is_none")]
rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
r#ref: Option<String>,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
impl std::fmt::Debug for LockNodeOriginalGitLab {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "gitlab:{}/{}", self.owner, self.repo)?;
match (&self.rev, &self.r#ref) {
(Some(rev), None) => write!(f, "/{rev}")?,
(None, Some(r#ref)) => write!(f, "/{ref}")?,
(Some(rev), Some(r#ref)) => write!(f, "/{rev}?ref={ref}")?,
(None, None) => {}
}
Ok(())
}
}
impl PartialEq for LockNodeOriginalGitLab {
fn eq(&self, other: &Self) -> bool {
self.owner.eq_ignore_ascii_case(&other.owner)
&& self.repo.eq_ignore_ascii_case(&other.repo)
&& self.rev == other.rev
&& self.r#ref == other.r#ref
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub(crate) struct LockNodeOriginalIndirect {
id: String,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
#[derive(Clone, Deserialize, Serialize, PartialEq)]
pub(crate) struct LockNodeOriginalFile {
url: String,
#[serde(flatten)]
unknown: HashMap<String, Value>,
}
impl Debug for LockNodeOriginalFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.url)
}
}
#[derive(Clone, Deserialize, Serialize, PartialEq)]
pub(crate) struct LockNodeOriginalTarball {
pub(crate) url: String,
#[serde(flatten)]
pub(crate) unknown: HashMap<String, Value>,
}
impl Debug for LockNodeOriginalTarball {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.url)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip(json: &str) -> LockNode {
let node: LockNode = serde_json::from_str(json).unwrap();
let reserialized = serde_json::to_string(&node).unwrap();
let redeserialized: serde_json::Value = serde_json::from_str(&reserialized).unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(json).unwrap(),
redeserialized
);
assert_eq!(json.replace(" ", "").replace("\n", ""), reserialized);
node
}
#[test]
fn git_with_unknown() {
let node = round_trip(
r#"{
"inputs": {
"gomod2nix": "gomod2nix",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1789557290,
"narHash": "sha256-Lnftv/+ajx7+0akzH34BeXrqi2N+DKDrBOCOjleD+LM=",
"ref": "refs/heads/main",
"rev": "9f5d4cbedf40e6cead443cf86acd3546b122c711",
"revCount": 291,
"shallow": false,
"type": "git",
"url": "https://git.alin.ovh/elgit"
},
"original": {
"shallow": false,
"type": "git",
"url": "https://git.alin.ovh/elgit"
}
}"#,
);
let LockNodeOriginal::Git(original) = node.original.unwrap() else {
unreachable!();
};
assert_eq!(false, original.unknown["shallow"]);
let LockNodeLocked::Git(locked) = node.locked.unwrap() else {
unreachable!();
};
assert_eq!(false, locked.unknown["shallow"]);
}
#[test]
fn tarball_with_rev() {
let node = round_trip(
r#"{
"locked": {
"lastModified": 1767892417,
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
}
}"#,
);
assert_eq!(Some(1767892417), node.locked.unwrap().last_modified());
assert_eq!(
"https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz",
format!("{:?}", node.original.unwrap())
);
}
#[test]
fn tarball_without_rev() {
let node = round_trip(
r#"{
"flake": false,
"locked": {
"lastModified": 1788917657,
"narHash": "sha256-VO8KzRYOgpVx36ZrJgyhKBIqs/LCSt75RVaCYxQv9TY=",
"type": "tarball",
"url": "file:///tmp/inner.tar.gz"
},
"original": {
"type": "tarball",
"url": "file:///tmp/inner.tar.gz"
}
}"#,
);
assert_eq!(Some(1788917657), node.locked.unwrap().last_modified());
}
#[test]
fn git_original_renders_as_flakeref() {
let node = round_trip(
r#"{
"original": {
"ref": "vyx",
"type": "git",
"url": "https://nossa.ee/~talya/pi"
}
}"#,
);
assert_eq!(
"git+https://nossa.ee/~talya/pi?ref=vyx",
format!("{:?}", node.original.unwrap())
);
}
#[test]
fn git_original_without_ref_renders_as_flakeref() {
let node = round_trip(
r#"{
"original": {
"type": "git",
"url": "https://nossa.ee/~talya/iqan"
}
}"#,
);
assert_eq!(
"git+https://nossa.ee/~talya/iqan",
format!("{:?}", node.original.unwrap())
);
}
#[test]
fn file_original_renders_as_flakeref() {
let node = round_trip(
r#"{
"original": {
"type": "file",
"url": "https://example.org/patch.diff"
}
}"#,
);
assert_eq!(
"https://example.org/patch.diff",
format!("{:?}", node.original.unwrap())
);
}
}
fn sorted_json_object<S, V: Serialize>(value: &V, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s = serde_json::to_string(value).unwrap();
let object: BTreeMap<String, Value> = serde_json::from_str(&s).unwrap();
object.serialize(serializer)
}