a linter for your flake.lock file
1use argh::FromArgs;
2use serde::Deserialize;
3use std::error::Error;
4use std::fs;
5use std::{collections::HashMap, path::PathBuf};
6
7/// locker - a tool to lint your flake.lock file
8#[derive(FromArgs)]
9#[argh(help_triggers("-h", "--help"))]
10struct Args {
11 #[argh(positional, default = "PathBuf::from(\"flake.lock\")")]
12 flake_lock: PathBuf,
13}
14
15#[derive(Deserialize, Debug)]
16struct FlakeLock {
17 nodes: HashMap<String, Node>,
18 version: usize,
19
20 #[allow(dead_code)]
21 root: String,
22}
23
24#[derive(Deserialize, Debug)]
25struct Node {
26 locked: Option<Locked>,
27}
28
29#[derive(Deserialize, Debug, Eq, PartialEq)]
30#[serde(tag = "type", rename_all = "lowercase")]
31enum Locked {
32 // scm
33 GitHub { owner: String, repo: String },
34 GitLab { owner: String, repo: String },
35 SourceHut { owner: String, repo: String },
36
37 // url
38 Git { url: String },
39 Hg { url: String },
40 Tarball { url: String },
41
42 // path
43 Path { path: String },
44}
45
46fn main() -> Result<(), Box<dyn Error>> {
47 let args: Args = argh::from_env();
48 let flake_lock_content = fs::read_to_string(&args.flake_lock)?;
49 let flake_lock: FlakeLock = serde_json::from_str(&flake_lock_content)?;
50
51 if flake_lock.version != 7 {
52 eprintln!("Unsupported flake.lock version: {}", flake_lock.version);
53 std::process::exit(1);
54 }
55
56 let inputs = parse_inputs(flake_lock);
57 let duplicates = find_duplicates(inputs);
58
59 if duplicates.is_empty() {
60 println!("No duplicate inputs found.");
61 std::process::exit(0);
62 }
63
64 println!("The following flake uris contained duplicate entries in your flake.lock:");
65 for (input, dups) in duplicates {
66 eprintln!(" '{}': {}", input, dups.join(", "));
67 }
68
69 std::process::exit(1);
70}
71
72fn parse_inputs(flake_lock: FlakeLock) -> HashMap<String, String> {
73 let mut data = HashMap::new();
74
75 for (k, v) in flake_lock.nodes {
76 if v.locked.is_none() {
77 continue;
78 }
79
80 let val = flake_uri(v.locked.unwrap());
81 data.entry(k).insert_entry(val);
82 }
83
84 data
85}
86
87fn find_duplicates(inputs: HashMap<String, String>) -> HashMap<String, Vec<String>> {
88 let mut seen: Vec<String> = Vec::new();
89 let mut duplicates: HashMap<String, Vec<String>> = HashMap::new();
90
91 for (input_name, input_uri) in inputs {
92 if seen.contains(&input_uri) {
93 duplicates.entry(input_uri).or_default().push(input_name);
94 } else {
95 seen.push(input_uri);
96 }
97 }
98
99 duplicates
100}
101
102fn flake_uri(lock: Locked) -> String {
103 match lock {
104 Locked::GitHub { owner, repo } => make_scm_uri("github", &owner, &repo),
105 Locked::GitLab { owner, repo } => make_scm_uri("gitlab", &owner, &repo),
106 Locked::SourceHut { owner, repo } => make_scm_uri("sourcehut", &owner, &repo),
107 Locked::Git { url } => make_url_uri("git", &url),
108 Locked::Hg { url } => make_url_uri("hg", &url),
109 Locked::Tarball { url } => make_url_uri("tarball", &url),
110 Locked::Path { path } => format!("path:{path}"),
111 }
112}
113
114fn make_scm_uri(node_type: &str, owner: &str, repo: &str) -> String {
115 format!(
116 "{node_type}:{}/{}",
117 owner.to_lowercase(),
118 repo.to_lowercase()
119 )
120}
121
122fn make_url_uri(node_type: &str, url: &str) -> String {
123 format!("{node_type}:{url}")
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 const FLAKE_LOCK: &str = r#"
131 {
132 "nodes": {
133 "input1": {
134 "locked": {
135 "type": "github",
136 "owner": "user1",
137 "repo": "repo1"
138 }
139 },
140 "input2": {
141 "locked": {
142 "type": "github",
143 "owner": "user2",
144 "repo": "repo2"
145 }
146 },
147 "input3": {
148 "locked": {
149 "type": "github",
150 "owner": "user1",
151 "repo": "repo1"
152 }
153 },
154 "input4": {
155 "locked": {
156 "type": "git",
157 "url": "https://example.com/repo.git"
158 }
159 },
160 "input5": {
161 "locked": {
162 "type": "git",
163 "url": "https://example.com/repo.git"
164 }
165 }
166 },
167 "version": 7,
168 "root": "."
169 }
170 "#;
171
172 #[test]
173 fn test_parse_inputs() {
174 let flake_lock: FlakeLock = serde_json::from_str(FLAKE_LOCK).unwrap();
175 let inputs = parse_inputs(flake_lock);
176
177 assert_eq!(inputs.len(), 5);
178 assert!(inputs.contains_key("input1"));
179 assert!(inputs.contains_key("input2"));
180 assert!(inputs.contains_key("input3"));
181 assert!(inputs.contains_key("input4"));
182 assert!(inputs.contains_key("input5"));
183
184 assert_eq!(inputs.get("input1").unwrap(), "github:user1/repo1");
185 assert_eq!(inputs.get("input2").unwrap(), "github:user2/repo2");
186 assert_eq!(inputs.get("input3").unwrap(), "github:user1/repo1");
187 assert_eq!(
188 inputs.get("input4").unwrap(),
189 "git:https://example.com/repo.git"
190 );
191 assert_eq!(
192 inputs.get("input5").unwrap(),
193 "git:https://example.com/repo.git"
194 );
195 }
196
197 #[test]
198 fn test_duplicates() {
199 let flake_lock: FlakeLock = serde_json::from_str(FLAKE_LOCK).unwrap();
200
201 let inputs = parse_inputs(flake_lock);
202 let duplicates = find_duplicates(inputs.clone());
203
204 assert_eq!(duplicates.len(), 2);
205 }
206
207 #[test]
208 fn test_duplicates_2() -> Result<(), Box<dyn Error>> {
209 let flake_lock_contents = fs::read_to_string("test/flake-lock.json")?;
210 let flake_lock: FlakeLock = serde_json::from_str(&flake_lock_contents)?;
211
212 let inputs = parse_inputs(flake_lock);
213 let duplicates = find_duplicates(inputs);
214
215 assert_eq!(duplicates.len(), 13);
216 assert!(duplicates.contains_key("github:nixos/nixpkgs"));
217 assert_eq!(duplicates.get("github:nixos/nixpkgs").unwrap().len(), 6);
218
219 assert_eq!(
220 duplicates
221 .get("tarball:https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.0.1/018afb31-abd1-7bff-a5e4-cff7e18efb7a/source.tar.gz")
222 .unwrap()
223 .len(),
224 1
225 );
226
227 Ok(())
228 }
229}