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}
16
17impl NestedInput {
18    /// Format for display: "path\tfollows_target" or just "path".
19    /// The tab separator allows the UI to parse and style the parts differently.
20    pub fn to_display_string(&self) -> String {
21        match &self.follows {
22            Some(target) => format!("{}\t{}", self.path, target),
23            None => self.path.clone(),
24        }
25    }
26}
27
28#[derive(Debug, Serialize, Deserialize)]
29pub struct FlakeLock {
30    nodes: HashMap<String, Node>,
31    root: String,
32    version: u8,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
36pub struct Node {
37    inputs: Option<HashMap<String, Input>>,
38    locked: Option<Locked>,
39    original: Option<Original>,
40}
41
42impl Node {
43    fn rev(&self) -> String {
44        self.locked.clone().unwrap().rev().to_string()
45    }
46}
47
48#[derive(Debug, Serialize, Deserialize, Clone)]
49#[serde(untagged)]
50pub enum Input {
51    Direct(String),
52    Indirect(Vec<String>),
53}
54
55impl Input {
56    /// Get the target node name for this input.
57    /// For Direct inputs, returns the node name directly.
58    /// For Indirect inputs (follows paths), returns the final target in the path.
59    fn id(&self) -> String {
60        match self {
61            Input::Direct(id) => id.to_string(),
62            Input::Indirect(path) => path.last().cloned().unwrap_or_default(),
63        }
64    }
65}
66
67#[derive(Debug, Serialize, Deserialize, Clone)]
68pub struct Locked {
69    owner: Option<String>,
70    repo: Option<String>,
71    rev: Option<String>,
72    #[serde(rename = "type")]
73    node_type: String,
74    #[serde(rename = "ref")]
75    ref_field: Option<String>,
76}
77
78impl Locked {
79    fn rev(&self) -> String {
80        self.rev.clone().unwrap()
81    }
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85pub struct Original {
86    owner: Option<String>,
87    repo: Option<String>,
88    #[serde(rename = "type")]
89    node_type: String,
90    #[serde(rename = "ref")]
91    ref_field: Option<String>,
92    url: Option<String>,
93}
94
95impl FlakeLock {
96    const LOCK: &'static str = "flake.lock";
97
98    pub fn from_default_path() -> Result<Self, FlakeEditError> {
99        let path = PathBuf::from(Self::LOCK);
100        Self::from_file(path)
101    }
102
103    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, FlakeEditError> {
104        let mut file = File::open(path)?;
105        let mut contents = String::new();
106        file.read_to_string(&mut contents)?;
107        Self::read_from_str(&contents)
108    }
109    pub fn read_from_str(str: &str) -> Result<Self, FlakeEditError> {
110        Ok(serde_json::from_str(str)?)
111    }
112    pub fn root(&self) -> &str {
113        &self.root
114    }
115    /// Query the lock file for a specific rev.
116    pub fn rev_for(&self, id: &str) -> Result<String, FlakeEditError> {
117        let root = self.root();
118        let resolved_root = self
119            .nodes
120            .get(root)
121            .ok_or(FlakeEditError::LockMissingRoot)?;
122        let binding = resolved_root
123            .inputs
124            .clone()
125            .ok_or_else(|| FlakeEditError::LockError("Could not resolve root.".into()))?;
126        let resolved_id = binding
127            .get(id)
128            .ok_or_else(|| FlakeEditError::LockError("Could not resolve id.".into()))?;
129        let id = resolved_id.id();
130        let node = self
131            .nodes
132            .get(&id)
133            .ok_or_else(|| FlakeEditError::LockError("Could not find node with id.".into()))?;
134        Ok(node.rev())
135    }
136
137    /// Get all nested input paths for shell completions.
138    /// Returns paths like "naersk.nixpkgs", "naersk.flake-utils", etc.
139    pub fn nested_input_paths(&self) -> Vec<String> {
140        self.nested_inputs()
141            .into_iter()
142            .map(|input| input.path)
143            .collect()
144    }
145
146    /// Get all nested inputs with their existing follows targets.
147    pub fn nested_inputs(&self) -> Vec<NestedInput> {
148        let mut inputs = Vec::new();
149
150        // Get the root node
151        let Some(root_node) = self.nodes.get(&self.root) else {
152            return inputs;
153        };
154
155        // Get top-level inputs from root
156        let Some(root_inputs) = &root_node.inputs else {
157            return inputs;
158        };
159
160        // For each top-level input, find its nested inputs
161        for (top_level_name, top_level_ref) in root_inputs {
162            // Resolve the node name (could be different from input name)
163            let node_name = match top_level_ref {
164                Input::Direct(name) => name.clone(),
165                Input::Indirect(_) => {
166                    // For indirect inputs (follows), skip - they don't have their own inputs
167                    continue;
168                }
169            };
170
171            // Get the node for this input
172            if let Some(node) = self.nodes.get(&node_name) {
173                // Get nested inputs of this node
174                if let Some(nested_inputs) = &node.inputs {
175                    for (nested_name, nested_ref) in nested_inputs {
176                        let path = format!("{}.{}", top_level_name, nested_name);
177                        let follows = match nested_ref {
178                            Input::Indirect(targets) => Some(targets.join(".")),
179                            Input::Direct(_) => None,
180                        };
181                        inputs.push(NestedInput { path, follows });
182                    }
183                }
184            }
185        }
186
187        inputs.sort_by(|a, b| a.path.cmp(&b.path));
188        inputs
189    }
190}
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn minimal_lock() -> &'static str {
196        r#"
197    {
198  "nodes": {
199    "nixpkgs": {
200      "locked": {
201        "lastModified": 1718714799,
202        "narHash": "sha256-FUZpz9rg3gL8NVPKbqU8ei1VkPLsTIfAJ2fdAf5qjak=",
203        "owner": "nixos",
204        "repo": "nixpkgs",
205        "rev": "c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
206        "type": "github"
207      },
208      "original": {
209        "owner": "nixos",
210        "ref": "nixos-unstable",
211        "repo": "nixpkgs",
212        "type": "github"
213      }
214    },
215    "root": {
216      "inputs": {
217        "nixpkgs": "nixpkgs"
218      }
219    }
220  },
221  "root": "root",
222  "version": 7
223}
224    "#
225    }
226    fn minimal_independent_lock_no_overrides() -> &'static str {
227        r#"
228    {
229  "nodes": {
230    "nixpkgs": {
231      "locked": {
232        "lastModified": 1721138476,
233        "narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
234        "owner": "nixos",
235        "repo": "nixpkgs",
236        "rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
237        "type": "github"
238      },
239      "original": {
240        "owner": "nixos",
241        "ref": "nixos-unstable",
242        "repo": "nixpkgs",
243        "type": "github"
244      }
245    },
246    "nixpkgs_2": {
247      "locked": {
248        "lastModified": 1719690277,
249        "narHash": "sha256-0xSej1g7eP2kaUF+JQp8jdyNmpmCJKRpO12mKl/36Kc=",
250        "owner": "nixos",
251        "repo": "nixpkgs",
252        "rev": "2741b4b489b55df32afac57bc4bfd220e8bf617e",
253        "type": "github"
254      },
255      "original": {
256        "owner": "nixos",
257        "ref": "nixos-unstable",
258        "repo": "nixpkgs",
259        "type": "github"
260      }
261    },
262    "root": {
263      "inputs": {
264        "nixpkgs": "nixpkgs",
265        "treefmt-nix": "treefmt-nix"
266      }
267    },
268    "treefmt-nix": {
269      "inputs": {
270        "nixpkgs": "nixpkgs_2"
271      },
272      "locked": {
273        "lastModified": 1721382922,
274        "narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
275        "owner": "numtide",
276        "repo": "treefmt-nix",
277        "rev": "50104496fb55c9140501ea80d183f3223d13ff65",
278        "type": "github"
279      },
280      "original": {
281        "owner": "numtide",
282        "repo": "treefmt-nix",
283        "type": "github"
284      }
285    }
286  },
287  "root": "root",
288  "version": 7
289}
290    "#
291    }
292
293    fn minimal_independent_lock_nixpkgs_overridden() -> &'static str {
294        r#"
295    {
296  "nodes": {
297    "nixpkgs": {
298      "locked": {
299        "lastModified": 1721138476,
300        "narHash": "sha256-+W5eZOhhemLQxelojLxETfbFbc19NWawsXBlapYpqIA=",
301        "owner": "nixos",
302        "repo": "nixpkgs",
303        "rev": "ad0b5eed1b6031efaed382844806550c3dcb4206",
304        "type": "github"
305      },
306      "original": {
307        "owner": "nixos",
308        "ref": "nixos-unstable",
309        "repo": "nixpkgs",
310        "type": "github"
311      }
312    },
313    "root": {
314      "inputs": {
315        "nixpkgs": "nixpkgs",
316        "treefmt-nix": "treefmt-nix"
317      }
318    },
319    "treefmt-nix": {
320      "inputs": {
321        "nixpkgs": [
322          "nixpkgs"
323        ]
324      },
325      "locked": {
326        "lastModified": 1721382922,
327        "narHash": "sha256-GYpibTC0YYKRpFR9aftym9jjRdUk67ejw1IWiaQkaiU=",
328        "owner": "numtide",
329        "repo": "treefmt-nix",
330        "rev": "50104496fb55c9140501ea80d183f3223d13ff65",
331        "type": "github"
332      },
333      "original": {
334        "owner": "numtide",
335        "repo": "treefmt-nix",
336        "type": "github"
337      }
338    }
339  },
340  "root": "root",
341  "version": 7
342}
343    "#
344    }
345
346    #[test]
347    fn parse_minimal() {
348        let minimal_lock = minimal_lock();
349        FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
350    }
351    #[test]
352    fn parse_minimal_version() {
353        let minimal_lock = minimal_lock();
354        let parsed_lock =
355            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
356        assert_eq!(7, parsed_lock.version);
357    }
358    #[test]
359    fn parse_minimal_root() {
360        let minimal_lock = minimal_lock();
361        let parsed_lock =
362            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
363        assert_eq!("root", parsed_lock.root);
364    }
365    #[test]
366    fn minimal_ref() {
367        let minimal_lock = minimal_lock();
368        let parsed_lock =
369            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
370        assert_eq!(
371            "c00d587b1a1afbf200b1d8f0b0e4ba9deb1c7f0e",
372            parsed_lock
373                .rev_for("nixpkgs")
374                .expect("Id: nixpkgs is in the lockfile.")
375        );
376    }
377    #[test]
378    fn parse_minimal_independent_lock_no_overrides() {
379        let minimal_lock = minimal_independent_lock_no_overrides();
380        FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
381    }
382    #[test]
383    fn minimal_independent_lock_no_overrides_ref() {
384        let minimal_lock = minimal_independent_lock_no_overrides();
385        let parsed_lock =
386            FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
387        assert_eq!(
388            "ad0b5eed1b6031efaed382844806550c3dcb4206",
389            parsed_lock
390                .rev_for("nixpkgs")
391                .expect("Id: nixpkgs is in the lockfile.")
392        );
393    }
394    #[test]
395    fn parse_minimal_independent_lock_nixpkgs_overridden() {
396        let minimal_lock = minimal_independent_lock_nixpkgs_overridden();
397        FlakeLock::read_from_str(minimal_lock).expect("Should be parsed correctly.");
398    }
399
400    #[test]
401    fn input_indirect_id() {
402        // Follows path like ["nixpkgs"] should return "nixpkgs"
403        let input = Input::Indirect(vec!["nixpkgs".to_string()]);
404        assert_eq!("nixpkgs", input.id());
405    }
406}