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, Default)]
86pub struct LockedInput {
87 #[serde(rename = "type")]
88 pub source_type: String,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub owner: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub repo: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub rev: Option<String>,
95 #[serde(default, rename = "narHash", skip_serializing_if = "Option::is_none")]
96 pub nar_hash: Option<String>,
97 #[serde(
98 default,
99 rename = "lastModified",
100 skip_serializing_if = "Option::is_none"
101 )]
102 pub last_modified: Option<u64>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub path: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub url: Option<String>,
109 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
111 pub git_ref: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub dir: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub host: Option<String>,
120 #[serde(flatten)]
124 pub extra: BTreeMap<String, serde_json::Value>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct OriginalInput {
130 #[serde(rename = "type")]
131 pub source_type: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub owner: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub repo: Option<String>,
136 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
138 pub git_ref: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub url: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub dir: Option<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub id: Option<String>,
145 #[serde(flatten)]
147 pub extra: BTreeMap<String, serde_json::Value>,
148}
149
150const SUPPORTED_VERSION: u32 = 7;
153
154impl FlakeLock {
155 pub fn parse(json: &str) -> Result<Self, FlakeLockError> {
157 let lock: FlakeLock = serde_json::from_str(json)?;
158 if lock.version != SUPPORTED_VERSION {
159 return Err(FlakeLockError::UnsupportedVersion {
160 expected: SUPPORTED_VERSION,
161 found: lock.version,
162 });
163 }
164 if !lock.nodes.contains_key(&lock.root) {
165 return Err(FlakeLockError::MissingRoot(lock.root.clone()));
166 }
167 Ok(lock)
168 }
169
170 pub fn to_json(&self) -> Result<String, FlakeLockError> {
172 Ok(serde_json::to_string_pretty(self)?)
173 }
174
175 pub fn root_node(&self) -> Result<&FlakeNode, FlakeLockError> {
177 self.nodes
178 .get(&self.root)
179 .ok_or_else(|| FlakeLockError::MissingRoot(self.root.clone()))
180 }
181
182 pub fn get_node(&self, name: &str) -> Result<&FlakeNode, FlakeLockError> {
184 self.nodes
185 .get(name)
186 .ok_or_else(|| FlakeLockError::NodeNotFound(name.to_string()))
187 }
188
189 pub fn root_inputs(&self) -> Result<Vec<(String, String)>, FlakeLockError> {
192 let root = self.root_node()?;
193 let mut out = Vec::new();
194 for (input_name, input_ref) in &root.inputs {
195 let resolved = self.resolve_ref(&self.root, input_ref)?;
196 out.push((input_name.clone(), resolved));
197 }
198 Ok(out)
199 }
200
201 pub fn resolve_ref(
208 &self,
209 _parent: &str,
210 input_ref: &InputRef,
211 ) -> Result<String, FlakeLockError> {
212 match input_ref {
213 InputRef::Direct(name) => {
214 if self.nodes.contains_key(name) {
215 Ok(name.clone())
216 } else {
217 Err(FlakeLockError::NodeNotFound(name.clone()))
218 }
219 }
220 InputRef::Follows(path) => self.resolve_follows_path(path),
221 }
222 }
223
224 fn resolve_follows_path(&self, path: &[String]) -> Result<String, FlakeLockError> {
230 if path.is_empty() {
231 return Err(FlakeLockError::FollowsFailed {
232 from: self.root.clone(),
233 path: vec![],
234 });
235 }
236
237 let mut current_node_name = self.root.clone();
238
239 for segment in path {
240 let node = self.nodes.get(¤t_node_name).ok_or_else(|| {
241 FlakeLockError::FollowsFailed {
242 from: current_node_name.clone(),
243 path: path.to_vec(),
244 }
245 })?;
246
247 let input_ref =
248 node.inputs.get(segment).ok_or_else(|| FlakeLockError::FollowsFailed {
249 from: current_node_name.clone(),
250 path: path.to_vec(),
251 })?;
252
253 current_node_name = match input_ref {
255 InputRef::Direct(name) => name.clone(),
256 InputRef::Follows(inner_path) => self.resolve_follows_path(inner_path)?,
257 };
258 }
259
260 Ok(current_node_name)
261 }
262
263 pub fn resolve_input(&self, path: &[&str]) -> Result<&FlakeNode, FlakeLockError> {
269 let mut current_name = self.root.clone();
270
271 for &segment in path {
272 let node = self.nodes.get(¤t_name).ok_or_else(|| {
273 FlakeLockError::NodeNotFound(current_name.clone())
274 })?;
275
276 let input_ref = node.inputs.get(segment).ok_or_else(|| {
277 FlakeLockError::NodeNotFound(format!("{current_name}.inputs.{segment}"))
278 })?;
279
280 current_name = self.resolve_ref(¤t_name, input_ref)?;
281 }
282
283 self.nodes
284 .get(¤t_name)
285 .ok_or(FlakeLockError::NodeNotFound(current_name))
286 }
287
288 pub fn resolve_node_input(
302 &self,
303 node_name: &str,
304 input_name: &str,
305 ) -> Result<String, FlakeLockError> {
306 let node = self
307 .nodes
308 .get(node_name)
309 .ok_or_else(|| FlakeLockError::NodeNotFound(node_name.to_string()))?;
310 let input_ref = node.inputs.get(input_name).ok_or_else(|| {
311 FlakeLockError::NodeNotFound(format!("{node_name}.inputs.{input_name}"))
312 })?;
313 self.resolve_ref(node_name, input_ref)
314 }
315
316 pub fn node_input_edges(&self, node_name: &str) -> Vec<(String, String)> {
323 let Some(node) = self.nodes.get(node_name) else {
324 return Vec::new();
325 };
326 let mut edges = Vec::new();
327 for input_name in node.inputs.keys() {
328 if let Ok(target) = self.resolve_node_input(node_name, input_name) {
329 edges.push((input_name.clone(), target));
330 }
331 }
332 edges
333 }
334
335 pub fn adjacency_map(&self) -> BTreeMap<String, Vec<(String, String)>> {
340 let mut map: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
341
342 for (node_name, node) in &self.nodes {
343 let mut edges = Vec::new();
344 for (input_name, input_ref) in &node.inputs {
345 if let Ok(target) = self.resolve_ref(node_name, input_ref) {
346 edges.push((input_name.clone(), target));
347 }
348 }
349 map.insert(node_name.clone(), edges);
350 }
351
352 map
353 }
354}
355
356#[cfg(test)]
359mod tests {
360 use super::*;
361
362 fn minimal_lock_json() -> &'static str {
366 r#"{
367 "nodes": {
368 "nixpkgs": {
369 "locked": {
370 "lastModified": 1700000000,
371 "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
372 "owner": "nixos",
373 "repo": "nixpkgs",
374 "rev": "abc123def456abc123def456abc123def456abc1",
375 "type": "github"
376 },
377 "original": {
378 "owner": "nixos",
379 "ref": "nixos-unstable",
380 "repo": "nixpkgs",
381 "type": "github"
382 }
383 },
384 "root": {
385 "inputs": {
386 "nixpkgs": "nixpkgs"
387 }
388 }
389 },
390 "root": "root",
391 "version": 7
392}"#
393 }
394
395 fn follows_lock_json() -> &'static str {
397 r#"{
398 "nodes": {
399 "nixpkgs": {
400 "locked": {
401 "lastModified": 1700000000,
402 "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
403 "owner": "nixos",
404 "repo": "nixpkgs",
405 "rev": "abc123def456abc123def456abc123def456abc1",
406 "type": "github"
407 },
408 "original": {
409 "owner": "nixos",
410 "ref": "nixos-unstable",
411 "repo": "nixpkgs",
412 "type": "github"
413 }
414 },
415 "root": {
416 "inputs": {
417 "nixpkgs": "nixpkgs",
418 "utils": "utils"
419 }
420 },
421 "systems": {
422 "locked": {
423 "lastModified": 1699999999,
424 "narHash": "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
425 "owner": "nix-systems",
426 "repo": "default",
427 "rev": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1",
428 "type": "github"
429 },
430 "original": {
431 "owner": "nix-systems",
432 "repo": "default",
433 "type": "github"
434 }
435 },
436 "utils": {
437 "inputs": {
438 "nixpkgs": [
439 "nixpkgs"
440 ],
441 "systems": "systems"
442 },
443 "locked": {
444 "lastModified": 1699999998,
445 "narHash": "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
446 "owner": "numtide",
447 "repo": "flake-utils",
448 "rev": "ccccccccccccccccccccccccccccccccccccccc1",
449 "type": "github"
450 },
451 "original": {
452 "owner": "numtide",
453 "repo": "flake-utils",
454 "type": "github"
455 }
456 }
457 },
458 "root": "root",
459 "version": 7
460}"#
461 }
462
463 fn deep_follows_json() -> &'static str {
466 r#"{
467 "nodes": {
468 "nixpkgs": {
469 "locked": {
470 "lastModified": 1700000000,
471 "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
472 "owner": "nixos",
473 "repo": "nixpkgs",
474 "rev": "abc123",
475 "type": "github"
476 },
477 "original": {
478 "owner": "nixos",
479 "ref": "nixos-unstable",
480 "repo": "nixpkgs",
481 "type": "github"
482 }
483 },
484 "root": {
485 "inputs": {
486 "bar": "bar",
487 "foo": "foo",
488 "nixpkgs": "nixpkgs"
489 }
490 },
491 "foo": {
492 "inputs": {
493 "nixpkgs": [
494 "nixpkgs"
495 ]
496 },
497 "locked": {
498 "lastModified": 1700000001,
499 "narHash": "sha256-FOO",
500 "owner": "example",
501 "repo": "foo",
502 "rev": "foofoo",
503 "type": "github"
504 },
505 "original": {
506 "owner": "example",
507 "repo": "foo",
508 "type": "github"
509 }
510 },
511 "bar": {
512 "inputs": {
513 "nixpkgs": [
514 "foo",
515 "nixpkgs"
516 ]
517 },
518 "locked": {
519 "lastModified": 1700000002,
520 "narHash": "sha256-BAR",
521 "owner": "example",
522 "repo": "bar",
523 "rev": "barbar",
524 "type": "github"
525 },
526 "original": {
527 "owner": "example",
528 "repo": "bar",
529 "type": "github"
530 }
531 }
532 },
533 "root": "root",
534 "version": 7
535}"#
536 }
537
538 #[test]
541 fn parse_minimal_lock() {
542 let lock = FlakeLock::parse(minimal_lock_json()).expect("parse failed");
543 assert_eq!(lock.version, 7);
544 assert_eq!(lock.root, "root");
545 assert_eq!(lock.nodes.len(), 2);
546 assert!(lock.nodes.contains_key("root"));
547 assert!(lock.nodes.contains_key("nixpkgs"));
548 }
549
550 #[test]
551 fn minimal_root_node_has_no_locked() {
552 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
553 let root = lock.root_node().unwrap();
554 assert!(root.locked.is_none());
555 assert!(root.original.is_none());
556 }
557
558 #[test]
559 fn minimal_nixpkgs_locked_fields() {
560 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
561 let nixpkgs = lock.get_node("nixpkgs").unwrap();
562 let locked = nixpkgs.locked.as_ref().expect("missing locked");
563 assert_eq!(locked.source_type, "github");
564 assert_eq!(locked.owner.as_deref(), Some("nixos"));
565 assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
566 assert_eq!(
567 locked.rev.as_deref(),
568 Some("abc123def456abc123def456abc123def456abc1"),
569 );
570 assert_eq!(locked.last_modified, Some(1_700_000_000));
571 assert!(locked.nar_hash.is_some());
572 }
573
574 #[test]
575 fn minimal_nixpkgs_original_fields() {
576 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
577 let nixpkgs = lock.get_node("nixpkgs").unwrap();
578 let original = nixpkgs.original.as_ref().expect("missing original");
579 assert_eq!(original.source_type, "github");
580 assert_eq!(original.owner.as_deref(), Some("nixos"));
581 assert_eq!(original.repo.as_deref(), Some("nixpkgs"));
582 assert_eq!(original.git_ref.as_deref(), Some("nixos-unstable"));
583 }
584
585 #[test]
586 fn minimal_root_inputs() {
587 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
588 let inputs = lock.root_inputs().unwrap();
589 assert_eq!(inputs.len(), 1);
590 assert_eq!(inputs[0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
591 }
592
593 #[test]
596 fn parse_follows_lock() {
597 let lock = FlakeLock::parse(follows_lock_json()).expect("parse failed");
598 assert_eq!(lock.nodes.len(), 4); }
600
601 #[test]
602 fn follows_utils_nixpkgs_resolves_to_root_nixpkgs() {
603 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
604 let utils = lock.get_node("utils").unwrap();
605 let nixpkgs_ref = &utils.inputs["nixpkgs"];
606 assert_eq!(nixpkgs_ref, &InputRef::Follows(vec!["nixpkgs".to_string()]));
607
608 let resolved = lock.resolve_ref("utils", nixpkgs_ref).unwrap();
610 assert_eq!(resolved, "nixpkgs");
611 }
612
613 #[test]
614 fn resolve_input_walk_utils_nixpkgs() {
615 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
616 let node = lock.resolve_input(&["utils", "nixpkgs"]).unwrap();
619 let locked = node.locked.as_ref().unwrap();
620 assert_eq!(locked.owner.as_deref(), Some("nixos"));
621 assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
622 }
623
624 #[test]
625 fn resolve_input_walk_utils_systems() {
626 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
627 let node = lock.resolve_input(&["utils", "systems"]).unwrap();
628 let locked = node.locked.as_ref().unwrap();
629 assert_eq!(locked.owner.as_deref(), Some("nix-systems"));
630 assert_eq!(locked.repo.as_deref(), Some("default"));
631 }
632
633 #[test]
636 fn deep_follows_bar_nixpkgs_resolves_through_foo() {
637 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
638
639 let node = lock.resolve_input(&["bar", "nixpkgs"]).unwrap();
645 let locked = node.locked.as_ref().unwrap();
646 assert_eq!(locked.owner.as_deref(), Some("nixos"));
647 assert_eq!(locked.rev.as_deref(), Some("abc123"));
648 }
649
650 #[test]
651 fn deep_follows_foo_nixpkgs_resolves_to_root() {
652 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
653 let node = lock.resolve_input(&["foo", "nixpkgs"]).unwrap();
654 let locked = node.locked.as_ref().unwrap();
655 assert_eq!(locked.owner.as_deref(), Some("nixos"));
656 }
657
658 #[test]
668 fn resolve_node_input_follows_lands_on_root_target() {
669 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
670 let target = lock.resolve_node_input("utils", "nixpkgs").unwrap();
674 assert_eq!(target, "nixpkgs");
675 let target = lock.resolve_node_input("utils", "systems").unwrap();
677 assert_eq!(target, "systems");
678 }
679
680 #[test]
681 fn node_input_edges_resolves_follows_for_subflake() {
682 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
683 let mut edges = lock.node_input_edges("utils");
684 edges.sort();
685 assert_eq!(
686 edges,
687 vec![
688 ("nixpkgs".to_string(), "nixpkgs".to_string()),
689 ("systems".to_string(), "systems".to_string()),
690 ],
691 "utils' follows edge must resolve to the ROOT nixpkgs node graph"
692 );
693 }
694
695 #[test]
696 fn node_input_edges_deep_follows_chain() {
697 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
701 let target = lock.resolve_node_input("bar", "nixpkgs").unwrap();
702 assert_eq!(target, "nixpkgs");
703 }
704
705 #[test]
706 fn node_input_edges_unknown_node_is_empty() {
707 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
708 assert!(lock.node_input_edges("does-not-exist").is_empty());
709 }
710
711 #[test]
712 fn resolve_node_input_unknown_input_errors() {
713 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
714 assert!(lock.resolve_node_input("utils", "ghost").is_err());
715 }
716
717 #[test]
720 fn adjacency_map_follows_lock() {
721 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
722 let adj = lock.adjacency_map();
723
724 let root_edges = &adj["root"];
726 assert_eq!(root_edges.len(), 2);
727 assert!(root_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
728 assert!(root_edges.contains(&("utils".to_string(), "utils".to_string())));
729
730 let utils_edges = &adj["utils"];
732 assert_eq!(utils_edges.len(), 2);
733 assert!(utils_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
734 assert!(utils_edges.contains(&("systems".to_string(), "systems".to_string())));
735
736 assert!(adj["nixpkgs"].is_empty());
738 assert!(adj["systems"].is_empty());
739 }
740
741 #[test]
744 fn rejects_unsupported_version() {
745 let json = r#"{ "nodes": { "root": { "inputs": {} } }, "root": "root", "version": 6 }"#;
746 let err = FlakeLock::parse(json).unwrap_err();
747 assert!(matches!(err, FlakeLockError::UnsupportedVersion { found: 6, .. }));
748 }
749
750 #[test]
751 fn rejects_missing_root_node() {
752 let json = r#"{ "nodes": { "x": {} }, "root": "root", "version": 7 }"#;
753 let err = FlakeLock::parse(json).unwrap_err();
754 assert!(matches!(err, FlakeLockError::MissingRoot(_)));
755 }
756
757 #[test]
758 fn get_node_missing_returns_error() {
759 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
760 assert!(lock.get_node("nonexistent").is_err());
761 }
762
763 #[test]
764 fn resolve_input_missing_segment_returns_error() {
765 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
766 let result = lock.resolve_input(&["nonexistent"]);
767 assert!(result.is_err());
768 }
769
770 #[test]
771 fn resolve_ref_direct_missing_node_returns_error() {
772 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
773 let result = lock.resolve_ref("root", &InputRef::Direct("ghost".to_string()));
774 assert!(result.is_err());
775 }
776
777 #[test]
778 fn resolve_follows_empty_path_returns_error() {
779 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
780 let result = lock.resolve_ref("root", &InputRef::Follows(vec![]));
781 assert!(result.is_err());
782 }
783
784 #[test]
785 fn resolve_follows_bad_segment_returns_error() {
786 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
787 let result = lock.resolve_ref(
788 "utils",
789 &InputRef::Follows(vec!["nonexistent".to_string()]),
790 );
791 assert!(result.is_err());
792 }
793
794 #[test]
797 fn roundtrip_minimal() {
798 let original = FlakeLock::parse(minimal_lock_json()).unwrap();
799 let json = original.to_json().unwrap();
800 let reparsed = FlakeLock::parse(&json).unwrap();
801
802 assert_eq!(reparsed.version, original.version);
803 assert_eq!(reparsed.root, original.root);
804 assert_eq!(reparsed.nodes.len(), original.nodes.len());
805
806 let np = reparsed.get_node("nixpkgs").unwrap();
808 let locked = np.locked.as_ref().unwrap();
809 assert_eq!(locked.rev.as_deref(), Some("abc123def456abc123def456abc123def456abc1"));
810 }
811
812 #[test]
813 fn roundtrip_with_follows() {
814 let original = FlakeLock::parse(follows_lock_json()).unwrap();
815 let json = original.to_json().unwrap();
816 let reparsed = FlakeLock::parse(&json).unwrap();
817
818 assert_eq!(reparsed.nodes.len(), original.nodes.len());
819
820 let utils = reparsed.get_node("utils").unwrap();
822 assert_eq!(
823 utils.inputs["nixpkgs"],
824 InputRef::Follows(vec!["nixpkgs".to_string()]),
825 );
826
827 let node = reparsed.resolve_input(&["utils", "nixpkgs"]).unwrap();
829 assert_eq!(
830 node.locked.as_ref().unwrap().owner.as_deref(),
831 Some("nixos"),
832 );
833 }
834
835 #[test]
838 fn parse_non_flake_input() {
839 let json = r#"{
840 "nodes": {
841 "data": {
842 "flake": false,
843 "locked": {
844 "lastModified": 1700000000,
845 "narHash": "sha256-DATA",
846 "owner": "someone",
847 "repo": "data-files",
848 "rev": "deadbeef",
849 "type": "github"
850 },
851 "original": {
852 "owner": "someone",
853 "repo": "data-files",
854 "type": "github"
855 }
856 },
857 "root": {
858 "inputs": {
859 "data": "data"
860 }
861 }
862 },
863 "root": "root",
864 "version": 7
865}"#;
866 let lock = FlakeLock::parse(json).unwrap();
867 let data = lock.get_node("data").unwrap();
868 assert_eq!(data.flake, Some(false));
869 }
870
871 #[test]
874 fn input_ref_direct_deserialize() {
875 let v: InputRef = serde_json::from_str(r#""nixpkgs""#).unwrap();
876 assert_eq!(v, InputRef::Direct("nixpkgs".to_string()));
877 }
878
879 #[test]
880 fn input_ref_follows_deserialize() {
881 let v: InputRef = serde_json::from_str(r#"["nixpkgs"]"#).unwrap();
882 assert_eq!(v, InputRef::Follows(vec!["nixpkgs".to_string()]));
883 }
884
885 #[test]
886 fn input_ref_follows_multi_segment_deserialize() {
887 let v: InputRef = serde_json::from_str(r#"["foo", "nixpkgs"]"#).unwrap();
888 assert_eq!(
889 v,
890 InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
891 );
892 }
893
894 #[test]
895 fn input_ref_direct_roundtrip() {
896 let original = InputRef::Direct("nixpkgs".to_string());
897 let json = serde_json::to_string(&original).unwrap();
898 let reparsed: InputRef = serde_json::from_str(&json).unwrap();
899 assert_eq!(original, reparsed);
900 }
901
902 #[test]
903 fn input_ref_follows_roundtrip() {
904 let original = InputRef::Follows(vec!["foo".to_string(), "bar".to_string()]);
905 let json = serde_json::to_string(&original).unwrap();
906 let reparsed: InputRef = serde_json::from_str(&json).unwrap();
907 assert_eq!(original, reparsed);
908 }
909
910 #[test]
913 fn parse_path_type_locked_input() {
914 let json = r#"{
915 "nodes": {
916 "local": {
917 "locked": {
918 "lastModified": 1700000000,
919 "narHash": "sha256-PATH",
920 "path": "/home/user/my-flake",
921 "type": "path"
922 },
923 "original": {
924 "type": "path",
925 "url": "/home/user/my-flake"
926 }
927 },
928 "root": {
929 "inputs": {
930 "local": "local"
931 }
932 }
933 },
934 "root": "root",
935 "version": 7
936}"#;
937 let lock = FlakeLock::parse(json).unwrap();
938 let local = lock.get_node("local").unwrap();
939 let locked = local.locked.as_ref().unwrap();
940 assert_eq!(locked.source_type, "path");
941 assert_eq!(locked.path.as_deref(), Some("/home/user/my-flake"));
942 }
943
944 #[test]
947 fn multiple_inputs_follow_same_target() {
948 let json = r#"{
949 "nodes": {
950 "nixpkgs": {
951 "locked": {
952 "lastModified": 1700000000,
953 "narHash": "sha256-NP",
954 "owner": "nixos",
955 "repo": "nixpkgs",
956 "rev": "aaa",
957 "type": "github"
958 },
959 "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
960 },
961 "root": {
962 "inputs": {
963 "a": "a",
964 "b": "b",
965 "nixpkgs": "nixpkgs"
966 }
967 },
968 "a": {
969 "inputs": { "nixpkgs": ["nixpkgs"] },
970 "locked": {
971 "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github"
972 },
973 "original": { "owner": "x", "repo": "a", "type": "github" }
974 },
975 "b": {
976 "inputs": { "nixpkgs": ["nixpkgs"] },
977 "locked": {
978 "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github"
979 },
980 "original": { "owner": "x", "repo": "b", "type": "github" }
981 }
982 },
983 "root": "root",
984 "version": 7
985}"#;
986 let lock = FlakeLock::parse(json).unwrap();
987
988 let a_np = lock.resolve_input(&["a", "nixpkgs"]).unwrap();
990 let b_np = lock.resolve_input(&["b", "nixpkgs"]).unwrap();
991
992 assert_eq!(
993 a_np.locked.as_ref().unwrap().rev.as_deref(),
994 Some("aaa"),
995 );
996 assert_eq!(
997 b_np.locked.as_ref().unwrap().rev.as_deref(),
998 Some("aaa"),
999 );
1000 }
1001
1002 #[test]
1005 fn invalid_json_returns_error() {
1006 assert!(FlakeLock::parse("not json").is_err());
1007 }
1008
1009 #[test]
1010 fn empty_object_returns_error() {
1011 assert!(FlakeLock::parse("{}").is_err());
1012 }
1013
1014 #[test]
1017 fn flake_false_node_default_is_none() {
1018 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1019 let nixpkgs = lock.get_node("nixpkgs").unwrap();
1020 assert_eq!(nixpkgs.flake, None);
1021 }
1022
1023 #[test]
1024 fn flake_false_roundtrips_through_json() {
1025 let json = r#"{
1026 "nodes": {
1027 "data-files": {
1028 "flake": false,
1029 "locked": {
1030 "lastModified": 1700000000,
1031 "narHash": "sha256-DATA",
1032 "owner": "example",
1033 "repo": "data",
1034 "rev": "abc123",
1035 "type": "github"
1036 },
1037 "original": {
1038 "owner": "example",
1039 "repo": "data",
1040 "type": "github"
1041 }
1042 },
1043 "root": {
1044 "inputs": {
1045 "data-files": "data-files"
1046 }
1047 }
1048 },
1049 "root": "root",
1050 "version": 7
1051}"#;
1052 let lock = FlakeLock::parse(json).unwrap();
1053 let data = lock.get_node("data-files").unwrap();
1054 assert_eq!(data.flake, Some(false));
1055
1056 let reserialized = lock.to_json().unwrap();
1057 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1058 let data2 = reparsed.get_node("data-files").unwrap();
1059 assert_eq!(data2.flake, Some(false));
1060 }
1061
1062 #[test]
1065 fn follows_of_follows_three_levels() {
1066 let json = r#"{
1067 "nodes": {
1068 "nixpkgs": {
1069 "locked": {
1070 "lastModified": 1700000000,
1071 "narHash": "sha256-NP",
1072 "owner": "nixos",
1073 "repo": "nixpkgs",
1074 "rev": "final",
1075 "type": "github"
1076 },
1077 "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
1078 },
1079 "root": {
1080 "inputs": {
1081 "a": "a",
1082 "b": "b",
1083 "c": "c",
1084 "nixpkgs": "nixpkgs"
1085 }
1086 },
1087 "a": {
1088 "inputs": { "nixpkgs": ["nixpkgs"] },
1089 "locked": { "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
1090 "original": { "owner": "x", "repo": "a", "type": "github" }
1091 },
1092 "b": {
1093 "inputs": { "nixpkgs": ["a", "nixpkgs"] },
1094 "locked": { "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
1095 "original": { "owner": "x", "repo": "b", "type": "github" }
1096 },
1097 "c": {
1098 "inputs": { "nixpkgs": ["b", "nixpkgs"] },
1099 "locked": { "lastModified": 3, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
1100 "original": { "owner": "x", "repo": "c", "type": "github" }
1101 }
1102 },
1103 "root": "root",
1104 "version": 7
1105}"#;
1106 let lock = FlakeLock::parse(json).unwrap();
1107
1108 let node = lock.resolve_input(&["c", "nixpkgs"]).unwrap();
1115 assert_eq!(
1116 node.locked.as_ref().unwrap().rev.as_deref(),
1117 Some("final"),
1118 );
1119 }
1120
1121 #[test]
1124 fn malformed_version_string() {
1125 let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": "seven" }"#;
1126 assert!(FlakeLock::parse(json).is_err());
1127 }
1128
1129 #[test]
1130 fn malformed_input_ref_integer() {
1131 let json = r#"{
1132 "nodes": {
1133 "root": {
1134 "inputs": { "x": 42 }
1135 }
1136 },
1137 "root": "root",
1138 "version": 7
1139}"#;
1140 assert!(FlakeLock::parse(json).is_err());
1141 }
1142
1143 #[test]
1144 fn missing_version_field() {
1145 let json = r#"{ "nodes": { "root": {} }, "root": "root" }"#;
1146 assert!(FlakeLock::parse(json).is_err());
1147 }
1148
1149 #[test]
1150 fn null_root_field() {
1151 let json = r#"{ "nodes": { "root": {} }, "root": null, "version": 7 }"#;
1152 assert!(FlakeLock::parse(json).is_err());
1153 }
1154
1155 #[test]
1158 fn roundtrip_deep_follows() {
1159 let original = FlakeLock::parse(deep_follows_json()).unwrap();
1160 let json = original.to_json().unwrap();
1161 let reparsed = FlakeLock::parse(&json).unwrap();
1162
1163 assert_eq!(reparsed.nodes.len(), original.nodes.len());
1164 let bar = reparsed.get_node("bar").unwrap();
1165 assert_eq!(
1166 bar.inputs["nixpkgs"],
1167 InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
1168 );
1169 }
1170
1171 #[test]
1174 fn adjacency_map_deep_follows() {
1175 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1176 let adj = lock.adjacency_map();
1177
1178 let root_edges = &adj["root"];
1179 assert_eq!(root_edges.len(), 3);
1180
1181 let bar_edges = &adj["bar"];
1182 assert_eq!(bar_edges.len(), 1);
1183 assert!(bar_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
1184 }
1185
1186 #[test]
1189 fn follows_chain_four_levels_deep() {
1190 let json = r#"{
1191 "nodes": {
1192 "nixpkgs": {
1193 "locked": { "lastModified": 1, "narHash": "sha256-NP", "owner": "n", "repo": "p", "rev": "final", "type": "github" },
1194 "original": { "owner": "n", "repo": "p", "type": "github" }
1195 },
1196 "root": {
1197 "inputs": { "a": "a", "b": "b", "c": "c", "d": "d", "nixpkgs": "nixpkgs" }
1198 },
1199 "a": {
1200 "inputs": { "nixpkgs": ["nixpkgs"] },
1201 "locked": { "lastModified": 2, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
1202 "original": { "owner": "x", "repo": "a", "type": "github" }
1203 },
1204 "b": {
1205 "inputs": { "nixpkgs": ["a", "nixpkgs"] },
1206 "locked": { "lastModified": 3, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
1207 "original": { "owner": "x", "repo": "b", "type": "github" }
1208 },
1209 "c": {
1210 "inputs": { "nixpkgs": ["b", "nixpkgs"] },
1211 "locked": { "lastModified": 4, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
1212 "original": { "owner": "x", "repo": "c", "type": "github" }
1213 },
1214 "d": {
1215 "inputs": { "nixpkgs": ["c", "nixpkgs"] },
1216 "locked": { "lastModified": 5, "narHash": "sha256-D", "owner": "x", "repo": "d", "rev": "d1", "type": "github" },
1217 "original": { "owner": "x", "repo": "d", "type": "github" }
1218 }
1219 },
1220 "root": "root",
1221 "version": 7
1222}"#;
1223 let lock = FlakeLock::parse(json).unwrap();
1224 let node = lock.resolve_input(&["d", "nixpkgs"]).unwrap();
1226 assert_eq!(node.locked.as_ref().unwrap().rev.as_deref(), Some("final"));
1227 }
1228
1229 #[test]
1232 fn flake_node_extra_field_roundtrips() {
1233 let json = r#"{
1235 "nodes": {
1236 "root": {
1237 "inputs": { "self-ref": "self-ref" }
1238 },
1239 "self-ref": {
1240 "locked": {
1241 "lastModified": 1700000000,
1242 "narHash": "sha256-X",
1243 "path": "/tmp/foo",
1244 "type": "path"
1245 },
1246 "original": {
1247 "type": "path",
1248 "url": "/tmp/foo"
1249 },
1250 "parent": ["root"]
1251 }
1252 },
1253 "root": "root",
1254 "version": 7
1255}"#;
1256 let lock = FlakeLock::parse(json).unwrap();
1257 let node = lock.get_node("self-ref").unwrap();
1258 assert!(node.extra.contains_key("parent"));
1259
1260 let reserialized = lock.to_json().unwrap();
1261 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1262 let node2 = reparsed.get_node("self-ref").unwrap();
1263 assert!(node2.extra.contains_key("parent"));
1264 }
1265
1266 #[test]
1269 fn original_input_extra_fields_roundtrip() {
1270 let json = r#"{
1271 "nodes": {
1272 "root": { "inputs": { "x": "x" } },
1273 "x": {
1274 "locked": {
1275 "lastModified": 1700000000,
1276 "narHash": "sha256-X",
1277 "owner": "o",
1278 "repo": "r",
1279 "rev": "abc",
1280 "type": "github"
1281 },
1282 "original": {
1283 "owner": "o",
1284 "repo": "r",
1285 "type": "github",
1286 "submodules": true,
1287 "shallow": false
1288 }
1289 }
1290 },
1291 "root": "root",
1292 "version": 7
1293}"#;
1294 let lock = FlakeLock::parse(json).unwrap();
1295 let x = lock.get_node("x").unwrap();
1296 let original = x.original.as_ref().unwrap();
1297 assert_eq!(original.extra.get("submodules"), Some(&serde_json::json!(true)));
1298 assert_eq!(original.extra.get("shallow"), Some(&serde_json::json!(false)));
1299
1300 let reserialized = lock.to_json().unwrap();
1301 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1302 let x2 = reparsed.get_node("x").unwrap();
1303 let orig2 = x2.original.as_ref().unwrap();
1304 assert_eq!(orig2.extra.get("submodules"), Some(&serde_json::json!(true)));
1305 }
1306
1307 #[test]
1310 fn unsupported_version_error_includes_found() {
1311 let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 99 }"#;
1312 let err = FlakeLock::parse(json).unwrap_err();
1313 match err {
1314 FlakeLockError::UnsupportedVersion { expected, found } => {
1315 assert_eq!(expected, 7);
1316 assert_eq!(found, 99);
1317 }
1318 other => panic!("expected UnsupportedVersion, got {other:?}"),
1319 }
1320 }
1321
1322 #[test]
1323 fn missing_root_error_includes_name() {
1324 let json = r#"{ "nodes": { "x": {} }, "root": "missing-root", "version": 7 }"#;
1325 match FlakeLock::parse(json).unwrap_err() {
1326 FlakeLockError::MissingRoot(name) => assert_eq!(name, "missing-root"),
1327 other => panic!("expected MissingRoot, got {other:?}"),
1328 }
1329 }
1330
1331 #[test]
1332 fn get_node_returns_node_not_found_with_name() {
1333 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1334 match lock.get_node("nope") {
1335 Err(FlakeLockError::NodeNotFound(n)) => assert_eq!(n, "nope"),
1336 other => panic!("expected NodeNotFound, got {other:?}"),
1337 }
1338 }
1339
1340 #[test]
1343 fn version_as_float_rejected() {
1344 let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 7.5 }"#;
1345 assert!(FlakeLock::parse(json).is_err());
1346 }
1347
1348 #[test]
1351 fn adjacency_map_minimal() {
1352 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1353 let adj = lock.adjacency_map();
1354 assert_eq!(adj.len(), 2);
1355 assert_eq!(adj["root"].len(), 1);
1356 assert_eq!(adj["root"][0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
1357 assert!(adj["nixpkgs"].is_empty());
1358 }
1359
1360 #[test]
1363 fn adjacency_map_skips_unresolvable() {
1364 let mut nodes = BTreeMap::new();
1366 nodes.insert("root".to_string(), FlakeNode {
1367 inputs: {
1368 let mut m = BTreeMap::new();
1369 m.insert("ghost".to_string(), InputRef::Direct("nonexistent".to_string()));
1370 m
1371 },
1372 locked: None,
1373 original: None,
1374 flake: None,
1375 extra: BTreeMap::new(),
1376 });
1377 let lock = FlakeLock {
1378 nodes,
1379 root: "root".to_string(),
1380 version: 7,
1381 };
1382 let adj = lock.adjacency_map();
1383 assert!(adj["root"].is_empty());
1385 }
1386
1387 #[test]
1390 fn resolve_input_empty_path_returns_root() {
1391 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1392 let node = lock.resolve_input(&[]).unwrap();
1393 assert!(node.inputs.contains_key("nixpkgs"));
1395 }
1396
1397 #[test]
1400 fn root_inputs_sorted_alphabetically() {
1401 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1402 let inputs = lock.root_inputs().unwrap();
1403 let names: Vec<&str> = inputs.iter().map(|(n, _)| n.as_str()).collect();
1405 assert_eq!(names, vec!["bar", "foo", "nixpkgs"]);
1406 }
1407
1408 #[test]
1411 fn input_ref_direct_serialize_to_string_literal() {
1412 let r = InputRef::Direct("nixpkgs".to_string());
1413 let json = serde_json::to_string(&r).unwrap();
1414 assert_eq!(json, r#""nixpkgs""#);
1415 }
1416
1417 #[test]
1418 fn input_ref_follows_serialize_to_array() {
1419 let r = InputRef::Follows(vec!["a".to_string(), "b".to_string()]);
1420 let json = serde_json::to_string(&r).unwrap();
1421 assert_eq!(json, r#"["a","b"]"#);
1422 }
1423
1424 #[test]
1427 fn tarball_type_input_with_url() {
1428 let json = r#"{
1429 "nodes": {
1430 "root": { "inputs": { "src": "src" } },
1431 "src": {
1432 "locked": {
1433 "lastModified": 1700000000,
1434 "narHash": "sha256-X",
1435 "type": "tarball",
1436 "url": "https://example.com/v1.0.tar.gz"
1437 },
1438 "original": {
1439 "type": "tarball",
1440 "url": "https://example.com/latest.tar.gz"
1441 }
1442 }
1443 },
1444 "root": "root",
1445 "version": 7
1446}"#;
1447 let lock = FlakeLock::parse(json).unwrap();
1448 let src = lock.get_node("src").unwrap();
1449 let locked = src.locked.as_ref().unwrap();
1450 assert_eq!(locked.source_type, "tarball");
1451 assert_eq!(locked.url.as_deref(), Some("https://example.com/v1.0.tar.gz"));
1452 }
1453
1454 #[test]
1457 fn git_ref_input_preserved() {
1458 let json = r#"{
1459 "nodes": {
1460 "root": { "inputs": { "deps": "deps" } },
1461 "deps": {
1462 "locked": {
1463 "lastModified": 1700000000,
1464 "narHash": "sha256-X",
1465 "owner": "o",
1466 "repo": "r",
1467 "rev": "abc",
1468 "ref": "refs/heads/main",
1469 "type": "github"
1470 },
1471 "original": {
1472 "owner": "o",
1473 "repo": "r",
1474 "ref": "main",
1475 "type": "github"
1476 }
1477 }
1478 },
1479 "root": "root",
1480 "version": 7
1481}"#;
1482 let lock = FlakeLock::parse(json).unwrap();
1483 let deps = lock.get_node("deps").unwrap();
1484 let locked = deps.locked.as_ref().unwrap();
1485 assert_eq!(locked.git_ref.as_deref(), Some("refs/heads/main"));
1486 let orig = deps.original.as_ref().unwrap();
1487 assert_eq!(orig.git_ref.as_deref(), Some("main"));
1488 }
1489
1490 #[test]
1493 fn git_dir_subdirectory_field() {
1494 let json = r#"{
1495 "nodes": {
1496 "root": { "inputs": { "subdir": "subdir" } },
1497 "subdir": {
1498 "locked": {
1499 "lastModified": 1700000000,
1500 "narHash": "sha256-X",
1501 "owner": "o",
1502 "repo": "r",
1503 "rev": "abc",
1504 "type": "github",
1505 "dir": "subdir/inside"
1506 },
1507 "original": {
1508 "owner": "o",
1509 "repo": "r",
1510 "type": "github",
1511 "dir": "subdir/inside"
1512 }
1513 }
1514 },
1515 "root": "root",
1516 "version": 7
1517}"#;
1518 let lock = FlakeLock::parse(json).unwrap();
1519 let s = lock.get_node("subdir").unwrap();
1520 let locked = s.locked.as_ref().unwrap();
1521 assert_eq!(locked.dir.as_deref(), Some("subdir/inside"));
1522 let orig = s.original.as_ref().unwrap();
1523 assert_eq!(orig.dir.as_deref(), Some("subdir/inside"));
1524 }
1525
1526 #[test]
1529 fn indirect_id_input() {
1530 let json = r#"{
1531 "nodes": {
1532 "root": { "inputs": { "nixpkgs": "nixpkgs" } },
1533 "nixpkgs": {
1534 "locked": {
1535 "lastModified": 1700000000,
1536 "narHash": "sha256-X",
1537 "owner": "nixos",
1538 "repo": "nixpkgs",
1539 "rev": "abc",
1540 "type": "github"
1541 },
1542 "original": {
1543 "id": "nixpkgs",
1544 "type": "indirect"
1545 }
1546 }
1547 },
1548 "root": "root",
1549 "version": 7
1550}"#;
1551 let lock = FlakeLock::parse(json).unwrap();
1552 let np = lock.get_node("nixpkgs").unwrap();
1553 let orig = np.original.as_ref().unwrap();
1554 assert_eq!(orig.source_type, "indirect");
1555 assert_eq!(orig.id.as_deref(), Some("nixpkgs"));
1556 }
1557
1558 #[test]
1561 fn resolve_follows_when_target_segment_is_direct() {
1562 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
1563 let node = lock.resolve_input(&["utils", "systems"]).unwrap();
1566 let locked = node.locked.as_ref().unwrap();
1567 assert_eq!(locked.source_type, "github");
1568 }
1569
1570 #[test]
1573 fn trailing_whitespace_in_json_ok() {
1574 let json = format!("{}\n\n \n", minimal_lock_json());
1575 let lock = FlakeLock::parse(&json).unwrap();
1576 assert_eq!(lock.version, 7);
1577 }
1578
1579 #[test]
1582 fn two_nodes_with_same_underlying_rev() {
1583 let json = r#"{
1584 "nodes": {
1585 "root": { "inputs": { "a": "a", "b": "b" } },
1586 "a": {
1587 "locked": {
1588 "lastModified": 1, "narHash": "sha256-X",
1589 "owner": "n", "repo": "p", "rev": "abc",
1590 "type": "github"
1591 },
1592 "original": { "owner": "n", "repo": "p", "type": "github" }
1593 },
1594 "b": {
1595 "locked": {
1596 "lastModified": 1, "narHash": "sha256-X",
1597 "owner": "n", "repo": "p", "rev": "abc",
1598 "type": "github"
1599 },
1600 "original": { "owner": "n", "repo": "p", "type": "github" }
1601 }
1602 },
1603 "root": "root",
1604 "version": 7
1605}"#;
1606 let lock = FlakeLock::parse(json).unwrap();
1607 assert_eq!(lock.nodes.len(), 3);
1608 let a = lock.get_node("a").unwrap();
1609 let b = lock.get_node("b").unwrap();
1610 assert_eq!(
1612 a.locked.as_ref().unwrap().rev,
1613 b.locked.as_ref().unwrap().rev
1614 );
1615 }
1616
1617 #[test]
1620 fn flake_lock_error_display_includes_context() {
1621 let err = FlakeLockError::FollowsFailed {
1622 from: "node-x".to_string(),
1623 path: vec!["a".to_string(), "b".to_string()],
1624 };
1625 let s = format!("{err}");
1626 assert!(s.contains("node-x"));
1627 }
1628
1629 #[test]
1632 fn extra_fields_roundtrip() {
1633 let json = r#"{
1634 "nodes": {
1635 "local": {
1636 "locked": {
1637 "lastModified": 1700000000,
1638 "narHash": "sha256-X",
1639 "path": "/home/user/proj",
1640 "type": "path",
1641 "revCount": 42,
1642 "submodules": true
1643 },
1644 "original": {
1645 "type": "path",
1646 "url": "/home/user/proj"
1647 }
1648 },
1649 "root": {
1650 "inputs": { "local": "local" }
1651 }
1652 },
1653 "root": "root",
1654 "version": 7
1655}"#;
1656 let lock = FlakeLock::parse(json).unwrap();
1657 let local = lock.get_node("local").unwrap();
1658 let locked = local.locked.as_ref().unwrap();
1659 assert_eq!(locked.extra.get("revCount"), Some(&serde_json::json!(42)));
1660 assert_eq!(locked.extra.get("submodules"), Some(&serde_json::json!(true)));
1661
1662 let reserialized = lock.to_json().unwrap();
1663 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1664 let local2 = reparsed.get_node("local").unwrap();
1665 let locked2 = local2.locked.as_ref().unwrap();
1666 assert_eq!(locked2.extra.get("revCount"), Some(&serde_json::json!(42)));
1667 }
1668}