Skip to main content

sui_compat/
flake.rs

1//! Nix flake.lock (v7) parser and input-graph resolver.
2//!
3//! Parses the JSON lock file that Nix writes, builds an adjacency map of the
4//! input graph, resolves `follows` references, and exposes a typed
5//! `resolve_input` walk.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11// ── Error type ──────────────────────────────────────────────
12
13/// Errors that can occur while parsing or resolving a flake lock file.
14#[derive(Debug, thiserror::Error)]
15pub enum FlakeLockError {
16    #[error("JSON parse error: {0}")]
17    Json(#[from] serde_json::Error),
18    #[error("unsupported lock version {found} (expected {expected})")]
19    UnsupportedVersion { expected: u32, found: u32 },
20    #[error("missing root node `{0}`")]
21    MissingRoot(String),
22    #[error("node not found: {0}")]
23    NodeNotFound(String),
24    #[error("follows resolution failed for path {path:?} starting from `{from}`")]
25    FollowsFailed { from: String, path: Vec<String> },
26}
27
28// ── Core types ──────────────────────────────────────────────
29
30/// A parsed and validated flake.lock file.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct FlakeLock {
33    /// All nodes keyed by their name.
34    pub nodes: BTreeMap<String, FlakeNode>,
35    /// Name of the root node (usually `"root"`).
36    pub root: String,
37    /// Lock file schema version (must be 7).
38    pub version: u32,
39}
40
41/// A single node in the input graph.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct FlakeNode {
44    /// Inputs — maps input name to either a direct node reference or a follows
45    /// path.
46    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
47    pub inputs: BTreeMap<String, InputRef>,
48    /// Pinned revision information (absent on the root node).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub locked: Option<LockedInput>,
51    /// Original (un-resolved) input reference (absent on the root node).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub original: Option<OriginalInput>,
54    /// Whether this node is a flake (defaults to `true` when absent).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub flake: Option<bool>,
57    /// Unknown fields (e.g. `parent` for path-typed flakes) — captured
58    /// via `serde(flatten)` so they round-trip even when sui-compat
59    /// doesn't know about them yet.
60    #[serde(flatten)]
61    pub extra: BTreeMap<String, serde_json::Value>,
62}
63
64/// A reference to another node in the input graph.
65///
66/// In the JSON encoding:
67/// - A plain string (`"nixpkgs"`) means *direct* node reference.
68/// - An array of strings (`["nixpkgs"]`) means *follows* — walk the path
69///   starting from the **parent of the current node** (resolved later).
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(untagged)]
72pub enum InputRef {
73    /// Direct reference to a named node.
74    Direct(String),
75    /// Follows path — resolve through the parent's input chain.
76    Follows(Vec<String>),
77}
78
79/// Locked (pinned) revision of a flake input.
80// `Default` so a caller can construct the two or three fields a given source
81// type actually uses without hand-writing `None` for the other nine. Every
82// field is already `Option` or defaulted by serde, so the derive adds no new
83// representable state — an all-`None` LockedInput was constructible from JSON
84// before this.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
86pub struct LockedInput {
87    #[serde(rename = "type")]
88    pub source_type: String,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub owner: Option<String>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub repo: Option<String>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub rev: Option<String>,
95    #[serde(default, rename = "narHash", skip_serializing_if = "Option::is_none")]
96    pub nar_hash: Option<String>,
97    #[serde(
98        default,
99        rename = "lastModified",
100        skip_serializing_if = "Option::is_none"
101    )]
102    pub last_modified: Option<u64>,
103    /// For `type = "path"` inputs.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub path: Option<String>,
106    /// For `type = "tarball"` or `type = "file"` inputs.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub url: Option<String>,
109    /// For specific git refs (e.g. `"refs/heads/main"`).
110    #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
111    pub git_ref: Option<String>,
112    /// Git directory (subdir within repo).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub dir: Option<String>,
115    /// Custom host for `github` / `gitlab` / `sourcehut` inputs
116    /// (e.g. `gitlab.gnome.org`, `git.example.com`).  When absent,
117    /// the platform default is used (gitlab.com etc).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub host: Option<String>,
120    /// Any other fields nix decides to add in the future (e.g.
121    /// `revCount`, `submodules`, `shallow`). Flattened so they
122    /// round-trip without losing data.
123    #[serde(flatten)]
124    pub extra: BTreeMap<String, serde_json::Value>,
125}
126
127/// Original (un-locked) input specification.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct OriginalInput {
130    #[serde(rename = "type")]
131    pub source_type: String,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub owner: Option<String>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub repo: Option<String>,
136    /// Branch/tag reference (e.g. `"nixos-unstable"`).
137    #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
138    pub git_ref: Option<String>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub url: Option<String>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub dir: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub id: Option<String>,
145    /// Unknown fields (for forward compatibility).
146    #[serde(flatten)]
147    pub extra: BTreeMap<String, serde_json::Value>,
148}
149
150// ── Parsing ─────────────────────────────────────────────────
151
152const SUPPORTED_VERSION: u32 = 7;
153
154impl FlakeLock {
155    /// Parse a `flake.lock` from its JSON text.
156    pub fn parse(json: &str) -> Result<Self, FlakeLockError> {
157        let lock: FlakeLock = serde_json::from_str(json)?;
158        if lock.version != SUPPORTED_VERSION {
159            return Err(FlakeLockError::UnsupportedVersion {
160                expected: SUPPORTED_VERSION,
161                found: lock.version,
162            });
163        }
164        if !lock.nodes.contains_key(&lock.root) {
165            return Err(FlakeLockError::MissingRoot(lock.root.clone()));
166        }
167        Ok(lock)
168    }
169
170    /// Serialize the lock back to pretty-printed JSON.
171    pub fn to_json(&self) -> Result<String, FlakeLockError> {
172        Ok(serde_json::to_string_pretty(self)?)
173    }
174
175    /// Get the root node.
176    pub fn root_node(&self) -> Result<&FlakeNode, FlakeLockError> {
177        self.nodes
178            .get(&self.root)
179            .ok_or_else(|| FlakeLockError::MissingRoot(self.root.clone()))
180    }
181
182    /// Get a node by name.
183    pub fn get_node(&self, name: &str) -> Result<&FlakeNode, FlakeLockError> {
184        self.nodes
185            .get(name)
186            .ok_or_else(|| FlakeLockError::NodeNotFound(name.to_string()))
187    }
188
189    /// Return the direct inputs of the root node as `(input_name, node_name)` pairs,
190    /// resolving follows along the way.
191    pub fn root_inputs(&self) -> Result<Vec<(String, String)>, FlakeLockError> {
192        let root = self.root_node()?;
193        let mut out = Vec::new();
194        for (input_name, input_ref) in &root.inputs {
195            let resolved = self.resolve_ref(&self.root, input_ref)?;
196            out.push((input_name.clone(), resolved));
197        }
198        Ok(out)
199    }
200
201    /// Resolve an `InputRef` to a concrete node name.
202    ///
203    /// - `Direct(name)` simply returns `name`.
204    /// - `Follows(path)` walks the path from the **root** node (Nix semantics:
205    ///   `["nixpkgs"]` means "follow root's nixpkgs input"; `["utils", "nixpkgs"]`
206    ///   means "follow root -> utils -> nixpkgs").
207    pub fn resolve_ref(
208        &self,
209        _parent: &str,
210        input_ref: &InputRef,
211    ) -> Result<String, FlakeLockError> {
212        match input_ref {
213            InputRef::Direct(name) => {
214                if self.nodes.contains_key(name) {
215                    Ok(name.clone())
216                } else {
217                    Err(FlakeLockError::NodeNotFound(name.clone()))
218                }
219            }
220            InputRef::Follows(path) => self.resolve_follows_path(path),
221        }
222    }
223
224    /// Walk a follows path starting from the root node.
225    ///
226    /// A path like `["nixpkgs"]` means: look up `root.inputs["nixpkgs"]` and
227    /// resolve it. A path like `["utils", "systems"]` means: look up
228    /// `root.inputs["utils"]`, find that node, then look up its `inputs["systems"]`.
229    fn resolve_follows_path(&self, path: &[String]) -> Result<String, FlakeLockError> {
230        if path.is_empty() {
231            return Err(FlakeLockError::FollowsFailed {
232                from: self.root.clone(),
233                path: vec![],
234            });
235        }
236
237        let mut current_node_name = self.root.clone();
238
239        for segment in path {
240            let node = self.nodes.get(&current_node_name).ok_or_else(|| {
241                FlakeLockError::FollowsFailed {
242                    from: current_node_name.clone(),
243                    path: path.to_vec(),
244                }
245            })?;
246
247            let input_ref =
248                node.inputs.get(segment).ok_or_else(|| FlakeLockError::FollowsFailed {
249                    from: current_node_name.clone(),
250                    path: path.to_vec(),
251                })?;
252
253            // Recurse — the input itself could be another follows or a direct ref.
254            current_node_name = match input_ref {
255                InputRef::Direct(name) => name.clone(),
256                InputRef::Follows(inner_path) => self.resolve_follows_path(inner_path)?,
257            };
258        }
259
260        Ok(current_node_name)
261    }
262
263    /// Walk the input graph from the root following a dotted-style path.
264    ///
265    /// `resolve_input(&["utils", "nixpkgs"])` starts at root, enters the
266    /// `utils` input, then enters that node's `nixpkgs` input, resolving any
267    /// follows along the way.
268    pub fn resolve_input(&self, path: &[&str]) -> Result<&FlakeNode, FlakeLockError> {
269        let mut current_name = self.root.clone();
270
271        for &segment in path {
272            let node = self.nodes.get(&current_name).ok_or_else(|| {
273                FlakeLockError::NodeNotFound(current_name.clone())
274            })?;
275
276            let input_ref = node.inputs.get(segment).ok_or_else(|| {
277                FlakeLockError::NodeNotFound(format!("{current_name}.inputs.{segment}"))
278            })?;
279
280            current_name = self.resolve_ref(&current_name, input_ref)?;
281        }
282
283        self.nodes
284            .get(&current_name)
285            .ok_or(FlakeLockError::NodeNotFound(current_name))
286    }
287
288    /// Resolve one edge of the input graph: given the *node name* of a flake
289    /// in the lock and the *input name* it declares, return the concrete node
290    /// name that input resolves to — walking any `follows` redirection.
291    ///
292    /// This is the load-bearing primitive for transitive-input resolution.
293    /// CppNix pins a flake's *entire* input closure in the ROOT lock's node
294    /// graph: a sub-flake's `inputs.substrate` edge is stored on `nodes[node]`
295    /// (as a direct node ref or a `follows` path rooted at the lock's root),
296    /// so the sub-flake's OWN `flake.lock` is irrelevant once the root lock
297    /// exists.  A consumer that recurses into the sub-flake and re-reads its
298    /// own lock resolves a *different* revision than nix (the sub-flake's
299    /// independent pin instead of the root's `follows` target).  Use this to
300    /// resolve every transitive input against the one authoritative graph.
301    pub fn resolve_node_input(
302        &self,
303        node_name: &str,
304        input_name: &str,
305    ) -> Result<String, FlakeLockError> {
306        let node = self
307            .nodes
308            .get(node_name)
309            .ok_or_else(|| FlakeLockError::NodeNotFound(node_name.to_string()))?;
310        let input_ref = node.inputs.get(input_name).ok_or_else(|| {
311            FlakeLockError::NodeNotFound(format!("{node_name}.inputs.{input_name}"))
312        })?;
313        self.resolve_ref(node_name, input_ref)
314    }
315
316    /// Return the resolved `(input_name, target_node_name)` pairs for a node,
317    /// in the lock's declaration order (BTreeMap ⇒ deterministic).
318    ///
319    /// Unresolvable edges (a `follows` into a missing sibling) are skipped —
320    /// the caller falls back to a stub input, matching the pre-existing
321    /// resolve-what-you-can behavior of `evaluate_flake`.
322    pub fn node_input_edges(&self, node_name: &str) -> Vec<(String, String)> {
323        let Some(node) = self.nodes.get(node_name) else {
324            return Vec::new();
325        };
326        let mut edges = Vec::new();
327        for input_name in node.inputs.keys() {
328            if let Ok(target) = self.resolve_node_input(node_name, input_name) {
329                edges.push((input_name.clone(), target));
330            }
331        }
332        edges
333    }
334
335    /// Build an adjacency list representation of the full input graph.
336    ///
337    /// Returns `node_name -> [(input_name, resolved_target_node)]`.
338    /// Follows are resolved; any unresolvable edges are silently skipped.
339    pub fn adjacency_map(&self) -> BTreeMap<String, Vec<(String, String)>> {
340        let mut map: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
341
342        for (node_name, node) in &self.nodes {
343            let mut edges = Vec::new();
344            for (input_name, input_ref) in &node.inputs {
345                if let Ok(target) = self.resolve_ref(node_name, input_ref) {
346                    edges.push((input_name.clone(), target));
347                }
348            }
349            map.insert(node_name.clone(), edges);
350        }
351
352        map
353    }
354}
355
356// ── Tests ───────────────────────────────────────────────────
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    // ── Fixtures ────────────────────────────────────────
363
364    /// Minimal flake.lock — root with one direct input.
365    fn minimal_lock_json() -> &'static str {
366        r#"{
367  "nodes": {
368    "nixpkgs": {
369      "locked": {
370        "lastModified": 1700000000,
371        "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
372        "owner": "nixos",
373        "repo": "nixpkgs",
374        "rev": "abc123def456abc123def456abc123def456abc1",
375        "type": "github"
376      },
377      "original": {
378        "owner": "nixos",
379        "ref": "nixos-unstable",
380        "repo": "nixpkgs",
381        "type": "github"
382      }
383    },
384    "root": {
385      "inputs": {
386        "nixpkgs": "nixpkgs"
387      }
388    }
389  },
390  "root": "root",
391  "version": 7
392}"#
393    }
394
395    /// Flake.lock with follows: `utils` follows root's `nixpkgs`.
396    fn follows_lock_json() -> &'static str {
397        r#"{
398  "nodes": {
399    "nixpkgs": {
400      "locked": {
401        "lastModified": 1700000000,
402        "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
403        "owner": "nixos",
404        "repo": "nixpkgs",
405        "rev": "abc123def456abc123def456abc123def456abc1",
406        "type": "github"
407      },
408      "original": {
409        "owner": "nixos",
410        "ref": "nixos-unstable",
411        "repo": "nixpkgs",
412        "type": "github"
413      }
414    },
415    "root": {
416      "inputs": {
417        "nixpkgs": "nixpkgs",
418        "utils": "utils"
419      }
420    },
421    "systems": {
422      "locked": {
423        "lastModified": 1699999999,
424        "narHash": "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
425        "owner": "nix-systems",
426        "repo": "default",
427        "rev": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1",
428        "type": "github"
429      },
430      "original": {
431        "owner": "nix-systems",
432        "repo": "default",
433        "type": "github"
434      }
435    },
436    "utils": {
437      "inputs": {
438        "nixpkgs": [
439          "nixpkgs"
440        ],
441        "systems": "systems"
442      },
443      "locked": {
444        "lastModified": 1699999998,
445        "narHash": "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
446        "owner": "numtide",
447        "repo": "flake-utils",
448        "rev": "ccccccccccccccccccccccccccccccccccccccc1",
449        "type": "github"
450      },
451      "original": {
452        "owner": "numtide",
453        "repo": "flake-utils",
454        "type": "github"
455      }
456    }
457  },
458  "root": "root",
459  "version": 7
460}"#
461    }
462
463    /// Multi-level follows: `bar.nixpkgs` follows `["foo", "nixpkgs"]`,
464    /// and `foo.nixpkgs` follows `["nixpkgs"]`.
465    fn deep_follows_json() -> &'static str {
466        r#"{
467  "nodes": {
468    "nixpkgs": {
469      "locked": {
470        "lastModified": 1700000000,
471        "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
472        "owner": "nixos",
473        "repo": "nixpkgs",
474        "rev": "abc123",
475        "type": "github"
476      },
477      "original": {
478        "owner": "nixos",
479        "ref": "nixos-unstable",
480        "repo": "nixpkgs",
481        "type": "github"
482      }
483    },
484    "root": {
485      "inputs": {
486        "bar": "bar",
487        "foo": "foo",
488        "nixpkgs": "nixpkgs"
489      }
490    },
491    "foo": {
492      "inputs": {
493        "nixpkgs": [
494          "nixpkgs"
495        ]
496      },
497      "locked": {
498        "lastModified": 1700000001,
499        "narHash": "sha256-FOO",
500        "owner": "example",
501        "repo": "foo",
502        "rev": "foofoo",
503        "type": "github"
504      },
505      "original": {
506        "owner": "example",
507        "repo": "foo",
508        "type": "github"
509      }
510    },
511    "bar": {
512      "inputs": {
513        "nixpkgs": [
514          "foo",
515          "nixpkgs"
516        ]
517      },
518      "locked": {
519        "lastModified": 1700000002,
520        "narHash": "sha256-BAR",
521        "owner": "example",
522        "repo": "bar",
523        "rev": "barbar",
524        "type": "github"
525      },
526      "original": {
527        "owner": "example",
528        "repo": "bar",
529        "type": "github"
530      }
531    }
532  },
533  "root": "root",
534  "version": 7
535}"#
536    }
537
538    // ── Parse minimal ───────────────────────────────────
539
540    #[test]
541    fn parse_minimal_lock() {
542        let lock = FlakeLock::parse(minimal_lock_json()).expect("parse failed");
543        assert_eq!(lock.version, 7);
544        assert_eq!(lock.root, "root");
545        assert_eq!(lock.nodes.len(), 2);
546        assert!(lock.nodes.contains_key("root"));
547        assert!(lock.nodes.contains_key("nixpkgs"));
548    }
549
550    #[test]
551    fn minimal_root_node_has_no_locked() {
552        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
553        let root = lock.root_node().unwrap();
554        assert!(root.locked.is_none());
555        assert!(root.original.is_none());
556    }
557
558    #[test]
559    fn minimal_nixpkgs_locked_fields() {
560        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
561        let nixpkgs = lock.get_node("nixpkgs").unwrap();
562        let locked = nixpkgs.locked.as_ref().expect("missing locked");
563        assert_eq!(locked.source_type, "github");
564        assert_eq!(locked.owner.as_deref(), Some("nixos"));
565        assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
566        assert_eq!(
567            locked.rev.as_deref(),
568            Some("abc123def456abc123def456abc123def456abc1"),
569        );
570        assert_eq!(locked.last_modified, Some(1_700_000_000));
571        assert!(locked.nar_hash.is_some());
572    }
573
574    #[test]
575    fn minimal_nixpkgs_original_fields() {
576        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
577        let nixpkgs = lock.get_node("nixpkgs").unwrap();
578        let original = nixpkgs.original.as_ref().expect("missing original");
579        assert_eq!(original.source_type, "github");
580        assert_eq!(original.owner.as_deref(), Some("nixos"));
581        assert_eq!(original.repo.as_deref(), Some("nixpkgs"));
582        assert_eq!(original.git_ref.as_deref(), Some("nixos-unstable"));
583    }
584
585    #[test]
586    fn minimal_root_inputs() {
587        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
588        let inputs = lock.root_inputs().unwrap();
589        assert_eq!(inputs.len(), 1);
590        assert_eq!(inputs[0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
591    }
592
593    // ── Parse with follows ──────────────────────────────
594
595    #[test]
596    fn parse_follows_lock() {
597        let lock = FlakeLock::parse(follows_lock_json()).expect("parse failed");
598        assert_eq!(lock.nodes.len(), 4); // root, nixpkgs, utils, systems
599    }
600
601    #[test]
602    fn follows_utils_nixpkgs_resolves_to_root_nixpkgs() {
603        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
604        let utils = lock.get_node("utils").unwrap();
605        let nixpkgs_ref = &utils.inputs["nixpkgs"];
606        assert_eq!(nixpkgs_ref, &InputRef::Follows(vec!["nixpkgs".to_string()]));
607
608        // Resolve through the API.
609        let resolved = lock.resolve_ref("utils", nixpkgs_ref).unwrap();
610        assert_eq!(resolved, "nixpkgs");
611    }
612
613    #[test]
614    fn resolve_input_walk_utils_nixpkgs() {
615        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
616        // Walk root -> utils -> nixpkgs. The follows should land on the root
617        // nixpkgs node.
618        let node = lock.resolve_input(&["utils", "nixpkgs"]).unwrap();
619        let locked = node.locked.as_ref().unwrap();
620        assert_eq!(locked.owner.as_deref(), Some("nixos"));
621        assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
622    }
623
624    #[test]
625    fn resolve_input_walk_utils_systems() {
626        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
627        let node = lock.resolve_input(&["utils", "systems"]).unwrap();
628        let locked = node.locked.as_ref().unwrap();
629        assert_eq!(locked.owner.as_deref(), Some("nix-systems"));
630        assert_eq!(locked.repo.as_deref(), Some("default"));
631    }
632
633    // ── Deep follows ────────────────────────────────────
634
635    #[test]
636    fn deep_follows_bar_nixpkgs_resolves_through_foo() {
637        let lock = FlakeLock::parse(deep_follows_json()).unwrap();
638
639        // bar.nixpkgs follows ["foo", "nixpkgs"] which means:
640        //   root -> foo -> nixpkgs
641        // foo.nixpkgs follows ["nixpkgs"] which means:
642        //   root -> nixpkgs
643        // So bar.nixpkgs should ultimately resolve to the root nixpkgs node.
644        let node = lock.resolve_input(&["bar", "nixpkgs"]).unwrap();
645        let locked = node.locked.as_ref().unwrap();
646        assert_eq!(locked.owner.as_deref(), Some("nixos"));
647        assert_eq!(locked.rev.as_deref(), Some("abc123"));
648    }
649
650    #[test]
651    fn deep_follows_foo_nixpkgs_resolves_to_root() {
652        let lock = FlakeLock::parse(deep_follows_json()).unwrap();
653        let node = lock.resolve_input(&["foo", "nixpkgs"]).unwrap();
654        let locked = node.locked.as_ref().unwrap();
655        assert_eq!(locked.owner.as_deref(), Some("nixos"));
656    }
657
658    // ── Transitive-input resolution (marquee darwin parity) ──
659    //
660    // These seal the byte-parity fix: a sub-flake's input, when
661    // redirected by a `follows` edge in the ROOT lock, must resolve to
662    // the follows TARGET (root's pin), NOT the sub-flake's own pin.  This
663    // is the `ishou.inputs.substrate = ["substrate"]` shape — sui must
664    // honor the root lock's node graph for every transitive input, never
665    // re-read the sub-flake's own `flake.lock`.
666
667    #[test]
668    fn resolve_node_input_follows_lands_on_root_target() {
669        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
670        // `utils.inputs.nixpkgs = ["nixpkgs"]` (a follows edge into root's
671        // nixpkgs) — resolving the (node, input) edge directly must return
672        // the root `nixpkgs` node, not some utils-local pin.
673        let target = lock.resolve_node_input("utils", "nixpkgs").unwrap();
674        assert_eq!(target, "nixpkgs");
675        // `utils.inputs.systems = "systems"` (a direct ref) resolves to itself.
676        let target = lock.resolve_node_input("utils", "systems").unwrap();
677        assert_eq!(target, "systems");
678    }
679
680    #[test]
681    fn node_input_edges_resolves_follows_for_subflake() {
682        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
683        let mut edges = lock.node_input_edges("utils");
684        edges.sort();
685        assert_eq!(
686            edges,
687            vec![
688                ("nixpkgs".to_string(), "nixpkgs".to_string()),
689                ("systems".to_string(), "systems".to_string()),
690            ],
691            "utils' follows edge must resolve to the ROOT nixpkgs node graph"
692        );
693    }
694
695    #[test]
696    fn node_input_edges_deep_follows_chain() {
697        // bar.inputs.nixpkgs = ["foo","nixpkgs"] → root→foo→nixpkgs → root's
698        // nixpkgs.  The whole redirection chain lives in the ROOT lock; a
699        // transitive consumer must walk it, not read bar's own lock.
700        let lock = FlakeLock::parse(deep_follows_json()).unwrap();
701        let target = lock.resolve_node_input("bar", "nixpkgs").unwrap();
702        assert_eq!(target, "nixpkgs");
703    }
704
705    #[test]
706    fn node_input_edges_unknown_node_is_empty() {
707        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
708        assert!(lock.node_input_edges("does-not-exist").is_empty());
709    }
710
711    #[test]
712    fn resolve_node_input_unknown_input_errors() {
713        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
714        assert!(lock.resolve_node_input("utils", "ghost").is_err());
715    }
716
717    // ── Adjacency map ───────────────────────────────────
718
719    #[test]
720    fn adjacency_map_follows_lock() {
721        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
722        let adj = lock.adjacency_map();
723
724        // root -> nixpkgs, utils
725        let root_edges = &adj["root"];
726        assert_eq!(root_edges.len(), 2);
727        assert!(root_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
728        assert!(root_edges.contains(&("utils".to_string(), "utils".to_string())));
729
730        // utils -> nixpkgs (resolved from follows), systems
731        let utils_edges = &adj["utils"];
732        assert_eq!(utils_edges.len(), 2);
733        assert!(utils_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
734        assert!(utils_edges.contains(&("systems".to_string(), "systems".to_string())));
735
736        // leaf nodes have no edges
737        assert!(adj["nixpkgs"].is_empty());
738        assert!(adj["systems"].is_empty());
739    }
740
741    // ── Error handling ──────────────────────────────────
742
743    #[test]
744    fn rejects_unsupported_version() {
745        let json = r#"{ "nodes": { "root": { "inputs": {} } }, "root": "root", "version": 6 }"#;
746        let err = FlakeLock::parse(json).unwrap_err();
747        assert!(matches!(err, FlakeLockError::UnsupportedVersion { found: 6, .. }));
748    }
749
750    #[test]
751    fn rejects_missing_root_node() {
752        let json = r#"{ "nodes": { "x": {} }, "root": "root", "version": 7 }"#;
753        let err = FlakeLock::parse(json).unwrap_err();
754        assert!(matches!(err, FlakeLockError::MissingRoot(_)));
755    }
756
757    #[test]
758    fn get_node_missing_returns_error() {
759        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
760        assert!(lock.get_node("nonexistent").is_err());
761    }
762
763    #[test]
764    fn resolve_input_missing_segment_returns_error() {
765        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
766        let result = lock.resolve_input(&["nonexistent"]);
767        assert!(result.is_err());
768    }
769
770    #[test]
771    fn resolve_ref_direct_missing_node_returns_error() {
772        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
773        let result = lock.resolve_ref("root", &InputRef::Direct("ghost".to_string()));
774        assert!(result.is_err());
775    }
776
777    #[test]
778    fn resolve_follows_empty_path_returns_error() {
779        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
780        let result = lock.resolve_ref("root", &InputRef::Follows(vec![]));
781        assert!(result.is_err());
782    }
783
784    #[test]
785    fn resolve_follows_bad_segment_returns_error() {
786        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
787        let result = lock.resolve_ref(
788            "utils",
789            &InputRef::Follows(vec!["nonexistent".to_string()]),
790        );
791        assert!(result.is_err());
792    }
793
794    // ── Roundtrip (serialize → parse) ───────────────────
795
796    #[test]
797    fn roundtrip_minimal() {
798        let original = FlakeLock::parse(minimal_lock_json()).unwrap();
799        let json = original.to_json().unwrap();
800        let reparsed = FlakeLock::parse(&json).unwrap();
801
802        assert_eq!(reparsed.version, original.version);
803        assert_eq!(reparsed.root, original.root);
804        assert_eq!(reparsed.nodes.len(), original.nodes.len());
805
806        // Verify locked data survives the trip.
807        let np = reparsed.get_node("nixpkgs").unwrap();
808        let locked = np.locked.as_ref().unwrap();
809        assert_eq!(locked.rev.as_deref(), Some("abc123def456abc123def456abc123def456abc1"));
810    }
811
812    #[test]
813    fn roundtrip_with_follows() {
814        let original = FlakeLock::parse(follows_lock_json()).unwrap();
815        let json = original.to_json().unwrap();
816        let reparsed = FlakeLock::parse(&json).unwrap();
817
818        assert_eq!(reparsed.nodes.len(), original.nodes.len());
819
820        // Follows survived — utils.inputs.nixpkgs is still a follows path.
821        let utils = reparsed.get_node("utils").unwrap();
822        assert_eq!(
823            utils.inputs["nixpkgs"],
824            InputRef::Follows(vec!["nixpkgs".to_string()]),
825        );
826
827        // Resolution still works after roundtrip.
828        let node = reparsed.resolve_input(&["utils", "nixpkgs"]).unwrap();
829        assert_eq!(
830            node.locked.as_ref().unwrap().owner.as_deref(),
831            Some("nixos"),
832        );
833    }
834
835    // ── Real-world-ish: non-flake input ─────────────────
836
837    #[test]
838    fn parse_non_flake_input() {
839        let json = r#"{
840  "nodes": {
841    "data": {
842      "flake": false,
843      "locked": {
844        "lastModified": 1700000000,
845        "narHash": "sha256-DATA",
846        "owner": "someone",
847        "repo": "data-files",
848        "rev": "deadbeef",
849        "type": "github"
850      },
851      "original": {
852        "owner": "someone",
853        "repo": "data-files",
854        "type": "github"
855      }
856    },
857    "root": {
858      "inputs": {
859        "data": "data"
860      }
861    }
862  },
863  "root": "root",
864  "version": 7
865}"#;
866        let lock = FlakeLock::parse(json).unwrap();
867        let data = lock.get_node("data").unwrap();
868        assert_eq!(data.flake, Some(false));
869    }
870
871    // ── InputRef serde ──────────────────────────────────
872
873    #[test]
874    fn input_ref_direct_deserialize() {
875        let v: InputRef = serde_json::from_str(r#""nixpkgs""#).unwrap();
876        assert_eq!(v, InputRef::Direct("nixpkgs".to_string()));
877    }
878
879    #[test]
880    fn input_ref_follows_deserialize() {
881        let v: InputRef = serde_json::from_str(r#"["nixpkgs"]"#).unwrap();
882        assert_eq!(v, InputRef::Follows(vec!["nixpkgs".to_string()]));
883    }
884
885    #[test]
886    fn input_ref_follows_multi_segment_deserialize() {
887        let v: InputRef = serde_json::from_str(r#"["foo", "nixpkgs"]"#).unwrap();
888        assert_eq!(
889            v,
890            InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
891        );
892    }
893
894    #[test]
895    fn input_ref_direct_roundtrip() {
896        let original = InputRef::Direct("nixpkgs".to_string());
897        let json = serde_json::to_string(&original).unwrap();
898        let reparsed: InputRef = serde_json::from_str(&json).unwrap();
899        assert_eq!(original, reparsed);
900    }
901
902    #[test]
903    fn input_ref_follows_roundtrip() {
904        let original = InputRef::Follows(vec!["foo".to_string(), "bar".to_string()]);
905        let json = serde_json::to_string(&original).unwrap();
906        let reparsed: InputRef = serde_json::from_str(&json).unwrap();
907        assert_eq!(original, reparsed);
908    }
909
910    // ── Path-type inputs ────────────────────────────────
911
912    #[test]
913    fn parse_path_type_locked_input() {
914        let json = r#"{
915  "nodes": {
916    "local": {
917      "locked": {
918        "lastModified": 1700000000,
919        "narHash": "sha256-PATH",
920        "path": "/home/user/my-flake",
921        "type": "path"
922      },
923      "original": {
924        "type": "path",
925        "url": "/home/user/my-flake"
926      }
927    },
928    "root": {
929      "inputs": {
930        "local": "local"
931      }
932    }
933  },
934  "root": "root",
935  "version": 7
936}"#;
937        let lock = FlakeLock::parse(json).unwrap();
938        let local = lock.get_node("local").unwrap();
939        let locked = local.locked.as_ref().unwrap();
940        assert_eq!(locked.source_type, "path");
941        assert_eq!(locked.path.as_deref(), Some("/home/user/my-flake"));
942    }
943
944    // ── Large graph: multiple follows chains ────────────
945
946    #[test]
947    fn multiple_inputs_follow_same_target() {
948        let json = r#"{
949  "nodes": {
950    "nixpkgs": {
951      "locked": {
952        "lastModified": 1700000000,
953        "narHash": "sha256-NP",
954        "owner": "nixos",
955        "repo": "nixpkgs",
956        "rev": "aaa",
957        "type": "github"
958      },
959      "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
960    },
961    "root": {
962      "inputs": {
963        "a": "a",
964        "b": "b",
965        "nixpkgs": "nixpkgs"
966      }
967    },
968    "a": {
969      "inputs": { "nixpkgs": ["nixpkgs"] },
970      "locked": {
971        "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github"
972      },
973      "original": { "owner": "x", "repo": "a", "type": "github" }
974    },
975    "b": {
976      "inputs": { "nixpkgs": ["nixpkgs"] },
977      "locked": {
978        "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github"
979      },
980      "original": { "owner": "x", "repo": "b", "type": "github" }
981    }
982  },
983  "root": "root",
984  "version": 7
985}"#;
986        let lock = FlakeLock::parse(json).unwrap();
987
988        // Both a and b follow root's nixpkgs.
989        let a_np = lock.resolve_input(&["a", "nixpkgs"]).unwrap();
990        let b_np = lock.resolve_input(&["b", "nixpkgs"]).unwrap();
991
992        assert_eq!(
993            a_np.locked.as_ref().unwrap().rev.as_deref(),
994            Some("aaa"),
995        );
996        assert_eq!(
997            b_np.locked.as_ref().unwrap().rev.as_deref(),
998            Some("aaa"),
999        );
1000    }
1001
1002    // ── Malformed JSON ──────────────────────────────────
1003
1004    #[test]
1005    fn invalid_json_returns_error() {
1006        assert!(FlakeLock::parse("not json").is_err());
1007    }
1008
1009    #[test]
1010    fn empty_object_returns_error() {
1011        assert!(FlakeLock::parse("{}").is_err());
1012    }
1013
1014    // ── flake = false nodes ─────────────────────────────
1015
1016    #[test]
1017    fn flake_false_node_default_is_none() {
1018        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1019        let nixpkgs = lock.get_node("nixpkgs").unwrap();
1020        assert_eq!(nixpkgs.flake, None);
1021    }
1022
1023    #[test]
1024    fn flake_false_roundtrips_through_json() {
1025        let json = r#"{
1026  "nodes": {
1027    "data-files": {
1028      "flake": false,
1029      "locked": {
1030        "lastModified": 1700000000,
1031        "narHash": "sha256-DATA",
1032        "owner": "example",
1033        "repo": "data",
1034        "rev": "abc123",
1035        "type": "github"
1036      },
1037      "original": {
1038        "owner": "example",
1039        "repo": "data",
1040        "type": "github"
1041      }
1042    },
1043    "root": {
1044      "inputs": {
1045        "data-files": "data-files"
1046      }
1047    }
1048  },
1049  "root": "root",
1050  "version": 7
1051}"#;
1052        let lock = FlakeLock::parse(json).unwrap();
1053        let data = lock.get_node("data-files").unwrap();
1054        assert_eq!(data.flake, Some(false));
1055
1056        let reserialized = lock.to_json().unwrap();
1057        let reparsed = FlakeLock::parse(&reserialized).unwrap();
1058        let data2 = reparsed.get_node("data-files").unwrap();
1059        assert_eq!(data2.flake, Some(false));
1060    }
1061
1062    // ── Follows-of-follows chains ───────────────────────
1063
1064    #[test]
1065    fn follows_of_follows_three_levels() {
1066        let json = r#"{
1067  "nodes": {
1068    "nixpkgs": {
1069      "locked": {
1070        "lastModified": 1700000000,
1071        "narHash": "sha256-NP",
1072        "owner": "nixos",
1073        "repo": "nixpkgs",
1074        "rev": "final",
1075        "type": "github"
1076      },
1077      "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
1078    },
1079    "root": {
1080      "inputs": {
1081        "a": "a",
1082        "b": "b",
1083        "c": "c",
1084        "nixpkgs": "nixpkgs"
1085      }
1086    },
1087    "a": {
1088      "inputs": { "nixpkgs": ["nixpkgs"] },
1089      "locked": { "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
1090      "original": { "owner": "x", "repo": "a", "type": "github" }
1091    },
1092    "b": {
1093      "inputs": { "nixpkgs": ["a", "nixpkgs"] },
1094      "locked": { "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
1095      "original": { "owner": "x", "repo": "b", "type": "github" }
1096    },
1097    "c": {
1098      "inputs": { "nixpkgs": ["b", "nixpkgs"] },
1099      "locked": { "lastModified": 3, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
1100      "original": { "owner": "x", "repo": "c", "type": "github" }
1101    }
1102  },
1103  "root": "root",
1104  "version": 7
1105}"#;
1106        let lock = FlakeLock::parse(json).unwrap();
1107
1108        // c.nixpkgs follows ["b", "nixpkgs"]
1109        //   → root -> b -> nixpkgs
1110        // b.nixpkgs follows ["a", "nixpkgs"]
1111        //   → root -> a -> nixpkgs
1112        // a.nixpkgs follows ["nixpkgs"]
1113        //   → root -> nixpkgs
1114        let node = lock.resolve_input(&["c", "nixpkgs"]).unwrap();
1115        assert_eq!(
1116            node.locked.as_ref().unwrap().rev.as_deref(),
1117            Some("final"),
1118        );
1119    }
1120
1121    // ── Malformed inputs ────────────────────────────────
1122
1123    #[test]
1124    fn malformed_version_string() {
1125        let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": "seven" }"#;
1126        assert!(FlakeLock::parse(json).is_err());
1127    }
1128
1129    #[test]
1130    fn malformed_input_ref_integer() {
1131        let json = r#"{
1132  "nodes": {
1133    "root": {
1134      "inputs": { "x": 42 }
1135    }
1136  },
1137  "root": "root",
1138  "version": 7
1139}"#;
1140        assert!(FlakeLock::parse(json).is_err());
1141    }
1142
1143    #[test]
1144    fn missing_version_field() {
1145        let json = r#"{ "nodes": { "root": {} }, "root": "root" }"#;
1146        assert!(FlakeLock::parse(json).is_err());
1147    }
1148
1149    #[test]
1150    fn null_root_field() {
1151        let json = r#"{ "nodes": { "root": {} }, "root": null, "version": 7 }"#;
1152        assert!(FlakeLock::parse(json).is_err());
1153    }
1154
1155    // ── to_json roundtrip deep follows ──────────────────
1156
1157    #[test]
1158    fn roundtrip_deep_follows() {
1159        let original = FlakeLock::parse(deep_follows_json()).unwrap();
1160        let json = original.to_json().unwrap();
1161        let reparsed = FlakeLock::parse(&json).unwrap();
1162
1163        assert_eq!(reparsed.nodes.len(), original.nodes.len());
1164        let bar = reparsed.get_node("bar").unwrap();
1165        assert_eq!(
1166            bar.inputs["nixpkgs"],
1167            InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
1168        );
1169    }
1170
1171    // ── Adjacency map with deep follows ─────────────────
1172
1173    #[test]
1174    fn adjacency_map_deep_follows() {
1175        let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1176        let adj = lock.adjacency_map();
1177
1178        let root_edges = &adj["root"];
1179        assert_eq!(root_edges.len(), 3);
1180
1181        let bar_edges = &adj["bar"];
1182        assert_eq!(bar_edges.len(), 1);
1183        assert!(bar_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
1184    }
1185
1186    // ── Additional Follows-of-Follows-of-Follows ────────
1187
1188    #[test]
1189    fn follows_chain_four_levels_deep() {
1190        let json = r#"{
1191  "nodes": {
1192    "nixpkgs": {
1193      "locked": { "lastModified": 1, "narHash": "sha256-NP", "owner": "n", "repo": "p", "rev": "final", "type": "github" },
1194      "original": { "owner": "n", "repo": "p", "type": "github" }
1195    },
1196    "root": {
1197      "inputs": { "a": "a", "b": "b", "c": "c", "d": "d", "nixpkgs": "nixpkgs" }
1198    },
1199    "a": {
1200      "inputs": { "nixpkgs": ["nixpkgs"] },
1201      "locked": { "lastModified": 2, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
1202      "original": { "owner": "x", "repo": "a", "type": "github" }
1203    },
1204    "b": {
1205      "inputs": { "nixpkgs": ["a", "nixpkgs"] },
1206      "locked": { "lastModified": 3, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
1207      "original": { "owner": "x", "repo": "b", "type": "github" }
1208    },
1209    "c": {
1210      "inputs": { "nixpkgs": ["b", "nixpkgs"] },
1211      "locked": { "lastModified": 4, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
1212      "original": { "owner": "x", "repo": "c", "type": "github" }
1213    },
1214    "d": {
1215      "inputs": { "nixpkgs": ["c", "nixpkgs"] },
1216      "locked": { "lastModified": 5, "narHash": "sha256-D", "owner": "x", "repo": "d", "rev": "d1", "type": "github" },
1217      "original": { "owner": "x", "repo": "d", "type": "github" }
1218    }
1219  },
1220  "root": "root",
1221  "version": 7
1222}"#;
1223        let lock = FlakeLock::parse(json).unwrap();
1224        // d -> c -> b -> a -> root nixpkgs
1225        let node = lock.resolve_input(&["d", "nixpkgs"]).unwrap();
1226        assert_eq!(node.locked.as_ref().unwrap().rev.as_deref(), Some("final"));
1227    }
1228
1229    // ── FlakeNode extra (catch-all) field preservation ──
1230
1231    #[test]
1232    fn flake_node_extra_field_roundtrips() {
1233        // Some path-typed inputs have a "parent" field on the node itself
1234        let json = r#"{
1235  "nodes": {
1236    "root": {
1237      "inputs": { "self-ref": "self-ref" }
1238    },
1239    "self-ref": {
1240      "locked": {
1241        "lastModified": 1700000000,
1242        "narHash": "sha256-X",
1243        "path": "/tmp/foo",
1244        "type": "path"
1245      },
1246      "original": {
1247        "type": "path",
1248        "url": "/tmp/foo"
1249      },
1250      "parent": ["root"]
1251    }
1252  },
1253  "root": "root",
1254  "version": 7
1255}"#;
1256        let lock = FlakeLock::parse(json).unwrap();
1257        let node = lock.get_node("self-ref").unwrap();
1258        assert!(node.extra.contains_key("parent"));
1259
1260        let reserialized = lock.to_json().unwrap();
1261        let reparsed = FlakeLock::parse(&reserialized).unwrap();
1262        let node2 = reparsed.get_node("self-ref").unwrap();
1263        assert!(node2.extra.contains_key("parent"));
1264    }
1265
1266    // ── OriginalInput extra fields roundtrip ────────────
1267
1268    #[test]
1269    fn original_input_extra_fields_roundtrip() {
1270        let json = r#"{
1271  "nodes": {
1272    "root": { "inputs": { "x": "x" } },
1273    "x": {
1274      "locked": {
1275        "lastModified": 1700000000,
1276        "narHash": "sha256-X",
1277        "owner": "o",
1278        "repo": "r",
1279        "rev": "abc",
1280        "type": "github"
1281      },
1282      "original": {
1283        "owner": "o",
1284        "repo": "r",
1285        "type": "github",
1286        "submodules": true,
1287        "shallow": false
1288      }
1289    }
1290  },
1291  "root": "root",
1292  "version": 7
1293}"#;
1294        let lock = FlakeLock::parse(json).unwrap();
1295        let x = lock.get_node("x").unwrap();
1296        let original = x.original.as_ref().unwrap();
1297        assert_eq!(original.extra.get("submodules"), Some(&serde_json::json!(true)));
1298        assert_eq!(original.extra.get("shallow"), Some(&serde_json::json!(false)));
1299
1300        let reserialized = lock.to_json().unwrap();
1301        let reparsed = FlakeLock::parse(&reserialized).unwrap();
1302        let x2 = reparsed.get_node("x").unwrap();
1303        let orig2 = x2.original.as_ref().unwrap();
1304        assert_eq!(orig2.extra.get("submodules"), Some(&serde_json::json!(true)));
1305    }
1306
1307    // ── More error variants ─────────────────────────────
1308
1309    #[test]
1310    fn unsupported_version_error_includes_found() {
1311        let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 99 }"#;
1312        let err = FlakeLock::parse(json).unwrap_err();
1313        match err {
1314            FlakeLockError::UnsupportedVersion { expected, found } => {
1315                assert_eq!(expected, 7);
1316                assert_eq!(found, 99);
1317            }
1318            other => panic!("expected UnsupportedVersion, got {other:?}"),
1319        }
1320    }
1321
1322    #[test]
1323    fn missing_root_error_includes_name() {
1324        let json = r#"{ "nodes": { "x": {} }, "root": "missing-root", "version": 7 }"#;
1325        match FlakeLock::parse(json).unwrap_err() {
1326            FlakeLockError::MissingRoot(name) => assert_eq!(name, "missing-root"),
1327            other => panic!("expected MissingRoot, got {other:?}"),
1328        }
1329    }
1330
1331    #[test]
1332    fn get_node_returns_node_not_found_with_name() {
1333        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1334        match lock.get_node("nope") {
1335            Err(FlakeLockError::NodeNotFound(n)) => assert_eq!(n, "nope"),
1336            other => panic!("expected NodeNotFound, got {other:?}"),
1337        }
1338    }
1339
1340    // ── version field as float rejected ─────────────────
1341
1342    #[test]
1343    fn version_as_float_rejected() {
1344        let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 7.5 }"#;
1345        assert!(FlakeLock::parse(json).is_err());
1346    }
1347
1348    // ── Adjacency map for minimal ────────────────────────
1349
1350    #[test]
1351    fn adjacency_map_minimal() {
1352        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1353        let adj = lock.adjacency_map();
1354        assert_eq!(adj.len(), 2);
1355        assert_eq!(adj["root"].len(), 1);
1356        assert_eq!(adj["root"][0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
1357        assert!(adj["nixpkgs"].is_empty());
1358    }
1359
1360    // ── adjacency_map skips unresolvable edges ──────────
1361
1362    #[test]
1363    fn adjacency_map_skips_unresolvable() {
1364        // Hand-crafted lock where one edge points to a non-existent node
1365        let mut nodes = BTreeMap::new();
1366        nodes.insert("root".to_string(), FlakeNode {
1367            inputs: {
1368                let mut m = BTreeMap::new();
1369                m.insert("ghost".to_string(), InputRef::Direct("nonexistent".to_string()));
1370                m
1371            },
1372            locked: None,
1373            original: None,
1374            flake: None,
1375            extra: BTreeMap::new(),
1376        });
1377        let lock = FlakeLock {
1378            nodes,
1379            root: "root".to_string(),
1380            version: 7,
1381        };
1382        let adj = lock.adjacency_map();
1383        // The unresolvable edge is silently skipped
1384        assert!(adj["root"].is_empty());
1385    }
1386
1387    // ── resolve_input on root with empty path ───────────
1388
1389    #[test]
1390    fn resolve_input_empty_path_returns_root() {
1391        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1392        let node = lock.resolve_input(&[]).unwrap();
1393        // With empty path, returns root node
1394        assert!(node.inputs.contains_key("nixpkgs"));
1395    }
1396
1397    // ── root_inputs returns sorted by BTreeMap ───────────
1398
1399    #[test]
1400    fn root_inputs_sorted_alphabetically() {
1401        let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1402        let inputs = lock.root_inputs().unwrap();
1403        // BTreeMap iterates in alphabetical key order: bar, foo, nixpkgs
1404        let names: Vec<&str> = inputs.iter().map(|(n, _)| n.as_str()).collect();
1405        assert_eq!(names, vec!["bar", "foo", "nixpkgs"]);
1406    }
1407
1408    // ── InputRef Direct serialization preserves value ───
1409
1410    #[test]
1411    fn input_ref_direct_serialize_to_string_literal() {
1412        let r = InputRef::Direct("nixpkgs".to_string());
1413        let json = serde_json::to_string(&r).unwrap();
1414        assert_eq!(json, r#""nixpkgs""#);
1415    }
1416
1417    #[test]
1418    fn input_ref_follows_serialize_to_array() {
1419        let r = InputRef::Follows(vec!["a".to_string(), "b".to_string()]);
1420        let json = serde_json::to_string(&r).unwrap();
1421        assert_eq!(json, r#"["a","b"]"#);
1422    }
1423
1424    // ── Tarball-type input ──────────────────────────────
1425
1426    #[test]
1427    fn tarball_type_input_with_url() {
1428        let json = r#"{
1429  "nodes": {
1430    "root": { "inputs": { "src": "src" } },
1431    "src": {
1432      "locked": {
1433        "lastModified": 1700000000,
1434        "narHash": "sha256-X",
1435        "type": "tarball",
1436        "url": "https://example.com/v1.0.tar.gz"
1437      },
1438      "original": {
1439        "type": "tarball",
1440        "url": "https://example.com/latest.tar.gz"
1441      }
1442    }
1443  },
1444  "root": "root",
1445  "version": 7
1446}"#;
1447        let lock = FlakeLock::parse(json).unwrap();
1448        let src = lock.get_node("src").unwrap();
1449        let locked = src.locked.as_ref().unwrap();
1450        assert_eq!(locked.source_type, "tarball");
1451        assert_eq!(locked.url.as_deref(), Some("https://example.com/v1.0.tar.gz"));
1452    }
1453
1454    // ── Git-ref input ───────────────────────────────────
1455
1456    #[test]
1457    fn git_ref_input_preserved() {
1458        let json = r#"{
1459  "nodes": {
1460    "root": { "inputs": { "deps": "deps" } },
1461    "deps": {
1462      "locked": {
1463        "lastModified": 1700000000,
1464        "narHash": "sha256-X",
1465        "owner": "o",
1466        "repo": "r",
1467        "rev": "abc",
1468        "ref": "refs/heads/main",
1469        "type": "github"
1470      },
1471      "original": {
1472        "owner": "o",
1473        "repo": "r",
1474        "ref": "main",
1475        "type": "github"
1476      }
1477    }
1478  },
1479  "root": "root",
1480  "version": 7
1481}"#;
1482        let lock = FlakeLock::parse(json).unwrap();
1483        let deps = lock.get_node("deps").unwrap();
1484        let locked = deps.locked.as_ref().unwrap();
1485        assert_eq!(locked.git_ref.as_deref(), Some("refs/heads/main"));
1486        let orig = deps.original.as_ref().unwrap();
1487        assert_eq!(orig.git_ref.as_deref(), Some("main"));
1488    }
1489
1490    // ── git dir field ───────────────────────────────────
1491
1492    #[test]
1493    fn git_dir_subdirectory_field() {
1494        let json = r#"{
1495  "nodes": {
1496    "root": { "inputs": { "subdir": "subdir" } },
1497    "subdir": {
1498      "locked": {
1499        "lastModified": 1700000000,
1500        "narHash": "sha256-X",
1501        "owner": "o",
1502        "repo": "r",
1503        "rev": "abc",
1504        "type": "github",
1505        "dir": "subdir/inside"
1506      },
1507      "original": {
1508        "owner": "o",
1509        "repo": "r",
1510        "type": "github",
1511        "dir": "subdir/inside"
1512      }
1513    }
1514  },
1515  "root": "root",
1516  "version": 7
1517}"#;
1518        let lock = FlakeLock::parse(json).unwrap();
1519        let s = lock.get_node("subdir").unwrap();
1520        let locked = s.locked.as_ref().unwrap();
1521        assert_eq!(locked.dir.as_deref(), Some("subdir/inside"));
1522        let orig = s.original.as_ref().unwrap();
1523        assert_eq!(orig.dir.as_deref(), Some("subdir/inside"));
1524    }
1525
1526    // ── Indirect / id-based input ───────────────────────
1527
1528    #[test]
1529    fn indirect_id_input() {
1530        let json = r#"{
1531  "nodes": {
1532    "root": { "inputs": { "nixpkgs": "nixpkgs" } },
1533    "nixpkgs": {
1534      "locked": {
1535        "lastModified": 1700000000,
1536        "narHash": "sha256-X",
1537        "owner": "nixos",
1538        "repo": "nixpkgs",
1539        "rev": "abc",
1540        "type": "github"
1541      },
1542      "original": {
1543        "id": "nixpkgs",
1544        "type": "indirect"
1545      }
1546    }
1547  },
1548  "root": "root",
1549  "version": 7
1550}"#;
1551        let lock = FlakeLock::parse(json).unwrap();
1552        let np = lock.get_node("nixpkgs").unwrap();
1553        let orig = np.original.as_ref().unwrap();
1554        assert_eq!(orig.source_type, "indirect");
1555        assert_eq!(orig.id.as_deref(), Some("nixpkgs"));
1556    }
1557
1558    // ── Resolve direct input that is itself a follows ──
1559
1560    #[test]
1561    fn resolve_follows_when_target_segment_is_direct() {
1562        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
1563        // utils.systems is a Direct input → resolve_follows_path goes through the
1564        // Direct branch when walking
1565        let node = lock.resolve_input(&["utils", "systems"]).unwrap();
1566        let locked = node.locked.as_ref().unwrap();
1567        assert_eq!(locked.source_type, "github");
1568    }
1569
1570    // ── Trailing whitespace in JSON ──────────────────────
1571
1572    #[test]
1573    fn trailing_whitespace_in_json_ok() {
1574        let json = format!("{}\n\n   \n", minimal_lock_json());
1575        let lock = FlakeLock::parse(&json).unwrap();
1576        assert_eq!(lock.version, 7);
1577    }
1578
1579    // ── Two distinct nodes referencing same locked rev ─
1580
1581    #[test]
1582    fn two_nodes_with_same_underlying_rev() {
1583        let json = r#"{
1584  "nodes": {
1585    "root": { "inputs": { "a": "a", "b": "b" } },
1586    "a": {
1587      "locked": {
1588        "lastModified": 1, "narHash": "sha256-X",
1589        "owner": "n", "repo": "p", "rev": "abc",
1590        "type": "github"
1591      },
1592      "original": { "owner": "n", "repo": "p", "type": "github" }
1593    },
1594    "b": {
1595      "locked": {
1596        "lastModified": 1, "narHash": "sha256-X",
1597        "owner": "n", "repo": "p", "rev": "abc",
1598        "type": "github"
1599      },
1600      "original": { "owner": "n", "repo": "p", "type": "github" }
1601    }
1602  },
1603  "root": "root",
1604  "version": 7
1605}"#;
1606        let lock = FlakeLock::parse(json).unwrap();
1607        assert_eq!(lock.nodes.len(), 3);
1608        let a = lock.get_node("a").unwrap();
1609        let b = lock.get_node("b").unwrap();
1610        // Different node names, same underlying rev
1611        assert_eq!(
1612            a.locked.as_ref().unwrap().rev,
1613            b.locked.as_ref().unwrap().rev
1614        );
1615    }
1616
1617    // ── FlakeLockError Display ──────────────────────────
1618
1619    #[test]
1620    fn flake_lock_error_display_includes_context() {
1621        let err = FlakeLockError::FollowsFailed {
1622            from: "node-x".to_string(),
1623            path: vec!["a".to_string(), "b".to_string()],
1624        };
1625        let s = format!("{err}");
1626        assert!(s.contains("node-x"));
1627    }
1628
1629    // ── Extra fields preserved ──────────────────────────
1630
1631    #[test]
1632    fn extra_fields_roundtrip() {
1633        let json = r#"{
1634  "nodes": {
1635    "local": {
1636      "locked": {
1637        "lastModified": 1700000000,
1638        "narHash": "sha256-X",
1639        "path": "/home/user/proj",
1640        "type": "path",
1641        "revCount": 42,
1642        "submodules": true
1643      },
1644      "original": {
1645        "type": "path",
1646        "url": "/home/user/proj"
1647      }
1648    },
1649    "root": {
1650      "inputs": { "local": "local" }
1651    }
1652  },
1653  "root": "root",
1654  "version": 7
1655}"#;
1656        let lock = FlakeLock::parse(json).unwrap();
1657        let local = lock.get_node("local").unwrap();
1658        let locked = local.locked.as_ref().unwrap();
1659        assert_eq!(locked.extra.get("revCount"), Some(&serde_json::json!(42)));
1660        assert_eq!(locked.extra.get("submodules"), Some(&serde_json::json!(true)));
1661
1662        let reserialized = lock.to_json().unwrap();
1663        let reparsed = FlakeLock::parse(&reserialized).unwrap();
1664        let local2 = reparsed.get_node("local").unwrap();
1665        let locked2 = local2.locked.as_ref().unwrap();
1666        assert_eq!(locked2.extra.get("revCount"), Some(&serde_json::json!(42)));
1667    }
1668}