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