use std::collections::BTreeMap;
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
use crate::SpecError;
use sui_compat::flake::{FlakeLock, FlakeLockError, FlakeNode, InputRef, LockedInput, OriginalInput};
#[derive(
DeriveTataraDomain,
Serialize,
Deserialize,
Archive,
RkyvSerialize,
RkyvDeserialize,
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
)]
#[tatara(keyword = "defgraph-hash")]
#[rkyv(derive(Debug))]
pub struct CanonicalGraphHash {
pub bytes: [u8; 32],
}
pub type NodeId = u32;
#[derive(
DeriveTataraDomain,
Serialize,
Deserialize,
Archive,
RkyvSerialize,
RkyvDeserialize,
Debug,
Clone,
PartialEq,
Eq,
)]
#[tatara(keyword = "deflockfile-graph")]
#[rkyv(derive(Debug))]
pub struct LockfileGraph {
pub version: u32,
pub root_id: NodeId,
pub nodes: Vec<InputNode>,
pub canonical_hash: CanonicalGraphHash,
}
#[derive(
DeriveTataraDomain,
Serialize,
Deserialize,
Archive,
RkyvSerialize,
RkyvDeserialize,
Debug,
Clone,
PartialEq,
Eq,
)]
#[tatara(keyword = "definput-node")]
#[rkyv(derive(Debug))]
pub struct InputNode {
pub id: NodeId,
pub name: String,
pub kind: InputKind,
pub inputs: Vec<NamedEdge>,
pub locked: LockedRef,
pub original: OriginalRef,
}
#[derive(
DeriveTataraDomain,
Serialize,
Deserialize,
Archive,
RkyvSerialize,
RkyvDeserialize,
Debug,
Clone,
PartialEq,
Eq,
)]
#[tatara(keyword = "defnamed-edge")]
#[rkyv(derive(Debug))]
pub struct NamedEdge {
pub name: String,
pub target: NodeId,
}
#[derive(
Serialize,
Deserialize,
Archive,
RkyvSerialize,
RkyvDeserialize,
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
)]
#[rkyv(derive(Debug))]
pub enum InputKind {
RootNode,
GithubFlake,
GitFlake,
PathFlake,
TarballFlake,
OtherFlake,
Unknown,
}
#[derive(
Serialize,
Deserialize,
Archive,
RkyvSerialize,
RkyvDeserialize,
Debug,
Clone,
PartialEq,
Eq,
)]
#[rkyv(derive(Debug))]
pub enum LockedRef {
Empty,
Github {
owner: String,
repo: String,
rev: String,
nar_hash: String,
last_modified: u64,
},
Git {
url: String,
rev: String,
nar_hash: String,
last_modified: u64,
},
Path {
path: String,
nar_hash: String,
last_modified: u64,
},
Tarball {
url: String,
nar_hash: String,
last_modified: u64,
},
Other {
raw_json: String,
},
}
#[derive(
Serialize,
Deserialize,
Archive,
RkyvSerialize,
RkyvDeserialize,
Debug,
Clone,
PartialEq,
Eq,
)]
#[rkyv(derive(Debug))]
pub enum OriginalRef {
Empty,
Github {
owner: String,
repo: String,
rev_or_ref: Option<String>,
},
Git {
url: String,
rev_or_ref: Option<String>,
},
Path {
path: String,
},
Tarball {
url: String,
},
Other {
raw_json: String,
},
}
#[derive(Debug, thiserror::Error)]
pub enum LockfileGraphError {
#[error("upstream flake.lock parse failed: {0}")]
Upstream(#[from] FlakeLockError),
#[error("unsupported flake-lock version {found} (expected 7)")]
UnsupportedVersion { found: u32 },
#[error("follows chain from {from:?} via {path:?} did not resolve to any node")]
UnresolvableFollows { from: String, path: Vec<String> },
#[error("rkyv archive of canonical graph failed: {0}")]
Archive(String),
}
impl LockfileGraph {
pub fn from_flake_lock(lock: &FlakeLock) -> Result<Self, LockfileGraphError> {
if lock.version != 7 {
return Err(LockfileGraphError::UnsupportedVersion {
found: lock.version,
});
}
let mut name_to_id: BTreeMap<String, NodeId> = BTreeMap::new();
let mut id_to_name: Vec<String> = Vec::new();
let mut frontier: Vec<String> = vec![lock.root.clone()];
name_to_id.insert(lock.root.clone(), 0);
id_to_name.push(lock.root.clone());
let mut head = 0;
while head < frontier.len() {
let current = frontier[head].clone();
head += 1;
let Some(node) = lock.nodes.get(¤t) else {
continue;
};
for (_attr, edge) in &node.inputs {
if let Some(target) = follow_target(lock, edge, ¤t) {
if !name_to_id.contains_key(&target) {
let id = id_to_name.len() as NodeId;
name_to_id.insert(target.clone(), id);
id_to_name.push(target.clone());
frontier.push(target);
}
}
}
}
let mut nodes: Vec<InputNode> = Vec::with_capacity(id_to_name.len());
for (id, name) in id_to_name.iter().enumerate() {
let upstream = lock.nodes.get(name);
let node = match upstream {
Some(node) => materialize(id as NodeId, name, node, lock, &name_to_id)?,
None => InputNode {
id: id as NodeId,
name: name.clone(),
kind: InputKind::RootNode,
inputs: Vec::new(),
locked: LockedRef::Empty,
original: OriginalRef::Empty,
},
};
nodes.push(node);
}
Ok(Self {
version: lock.version,
root_id: 0,
nodes,
canonical_hash: CanonicalGraphHash { bytes: [0u8; 32] },
})
}
pub fn archive_and_hash(mut self) -> Result<(Self, Vec<u8>), LockfileGraphError> {
let initial = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
.map_err(|e| LockfileGraphError::Archive(e.to_string()))?;
let hash = blake3::hash(&initial);
self.canonical_hash = CanonicalGraphHash { bytes: hash.into() };
let stamped = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
.map_err(|e| LockfileGraphError::Archive(e.to_string()))?;
Ok((self, stamped.to_vec()))
}
}
fn follow_target(lock: &FlakeLock, edge: &InputRef, _from: &str) -> Option<String> {
match edge {
InputRef::Direct(name) => Some(name.clone()),
InputRef::Follows(path) => resolve_follows_path(lock, path),
}
}
fn resolve_follows_path(lock: &FlakeLock, path: &[String]) -> Option<String> {
let mut current = lock.root.clone();
for step in path {
let node = lock.nodes.get(¤t)?;
let next = node.inputs.get(step)?;
current = match next {
InputRef::Direct(name) => name.clone(),
InputRef::Follows(inner) => return resolve_follows_path(lock, inner),
};
}
Some(current)
}
fn materialize(
id: NodeId,
name: &str,
upstream: &FlakeNode,
lock: &FlakeLock,
name_to_id: &BTreeMap<String, NodeId>,
) -> Result<InputNode, LockfileGraphError> {
let mut edges: Vec<NamedEdge> = Vec::with_capacity(upstream.inputs.len());
for (attr, edge) in &upstream.inputs {
let target_name = follow_target(lock, edge, name).ok_or_else(|| {
LockfileGraphError::UnresolvableFollows {
from: name.to_string(),
path: match edge {
InputRef::Follows(p) => p.clone(),
InputRef::Direct(n) => vec![n.clone()],
},
}
})?;
let target_id = *name_to_id.get(&target_name).ok_or_else(|| {
LockfileGraphError::UnresolvableFollows {
from: name.to_string(),
path: vec![target_name.clone()],
}
})?;
edges.push(NamedEdge {
name: attr.clone(),
target: target_id,
});
}
let (kind, locked) = classify_locked(upstream.locked.as_ref());
let original = classify_original(upstream.original.as_ref());
let kind = if name == "root" { InputKind::RootNode } else { kind };
Ok(InputNode {
id,
name: name.to_string(),
kind,
inputs: edges,
locked,
original,
})
}
fn classify_locked(locked: Option<&LockedInput>) -> (InputKind, LockedRef) {
let Some(l) = locked else {
return (InputKind::Unknown, LockedRef::Empty);
};
match l.source_type.as_str() {
"github" => (
InputKind::GithubFlake,
LockedRef::Github {
owner: l.owner.clone().unwrap_or_default(),
repo: l.repo.clone().unwrap_or_default(),
rev: l.rev.clone().unwrap_or_default(),
nar_hash: l.nar_hash.clone().unwrap_or_default(),
last_modified: l.last_modified.unwrap_or(0),
},
),
"git" => (
InputKind::GitFlake,
LockedRef::Git {
url: l.url.clone().unwrap_or_default(),
rev: l.rev.clone().unwrap_or_default(),
nar_hash: l.nar_hash.clone().unwrap_or_default(),
last_modified: l.last_modified.unwrap_or(0),
},
),
"path" => (
InputKind::PathFlake,
LockedRef::Path {
path: l.path.clone().unwrap_or_default(),
nar_hash: l.nar_hash.clone().unwrap_or_default(),
last_modified: l.last_modified.unwrap_or(0),
},
),
"tarball" | "file" => (
InputKind::TarballFlake,
LockedRef::Tarball {
url: l.url.clone().unwrap_or_default(),
nar_hash: l.nar_hash.clone().unwrap_or_default(),
last_modified: l.last_modified.unwrap_or(0),
},
),
other => (
InputKind::OtherFlake,
LockedRef::Other {
raw_json: serde_json::to_string(other).unwrap_or_default(),
},
),
}
}
fn classify_original(original: Option<&OriginalInput>) -> OriginalRef {
let Some(o) = original else {
return OriginalRef::Empty;
};
match o.source_type.as_str() {
"github" | "gitlab" | "sourcehut" => OriginalRef::Github {
owner: o.owner.clone().unwrap_or_default(),
repo: o.repo.clone().unwrap_or_default(),
rev_or_ref: o.git_ref.clone(),
},
"git" => OriginalRef::Git {
url: o.url.clone().unwrap_or_default(),
rev_or_ref: o.git_ref.clone(),
},
"path" => OriginalRef::Path {
path: o.url.clone().unwrap_or_default(),
},
"tarball" | "file" => OriginalRef::Tarball {
url: o.url.clone().unwrap_or_default(),
},
other => OriginalRef::Other {
raw_json: serde_json::to_string(other).unwrap_or_default(),
},
}
}
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "deflockfile-graph-fixture")]
pub struct LockfileGraphFixture {
pub name: String,
pub version: u32,
#[serde(rename = "rootId")]
pub root_id: NodeId,
pub nodes: Vec<serde_json::Value>, pub notes: String,
}
pub const CANONICAL_LOCKFILE_GRAPH_FIXTURES_LISP: &str =
include_str!("../specs/lockfile_graph.lisp");
pub fn load_fixtures() -> Result<Vec<LockfileGraphFixture>, SpecError> {
crate::loader::load_all::<LockfileGraphFixture>(CANONICAL_LOCKFILE_GRAPH_FIXTURES_LISP)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn minimal_lock_json() -> &'static str {
r#"{
"nodes": {
"root": { "inputs": { "nixpkgs": "nixpkgs" } },
"nixpkgs": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-deadbeefdeadbeefdeadbeefdeadbeefdeadbeef0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "abc1234567890abc1234567890abc1234567890ab",
"type": "github"
},
"original": { "owner": "NixOS", "repo": "nixpkgs", "type": "github" }
}
},
"root": "root",
"version": 7
}"#
}
fn follows_lock_json() -> &'static str {
r#"{
"nodes": {
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"flake-utils": "flake-utils"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1700000000,
"narHash": "sha256-deadbeefdeadbeefdeadbeefdeadbeefdeadbeef0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "abc1234567890abc1234567890abc1234567890ab",
"type": "github"
},
"original": { "owner": "NixOS", "repo": "nixpkgs", "type": "github" }
},
"flake-utils": {
"inputs": { "nixpkgs": ["nixpkgs"] },
"locked": {
"lastModified": 1700000001,
"narHash": "sha256-cafebabecafebabecafebabecafebabecafebabe0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "0011223344556677889900112233445566778899",
"type": "github"
},
"original": { "owner": "numtide", "repo": "flake-utils", "type": "github" }
}
},
"root": "root",
"version": 7
}"#
}
#[test]
fn minimal_graph_builds_with_root_id_zero() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let g = LockfileGraph::from_flake_lock(&lock).unwrap();
assert_eq!(g.version, 7);
assert_eq!(g.root_id, 0);
assert_eq!(g.nodes.len(), 2);
assert_eq!(g.nodes[0].name, "root");
assert_eq!(g.nodes[0].kind, InputKind::RootNode);
assert_eq!(g.nodes[1].name, "nixpkgs");
assert_eq!(g.nodes[1].kind, InputKind::GithubFlake);
}
#[test]
fn follows_are_resolved_at_parse_time() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let g = LockfileGraph::from_flake_lock(&lock).unwrap();
assert_eq!(g.nodes.len(), 3);
let root = &g.nodes[0];
let edge_names: Vec<&str> = root.inputs.iter().map(|e| e.name.as_str()).collect();
assert!(edge_names.contains(&"nixpkgs"));
assert!(edge_names.contains(&"flake-utils"));
let nixpkgs_id_via_root = root
.inputs
.iter()
.find(|e| e.name == "nixpkgs")
.map(|e| e.target)
.unwrap();
let flake_utils = g.nodes.iter().find(|n| n.name == "flake-utils").unwrap();
let nixpkgs_id_via_utils = flake_utils
.inputs
.iter()
.find(|e| e.name == "nixpkgs")
.map(|e| e.target)
.unwrap();
assert_eq!(nixpkgs_id_via_root, nixpkgs_id_via_utils);
}
#[test]
fn archive_and_hash_stamps_canonical_hash() {
let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
let g = LockfileGraph::from_flake_lock(&lock).unwrap();
assert_eq!(g.canonical_hash.bytes, [0u8; 32]);
let (stamped, bytes) = g.archive_and_hash().unwrap();
assert_ne!(stamped.canonical_hash.bytes, [0u8; 32]);
assert!(!bytes.is_empty());
}
#[test]
fn archive_roundtrips_via_rkyv() {
let lock = FlakeLock::parse(follows_lock_json()).unwrap();
let g = LockfileGraph::from_flake_lock(&lock).unwrap();
let (_stamped, bytes) = g.clone().archive_and_hash().unwrap();
let archived =
rkyv::access::<ArchivedLockfileGraph, rkyv::rancor::Error>(&bytes).unwrap();
assert_eq!(archived.version, 7);
assert_eq!(archived.root_id, 0);
assert_eq!(archived.nodes.len(), 3);
}
#[test]
fn unsupported_version_rejected() {
let bad = r#"{ "nodes": {"root":{}}, "root":"root", "version": 6 }"#;
let parse_err = FlakeLock::parse(bad).unwrap_err();
match parse_err {
FlakeLockError::UnsupportedVersion { found, .. } => assert_eq!(found, 6),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn fixtures_load_from_lisp() {
let fixtures = load_fixtures().unwrap();
let names: Vec<_> = fixtures.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"minimal-one-input"));
assert!(names.contains(&"follows-resolved-at-parse"));
}
}