a linter for your flake.lock file
at main 229 lines 6.4 kB view raw
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}