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