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#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct NestedInput {
11 pub path: String,
13 pub follows: Option<String>,
15 pub url: Option<String>,
17}
18
19impl NestedInput {
20 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 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 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 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 if let Some(end) = rest[1..].find('"') {
151 segments.push(&rest[1..end + 1]);
152 rest = &rest[end + 2..];
153 rest = rest.strip_prefix('.').unwrap_or(rest);
155 } else {
156 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 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 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 return Ok(node_name);
211 }
212 }
213
214 Err(FlakeEditError::LockError("Empty input path.".into()))
215 }
216
217 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 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 pub fn nested_inputs(&self) -> Vec<NestedInput> {
238 let mut inputs = Vec::new();
239
240 let Some(root_node) = self.nodes.get(&self.root) else {
242 return inputs;
243 };
244
245 let Some(root_inputs) = &root_node.inputs else {
247 return inputs;
248 };
249
250 for (top_level_name, top_level_ref) in root_inputs {
252 let node_name = match top_level_ref {
254 Input::Direct(name) => name.clone(),
255 Input::Indirect(_) => {
256 continue;
258 }
259 };
260
261 if let Some(node) = self.nodes.get(&node_name) {
263 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 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 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 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 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 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 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 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 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 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}