1use crate::error::Error;
2use crate::follows::{AttrPath, Segment};
3use serde::{Deserialize, Deserializer};
4use std::collections::HashMap;
5use std::fs::File;
6use std::io::Read;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum LockError {
18 #[error("failed to parse flake.lock as json")]
20 Parse(#[from] serde_json::Error),
21 #[error("flake.lock is missing the root node")]
23 MissingRoot,
24 #[error("flake.lock root has no inputs")]
26 RootHasNoInputs,
27 #[error("input '{path}' has no sub-inputs in flake.lock")]
29 InputHasNoSubInputs { path: String },
30 #[error("input '{path}' not found in flake.lock")]
32 InputNotFound { path: String },
33 #[error("input '{path}' has no follows target")]
36 FollowsTargetMissing { path: String },
37 #[error("could not find lockfile node '{node}' referenced by input '{path}'")]
39 NodeMissingForPath { node: String, path: String },
40 #[error("could not find lockfile node '{node}'")]
42 NodeMissing { node: String },
43 #[error("cycle while resolving follows path")]
45 FollowsCycle,
46 #[error("lockfile node has no locked information")]
48 NodeNotLocked,
49 #[error("locked node has no rev")]
51 LockedHasNoRev,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct NestedInput {
58 pub path: AttrPath,
60 pub follows: Option<AttrPath>,
62 pub url: Option<String>,
66}
67
68impl NestedInput {
69 pub fn to_display_string(&self) -> String {
73 match &self.follows {
74 Some(target) => format!("{}\t{}", self.path, target),
75 None => self.path.to_string(),
76 }
77 }
78}
79
80#[derive(Debug, Deserialize)]
83pub struct FlakeLock {
84 nodes: HashMap<String, Node>,
85 root: String,
86}
87
88#[derive(Debug, Deserialize)]
90pub(crate) struct Node {
91 inputs: Option<HashMap<String, Input>>,
92 locked: Option<Locked>,
93 original: Option<Original>,
94}
95
96impl Node {
97 fn rev(&self) -> Result<String, LockError> {
98 self.locked.as_ref().ok_or(LockError::NodeNotLocked)?.rev()
99 }
100}
101
102#[derive(Debug, Clone)]
114pub enum Input {
115 Direct(String),
116 Indirect(Option<AttrPath>),
117}
118
119impl<'de> Deserialize<'de> for Input {
120 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121 where
122 D: Deserializer<'de>,
123 {
124 use serde::de::{Error, SeqAccess, Visitor};
125 use std::fmt;
126
127 struct InputVisitor;
128
129 impl<'de> Visitor<'de> for InputVisitor {
130 type Value = Input;
131
132 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
133 f.write_str(
134 "a node name string, an empty array, or an array of \
135 non-empty segment names",
136 )
137 }
138
139 fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
140 Ok(Input::Direct(v.to_string()))
141 }
142
143 fn visit_string<E: Error>(self, v: String) -> Result<Self::Value, E> {
144 Ok(Input::Direct(v))
145 }
146
147 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
148 where
149 A: SeqAccess<'de>,
150 {
151 let Some(first) = seq.next_element::<String>()? else {
156 return Ok(Input::Indirect(None));
157 };
158 let first_seg = Segment::from_unquoted(first).map_err(A::Error::custom)?;
159 let mut path = AttrPath::new(first_seg);
160 while let Some(raw) = seq.next_element::<String>()? {
161 let seg = Segment::from_unquoted(raw).map_err(A::Error::custom)?;
162 path.push(seg);
163 }
164 Ok(Input::Indirect(Some(path)))
165 }
166 }
167
168 deserializer.deserialize_any(InputVisitor)
169 }
170}
171
172#[derive(Debug, Deserialize, Clone)]
176pub(crate) struct Locked {
177 rev: Option<String>,
178}
179
180impl Locked {
181 fn rev(&self) -> Result<String, LockError> {
182 self.rev.clone().ok_or(LockError::LockedHasNoRev)
183 }
184}
185
186#[derive(Debug)]
189pub(crate) enum Original {
190 Github {
191 owner: String,
192 repo: String,
193 ref_field: Option<String>,
194 },
195 Gitlab {
196 owner: String,
197 repo: String,
198 ref_field: Option<String>,
199 },
200 Sourcehut {
201 owner: String,
202 repo: String,
203 ref_field: Option<String>,
204 },
205 Git {
206 url: String,
207 ref_field: Option<String>,
208 },
209 Hg {
210 url: String,
211 ref_field: Option<String>,
212 },
213 Tarball {
214 url: String,
215 },
216 File {
217 url: String,
218 },
219 Path {
220 path: String,
221 },
222 Indirect {
223 id: String,
224 ref_field: Option<String>,
225 },
226 Unknown {
232 node_type: String,
233 },
234}
235
236impl<'de> Deserialize<'de> for Original {
237 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
238 where
239 D: Deserializer<'de>,
240 {
241 use serde::de::Error;
242
243 #[derive(Deserialize)]
244 struct ForgePayload {
245 owner: String,
246 repo: String,
247 #[serde(rename = "ref")]
248 ref_field: Option<String>,
249 }
250 #[derive(Deserialize)]
251 struct VcsPayload {
252 url: String,
253 #[serde(rename = "ref")]
254 ref_field: Option<String>,
255 }
256 #[derive(Deserialize)]
257 struct UrlPayload {
258 url: String,
259 }
260 #[derive(Deserialize)]
261 struct PathPayload {
262 path: String,
263 }
264 #[derive(Deserialize)]
265 struct IndirectPayload {
266 id: String,
267 #[serde(rename = "ref")]
268 ref_field: Option<String>,
269 }
270
271 let value = serde_json::Value::deserialize(deserializer)?;
272 let node_type = value
273 .get("type")
274 .and_then(serde_json::Value::as_str)
275 .ok_or_else(|| D::Error::missing_field("type"))?
276 .to_string();
277
278 fn payload<T: for<'a> Deserialize<'a>, E: Error>(value: serde_json::Value) -> Result<T, E> {
279 serde_json::from_value(value).map_err(E::custom)
280 }
281
282 Ok(match node_type.as_str() {
283 "github" => {
284 let ForgePayload {
285 owner,
286 repo,
287 ref_field,
288 } = payload(value)?;
289 Original::Github {
290 owner,
291 repo,
292 ref_field,
293 }
294 }
295 "gitlab" => {
296 let ForgePayload {
297 owner,
298 repo,
299 ref_field,
300 } = payload(value)?;
301 Original::Gitlab {
302 owner,
303 repo,
304 ref_field,
305 }
306 }
307 "sourcehut" => {
308 let ForgePayload {
309 owner,
310 repo,
311 ref_field,
312 } = payload(value)?;
313 Original::Sourcehut {
314 owner,
315 repo,
316 ref_field,
317 }
318 }
319 "git" => {
320 let VcsPayload { url, ref_field } = payload(value)?;
321 Original::Git { url, ref_field }
322 }
323 "hg" => {
324 let VcsPayload { url, ref_field } = payload(value)?;
325 Original::Hg { url, ref_field }
326 }
327 "tarball" => {
328 let UrlPayload { url } = payload(value)?;
329 Original::Tarball { url }
330 }
331 "file" => {
332 let UrlPayload { url } = payload(value)?;
333 Original::File { url }
334 }
335 "path" => {
336 let PathPayload { path } = payload(value)?;
337 Original::Path { path }
338 }
339 "indirect" => {
340 let IndirectPayload { id, ref_field } = payload(value)?;
341 Original::Indirect { id, ref_field }
342 }
343 _ => Original::Unknown { node_type },
344 })
345 }
346}
347
348impl Original {
349 fn to_flake_url(&self) -> Option<String> {
353 match self {
354 Original::Github {
355 owner,
356 repo,
357 ref_field,
358 } => Some(forge_flake_url("github", owner, repo, ref_field.as_deref())),
359 Original::Gitlab {
360 owner,
361 repo,
362 ref_field,
363 } => Some(forge_flake_url("gitlab", owner, repo, ref_field.as_deref())),
364 Original::Sourcehut {
365 owner,
366 repo,
367 ref_field,
368 } => Some(forge_flake_url(
369 "sourcehut",
370 owner,
371 repo,
372 ref_field.as_deref(),
373 )),
374 Original::Git { url, ref_field } => {
375 Some(prefixed_vcs_url("git+", url, ref_field.as_deref()))
376 }
377 Original::Hg { url, ref_field } => {
378 Some(prefixed_vcs_url("hg+", url, ref_field.as_deref()))
379 }
380 Original::Tarball { url } | Original::File { url } => Some(url.clone()),
381 Original::Path { path } => Some(format!("path:{path}")),
382 Original::Indirect { id, ref_field } => {
383 Some(indirect_flake_url(id, ref_field.as_deref()))
384 }
385 Original::Unknown { node_type } => {
386 tracing::warn!(
387 "Unknown flake.lock node type '{node_type}'; cannot reconstruct flake URL"
388 );
389 None
390 }
391 }
392 }
393}
394
395fn forge_flake_url(scheme: &str, owner: &str, repo: &str, ref_field: Option<&str>) -> String {
398 let mut url = format!("{scheme}:{owner}/{repo}");
399 if let Some(r) = ref_field {
400 url.push('/');
401 url.push_str(r);
402 }
403 url
404}
405
406fn prefixed_vcs_url(scheme_prefix: &str, url: &str, ref_field: Option<&str>) -> String {
413 let Some(r) = ref_field else {
414 return format!("{scheme_prefix}{url}");
415 };
416 let separator = if url.contains('?') { '&' } else { '?' };
417 format!("{scheme_prefix}{url}{separator}ref={r}")
418}
419
420fn indirect_flake_url(id: &str, ref_field: Option<&str>) -> String {
424 match ref_field {
425 Some(r) => format!("flake:{id}/{r}"),
426 None => format!("flake:{id}"),
427 }
428}
429
430#[cfg(test)]
435thread_local! {
436 pub(crate) static NESTED_INPUTS_CALLS: std::cell::Cell<usize> =
437 const { std::cell::Cell::new(0) };
438}
439
440impl FlakeLock {
441 const LOCK: &'static str = "flake.lock";
442
443 pub fn from_default_path() -> Result<Self, Error> {
445 let path = PathBuf::from(Self::LOCK);
446 Self::from_file(path)
447 }
448
449 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
451 let path = path.as_ref();
452 let mut file = File::open(path).map_err(|source| Error::Read {
453 path: path.to_path_buf(),
454 source,
455 })?;
456 let mut contents = String::new();
457 file.read_to_string(&mut contents)
458 .map_err(|source| Error::Read {
459 path: path.to_path_buf(),
460 source,
461 })?;
462 Self::read_from_str(&contents)
463 }
464
465 pub fn read_from_str(str: &str) -> Result<Self, Error> {
467 serde_json::from_str(str).map_err(|e| Error::Lock(LockError::Parse(e)))
468 }
469
470 pub fn root(&self) -> &str {
472 &self.root
473 }
474
475 fn resolve_input_path(&self, path: &AttrPath) -> Result<String, LockError> {
482 const MAX_HOPS: usize = 64;
485 self.resolve_input_path_inner(path.segments(), MAX_HOPS)
486 }
487
488 fn resolve_input_path_inner(
492 &self,
493 segments: &[Segment],
494 budget: usize,
495 ) -> Result<String, LockError> {
496 if budget == 0 {
497 return Err(LockError::FollowsCycle);
498 }
499
500 let mut current_key = self.root.clone();
501 let mut current_node = self.nodes.get(self.root()).ok_or(LockError::MissingRoot)?;
502
503 for (i, segment) in segments.iter().enumerate() {
504 let inputs = current_node.inputs.as_ref().ok_or_else(|| {
505 if i == 0 {
506 LockError::RootHasNoInputs
507 } else {
508 let prefix: Vec<_> = segments[..i].iter().map(|s| s.as_str()).collect();
509 LockError::InputHasNoSubInputs {
510 path: prefix.join("."),
511 }
512 }
513 })?;
514
515 let resolved = inputs.get(segment.as_str()).ok_or_else(|| {
516 let prefix: Vec<_> = segments[..=i].iter().map(|s| s.as_str()).collect();
517 LockError::InputNotFound {
518 path: prefix.join("."),
519 }
520 })?;
521
522 match resolved {
523 Input::Direct(node_key) => {
524 current_key = node_key.clone();
525 }
526 Input::Indirect(Some(follows_path)) => {
527 let mut new_path: Vec<Segment> = follows_path.segments().to_vec();
528 new_path.extend(segments[i + 1..].iter().cloned());
529 return self.resolve_input_path_inner(&new_path, budget - 1);
530 }
531 Input::Indirect(None) => {
532 let prefix: Vec<_> = segments[..=i].iter().map(|s| s.as_str()).collect();
533 return Err(LockError::FollowsTargetMissing {
534 path: prefix.join("."),
535 });
536 }
537 }
538
539 if i + 1 < segments.len() {
540 current_node = self.nodes.get(¤t_key).ok_or_else(|| {
541 let prefix: Vec<_> = segments[..=i].iter().map(|s| s.as_str()).collect();
542 LockError::NodeMissingForPath {
543 node: current_key.clone(),
544 path: prefix.join("."),
545 }
546 })?;
547 }
548 }
549
550 Ok(current_key)
551 }
552
553 pub fn rev_for(&self, path: &AttrPath) -> Result<String, Error> {
561 let node_name = self.resolve_input_path(path)?;
562 let node = self
563 .nodes
564 .get(&node_name)
565 .ok_or_else(|| LockError::NodeMissing {
566 node: node_name.clone(),
567 })?;
568 Ok(node.rev()?)
569 }
570
571 pub fn nested_inputs(&self) -> Vec<NestedInput> {
581 #[cfg(test)]
582 NESTED_INPUTS_CALLS.with(|c| c.set(c.get() + 1));
583
584 let mut inputs = Vec::new();
585 let Some(root_node) = self.nodes.get(&self.root) else {
586 return inputs;
587 };
588 let Some(root_inputs) = &root_node.inputs else {
589 return inputs;
590 };
591
592 for (top_level_name, top_level_ref) in root_inputs {
593 let node_name = match top_level_ref {
594 Input::Direct(name) => name.clone(),
595 Input::Indirect(_) => continue,
599 };
600 let Ok(parent_seg) = Segment::from_unquoted(top_level_name.clone()) else {
601 continue;
602 };
603 let path = AttrPath::new(parent_seg);
604 let mut visited: HashMap<String, ()> = HashMap::new();
605 visited.insert(node_name.clone(), ());
606 self.collect_nested_inputs_recursive(&node_name, &path, 1, &mut visited, &mut inputs);
607 }
608
609 inputs.sort_by(|a, b| a.path.cmp(&b.path));
610 inputs
611 }
612
613 fn collect_nested_inputs_recursive(
616 &self,
617 node_name: &str,
618 parent_path: &AttrPath,
619 depth: usize,
620 visited: &mut HashMap<String, ()>,
621 out: &mut Vec<NestedInput>,
622 ) {
623 if depth >= NESTED_INPUTS_MAX_DEPTH {
624 return;
625 }
626 let Some(node) = self.nodes.get(node_name) else {
627 return;
628 };
629 let Some(node_inputs) = &node.inputs else {
630 return;
631 };
632
633 let mut keys: Vec<&String> = node_inputs.keys().collect();
635 keys.sort();
636 for nested_name in keys {
637 let nested_ref = node_inputs.get(nested_name).unwrap();
638 let Ok(nested_seg) = Segment::from_unquoted(nested_name.clone()) else {
639 continue;
640 };
641 let mut path = parent_path.clone();
642 path.push(nested_seg);
643
644 let (follows, url, descend_into) = match nested_ref {
645 Input::Indirect(Some(target)) => (Some(target.clone()), None, None),
646 Input::Indirect(None) => (None, None, None),
650 Input::Direct(child_node_name) => {
651 let url = self
652 .nodes
653 .get(child_node_name.as_str())
654 .and_then(|n| n.original.as_ref())
655 .and_then(|o| o.to_flake_url());
656 (None, url, Some(child_node_name.clone()))
657 }
658 };
659
660 out.push(NestedInput {
661 path: path.clone(),
662 follows,
663 url,
664 });
665
666 if let Some(child) = descend_into {
667 if visited.contains_key(&child) {
668 continue;
669 }
670 visited.insert(child.clone(), ());
671 self.collect_nested_inputs_recursive(&child, &path, depth + 1, visited, out);
672 visited.remove(&child);
673 }
674 }
675 }
676}
677
678pub const NESTED_INPUTS_MAX_DEPTH: usize = 64;
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 fn minimal_lock() -> &'static str {
686 r#"
687 {
688 "nodes": {
689 "nixpkgs": {
690 "locked": {
691 "lastModified": 1718714799,
692 "narHash": "sha256-FUZpz9rg3gL8NVPKbqU8ei1VkPLsTIfAJ2fdAf5qjak=",
693 "owner": "nixos",
694 "repo": "nixpkgs",
695 "rev": "c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
696 "type": "github"
697 },
698 "original": {
699 "owner": "nixos",
700 "ref": "nixos-unstable",
701 "repo": "nixpkgs",
702 "type": "github"
703 }
704 },
705 "root": {
706 "inputs": {
707 "nixpkgs": "nixpkgs"
708 }
709 }
710 },
711 "root": "root",
712 "version": 7
713}
714 "#
715 }
716 fn minimal_independent_lock_no_overrides() -> &'static str {
717 r#"
718 {
719 "nodes": {
720 "nixpkgs": {
721 "locked": {
722 "lastModified": 1721138476,
723 "narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
724 "owner": "nixos",
725 "repo": "nixpkgs",
726 "rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
727 "type": "github"
728 },
729 "original": {
730 "owner": "nixos",
731 "ref": "nixos-unstable",
732 "repo": "nixpkgs",
733 "type": "github"
734 }
735 },
736 "nixpkgs_2": {
737 "locked": {
738 "lastModified": 1719690277,
739 "narHash": "sha256-0xSej1g7eP2kaUF+JQp8jdyNmpmCJKRpO12mKl/36Kc=",
740 "owner": "nixos",
741 "repo": "nixpkgs",
742 "rev": "2741b4b489b55df32afac57bc4bfd220e8bf617e",
743 "type": "github"
744 },
745 "original": {
746 "owner": "nixos",
747 "ref": "nixos-unstable",
748 "repo": "nixpkgs",
749 "type": "github"
750 }
751 },
752 "root": {
753 "inputs": {
754 "nixpkgs": "nixpkgs",
755 "treefmt-nix": "treefmt-nix"
756 }
757 },
758 "treefmt-nix": {
759 "inputs": {
760 "nixpkgs": "nixpkgs_2"
761 },
762 "locked": {
763 "lastModified": 1721382922,
764 "narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
765 "owner": "numtide",
766 "repo": "treefmt-nix",
767 "rev": "50104496fb55c9140501ea80d183f3223d13ff65",
768 "type": "github"
769 },
770 "original": {
771 "owner": "numtide",
772 "repo": "treefmt-nix",
773 "type": "github"
774 }
775 }
776 },
777 "root": "root",
778 "version": 7
779}
780 "#
781 }
782
783 fn minimal_independent_lock_nixpkgs_overridden() -> &'static str {
784 r#"
785 {
786 "nodes": {
787 "nixpkgs": {
788 "locked": {
789 "lastModified": 1721138476,
790 "narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
791 "owner": "nixos",
792 "repo": "nixpkgs",
793 "rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
794 "type": "github"
795 },
796 "original": {
797 "owner": "nixos",
798 "ref": "nixos-unstable",
799 "repo": "nixpkgs",
800 "type": "github"
801 }
802 },
803 "root": {
804 "inputs": {
805 "nixpkgs": "nixpkgs",
806 "treefmt-nix": "treefmt-nix"
807 }
808 },
809 "treefmt-nix": {
810 "inputs": {
811 "nixpkgs": [
812 "nixpkgs"
813 ]
814 },
815 "locked": {
816 "lastModified": 1721382922,
817 "narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
818 "owner": "numtide",
819 "repo": "treefmt-nix",
820 "rev": "50104496fb55c9140501ea80d183f3223d13ff65",
821 "type": "github"
822 },
823 "original": {
824 "owner": "numtide",
825 "repo": "treefmt-nix",
826 "type": "github"
827 }
828 }
829 },
830 "root": "root",
831 "version": 7
832}
833 "#
834 }
835
836 #[test]
837 fn parse_minimal() {
838 let minimal_lock = minimal_lock();
839 FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
840 }
841 #[test]
845 fn parse_ignores_unknown_version() {
846 let lock = r#"{
847 "nodes": { "root": { "inputs": {} } },
848 "root": "root",
849 "version": 99
850}"#;
851 FlakeLock::read_from_str(lock).expect("unknown version must still parse");
852 }
853 #[test]
854 fn parse_minimal_root() {
855 let minimal_lock = minimal_lock();
856 let parsed_lock =
857 FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
858 assert_eq!("root", parsed_lock.root);
859 }
860 #[test]
861 fn minimal_ref() {
862 let minimal_lock = minimal_lock();
863 let parsed_lock =
864 FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
865 assert_eq!(
866 "c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
867 parsed_lock
868 .rev_for(&"nixpkgs".parse().unwrap())
869 .expect("Id: nixpkgs is in the lockfile.")
870 );
871 }
872 #[test]
873 fn parse_minimal_independent_lock_no_overrides() {
874 let minimal_lock = minimal_independent_lock_no_overrides();
875 FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
876 }
877 #[test]
878 fn minimal_independent_lock_no_overrides_ref() {
879 let minimal_lock = minimal_independent_lock_no_overrides();
880 let parsed_lock =
881 FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
882 assert_eq!(
883 "ad0b5eed1b6031efaed382844806550c3dcb4206",
884 parsed_lock
885 .rev_for(&"nixpkgs".parse().unwrap())
886 .expect("Id: nixpkgs is in the lockfile.")
887 );
888 }
889 #[test]
890 fn parse_minimal_independent_lock_nixpkgs_overridden() {
891 let minimal_lock = minimal_independent_lock_nixpkgs_overridden();
892 FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
893 }
894
895 #[test]
896 fn rev_for_sub_input_path_missing_parent_returns_error() {
897 let minimal_lock = minimal_lock();
898 let parsed_lock =
899 FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
900 assert!(
901 parsed_lock
902 .rev_for(&"browseros.nixpkgs".parse().unwrap())
903 .is_err()
904 );
905 }
906
907 #[test]
908 fn rev_for_sub_input_path_resolves() {
909 let lock = minimal_independent_lock_no_overrides();
910 let parsed = FlakeLock::read_from_str(lock).expect("Should be parsed correctly.");
911 assert_eq!(
912 "2741b4b489b55df32afac57bc4bfd220e8bf617e",
913 parsed
914 .rev_for(&"treefmt-nix.nixpkgs".parse().unwrap())
915 .expect("Should resolve sub-input path")
916 );
917 }
918
919 #[test]
920 fn rev_for_sub_input_follows_resolves() {
921 let lock = minimal_independent_lock_nixpkgs_overridden();
922 let parsed = FlakeLock::read_from_str(lock).expect("Should be parsed correctly.");
923 assert_eq!(
924 parsed.rev_for(&"nixpkgs".parse().unwrap()).unwrap(),
925 parsed
926 .rev_for(&"treefmt-nix.nixpkgs".parse().unwrap())
927 .expect("Should resolve followed sub-input")
928 );
929 }
930
931 #[test]
932 fn rev_for_quoted_id() {
933 let minimal_lock = minimal_lock();
936 let parsed_lock =
937 FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
938 assert_eq!(
939 parsed_lock.rev_for(&"nixpkgs".parse().unwrap()).unwrap(),
940 parsed_lock
941 .rev_for(&"\"nixpkgs\"".parse().unwrap())
942 .unwrap(),
943 );
944 }
945
946 #[test]
947 fn rev_for_node_without_locked_returns_error() {
948 let lock = r#"{
949 "nodes": {
950 "root": {
951 "inputs": { "bare": "bare" }
952 },
953 "bare": {
954 "original": { "owner": "o", "repo": "r", "type": "github" }
955 }
956 },
957 "root": "root",
958 "version": 7
959}"#;
960 let parsed = FlakeLock::read_from_str(lock).unwrap();
961 assert!(parsed.rev_for(&"bare".parse().unwrap()).is_err());
962 }
963
964 #[test]
965 fn rev_for_node_without_rev_returns_error() {
966 let lock = r#"{
969 "nodes": {
970 "root": {
971 "inputs": { "norev": "norev" }
972 },
973 "norev": {
974 "locked": { "lastModified": 1, "narHash": "", "type": "path" },
975 "original": { "type": "path", "path": "/tmp/norev" }
976 }
977 },
978 "root": "root",
979 "version": 7
980}"#;
981 let parsed = FlakeLock::read_from_str(lock).unwrap();
982 assert!(parsed.rev_for(&"norev".parse().unwrap()).is_err());
983 }
984
985 #[test]
986 fn nested_input_path_quotes_dots() {
987 let lock = r#"{
988 "nodes": {
989 "hls-1.10": {
990 "inputs": { "nixpkgs": "nixpkgs_2" },
991 "flake": false,
992 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
993 "original": { "owner": "o", "repo": "r", "type": "github" }
994 },
995 "nixpkgs": {
996 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
997 "original": { "owner": "o", "repo": "r", "type": "github" }
998 },
999 "nixpkgs_2": {
1000 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
1001 "original": { "owner": "o", "repo": "r", "type": "github" }
1002 },
1003 "root": {
1004 "inputs": { "hls-1.10": "hls-1.10", "nixpkgs": "nixpkgs" }
1005 }
1006 },
1007 "root": "root",
1008 "version": 7
1009}"#;
1010 let parsed = FlakeLock::read_from_str(lock).unwrap();
1011 let nested = parsed.nested_inputs();
1012 assert_eq!(nested.len(), 1);
1013 assert_eq!(nested[0].path.to_string(), "\"hls-1.10\".nixpkgs");
1014 }
1015
1016 #[test]
1017 fn nested_inputs_recurses_to_grandchild() {
1018 let lock = r#"{
1023 "nodes": {
1024 "flake-parts": {
1025 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "a", "type": "github" },
1026 "original": { "owner": "o", "repo": "r", "type": "github" }
1027 },
1028 "flake-parts_2": {
1029 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "b", "type": "github" },
1030 "original": { "owner": "o", "repo": "r", "type": "github" }
1031 },
1032 "neovim": {
1033 "inputs": { "nixvim": "nixvim" },
1034 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "c", "type": "github" },
1035 "original": { "owner": "o", "repo": "r", "type": "github" }
1036 },
1037 "nixvim": {
1038 "inputs": { "flake-parts": "flake-parts_2" },
1039 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "d", "type": "github" },
1040 "original": { "owner": "o", "repo": "r", "type": "github" }
1041 },
1042 "root": {
1043 "inputs": { "flake-parts": "flake-parts", "neovim": "neovim" }
1044 }
1045 },
1046 "root": "root",
1047 "version": 7
1048}"#;
1049 let parsed = FlakeLock::read_from_str(lock).unwrap();
1050 let nested = parsed.nested_inputs();
1051 let paths: Vec<String> = nested.iter().map(|n| n.path.to_string()).collect();
1052 assert!(
1053 paths.contains(&"neovim.nixvim".to_string()),
1054 "depth-1 path missing, got: {paths:?}"
1055 );
1056 assert!(
1057 paths.contains(&"neovim.nixvim.flake-parts".to_string()),
1058 "depth-2 path missing, got: {paths:?}"
1059 );
1060 }
1061
1062 #[test]
1063 fn nested_inputs_terminates_on_cyclic_lockfile() {
1064 let lock = r#"{
1068 "nodes": {
1069 "a": {
1070 "inputs": { "b": "b" },
1071 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "a", "type": "github" },
1072 "original": { "owner": "o", "repo": "r", "type": "github" }
1073 },
1074 "b": {
1075 "inputs": { "a": "a" },
1076 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "b", "type": "github" },
1077 "original": { "owner": "o", "repo": "r", "type": "github" }
1078 },
1079 "root": { "inputs": { "a": "a" } }
1080 },
1081 "root": "root",
1082 "version": 7
1083}"#;
1084 let parsed = FlakeLock::read_from_str(lock).unwrap();
1085 let nested = parsed.nested_inputs();
1086 assert!(!nested.is_empty());
1087 assert!(
1088 nested
1089 .iter()
1090 .all(|n| n.path.len() <= NESTED_INPUTS_MAX_DEPTH)
1091 );
1092 }
1093
1094 #[test]
1095 fn rev_for_quoted_sub_input_path() {
1096 let lock = r#"{
1097 "nodes": {
1098 "hls-1.10": {
1099 "inputs": { "nixpkgs": "nixpkgs_2" },
1100 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
1101 "original": { "owner": "o", "repo": "r", "type": "github" }
1102 },
1103 "nixpkgs": {
1104 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
1105 "original": { "owner": "o", "repo": "r", "type": "github" }
1106 },
1107 "nixpkgs_2": {
1108 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
1109 "original": { "owner": "o", "repo": "r", "type": "github" }
1110 },
1111 "root": {
1112 "inputs": { "hls-1.10": "hls-1.10", "nixpkgs": "nixpkgs" }
1113 }
1114 },
1115 "root": "root",
1116 "version": 7
1117}"#;
1118 let parsed = FlakeLock::read_from_str(lock).unwrap();
1119 assert_eq!(
1120 "def",
1121 parsed
1122 .rev_for(&"\"hls-1.10\".nixpkgs".parse().unwrap())
1123 .expect("Should resolve quoted sub-input path")
1124 );
1125 }
1126
1127 #[test]
1131 fn rev_for_indirect_resolves_via_root_inputs() {
1132 let lock = r#"{
1133 "nodes": {
1134 "nixpkgs_2": {
1135 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "type": "github" },
1136 "original": { "owner": "o", "repo": "r", "type": "github" }
1137 },
1138 "treefmt-nix": {
1139 "inputs": { "nixpkgs": ["nixpkgs"] },
1140 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "type": "github" },
1141 "original": { "owner": "o", "repo": "r", "type": "github" }
1142 },
1143 "root": {
1144 "inputs": { "nixpkgs": "nixpkgs_2", "treefmt-nix": "treefmt-nix" }
1145 }
1146 },
1147 "root": "root",
1148 "version": 7
1149}"#;
1150 let parsed = FlakeLock::read_from_str(lock).unwrap();
1151 assert_eq!(
1154 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1155 parsed
1156 .rev_for(&"treefmt-nix.nixpkgs".parse().unwrap())
1157 .expect("indirect follows must resolve through root.inputs, not by node name")
1158 );
1159 }
1160
1161 #[test]
1164 fn rev_for_indirect_multi_segment_path() {
1165 let lock = r#"{
1166 "nodes": {
1167 "nixpkgs": {
1168 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "1111111111111111111111111111111111111111", "type": "github" },
1169 "original": { "owner": "o", "repo": "r", "type": "github" }
1170 },
1171 "nixpkgs_2": {
1172 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "2222222222222222222222222222222222222222", "type": "github" },
1173 "original": { "owner": "o", "repo": "r", "type": "github" }
1174 },
1175 "crane": {
1176 "inputs": { "nixpkgs": "nixpkgs_2" },
1177 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "cccccccccccccccccccccccccccccccccccccccc", "type": "github" },
1178 "original": { "owner": "o", "repo": "r", "type": "github" }
1179 },
1180 "devshell": {
1181 "inputs": { "nixpkgs": ["crane", "nixpkgs"] },
1182 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "dddddddddddddddddddddddddddddddddddddddd", "type": "github" },
1183 "original": { "owner": "o", "repo": "r", "type": "github" }
1184 },
1185 "root": {
1186 "inputs": { "nixpkgs": "nixpkgs", "crane": "crane", "devshell": "devshell" }
1187 }
1188 },
1189 "root": "root",
1190 "version": 7
1191}"#;
1192 let parsed = FlakeLock::read_from_str(lock).unwrap();
1193 assert_eq!(
1196 "2222222222222222222222222222222222222222",
1197 parsed
1198 .rev_for(&"devshell.nixpkgs".parse().unwrap())
1199 .expect("multi-segment indirect follows must be walked from root")
1200 );
1201 }
1202
1203 fn collect_indirect_targets(lock: &FlakeLock) -> Vec<(String, String, Vec<String>)> {
1206 let mut out: Vec<(String, String, Vec<String>)> = Vec::new();
1207 for (node_name, node) in &lock.nodes {
1208 let Some(inputs) = node.inputs.as_ref() else {
1209 continue;
1210 };
1211 for (input_name, input_ref) in inputs {
1212 if let Input::Indirect(Some(path)) = input_ref {
1213 let segs: Vec<String> = path
1214 .segments()
1215 .iter()
1216 .map(|s| s.as_str().to_string())
1217 .collect();
1218 out.push((node_name.clone(), input_name.clone(), segs));
1219 }
1220 }
1221 }
1222 out.sort();
1223 out
1224 }
1225
1226 #[test]
1231 fn fixture_depth_upstream_redundant_depth3_parses_indirects() {
1232 let lock_text =
1233 std::fs::read_to_string("tests/fixtures/depth_upstream_redundant_depth3.flake.lock")
1234 .expect("fixture present");
1235 let lock = FlakeLock::read_from_str(&lock_text).expect("fixture parses");
1236 let mut segs_only: Vec<Vec<String>> = collect_indirect_targets(&lock)
1237 .into_iter()
1238 .map(|(_, _, segs)| segs)
1239 .collect();
1240 segs_only.sort();
1241 assert_eq!(
1242 segs_only,
1243 vec![
1244 vec!["nixpkgs".to_string()],
1245 vec![
1246 "omnibus".to_string(),
1247 "flops".to_string(),
1248 "nixpkgs".to_string()
1249 ],
1250 vec!["omnibus".to_string(), "nixpkgs".to_string()],
1251 ],
1252 "Indirect entries must be decoded with their full structural depth",
1253 );
1254 }
1255
1256 #[test]
1260 fn fixture_depth_upstream_partial_parses_indirects() {
1261 let lock_text = std::fs::read_to_string("tests/fixtures/depth_upstream_partial.flake.lock")
1262 .expect("fixture present");
1263 let lock = FlakeLock::read_from_str(&lock_text).expect("fixture parses");
1264 let entries = collect_indirect_targets(&lock);
1265 assert!(
1266 entries.len() >= 3,
1267 "fixture has at least three Indirect arrays, got {}",
1268 entries.len()
1269 );
1270 for (node, input, segs) in &entries {
1271 assert!(
1272 !segs.is_empty(),
1273 "{node}.{input}: Indirect path must be non-empty",
1274 );
1275 for seg in segs {
1276 assert!(
1277 !seg.is_empty() && !seg.contains('"'),
1278 "{node}.{input}: segment `{seg}` must be a valid Nix name",
1279 );
1280 }
1281 }
1282 }
1283
1284 #[test]
1289 fn fixture_dot_ancestor_cycle_parses_indirects_with_dotted_node() {
1290 let lock_text = std::fs::read_to_string("tests/fixtures/dot_ancestor_cycle.flake.lock")
1291 .expect("fixture present");
1292 let lock = FlakeLock::read_from_str(&lock_text).expect("fixture parses");
1293 let hls = lock.nodes.get("hls-1.10").expect("hls-1.10 node");
1296 let inputs = hls.inputs.as_ref().expect("hls-1.10 has inputs");
1297 match inputs.get("helper").expect("helper input present") {
1298 Input::Indirect(Some(path)) => {
1299 let segs: Vec<&str> = path.segments().iter().map(|s| s.as_str()).collect();
1300 assert_eq!(segs, vec!["helper"]);
1301 }
1302 Input::Indirect(None) => panic!("expected Indirect(Some), got Indirect(None)"),
1303 Input::Direct(name) => panic!("expected Indirect, got Direct({name})"),
1304 }
1305 }
1306
1307 #[test]
1312 fn indirect_empty_array_is_accepted_as_none() {
1313 let lock = r#"{
1314 "nodes": {
1315 "child": {
1316 "inputs": { "disabled": [] },
1317 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "x", "type": "github" },
1318 "original": { "owner": "o", "repo": "r", "type": "github" }
1319 },
1320 "root": { "inputs": { "child": "child" } }
1321 },
1322 "root": "root",
1323 "version": 7
1324}"#;
1325 let parsed = FlakeLock::read_from_str(lock).expect("empty Indirect must parse");
1326 let child = parsed.nodes.get("child").expect("child node");
1327 let inputs = child.inputs.as_ref().expect("child has inputs");
1328 match inputs.get("disabled").expect("disabled input present") {
1329 Input::Indirect(None) => {}
1330 other => panic!("expected Indirect(None), got {other:?}"),
1331 }
1332 }
1333
1334 #[test]
1341 fn nested_inputs_handles_mixed_direct_indirect_and_empty() {
1342 let lock = r#"{
1343 "nodes": {
1344 "flake-parts": {
1345 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "fp", "type": "github" },
1346 "original": { "owner": "o", "repo": "r", "type": "github" }
1347 },
1348 "nix": {
1349 "inputs": {
1350 "flake-compat": [],
1351 "flake-parts": "flake-parts",
1352 "nixpkgs": ["nixpkgs"]
1353 },
1354 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "n", "type": "github" },
1355 "original": { "owner": "o", "repo": "r", "type": "github" }
1356 },
1357 "nixpkgs": {
1358 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "np", "type": "github" },
1359 "original": { "owner": "o", "repo": "r", "type": "github" }
1360 },
1361 "root": { "inputs": { "nix": "nix", "nixpkgs": "nixpkgs" } }
1362 },
1363 "root": "root",
1364 "version": 7
1365}"#;
1366 let parsed = FlakeLock::read_from_str(lock).expect("mixed-shape lock parses");
1367 let nested = parsed.nested_inputs();
1368 let by_path: std::collections::HashMap<String, &NestedInput> =
1369 nested.iter().map(|n| (n.path.to_string(), n)).collect();
1370
1371 let disabled = by_path
1372 .get("nix.flake-compat")
1373 .expect("empty Indirect emitted as nested input");
1374 assert!(
1375 disabled.follows.is_none(),
1376 "empty `[]` must surface as follows: None, got {:?}",
1377 disabled.follows
1378 );
1379
1380 let resolved = by_path
1381 .get("nix.nixpkgs")
1382 .expect("non-empty Indirect emitted as nested input");
1383 assert_eq!(
1384 resolved.follows.as_ref().map(|p| p.to_string()),
1385 Some("nixpkgs".to_string()),
1386 "non-empty Indirect must surface its follows target",
1387 );
1388 }
1389
1390 #[test]
1394 fn to_flake_url_git_type_prepends_scheme() {
1395 let o = Original::Git {
1396 url: "https://git.clan.lol/clan/munix".to_string(),
1397 ref_field: None,
1398 };
1399 assert_eq!(
1400 o.to_flake_url().as_deref(),
1401 Some("git+https://git.clan.lol/clan/munix"),
1402 );
1403 }
1404
1405 #[test]
1408 fn to_flake_url_git_with_ref_appends_query() {
1409 let o = Original::Git {
1410 url: "https://git.example.com/repo".to_string(),
1411 ref_field: Some("main".to_string()),
1412 };
1413 assert_eq!(
1414 o.to_flake_url().as_deref(),
1415 Some("git+https://git.example.com/repo?ref=main"),
1416 );
1417 }
1418
1419 #[test]
1423 fn to_flake_url_hg_type_prepends_scheme() {
1424 let o = Original::Hg {
1425 url: "https://hg.example.com/repo".to_string(),
1426 ref_field: None,
1427 };
1428 assert_eq!(
1429 o.to_flake_url().as_deref(),
1430 Some("hg+https://hg.example.com/repo"),
1431 );
1432 }
1433
1434 #[test]
1439 fn to_flake_url_git_with_existing_query_appends_with_ampersand() {
1440 let o = Original::Git {
1441 url: "https://git.example.com/repo?dir=subdir".to_string(),
1442 ref_field: Some("main".to_string()),
1443 };
1444 assert_eq!(
1445 o.to_flake_url().as_deref(),
1446 Some("git+https://git.example.com/repo?dir=subdir&ref=main"),
1447 );
1448 }
1449
1450 #[test]
1451 fn to_flake_url_tarball_returns_url_unchanged() {
1452 let o = Original::Tarball {
1453 url: "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz".to_string(),
1454 };
1455 assert_eq!(
1456 o.to_flake_url().as_deref(),
1457 Some("https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz"),
1458 );
1459 }
1460
1461 #[test]
1462 fn to_flake_url_path_uses_path_field() {
1463 let o = Original::Path {
1464 path: "/etc/nixos".to_string(),
1465 };
1466 assert_eq!(o.to_flake_url().as_deref(), Some("path:/etc/nixos"));
1467 }
1468
1469 #[test]
1470 fn to_flake_url_indirect_uses_id_field() {
1471 let o = Original::Indirect {
1472 id: "nixpkgs".to_string(),
1473 ref_field: None,
1474 };
1475 assert_eq!(o.to_flake_url().as_deref(), Some("flake:nixpkgs"));
1476 }
1477
1478 #[test]
1482 fn to_flake_url_indirect_with_ref_appends_path_component() {
1483 let o = Original::Indirect {
1484 id: "nixpkgs".to_string(),
1485 ref_field: Some("nixos-25.05".to_string()),
1486 };
1487 assert_eq!(
1488 o.to_flake_url().as_deref(),
1489 Some("flake:nixpkgs/nixos-25.05"),
1490 );
1491 }
1492
1493 #[test]
1496 fn to_flake_url_unknown_type_returns_none() {
1497 let o: Original = serde_json::from_str(r#"{"type": "future-type"}"#).unwrap();
1498 assert!(matches!(&o, Original::Unknown { node_type } if node_type == "future-type"));
1499 assert_eq!(o.to_flake_url(), None);
1500 }
1501
1502 #[test]
1506 fn malformed_known_type_is_a_parse_error() {
1507 let err = serde_json::from_str::<Original>(r#"{"type": "github"}"#).unwrap_err();
1508 assert!(
1509 err.to_string().contains("owner"),
1510 "error must name the missing field, got: {err}",
1511 );
1512 }
1513
1514 #[test]
1518 fn missing_type_is_a_parse_error() {
1519 let err = serde_json::from_str::<Original>(r#"{}"#).unwrap_err();
1520 assert!(
1521 err.to_string().contains("type"),
1522 "error must name the missing field, got: {err}",
1523 );
1524 }
1525}