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 resolve_node_input(
297 &self,
298 node_name: &str,
299 input_name: &str,
300 ) -> Result<String, FlakeLockError> {
301 let node = self
302 .nodes
303 .get(node_name)
304 .ok_or_else(|| FlakeLockError::NodeNotFound(node_name.to_string()))?;
305 let input_ref = node.inputs.get(input_name).ok_or_else(|| {
306 FlakeLockError::NodeNotFound(format!("{node_name}.inputs.{input_name}"))
307 })?;
308 self.resolve_ref(node_name, input_ref)
309 }
310
311 pub fn node_input_edges(&self, node_name: &str) -> Vec<(String, String)> {
318 let Some(node) = self.nodes.get(node_name) else {
319 return Vec::new();
320 };
321 let mut edges = Vec::new();
322 for input_name in node.inputs.keys() {
323 if let Ok(target) = self.resolve_node_input(node_name, input_name) {
324 edges.push((input_name.clone(), target));
325 }
326 }
327 edges
328 }
329
330 pub fn adjacency_map(&self) -> BTreeMap<String, Vec<(String, String)>> {
335 let mut map: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
336
337 for (node_name, node) in &self.nodes {
338 let mut edges = Vec::new();
339 for (input_name, input_ref) in &node.inputs {
340 if let Ok(target) = self.resolve_ref(node_name, input_ref) {
341 edges.push((input_name.clone(), target));
342 }
343 }
344 map.insert(node_name.clone(), edges);
345 }
346
347 map
348 }
349}
350
351#[cfg(test)]
354mod tests {
355 use super::*;
356
357 fn minimal_lock_json() -> &'static str {
361 r#"{
362 "nodes": {
363 "nixpkgs": {
364 "locked": {
365 "lastModified": 1700000000,
366 "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
367 "owner": "nixos",
368 "repo": "nixpkgs",
369 "rev": "abc123def456abc123def456abc123def456abc1",
370 "type": "github"
371 },
372 "original": {
373 "owner": "nixos",
374 "ref": "nixos-unstable",
375 "repo": "nixpkgs",
376 "type": "github"
377 }
378 },
379 "root": {
380 "inputs": {
381 "nixpkgs": "nixpkgs"
382 }
383 }
384 },
385 "root": "root",
386 "version": 7
387}"#
388 }
389
390 fn follows_lock_json() -> &'static str {
392 r#"{
393 "nodes": {
394 "nixpkgs": {
395 "locked": {
396 "lastModified": 1700000000,
397 "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
398 "owner": "nixos",
399 "repo": "nixpkgs",
400 "rev": "abc123def456abc123def456abc123def456abc1",
401 "type": "github"
402 },
403 "original": {
404 "owner": "nixos",
405 "ref": "nixos-unstable",
406 "repo": "nixpkgs",
407 "type": "github"
408 }
409 },
410 "root": {
411 "inputs": {
412 "nixpkgs": "nixpkgs",
413 "utils": "utils"
414 }
415 },
416 "systems": {
417 "locked": {
418 "lastModified": 1699999999,
419 "narHash": "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
420 "owner": "nix-systems",
421 "repo": "default",
422 "rev": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1",
423 "type": "github"
424 },
425 "original": {
426 "owner": "nix-systems",
427 "repo": "default",
428 "type": "github"
429 }
430 },
431 "utils": {
432 "inputs": {
433 "nixpkgs": [
434 "nixpkgs"
435 ],
436 "systems": "systems"
437 },
438 "locked": {
439 "lastModified": 1699999998,
440 "narHash": "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
441 "owner": "numtide",
442 "repo": "flake-utils",
443 "rev": "ccccccccccccccccccccccccccccccccccccccc1",
444 "type": "github"
445 },
446 "original": {
447 "owner": "numtide",
448 "repo": "flake-utils",
449 "type": "github"
450 }
451 }
452 },
453 "root": "root",
454 "version": 7
455}"#
456 }
457
458 fn deep_follows_json() -> &'static str {
461 r#"{
462 "nodes": {
463 "nixpkgs": {
464 "locked": {
465 "lastModified": 1700000000,
466 "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
467 "owner": "nixos",
468 "repo": "nixpkgs",
469 "rev": "abc123",
470 "type": "github"
471 },
472 "original": {
473 "owner": "nixos",
474 "ref": "nixos-unstable",
475 "repo": "nixpkgs",
476 "type": "github"
477 }
478 },
479 "root": {
480 "inputs": {
481 "bar": "bar",
482 "foo": "foo",
483 "nixpkgs": "nixpkgs"
484 }
485 },
486 "foo": {
487 "inputs": {
488 "nixpkgs": [
489 "nixpkgs"
490 ]
491 },
492 "locked": {
493 "lastModified": 1700000001,
494 "narHash": "sha256-FOO",
495 "owner": "example",
496 "repo": "foo",
497 "rev": "foofoo",
498 "type": "github"
499 },
500 "original": {
501 "owner": "example",
502 "repo": "foo",
503 "type": "github"
504 }
505 },
506 "bar": {
507 "inputs": {
508 "nixpkgs": [
509 "foo",
510 "nixpkgs"
511 ]
512 },
513 "locked": {
514 "lastModified": 1700000002,
515 "narHash": "sha256-BAR",
516 "owner": "example",
517 "repo": "bar",
518 "rev": "barbar",
519 "type": "github"
520 },
521 "original": {
522 "owner": "example",
523 "repo": "bar",
524 "type": "github"
525 }
526 }
527 },
528 "root": "root",
529 "version": 7
530}"#
531 }
532
533 #[test]
536 fn parse_minimal_lock() {
537 let lock = FlakeLock::parse(minimal_lock_json()).expect("parse failed");
538 assert_eq!(lock.version, 7);
539 assert_eq!(lock.root, "root");
540 assert_eq!(lock.nodes.len(), 2);
541 assert!(lock.nodes.contains_key("root"));
542 assert!(lock.nodes.contains_key("nixpkgs"));
543 }
544
545 #[test]
546 fn minimal_root_node_has_no_locked() {
547 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
548 let root = lock.root_node().unwrap();
549 assert!(root.locked.is_none());
550 assert!(root.original.is_none());
551 }
552
553 #[test]
554 fn minimal_nixpkgs_locked_fields() {
555 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
556 let nixpkgs = lock.get_node("nixpkgs").unwrap();
557 let locked = nixpkgs.locked.as_ref().expect("missing locked");
558 assert_eq!(locked.source_type, "github");
559 assert_eq!(locked.owner.as_deref(), Some("nixos"));
560 assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
561 assert_eq!(
562 locked.rev.as_deref(),
563 Some("abc123def456abc123def456abc123def456abc1"),
564 );
565 assert_eq!(locked.last_modified, Some(1_700_000_000));
566 assert!(locked.nar_hash.is_some());
567 }
568
569 #[test]
570 fn minimal_nixpkgs_original_fields() {
571 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
572 let nixpkgs = lock.get_node("nixpkgs").unwrap();
573 let original = nixpkgs.original.as_ref().expect("missing original");
574 assert_eq!(original.source_type, "github");
575 assert_eq!(original.owner.as_deref(), Some("nixos"));
576 assert_eq!(original.repo.as_deref(), Some("nixpkgs"));
577 assert_eq!(original.git_ref.as_deref(), Some("nixos-unstable"));
578 }
579
580 #[test]
581 fn minimal_root_inputs() {
582 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
583 let inputs = lock.root_inputs().unwrap();
584 assert_eq!(inputs.len(), 1);
585 assert_eq!(inputs[0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
586 }
587
588 #[test]
591 fn parse_follows_lock() {
592 let lock = FlakeLock::parse(follows_lock_json()).expect("parse failed");
593 assert_eq!(lock.nodes.len(), 4); }
595
596 #[test]
597 fn follows_utils_nixpkgs_resolves_to_root_nixpkgs() {
598 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
599 let utils = lock.get_node("utils").unwrap();
600 let nixpkgs_ref = &utils.inputs["nixpkgs"];
601 assert_eq!(nixpkgs_ref, &InputRef::Follows(vec!["nixpkgs".to_string()]));
602
603 let resolved = lock.resolve_ref("utils", nixpkgs_ref).unwrap();
605 assert_eq!(resolved, "nixpkgs");
606 }
607
608 #[test]
609 fn resolve_input_walk_utils_nixpkgs() {
610 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
611 let node = lock.resolve_input(&["utils", "nixpkgs"]).unwrap();
614 let locked = node.locked.as_ref().unwrap();
615 assert_eq!(locked.owner.as_deref(), Some("nixos"));
616 assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
617 }
618
619 #[test]
620 fn resolve_input_walk_utils_systems() {
621 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
622 let node = lock.resolve_input(&["utils", "systems"]).unwrap();
623 let locked = node.locked.as_ref().unwrap();
624 assert_eq!(locked.owner.as_deref(), Some("nix-systems"));
625 assert_eq!(locked.repo.as_deref(), Some("default"));
626 }
627
628 #[test]
631 fn deep_follows_bar_nixpkgs_resolves_through_foo() {
632 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
633
634 let node = lock.resolve_input(&["bar", "nixpkgs"]).unwrap();
640 let locked = node.locked.as_ref().unwrap();
641 assert_eq!(locked.owner.as_deref(), Some("nixos"));
642 assert_eq!(locked.rev.as_deref(), Some("abc123"));
643 }
644
645 #[test]
646 fn deep_follows_foo_nixpkgs_resolves_to_root() {
647 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
648 let node = lock.resolve_input(&["foo", "nixpkgs"]).unwrap();
649 let locked = node.locked.as_ref().unwrap();
650 assert_eq!(locked.owner.as_deref(), Some("nixos"));
651 }
652
653 #[test]
663 fn resolve_node_input_follows_lands_on_root_target() {
664 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
665 let target = lock.resolve_node_input("utils", "nixpkgs").unwrap();
669 assert_eq!(target, "nixpkgs");
670 let target = lock.resolve_node_input("utils", "systems").unwrap();
672 assert_eq!(target, "systems");
673 }
674
675 #[test]
676 fn node_input_edges_resolves_follows_for_subflake() {
677 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
678 let mut edges = lock.node_input_edges("utils");
679 edges.sort();
680 assert_eq!(
681 edges,
682 vec![
683 ("nixpkgs".to_string(), "nixpkgs".to_string()),
684 ("systems".to_string(), "systems".to_string()),
685 ],
686 "utils' follows edge must resolve to the ROOT nixpkgs node graph"
687 );
688 }
689
690 #[test]
691 fn node_input_edges_deep_follows_chain() {
692 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
696 let target = lock.resolve_node_input("bar", "nixpkgs").unwrap();
697 assert_eq!(target, "nixpkgs");
698 }
699
700 #[test]
701 fn node_input_edges_unknown_node_is_empty() {
702 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
703 assert!(lock.node_input_edges("does-not-exist").is_empty());
704 }
705
706 #[test]
707 fn resolve_node_input_unknown_input_errors() {
708 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
709 assert!(lock.resolve_node_input("utils", "ghost").is_err());
710 }
711
712 #[test]
715 fn adjacency_map_follows_lock() {
716 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
717 let adj = lock.adjacency_map();
718
719 let root_edges = &adj["root"];
721 assert_eq!(root_edges.len(), 2);
722 assert!(root_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
723 assert!(root_edges.contains(&("utils".to_string(), "utils".to_string())));
724
725 let utils_edges = &adj["utils"];
727 assert_eq!(utils_edges.len(), 2);
728 assert!(utils_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
729 assert!(utils_edges.contains(&("systems".to_string(), "systems".to_string())));
730
731 assert!(adj["nixpkgs"].is_empty());
733 assert!(adj["systems"].is_empty());
734 }
735
736 #[test]
739 fn rejects_unsupported_version() {
740 let json = r#"{ "nodes": { "root": { "inputs": {} } }, "root": "root", "version": 6 }"#;
741 let err = FlakeLock::parse(json).unwrap_err();
742 assert!(matches!(err, FlakeLockError::UnsupportedVersion { found: 6, .. }));
743 }
744
745 #[test]
746 fn rejects_missing_root_node() {
747 let json = r#"{ "nodes": { "x": {} }, "root": "root", "version": 7 }"#;
748 let err = FlakeLock::parse(json).unwrap_err();
749 assert!(matches!(err, FlakeLockError::MissingRoot(_)));
750 }
751
752 #[test]
753 fn get_node_missing_returns_error() {
754 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
755 assert!(lock.get_node("nonexistent").is_err());
756 }
757
758 #[test]
759 fn resolve_input_missing_segment_returns_error() {
760 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
761 let result = lock.resolve_input(&["nonexistent"]);
762 assert!(result.is_err());
763 }
764
765 #[test]
766 fn resolve_ref_direct_missing_node_returns_error() {
767 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
768 let result = lock.resolve_ref("root", &InputRef::Direct("ghost".to_string()));
769 assert!(result.is_err());
770 }
771
772 #[test]
773 fn resolve_follows_empty_path_returns_error() {
774 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
775 let result = lock.resolve_ref("root", &InputRef::Follows(vec![]));
776 assert!(result.is_err());
777 }
778
779 #[test]
780 fn resolve_follows_bad_segment_returns_error() {
781 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
782 let result = lock.resolve_ref(
783 "utils",
784 &InputRef::Follows(vec!["nonexistent".to_string()]),
785 );
786 assert!(result.is_err());
787 }
788
789 #[test]
792 fn roundtrip_minimal() {
793 let original = FlakeLock::parse(minimal_lock_json()).unwrap();
794 let json = original.to_json().unwrap();
795 let reparsed = FlakeLock::parse(&json).unwrap();
796
797 assert_eq!(reparsed.version, original.version);
798 assert_eq!(reparsed.root, original.root);
799 assert_eq!(reparsed.nodes.len(), original.nodes.len());
800
801 let np = reparsed.get_node("nixpkgs").unwrap();
803 let locked = np.locked.as_ref().unwrap();
804 assert_eq!(locked.rev.as_deref(), Some("abc123def456abc123def456abc123def456abc1"));
805 }
806
807 #[test]
808 fn roundtrip_with_follows() {
809 let original = FlakeLock::parse(follows_lock_json()).unwrap();
810 let json = original.to_json().unwrap();
811 let reparsed = FlakeLock::parse(&json).unwrap();
812
813 assert_eq!(reparsed.nodes.len(), original.nodes.len());
814
815 let utils = reparsed.get_node("utils").unwrap();
817 assert_eq!(
818 utils.inputs["nixpkgs"],
819 InputRef::Follows(vec!["nixpkgs".to_string()]),
820 );
821
822 let node = reparsed.resolve_input(&["utils", "nixpkgs"]).unwrap();
824 assert_eq!(
825 node.locked.as_ref().unwrap().owner.as_deref(),
826 Some("nixos"),
827 );
828 }
829
830 #[test]
833 fn parse_non_flake_input() {
834 let json = r#"{
835 "nodes": {
836 "data": {
837 "flake": false,
838 "locked": {
839 "lastModified": 1700000000,
840 "narHash": "sha256-DATA",
841 "owner": "someone",
842 "repo": "data-files",
843 "rev": "deadbeef",
844 "type": "github"
845 },
846 "original": {
847 "owner": "someone",
848 "repo": "data-files",
849 "type": "github"
850 }
851 },
852 "root": {
853 "inputs": {
854 "data": "data"
855 }
856 }
857 },
858 "root": "root",
859 "version": 7
860}"#;
861 let lock = FlakeLock::parse(json).unwrap();
862 let data = lock.get_node("data").unwrap();
863 assert_eq!(data.flake, Some(false));
864 }
865
866 #[test]
869 fn input_ref_direct_deserialize() {
870 let v: InputRef = serde_json::from_str(r#""nixpkgs""#).unwrap();
871 assert_eq!(v, InputRef::Direct("nixpkgs".to_string()));
872 }
873
874 #[test]
875 fn input_ref_follows_deserialize() {
876 let v: InputRef = serde_json::from_str(r#"["nixpkgs"]"#).unwrap();
877 assert_eq!(v, InputRef::Follows(vec!["nixpkgs".to_string()]));
878 }
879
880 #[test]
881 fn input_ref_follows_multi_segment_deserialize() {
882 let v: InputRef = serde_json::from_str(r#"["foo", "nixpkgs"]"#).unwrap();
883 assert_eq!(
884 v,
885 InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
886 );
887 }
888
889 #[test]
890 fn input_ref_direct_roundtrip() {
891 let original = InputRef::Direct("nixpkgs".to_string());
892 let json = serde_json::to_string(&original).unwrap();
893 let reparsed: InputRef = serde_json::from_str(&json).unwrap();
894 assert_eq!(original, reparsed);
895 }
896
897 #[test]
898 fn input_ref_follows_roundtrip() {
899 let original = InputRef::Follows(vec!["foo".to_string(), "bar".to_string()]);
900 let json = serde_json::to_string(&original).unwrap();
901 let reparsed: InputRef = serde_json::from_str(&json).unwrap();
902 assert_eq!(original, reparsed);
903 }
904
905 #[test]
908 fn parse_path_type_locked_input() {
909 let json = r#"{
910 "nodes": {
911 "local": {
912 "locked": {
913 "lastModified": 1700000000,
914 "narHash": "sha256-PATH",
915 "path": "/home/user/my-flake",
916 "type": "path"
917 },
918 "original": {
919 "type": "path",
920 "url": "/home/user/my-flake"
921 }
922 },
923 "root": {
924 "inputs": {
925 "local": "local"
926 }
927 }
928 },
929 "root": "root",
930 "version": 7
931}"#;
932 let lock = FlakeLock::parse(json).unwrap();
933 let local = lock.get_node("local").unwrap();
934 let locked = local.locked.as_ref().unwrap();
935 assert_eq!(locked.source_type, "path");
936 assert_eq!(locked.path.as_deref(), Some("/home/user/my-flake"));
937 }
938
939 #[test]
942 fn multiple_inputs_follow_same_target() {
943 let json = r#"{
944 "nodes": {
945 "nixpkgs": {
946 "locked": {
947 "lastModified": 1700000000,
948 "narHash": "sha256-NP",
949 "owner": "nixos",
950 "repo": "nixpkgs",
951 "rev": "aaa",
952 "type": "github"
953 },
954 "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
955 },
956 "root": {
957 "inputs": {
958 "a": "a",
959 "b": "b",
960 "nixpkgs": "nixpkgs"
961 }
962 },
963 "a": {
964 "inputs": { "nixpkgs": ["nixpkgs"] },
965 "locked": {
966 "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github"
967 },
968 "original": { "owner": "x", "repo": "a", "type": "github" }
969 },
970 "b": {
971 "inputs": { "nixpkgs": ["nixpkgs"] },
972 "locked": {
973 "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github"
974 },
975 "original": { "owner": "x", "repo": "b", "type": "github" }
976 }
977 },
978 "root": "root",
979 "version": 7
980}"#;
981 let lock = FlakeLock::parse(json).unwrap();
982
983 let a_np = lock.resolve_input(&["a", "nixpkgs"]).unwrap();
985 let b_np = lock.resolve_input(&["b", "nixpkgs"]).unwrap();
986
987 assert_eq!(
988 a_np.locked.as_ref().unwrap().rev.as_deref(),
989 Some("aaa"),
990 );
991 assert_eq!(
992 b_np.locked.as_ref().unwrap().rev.as_deref(),
993 Some("aaa"),
994 );
995 }
996
997 #[test]
1000 fn invalid_json_returns_error() {
1001 assert!(FlakeLock::parse("not json").is_err());
1002 }
1003
1004 #[test]
1005 fn empty_object_returns_error() {
1006 assert!(FlakeLock::parse("{}").is_err());
1007 }
1008
1009 #[test]
1012 fn flake_false_node_default_is_none() {
1013 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1014 let nixpkgs = lock.get_node("nixpkgs").unwrap();
1015 assert_eq!(nixpkgs.flake, None);
1016 }
1017
1018 #[test]
1019 fn flake_false_roundtrips_through_json() {
1020 let json = r#"{
1021 "nodes": {
1022 "data-files": {
1023 "flake": false,
1024 "locked": {
1025 "lastModified": 1700000000,
1026 "narHash": "sha256-DATA",
1027 "owner": "example",
1028 "repo": "data",
1029 "rev": "abc123",
1030 "type": "github"
1031 },
1032 "original": {
1033 "owner": "example",
1034 "repo": "data",
1035 "type": "github"
1036 }
1037 },
1038 "root": {
1039 "inputs": {
1040 "data-files": "data-files"
1041 }
1042 }
1043 },
1044 "root": "root",
1045 "version": 7
1046}"#;
1047 let lock = FlakeLock::parse(json).unwrap();
1048 let data = lock.get_node("data-files").unwrap();
1049 assert_eq!(data.flake, Some(false));
1050
1051 let reserialized = lock.to_json().unwrap();
1052 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1053 let data2 = reparsed.get_node("data-files").unwrap();
1054 assert_eq!(data2.flake, Some(false));
1055 }
1056
1057 #[test]
1060 fn follows_of_follows_three_levels() {
1061 let json = r#"{
1062 "nodes": {
1063 "nixpkgs": {
1064 "locked": {
1065 "lastModified": 1700000000,
1066 "narHash": "sha256-NP",
1067 "owner": "nixos",
1068 "repo": "nixpkgs",
1069 "rev": "final",
1070 "type": "github"
1071 },
1072 "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
1073 },
1074 "root": {
1075 "inputs": {
1076 "a": "a",
1077 "b": "b",
1078 "c": "c",
1079 "nixpkgs": "nixpkgs"
1080 }
1081 },
1082 "a": {
1083 "inputs": { "nixpkgs": ["nixpkgs"] },
1084 "locked": { "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
1085 "original": { "owner": "x", "repo": "a", "type": "github" }
1086 },
1087 "b": {
1088 "inputs": { "nixpkgs": ["a", "nixpkgs"] },
1089 "locked": { "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
1090 "original": { "owner": "x", "repo": "b", "type": "github" }
1091 },
1092 "c": {
1093 "inputs": { "nixpkgs": ["b", "nixpkgs"] },
1094 "locked": { "lastModified": 3, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
1095 "original": { "owner": "x", "repo": "c", "type": "github" }
1096 }
1097 },
1098 "root": "root",
1099 "version": 7
1100}"#;
1101 let lock = FlakeLock::parse(json).unwrap();
1102
1103 let node = lock.resolve_input(&["c", "nixpkgs"]).unwrap();
1110 assert_eq!(
1111 node.locked.as_ref().unwrap().rev.as_deref(),
1112 Some("final"),
1113 );
1114 }
1115
1116 #[test]
1119 fn malformed_version_string() {
1120 let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": "seven" }"#;
1121 assert!(FlakeLock::parse(json).is_err());
1122 }
1123
1124 #[test]
1125 fn malformed_input_ref_integer() {
1126 let json = r#"{
1127 "nodes": {
1128 "root": {
1129 "inputs": { "x": 42 }
1130 }
1131 },
1132 "root": "root",
1133 "version": 7
1134}"#;
1135 assert!(FlakeLock::parse(json).is_err());
1136 }
1137
1138 #[test]
1139 fn missing_version_field() {
1140 let json = r#"{ "nodes": { "root": {} }, "root": "root" }"#;
1141 assert!(FlakeLock::parse(json).is_err());
1142 }
1143
1144 #[test]
1145 fn null_root_field() {
1146 let json = r#"{ "nodes": { "root": {} }, "root": null, "version": 7 }"#;
1147 assert!(FlakeLock::parse(json).is_err());
1148 }
1149
1150 #[test]
1153 fn roundtrip_deep_follows() {
1154 let original = FlakeLock::parse(deep_follows_json()).unwrap();
1155 let json = original.to_json().unwrap();
1156 let reparsed = FlakeLock::parse(&json).unwrap();
1157
1158 assert_eq!(reparsed.nodes.len(), original.nodes.len());
1159 let bar = reparsed.get_node("bar").unwrap();
1160 assert_eq!(
1161 bar.inputs["nixpkgs"],
1162 InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
1163 );
1164 }
1165
1166 #[test]
1169 fn adjacency_map_deep_follows() {
1170 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1171 let adj = lock.adjacency_map();
1172
1173 let root_edges = &adj["root"];
1174 assert_eq!(root_edges.len(), 3);
1175
1176 let bar_edges = &adj["bar"];
1177 assert_eq!(bar_edges.len(), 1);
1178 assert!(bar_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
1179 }
1180
1181 #[test]
1184 fn follows_chain_four_levels_deep() {
1185 let json = r#"{
1186 "nodes": {
1187 "nixpkgs": {
1188 "locked": { "lastModified": 1, "narHash": "sha256-NP", "owner": "n", "repo": "p", "rev": "final", "type": "github" },
1189 "original": { "owner": "n", "repo": "p", "type": "github" }
1190 },
1191 "root": {
1192 "inputs": { "a": "a", "b": "b", "c": "c", "d": "d", "nixpkgs": "nixpkgs" }
1193 },
1194 "a": {
1195 "inputs": { "nixpkgs": ["nixpkgs"] },
1196 "locked": { "lastModified": 2, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
1197 "original": { "owner": "x", "repo": "a", "type": "github" }
1198 },
1199 "b": {
1200 "inputs": { "nixpkgs": ["a", "nixpkgs"] },
1201 "locked": { "lastModified": 3, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
1202 "original": { "owner": "x", "repo": "b", "type": "github" }
1203 },
1204 "c": {
1205 "inputs": { "nixpkgs": ["b", "nixpkgs"] },
1206 "locked": { "lastModified": 4, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
1207 "original": { "owner": "x", "repo": "c", "type": "github" }
1208 },
1209 "d": {
1210 "inputs": { "nixpkgs": ["c", "nixpkgs"] },
1211 "locked": { "lastModified": 5, "narHash": "sha256-D", "owner": "x", "repo": "d", "rev": "d1", "type": "github" },
1212 "original": { "owner": "x", "repo": "d", "type": "github" }
1213 }
1214 },
1215 "root": "root",
1216 "version": 7
1217}"#;
1218 let lock = FlakeLock::parse(json).unwrap();
1219 let node = lock.resolve_input(&["d", "nixpkgs"]).unwrap();
1221 assert_eq!(node.locked.as_ref().unwrap().rev.as_deref(), Some("final"));
1222 }
1223
1224 #[test]
1227 fn flake_node_extra_field_roundtrips() {
1228 let json = r#"{
1230 "nodes": {
1231 "root": {
1232 "inputs": { "self-ref": "self-ref" }
1233 },
1234 "self-ref": {
1235 "locked": {
1236 "lastModified": 1700000000,
1237 "narHash": "sha256-X",
1238 "path": "/tmp/foo",
1239 "type": "path"
1240 },
1241 "original": {
1242 "type": "path",
1243 "url": "/tmp/foo"
1244 },
1245 "parent": ["root"]
1246 }
1247 },
1248 "root": "root",
1249 "version": 7
1250}"#;
1251 let lock = FlakeLock::parse(json).unwrap();
1252 let node = lock.get_node("self-ref").unwrap();
1253 assert!(node.extra.contains_key("parent"));
1254
1255 let reserialized = lock.to_json().unwrap();
1256 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1257 let node2 = reparsed.get_node("self-ref").unwrap();
1258 assert!(node2.extra.contains_key("parent"));
1259 }
1260
1261 #[test]
1264 fn original_input_extra_fields_roundtrip() {
1265 let json = r#"{
1266 "nodes": {
1267 "root": { "inputs": { "x": "x" } },
1268 "x": {
1269 "locked": {
1270 "lastModified": 1700000000,
1271 "narHash": "sha256-X",
1272 "owner": "o",
1273 "repo": "r",
1274 "rev": "abc",
1275 "type": "github"
1276 },
1277 "original": {
1278 "owner": "o",
1279 "repo": "r",
1280 "type": "github",
1281 "submodules": true,
1282 "shallow": false
1283 }
1284 }
1285 },
1286 "root": "root",
1287 "version": 7
1288}"#;
1289 let lock = FlakeLock::parse(json).unwrap();
1290 let x = lock.get_node("x").unwrap();
1291 let original = x.original.as_ref().unwrap();
1292 assert_eq!(original.extra.get("submodules"), Some(&serde_json::json!(true)));
1293 assert_eq!(original.extra.get("shallow"), Some(&serde_json::json!(false)));
1294
1295 let reserialized = lock.to_json().unwrap();
1296 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1297 let x2 = reparsed.get_node("x").unwrap();
1298 let orig2 = x2.original.as_ref().unwrap();
1299 assert_eq!(orig2.extra.get("submodules"), Some(&serde_json::json!(true)));
1300 }
1301
1302 #[test]
1305 fn unsupported_version_error_includes_found() {
1306 let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 99 }"#;
1307 let err = FlakeLock::parse(json).unwrap_err();
1308 match err {
1309 FlakeLockError::UnsupportedVersion { expected, found } => {
1310 assert_eq!(expected, 7);
1311 assert_eq!(found, 99);
1312 }
1313 other => panic!("expected UnsupportedVersion, got {other:?}"),
1314 }
1315 }
1316
1317 #[test]
1318 fn missing_root_error_includes_name() {
1319 let json = r#"{ "nodes": { "x": {} }, "root": "missing-root", "version": 7 }"#;
1320 match FlakeLock::parse(json).unwrap_err() {
1321 FlakeLockError::MissingRoot(name) => assert_eq!(name, "missing-root"),
1322 other => panic!("expected MissingRoot, got {other:?}"),
1323 }
1324 }
1325
1326 #[test]
1327 fn get_node_returns_node_not_found_with_name() {
1328 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1329 match lock.get_node("nope") {
1330 Err(FlakeLockError::NodeNotFound(n)) => assert_eq!(n, "nope"),
1331 other => panic!("expected NodeNotFound, got {other:?}"),
1332 }
1333 }
1334
1335 #[test]
1338 fn version_as_float_rejected() {
1339 let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 7.5 }"#;
1340 assert!(FlakeLock::parse(json).is_err());
1341 }
1342
1343 #[test]
1346 fn adjacency_map_minimal() {
1347 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1348 let adj = lock.adjacency_map();
1349 assert_eq!(adj.len(), 2);
1350 assert_eq!(adj["root"].len(), 1);
1351 assert_eq!(adj["root"][0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
1352 assert!(adj["nixpkgs"].is_empty());
1353 }
1354
1355 #[test]
1358 fn adjacency_map_skips_unresolvable() {
1359 let mut nodes = BTreeMap::new();
1361 nodes.insert("root".to_string(), FlakeNode {
1362 inputs: {
1363 let mut m = BTreeMap::new();
1364 m.insert("ghost".to_string(), InputRef::Direct("nonexistent".to_string()));
1365 m
1366 },
1367 locked: None,
1368 original: None,
1369 flake: None,
1370 extra: BTreeMap::new(),
1371 });
1372 let lock = FlakeLock {
1373 nodes,
1374 root: "root".to_string(),
1375 version: 7,
1376 };
1377 let adj = lock.adjacency_map();
1378 assert!(adj["root"].is_empty());
1380 }
1381
1382 #[test]
1385 fn resolve_input_empty_path_returns_root() {
1386 let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1387 let node = lock.resolve_input(&[]).unwrap();
1388 assert!(node.inputs.contains_key("nixpkgs"));
1390 }
1391
1392 #[test]
1395 fn root_inputs_sorted_alphabetically() {
1396 let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1397 let inputs = lock.root_inputs().unwrap();
1398 let names: Vec<&str> = inputs.iter().map(|(n, _)| n.as_str()).collect();
1400 assert_eq!(names, vec!["bar", "foo", "nixpkgs"]);
1401 }
1402
1403 #[test]
1406 fn input_ref_direct_serialize_to_string_literal() {
1407 let r = InputRef::Direct("nixpkgs".to_string());
1408 let json = serde_json::to_string(&r).unwrap();
1409 assert_eq!(json, r#""nixpkgs""#);
1410 }
1411
1412 #[test]
1413 fn input_ref_follows_serialize_to_array() {
1414 let r = InputRef::Follows(vec!["a".to_string(), "b".to_string()]);
1415 let json = serde_json::to_string(&r).unwrap();
1416 assert_eq!(json, r#"["a","b"]"#);
1417 }
1418
1419 #[test]
1422 fn tarball_type_input_with_url() {
1423 let json = r#"{
1424 "nodes": {
1425 "root": { "inputs": { "src": "src" } },
1426 "src": {
1427 "locked": {
1428 "lastModified": 1700000000,
1429 "narHash": "sha256-X",
1430 "type": "tarball",
1431 "url": "https://example.com/v1.0.tar.gz"
1432 },
1433 "original": {
1434 "type": "tarball",
1435 "url": "https://example.com/latest.tar.gz"
1436 }
1437 }
1438 },
1439 "root": "root",
1440 "version": 7
1441}"#;
1442 let lock = FlakeLock::parse(json).unwrap();
1443 let src = lock.get_node("src").unwrap();
1444 let locked = src.locked.as_ref().unwrap();
1445 assert_eq!(locked.source_type, "tarball");
1446 assert_eq!(locked.url.as_deref(), Some("https://example.com/v1.0.tar.gz"));
1447 }
1448
1449 #[test]
1452 fn git_ref_input_preserved() {
1453 let json = r#"{
1454 "nodes": {
1455 "root": { "inputs": { "deps": "deps" } },
1456 "deps": {
1457 "locked": {
1458 "lastModified": 1700000000,
1459 "narHash": "sha256-X",
1460 "owner": "o",
1461 "repo": "r",
1462 "rev": "abc",
1463 "ref": "refs/heads/main",
1464 "type": "github"
1465 },
1466 "original": {
1467 "owner": "o",
1468 "repo": "r",
1469 "ref": "main",
1470 "type": "github"
1471 }
1472 }
1473 },
1474 "root": "root",
1475 "version": 7
1476}"#;
1477 let lock = FlakeLock::parse(json).unwrap();
1478 let deps = lock.get_node("deps").unwrap();
1479 let locked = deps.locked.as_ref().unwrap();
1480 assert_eq!(locked.git_ref.as_deref(), Some("refs/heads/main"));
1481 let orig = deps.original.as_ref().unwrap();
1482 assert_eq!(orig.git_ref.as_deref(), Some("main"));
1483 }
1484
1485 #[test]
1488 fn git_dir_subdirectory_field() {
1489 let json = r#"{
1490 "nodes": {
1491 "root": { "inputs": { "subdir": "subdir" } },
1492 "subdir": {
1493 "locked": {
1494 "lastModified": 1700000000,
1495 "narHash": "sha256-X",
1496 "owner": "o",
1497 "repo": "r",
1498 "rev": "abc",
1499 "type": "github",
1500 "dir": "subdir/inside"
1501 },
1502 "original": {
1503 "owner": "o",
1504 "repo": "r",
1505 "type": "github",
1506 "dir": "subdir/inside"
1507 }
1508 }
1509 },
1510 "root": "root",
1511 "version": 7
1512}"#;
1513 let lock = FlakeLock::parse(json).unwrap();
1514 let s = lock.get_node("subdir").unwrap();
1515 let locked = s.locked.as_ref().unwrap();
1516 assert_eq!(locked.dir.as_deref(), Some("subdir/inside"));
1517 let orig = s.original.as_ref().unwrap();
1518 assert_eq!(orig.dir.as_deref(), Some("subdir/inside"));
1519 }
1520
1521 #[test]
1524 fn indirect_id_input() {
1525 let json = r#"{
1526 "nodes": {
1527 "root": { "inputs": { "nixpkgs": "nixpkgs" } },
1528 "nixpkgs": {
1529 "locked": {
1530 "lastModified": 1700000000,
1531 "narHash": "sha256-X",
1532 "owner": "nixos",
1533 "repo": "nixpkgs",
1534 "rev": "abc",
1535 "type": "github"
1536 },
1537 "original": {
1538 "id": "nixpkgs",
1539 "type": "indirect"
1540 }
1541 }
1542 },
1543 "root": "root",
1544 "version": 7
1545}"#;
1546 let lock = FlakeLock::parse(json).unwrap();
1547 let np = lock.get_node("nixpkgs").unwrap();
1548 let orig = np.original.as_ref().unwrap();
1549 assert_eq!(orig.source_type, "indirect");
1550 assert_eq!(orig.id.as_deref(), Some("nixpkgs"));
1551 }
1552
1553 #[test]
1556 fn resolve_follows_when_target_segment_is_direct() {
1557 let lock = FlakeLock::parse(follows_lock_json()).unwrap();
1558 let node = lock.resolve_input(&["utils", "systems"]).unwrap();
1561 let locked = node.locked.as_ref().unwrap();
1562 assert_eq!(locked.source_type, "github");
1563 }
1564
1565 #[test]
1568 fn trailing_whitespace_in_json_ok() {
1569 let json = format!("{}\n\n \n", minimal_lock_json());
1570 let lock = FlakeLock::parse(&json).unwrap();
1571 assert_eq!(lock.version, 7);
1572 }
1573
1574 #[test]
1577 fn two_nodes_with_same_underlying_rev() {
1578 let json = r#"{
1579 "nodes": {
1580 "root": { "inputs": { "a": "a", "b": "b" } },
1581 "a": {
1582 "locked": {
1583 "lastModified": 1, "narHash": "sha256-X",
1584 "owner": "n", "repo": "p", "rev": "abc",
1585 "type": "github"
1586 },
1587 "original": { "owner": "n", "repo": "p", "type": "github" }
1588 },
1589 "b": {
1590 "locked": {
1591 "lastModified": 1, "narHash": "sha256-X",
1592 "owner": "n", "repo": "p", "rev": "abc",
1593 "type": "github"
1594 },
1595 "original": { "owner": "n", "repo": "p", "type": "github" }
1596 }
1597 },
1598 "root": "root",
1599 "version": 7
1600}"#;
1601 let lock = FlakeLock::parse(json).unwrap();
1602 assert_eq!(lock.nodes.len(), 3);
1603 let a = lock.get_node("a").unwrap();
1604 let b = lock.get_node("b").unwrap();
1605 assert_eq!(
1607 a.locked.as_ref().unwrap().rev,
1608 b.locked.as_ref().unwrap().rev
1609 );
1610 }
1611
1612 #[test]
1615 fn flake_lock_error_display_includes_context() {
1616 let err = FlakeLockError::FollowsFailed {
1617 from: "node-x".to_string(),
1618 path: vec!["a".to_string(), "b".to_string()],
1619 };
1620 let s = format!("{err}");
1621 assert!(s.contains("node-x"));
1622 }
1623
1624 #[test]
1627 fn extra_fields_roundtrip() {
1628 let json = r#"{
1629 "nodes": {
1630 "local": {
1631 "locked": {
1632 "lastModified": 1700000000,
1633 "narHash": "sha256-X",
1634 "path": "/home/user/proj",
1635 "type": "path",
1636 "revCount": 42,
1637 "submodules": true
1638 },
1639 "original": {
1640 "type": "path",
1641 "url": "/home/user/proj"
1642 }
1643 },
1644 "root": {
1645 "inputs": { "local": "local" }
1646 }
1647 },
1648 "root": "root",
1649 "version": 7
1650}"#;
1651 let lock = FlakeLock::parse(json).unwrap();
1652 let local = lock.get_node("local").unwrap();
1653 let locked = local.locked.as_ref().unwrap();
1654 assert_eq!(locked.extra.get("revCount"), Some(&serde_json::json!(42)));
1655 assert_eq!(locked.extra.get("submodules"), Some(&serde_json::json!(true)));
1656
1657 let reserialized = lock.to_json().unwrap();
1658 let reparsed = FlakeLock::parse(&reserialized).unwrap();
1659 let local2 = reparsed.get_node("local").unwrap();
1660 let locked2 = local2.locked.as_ref().unwrap();
1661 assert_eq!(locked2.extra.get("revCount"), Some(&serde_json::json!(42)));
1662 }
1663}