1use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, thiserror::Error)]
15pub enum FlakeLockError {
16 #[error("JSON parse error: {0}")]
17 Json(#[from] serde_json::Error),
18 #[error("unsupported lock version {found} (expected {expected})")]
19 UnsupportedVersion { expected: u32, found: u32 },
20 #[error("missing root node `{0}`")]
21 MissingRoot(String),
22 #[error("node not found: {0}")]
23 NodeNotFound(String),
24 #[error("follows resolution failed for path {path:?} starting from `{from}`")]
25 FollowsFailed { from: String, path: Vec<String> },
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct FlakeLock {
33 pub nodes: BTreeMap<String, FlakeNode>,
35 pub root: String,
37 pub version: u32,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct FlakeNode {
44 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
47 pub inputs: BTreeMap<String, InputRef>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub locked: Option<LockedInput>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub original: Option<OriginalInput>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub flake: Option<bool>,
57 #[serde(flatten)]
61 pub extra: BTreeMap<String, serde_json::Value>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(untagged)]
72pub enum InputRef {
73 Direct(String),
75 Follows(Vec<String>),
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct LockedInput {
82 #[serde(rename = "type")]
83 pub source_type: String,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub owner: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub repo: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub rev: Option<String>,
90 #[serde(default, rename = "narHash", skip_serializing_if = "Option::is_none")]
91 pub nar_hash: Option<String>,
92 #[serde(
93 default,
94 rename = "lastModified",
95 skip_serializing_if = "Option::is_none"
96 )]
97 pub last_modified: Option<u64>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub path: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub url: Option<String>,
104 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
106 pub git_ref: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub dir: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub host: Option<String>,
115 #[serde(flatten)]
119 pub extra: BTreeMap<String, serde_json::Value>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct OriginalInput {
125 #[serde(rename = "type")]
126 pub source_type: String,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub owner: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub repo: Option<String>,
131 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
133 pub git_ref: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub url: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub dir: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub id: Option<String>,
140 #[serde(flatten)]
142 pub extra: BTreeMap<String, serde_json::Value>,
143}
144
145const SUPPORTED_VERSION: u32 = 7;
148
149impl FlakeLock {
150 pub fn parse(json: &str) -> Result<Self, FlakeLockError> {
152 let lock: FlakeLock = serde_json::from_str(json)?;
153 if lock.version != SUPPORTED_VERSION {
154 return Err(FlakeLockError::UnsupportedVersion {
155 expected: SUPPORTED_VERSION,
156 found: lock.version,
157 });
158 }
159 if !lock.nodes.contains_key(&lock.root) {
160 return Err(FlakeLockError::MissingRoot(lock.root.clone()));
161 }
162 Ok(lock)
163 }
164
165 pub fn to_json(&self) -> Result<String, FlakeLockError> {
167 Ok(serde_json::to_string_pretty(self)?)
168 }
169
170 pub fn root_node(&self) -> Result<&FlakeNode, FlakeLockError> {
172 self.nodes
173 .get(&self.root)
174 .ok_or_else(|| FlakeLockError::MissingRoot(self.root.clone()))
175 }
176
177 pub fn get_node(&self, name: &str) -> Result<&FlakeNode, FlakeLockError> {
179 self.nodes
180 .get(name)
181 .ok_or_else(|| FlakeLockError::NodeNotFound(name.to_string()))
182 }
183
184 pub fn root_inputs(&self) -> Result<Vec<(String, String)>, FlakeLockError> {
187 let root = self.root_node()?;
188 let mut out = Vec::new();
189 for (input_name, input_ref) in &root.inputs {
190 let resolved = self.resolve_ref(&self.root, input_ref)?;
191 out.push((input_name.clone(), resolved));
192 }
193 Ok(out)
194 }
195
196 pub fn resolve_ref(
203 &self,
204 _parent: &str,
205 input_ref: &InputRef,
206 ) -> Result<String, FlakeLockError> {
207 match input_ref {
208 InputRef::Direct(name) => {
209 if self.nodes.contains_key(name) {
210 Ok(name.clone())
211 } else {
212 Err(FlakeLockError::NodeNotFound(name.clone()))
213 }
214 }
215 InputRef::Follows(path) => self.resolve_follows_path(path),
216 }
217 }
218
219 fn resolve_follows_path(&self, path: &[String]) -> Result<String, FlakeLockError> {
225 if path.is_empty() {
226 return Err(FlakeLockError::FollowsFailed {
227 from: self.root.clone(),
228 path: vec![],
229 });
230 }
231
232 let mut current_node_name = self.root.clone();
233
234 for segment in path {
235 let node = self.nodes.get(¤t_node_name).ok_or_else(|| {
236 FlakeLockError::FollowsFailed {
237 from: current_node_name.clone(),
238 path: path.to_vec(),
239 }
240 })?;
241
242 let input_ref =
243 node.inputs.get(segment).ok_or_else(|| FlakeLockError::FollowsFailed {
244 from: current_node_name.clone(),
245 path: path.to_vec(),
246 })?;
247
248 current_node_name = match input_ref {
250 InputRef::Direct(name) => name.clone(),
251 InputRef::Follows(inner_path) => self.resolve_follows_path(inner_path)?,
252 };
253 }
254
255 Ok(current_node_name)
256 }
257
258 pub fn resolve_input(&self, path: &[&str]) -> Result<&FlakeNode, FlakeLockError> {
264 let mut current_name = self.root.clone();
265
266 for &segment in path {
267 let node = self.nodes.get(¤t_name).ok_or_else(|| {
268 FlakeLockError::NodeNotFound(current_name.clone())
269 })?;
270
271 let input_ref = node.inputs.get(segment).ok_or_else(|| {
272 FlakeLockError::NodeNotFound(format!("{current_name}.inputs.{segment}"))
273 })?;
274
275 current_name = self.resolve_ref(¤t_name, input_ref)?;
276 }
277
278 self.nodes
279 .get(¤t_name)
280 .ok_or(FlakeLockError::NodeNotFound(current_name))
281 }
282
283 pub fn adjacency_map(&self) -> BTreeMap<String, Vec<(String, String)>> {
288 let mut map: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
289
290 for (node_name, node) in &self.nodes {
291 let mut edges = Vec::new();
292 for (input_name, input_ref) in &node.inputs {
293 if let Ok(target) = self.resolve_ref(node_name, input_ref) {
294 edges.push((input_name.clone(), target));
295 }
296 }
297 map.insert(node_name.clone(), edges);
298 }
299
300 map
301 }
302}
303
304#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn minimal_lock_json() -> &'static str {
314 r#"{
315 "nodes": {
316 "nixpkgs": {
317 "locked": {
318 "lastModified": 1700000000,
319 "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
320 "owner": "nixos",
321 "repo": "nixpkgs",
322 "rev": "abc123def456abc123def456abc123def456abc1",
323 "type": "github"
324 },
325 "original": {
326 "owner": "nixos",
327 "ref": "nixos-unstable",
328 "repo": "nixpkgs",
329 "type": "github"
330 }
331 },
332 "root": {
333 "inputs": {
334 "nixpkgs": "nixpkgs"
335 }
336 }
337 },
338 "root": "root",
339 "version": 7
340}"#
341 }
342
343 fn follows_lock_json() -> &'static str {
345 r#"{
346 "nodes": {
347 "nixpkgs": {
348 "locked": {
349 "lastModified": 1700000000,
350 "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
351 "owner": "nixos",
352 "repo": "nixpkgs",
353 "rev": "abc123def456abc123def456abc123def456abc1",
354 "type": "github"
355 },
356 "original": {
357 "owner": "nixos",
358 "ref": "nixos-unstable",
359 "repo": "nixpkgs",
360 "type": "github"
361 }
362 },
363 "root": {
364 "inputs": {
365 "nixpkgs": "nixpkgs",
366 "utils": "utils"
367 }
368 },
369 "systems": {
370 "locked": {
371 "lastModified": 1699999999,
372 "narHash": "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
373 "owner": "nix-systems",
374 "repo": "default",
375 "rev": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1",
376 "type": "github"
377 },
378 "original": {
379 "owner": "nix-systems",
380 "repo": "default",
381 "type": "github"
382 }
383 },
384 "utils": {
385 "inputs": {
386 "nixpkgs": [
387 "nixpkgs"
388 ],
389 "systems": "systems"
390 },
391 "locked": {
392 "lastModified": 1699999998,
393 "narHash": "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
394 "owner": "numtide",
395 "repo": "flake-utils",
396 "rev": "ccccccccccccccccccccccccccccccccccccccc1",
397 "type": "github"
398 },
399 "original": {
400 "owner": "numtide",
401 "repo": "flake-utils",
402 "type": "github"
403 }
404 }
405 },
406 "root": "root",
407 "version": 7
408}"#
409 }
410
411 fn deep_follows_json() -> &'static str {
414 r#"{
415 "nodes": {
416 "nixpkgs": {
417 "locked": {
418 "lastModified": 1700000000,
419 "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
420 "owner": "nixos",
421 "repo": "nixpkgs",
422 "rev": "abc123",
423 "type": "github"
424 },
425 "original": {
426 "owner": "nixos",
427 "ref": "nixos-unstable",
428 "repo": "nixpkgs",
429 "type": "github"
430 }
431 },
432 "root": {
433 "inputs": {
434 "bar": "bar",
435 "foo": "foo",
436 "nixpkgs": "nixpkgs"
437 }
438 },
439 "foo": {
440 "inputs": {
441 "nixpkgs": [
442 "nixpkgs"
443 ]
444 },
445 "locked": {
446 "lastModified": 1700000001,
447 "narHash": "sha256-FOO",
448 "owner": "example",
449 "repo": "foo",
450 "rev": "foofoo",
451 "type": "github"
452 },
453 "original": {
454 "owner": "example",
455 "repo": "foo",
456 "type": "github"
457 }
458 },
459 "bar": {
460 "inputs": {
461 "nixpkgs": [
462 "foo",
463 "nixpkgs"
464 ]
465 },
466 "locked": {
467 "lastModified": 1700000002,
468 "narHash": "sha256-BAR",
469 "owner": "example",
470 "repo": "bar",
471 "rev": "barbar",
472 "type": "github"
473 },
474 "original": {
475 "owner": "example",
476 "repo": "bar",
477 "type": "github"
478 }
479 }
480 },
481 "root": "root",
482 "version": 7
483}"#
484 }
485
486 #[test]
489 fn parse_minimal_lock() {
490 let lock = FlakeLock::parse(minimal_lock_json()).expect("parse failed");
491 assert_eq!(lock.version, 7);
492 assert_eq!(lock.root, "root");
493 assert_eq!(lock.nodes.len(), 2);
494 assert!(lock.nodes.contains_key("root"));
495 assert!(lock.nodes.contains_key("nixpkgs"));
496 }
497
498 #[test]
499 fn minimal_root_node_has_no_locked() {
500 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
501 let root = lock.root_node().unwrap();
502 assert!(root.locked.is_none());
503 assert!(root.original.is_none());
504 }
505
506 #[test]
507 fn minimal_nixpkgs_locked_fields() {
508 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
509 let nixpkgs = lock.get_node("nixpkgs").unwrap();
510 let locked = nixpkgs.locked.as_ref().expect("missing locked");
511 assert_eq!(locked.source_type, "github");
512 assert_eq!(locked.owner.as_deref(), Some("nixos"));
513 assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
514 assert_eq!(
515 locked.rev.as_deref(),
516 Some("abc123def456abc123def456abc123def456abc1"),
517 );
518 assert_eq!(locked.last_modified, Some(1_700_000_000));
519 assert!(locked.nar_hash.is_some());
520 }
521
522 #[test]
523 fn minimal_nixpkgs_original_fields() {
524 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
525 let nixpkgs = lock.get_node("nixpkgs").unwrap();
526 let original = nixpkgs.original.as_ref().expect("missing original");
527 assert_eq!(original.source_type, "github");
528 assert_eq!(original.owner.as_deref(), Some("nixos"));
529 assert_eq!(original.repo.as_deref(), Some("nixpkgs"));
530 assert_eq!(original.git_ref.as_deref(), Some("nixos-unstable"));
531 }
532
533 #[test]
534 fn minimal_root_inputs() {
535 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
536 let inputs = lock.root_inputs().unwrap();
537 assert_eq!(inputs.len(), 1);
538 assert_eq!(inputs[0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
539 }
540
541 #[test]
544 fn parse_follows_lock() {
545 let lock = FlakeLock::parse(follows_lock_json()).expect("parse failed");
546 assert_eq!(lock.nodes.len(), 4); }
548
549 #[test]
550 fn follows_utils_nixpkgs_resolves_to_root_nixpkgs() {
551 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
552 let utils = lock.get_node("utils").unwrap();
553 let nixpkgs_ref = &utils.inputs["nixpkgs"];
554 assert_eq!(nixpkgs_ref, &InputRef::Follows(vec!["nixpkgs".to_string()]));
555
556 let resolved = lock.resolve_ref("utils", nixpkgs_ref).unwrap();
558 assert_eq!(resolved, "nixpkgs");
559 }
560
561 #[test]
562 fn resolve_input_walk_utils_nixpkgs() {
563 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
564 let node = lock.resolve_input(&["utils", "nixpkgs"]).unwrap();
567 let locked = node.locked.as_ref().unwrap();
568 assert_eq!(locked.owner.as_deref(), Some("nixos"));
569 assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
570 }
571
572 #[test]
573 fn resolve_input_walk_utils_systems() {
574 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
575 let node = lock.resolve_input(&["utils", "systems"]).unwrap();
576 let locked = node.locked.as_ref().unwrap();
577 assert_eq!(locked.owner.as_deref(), Some("nix-systems"));
578 assert_eq!(locked.repo.as_deref(), Some("default"));
579 }
580
581 #[test]
584 fn deep_follows_bar_nixpkgs_resolves_through_foo() {
585 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
586
587 let node = lock.resolve_input(&["bar", "nixpkgs"]).unwrap();
593 let locked = node.locked.as_ref().unwrap();
594 assert_eq!(locked.owner.as_deref(), Some("nixos"));
595 assert_eq!(locked.rev.as_deref(), Some("abc123"));
596 }
597
598 #[test]
599 fn deep_follows_foo_nixpkgs_resolves_to_root() {
600 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
601 let node = lock.resolve_input(&["foo", "nixpkgs"]).unwrap();
602 let locked = node.locked.as_ref().unwrap();
603 assert_eq!(locked.owner.as_deref(), Some("nixos"));
604 }
605
606 #[test]
609 fn adjacency_map_follows_lock() {
610 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
611 let adj = lock.adjacency_map();
612
613 let root_edges = &adj["root"];
615 assert_eq!(root_edges.len(), 2);
616 assert!(root_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
617 assert!(root_edges.contains(&("utils".to_string(), "utils".to_string())));
618
619 let utils_edges = &adj["utils"];
621 assert_eq!(utils_edges.len(), 2);
622 assert!(utils_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
623 assert!(utils_edges.contains(&("systems".to_string(), "systems".to_string())));
624
625 assert!(adj["nixpkgs"].is_empty());
627 assert!(adj["systems"].is_empty());
628 }
629
630 #[test]
633 fn rejects_unsupported_version() {
634 let json = r#"{ "nodes": { "root": { "inputs": {} } }, "root": "root", "version": 6 }"#;
635 let err = FlakeLock::parse(json).unwrap_err();
636 assert!(matches!(err, FlakeLockError::UnsupportedVersion { found: 6, .. }));
637 }
638
639 #[test]
640 fn rejects_missing_root_node() {
641 let json = r#"{ "nodes": { "x": {} }, "root": "root", "version": 7 }"#;
642 let err = FlakeLock::parse(json).unwrap_err();
643 assert!(matches!(err, FlakeLockError::MissingRoot(_)));
644 }
645
646 #[test]
647 fn get_node_missing_returns_error() {
648 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
649 assert!(lock.get_node("nonexistent").is_err());
650 }
651
652 #[test]
653 fn resolve_input_missing_segment_returns_error() {
654 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
655 let result = lock.resolve_input(&["nonexistent"]);
656 assert!(result.is_err());
657 }
658
659 #[test]
660 fn resolve_ref_direct_missing_node_returns_error() {
661 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
662 let result = lock.resolve_ref("root", &InputRef::Direct("ghost".to_string()));
663 assert!(result.is_err());
664 }
665
666 #[test]
667 fn resolve_follows_empty_path_returns_error() {
668 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
669 let result = lock.resolve_ref("root", &InputRef::Follows(vec![]));
670 assert!(result.is_err());
671 }
672
673 #[test]
674 fn resolve_follows_bad_segment_returns_error() {
675 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
676 let result = lock.resolve_ref(
677 "utils",
678 &InputRef::Follows(vec!["nonexistent".to_string()]),
679 );
680 assert!(result.is_err());
681 }
682
683 #[test]
686 fn roundtrip_minimal() {
687 let original = FlakeLock::parse(minimal_lock_json()).unwrap();
688 let json = original.to_json().unwrap();
689 let reparsed = FlakeLock::parse(&json).unwrap();
690
691 assert_eq!(reparsed.version, original.version);
692 assert_eq!(reparsed.root, original.root);
693 assert_eq!(reparsed.nodes.len(), original.nodes.len());
694
695 let np = reparsed.get_node("nixpkgs").unwrap();
697 let locked = np.locked.as_ref().unwrap();
698 assert_eq!(locked.rev.as_deref(), Some("abc123def456abc123def456abc123def456abc1"));
699 }
700
701 #[test]
702 fn roundtrip_with_follows() {
703 let original = FlakeLock::parse(follows_lock_json()).unwrap();
704 let json = original.to_json().unwrap();
705 let reparsed = FlakeLock::parse(&json).unwrap();
706
707 assert_eq!(reparsed.nodes.len(), original.nodes.len());
708
709 let utils = reparsed.get_node("utils").unwrap();
711 assert_eq!(
712 utils.inputs["nixpkgs"],
713 InputRef::Follows(vec!["nixpkgs".to_string()]),
714 );
715
716 let node = reparsed.resolve_input(&["utils", "nixpkgs"]).unwrap();
718 assert_eq!(
719 node.locked.as_ref().unwrap().owner.as_deref(),
720 Some("nixos"),
721 );
722 }
723
724 #[test]
727 fn parse_non_flake_input() {
728 let json = r#"{
729 "nodes": {
730 "data": {
731 "flake": false,
732 "locked": {
733 "lastModified": 1700000000,
734 "narHash": "sha256-DATA",
735 "owner": "someone",
736 "repo": "data-files",
737 "rev": "deadbeef",
738 "type": "github"
739 },
740 "original": {
741 "owner": "someone",
742 "repo": "data-files",
743 "type": "github"
744 }
745 },
746 "root": {
747 "inputs": {
748 "data": "data"
749 }
750 }
751 },
752 "root": "root",
753 "version": 7
754}"#;
755 let lock = FlakeLock::parse(json).unwrap();
756 let data = lock.get_node("data").unwrap();
757 assert_eq!(data.flake, Some(false));
758 }
759
760 #[test]
763 fn input_ref_direct_deserialize() {
764 let v: InputRef = serde_json::from_str(r#""nixpkgs""#).unwrap();
765 assert_eq!(v, InputRef::Direct("nixpkgs".to_string()));
766 }
767
768 #[test]
769 fn input_ref_follows_deserialize() {
770 let v: InputRef = serde_json::from_str(r#"["nixpkgs"]"#).unwrap();
771 assert_eq!(v, InputRef::Follows(vec!["nixpkgs".to_string()]));
772 }
773
774 #[test]
775 fn input_ref_follows_multi_segment_deserialize() {
776 let v: InputRef = serde_json::from_str(r#"["foo", "nixpkgs"]"#).unwrap();
777 assert_eq!(
778 v,
779 InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
780 );
781 }
782
783 #[test]
784 fn input_ref_direct_roundtrip() {
785 let original = InputRef::Direct("nixpkgs".to_string());
786 let json = serde_json::to_string(&original).unwrap();
787 let reparsed: InputRef = serde_json::from_str(&json).unwrap();
788 assert_eq!(original, reparsed);
789 }
790
791 #[test]
792 fn input_ref_follows_roundtrip() {
793 let original = InputRef::Follows(vec!["foo".to_string(), "bar".to_string()]);
794 let json = serde_json::to_string(&original).unwrap();
795 let reparsed: InputRef = serde_json::from_str(&json).unwrap();
796 assert_eq!(original, reparsed);
797 }
798
799 #[test]
802 fn parse_path_type_locked_input() {
803 let json = r#"{
804 "nodes": {
805 "local": {
806 "locked": {
807 "lastModified": 1700000000,
808 "narHash": "sha256-PATH",
809 "path": "/home/user/my-flake",
810 "type": "path"
811 },
812 "original": {
813 "type": "path",
814 "url": "/home/user/my-flake"
815 }
816 },
817 "root": {
818 "inputs": {
819 "local": "local"
820 }
821 }
822 },
823 "root": "root",
824 "version": 7
825}"#;
826 let lock = FlakeLock::parse(json).unwrap();
827 let local = lock.get_node("local").unwrap();
828 let locked = local.locked.as_ref().unwrap();
829 assert_eq!(locked.source_type, "path");
830 assert_eq!(locked.path.as_deref(), Some("/home/user/my-flake"));
831 }
832
833 #[test]
836 fn multiple_inputs_follow_same_target() {
837 let json = r#"{
838 "nodes": {
839 "nixpkgs": {
840 "locked": {
841 "lastModified": 1700000000,
842 "narHash": "sha256-NP",
843 "owner": "nixos",
844 "repo": "nixpkgs",
845 "rev": "aaa",
846 "type": "github"
847 },
848 "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
849 },
850 "root": {
851 "inputs": {
852 "a": "a",
853 "b": "b",
854 "nixpkgs": "nixpkgs"
855 }
856 },
857 "a": {
858 "inputs": { "nixpkgs": ["nixpkgs"] },
859 "locked": {
860 "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github"
861 },
862 "original": { "owner": "x", "repo": "a", "type": "github" }
863 },
864 "b": {
865 "inputs": { "nixpkgs": ["nixpkgs"] },
866 "locked": {
867 "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github"
868 },
869 "original": { "owner": "x", "repo": "b", "type": "github" }
870 }
871 },
872 "root": "root",
873 "version": 7
874}"#;
875 let lock = FlakeLock::parse(json).unwrap();
876
877 let a_np = lock.resolve_input(&["a", "nixpkgs"]).unwrap();
879 let b_np = lock.resolve_input(&["b", "nixpkgs"]).unwrap();
880
881 assert_eq!(
882 a_np.locked.as_ref().unwrap().rev.as_deref(),
883 Some("aaa"),
884 );
885 assert_eq!(
886 b_np.locked.as_ref().unwrap().rev.as_deref(),
887 Some("aaa"),
888 );
889 }
890
891 #[test]
894 fn invalid_json_returns_error() {
895 assert!(FlakeLock::parse("not json").is_err());
896 }
897
898 #[test]
899 fn empty_object_returns_error() {
900 assert!(FlakeLock::parse("{}").is_err());
901 }
902
903 #[test]
906 fn flake_false_node_default_is_none() {
907 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
908 let nixpkgs = lock.get_node("nixpkgs").unwrap();
909 assert_eq!(nixpkgs.flake, None);
910 }
911
912 #[test]
913 fn flake_false_roundtrips_through_json() {
914 let json = r#"{
915 "nodes": {
916 "data-files": {
917 "flake": false,
918 "locked": {
919 "lastModified": 1700000000,
920 "narHash": "sha256-DATA",
921 "owner": "example",
922 "repo": "data",
923 "rev": "abc123",
924 "type": "github"
925 },
926 "original": {
927 "owner": "example",
928 "repo": "data",
929 "type": "github"
930 }
931 },
932 "root": {
933 "inputs": {
934 "data-files": "data-files"
935 }
936 }
937 },
938 "root": "root",
939 "version": 7
940}"#;
941 let lock = FlakeLock::parse(json).unwrap();
942 let data = lock.get_node("data-files").unwrap();
943 assert_eq!(data.flake, Some(false));
944
945 let reserialized = lock.to_json().unwrap();
946 let reparsed = FlakeLock::parse(&reserialized).unwrap();
947 let data2 = reparsed.get_node("data-files").unwrap();
948 assert_eq!(data2.flake, Some(false));
949 }
950
951 #[test]
954 fn follows_of_follows_three_levels() {
955 let json = r#"{
956 "nodes": {
957 "nixpkgs": {
958 "locked": {
959 "lastModified": 1700000000,
960 "narHash": "sha256-NP",
961 "owner": "nixos",
962 "repo": "nixpkgs",
963 "rev": "final",
964 "type": "github"
965 },
966 "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
967 },
968 "root": {
969 "inputs": {
970 "a": "a",
971 "b": "b",
972 "c": "c",
973 "nixpkgs": "nixpkgs"
974 }
975 },
976 "a": {
977 "inputs": { "nixpkgs": ["nixpkgs"] },
978 "locked": { "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
979 "original": { "owner": "x", "repo": "a", "type": "github" }
980 },
981 "b": {
982 "inputs": { "nixpkgs": ["a", "nixpkgs"] },
983 "locked": { "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
984 "original": { "owner": "x", "repo": "b", "type": "github" }
985 },
986 "c": {
987 "inputs": { "nixpkgs": ["b", "nixpkgs"] },
988 "locked": { "lastModified": 3, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
989 "original": { "owner": "x", "repo": "c", "type": "github" }
990 }
991 },
992 "root": "root",
993 "version": 7
994}"#;
995 let lock = FlakeLock::parse(json).unwrap();
996
997 let node = lock.resolve_input(&["c", "nixpkgs"]).unwrap();
1004 assert_eq!(
1005 node.locked.as_ref().unwrap().rev.as_deref(),
1006 Some("final"),
1007 );
1008 }
1009
1010 #[test]
1013 fn malformed_version_string() {
1014 let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": "seven" }"#;
1015 assert!(FlakeLock::parse(json).is_err());
1016 }
1017
1018 #[test]
1019 fn malformed_input_ref_integer() {
1020 let json = r#"{
1021 "nodes": {
1022 "root": {
1023 "inputs": { "x": 42 }
1024 }
1025 },
1026 "root": "root",
1027 "version": 7
1028}"#;
1029 assert!(FlakeLock::parse(json).is_err());
1030 }
1031
1032 #[test]
1033 fn missing_version_field() {
1034 let json = r#"{ "nodes": { "root": {} }, "root": "root" }"#;
1035 assert!(FlakeLock::parse(json).is_err());
1036 }
1037
1038 #[test]
1039 fn null_root_field() {
1040 let json = r#"{ "nodes": { "root": {} }, "root": null, "version": 7 }"#;
1041 assert!(FlakeLock::parse(json).is_err());
1042 }
1043
1044 #[test]
1047 fn roundtrip_deep_follows() {
1048 let original = FlakeLock::parse(deep_follows_json()).unwrap();
1049 let json = original.to_json().unwrap();
1050 let reparsed = FlakeLock::parse(&json).unwrap();
1051
1052 assert_eq!(reparsed.nodes.len(), original.nodes.len());
1053 let bar = reparsed.get_node("bar").unwrap();
1054 assert_eq!(
1055 bar.inputs["nixpkgs"],
1056 InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
1057 );
1058 }
1059
1060 #[test]
1063 fn adjacency_map_deep_follows() {
1064 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1065 let adj = lock.adjacency_map();
1066
1067 let root_edges = &adj["root"];
1068 assert_eq!(root_edges.len(), 3);
1069
1070 let bar_edges = &adj["bar"];
1071 assert_eq!(bar_edges.len(), 1);
1072 assert!(bar_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
1073 }
1074
1075 #[test]
1078 fn follows_chain_four_levels_deep() {
1079 let json = r#"{
1080 "nodes": {
1081 "nixpkgs": {
1082 "locked": { "lastModified": 1, "narHash": "sha256-NP", "owner": "n", "repo": "p", "rev": "final", "type": "github" },
1083 "original": { "owner": "n", "repo": "p", "type": "github" }
1084 },
1085 "root": {
1086 "inputs": { "a": "a", "b": "b", "c": "c", "d": "d", "nixpkgs": "nixpkgs" }
1087 },
1088 "a": {
1089 "inputs": { "nixpkgs": ["nixpkgs"] },
1090 "locked": { "lastModified": 2, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
1091 "original": { "owner": "x", "repo": "a", "type": "github" }
1092 },
1093 "b": {
1094 "inputs": { "nixpkgs": ["a", "nixpkgs"] },
1095 "locked": { "lastModified": 3, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
1096 "original": { "owner": "x", "repo": "b", "type": "github" }
1097 },
1098 "c": {
1099 "inputs": { "nixpkgs": ["b", "nixpkgs"] },
1100 "locked": { "lastModified": 4, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
1101 "original": { "owner": "x", "repo": "c", "type": "github" }
1102 },
1103 "d": {
1104 "inputs": { "nixpkgs": ["c", "nixpkgs"] },
1105 "locked": { "lastModified": 5, "narHash": "sha256-D", "owner": "x", "repo": "d", "rev": "d1", "type": "github" },
1106 "original": { "owner": "x", "repo": "d", "type": "github" }
1107 }
1108 },
1109 "root": "root",
1110 "version": 7
1111}"#;
1112 let lock = FlakeLock::parse(json).unwrap();
1113 let node = lock.resolve_input(&["d", "nixpkgs"]).unwrap();
1115 assert_eq!(node.locked.as_ref().unwrap().rev.as_deref(), Some("final"));
1116 }
1117
1118 #[test]
1121 fn flake_node_extra_field_roundtrips() {
1122 let json = r#"{
1124 "nodes": {
1125 "root": {
1126 "inputs": { "self-ref": "self-ref" }
1127 },
1128 "self-ref": {
1129 "locked": {
1130 "lastModified": 1700000000,
1131 "narHash": "sha256-X",
1132 "path": "/tmp/foo",
1133 "type": "path"
1134 },
1135 "original": {
1136 "type": "path",
1137 "url": "/tmp/foo"
1138 },
1139 "parent": ["root"]
1140 }
1141 },
1142 "root": "root",
1143 "version": 7
1144}"#;
1145 let lock = FlakeLock::parse(json).unwrap();
1146 let node = lock.get_node("self-ref").unwrap();
1147 assert!(node.extra.contains_key("parent"));
1148
1149 let reserialized = lock.to_json().unwrap();
1150 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1151 let node2 = reparsed.get_node("self-ref").unwrap();
1152 assert!(node2.extra.contains_key("parent"));
1153 }
1154
1155 #[test]
1158 fn original_input_extra_fields_roundtrip() {
1159 let json = r#"{
1160 "nodes": {
1161 "root": { "inputs": { "x": "x" } },
1162 "x": {
1163 "locked": {
1164 "lastModified": 1700000000,
1165 "narHash": "sha256-X",
1166 "owner": "o",
1167 "repo": "r",
1168 "rev": "abc",
1169 "type": "github"
1170 },
1171 "original": {
1172 "owner": "o",
1173 "repo": "r",
1174 "type": "github",
1175 "submodules": true,
1176 "shallow": false
1177 }
1178 }
1179 },
1180 "root": "root",
1181 "version": 7
1182}"#;
1183 let lock = FlakeLock::parse(json).unwrap();
1184 let x = lock.get_node("x").unwrap();
1185 let original = x.original.as_ref().unwrap();
1186 assert_eq!(original.extra.get("submodules"), Some(&serde_json::json!(true)));
1187 assert_eq!(original.extra.get("shallow"), Some(&serde_json::json!(false)));
1188
1189 let reserialized = lock.to_json().unwrap();
1190 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1191 let x2 = reparsed.get_node("x").unwrap();
1192 let orig2 = x2.original.as_ref().unwrap();
1193 assert_eq!(orig2.extra.get("submodules"), Some(&serde_json::json!(true)));
1194 }
1195
1196 #[test]
1199 fn unsupported_version_error_includes_found() {
1200 let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 99 }"#;
1201 let err = FlakeLock::parse(json).unwrap_err();
1202 match err {
1203 FlakeLockError::UnsupportedVersion { expected, found } => {
1204 assert_eq!(expected, 7);
1205 assert_eq!(found, 99);
1206 }
1207 other => panic!("expected UnsupportedVersion, got {other:?}"),
1208 }
1209 }
1210
1211 #[test]
1212 fn missing_root_error_includes_name() {
1213 let json = r#"{ "nodes": { "x": {} }, "root": "missing-root", "version": 7 }"#;
1214 match FlakeLock::parse(json).unwrap_err() {
1215 FlakeLockError::MissingRoot(name) => assert_eq!(name, "missing-root"),
1216 other => panic!("expected MissingRoot, got {other:?}"),
1217 }
1218 }
1219
1220 #[test]
1221 fn get_node_returns_node_not_found_with_name() {
1222 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1223 match lock.get_node("nope") {
1224 Err(FlakeLockError::NodeNotFound(n)) => assert_eq!(n, "nope"),
1225 other => panic!("expected NodeNotFound, got {other:?}"),
1226 }
1227 }
1228
1229 #[test]
1232 fn version_as_float_rejected() {
1233 let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 7.5 }"#;
1234 assert!(FlakeLock::parse(json).is_err());
1235 }
1236
1237 #[test]
1240 fn adjacency_map_minimal() {
1241 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1242 let adj = lock.adjacency_map();
1243 assert_eq!(adj.len(), 2);
1244 assert_eq!(adj["root"].len(), 1);
1245 assert_eq!(adj["root"][0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
1246 assert!(adj["nixpkgs"].is_empty());
1247 }
1248
1249 #[test]
1252 fn adjacency_map_skips_unresolvable() {
1253 let mut nodes = BTreeMap::new();
1255 nodes.insert("root".to_string(), FlakeNode {
1256 inputs: {
1257 let mut m = BTreeMap::new();
1258 m.insert("ghost".to_string(), InputRef::Direct("nonexistent".to_string()));
1259 m
1260 },
1261 locked: None,
1262 original: None,
1263 flake: None,
1264 extra: BTreeMap::new(),
1265 });
1266 let lock = FlakeLock {
1267 nodes,
1268 root: "root".to_string(),
1269 version: 7,
1270 };
1271 let adj = lock.adjacency_map();
1272 assert!(adj["root"].is_empty());
1274 }
1275
1276 #[test]
1279 fn resolve_input_empty_path_returns_root() {
1280 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1281 let node = lock.resolve_input(&[]).unwrap();
1282 assert!(node.inputs.contains_key("nixpkgs"));
1284 }
1285
1286 #[test]
1289 fn root_inputs_sorted_alphabetically() {
1290 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1291 let inputs = lock.root_inputs().unwrap();
1292 let names: Vec<&str> = inputs.iter().map(|(n, _)| n.as_str()).collect();
1294 assert_eq!(names, vec!["bar", "foo", "nixpkgs"]);
1295 }
1296
1297 #[test]
1300 fn input_ref_direct_serialize_to_string_literal() {
1301 let r = InputRef::Direct("nixpkgs".to_string());
1302 let json = serde_json::to_string(&r).unwrap();
1303 assert_eq!(json, r#""nixpkgs""#);
1304 }
1305
1306 #[test]
1307 fn input_ref_follows_serialize_to_array() {
1308 let r = InputRef::Follows(vec!["a".to_string(), "b".to_string()]);
1309 let json = serde_json::to_string(&r).unwrap();
1310 assert_eq!(json, r#"["a","b"]"#);
1311 }
1312
1313 #[test]
1316 fn tarball_type_input_with_url() {
1317 let json = r#"{
1318 "nodes": {
1319 "root": { "inputs": { "src": "src" } },
1320 "src": {
1321 "locked": {
1322 "lastModified": 1700000000,
1323 "narHash": "sha256-X",
1324 "type": "tarball",
1325 "url": "https://example.com/v1.0.tar.gz"
1326 },
1327 "original": {
1328 "type": "tarball",
1329 "url": "https://example.com/latest.tar.gz"
1330 }
1331 }
1332 },
1333 "root": "root",
1334 "version": 7
1335}"#;
1336 let lock = FlakeLock::parse(json).unwrap();
1337 let src = lock.get_node("src").unwrap();
1338 let locked = src.locked.as_ref().unwrap();
1339 assert_eq!(locked.source_type, "tarball");
1340 assert_eq!(locked.url.as_deref(), Some("https://example.com/v1.0.tar.gz"));
1341 }
1342
1343 #[test]
1346 fn git_ref_input_preserved() {
1347 let json = r#"{
1348 "nodes": {
1349 "root": { "inputs": { "deps": "deps" } },
1350 "deps": {
1351 "locked": {
1352 "lastModified": 1700000000,
1353 "narHash": "sha256-X",
1354 "owner": "o",
1355 "repo": "r",
1356 "rev": "abc",
1357 "ref": "refs/heads/main",
1358 "type": "github"
1359 },
1360 "original": {
1361 "owner": "o",
1362 "repo": "r",
1363 "ref": "main",
1364 "type": "github"
1365 }
1366 }
1367 },
1368 "root": "root",
1369 "version": 7
1370}"#;
1371 let lock = FlakeLock::parse(json).unwrap();
1372 let deps = lock.get_node("deps").unwrap();
1373 let locked = deps.locked.as_ref().unwrap();
1374 assert_eq!(locked.git_ref.as_deref(), Some("refs/heads/main"));
1375 let orig = deps.original.as_ref().unwrap();
1376 assert_eq!(orig.git_ref.as_deref(), Some("main"));
1377 }
1378
1379 #[test]
1382 fn git_dir_subdirectory_field() {
1383 let json = r#"{
1384 "nodes": {
1385 "root": { "inputs": { "subdir": "subdir" } },
1386 "subdir": {
1387 "locked": {
1388 "lastModified": 1700000000,
1389 "narHash": "sha256-X",
1390 "owner": "o",
1391 "repo": "r",
1392 "rev": "abc",
1393 "type": "github",
1394 "dir": "subdir/inside"
1395 },
1396 "original": {
1397 "owner": "o",
1398 "repo": "r",
1399 "type": "github",
1400 "dir": "subdir/inside"
1401 }
1402 }
1403 },
1404 "root": "root",
1405 "version": 7
1406}"#;
1407 let lock = FlakeLock::parse(json).unwrap();
1408 let s = lock.get_node("subdir").unwrap();
1409 let locked = s.locked.as_ref().unwrap();
1410 assert_eq!(locked.dir.as_deref(), Some("subdir/inside"));
1411 let orig = s.original.as_ref().unwrap();
1412 assert_eq!(orig.dir.as_deref(), Some("subdir/inside"));
1413 }
1414
1415 #[test]
1418 fn indirect_id_input() {
1419 let json = r#"{
1420 "nodes": {
1421 "root": { "inputs": { "nixpkgs": "nixpkgs" } },
1422 "nixpkgs": {
1423 "locked": {
1424 "lastModified": 1700000000,
1425 "narHash": "sha256-X",
1426 "owner": "nixos",
1427 "repo": "nixpkgs",
1428 "rev": "abc",
1429 "type": "github"
1430 },
1431 "original": {
1432 "id": "nixpkgs",
1433 "type": "indirect"
1434 }
1435 }
1436 },
1437 "root": "root",
1438 "version": 7
1439}"#;
1440 let lock = FlakeLock::parse(json).unwrap();
1441 let np = lock.get_node("nixpkgs").unwrap();
1442 let orig = np.original.as_ref().unwrap();
1443 assert_eq!(orig.source_type, "indirect");
1444 assert_eq!(orig.id.as_deref(), Some("nixpkgs"));
1445 }
1446
1447 #[test]
1450 fn resolve_follows_when_target_segment_is_direct() {
1451 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
1452 let node = lock.resolve_input(&["utils", "systems"]).unwrap();
1455 let locked = node.locked.as_ref().unwrap();
1456 assert_eq!(locked.source_type, "github");
1457 }
1458
1459 #[test]
1462 fn trailing_whitespace_in_json_ok() {
1463 let json = format!("{}\n\n \n", minimal_lock_json());
1464 let lock = FlakeLock::parse(&json).unwrap();
1465 assert_eq!(lock.version, 7);
1466 }
1467
1468 #[test]
1471 fn two_nodes_with_same_underlying_rev() {
1472 let json = r#"{
1473 "nodes": {
1474 "root": { "inputs": { "a": "a", "b": "b" } },
1475 "a": {
1476 "locked": {
1477 "lastModified": 1, "narHash": "sha256-X",
1478 "owner": "n", "repo": "p", "rev": "abc",
1479 "type": "github"
1480 },
1481 "original": { "owner": "n", "repo": "p", "type": "github" }
1482 },
1483 "b": {
1484 "locked": {
1485 "lastModified": 1, "narHash": "sha256-X",
1486 "owner": "n", "repo": "p", "rev": "abc",
1487 "type": "github"
1488 },
1489 "original": { "owner": "n", "repo": "p", "type": "github" }
1490 }
1491 },
1492 "root": "root",
1493 "version": 7
1494}"#;
1495 let lock = FlakeLock::parse(json).unwrap();
1496 assert_eq!(lock.nodes.len(), 3);
1497 let a = lock.get_node("a").unwrap();
1498 let b = lock.get_node("b").unwrap();
1499 assert_eq!(
1501 a.locked.as_ref().unwrap().rev,
1502 b.locked.as_ref().unwrap().rev
1503 );
1504 }
1505
1506 #[test]
1509 fn flake_lock_error_display_includes_context() {
1510 let err = FlakeLockError::FollowsFailed {
1511 from: "node-x".to_string(),
1512 path: vec!["a".to_string(), "b".to_string()],
1513 };
1514 let s = format!("{err}");
1515 assert!(s.contains("node-x"));
1516 }
1517
1518 #[test]
1521 fn extra_fields_roundtrip() {
1522 let json = r#"{
1523 "nodes": {
1524 "local": {
1525 "locked": {
1526 "lastModified": 1700000000,
1527 "narHash": "sha256-X",
1528 "path": "/home/user/proj",
1529 "type": "path",
1530 "revCount": 42,
1531 "submodules": true
1532 },
1533 "original": {
1534 "type": "path",
1535 "url": "/home/user/proj"
1536 }
1537 },
1538 "root": {
1539 "inputs": { "local": "local" }
1540 }
1541 },
1542 "root": "root",
1543 "version": 7
1544}"#;
1545 let lock = FlakeLock::parse(json).unwrap();
1546 let local = lock.get_node("local").unwrap();
1547 let locked = local.locked.as_ref().unwrap();
1548 assert_eq!(locked.extra.get("revCount"), Some(&serde_json::json!(42)));
1549 assert_eq!(locked.extra.get("submodules"), Some(&serde_json::json!(true)));
1550
1551 let reserialized = lock.to_json().unwrap();
1552 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1553 let local2 = reparsed.get_node("local").unwrap();
1554 let locked2 = local2.locked.as_ref().unwrap();
1555 assert_eq!(locked2.extra.get("revCount"), Some(&serde_json::json!(42)));
1556 }
1557}