1use std::collections::BTreeMap;
54
55use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
56use serde::{Deserialize, Serialize};
57use tatara_lisp::DeriveTataraDomain;
58
59use crate::SpecError;
60use sui_compat::flake::{FlakeLock, FlakeLockError, FlakeNode, InputRef, LockedInput, OriginalInput};
61
62#[derive(
70 DeriveTataraDomain,
71 Serialize,
72 Deserialize,
73 Archive,
74 RkyvSerialize,
75 RkyvDeserialize,
76 Debug,
77 Clone,
78 Copy,
79 PartialEq,
80 Eq,
81 Hash,
82)]
83#[tatara(keyword = "defgraph-hash")]
84#[rkyv(derive(Debug))]
85pub struct CanonicalGraphHash {
86 pub bytes: [u8; 32],
87}
88
89pub type NodeId = u32;
92
93#[derive(
95 DeriveTataraDomain,
96 Serialize,
97 Deserialize,
98 Archive,
99 RkyvSerialize,
100 RkyvDeserialize,
101 Debug,
102 Clone,
103 PartialEq,
104 Eq,
105)]
106#[tatara(keyword = "deflockfile-graph")]
107#[rkyv(derive(Debug))]
108pub struct LockfileGraph {
109 pub version: u32,
112 pub root_id: NodeId,
114 pub nodes: Vec<InputNode>,
116 pub canonical_hash: CanonicalGraphHash,
120}
121
122#[derive(
124 DeriveTataraDomain,
125 Serialize,
126 Deserialize,
127 Archive,
128 RkyvSerialize,
129 RkyvDeserialize,
130 Debug,
131 Clone,
132 PartialEq,
133 Eq,
134)]
135#[tatara(keyword = "definput-node")]
136#[rkyv(derive(Debug))]
137pub struct InputNode {
138 pub id: NodeId,
140 pub name: String,
143 pub kind: InputKind,
145 pub inputs: Vec<NamedEdge>,
148 pub locked: LockedRef,
150 pub original: OriginalRef,
153}
154
155#[derive(
157 DeriveTataraDomain,
158 Serialize,
159 Deserialize,
160 Archive,
161 RkyvSerialize,
162 RkyvDeserialize,
163 Debug,
164 Clone,
165 PartialEq,
166 Eq,
167)]
168#[tatara(keyword = "defnamed-edge")]
169#[rkyv(derive(Debug))]
170pub struct NamedEdge {
171 pub name: String,
172 pub target: NodeId,
173}
174
175#[derive(
182 Serialize,
183 Deserialize,
184 Archive,
185 RkyvSerialize,
186 RkyvDeserialize,
187 Debug,
188 Clone,
189 Copy,
190 PartialEq,
191 Eq,
192 Hash,
193)]
194#[rkyv(derive(Debug))]
195pub enum InputKind {
196 RootNode,
198 GithubFlake,
200 GitFlake,
202 PathFlake,
204 TarballFlake,
206 OtherFlake,
209 Unknown,
212}
213
214#[derive(
217 Serialize,
218 Deserialize,
219 Archive,
220 RkyvSerialize,
221 RkyvDeserialize,
222 Debug,
223 Clone,
224 PartialEq,
225 Eq,
226)]
227#[rkyv(derive(Debug))]
228pub enum LockedRef {
229 Empty,
230 Github {
231 owner: String,
232 repo: String,
233 rev: String,
234 nar_hash: String,
235 last_modified: u64,
236 },
237 Git {
238 url: String,
239 rev: String,
240 nar_hash: String,
241 last_modified: u64,
242 },
243 Path {
244 path: String,
245 nar_hash: String,
246 last_modified: u64,
247 },
248 Tarball {
249 url: String,
250 nar_hash: String,
251 last_modified: u64,
252 },
253 Other {
254 raw_json: String,
257 },
258}
259
260#[derive(
262 Serialize,
263 Deserialize,
264 Archive,
265 RkyvSerialize,
266 RkyvDeserialize,
267 Debug,
268 Clone,
269 PartialEq,
270 Eq,
271)]
272#[rkyv(derive(Debug))]
273pub enum OriginalRef {
274 Empty,
275 Github {
276 owner: String,
277 repo: String,
278 rev_or_ref: Option<String>,
279 },
280 Git {
281 url: String,
282 rev_or_ref: Option<String>,
283 },
284 Path {
285 path: String,
286 },
287 Tarball {
288 url: String,
289 },
290 Other {
291 raw_json: String,
292 },
293}
294
295#[derive(Debug, thiserror::Error)]
299pub enum LockfileGraphError {
300 #[error("upstream flake.lock parse failed: {0}")]
301 Upstream(#[from] FlakeLockError),
302
303 #[error("unsupported flake-lock version {found} (expected 7)")]
304 UnsupportedVersion { found: u32 },
305
306 #[error("follows chain from {from:?} via {path:?} did not resolve to any node")]
307 UnresolvableFollows { from: String, path: Vec<String> },
308
309 #[error("rkyv archive of canonical graph failed: {0}")]
310 Archive(String),
311}
312
313impl LockfileGraph {
314 pub fn from_flake_lock(lock: &FlakeLock) -> Result<Self, LockfileGraphError> {
334 if lock.version != 7 {
335 return Err(LockfileGraphError::UnsupportedVersion {
336 found: lock.version,
337 });
338 }
339
340 let mut name_to_id: BTreeMap<String, NodeId> = BTreeMap::new();
342 let mut id_to_name: Vec<String> = Vec::new();
343 let mut frontier: Vec<String> = vec![lock.root.clone()];
344 name_to_id.insert(lock.root.clone(), 0);
345 id_to_name.push(lock.root.clone());
346
347 let mut head = 0;
348 while head < frontier.len() {
349 let current = frontier[head].clone();
350 head += 1;
351 let Some(node) = lock.nodes.get(¤t) else {
352 continue;
353 };
354 for (_attr, edge) in &node.inputs {
357 if let Some(target) = follow_target(lock, edge, ¤t) {
358 if !name_to_id.contains_key(&target) {
359 let id = id_to_name.len() as NodeId;
360 name_to_id.insert(target.clone(), id);
361 id_to_name.push(target.clone());
362 frontier.push(target);
363 }
364 }
365 }
366 }
367
368 let mut nodes: Vec<InputNode> = Vec::with_capacity(id_to_name.len());
370 for (id, name) in id_to_name.iter().enumerate() {
371 let upstream = lock.nodes.get(name);
372 let node = match upstream {
373 Some(node) => materialize(id as NodeId, name, node, lock, &name_to_id)?,
374 None => InputNode {
375 id: id as NodeId,
376 name: name.clone(),
377 kind: InputKind::RootNode,
378 inputs: Vec::new(),
379 locked: LockedRef::Empty,
380 original: OriginalRef::Empty,
381 },
382 };
383 nodes.push(node);
384 }
385
386 Ok(Self {
387 version: lock.version,
388 root_id: 0,
389 nodes,
390 canonical_hash: CanonicalGraphHash { bytes: [0u8; 32] },
391 })
392 }
393
394 pub fn archive_and_hash(mut self) -> Result<(Self, Vec<u8>), LockfileGraphError> {
408 let initial = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
409 .map_err(|e| LockfileGraphError::Archive(e.to_string()))?;
410 let hash = blake3::hash(&initial);
411 self.canonical_hash = CanonicalGraphHash { bytes: hash.into() };
412 let stamped = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
413 .map_err(|e| LockfileGraphError::Archive(e.to_string()))?;
414 Ok((self, stamped.to_vec()))
415 }
416}
417
418fn follow_target(lock: &FlakeLock, edge: &InputRef, _from: &str) -> Option<String> {
424 match edge {
425 InputRef::Direct(name) => Some(name.clone()),
426 InputRef::Follows(path) => resolve_follows_path(lock, path),
427 }
428}
429
430fn resolve_follows_path(lock: &FlakeLock, path: &[String]) -> Option<String> {
431 let mut current = lock.root.clone();
432 for step in path {
433 let node = lock.nodes.get(¤t)?;
434 let next = node.inputs.get(step)?;
435 current = match next {
436 InputRef::Direct(name) => name.clone(),
437 InputRef::Follows(inner) => return resolve_follows_path(lock, inner),
438 };
439 }
440 Some(current)
441}
442
443fn materialize(
444 id: NodeId,
445 name: &str,
446 upstream: &FlakeNode,
447 lock: &FlakeLock,
448 name_to_id: &BTreeMap<String, NodeId>,
449) -> Result<InputNode, LockfileGraphError> {
450 let mut edges: Vec<NamedEdge> = Vec::with_capacity(upstream.inputs.len());
451 for (attr, edge) in &upstream.inputs {
452 let target_name = follow_target(lock, edge, name).ok_or_else(|| {
453 LockfileGraphError::UnresolvableFollows {
454 from: name.to_string(),
455 path: match edge {
456 InputRef::Follows(p) => p.clone(),
457 InputRef::Direct(n) => vec![n.clone()],
458 },
459 }
460 })?;
461 let target_id = *name_to_id.get(&target_name).ok_or_else(|| {
462 LockfileGraphError::UnresolvableFollows {
463 from: name.to_string(),
464 path: vec![target_name.clone()],
465 }
466 })?;
467 edges.push(NamedEdge {
468 name: attr.clone(),
469 target: target_id,
470 });
471 }
472
473 let (kind, locked) = classify_locked(upstream.locked.as_ref());
474 let original = classify_original(upstream.original.as_ref());
475 let kind = if name == "root" { InputKind::RootNode } else { kind };
476
477 Ok(InputNode {
478 id,
479 name: name.to_string(),
480 kind,
481 inputs: edges,
482 locked,
483 original,
484 })
485}
486
487fn classify_locked(locked: Option<&LockedInput>) -> (InputKind, LockedRef) {
488 let Some(l) = locked else {
489 return (InputKind::Unknown, LockedRef::Empty);
490 };
491 match l.source_type.as_str() {
492 "github" => (
493 InputKind::GithubFlake,
494 LockedRef::Github {
495 owner: l.owner.clone().unwrap_or_default(),
496 repo: l.repo.clone().unwrap_or_default(),
497 rev: l.rev.clone().unwrap_or_default(),
498 nar_hash: l.nar_hash.clone().unwrap_or_default(),
499 last_modified: l.last_modified.unwrap_or(0),
500 },
501 ),
502 "git" => (
503 InputKind::GitFlake,
504 LockedRef::Git {
505 url: l.url.clone().unwrap_or_default(),
506 rev: l.rev.clone().unwrap_or_default(),
507 nar_hash: l.nar_hash.clone().unwrap_or_default(),
508 last_modified: l.last_modified.unwrap_or(0),
509 },
510 ),
511 "path" => (
512 InputKind::PathFlake,
513 LockedRef::Path {
514 path: l.path.clone().unwrap_or_default(),
515 nar_hash: l.nar_hash.clone().unwrap_or_default(),
516 last_modified: l.last_modified.unwrap_or(0),
517 },
518 ),
519 "tarball" | "file" => (
520 InputKind::TarballFlake,
521 LockedRef::Tarball {
522 url: l.url.clone().unwrap_or_default(),
523 nar_hash: l.nar_hash.clone().unwrap_or_default(),
524 last_modified: l.last_modified.unwrap_or(0),
525 },
526 ),
527 other => (
528 InputKind::OtherFlake,
529 LockedRef::Other {
530 raw_json: serde_json::to_string(other).unwrap_or_default(),
531 },
532 ),
533 }
534}
535
536fn classify_original(original: Option<&OriginalInput>) -> OriginalRef {
537 let Some(o) = original else {
538 return OriginalRef::Empty;
539 };
540 match o.source_type.as_str() {
541 "github" | "gitlab" | "sourcehut" => OriginalRef::Github {
542 owner: o.owner.clone().unwrap_or_default(),
543 repo: o.repo.clone().unwrap_or_default(),
544 rev_or_ref: o.git_ref.clone(),
545 },
546 "git" => OriginalRef::Git {
547 url: o.url.clone().unwrap_or_default(),
548 rev_or_ref: o.git_ref.clone(),
549 },
550 "path" => OriginalRef::Path {
551 path: o.url.clone().unwrap_or_default(),
554 },
555 "tarball" | "file" => OriginalRef::Tarball {
556 url: o.url.clone().unwrap_or_default(),
557 },
558 other => OriginalRef::Other {
559 raw_json: serde_json::to_string(other).unwrap_or_default(),
560 },
561 }
562}
563
564#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
569#[tatara(keyword = "deflockfile-graph-fixture")]
570pub struct LockfileGraphFixture {
571 pub name: String,
572 pub version: u32,
573 #[serde(rename = "rootId")]
574 pub root_id: NodeId,
575 pub nodes: Vec<serde_json::Value>, pub notes: String,
577}
578
579pub const CANONICAL_LOCKFILE_GRAPH_FIXTURES_LISP: &str =
580 include_str!("../specs/lockfile_graph.lisp");
581
582pub fn load_fixtures() -> Result<Vec<LockfileGraphFixture>, SpecError> {
588 crate::loader::load_all::<LockfileGraphFixture>(CANONICAL_LOCKFILE_GRAPH_FIXTURES_LISP)
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594 use pretty_assertions::assert_eq;
595
596 fn minimal_lock_json() -> &'static str {
597 r#"{
598 "nodes": {
599 "root": { "inputs": { "nixpkgs": "nixpkgs" } },
600 "nixpkgs": {
601 "locked": {
602 "lastModified": 1700000000,
603 "narHash": "sha256-deadbeefdeadbeefdeadbeefdeadbeefdeadbeef0=",
604 "owner": "NixOS",
605 "repo": "nixpkgs",
606 "rev": "abc1234567890abc1234567890abc1234567890ab",
607 "type": "github"
608 },
609 "original": { "owner": "NixOS", "repo": "nixpkgs", "type": "github" }
610 }
611 },
612 "root": "root",
613 "version": 7
614 }"#
615 }
616
617 fn follows_lock_json() -> &'static str {
618 r#"{
619 "nodes": {
620 "root": {
621 "inputs": {
622 "nixpkgs": "nixpkgs",
623 "flake-utils": "flake-utils"
624 }
625 },
626 "nixpkgs": {
627 "locked": {
628 "lastModified": 1700000000,
629 "narHash": "sha256-deadbeefdeadbeefdeadbeefdeadbeefdeadbeef0=",
630 "owner": "NixOS",
631 "repo": "nixpkgs",
632 "rev": "abc1234567890abc1234567890abc1234567890ab",
633 "type": "github"
634 },
635 "original": { "owner": "NixOS", "repo": "nixpkgs", "type": "github" }
636 },
637 "flake-utils": {
638 "inputs": { "nixpkgs": ["nixpkgs"] },
639 "locked": {
640 "lastModified": 1700000001,
641 "narHash": "sha256-cafebabecafebabecafebabecafebabecafebabe0=",
642 "owner": "numtide",
643 "repo": "flake-utils",
644 "rev": "0011223344556677889900112233445566778899",
645 "type": "github"
646 },
647 "original": { "owner": "numtide", "repo": "flake-utils", "type": "github" }
648 }
649 },
650 "root": "root",
651 "version": 7
652 }"#
653 }
654
655 #[test]
656 fn minimal_graph_builds_with_root_id_zero() {
657 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
658 let g = LockfileGraph::from_flake_lock(&lock).unwrap();
659 assert_eq!(g.version, 7);
660 assert_eq!(g.root_id, 0);
661 assert_eq!(g.nodes.len(), 2);
662 assert_eq!(g.nodes[0].name, "root");
663 assert_eq!(g.nodes[0].kind, InputKind::RootNode);
664 assert_eq!(g.nodes[1].name, "nixpkgs");
665 assert_eq!(g.nodes[1].kind, InputKind::GithubFlake);
666 }
667
668 #[test]
669 fn follows_are_resolved_at_parse_time() {
670 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
671 let g = LockfileGraph::from_flake_lock(&lock).unwrap();
672 assert_eq!(g.nodes.len(), 3);
673
674 let root = &g.nodes[0];
676 let edge_names: Vec<&str> = root.inputs.iter().map(|e| e.name.as_str()).collect();
677 assert!(edge_names.contains(&"nixpkgs"));
678 assert!(edge_names.contains(&"flake-utils"));
679
680 let nixpkgs_id_via_root = root
683 .inputs
684 .iter()
685 .find(|e| e.name == "nixpkgs")
686 .map(|e| e.target)
687 .unwrap();
688 let flake_utils = g.nodes.iter().find(|n| n.name == "flake-utils").unwrap();
689 let nixpkgs_id_via_utils = flake_utils
690 .inputs
691 .iter()
692 .find(|e| e.name == "nixpkgs")
693 .map(|e| e.target)
694 .unwrap();
695 assert_eq!(nixpkgs_id_via_root, nixpkgs_id_via_utils);
696 }
697
698 #[test]
699 fn archive_and_hash_stamps_canonical_hash() {
700 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
701 let g = LockfileGraph::from_flake_lock(&lock).unwrap();
702 assert_eq!(g.canonical_hash.bytes, [0u8; 32]);
703 let (stamped, bytes) = g.archive_and_hash().unwrap();
704 assert_ne!(stamped.canonical_hash.bytes, [0u8; 32]);
705 assert!(!bytes.is_empty());
706 }
707
708 #[test]
709 fn archive_roundtrips_via_rkyv() {
710 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
711 let g = LockfileGraph::from_flake_lock(&lock).unwrap();
712 let (_stamped, bytes) = g.clone().archive_and_hash().unwrap();
713 let archived =
714 rkyv::access::<ArchivedLockfileGraph, rkyv::rancor::Error>(&bytes).unwrap();
715 assert_eq!(archived.version, 7);
716 assert_eq!(archived.root_id, 0);
717 assert_eq!(archived.nodes.len(), 3);
718 }
719
720 #[test]
721 fn unsupported_version_rejected() {
722 let bad = r#"{ "nodes": {"root":{}}, "root":"root", "version": 6 }"#;
723 let parse_err = FlakeLock::parse(bad).unwrap_err();
726 match parse_err {
727 FlakeLockError::UnsupportedVersion { found, .. } => assert_eq!(found, 6),
728 other => panic!("unexpected error: {other:?}"),
729 }
730 }
731
732 #[test]
733 fn fixtures_load_from_lisp() {
734 let fixtures = load_fixtures().unwrap();
735 let names: Vec<_> = fixtures.iter().map(|f| f.name.as_str()).collect();
736 assert!(names.contains(&"minimal-one-input"));
737 assert!(names.contains(&"follows-resolved-at-parse"));
738 }
739}