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    /// Build an adjacency list representation of the full input graph.
284    ///
285    /// Returns `node_name -> [(input_name, resolved_target_node)]`.
286    /// Follows are resolved; any unresolvable edges are silently skipped.
287    pub fn adjacency_map(&self) -> BTreeMap<String, Vec<(String, String)>> {
288        let mut map: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
289
290        for (node_name, node) in &self.nodes {
291            let mut edges = Vec::new();
292            for (input_name, input_ref) in &node.inputs {
293                if let Ok(target) = self.resolve_ref(node_name, input_ref) {
294                    edges.push((input_name.clone(), target));
295                }
296            }
297            map.insert(node_name.clone(), edges);
298        }
299
300        map
301    }
302}
303
304// ── Tests ───────────────────────────────────────────────────
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    // ── Fixtures ────────────────────────────────────────
311
312    /// Minimal flake.lock — root with one direct input.
313    fn minimal_lock_json() -> &'static str {
314        r#"{
315  "nodes": {
316    "nixpkgs": {
317      "locked": {
318        "lastModified": 1700000000,
319        "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
320        "owner": "nixos",
321        "repo": "nixpkgs",
322        "rev": "abc123def456abc123def456abc123def456abc1",
323        "type": "github"
324      },
325      "original": {
326        "owner": "nixos",
327        "ref": "nixos-unstable",
328        "repo": "nixpkgs",
329        "type": "github"
330      }
331    },
332    "root": {
333      "inputs": {
334        "nixpkgs": "nixpkgs"
335      }
336    }
337  },
338  "root": "root",
339  "version": 7
340}"#
341    }
342
343    /// Flake.lock with follows: `utils` follows root's `nixpkgs`.
344    fn follows_lock_json() -> &'static str {
345        r#"{
346  "nodes": {
347    "nixpkgs": {
348      "locked": {
349        "lastModified": 1700000000,
350        "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
351        "owner": "nixos",
352        "repo": "nixpkgs",
353        "rev": "abc123def456abc123def456abc123def456abc1",
354        "type": "github"
355      },
356      "original": {
357        "owner": "nixos",
358        "ref": "nixos-unstable",
359        "repo": "nixpkgs",
360        "type": "github"
361      }
362    },
363    "root": {
364      "inputs": {
365        "nixpkgs": "nixpkgs",
366        "utils": "utils"
367      }
368    },
369    "systems": {
370      "locked": {
371        "lastModified": 1699999999,
372        "narHash": "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
373        "owner": "nix-systems",
374        "repo": "default",
375        "rev": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1",
376        "type": "github"
377      },
378      "original": {
379        "owner": "nix-systems",
380        "repo": "default",
381        "type": "github"
382      }
383    },
384    "utils": {
385      "inputs": {
386        "nixpkgs": [
387          "nixpkgs"
388        ],
389        "systems": "systems"
390      },
391      "locked": {
392        "lastModified": 1699999998,
393        "narHash": "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
394        "owner": "numtide",
395        "repo": "flake-utils",
396        "rev": "ccccccccccccccccccccccccccccccccccccccc1",
397        "type": "github"
398      },
399      "original": {
400        "owner": "numtide",
401        "repo": "flake-utils",
402        "type": "github"
403      }
404    }
405  },
406  "root": "root",
407  "version": 7
408}"#
409    }
410
411    /// Multi-level follows: `bar.nixpkgs` follows `["foo", "nixpkgs"]`,
412    /// and `foo.nixpkgs` follows `["nixpkgs"]`.
413    fn deep_follows_json() -> &'static str {
414        r#"{
415  "nodes": {
416    "nixpkgs": {
417      "locked": {
418        "lastModified": 1700000000,
419        "narHash": "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
420        "owner": "nixos",
421        "repo": "nixpkgs",
422        "rev": "abc123",
423        "type": "github"
424      },
425      "original": {
426        "owner": "nixos",
427        "ref": "nixos-unstable",
428        "repo": "nixpkgs",
429        "type": "github"
430      }
431    },
432    "root": {
433      "inputs": {
434        "bar": "bar",
435        "foo": "foo",
436        "nixpkgs": "nixpkgs"
437      }
438    },
439    "foo": {
440      "inputs": {
441        "nixpkgs": [
442          "nixpkgs"
443        ]
444      },
445      "locked": {
446        "lastModified": 1700000001,
447        "narHash": "sha256-FOO",
448        "owner": "example",
449        "repo": "foo",
450        "rev": "foofoo",
451        "type": "github"
452      },
453      "original": {
454        "owner": "example",
455        "repo": "foo",
456        "type": "github"
457      }
458    },
459    "bar": {
460      "inputs": {
461        "nixpkgs": [
462          "foo",
463          "nixpkgs"
464        ]
465      },
466      "locked": {
467        "lastModified": 1700000002,
468        "narHash": "sha256-BAR",
469        "owner": "example",
470        "repo": "bar",
471        "rev": "barbar",
472        "type": "github"
473      },
474      "original": {
475        "owner": "example",
476        "repo": "bar",
477        "type": "github"
478      }
479    }
480  },
481  "root": "root",
482  "version": 7
483}"#
484    }
485
486    // ── Parse minimal ───────────────────────────────────
487
488    #[test]
489    fn parse_minimal_lock() {
490        let lock = FlakeLock::parse(minimal_lock_json()).expect("parse failed");
491        assert_eq!(lock.version, 7);
492        assert_eq!(lock.root, "root");
493        assert_eq!(lock.nodes.len(), 2);
494        assert!(lock.nodes.contains_key("root"));
495        assert!(lock.nodes.contains_key("nixpkgs"));
496    }
497
498    #[test]
499    fn minimal_root_node_has_no_locked() {
500        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
501        let root = lock.root_node().unwrap();
502        assert!(root.locked.is_none());
503        assert!(root.original.is_none());
504    }
505
506    #[test]
507    fn minimal_nixpkgs_locked_fields() {
508        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
509        let nixpkgs = lock.get_node("nixpkgs").unwrap();
510        let locked = nixpkgs.locked.as_ref().expect("missing locked");
511        assert_eq!(locked.source_type, "github");
512        assert_eq!(locked.owner.as_deref(), Some("nixos"));
513        assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
514        assert_eq!(
515            locked.rev.as_deref(),
516            Some("abc123def456abc123def456abc123def456abc1"),
517        );
518        assert_eq!(locked.last_modified, Some(1_700_000_000));
519        assert!(locked.nar_hash.is_some());
520    }
521
522    #[test]
523    fn minimal_nixpkgs_original_fields() {
524        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
525        let nixpkgs = lock.get_node("nixpkgs").unwrap();
526        let original = nixpkgs.original.as_ref().expect("missing original");
527        assert_eq!(original.source_type, "github");
528        assert_eq!(original.owner.as_deref(), Some("nixos"));
529        assert_eq!(original.repo.as_deref(), Some("nixpkgs"));
530        assert_eq!(original.git_ref.as_deref(), Some("nixos-unstable"));
531    }
532
533    #[test]
534    fn minimal_root_inputs() {
535        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
536        let inputs = lock.root_inputs().unwrap();
537        assert_eq!(inputs.len(), 1);
538        assert_eq!(inputs[0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
539    }
540
541    // ── Parse with follows ──────────────────────────────
542
543    #[test]
544    fn parse_follows_lock() {
545        let lock = FlakeLock::parse(follows_lock_json()).expect("parse failed");
546        assert_eq!(lock.nodes.len(), 4); // root, nixpkgs, utils, systems
547    }
548
549    #[test]
550    fn follows_utils_nixpkgs_resolves_to_root_nixpkgs() {
551        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
552        let utils = lock.get_node("utils").unwrap();
553        let nixpkgs_ref = &utils.inputs["nixpkgs"];
554        assert_eq!(nixpkgs_ref, &InputRef::Follows(vec!["nixpkgs".to_string()]));
555
556        // Resolve through the API.
557        let resolved = lock.resolve_ref("utils", nixpkgs_ref).unwrap();
558        assert_eq!(resolved, "nixpkgs");
559    }
560
561    #[test]
562    fn resolve_input_walk_utils_nixpkgs() {
563        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
564        // Walk root -> utils -> nixpkgs. The follows should land on the root
565        // nixpkgs node.
566        let node = lock.resolve_input(&["utils", "nixpkgs"]).unwrap();
567        let locked = node.locked.as_ref().unwrap();
568        assert_eq!(locked.owner.as_deref(), Some("nixos"));
569        assert_eq!(locked.repo.as_deref(), Some("nixpkgs"));
570    }
571
572    #[test]
573    fn resolve_input_walk_utils_systems() {
574        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
575        let node = lock.resolve_input(&["utils", "systems"]).unwrap();
576        let locked = node.locked.as_ref().unwrap();
577        assert_eq!(locked.owner.as_deref(), Some("nix-systems"));
578        assert_eq!(locked.repo.as_deref(), Some("default"));
579    }
580
581    // ── Deep follows ────────────────────────────────────
582
583    #[test]
584    fn deep_follows_bar_nixpkgs_resolves_through_foo() {
585        let lock = FlakeLock::parse(deep_follows_json()).unwrap();
586
587        // bar.nixpkgs follows ["foo", "nixpkgs"] which means:
588        //   root -> foo -> nixpkgs
589        // foo.nixpkgs follows ["nixpkgs"] which means:
590        //   root -> nixpkgs
591        // So bar.nixpkgs should ultimately resolve to the root nixpkgs node.
592        let node = lock.resolve_input(&["bar", "nixpkgs"]).unwrap();
593        let locked = node.locked.as_ref().unwrap();
594        assert_eq!(locked.owner.as_deref(), Some("nixos"));
595        assert_eq!(locked.rev.as_deref(), Some("abc123"));
596    }
597
598    #[test]
599    fn deep_follows_foo_nixpkgs_resolves_to_root() {
600        let lock = FlakeLock::parse(deep_follows_json()).unwrap();
601        let node = lock.resolve_input(&["foo", "nixpkgs"]).unwrap();
602        let locked = node.locked.as_ref().unwrap();
603        assert_eq!(locked.owner.as_deref(), Some("nixos"));
604    }
605
606    // ── Adjacency map ───────────────────────────────────
607
608    #[test]
609    fn adjacency_map_follows_lock() {
610        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
611        let adj = lock.adjacency_map();
612
613        // root -> nixpkgs, utils
614        let root_edges = &adj["root"];
615        assert_eq!(root_edges.len(), 2);
616        assert!(root_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
617        assert!(root_edges.contains(&("utils".to_string(), "utils".to_string())));
618
619        // utils -> nixpkgs (resolved from follows), systems
620        let utils_edges = &adj["utils"];
621        assert_eq!(utils_edges.len(), 2);
622        assert!(utils_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
623        assert!(utils_edges.contains(&("systems".to_string(), "systems".to_string())));
624
625        // leaf nodes have no edges
626        assert!(adj["nixpkgs"].is_empty());
627        assert!(adj["systems"].is_empty());
628    }
629
630    // ── Error handling ──────────────────────────────────
631
632    #[test]
633    fn rejects_unsupported_version() {
634        let json = r#"{ "nodes": { "root": { "inputs": {} } }, "root": "root", "version": 6 }"#;
635        let err = FlakeLock::parse(json).unwrap_err();
636        assert!(matches!(err, FlakeLockError::UnsupportedVersion { found: 6, .. }));
637    }
638
639    #[test]
640    fn rejects_missing_root_node() {
641        let json = r#"{ "nodes": { "x": {} }, "root": "root", "version": 7 }"#;
642        let err = FlakeLock::parse(json).unwrap_err();
643        assert!(matches!(err, FlakeLockError::MissingRoot(_)));
644    }
645
646    #[test]
647    fn get_node_missing_returns_error() {
648        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
649        assert!(lock.get_node("nonexistent").is_err());
650    }
651
652    #[test]
653    fn resolve_input_missing_segment_returns_error() {
654        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
655        let result = lock.resolve_input(&["nonexistent"]);
656        assert!(result.is_err());
657    }
658
659    #[test]
660    fn resolve_ref_direct_missing_node_returns_error() {
661        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
662        let result = lock.resolve_ref("root", &InputRef::Direct("ghost".to_string()));
663        assert!(result.is_err());
664    }
665
666    #[test]
667    fn resolve_follows_empty_path_returns_error() {
668        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
669        let result = lock.resolve_ref("root", &InputRef::Follows(vec![]));
670        assert!(result.is_err());
671    }
672
673    #[test]
674    fn resolve_follows_bad_segment_returns_error() {
675        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
676        let result = lock.resolve_ref(
677            "utils",
678            &InputRef::Follows(vec!["nonexistent".to_string()]),
679        );
680        assert!(result.is_err());
681    }
682
683    // ── Roundtrip (serialize → parse) ───────────────────
684
685    #[test]
686    fn roundtrip_minimal() {
687        let original = FlakeLock::parse(minimal_lock_json()).unwrap();
688        let json = original.to_json().unwrap();
689        let reparsed = FlakeLock::parse(&json).unwrap();
690
691        assert_eq!(reparsed.version, original.version);
692        assert_eq!(reparsed.root, original.root);
693        assert_eq!(reparsed.nodes.len(), original.nodes.len());
694
695        // Verify locked data survives the trip.
696        let np = reparsed.get_node("nixpkgs").unwrap();
697        let locked = np.locked.as_ref().unwrap();
698        assert_eq!(locked.rev.as_deref(), Some("abc123def456abc123def456abc123def456abc1"));
699    }
700
701    #[test]
702    fn roundtrip_with_follows() {
703        let original = FlakeLock::parse(follows_lock_json()).unwrap();
704        let json = original.to_json().unwrap();
705        let reparsed = FlakeLock::parse(&json).unwrap();
706
707        assert_eq!(reparsed.nodes.len(), original.nodes.len());
708
709        // Follows survived — utils.inputs.nixpkgs is still a follows path.
710        let utils = reparsed.get_node("utils").unwrap();
711        assert_eq!(
712            utils.inputs["nixpkgs"],
713            InputRef::Follows(vec!["nixpkgs".to_string()]),
714        );
715
716        // Resolution still works after roundtrip.
717        let node = reparsed.resolve_input(&["utils", "nixpkgs"]).unwrap();
718        assert_eq!(
719            node.locked.as_ref().unwrap().owner.as_deref(),
720            Some("nixos"),
721        );
722    }
723
724    // ── Real-world-ish: non-flake input ─────────────────
725
726    #[test]
727    fn parse_non_flake_input() {
728        let json = r#"{
729  "nodes": {
730    "data": {
731      "flake": false,
732      "locked": {
733        "lastModified": 1700000000,
734        "narHash": "sha256-DATA",
735        "owner": "someone",
736        "repo": "data-files",
737        "rev": "deadbeef",
738        "type": "github"
739      },
740      "original": {
741        "owner": "someone",
742        "repo": "data-files",
743        "type": "github"
744      }
745    },
746    "root": {
747      "inputs": {
748        "data": "data"
749      }
750    }
751  },
752  "root": "root",
753  "version": 7
754}"#;
755        let lock = FlakeLock::parse(json).unwrap();
756        let data = lock.get_node("data").unwrap();
757        assert_eq!(data.flake, Some(false));
758    }
759
760    // ── InputRef serde ──────────────────────────────────
761
762    #[test]
763    fn input_ref_direct_deserialize() {
764        let v: InputRef = serde_json::from_str(r#""nixpkgs""#).unwrap();
765        assert_eq!(v, InputRef::Direct("nixpkgs".to_string()));
766    }
767
768    #[test]
769    fn input_ref_follows_deserialize() {
770        let v: InputRef = serde_json::from_str(r#"["nixpkgs"]"#).unwrap();
771        assert_eq!(v, InputRef::Follows(vec!["nixpkgs".to_string()]));
772    }
773
774    #[test]
775    fn input_ref_follows_multi_segment_deserialize() {
776        let v: InputRef = serde_json::from_str(r#"["foo", "nixpkgs"]"#).unwrap();
777        assert_eq!(
778            v,
779            InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
780        );
781    }
782
783    #[test]
784    fn input_ref_direct_roundtrip() {
785        let original = InputRef::Direct("nixpkgs".to_string());
786        let json = serde_json::to_string(&original).unwrap();
787        let reparsed: InputRef = serde_json::from_str(&json).unwrap();
788        assert_eq!(original, reparsed);
789    }
790
791    #[test]
792    fn input_ref_follows_roundtrip() {
793        let original = InputRef::Follows(vec!["foo".to_string(), "bar".to_string()]);
794        let json = serde_json::to_string(&original).unwrap();
795        let reparsed: InputRef = serde_json::from_str(&json).unwrap();
796        assert_eq!(original, reparsed);
797    }
798
799    // ── Path-type inputs ────────────────────────────────
800
801    #[test]
802    fn parse_path_type_locked_input() {
803        let json = r#"{
804  "nodes": {
805    "local": {
806      "locked": {
807        "lastModified": 1700000000,
808        "narHash": "sha256-PATH",
809        "path": "/home/user/my-flake",
810        "type": "path"
811      },
812      "original": {
813        "type": "path",
814        "url": "/home/user/my-flake"
815      }
816    },
817    "root": {
818      "inputs": {
819        "local": "local"
820      }
821    }
822  },
823  "root": "root",
824  "version": 7
825}"#;
826        let lock = FlakeLock::parse(json).unwrap();
827        let local = lock.get_node("local").unwrap();
828        let locked = local.locked.as_ref().unwrap();
829        assert_eq!(locked.source_type, "path");
830        assert_eq!(locked.path.as_deref(), Some("/home/user/my-flake"));
831    }
832
833    // ── Large graph: multiple follows chains ────────────
834
835    #[test]
836    fn multiple_inputs_follow_same_target() {
837        let json = r#"{
838  "nodes": {
839    "nixpkgs": {
840      "locked": {
841        "lastModified": 1700000000,
842        "narHash": "sha256-NP",
843        "owner": "nixos",
844        "repo": "nixpkgs",
845        "rev": "aaa",
846        "type": "github"
847      },
848      "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
849    },
850    "root": {
851      "inputs": {
852        "a": "a",
853        "b": "b",
854        "nixpkgs": "nixpkgs"
855      }
856    },
857    "a": {
858      "inputs": { "nixpkgs": ["nixpkgs"] },
859      "locked": {
860        "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github"
861      },
862      "original": { "owner": "x", "repo": "a", "type": "github" }
863    },
864    "b": {
865      "inputs": { "nixpkgs": ["nixpkgs"] },
866      "locked": {
867        "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github"
868      },
869      "original": { "owner": "x", "repo": "b", "type": "github" }
870    }
871  },
872  "root": "root",
873  "version": 7
874}"#;
875        let lock = FlakeLock::parse(json).unwrap();
876
877        // Both a and b follow root's nixpkgs.
878        let a_np = lock.resolve_input(&["a", "nixpkgs"]).unwrap();
879        let b_np = lock.resolve_input(&["b", "nixpkgs"]).unwrap();
880
881        assert_eq!(
882            a_np.locked.as_ref().unwrap().rev.as_deref(),
883            Some("aaa"),
884        );
885        assert_eq!(
886            b_np.locked.as_ref().unwrap().rev.as_deref(),
887            Some("aaa"),
888        );
889    }
890
891    // ── Malformed JSON ──────────────────────────────────
892
893    #[test]
894    fn invalid_json_returns_error() {
895        assert!(FlakeLock::parse("not json").is_err());
896    }
897
898    #[test]
899    fn empty_object_returns_error() {
900        assert!(FlakeLock::parse("{}").is_err());
901    }
902
903    // ── flake = false nodes ─────────────────────────────
904
905    #[test]
906    fn flake_false_node_default_is_none() {
907        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
908        let nixpkgs = lock.get_node("nixpkgs").unwrap();
909        assert_eq!(nixpkgs.flake, None);
910    }
911
912    #[test]
913    fn flake_false_roundtrips_through_json() {
914        let json = r#"{
915  "nodes": {
916    "data-files": {
917      "flake": false,
918      "locked": {
919        "lastModified": 1700000000,
920        "narHash": "sha256-DATA",
921        "owner": "example",
922        "repo": "data",
923        "rev": "abc123",
924        "type": "github"
925      },
926      "original": {
927        "owner": "example",
928        "repo": "data",
929        "type": "github"
930      }
931    },
932    "root": {
933      "inputs": {
934        "data-files": "data-files"
935      }
936    }
937  },
938  "root": "root",
939  "version": 7
940}"#;
941        let lock = FlakeLock::parse(json).unwrap();
942        let data = lock.get_node("data-files").unwrap();
943        assert_eq!(data.flake, Some(false));
944
945        let reserialized = lock.to_json().unwrap();
946        let reparsed = FlakeLock::parse(&reserialized).unwrap();
947        let data2 = reparsed.get_node("data-files").unwrap();
948        assert_eq!(data2.flake, Some(false));
949    }
950
951    // ── Follows-of-follows chains ───────────────────────
952
953    #[test]
954    fn follows_of_follows_three_levels() {
955        let json = r#"{
956  "nodes": {
957    "nixpkgs": {
958      "locked": {
959        "lastModified": 1700000000,
960        "narHash": "sha256-NP",
961        "owner": "nixos",
962        "repo": "nixpkgs",
963        "rev": "final",
964        "type": "github"
965      },
966      "original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
967    },
968    "root": {
969      "inputs": {
970        "a": "a",
971        "b": "b",
972        "c": "c",
973        "nixpkgs": "nixpkgs"
974      }
975    },
976    "a": {
977      "inputs": { "nixpkgs": ["nixpkgs"] },
978      "locked": { "lastModified": 1, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
979      "original": { "owner": "x", "repo": "a", "type": "github" }
980    },
981    "b": {
982      "inputs": { "nixpkgs": ["a", "nixpkgs"] },
983      "locked": { "lastModified": 2, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
984      "original": { "owner": "x", "repo": "b", "type": "github" }
985    },
986    "c": {
987      "inputs": { "nixpkgs": ["b", "nixpkgs"] },
988      "locked": { "lastModified": 3, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
989      "original": { "owner": "x", "repo": "c", "type": "github" }
990    }
991  },
992  "root": "root",
993  "version": 7
994}"#;
995        let lock = FlakeLock::parse(json).unwrap();
996
997        // c.nixpkgs follows ["b", "nixpkgs"]
998        //   → root -> b -> nixpkgs
999        // b.nixpkgs follows ["a", "nixpkgs"]
1000        //   → root -> a -> nixpkgs
1001        // a.nixpkgs follows ["nixpkgs"]
1002        //   → root -> nixpkgs
1003        let node = lock.resolve_input(&["c", "nixpkgs"]).unwrap();
1004        assert_eq!(
1005            node.locked.as_ref().unwrap().rev.as_deref(),
1006            Some("final"),
1007        );
1008    }
1009
1010    // ── Malformed inputs ────────────────────────────────
1011
1012    #[test]
1013    fn malformed_version_string() {
1014        let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": "seven" }"#;
1015        assert!(FlakeLock::parse(json).is_err());
1016    }
1017
1018    #[test]
1019    fn malformed_input_ref_integer() {
1020        let json = r#"{
1021  "nodes": {
1022    "root": {
1023      "inputs": { "x": 42 }
1024    }
1025  },
1026  "root": "root",
1027  "version": 7
1028}"#;
1029        assert!(FlakeLock::parse(json).is_err());
1030    }
1031
1032    #[test]
1033    fn missing_version_field() {
1034        let json = r#"{ "nodes": { "root": {} }, "root": "root" }"#;
1035        assert!(FlakeLock::parse(json).is_err());
1036    }
1037
1038    #[test]
1039    fn null_root_field() {
1040        let json = r#"{ "nodes": { "root": {} }, "root": null, "version": 7 }"#;
1041        assert!(FlakeLock::parse(json).is_err());
1042    }
1043
1044    // ── to_json roundtrip deep follows ──────────────────
1045
1046    #[test]
1047    fn roundtrip_deep_follows() {
1048        let original = FlakeLock::parse(deep_follows_json()).unwrap();
1049        let json = original.to_json().unwrap();
1050        let reparsed = FlakeLock::parse(&json).unwrap();
1051
1052        assert_eq!(reparsed.nodes.len(), original.nodes.len());
1053        let bar = reparsed.get_node("bar").unwrap();
1054        assert_eq!(
1055            bar.inputs["nixpkgs"],
1056            InputRef::Follows(vec!["foo".to_string(), "nixpkgs".to_string()]),
1057        );
1058    }
1059
1060    // ── Adjacency map with deep follows ─────────────────
1061
1062    #[test]
1063    fn adjacency_map_deep_follows() {
1064        let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1065        let adj = lock.adjacency_map();
1066
1067        let root_edges = &adj["root"];
1068        assert_eq!(root_edges.len(), 3);
1069
1070        let bar_edges = &adj["bar"];
1071        assert_eq!(bar_edges.len(), 1);
1072        assert!(bar_edges.contains(&("nixpkgs".to_string(), "nixpkgs".to_string())));
1073    }
1074
1075    // ── Additional Follows-of-Follows-of-Follows ────────
1076
1077    #[test]
1078    fn follows_chain_four_levels_deep() {
1079        let json = r#"{
1080  "nodes": {
1081    "nixpkgs": {
1082      "locked": { "lastModified": 1, "narHash": "sha256-NP", "owner": "n", "repo": "p", "rev": "final", "type": "github" },
1083      "original": { "owner": "n", "repo": "p", "type": "github" }
1084    },
1085    "root": {
1086      "inputs": { "a": "a", "b": "b", "c": "c", "d": "d", "nixpkgs": "nixpkgs" }
1087    },
1088    "a": {
1089      "inputs": { "nixpkgs": ["nixpkgs"] },
1090      "locked": { "lastModified": 2, "narHash": "sha256-A", "owner": "x", "repo": "a", "rev": "a1", "type": "github" },
1091      "original": { "owner": "x", "repo": "a", "type": "github" }
1092    },
1093    "b": {
1094      "inputs": { "nixpkgs": ["a", "nixpkgs"] },
1095      "locked": { "lastModified": 3, "narHash": "sha256-B", "owner": "x", "repo": "b", "rev": "b1", "type": "github" },
1096      "original": { "owner": "x", "repo": "b", "type": "github" }
1097    },
1098    "c": {
1099      "inputs": { "nixpkgs": ["b", "nixpkgs"] },
1100      "locked": { "lastModified": 4, "narHash": "sha256-C", "owner": "x", "repo": "c", "rev": "c1", "type": "github" },
1101      "original": { "owner": "x", "repo": "c", "type": "github" }
1102    },
1103    "d": {
1104      "inputs": { "nixpkgs": ["c", "nixpkgs"] },
1105      "locked": { "lastModified": 5, "narHash": "sha256-D", "owner": "x", "repo": "d", "rev": "d1", "type": "github" },
1106      "original": { "owner": "x", "repo": "d", "type": "github" }
1107    }
1108  },
1109  "root": "root",
1110  "version": 7
1111}"#;
1112        let lock = FlakeLock::parse(json).unwrap();
1113        // d -> c -> b -> a -> root nixpkgs
1114        let node = lock.resolve_input(&["d", "nixpkgs"]).unwrap();
1115        assert_eq!(node.locked.as_ref().unwrap().rev.as_deref(), Some("final"));
1116    }
1117
1118    // ── FlakeNode extra (catch-all) field preservation ──
1119
1120    #[test]
1121    fn flake_node_extra_field_roundtrips() {
1122        // Some path-typed inputs have a "parent" field on the node itself
1123        let json = r#"{
1124  "nodes": {
1125    "root": {
1126      "inputs": { "self-ref": "self-ref" }
1127    },
1128    "self-ref": {
1129      "locked": {
1130        "lastModified": 1700000000,
1131        "narHash": "sha256-X",
1132        "path": "/tmp/foo",
1133        "type": "path"
1134      },
1135      "original": {
1136        "type": "path",
1137        "url": "/tmp/foo"
1138      },
1139      "parent": ["root"]
1140    }
1141  },
1142  "root": "root",
1143  "version": 7
1144}"#;
1145        let lock = FlakeLock::parse(json).unwrap();
1146        let node = lock.get_node("self-ref").unwrap();
1147        assert!(node.extra.contains_key("parent"));
1148
1149        let reserialized = lock.to_json().unwrap();
1150        let reparsed = FlakeLock::parse(&reserialized).unwrap();
1151        let node2 = reparsed.get_node("self-ref").unwrap();
1152        assert!(node2.extra.contains_key("parent"));
1153    }
1154
1155    // ── OriginalInput extra fields roundtrip ────────────
1156
1157    #[test]
1158    fn original_input_extra_fields_roundtrip() {
1159        let json = r#"{
1160  "nodes": {
1161    "root": { "inputs": { "x": "x" } },
1162    "x": {
1163      "locked": {
1164        "lastModified": 1700000000,
1165        "narHash": "sha256-X",
1166        "owner": "o",
1167        "repo": "r",
1168        "rev": "abc",
1169        "type": "github"
1170      },
1171      "original": {
1172        "owner": "o",
1173        "repo": "r",
1174        "type": "github",
1175        "submodules": true,
1176        "shallow": false
1177      }
1178    }
1179  },
1180  "root": "root",
1181  "version": 7
1182}"#;
1183        let lock = FlakeLock::parse(json).unwrap();
1184        let x = lock.get_node("x").unwrap();
1185        let original = x.original.as_ref().unwrap();
1186        assert_eq!(original.extra.get("submodules"), Some(&serde_json::json!(true)));
1187        assert_eq!(original.extra.get("shallow"), Some(&serde_json::json!(false)));
1188
1189        let reserialized = lock.to_json().unwrap();
1190        let reparsed = FlakeLock::parse(&reserialized).unwrap();
1191        let x2 = reparsed.get_node("x").unwrap();
1192        let orig2 = x2.original.as_ref().unwrap();
1193        assert_eq!(orig2.extra.get("submodules"), Some(&serde_json::json!(true)));
1194    }
1195
1196    // ── More error variants ─────────────────────────────
1197
1198    #[test]
1199    fn unsupported_version_error_includes_found() {
1200        let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 99 }"#;
1201        let err = FlakeLock::parse(json).unwrap_err();
1202        match err {
1203            FlakeLockError::UnsupportedVersion { expected, found } => {
1204                assert_eq!(expected, 7);
1205                assert_eq!(found, 99);
1206            }
1207            other => panic!("expected UnsupportedVersion, got {other:?}"),
1208        }
1209    }
1210
1211    #[test]
1212    fn missing_root_error_includes_name() {
1213        let json = r#"{ "nodes": { "x": {} }, "root": "missing-root", "version": 7 }"#;
1214        match FlakeLock::parse(json).unwrap_err() {
1215            FlakeLockError::MissingRoot(name) => assert_eq!(name, "missing-root"),
1216            other => panic!("expected MissingRoot, got {other:?}"),
1217        }
1218    }
1219
1220    #[test]
1221    fn get_node_returns_node_not_found_with_name() {
1222        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1223        match lock.get_node("nope") {
1224            Err(FlakeLockError::NodeNotFound(n)) => assert_eq!(n, "nope"),
1225            other => panic!("expected NodeNotFound, got {other:?}"),
1226        }
1227    }
1228
1229    // ── version field as float rejected ─────────────────
1230
1231    #[test]
1232    fn version_as_float_rejected() {
1233        let json = r#"{ "nodes": { "root": {} }, "root": "root", "version": 7.5 }"#;
1234        assert!(FlakeLock::parse(json).is_err());
1235    }
1236
1237    // ── Adjacency map for minimal ────────────────────────
1238
1239    #[test]
1240    fn adjacency_map_minimal() {
1241        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1242        let adj = lock.adjacency_map();
1243        assert_eq!(adj.len(), 2);
1244        assert_eq!(adj["root"].len(), 1);
1245        assert_eq!(adj["root"][0], ("nixpkgs".to_string(), "nixpkgs".to_string()));
1246        assert!(adj["nixpkgs"].is_empty());
1247    }
1248
1249    // ── adjacency_map skips unresolvable edges ──────────
1250
1251    #[test]
1252    fn adjacency_map_skips_unresolvable() {
1253        // Hand-crafted lock where one edge points to a non-existent node
1254        let mut nodes = BTreeMap::new();
1255        nodes.insert("root".to_string(), FlakeNode {
1256            inputs: {
1257                let mut m = BTreeMap::new();
1258                m.insert("ghost".to_string(), InputRef::Direct("nonexistent".to_string()));
1259                m
1260            },
1261            locked: None,
1262            original: None,
1263            flake: None,
1264            extra: BTreeMap::new(),
1265        });
1266        let lock = FlakeLock {
1267            nodes,
1268            root: "root".to_string(),
1269            version: 7,
1270        };
1271        let adj = lock.adjacency_map();
1272        // The unresolvable edge is silently skipped
1273        assert!(adj["root"].is_empty());
1274    }
1275
1276    // ── resolve_input on root with empty path ───────────
1277
1278    #[test]
1279    fn resolve_input_empty_path_returns_root() {
1280        let lock = FlakeLock::parse(minimal_lock_json()).unwrap();
1281        let node = lock.resolve_input(&[]).unwrap();
1282        // With empty path, returns root node
1283        assert!(node.inputs.contains_key("nixpkgs"));
1284    }
1285
1286    // ── root_inputs returns sorted by BTreeMap ───────────
1287
1288    #[test]
1289    fn root_inputs_sorted_alphabetically() {
1290        let lock = FlakeLock::parse(deep_follows_json()).unwrap();
1291        let inputs = lock.root_inputs().unwrap();
1292        // BTreeMap iterates in alphabetical key order: bar, foo, nixpkgs
1293        let names: Vec<&str> = inputs.iter().map(|(n, _)| n.as_str()).collect();
1294        assert_eq!(names, vec!["bar", "foo", "nixpkgs"]);
1295    }
1296
1297    // ── InputRef Direct serialization preserves value ───
1298
1299    #[test]
1300    fn input_ref_direct_serialize_to_string_literal() {
1301        let r = InputRef::Direct("nixpkgs".to_string());
1302        let json = serde_json::to_string(&r).unwrap();
1303        assert_eq!(json, r#""nixpkgs""#);
1304    }
1305
1306    #[test]
1307    fn input_ref_follows_serialize_to_array() {
1308        let r = InputRef::Follows(vec!["a".to_string(), "b".to_string()]);
1309        let json = serde_json::to_string(&r).unwrap();
1310        assert_eq!(json, r#"["a","b"]"#);
1311    }
1312
1313    // ── Tarball-type input ──────────────────────────────
1314
1315    #[test]
1316    fn tarball_type_input_with_url() {
1317        let json = r#"{
1318  "nodes": {
1319    "root": { "inputs": { "src": "src" } },
1320    "src": {
1321      "locked": {
1322        "lastModified": 1700000000,
1323        "narHash": "sha256-X",
1324        "type": "tarball",
1325        "url": "https://example.com/v1.0.tar.gz"
1326      },
1327      "original": {
1328        "type": "tarball",
1329        "url": "https://example.com/latest.tar.gz"
1330      }
1331    }
1332  },
1333  "root": "root",
1334  "version": 7
1335}"#;
1336        let lock = FlakeLock::parse(json).unwrap();
1337        let src = lock.get_node("src").unwrap();
1338        let locked = src.locked.as_ref().unwrap();
1339        assert_eq!(locked.source_type, "tarball");
1340        assert_eq!(locked.url.as_deref(), Some("https://example.com/v1.0.tar.gz"));
1341    }
1342
1343    // ── Git-ref input ───────────────────────────────────
1344
1345    #[test]
1346    fn git_ref_input_preserved() {
1347        let json = r#"{
1348  "nodes": {
1349    "root": { "inputs": { "deps": "deps" } },
1350    "deps": {
1351      "locked": {
1352        "lastModified": 1700000000,
1353        "narHash": "sha256-X",
1354        "owner": "o",
1355        "repo": "r",
1356        "rev": "abc",
1357        "ref": "refs/heads/main",
1358        "type": "github"
1359      },
1360      "original": {
1361        "owner": "o",
1362        "repo": "r",
1363        "ref": "main",
1364        "type": "github"
1365      }
1366    }
1367  },
1368  "root": "root",
1369  "version": 7
1370}"#;
1371        let lock = FlakeLock::parse(json).unwrap();
1372        let deps = lock.get_node("deps").unwrap();
1373        let locked = deps.locked.as_ref().unwrap();
1374        assert_eq!(locked.git_ref.as_deref(), Some("refs/heads/main"));
1375        let orig = deps.original.as_ref().unwrap();
1376        assert_eq!(orig.git_ref.as_deref(), Some("main"));
1377    }
1378
1379    // ── git dir field ───────────────────────────────────
1380
1381    #[test]
1382    fn git_dir_subdirectory_field() {
1383        let json = r#"{
1384  "nodes": {
1385    "root": { "inputs": { "subdir": "subdir" } },
1386    "subdir": {
1387      "locked": {
1388        "lastModified": 1700000000,
1389        "narHash": "sha256-X",
1390        "owner": "o",
1391        "repo": "r",
1392        "rev": "abc",
1393        "type": "github",
1394        "dir": "subdir/inside"
1395      },
1396      "original": {
1397        "owner": "o",
1398        "repo": "r",
1399        "type": "github",
1400        "dir": "subdir/inside"
1401      }
1402    }
1403  },
1404  "root": "root",
1405  "version": 7
1406}"#;
1407        let lock = FlakeLock::parse(json).unwrap();
1408        let s = lock.get_node("subdir").unwrap();
1409        let locked = s.locked.as_ref().unwrap();
1410        assert_eq!(locked.dir.as_deref(), Some("subdir/inside"));
1411        let orig = s.original.as_ref().unwrap();
1412        assert_eq!(orig.dir.as_deref(), Some("subdir/inside"));
1413    }
1414
1415    // ── Indirect / id-based input ───────────────────────
1416
1417    #[test]
1418    fn indirect_id_input() {
1419        let json = r#"{
1420  "nodes": {
1421    "root": { "inputs": { "nixpkgs": "nixpkgs" } },
1422    "nixpkgs": {
1423      "locked": {
1424        "lastModified": 1700000000,
1425        "narHash": "sha256-X",
1426        "owner": "nixos",
1427        "repo": "nixpkgs",
1428        "rev": "abc",
1429        "type": "github"
1430      },
1431      "original": {
1432        "id": "nixpkgs",
1433        "type": "indirect"
1434      }
1435    }
1436  },
1437  "root": "root",
1438  "version": 7
1439}"#;
1440        let lock = FlakeLock::parse(json).unwrap();
1441        let np = lock.get_node("nixpkgs").unwrap();
1442        let orig = np.original.as_ref().unwrap();
1443        assert_eq!(orig.source_type, "indirect");
1444        assert_eq!(orig.id.as_deref(), Some("nixpkgs"));
1445    }
1446
1447    // ── Resolve direct input that is itself a follows ──
1448
1449    #[test]
1450    fn resolve_follows_when_target_segment_is_direct() {
1451        let lock = FlakeLock::parse(follows_lock_json()).unwrap();
1452        // utils.systems is a Direct input → resolve_follows_path goes through the
1453        // Direct branch when walking
1454        let node = lock.resolve_input(&["utils", "systems"]).unwrap();
1455        let locked = node.locked.as_ref().unwrap();
1456        assert_eq!(locked.source_type, "github");
1457    }
1458
1459    // ── Trailing whitespace in JSON ──────────────────────
1460
1461    #[test]
1462    fn trailing_whitespace_in_json_ok() {
1463        let json = format!("{}\n\n   \n", minimal_lock_json());
1464        let lock = FlakeLock::parse(&json).unwrap();
1465        assert_eq!(lock.version, 7);
1466    }
1467
1468    // ── Two distinct nodes referencing same locked rev ─
1469
1470    #[test]
1471    fn two_nodes_with_same_underlying_rev() {
1472        let json = r#"{
1473  "nodes": {
1474    "root": { "inputs": { "a": "a", "b": "b" } },
1475    "a": {
1476      "locked": {
1477        "lastModified": 1, "narHash": "sha256-X",
1478        "owner": "n", "repo": "p", "rev": "abc",
1479        "type": "github"
1480      },
1481      "original": { "owner": "n", "repo": "p", "type": "github" }
1482    },
1483    "b": {
1484      "locked": {
1485        "lastModified": 1, "narHash": "sha256-X",
1486        "owner": "n", "repo": "p", "rev": "abc",
1487        "type": "github"
1488      },
1489      "original": { "owner": "n", "repo": "p", "type": "github" }
1490    }
1491  },
1492  "root": "root",
1493  "version": 7
1494}"#;
1495        let lock = FlakeLock::parse(json).unwrap();
1496        assert_eq!(lock.nodes.len(), 3);
1497        let a = lock.get_node("a").unwrap();
1498        let b = lock.get_node("b").unwrap();
1499        // Different node names, same underlying rev
1500        assert_eq!(
1501            a.locked.as_ref().unwrap().rev,
1502            b.locked.as_ref().unwrap().rev
1503        );
1504    }
1505
1506    // ── FlakeLockError Display ──────────────────────────
1507
1508    #[test]
1509    fn flake_lock_error_display_includes_context() {
1510        let err = FlakeLockError::FollowsFailed {
1511            from: "node-x".to_string(),
1512            path: vec!["a".to_string(), "b".to_string()],
1513        };
1514        let s = format!("{err}");
1515        assert!(s.contains("node-x"));
1516    }
1517
1518    // ── Extra fields preserved ──────────────────────────
1519
1520    #[test]
1521    fn extra_fields_roundtrip() {
1522        let json = r#"{
1523  "nodes": {
1524    "local": {
1525      "locked": {
1526        "lastModified": 1700000000,
1527        "narHash": "sha256-X",
1528        "path": "/home/user/proj",
1529        "type": "path",
1530        "revCount": 42,
1531        "submodules": true
1532      },
1533      "original": {
1534        "type": "path",
1535        "url": "/home/user/proj"
1536      }
1537    },
1538    "root": {
1539      "inputs": { "local": "local" }
1540    }
1541  },
1542  "root": "root",
1543  "version": 7
1544}"#;
1545        let lock = FlakeLock::parse(json).unwrap();
1546        let local = lock.get_node("local").unwrap();
1547        let locked = local.locked.as_ref().unwrap();
1548        assert_eq!(locked.extra.get("revCount"), Some(&serde_json::json!(42)));
1549        assert_eq!(locked.extra.get("submodules"), Some(&serde_json::json!(true)));
1550
1551        let reserialized = lock.to_json().unwrap();
1552        let reparsed = FlakeLock::parse(&reserialized).unwrap();
1553        let local2 = reparsed.get_node("local").unwrap();
1554        let locked2 = local2.locked.as_ref().unwrap();
1555        assert_eq!(locked2.extra.get("revCount"), Some(&serde_json::json!(42)));
1556    }
1557}