Skip to main content

flake_edit/
lock.rs

1use crate::error::Error;
2use crate::follows::{AttrPath, Segment};
3use serde::{Deserialize, Deserializer};
4use std::collections::HashMap;
5use std::fs::File;
6use std::io::Read;
7use std::path::{Path, PathBuf};
8
9/// Errors that arise from parsing or walking `flake.lock`.
10///
11/// Each variant pinpoints a distinct shape failure: a missing field, a
12/// follows cycle, or a node that the resolver cannot find. The variants
13/// carry the smallest contextual data (segment path, node key) that lets
14/// the caller reconstruct the failure without reparsing the message.
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum LockError {
18    /// `flake.lock` did not parse as JSON.
19    #[error("failed to parse flake.lock as json")]
20    Parse(#[from] serde_json::Error),
21    /// The lockfile has no `root` node referenced by `nodes`.
22    #[error("flake.lock is missing the root node")]
23    MissingRoot,
24    /// The root node carries no `inputs`, so a path cannot be walked.
25    #[error("flake.lock root has no inputs")]
26    RootHasNoInputs,
27    /// An intermediate input on the path has no sub-inputs.
28    #[error("input '{path}' has no sub-inputs in flake.lock")]
29    InputHasNoSubInputs { path: String },
30    /// A path segment does not resolve to a declared input.
31    #[error("input '{path}' not found in flake.lock")]
32    InputNotFound { path: String },
33    /// A `follows` declaration with an empty target was encountered while
34    /// walking. Surfaced as a hard failure when the path needs a target.
35    #[error("input '{path}' has no follows target")]
36    FollowsTargetMissing { path: String },
37    /// The node referenced by a path segment is absent from the `nodes` map.
38    #[error("could not find lockfile node '{node}' referenced by input '{path}'")]
39    NodeMissingForPath { node: String, path: String },
40    /// The node looked up directly by name is absent.
41    #[error("could not find lockfile node '{node}'")]
42    NodeMissing { node: String },
43    /// Recursion budget exhausted while resolving a follows path.
44    #[error("cycle while resolving follows path")]
45    FollowsCycle,
46    /// A node has no `locked` block (so no `rev` can be retrieved).
47    #[error("lockfile node has no locked information")]
48    NodeNotLocked,
49    /// A locked block has no `rev`.
50    #[error("locked node has no rev")]
51    LockedHasNoRev,
52}
53
54/// A nested input discovered in `flake.lock` with its existing follows
55/// target, if any.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct NestedInput {
58    /// Dotted path to the nested input (e.g. `crane.nixpkgs`).
59    pub path: AttrPath,
60    /// Existing follows target, if the input is redirected.
61    pub follows: Option<AttrPath>,
62    /// Original flake URL for `Direct` references (e.g.
63    /// `github:nixos/nixpkgs/nixos-unstable`). `None` for follows-only
64    /// references.
65    pub url: Option<String>,
66}
67
68impl NestedInput {
69    /// Render as `path\tfollows_target` (or just `path` when the input has no
70    /// follows target). The tab separator lets the UI style each side
71    /// independently.
72    pub fn to_display_string(&self) -> String {
73        match &self.follows {
74            Some(target) => format!("{}\t{}", self.path, target),
75            None => self.path.to_string(),
76        }
77    }
78}
79
80/// Parsed `flake.lock`. Loaded with [`Self::from_default_path`],
81/// [`Self::from_file`], or [`Self::read_from_str`].
82#[derive(Debug, Deserialize)]
83pub struct FlakeLock {
84    nodes: HashMap<String, Node>,
85    root: String,
86}
87
88/// A single entry in the lockfile's `nodes` map.
89#[derive(Debug, Deserialize)]
90pub(crate) struct Node {
91    inputs: Option<HashMap<String, Input>>,
92    locked: Option<Locked>,
93    original: Option<Original>,
94}
95
96impl Node {
97    fn rev(&self) -> Result<String, LockError> {
98        self.locked.as_ref().ok_or(LockError::NodeNotLocked)?.rev()
99    }
100}
101
102/// Reference from a node's `inputs` map.
103///
104/// Lockfile shape:
105/// - `"name"` is a direct reference to another node, parsed as
106///   [`Self::Direct`].
107/// - `["a", "b", ...]` is a follows path with a target, parsed as
108///   `Indirect(Some(path))`.
109/// - `[]` is a follows declaration with no target (Nix emits this when an
110///   upstream chain has overridden the input to nothing, e.g. a lockfile
111///   whose source `flake.nix` carries
112///   `nix.inputs.flake-compat.follows = "";`), parsed as `Indirect(None)`.
113#[derive(Debug, Clone)]
114pub enum Input {
115    Direct(String),
116    Indirect(Option<AttrPath>),
117}
118
119impl<'de> Deserialize<'de> for Input {
120    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121    where
122        D: Deserializer<'de>,
123    {
124        use serde::de::{Error, SeqAccess, Visitor};
125        use std::fmt;
126
127        struct InputVisitor;
128
129        impl<'de> Visitor<'de> for InputVisitor {
130            type Value = Input;
131
132            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
133                f.write_str(
134                    "a node name string, an empty array, or an array of \
135                     non-empty segment names",
136                )
137            }
138
139            fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
140                Ok(Input::Direct(v.to_string()))
141            }
142
143            fn visit_string<E: Error>(self, v: String) -> Result<Self::Value, E> {
144                Ok(Input::Direct(v))
145            }
146
147            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
148            where
149                A: SeqAccess<'de>,
150            {
151                // Empty `[]` is a real lockfile shape: `inputs.X = []`
152                // marks an input whose follows chain has been overridden
153                // away. Surface it as `Indirect(None)` instead of an
154                // error.
155                let Some(first) = seq.next_element::<String>()? else {
156                    return Ok(Input::Indirect(None));
157                };
158                let first_seg = Segment::from_unquoted(first).map_err(A::Error::custom)?;
159                let mut path = AttrPath::new(first_seg);
160                while let Some(raw) = seq.next_element::<String>()? {
161                    let seg = Segment::from_unquoted(raw).map_err(A::Error::custom)?;
162                    path.push(seg);
163                }
164                Ok(Input::Indirect(Some(path)))
165            }
166        }
167
168        deserializer.deserialize_any(InputVisitor)
169    }
170}
171
172/// Locked metadata for a node. Only [`Self::rev`] is consumed by the
173/// crate; the other JSON coordinates (`owner`, `repo`, `type`, `narHash`,
174/// ...) are ignored on parse.
175#[derive(Debug, Deserialize, Clone)]
176pub(crate) struct Locked {
177    rev: Option<String>,
178}
179
180impl Locked {
181    fn rev(&self) -> Result<String, LockError> {
182        self.rev.clone().ok_or(LockError::LockedHasNoRev)
183    }
184}
185
186/// Original (pre-lock) reference for a node, as written in the source
187/// flake. One variant per flakeref shape Nix produces in `flake.lock`.
188#[derive(Debug)]
189pub(crate) enum Original {
190    Github {
191        owner: String,
192        repo: String,
193        ref_field: Option<String>,
194    },
195    Gitlab {
196        owner: String,
197        repo: String,
198        ref_field: Option<String>,
199    },
200    Sourcehut {
201        owner: String,
202        repo: String,
203        ref_field: Option<String>,
204    },
205    Git {
206        url: String,
207        ref_field: Option<String>,
208    },
209    Hg {
210        url: String,
211        ref_field: Option<String>,
212    },
213    Tarball {
214        url: String,
215    },
216    File {
217        url: String,
218    },
219    Path {
220        path: String,
221    },
222    Indirect {
223        id: String,
224        ref_field: Option<String>,
225    },
226    /// Flakeref `type` not recognized by this crate. Tolerated because
227    /// Nix adds new schemes occasionally; lockfiles that mention one
228    /// should still parse so the rest of the inputs stay usable.
229    /// Malformed payloads of recognized types surface as `D::Error`,
230    /// not here.
231    Unknown {
232        node_type: String,
233    },
234}
235
236impl<'de> Deserialize<'de> for Original {
237    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
238    where
239        D: Deserializer<'de>,
240    {
241        use serde::de::Error;
242
243        #[derive(Deserialize)]
244        struct ForgePayload {
245            owner: String,
246            repo: String,
247            #[serde(rename = "ref")]
248            ref_field: Option<String>,
249        }
250        #[derive(Deserialize)]
251        struct VcsPayload {
252            url: String,
253            #[serde(rename = "ref")]
254            ref_field: Option<String>,
255        }
256        #[derive(Deserialize)]
257        struct UrlPayload {
258            url: String,
259        }
260        #[derive(Deserialize)]
261        struct PathPayload {
262            path: String,
263        }
264        #[derive(Deserialize)]
265        struct IndirectPayload {
266            id: String,
267            #[serde(rename = "ref")]
268            ref_field: Option<String>,
269        }
270
271        let value = serde_json::Value::deserialize(deserializer)?;
272        let node_type = value
273            .get("type")
274            .and_then(serde_json::Value::as_str)
275            .ok_or_else(|| D::Error::missing_field("type"))?
276            .to_string();
277
278        fn payload<T: for<'a> Deserialize<'a>, E: Error>(value: serde_json::Value) -> Result<T, E> {
279            serde_json::from_value(value).map_err(E::custom)
280        }
281
282        Ok(match node_type.as_str() {
283            "github" => {
284                let ForgePayload {
285                    owner,
286                    repo,
287                    ref_field,
288                } = payload(value)?;
289                Original::Github {
290                    owner,
291                    repo,
292                    ref_field,
293                }
294            }
295            "gitlab" => {
296                let ForgePayload {
297                    owner,
298                    repo,
299                    ref_field,
300                } = payload(value)?;
301                Original::Gitlab {
302                    owner,
303                    repo,
304                    ref_field,
305                }
306            }
307            "sourcehut" => {
308                let ForgePayload {
309                    owner,
310                    repo,
311                    ref_field,
312                } = payload(value)?;
313                Original::Sourcehut {
314                    owner,
315                    repo,
316                    ref_field,
317                }
318            }
319            "git" => {
320                let VcsPayload { url, ref_field } = payload(value)?;
321                Original::Git { url, ref_field }
322            }
323            "hg" => {
324                let VcsPayload { url, ref_field } = payload(value)?;
325                Original::Hg { url, ref_field }
326            }
327            "tarball" => {
328                let UrlPayload { url } = payload(value)?;
329                Original::Tarball { url }
330            }
331            "file" => {
332                let UrlPayload { url } = payload(value)?;
333                Original::File { url }
334            }
335            "path" => {
336                let PathPayload { path } = payload(value)?;
337                Original::Path { path }
338            }
339            "indirect" => {
340                let IndirectPayload { id, ref_field } = payload(value)?;
341                Original::Indirect { id, ref_field }
342            }
343            _ => Original::Unknown { node_type },
344        })
345    }
346}
347
348impl Original {
349    /// Reconstruct a flake URL from the original reference. Returns
350    /// `None` for [`Original::Unknown`], which also logs a
351    /// `tracing::warn!` naming the unrecognized type.
352    fn to_flake_url(&self) -> Option<String> {
353        match self {
354            Original::Github {
355                owner,
356                repo,
357                ref_field,
358            } => Some(forge_flake_url("github", owner, repo, ref_field.as_deref())),
359            Original::Gitlab {
360                owner,
361                repo,
362                ref_field,
363            } => Some(forge_flake_url("gitlab", owner, repo, ref_field.as_deref())),
364            Original::Sourcehut {
365                owner,
366                repo,
367                ref_field,
368            } => Some(forge_flake_url(
369                "sourcehut",
370                owner,
371                repo,
372                ref_field.as_deref(),
373            )),
374            Original::Git { url, ref_field } => {
375                Some(prefixed_vcs_url("git+", url, ref_field.as_deref()))
376            }
377            Original::Hg { url, ref_field } => {
378                Some(prefixed_vcs_url("hg+", url, ref_field.as_deref()))
379            }
380            Original::Tarball { url } | Original::File { url } => Some(url.clone()),
381            Original::Path { path } => Some(format!("path:{path}")),
382            Original::Indirect { id, ref_field } => {
383                Some(indirect_flake_url(id, ref_field.as_deref()))
384            }
385            Original::Unknown { node_type } => {
386                tracing::warn!(
387                    "Unknown flake.lock node type '{node_type}'; cannot reconstruct flake URL"
388                );
389                None
390            }
391        }
392    }
393}
394
395/// Build a `<scheme>:<owner>/<repo>[/<ref>]` URL, the shape shared by
396/// `github`, `gitlab`, and `sourcehut` flakerefs.
397fn forge_flake_url(scheme: &str, owner: &str, repo: &str, ref_field: Option<&str>) -> String {
398    let mut url = format!("{scheme}:{owner}/{repo}");
399    if let Some(r) = ref_field {
400        url.push('/');
401        url.push_str(r);
402    }
403    url
404}
405
406/// Prepend `git+` / `hg+` to a VCS transport URL and append `?ref=<r>`.
407///
408/// Nix keeps unrecognized query parameters (e.g. `?dir=subdir`) inside
409/// the lockfile's `url` field, so the input may already carry a `?`.
410/// Append with `&` in that case to avoid producing a flakeref with two
411/// `?`, which Nix's URL parser rejects.
412fn prefixed_vcs_url(scheme_prefix: &str, url: &str, ref_field: Option<&str>) -> String {
413    let Some(r) = ref_field else {
414        return format!("{scheme_prefix}{url}");
415    };
416    let separator = if url.contains('?') { '&' } else { '?' };
417    format!("{scheme_prefix}{url}{separator}ref={r}")
418}
419
420/// Indirect refs encode `ref` as a path component (`flake:<id>/<ref>`),
421/// not a query parameter. Other VCS schemes use `?ref=`; the indirect
422/// scheme is the odd one out and easy to get wrong.
423fn indirect_flake_url(id: &str, ref_field: Option<&str>) -> String {
424    match ref_field {
425        Some(r) => format!("flake:{id}/{r}"),
426        None => format!("flake:{id}"),
427    }
428}
429
430// Per-thread `nested_inputs` call counter. A `follow` invocation must walk
431// the immutable lockfile once and thread the result through every consumer;
432// tests reset this and assert the walk count. Thread-local so parallel test
433// threads do not contend.
434#[cfg(test)]
435thread_local! {
436    pub(crate) static NESTED_INPUTS_CALLS: std::cell::Cell<usize> =
437        const { std::cell::Cell::new(0) };
438}
439
440impl FlakeLock {
441    const LOCK: &'static str = "flake.lock";
442
443    /// Load `flake.lock` from the current directory.
444    pub fn from_default_path() -> Result<Self, Error> {
445        let path = PathBuf::from(Self::LOCK);
446        Self::from_file(path)
447    }
448
449    /// Load and parse a lockfile from `path`.
450    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
451        let path = path.as_ref();
452        let mut file = File::open(path).map_err(|source| Error::Read {
453            path: path.to_path_buf(),
454            source,
455        })?;
456        let mut contents = String::new();
457        file.read_to_string(&mut contents)
458            .map_err(|source| Error::Read {
459                path: path.to_path_buf(),
460                source,
461            })?;
462        Self::read_from_str(&contents)
463    }
464
465    /// Parse lockfile JSON from `str`.
466    pub fn read_from_str(str: &str) -> Result<Self, Error> {
467        serde_json::from_str(str).map_err(|e| Error::Lock(LockError::Parse(e)))
468    }
469
470    /// Name of the root node.
471    pub fn root(&self) -> &str {
472        &self.root
473    }
474
475    /// Resolve an [`AttrPath`] to the node key it ultimately points at,
476    /// walking the lock tree from `root`.
477    ///
478    /// [`Input::Indirect`] entries are `follows` paths of input *names*
479    /// rooted at the lock root, not node keys, so encountering one restarts
480    /// the walk from root with that path plus any remaining segments.
481    fn resolve_input_path(&self, path: &AttrPath) -> Result<String, LockError> {
482        // Bound recursion: a valid lock file has no follows cycles, but we
483        // still guard against malformed input.
484        const MAX_HOPS: usize = 64;
485        self.resolve_input_path_inner(path.segments(), MAX_HOPS)
486    }
487
488    /// `segments` is non-empty by construction: callers either forward an
489    /// [`AttrPath`]'s segments or prepend an [`Input::Indirect`] follows
490    /// path, both of which are non-empty.
491    fn resolve_input_path_inner(
492        &self,
493        segments: &[Segment],
494        budget: usize,
495    ) -> Result<String, LockError> {
496        if budget == 0 {
497            return Err(LockError::FollowsCycle);
498        }
499
500        let mut current_key = self.root.clone();
501        let mut current_node = self.nodes.get(self.root()).ok_or(LockError::MissingRoot)?;
502
503        for (i, segment) in segments.iter().enumerate() {
504            let inputs = current_node.inputs.as_ref().ok_or_else(|| {
505                if i == 0 {
506                    LockError::RootHasNoInputs
507                } else {
508                    let prefix: Vec<_> = segments[..i].iter().map(|s| s.as_str()).collect();
509                    LockError::InputHasNoSubInputs {
510                        path: prefix.join("."),
511                    }
512                }
513            })?;
514
515            let resolved = inputs.get(segment.as_str()).ok_or_else(|| {
516                let prefix: Vec<_> = segments[..=i].iter().map(|s| s.as_str()).collect();
517                LockError::InputNotFound {
518                    path: prefix.join("."),
519                }
520            })?;
521
522            match resolved {
523                Input::Direct(node_key) => {
524                    current_key = node_key.clone();
525                }
526                Input::Indirect(Some(follows_path)) => {
527                    let mut new_path: Vec<Segment> = follows_path.segments().to_vec();
528                    new_path.extend(segments[i + 1..].iter().cloned());
529                    return self.resolve_input_path_inner(&new_path, budget - 1);
530                }
531                Input::Indirect(None) => {
532                    let prefix: Vec<_> = segments[..=i].iter().map(|s| s.as_str()).collect();
533                    return Err(LockError::FollowsTargetMissing {
534                        path: prefix.join("."),
535                    });
536                }
537            }
538
539            if i + 1 < segments.len() {
540                current_node = self.nodes.get(&current_key).ok_or_else(|| {
541                    let prefix: Vec<_> = segments[..=i].iter().map(|s| s.as_str()).collect();
542                    LockError::NodeMissingForPath {
543                        node: current_key.clone(),
544                        path: prefix.join("."),
545                    }
546                })?;
547            }
548        }
549
550        Ok(current_key)
551    }
552
553    /// Resolve `path` to its locked revision.
554    ///
555    /// # Errors
556    ///
557    /// Returns a [`Error::Lock`] wrapping the underlying
558    /// [`LockError`] if any segment is missing in the lock graph, the
559    /// resolved node is not present, or it carries no `rev`.
560    pub fn rev_for(&self, path: &AttrPath) -> Result<String, Error> {
561        let node_name = self.resolve_input_path(path)?;
562        let node = self
563            .nodes
564            .get(&node_name)
565            .ok_or_else(|| LockError::NodeMissing {
566                node: node_name.clone(),
567            })?;
568        Ok(node.rev()?)
569    }
570
571    /// All nested inputs reachable from the root, with their existing
572    /// follows targets.
573    ///
574    /// Walks `flake.lock` recursively from the root, emitting one entry per
575    /// `inputs.X` on any descendant node and building the path
576    /// segment-by-segment. Capped at [`NESTED_INPUTS_MAX_DEPTH`]. Cycles in
577    /// the node graph are broken by a visited set keyed on node name.
578    ///
579    /// Output is sorted by path for stable emission order.
580    pub fn nested_inputs(&self) -> Vec<NestedInput> {
581        #[cfg(test)]
582        NESTED_INPUTS_CALLS.with(|c| c.set(c.get() + 1));
583
584        let mut inputs = Vec::new();
585        let Some(root_node) = self.nodes.get(&self.root) else {
586            return inputs;
587        };
588        let Some(root_inputs) = &root_node.inputs else {
589            return inputs;
590        };
591
592        for (top_level_name, top_level_ref) in root_inputs {
593            let node_name = match top_level_ref {
594                Input::Direct(name) => name.clone(),
595                // Indirect top-level inputs have no sub-inputs to descend
596                // into. Their follows edges still appear via the recursive
597                // walker on the `Direct` siblings that own them.
598                Input::Indirect(_) => continue,
599            };
600            let Ok(parent_seg) = Segment::from_unquoted(top_level_name.clone()) else {
601                continue;
602            };
603            let path = AttrPath::new(parent_seg);
604            let mut visited: HashMap<String, ()> = HashMap::new();
605            visited.insert(node_name.clone(), ());
606            self.collect_nested_inputs_recursive(&node_name, &path, 1, &mut visited, &mut inputs);
607        }
608
609        inputs.sort_by(|a, b| a.path.cmp(&b.path));
610        inputs
611    }
612
613    /// Descend through `node_name`, emitting one [`NestedInput`] per declared
614    /// sub-input up to [`NESTED_INPUTS_MAX_DEPTH`].
615    fn collect_nested_inputs_recursive(
616        &self,
617        node_name: &str,
618        parent_path: &AttrPath,
619        depth: usize,
620        visited: &mut HashMap<String, ()>,
621        out: &mut Vec<NestedInput>,
622    ) {
623        if depth >= NESTED_INPUTS_MAX_DEPTH {
624            return;
625        }
626        let Some(node) = self.nodes.get(node_name) else {
627            return;
628        };
629        let Some(node_inputs) = &node.inputs else {
630            return;
631        };
632
633        // Iterate sub-inputs in lex order so emission is deterministic.
634        let mut keys: Vec<&String> = node_inputs.keys().collect();
635        keys.sort();
636        for nested_name in keys {
637            let nested_ref = node_inputs.get(nested_name).unwrap();
638            let Ok(nested_seg) = Segment::from_unquoted(nested_name.clone()) else {
639                continue;
640            };
641            let mut path = parent_path.clone();
642            path.push(nested_seg);
643
644            let (follows, url, descend_into) = match nested_ref {
645                Input::Indirect(Some(target)) => (Some(target.clone()), None, None),
646                // Empty `[]` declarations have no follows target. Emit
647                // the input with `follows: None` so the path is still
648                // visible to the UI.
649                Input::Indirect(None) => (None, None, None),
650                Input::Direct(child_node_name) => {
651                    let url = self
652                        .nodes
653                        .get(child_node_name.as_str())
654                        .and_then(|n| n.original.as_ref())
655                        .and_then(|o| o.to_flake_url());
656                    (None, url, Some(child_node_name.clone()))
657                }
658            };
659
660            out.push(NestedInput {
661                path: path.clone(),
662                follows,
663                url,
664            });
665
666            if let Some(child) = descend_into {
667                if visited.contains_key(&child) {
668                    continue;
669                }
670                visited.insert(child.clone(), ());
671                self.collect_nested_inputs_recursive(&child, &path, depth + 1, visited, out);
672                visited.remove(&child);
673            }
674        }
675    }
676}
677
678/// Maximum recursion depth for [`FlakeLock::nested_inputs`]. Backstops
679/// pathological cycles in malformed lockfiles.
680pub const NESTED_INPUTS_MAX_DEPTH: usize = 64;
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    fn minimal_lock() -> &'static str {
686        r#"
687    {
688  "nodes": {
689    "nixpkgs": {
690      "locked": {
691        "lastModified": 1718714799,
692        "narHash": "sha256-FUZpz9rg3gL8NVPKbqU8ei1VkPLsTIfAJ2fdAf5qjak=",
693        "owner": "nixos",
694        "repo": "nixpkgs",
695        "rev": "c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
696        "type": "github"
697      },
698      "original": {
699        "owner": "nixos",
700        "ref": "nixos-unstable",
701        "repo": "nixpkgs",
702        "type": "github"
703      }
704    },
705    "root": {
706      "inputs": {
707        "nixpkgs": "nixpkgs"
708      }
709    }
710  },
711  "root": "root",
712  "version": 7
713}
714    "#
715    }
716    fn minimal_independent_lock_no_overrides() -> &'static str {
717        r#"
718    {
719  "nodes": {
720    "nixpkgs": {
721      "locked": {
722        "lastModified": 1721138476,
723        "narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
724        "owner": "nixos",
725        "repo": "nixpkgs",
726        "rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
727        "type": "github"
728      },
729      "original": {
730        "owner": "nixos",
731        "ref": "nixos-unstable",
732        "repo": "nixpkgs",
733        "type": "github"
734      }
735    },
736    "nixpkgs_2": {
737      "locked": {
738        "lastModified": 1719690277,
739        "narHash": "sha256-0xSej1g7eP2kaUF+JQp8jdyNmpmCJKRpO12mKl/36Kc=",
740        "owner": "nixos",
741        "repo": "nixpkgs",
742        "rev": "2741b4b489b55df32afac57bc4bfd220e8bf617e",
743        "type": "github"
744      },
745      "original": {
746        "owner": "nixos",
747        "ref": "nixos-unstable",
748        "repo": "nixpkgs",
749        "type": "github"
750      }
751    },
752    "root": {
753      "inputs": {
754        "nixpkgs": "nixpkgs",
755        "treefmt-nix": "treefmt-nix"
756      }
757    },
758    "treefmt-nix": {
759      "inputs": {
760        "nixpkgs": "nixpkgs_2"
761      },
762      "locked": {
763        "lastModified": 1721382922,
764        "narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
765        "owner": "numtide",
766        "repo": "treefmt-nix",
767        "rev": "50104496fb55c9140501ea80d183f3223d13ff65",
768        "type": "github"
769      },
770      "original": {
771        "owner": "numtide",
772        "repo": "treefmt-nix",
773        "type": "github"
774      }
775    }
776  },
777  "root": "root",
778  "version": 7
779}
780    "#
781    }
782
783    fn minimal_independent_lock_nixpkgs_overridden() -> &'static str {
784        r#"
785    {
786  "nodes": {
787    "nixpkgs": {
788      "locked": {
789        "lastModified": 1721138476,
790        "narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
791        "owner": "nixos",
792        "repo": "nixpkgs",
793        "rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
794        "type": "github"
795      },
796      "original": {
797        "owner": "nixos",
798        "ref": "nixos-unstable",
799        "repo": "nixpkgs",
800        "type": "github"
801      }
802    },
803    "root": {
804      "inputs": {
805        "nixpkgs": "nixpkgs",
806        "treefmt-nix": "treefmt-nix"
807      }
808    },
809    "treefmt-nix": {
810      "inputs": {
811        "nixpkgs": [
812          "nixpkgs"
813        ]
814      },
815      "locked": {
816        "lastModified": 1721382922,
817        "narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
818        "owner": "numtide",
819        "repo": "treefmt-nix",
820        "rev": "50104496fb55c9140501ea80d183f3223d13ff65",
821        "type": "github"
822      },
823      "original": {
824        "owner": "numtide",
825        "repo": "treefmt-nix",
826        "type": "github"
827      }
828    }
829  },
830  "root": "root",
831  "version": 7
832}
833    "#
834    }
835
836    #[test]
837    fn parse_minimal() {
838        let minimal_lock = minimal_lock();
839        FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
840    }
841    /// The lockfile's top-level `"version"` field is not validated. A
842    /// wildly unsupported version (e.g. `99`) must still parse cleanly
843    /// so this crate can read whatever shape Nix produces.
844    #[test]
845    fn parse_ignores_unknown_version() {
846        let lock = r#"{
847  "nodes": { "root": { "inputs": {} } },
848  "root": "root",
849  "version": 99
850}"#;
851        FlakeLock::read_from_str(lock).expect("unknown version must still parse");
852    }
853    #[test]
854    fn parse_minimal_root() {
855        let minimal_lock = minimal_lock();
856        let parsed_lock =
857            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
858        assert_eq!("root", parsed_lock.root);
859    }
860    #[test]
861    fn minimal_ref() {
862        let minimal_lock = minimal_lock();
863        let parsed_lock =
864            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
865        assert_eq!(
866            "c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
867            parsed_lock
868                .rev_for(&"nixpkgs".parse().unwrap())
869                .expect("Id: nixpkgs is in the lockfile.")
870        );
871    }
872    #[test]
873    fn parse_minimal_independent_lock_no_overrides() {
874        let minimal_lock = minimal_independent_lock_no_overrides();
875        FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
876    }
877    #[test]
878    fn minimal_independent_lock_no_overrides_ref() {
879        let minimal_lock = minimal_independent_lock_no_overrides();
880        let parsed_lock =
881            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
882        assert_eq!(
883            "ad0b5eed1b6031efaed382844806550c3dcb4206",
884            parsed_lock
885                .rev_for(&"nixpkgs".parse().unwrap())
886                .expect("Id: nixpkgs is in the lockfile.")
887        );
888    }
889    #[test]
890    fn parse_minimal_independent_lock_nixpkgs_overridden() {
891        let minimal_lock = minimal_independent_lock_nixpkgs_overridden();
892        FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
893    }
894
895    #[test]
896    fn rev_for_sub_input_path_missing_parent_returns_error() {
897        let minimal_lock = minimal_lock();
898        let parsed_lock =
899            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
900        assert!(
901            parsed_lock
902                .rev_for(&"browseros.nixpkgs".parse().unwrap())
903                .is_err()
904        );
905    }
906
907    #[test]
908    fn rev_for_sub_input_path_resolves() {
909        let lock = minimal_independent_lock_no_overrides();
910        let parsed = FlakeLock::read_from_str(lock).expect("Should be parsed correctly.");
911        assert_eq!(
912            "2741b4b489b55df32afac57bc4bfd220e8bf617e",
913            parsed
914                .rev_for(&"treefmt-nix.nixpkgs".parse().unwrap())
915                .expect("Should resolve sub-input path")
916        );
917    }
918
919    #[test]
920    fn rev_for_sub_input_follows_resolves() {
921        let lock = minimal_independent_lock_nixpkgs_overridden();
922        let parsed = FlakeLock::read_from_str(lock).expect("Should be parsed correctly.");
923        assert_eq!(
924            parsed.rev_for(&"nixpkgs".parse().unwrap()).unwrap(),
925            parsed
926                .rev_for(&"treefmt-nix.nixpkgs".parse().unwrap())
927                .expect("Should resolve followed sub-input")
928        );
929    }
930
931    #[test]
932    fn rev_for_quoted_id() {
933        // Quoted attribute names like `"nixpkgs-24.11"` from `list --format simple`
934        // should be stripped before lock lookup.
935        let minimal_lock = minimal_lock();
936        let parsed_lock =
937            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
938        assert_eq!(
939            parsed_lock.rev_for(&"nixpkgs".parse().unwrap()).unwrap(),
940            parsed_lock
941                .rev_for(&"\"nixpkgs\"".parse().unwrap())
942                .unwrap(),
943        );
944    }
945
946    #[test]
947    fn rev_for_node_without_locked_returns_error() {
948        let lock = r#"{
949  "nodes": {
950    "root": {
951      "inputs": { "bare": "bare" }
952    },
953    "bare": {
954      "original": { "owner": "o", "repo": "r", "type": "github" }
955    }
956  },
957  "root": "root",
958  "version": 7
959}"#;
960        let parsed = FlakeLock::read_from_str(lock).unwrap();
961        assert!(parsed.rev_for(&"bare".parse().unwrap()).is_err());
962    }
963
964    #[test]
965    fn rev_for_node_without_rev_returns_error() {
966        // `path` is set only so the `original` block deserializes; this
967        // test is about missing `locked.rev`.
968        let lock = r#"{
969  "nodes": {
970    "root": {
971      "inputs": { "norev": "norev" }
972    },
973    "norev": {
974      "locked": { "lastModified": 1, "narHash": "", "type": "path" },
975      "original": { "type": "path", "path": "/tmp/norev" }
976    }
977  },
978  "root": "root",
979  "version": 7
980}"#;
981        let parsed = FlakeLock::read_from_str(lock).unwrap();
982        assert!(parsed.rev_for(&"norev".parse().unwrap()).is_err());
983    }
984
985    #[test]
986    fn nested_input_path_quotes_dots() {
987        let lock = r#"{
988  "nodes": {
989    "hls-1.10": {
990      "inputs": { "nixpkgs": "nixpkgs_2" },
991      "flake": false,
992      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
993      "original": { "owner": "o", "repo": "r", "type": "github" }
994    },
995    "nixpkgs": {
996      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
997      "original": { "owner": "o", "repo": "r", "type": "github" }
998    },
999    "nixpkgs_2": {
1000      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
1001      "original": { "owner": "o", "repo": "r", "type": "github" }
1002    },
1003    "root": {
1004      "inputs": { "hls-1.10": "hls-1.10", "nixpkgs": "nixpkgs" }
1005    }
1006  },
1007  "root": "root",
1008  "version": 7
1009}"#;
1010        let parsed = FlakeLock::read_from_str(lock).unwrap();
1011        let nested = parsed.nested_inputs();
1012        assert_eq!(nested.len(), 1);
1013        assert_eq!(nested[0].path.to_string(), "\"hls-1.10\".nixpkgs");
1014    }
1015
1016    #[test]
1017    fn nested_inputs_recurses_to_grandchild() {
1018        // The walker descends through `Direct` children, so a depth-2
1019        // nested input (root → neovim → nixvim → flake-parts) must be
1020        // emitted with a 3-segment path. This exercises the recursive
1021        // path-stack code in `collect_nested_inputs_recursive`.
1022        let lock = r#"{
1023  "nodes": {
1024    "flake-parts": {
1025      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "a", "type": "github" },
1026      "original": { "owner": "o", "repo": "r", "type": "github" }
1027    },
1028    "flake-parts_2": {
1029      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "b", "type": "github" },
1030      "original": { "owner": "o", "repo": "r", "type": "github" }
1031    },
1032    "neovim": {
1033      "inputs": { "nixvim": "nixvim" },
1034      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "c", "type": "github" },
1035      "original": { "owner": "o", "repo": "r", "type": "github" }
1036    },
1037    "nixvim": {
1038      "inputs": { "flake-parts": "flake-parts_2" },
1039      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "d", "type": "github" },
1040      "original": { "owner": "o", "repo": "r", "type": "github" }
1041    },
1042    "root": {
1043      "inputs": { "flake-parts": "flake-parts", "neovim": "neovim" }
1044    }
1045  },
1046  "root": "root",
1047  "version": 7
1048}"#;
1049        let parsed = FlakeLock::read_from_str(lock).unwrap();
1050        let nested = parsed.nested_inputs();
1051        let paths: Vec<String> = nested.iter().map(|n| n.path.to_string()).collect();
1052        assert!(
1053            paths.contains(&"neovim.nixvim".to_string()),
1054            "depth-1 path missing, got: {paths:?}"
1055        );
1056        assert!(
1057            paths.contains(&"neovim.nixvim.flake-parts".to_string()),
1058            "depth-2 path missing, got: {paths:?}"
1059        );
1060    }
1061
1062    #[test]
1063    fn nested_inputs_terminates_on_cyclic_lockfile() {
1064        // A pathological lock graph where node A's input recurses back
1065        // into A itself must not loop forever. The visited-set in
1066        // `collect_nested_inputs_recursive` short-circuits the cycle.
1067        let lock = r#"{
1068  "nodes": {
1069    "a": {
1070      "inputs": { "b": "b" },
1071      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "a", "type": "github" },
1072      "original": { "owner": "o", "repo": "r", "type": "github" }
1073    },
1074    "b": {
1075      "inputs": { "a": "a" },
1076      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "b", "type": "github" },
1077      "original": { "owner": "o", "repo": "r", "type": "github" }
1078    },
1079    "root": { "inputs": { "a": "a" } }
1080  },
1081  "root": "root",
1082  "version": 7
1083}"#;
1084        let parsed = FlakeLock::read_from_str(lock).unwrap();
1085        let nested = parsed.nested_inputs();
1086        assert!(!nested.is_empty());
1087        assert!(
1088            nested
1089                .iter()
1090                .all(|n| n.path.len() <= NESTED_INPUTS_MAX_DEPTH)
1091        );
1092    }
1093
1094    #[test]
1095    fn rev_for_quoted_sub_input_path() {
1096        let lock = r#"{
1097  "nodes": {
1098    "hls-1.10": {
1099      "inputs": { "nixpkgs": "nixpkgs_2" },
1100      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
1101      "original": { "owner": "o", "repo": "r", "type": "github" }
1102    },
1103    "nixpkgs": {
1104      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
1105      "original": { "owner": "o", "repo": "r", "type": "github" }
1106    },
1107    "nixpkgs_2": {
1108      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
1109      "original": { "owner": "o", "repo": "r", "type": "github" }
1110    },
1111    "root": {
1112      "inputs": { "hls-1.10": "hls-1.10", "nixpkgs": "nixpkgs" }
1113    }
1114  },
1115  "root": "root",
1116  "version": 7
1117}"#;
1118        let parsed = FlakeLock::read_from_str(lock).unwrap();
1119        assert_eq!(
1120            "def",
1121            parsed
1122                .rev_for(&"\"hls-1.10\".nixpkgs".parse().unwrap())
1123                .expect("Should resolve quoted sub-input path")
1124        );
1125    }
1126
1127    /// An `Indirect` follows path names *inputs* from the lock root, not node
1128    /// keys. Resolution must go through `root.inputs`, which may map e.g.
1129    /// `nixpkgs` to a node keyed `nixpkgs_2`.
1130    #[test]
1131    fn rev_for_indirect_resolves_via_root_inputs() {
1132        let lock = r#"{
1133  "nodes": {
1134    "nixpkgs_2": {
1135      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "type": "github" },
1136      "original": { "owner": "o", "repo": "r", "type": "github" }
1137    },
1138    "treefmt-nix": {
1139      "inputs": { "nixpkgs": ["nixpkgs"] },
1140      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "type": "github" },
1141      "original": { "owner": "o", "repo": "r", "type": "github" }
1142    },
1143    "root": {
1144      "inputs": { "nixpkgs": "nixpkgs_2", "treefmt-nix": "treefmt-nix" }
1145    }
1146  },
1147  "root": "root",
1148  "version": 7
1149}"#;
1150        let parsed = FlakeLock::read_from_str(lock).unwrap();
1151        // treefmt-nix.nixpkgs follows ["nixpkgs"], i.e. root.inputs.nixpkgs,
1152        // which is node "nixpkgs_2". There is no node literally named "nixpkgs".
1153        assert_eq!(
1154            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1155            parsed
1156                .rev_for(&"treefmt-nix.nixpkgs".parse().unwrap())
1157                .expect("indirect follows must resolve through root.inputs, not by node name")
1158        );
1159    }
1160
1161    /// A multi-segment follows path like `["crane", "nixpkgs"]` must be walked
1162    /// segment-by-segment from root, since each hop can map to a renamed node key.
1163    #[test]
1164    fn rev_for_indirect_multi_segment_path() {
1165        let lock = r#"{
1166  "nodes": {
1167    "nixpkgs": {
1168      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "1111111111111111111111111111111111111111", "type": "github" },
1169      "original": { "owner": "o", "repo": "r", "type": "github" }
1170    },
1171    "nixpkgs_2": {
1172      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "2222222222222222222222222222222222222222", "type": "github" },
1173      "original": { "owner": "o", "repo": "r", "type": "github" }
1174    },
1175    "crane": {
1176      "inputs": { "nixpkgs": "nixpkgs_2" },
1177      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "cccccccccccccccccccccccccccccccccccccccc", "type": "github" },
1178      "original": { "owner": "o", "repo": "r", "type": "github" }
1179    },
1180    "devshell": {
1181      "inputs": { "nixpkgs": ["crane", "nixpkgs"] },
1182      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "dddddddddddddddddddddddddddddddddddddddd", "type": "github" },
1183      "original": { "owner": "o", "repo": "r", "type": "github" }
1184    },
1185    "root": {
1186      "inputs": { "nixpkgs": "nixpkgs", "crane": "crane", "devshell": "devshell" }
1187    }
1188  },
1189  "root": "root",
1190  "version": 7
1191}"#;
1192        let parsed = FlakeLock::read_from_str(lock).unwrap();
1193        // devshell.nixpkgs follows ["crane","nixpkgs"] -> node "nixpkgs_2" (rev 222...),
1194        // NOT root's "nixpkgs" node (rev 111...).
1195        assert_eq!(
1196            "2222222222222222222222222222222222222222",
1197            parsed
1198                .rev_for(&"devshell.nixpkgs".parse().unwrap())
1199                .expect("multi-segment indirect follows must be walked from root")
1200        );
1201    }
1202
1203    /// Walk every `inputs` map and collect each `Indirect` follows target as
1204    /// a vector of segment strings. Used by the fixture parse tests below.
1205    fn collect_indirect_targets(lock: &FlakeLock) -> Vec<(String, String, Vec<String>)> {
1206        let mut out: Vec<(String, String, Vec<String>)> = Vec::new();
1207        for (node_name, node) in &lock.nodes {
1208            let Some(inputs) = node.inputs.as_ref() else {
1209                continue;
1210            };
1211            for (input_name, input_ref) in inputs {
1212                if let Input::Indirect(Some(path)) = input_ref {
1213                    let segs: Vec<String> = path
1214                        .segments()
1215                        .iter()
1216                        .map(|s| s.as_str().to_string())
1217                        .collect();
1218                    out.push((node_name.clone(), input_name.clone(), segs));
1219                }
1220            }
1221        }
1222        out.sort();
1223        out
1224    }
1225
1226    /// `depth_upstream_redundant_depth3.flake.lock` has three `Indirect`
1227    /// entries forming the upstream-propagation chain: `omnibus.nixpkgs`
1228    /// follows `["nixpkgs"]`, the depth-2 follows `["omnibus", "nixpkgs"]`,
1229    /// and the depth-3 follows `["omnibus", "flops", "nixpkgs"]`.
1230    #[test]
1231    fn fixture_depth_upstream_redundant_depth3_parses_indirects() {
1232        let lock_text =
1233            std::fs::read_to_string("tests/fixtures/depth_upstream_redundant_depth3.flake.lock")
1234                .expect("fixture present");
1235        let lock = FlakeLock::read_from_str(&lock_text).expect("fixture parses");
1236        let mut segs_only: Vec<Vec<String>> = collect_indirect_targets(&lock)
1237            .into_iter()
1238            .map(|(_, _, segs)| segs)
1239            .collect();
1240        segs_only.sort();
1241        assert_eq!(
1242            segs_only,
1243            vec![
1244                vec!["nixpkgs".to_string()],
1245                vec![
1246                    "omnibus".to_string(),
1247                    "flops".to_string(),
1248                    "nixpkgs".to_string()
1249                ],
1250                vec!["omnibus".to_string(), "nixpkgs".to_string()],
1251            ],
1252            "Indirect entries must be decoded with their full structural depth",
1253        );
1254    }
1255
1256    /// `depth_upstream_partial.flake.lock` covers a deeper mix of Indirect
1257    /// shapes; verify each one is decoded into a non-empty `AttrPath` whose
1258    /// segments are valid Nix names (no embedded quotes / control chars).
1259    #[test]
1260    fn fixture_depth_upstream_partial_parses_indirects() {
1261        let lock_text = std::fs::read_to_string("tests/fixtures/depth_upstream_partial.flake.lock")
1262            .expect("fixture present");
1263        let lock = FlakeLock::read_from_str(&lock_text).expect("fixture parses");
1264        let entries = collect_indirect_targets(&lock);
1265        assert!(
1266            entries.len() >= 3,
1267            "fixture has at least three Indirect arrays, got {}",
1268            entries.len()
1269        );
1270        for (node, input, segs) in &entries {
1271            assert!(
1272                !segs.is_empty(),
1273                "{node}.{input}: Indirect path must be non-empty",
1274            );
1275            for seg in segs {
1276                assert!(
1277                    !seg.is_empty() && !seg.contains('"'),
1278                    "{node}.{input}: segment `{seg}` must be a valid Nix name",
1279                );
1280            }
1281        }
1282    }
1283
1284    /// `dot_ancestor_cycle.flake.lock` exercises the dotted-segment case:
1285    /// the lockfile node `hls-1.10` is reachable through the typed
1286    /// `AttrPath`, even though a literal dot in the segment forces source-
1287    /// form quoting at the `flake.nix` boundary.
1288    #[test]
1289    fn fixture_dot_ancestor_cycle_parses_indirects_with_dotted_node() {
1290        let lock_text = std::fs::read_to_string("tests/fixtures/dot_ancestor_cycle.flake.lock")
1291            .expect("fixture present");
1292        let lock = FlakeLock::read_from_str(&lock_text).expect("fixture parses");
1293        // Direct walk: the dotted node `hls-1.10` exists and its
1294        // `Indirect` `["helper"]` entry is decoded as a one-segment path.
1295        let hls = lock.nodes.get("hls-1.10").expect("hls-1.10 node");
1296        let inputs = hls.inputs.as_ref().expect("hls-1.10 has inputs");
1297        match inputs.get("helper").expect("helper input present") {
1298            Input::Indirect(Some(path)) => {
1299                let segs: Vec<&str> = path.segments().iter().map(|s| s.as_str()).collect();
1300                assert_eq!(segs, vec!["helper"]);
1301            }
1302            Input::Indirect(None) => panic!("expected Indirect(Some), got Indirect(None)"),
1303            Input::Direct(name) => panic!("expected Indirect, got Direct({name})"),
1304        }
1305    }
1306
1307    /// Empty `[]` follows arrays mark an input whose follows chain has
1308    /// been overridden away (e.g. a lockfile entry `inputs.flake-compat
1309    /// = []`). The deserializer must accept them and store them as
1310    /// `Indirect(None)`.
1311    #[test]
1312    fn indirect_empty_array_is_accepted_as_none() {
1313        let lock = r#"{
1314  "nodes": {
1315    "child": {
1316      "inputs": { "disabled": [] },
1317      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "x", "type": "github" },
1318      "original": { "owner": "o", "repo": "r", "type": "github" }
1319    },
1320    "root": { "inputs": { "child": "child" } }
1321  },
1322  "root": "root",
1323  "version": 7
1324}"#;
1325        let parsed = FlakeLock::read_from_str(lock).expect("empty Indirect must parse");
1326        let child = parsed.nodes.get("child").expect("child node");
1327        let inputs = child.inputs.as_ref().expect("child has inputs");
1328        match inputs.get("disabled").expect("disabled input present") {
1329            Input::Indirect(None) => {}
1330            other => panic!("expected Indirect(None), got {other:?}"),
1331        }
1332    }
1333
1334    /// A top-level input (`nix`) whose nested inputs map mixes
1335    /// [`Input::Direct`] references, non-empty
1336    /// [`Input::Indirect`] arrays (`["nixpkgs"]`), and empty `[]`
1337    /// declarations must parse cleanly. [`FlakeLock::nested_inputs`]
1338    /// emits one entry per declaration; entries built from `[]` carry
1339    /// `follows: None`.
1340    #[test]
1341    fn nested_inputs_handles_mixed_direct_indirect_and_empty() {
1342        let lock = r#"{
1343  "nodes": {
1344    "flake-parts": {
1345      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "fp", "type": "github" },
1346      "original": { "owner": "o", "repo": "r", "type": "github" }
1347    },
1348    "nix": {
1349      "inputs": {
1350        "flake-compat": [],
1351        "flake-parts": "flake-parts",
1352        "nixpkgs": ["nixpkgs"]
1353      },
1354      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "n", "type": "github" },
1355      "original": { "owner": "o", "repo": "r", "type": "github" }
1356    },
1357    "nixpkgs": {
1358      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "np", "type": "github" },
1359      "original": { "owner": "o", "repo": "r", "type": "github" }
1360    },
1361    "root": { "inputs": { "nix": "nix", "nixpkgs": "nixpkgs" } }
1362  },
1363  "root": "root",
1364  "version": 7
1365}"#;
1366        let parsed = FlakeLock::read_from_str(lock).expect("mixed-shape lock parses");
1367        let nested = parsed.nested_inputs();
1368        let by_path: std::collections::HashMap<String, &NestedInput> =
1369            nested.iter().map(|n| (n.path.to_string(), n)).collect();
1370
1371        let disabled = by_path
1372            .get("nix.flake-compat")
1373            .expect("empty Indirect emitted as nested input");
1374        assert!(
1375            disabled.follows.is_none(),
1376            "empty `[]` must surface as follows: None, got {:?}",
1377            disabled.follows
1378        );
1379
1380        let resolved = by_path
1381            .get("nix.nixpkgs")
1382            .expect("non-empty Indirect emitted as nested input");
1383        assert_eq!(
1384            resolved.follows.as_ref().map(|p| p.to_string()),
1385            Some("nixpkgs".to_string()),
1386            "non-empty Indirect must surface its follows target",
1387        );
1388    }
1389
1390    /// A bare `https://...` parses as a `tarball` flakeref, not a
1391    /// `git` repo, so the missing `git+` prefix is a silent corruption
1392    /// rather than a parse error.
1393    #[test]
1394    fn to_flake_url_git_type_prepends_scheme() {
1395        let o = Original::Git {
1396            url: "https://git.clan.lol/clan/munix".to_string(),
1397            ref_field: None,
1398        };
1399        assert_eq!(
1400            o.to_flake_url().as_deref(),
1401            Some("git+https://git.clan.lol/clan/munix"),
1402        );
1403    }
1404
1405    /// Loss of `ref` would unpin the reconstructed URL from the branch
1406    /// the lock captured.
1407    #[test]
1408    fn to_flake_url_git_with_ref_appends_query() {
1409        let o = Original::Git {
1410            url: "https://git.example.com/repo".to_string(),
1411            ref_field: Some("main".to_string()),
1412        };
1413        assert_eq!(
1414            o.to_flake_url().as_deref(),
1415            Some("git+https://git.example.com/repo?ref=main"),
1416        );
1417    }
1418
1419    /// The lockfile type is `"hg"` (not `"mercurial"`); the previous
1420    /// spelling silently matched no input and dropped every hg
1421    /// promotion.
1422    #[test]
1423    fn to_flake_url_hg_type_prepends_scheme() {
1424        let o = Original::Hg {
1425            url: "https://hg.example.com/repo".to_string(),
1426            ref_field: None,
1427        };
1428        assert_eq!(
1429            o.to_flake_url().as_deref(),
1430            Some("hg+https://hg.example.com/repo"),
1431        );
1432    }
1433
1434    /// `original.url` may already carry a `?` (Nix only strips
1435    /// recognized params like `ref` / `shallow`; the rest stay
1436    /// in-band). Appending `?ref=` would produce two `?`, which Nix
1437    /// rejects.
1438    #[test]
1439    fn to_flake_url_git_with_existing_query_appends_with_ampersand() {
1440        let o = Original::Git {
1441            url: "https://git.example.com/repo?dir=subdir".to_string(),
1442            ref_field: Some("main".to_string()),
1443        };
1444        assert_eq!(
1445            o.to_flake_url().as_deref(),
1446            Some("git+https://git.example.com/repo?dir=subdir&ref=main"),
1447        );
1448    }
1449
1450    #[test]
1451    fn to_flake_url_tarball_returns_url_unchanged() {
1452        let o = Original::Tarball {
1453            url: "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz".to_string(),
1454        };
1455        assert_eq!(
1456            o.to_flake_url().as_deref(),
1457            Some("https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz"),
1458        );
1459    }
1460
1461    #[test]
1462    fn to_flake_url_path_uses_path_field() {
1463        let o = Original::Path {
1464            path: "/etc/nixos".to_string(),
1465        };
1466        assert_eq!(o.to_flake_url().as_deref(), Some("path:/etc/nixos"));
1467    }
1468
1469    #[test]
1470    fn to_flake_url_indirect_uses_id_field() {
1471        let o = Original::Indirect {
1472            id: "nixpkgs".to_string(),
1473            ref_field: None,
1474        };
1475        assert_eq!(o.to_flake_url().as_deref(), Some("flake:nixpkgs"));
1476    }
1477
1478    /// Indirect refs use a path component (`flake:<id>/<ref>`) rather
1479    /// than the `?ref=` query the VCS schemes use; pin the test on
1480    /// that asymmetry.
1481    #[test]
1482    fn to_flake_url_indirect_with_ref_appends_path_component() {
1483        let o = Original::Indirect {
1484            id: "nixpkgs".to_string(),
1485            ref_field: Some("nixos-25.05".to_string()),
1486        };
1487        assert_eq!(
1488            o.to_flake_url().as_deref(),
1489            Some("flake:nixpkgs/nixos-25.05"),
1490        );
1491    }
1492
1493    /// Unrecognized `type` strings deserialize to [`Original::Unknown`]
1494    /// and [`Original::to_flake_url`] returns `None`.
1495    #[test]
1496    fn to_flake_url_unknown_type_returns_none() {
1497        let o: Original = serde_json::from_str(r#"{"type": "future-type"}"#).unwrap();
1498        assert!(matches!(&o, Original::Unknown { node_type } if node_type == "future-type"));
1499        assert_eq!(o.to_flake_url(), None);
1500    }
1501
1502    /// Malformed payload of a recognized type must be a parse error, not
1503    /// a silent fall-through to [`Original::Unknown`]. The error message
1504    /// must name the missing field so the failure is actionable.
1505    #[test]
1506    fn malformed_known_type_is_a_parse_error() {
1507        let err = serde_json::from_str::<Original>(r#"{"type": "github"}"#).unwrap_err();
1508        assert!(
1509            err.to_string().contains("owner"),
1510            "error must name the missing field, got: {err}",
1511        );
1512    }
1513
1514    /// A node with `original.type` absent is malformed at the protocol
1515    /// level. Surface it as a parse error rather than coercing to
1516    /// `Unknown { node_type: "" }`.
1517    #[test]
1518    fn missing_type_is_a_parse_error() {
1519        let err = serde_json::from_str::<Original>(r#"{}"#).unwrap_err();
1520        assert!(
1521            err.to_string().contains("type"),
1522            "error must name the missing field, got: {err}",
1523        );
1524    }
1525}