Skip to main content

flake_edit/
lock.rs

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/// Errors that arise from parsing or walking `flake.lock`.
13///
14/// Each variant pinpoints a distinct shape failure: a missing field, a
15/// follows cycle, or a node that the resolver cannot find. The variants
16/// carry the smallest contextual data (segment path, node key) that lets
17/// the caller reconstruct the failure without reparsing the message.
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum LockError {
21    /// `flake.lock` did not parse as JSON.
22    #[error("failed to parse flake.lock as json")]
23    Parse(#[from] serde_json::Error),
24    /// The lockfile has no `root` node referenced by `nodes`.
25    #[error("flake.lock is missing the root node")]
26    MissingRoot,
27    /// The root node carries no `inputs`, so a path cannot be walked.
28    #[error("flake.lock root has no inputs")]
29    RootHasNoInputs,
30    /// An intermediate input on the path has no sub-inputs.
31    #[error("input '{path}' has no sub-inputs in flake.lock")]
32    InputHasNoSubInputs { path: String },
33    /// A path segment does not resolve to a declared input.
34    #[error("input '{path}' not found in flake.lock")]
35    InputNotFound { path: String },
36    /// A `follows` declaration with an empty target was encountered while
37    /// walking. Surfaced as a hard failure when the path needs a target.
38    #[error("input '{path}' has no follows target")]
39    FollowsTargetMissing { path: String },
40    /// The node referenced by a path segment is absent from the `nodes` map.
41    #[error("could not find lockfile node '{node}' referenced by input '{path}'")]
42    NodeMissingForPath { node: String, path: String },
43    /// The node looked up directly by name is absent.
44    #[error("could not find lockfile node '{node}'")]
45    NodeMissing { node: String },
46    /// Recursion budget exhausted while resolving a follows path.
47    #[error("cycle while resolving follows path")]
48    FollowsCycle,
49    /// A node has no `locked` block (so no `rev` can be retrieved).
50    #[error("lockfile node has no locked information")]
51    NodeNotLocked,
52    /// A locked block has no `rev`.
53    #[error("locked node has no rev")]
54    LockedHasNoRev,
55}
56
57/// A nested input discovered in `flake.lock` with its existing follows
58/// target, if any.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct NestedInput {
61    /// Dotted path to the nested input (e.g. `crane.nixpkgs`).
62    pub path: AttrPath,
63    /// Existing follows target, if the input is redirected.
64    pub follows: Option<AttrPath>,
65    /// Original flake URL for `Direct` references (e.g.
66    /// `github:nixos/nixpkgs/nixos-unstable`). `None` for follows-only
67    /// references.
68    pub url: Option<String>,
69}
70
71impl NestedInput {
72    /// Render as `path\tfollows_target` (or just `path` when the input has no
73    /// follows target). The tab separator lets the UI style each side
74    /// independently.
75    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/// Parsed `flake.lock`. Loaded with [`Self::from_default_path`],
84/// [`Self::from_file`], or [`Self::read_from_str`].
85#[derive(Debug, Deserialize)]
86pub struct FlakeLock {
87    nodes: HashMap<String, Node>,
88    root: String,
89}
90
91/// A single entry in the lockfile's `nodes` map.
92#[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/// Reference from a node's `inputs` map.
106///
107/// Lockfile shape:
108/// - `"name"` is a direct reference to another node, parsed as
109///   [`Self::Direct`].
110/// - `["a", "b", ...]` is a follows path with a target, parsed as
111///   `Indirect(Some(path))`.
112/// - `[]` is a follows declaration with no target (Nix emits this when an
113///   upstream chain has overridden the input to nothing, e.g. a lockfile
114///   whose source `flake.nix` carries
115///   `nix.inputs.flake-compat.follows = "";`), parsed as `Indirect(None)`.
116#[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                // Empty `[]` is a real lockfile shape: `inputs.X = []`
155                // marks an input whose follows chain has been overridden
156                // away. Surface it as `Indirect(None)` instead of an
157                // error.
158                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/// Locked metadata for a node. Only [`Self::rev`] and, under the
176/// `sizes` feature, the NAR hash (store-size estimation) are kept.
177/// The other JSON coordinates (`owner`, `repo`, `type`, ...) are
178/// ignored on parse.
179#[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/// Original (pre-lock) reference for a node, as written in the source
194/// flake. One variant per flakeref shape Nix produces in `flake.lock`.
195#[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    /// Flakeref `type` not recognized by this crate. Tolerated because
234    /// Nix adds new schemes occasionally; lockfiles that mention one
235    /// should still parse so the rest of the inputs stay usable.
236    /// Malformed payloads of recognized types surface as `D::Error`,
237    /// not here.
238    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    /// Reconstruct a flake URL from the original reference. Returns
357    /// `None` for [`Original::Unknown`], which also logs a
358    /// `tracing::warn!` naming the unrecognized type.
359    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
402/// Build a `<scheme>:<owner>/<repo>[/<ref>]` URL, the shape shared by
403/// `github`, `gitlab`, and `sourcehut` flakerefs.
404fn 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
413/// Prepend `git+` / `hg+` to a VCS transport URL and append `?ref=<r>`.
414///
415/// Nix keeps unrecognized query parameters (e.g. `?dir=subdir`) inside
416/// the lockfile's `url` field, so the input may already carry a `?`.
417/// Append with `&` in that case to avoid producing a flakeref with two
418/// `?`, which Nix's URL parser rejects.
419fn 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
427/// Indirect refs encode `ref` as a path component (`flake:<id>/<ref>`),
428/// not a query parameter. Other VCS schemes use `?ref=`; the indirect
429/// scheme is the odd one out and easy to get wrong.
430fn 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// Per-thread `nested_inputs` call counter. A `follow` invocation must walk
438// the immutable lockfile once and thread the result through every consumer;
439// tests reset this and assert the walk count. Thread-local so parallel test
440// threads do not contend.
441#[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    /// Load `flake.lock` from the current directory.
451    pub fn from_default_path() -> Result<Self, Error> {
452        let path = PathBuf::from(Self::LOCK);
453        Self::from_file(path)
454    }
455
456    /// Load and parse a lockfile from `path`.
457    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    /// Parse lockfile JSON from `str`.
473    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    /// Name of the root node.
478    pub fn root(&self) -> &str {
479        &self.root
480    }
481
482    /// Resolve an [`AttrPath`] to the node key it ultimately points at,
483    /// walking the lock tree from `root`.
484    ///
485    /// [`Input::Indirect`] entries are `follows` paths of input *names*
486    /// rooted at the lock root, not node keys, so encountering one restarts
487    /// the walk from root with that path plus any remaining segments.
488    fn resolve_input_path(&self, path: &AttrPath) -> Result<String, LockError> {
489        // Bound recursion: a valid lock file has no follows cycles, but we
490        // still guard against malformed input.
491        const MAX_HOPS: usize = 64;
492        self.resolve_input_path_inner(path.segments(), MAX_HOPS)
493    }
494
495    /// `segments` is non-empty by construction: callers either forward an
496    /// [`AttrPath`]'s segments or prepend an [`Input::Indirect`] follows
497    /// path, both of which are non-empty.
498    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(&current_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    /// Resolve `path` to its locked revision.
559    ///
560    /// # Errors
561    ///
562    /// Returns a [`Error::Lock`] wrapping the underlying
563    /// [`LockError`] if any segment is missing in the lock graph, the
564    /// resolved node is not present, or it carries no `rev`.
565    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    /// All nested inputs reachable from the root, with their existing
577    /// follows targets.
578    ///
579    /// Walks `flake.lock` recursively from the root, emitting one entry per
580    /// `inputs.X` on any descendant node and building the path
581    /// segment-by-segment. Capped at [`NESTED_INPUTS_MAX_DEPTH`]. Cycles in
582    /// the node graph are broken by a visited set keyed on node name.
583    ///
584    /// Output is sorted by path for stable emission order.
585    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                // Indirect top-level inputs have no sub-inputs to descend
601                // into. Their follows edges still appear via the recursive
602                // walker on the `Direct` siblings that own them.
603                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    /// Descend through `node_name`, emitting one [`NestedInput`] per declared
619    /// sub-input up to [`NESTED_INPUTS_MAX_DEPTH`].
620    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        // Iterate sub-inputs in lex order so emission is deterministic.
639        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                // Empty `[]` declarations have no follows target. Emit
652                // the input with `follows: None` so the path is still
653                // visible to the UI.
654                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
683/// Maximum recursion depth for [`FlakeLock::nested_inputs`]. Backstops
684/// pathological cycles in malformed lockfiles.
685pub 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    /// The lockfile's top-level `"version"` field is not validated. A
847    /// wildly unsupported version (e.g. `99`) must still parse cleanly
848    /// so this crate can read whatever shape Nix produces.
849    #[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        // Quoted attribute names like `"nixpkgs-24.11"` from `list --format simple`
939        // should be stripped before lock lookup.
940        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        // `path` is set only so the `original` block deserializes; this
972        // test is about missing `locked.rev`.
973        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        // The walker descends through `Direct` children, so a depth-2
1024        // nested input (root → neovim → nixvim → flake-parts) must be
1025        // emitted with a 3-segment path. This exercises the recursive
1026        // path-stack code in `collect_nested_inputs_recursive`.
1027        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        // A pathological lock graph where node A's input recurses back
1070        // into A itself must not loop forever. The visited-set in
1071        // `collect_nested_inputs_recursive` short-circuits the cycle.
1072        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    /// An `Indirect` follows path names *inputs* from the lock root, not node
1133    /// keys. Resolution must go through `root.inputs`, which may map e.g.
1134    /// `nixpkgs` to a node keyed `nixpkgs_2`.
1135    #[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        // treefmt-nix.nixpkgs follows ["nixpkgs"], i.e. root.inputs.nixpkgs,
1157        // which is node "nixpkgs_2". There is no node literally named "nixpkgs".
1158        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    /// A multi-segment follows path like `["crane", "nixpkgs"]` must be walked
1167    /// segment-by-segment from root, since each hop can map to a renamed node key.
1168    #[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        // devshell.nixpkgs follows ["crane","nixpkgs"] -> node "nixpkgs_2" (rev 222...),
1199        // NOT root's "nixpkgs" node (rev 111...).
1200        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    /// Walk every `inputs` map and collect each `Indirect` follows target as
1209    /// a vector of segment strings. Used by the fixture parse tests below.
1210    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    /// `depth_upstream_redundant_depth3.flake.lock` has three `Indirect`
1232    /// entries forming the upstream-propagation chain: `omnibus.nixpkgs`
1233    /// follows `["nixpkgs"]`, the depth-2 follows `["omnibus", "nixpkgs"]`,
1234    /// and the depth-3 follows `["omnibus", "flops", "nixpkgs"]`.
1235    #[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    /// `depth_upstream_partial.flake.lock` covers a deeper mix of Indirect
1262    /// shapes; verify each one is decoded into a non-empty `AttrPath` whose
1263    /// segments are valid Nix names (no embedded quotes / control chars).
1264    #[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    /// `dot_ancestor_cycle.flake.lock` exercises the dotted-segment case:
1290    /// the lockfile node `hls-1.10` is reachable through the typed
1291    /// `AttrPath`, even though a literal dot in the segment forces source-
1292    /// form quoting at the `flake.nix` boundary.
1293    #[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        // Direct walk: the dotted node `hls-1.10` exists and its
1299        // `Indirect` `["helper"]` entry is decoded as a one-segment path.
1300        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    /// Empty `[]` follows arrays mark an input whose follows chain has
1313    /// been overridden away (e.g. a lockfile entry `inputs.flake-compat
1314    /// = []`). The deserializer must accept them and store them as
1315    /// `Indirect(None)`.
1316    #[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    /// A top-level input (`nix`) whose nested inputs map mixes
1340    /// [`Input::Direct`] references, non-empty
1341    /// [`Input::Indirect`] arrays (`["nixpkgs"]`), and empty `[]`
1342    /// declarations must parse cleanly. [`FlakeLock::nested_inputs`]
1343    /// emits one entry per declaration; entries built from `[]` carry
1344    /// `follows: None`.
1345    #[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    /// A bare `https://...` parses as a `tarball` flakeref, not a
1396    /// `git` repo, so the missing `git+` prefix is a silent corruption
1397    /// rather than a parse error.
1398    #[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    /// Loss of `ref` would unpin the reconstructed URL from the branch
1411    /// the lock captured.
1412    #[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    /// The lockfile type is `"hg"` (not `"mercurial"`); the previous
1425    /// spelling silently matched no input and dropped every hg
1426    /// promotion.
1427    #[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    /// `original.url` may already carry a `?` (Nix only strips
1440    /// recognized params like `ref` / `shallow`; the rest stay
1441    /// in-band). Appending `?ref=` would produce two `?`, which Nix
1442    /// rejects.
1443    #[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    /// Indirect refs use a path component (`flake:<id>/<ref>`) rather
1484    /// than the `?ref=` query the VCS schemes use; pin the test on
1485    /// that asymmetry.
1486    #[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    /// Unrecognized `type` strings deserialize to [`Original::Unknown`]
1499    /// and [`Original::to_flake_url`] returns `None`.
1500    #[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    /// Malformed payload of a recognized type must be a parse error, not
1508    /// a silent fall-through to [`Original::Unknown`]. The error message
1509    /// must name the missing field so the failure is actionable.
1510    #[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    /// A node with `original.type` absent is malformed at the protocol
1520    /// level. Surface it as a parse error rather than coercing to
1521    /// `Unknown { node_type: "" }`.
1522    #[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}