Skip to main content

flake_edit/
lock.rs

1use crate::error::FlakeEditError;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs::File;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8/// A nested input path with optional existing follows target.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct NestedInput {
11    /// The path to the nested input (e.g., "crane.nixpkgs")
12    pub path: String,
13    /// The target this input follows, if any (e.g., "nixpkgs")
14    pub follows: Option<String>,
15    /// The original flake URL for Direct references (e.g., "github:nixos/nixpkgs/nixos-unstable")
16    pub url: Option<String>,
17}
18
19impl NestedInput {
20    /// Format for display: "path\tfollows_target" or just "path".
21    /// The tab separator allows the UI to parse and style the parts differently.
22    pub fn to_display_string(&self) -> String {
23        match &self.follows {
24            Some(target) => format!("{}\t{}", self.path, target),
25            None => self.path.clone(),
26        }
27    }
28}
29
30#[derive(Debug, Serialize, Deserialize)]
31pub struct FlakeLock {
32    nodes: HashMap<String, Node>,
33    root: String,
34    version: u8,
35}
36
37#[derive(Debug, Serialize, Deserialize)]
38pub struct Node {
39    inputs: Option<HashMap<String, Input>>,
40    locked: Option<Locked>,
41    original: Option<Original>,
42}
43
44impl Node {
45    fn rev(&self) -> Result<String, FlakeEditError> {
46        self.locked
47            .as_ref()
48            .ok_or_else(|| FlakeEditError::LockError("Node has no locked information.".into()))?
49            .rev()
50    }
51}
52
53#[derive(Debug, Serialize, Deserialize, Clone)]
54#[serde(untagged)]
55pub enum Input {
56    Direct(String),
57    Indirect(Vec<String>),
58}
59
60impl Input {
61    /// Get the target node name for this input.
62    /// For Direct inputs, returns the node name directly.
63    /// For Indirect inputs (follows paths), returns the final target in the path.
64    fn id(&self) -> String {
65        match self {
66            Input::Direct(id) => id.to_string(),
67            Input::Indirect(path) => path.last().cloned().unwrap_or_default(),
68        }
69    }
70}
71
72#[derive(Debug, Serialize, Deserialize, Clone)]
73pub struct Locked {
74    owner: Option<String>,
75    repo: Option<String>,
76    rev: Option<String>,
77    #[serde(rename = "type")]
78    node_type: String,
79    #[serde(rename = "ref")]
80    ref_field: Option<String>,
81}
82
83impl Locked {
84    fn rev(&self) -> Result<String, FlakeEditError> {
85        self.rev
86            .clone()
87            .ok_or_else(|| FlakeEditError::LockError("Locked node has no rev.".into()))
88    }
89}
90
91#[derive(Debug, Serialize, Deserialize)]
92pub struct Original {
93    owner: Option<String>,
94    repo: Option<String>,
95    #[serde(rename = "type")]
96    node_type: String,
97    #[serde(rename = "ref")]
98    ref_field: Option<String>,
99    url: Option<String>,
100}
101
102impl Original {
103    /// Reconstruct a flake URL from the original reference.
104    pub fn to_flake_url(&self) -> Option<String> {
105        match self.node_type.as_str() {
106            "github" | "gitlab" | "sourcehut" => {
107                let owner = self.owner.as_deref()?;
108                let repo = self.repo.as_deref()?;
109                let mut url = format!("{}:{}/{}", self.node_type, owner, repo);
110                if let Some(ref_field) = &self.ref_field {
111                    url.push('/');
112                    url.push_str(ref_field);
113                }
114                Some(url)
115            }
116            _ => self.url.clone(),
117        }
118    }
119}
120
121impl FlakeLock {
122    const LOCK: &'static str = "flake.lock";
123
124    pub fn from_default_path() -> Result<Self, FlakeEditError> {
125        let path = PathBuf::from(Self::LOCK);
126        Self::from_file(path)
127    }
128
129    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, FlakeEditError> {
130        let mut file = File::open(path)?;
131        let mut contents = String::new();
132        file.read_to_string(&mut contents)?;
133        Self::read_from_str(&contents)
134    }
135    pub fn read_from_str(str: &str) -> Result<Self, FlakeEditError> {
136        Ok(serde_json::from_str(str)?)
137    }
138    pub fn root(&self) -> &str {
139        &self.root
140    }
141    /// Split an input path into segments, respecting quoted names.
142    /// E.g. `"hls-1.10".nixpkgs` -> `["hls-1.10", "nixpkgs"]`
143    /// E.g. `browseros.nixpkgs` -> `["browseros", "nixpkgs"]`
144    fn split_input_path(path: &str) -> Vec<&str> {
145        let mut segments = Vec::new();
146        let mut rest = path;
147        while !rest.is_empty() {
148            if rest.starts_with('"') {
149                // Quoted segment: find closing quote
150                if let Some(end) = rest[1..].find('"') {
151                    segments.push(&rest[1..end + 1]);
152                    rest = &rest[end + 2..];
153                    // Skip the dot separator if present
154                    rest = rest.strip_prefix('.').unwrap_or(rest);
155                } else {
156                    // Malformed: no closing quote, treat rest as one segment
157                    segments.push(rest.trim_matches('"'));
158                    break;
159                }
160            } else if let Some(dot) = rest.find('.') {
161                segments.push(&rest[..dot]);
162                rest = &rest[dot + 1..];
163            } else {
164                segments.push(rest);
165                break;
166            }
167        }
168        segments
169    }
170
171    /// Resolve an input path to a node name by walking the lock tree.
172    fn resolve_input_path(&self, segments: &[&str]) -> Result<String, FlakeEditError> {
173        let mut current_node = self
174            .nodes
175            .get(self.root())
176            .ok_or(FlakeEditError::LockMissingRoot)?;
177
178        for (i, segment) in segments.iter().enumerate() {
179            let inputs = current_node.inputs.as_ref().ok_or_else(|| {
180                if i == 0 {
181                    FlakeEditError::LockError("Could not resolve root.".into())
182                } else {
183                    FlakeEditError::LockError(format!(
184                        "Input '{}' has no sub-inputs.",
185                        segments[..i].join(".")
186                    ))
187                }
188            })?;
189
190            let resolved = inputs.get(*segment).ok_or_else(|| {
191                FlakeEditError::LockError(format!(
192                    "Input '{}' not found in lock file.",
193                    segments[..=i].join(".")
194                ))
195            })?;
196
197            let node_name = resolved.id();
198
199            if i < segments.len() - 1 {
200                // Intermediate segment: move to the next node
201                current_node = self.nodes.get(&node_name).ok_or_else(|| {
202                    FlakeEditError::LockError(format!(
203                        "Could not find node '{}' for input '{}'.",
204                        node_name,
205                        segments[..=i].join(".")
206                    ))
207                })?;
208            } else {
209                // Final segment: return the node name
210                return Ok(node_name);
211            }
212        }
213
214        Err(FlakeEditError::LockError("Empty input path.".into()))
215    }
216
217    /// Query the lock file for a specific rev.
218    pub fn rev_for(&self, id: &str) -> Result<String, FlakeEditError> {
219        let segments = Self::split_input_path(id);
220        let node_name = self.resolve_input_path(&segments)?;
221        let node = self.nodes.get(&node_name).ok_or_else(|| {
222            FlakeEditError::LockError(format!("Could not find node '{node_name}'."))
223        })?;
224        node.rev()
225    }
226
227    /// Get all nested input paths for shell completions.
228    /// Returns paths like "naersk.nixpkgs", "naersk.flake-utils", etc.
229    pub fn nested_input_paths(&self) -> Vec<String> {
230        self.nested_inputs()
231            .into_iter()
232            .map(|input| input.path)
233            .collect()
234    }
235
236    /// Get all nested inputs with their existing follows targets.
237    pub fn nested_inputs(&self) -> Vec<NestedInput> {
238        let mut inputs = Vec::new();
239
240        // Get the root node
241        let Some(root_node) = self.nodes.get(&self.root) else {
242            return inputs;
243        };
244
245        // Get top-level inputs from root
246        let Some(root_inputs) = &root_node.inputs else {
247            return inputs;
248        };
249
250        // For each top-level input, find its nested inputs
251        for (top_level_name, top_level_ref) in root_inputs {
252            // Resolve the node name (could be different from input name)
253            let node_name = match top_level_ref {
254                Input::Direct(name) => name.clone(),
255                Input::Indirect(_) => {
256                    // For indirect inputs (follows), skip - they don't have their own inputs
257                    continue;
258                }
259            };
260
261            // Get the node for this input
262            if let Some(node) = self.nodes.get(&node_name) {
263                // Get nested inputs of this node
264                if let Some(nested_inputs) = &node.inputs {
265                    for (nested_name, nested_ref) in nested_inputs {
266                        let quoted_parent = if top_level_name.contains('.') {
267                            format!("\"{}\"", top_level_name)
268                        } else {
269                            top_level_name.clone()
270                        };
271                        let quoted_nested = if nested_name.contains('.') {
272                            format!("\"{}\"", nested_name)
273                        } else {
274                            nested_name.clone()
275                        };
276                        let path = format!("{}.{}", quoted_parent, quoted_nested);
277                        let (follows, url) = match nested_ref {
278                            Input::Indirect(targets) => (Some(targets.join(".")), None),
279                            Input::Direct(node_name) => {
280                                let url = self
281                                    .nodes
282                                    .get(node_name.as_str())
283                                    .and_then(|n| n.original.as_ref())
284                                    .and_then(|o| o.to_flake_url());
285                                (None, url)
286                            }
287                        };
288                        inputs.push(NestedInput { path, follows, url });
289                    }
290                }
291            }
292        }
293
294        inputs.sort_by(|a, b| a.path.cmp(&b.path));
295        inputs
296    }
297}
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn minimal_lock() -> &'static str {
303        r#"
304    {
305  "nodes": {
306    "nixpkgs": {
307      "locked": {
308        "lastModified": 1718714799,
309        "narHash": "sha256-FUZpz9rg3gL8NVPKbqU8ei1VkPLsTIfAJ2fdAf5qjak=",
310        "owner": "nixos",
311        "repo": "nixpkgs",
312        "rev": "c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
313        "type": "github"
314      },
315      "original": {
316        "owner": "nixos",
317        "ref": "nixos-unstable",
318        "repo": "nixpkgs",
319        "type": "github"
320      }
321    },
322    "root": {
323      "inputs": {
324        "nixpkgs": "nixpkgs"
325      }
326    }
327  },
328  "root": "root",
329  "version": 7
330}
331    "#
332    }
333    fn minimal_independent_lock_no_overrides() -> &'static str {
334        r#"
335    {
336  "nodes": {
337    "nixpkgs": {
338      "locked": {
339        "lastModified": 1721138476,
340        "narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
341        "owner": "nixos",
342        "repo": "nixpkgs",
343        "rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
344        "type": "github"
345      },
346      "original": {
347        "owner": "nixos",
348        "ref": "nixos-unstable",
349        "repo": "nixpkgs",
350        "type": "github"
351      }
352    },
353    "nixpkgs_2": {
354      "locked": {
355        "lastModified": 1719690277,
356        "narHash": "sha256-0xSej1g7eP2kaUF+JQp8jdyNmpmCJKRpO12mKl/36Kc=",
357        "owner": "nixos",
358        "repo": "nixpkgs",
359        "rev": "2741b4b489b55df32afac57bc4bfd220e8bf617e",
360        "type": "github"
361      },
362      "original": {
363        "owner": "nixos",
364        "ref": "nixos-unstable",
365        "repo": "nixpkgs",
366        "type": "github"
367      }
368    },
369    "root": {
370      "inputs": {
371        "nixpkgs": "nixpkgs",
372        "treefmt-nix": "treefmt-nix"
373      }
374    },
375    "treefmt-nix": {
376      "inputs": {
377        "nixpkgs": "nixpkgs_2"
378      },
379      "locked": {
380        "lastModified": 1721382922,
381        "narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
382        "owner": "numtide",
383        "repo": "treefmt-nix",
384        "rev": "50104496fb55c9140501ea80d183f3223d13ff65",
385        "type": "github"
386      },
387      "original": {
388        "owner": "numtide",
389        "repo": "treefmt-nix",
390        "type": "github"
391      }
392    }
393  },
394  "root": "root",
395  "version": 7
396}
397    "#
398    }
399
400    fn minimal_independent_lock_nixpkgs_overridden() -> &'static str {
401        r#"
402    {
403  "nodes": {
404    "nixpkgs": {
405      "locked": {
406        "lastModified": 1721138476,
407        "narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
408        "owner": "nixos",
409        "repo": "nixpkgs",
410        "rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
411        "type": "github"
412      },
413      "original": {
414        "owner": "nixos",
415        "ref": "nixos-unstable",
416        "repo": "nixpkgs",
417        "type": "github"
418      }
419    },
420    "root": {
421      "inputs": {
422        "nixpkgs": "nixpkgs",
423        "treefmt-nix": "treefmt-nix"
424      }
425    },
426    "treefmt-nix": {
427      "inputs": {
428        "nixpkgs": [
429          "nixpkgs"
430        ]
431      },
432      "locked": {
433        "lastModified": 1721382922,
434        "narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
435        "owner": "numtide",
436        "repo": "treefmt-nix",
437        "rev": "50104496fb55c9140501ea80d183f3223d13ff65",
438        "type": "github"
439      },
440      "original": {
441        "owner": "numtide",
442        "repo": "treefmt-nix",
443        "type": "github"
444      }
445    }
446  },
447  "root": "root",
448  "version": 7
449}
450    "#
451    }
452
453    #[test]
454    fn parse_minimal() {
455        let minimal_lock = minimal_lock();
456        FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
457    }
458    #[test]
459    fn parse_minimal_version() {
460        let minimal_lock = minimal_lock();
461        let parsed_lock =
462            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
463        assert_eq!(7, parsed_lock.version);
464    }
465    #[test]
466    fn parse_minimal_root() {
467        let minimal_lock = minimal_lock();
468        let parsed_lock =
469            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
470        assert_eq!("root", parsed_lock.root);
471    }
472    #[test]
473    fn minimal_ref() {
474        let minimal_lock = minimal_lock();
475        let parsed_lock =
476            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
477        assert_eq!(
478            "c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
479            parsed_lock
480                .rev_for("nixpkgs")
481                .expect("Id: nixpkgs is in the lockfile.")
482        );
483    }
484    #[test]
485    fn parse_minimal_independent_lock_no_overrides() {
486        let minimal_lock = minimal_independent_lock_no_overrides();
487        FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
488    }
489    #[test]
490    fn minimal_independent_lock_no_overrides_ref() {
491        let minimal_lock = minimal_independent_lock_no_overrides();
492        let parsed_lock =
493            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
494        assert_eq!(
495            "ad0b5eed1b6031efaed382844806550c3dcb4206",
496            parsed_lock
497                .rev_for("nixpkgs")
498                .expect("Id: nixpkgs is in the lockfile.")
499        );
500    }
501    #[test]
502    fn parse_minimal_independent_lock_nixpkgs_overridden() {
503        let minimal_lock = minimal_independent_lock_nixpkgs_overridden();
504        FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
505    }
506
507    #[test]
508    fn input_indirect_id() {
509        // Follows path like ["nixpkgs"] should return "nixpkgs"
510        let input = Input::Indirect(vec!["nixpkgs".to_string()]);
511        assert_eq!("nixpkgs", input.id());
512    }
513
514    #[test]
515    fn rev_for_sub_input_path_missing_parent_returns_error() {
516        // Sub-input paths where the parent doesn't exist should error.
517        let minimal_lock = minimal_lock();
518        let parsed_lock =
519            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
520        assert!(parsed_lock.rev_for("browseros.nixpkgs").is_err());
521    }
522
523    #[test]
524    fn rev_for_sub_input_path_resolves() {
525        // Sub-input paths like "treefmt-nix.nixpkgs" should traverse the lock tree.
526        let lock = minimal_independent_lock_no_overrides();
527        let parsed = FlakeLock::read_from_str(lock).expect("Should be parsed correctly.");
528        assert_eq!(
529            "2741b4b489b55df32afac57bc4bfd220e8bf617e",
530            parsed
531                .rev_for("treefmt-nix.nixpkgs")
532                .expect("Should resolve sub-input path")
533        );
534    }
535
536    #[test]
537    fn rev_for_sub_input_follows_resolves() {
538        // Sub-input that follows the root input should resolve to the same rev.
539        let lock = minimal_independent_lock_nixpkgs_overridden();
540        let parsed = FlakeLock::read_from_str(lock).expect("Should be parsed correctly.");
541        assert_eq!(
542            parsed.rev_for("nixpkgs").unwrap(),
543            parsed
544                .rev_for("treefmt-nix.nixpkgs")
545                .expect("Should resolve followed sub-input")
546        );
547    }
548
549    #[test]
550    fn rev_for_quoted_id() {
551        // Quoted attribute names like `"nixpkgs-24.11"` from `list --format simple`
552        // should be stripped before lock lookup.
553        let minimal_lock = minimal_lock();
554        let parsed_lock =
555            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
556        assert_eq!(
557            parsed_lock.rev_for("nixpkgs").unwrap(),
558            parsed_lock.rev_for("\"nixpkgs\"").unwrap(),
559        );
560    }
561
562    #[test]
563    fn rev_for_node_without_locked_returns_error() {
564        // A node that exists but has no "locked" field should error, not panic.
565        let lock = r#"{
566  "nodes": {
567    "root": {
568      "inputs": { "bare": "bare" }
569    },
570    "bare": {
571      "original": { "owner": "o", "repo": "r", "type": "github" }
572    }
573  },
574  "root": "root",
575  "version": 7
576}"#;
577        let parsed = FlakeLock::read_from_str(lock).unwrap();
578        assert!(parsed.rev_for("bare").is_err());
579    }
580
581    #[test]
582    fn rev_for_node_without_rev_returns_error() {
583        // A locked node without a "rev" field should error, not panic.
584        let lock = r#"{
585  "nodes": {
586    "root": {
587      "inputs": { "norev": "norev" }
588    },
589    "norev": {
590      "locked": { "lastModified": 1, "narHash": "", "type": "path" },
591      "original": { "type": "path" }
592    }
593  },
594  "root": "root",
595  "version": 7
596}"#;
597        let parsed = FlakeLock::read_from_str(lock).unwrap();
598        assert!(parsed.rev_for("norev").is_err());
599    }
600
601    #[test]
602    fn nested_input_path_quotes_dots() {
603        // Input names with dots should be quoted in the path
604        let lock = r#"{
605  "nodes": {
606    "hls-1.10": {
607      "inputs": { "nixpkgs": "nixpkgs_2" },
608      "flake": false,
609      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
610      "original": { "owner": "o", "repo": "r", "type": "github" }
611    },
612    "nixpkgs": {
613      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
614      "original": { "owner": "o", "repo": "r", "type": "github" }
615    },
616    "nixpkgs_2": {
617      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
618      "original": { "owner": "o", "repo": "r", "type": "github" }
619    },
620    "root": {
621      "inputs": { "hls-1.10": "hls-1.10", "nixpkgs": "nixpkgs" }
622    }
623  },
624  "root": "root",
625  "version": 7
626}"#;
627        let parsed = FlakeLock::read_from_str(lock).unwrap();
628        let nested = parsed.nested_inputs();
629        assert_eq!(nested.len(), 1);
630        assert_eq!(nested[0].path, "\"hls-1.10\".nixpkgs");
631    }
632
633    #[test]
634    fn rev_for_quoted_sub_input_path() {
635        // Quoted input names with dots like "hls-1.10".nixpkgs should resolve
636        let lock = r#"{
637  "nodes": {
638    "hls-1.10": {
639      "inputs": { "nixpkgs": "nixpkgs_2" },
640      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
641      "original": { "owner": "o", "repo": "r", "type": "github" }
642    },
643    "nixpkgs": {
644      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
645      "original": { "owner": "o", "repo": "r", "type": "github" }
646    },
647    "nixpkgs_2": {
648      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "def", "type": "github" },
649      "original": { "owner": "o", "repo": "r", "type": "github" }
650    },
651    "root": {
652      "inputs": { "hls-1.10": "hls-1.10", "nixpkgs": "nixpkgs" }
653    }
654  },
655  "root": "root",
656  "version": 7
657}"#;
658        let parsed = FlakeLock::read_from_str(lock).unwrap();
659        assert_eq!(
660            "def",
661            parsed
662                .rev_for("\"hls-1.10\".nixpkgs")
663                .expect("Should resolve quoted sub-input path")
664        );
665    }
666
667    #[test]
668    fn split_input_path_simple() {
669        assert_eq!(FlakeLock::split_input_path("nixpkgs"), vec!["nixpkgs"]);
670    }
671
672    #[test]
673    fn split_input_path_dotted() {
674        assert_eq!(
675            FlakeLock::split_input_path("treefmt-nix.nixpkgs"),
676            vec!["treefmt-nix", "nixpkgs"]
677        );
678    }
679
680    #[test]
681    fn split_input_path_quoted() {
682        assert_eq!(
683            FlakeLock::split_input_path("\"hls-1.10\".nixpkgs"),
684            vec!["hls-1.10", "nixpkgs"]
685        );
686    }
687}