1use crate::error::NixError;
7use crate::Result;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11use std::path::Path;
12use std::process::Command;
13use tracing::{debug, info, warn};
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct NixHash {
18 pub hash: String,
20 pub source: HashSource,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum HashSource {
27 FlakeLock,
29 FlakeNix,
31 Metadata,
33 Directory,
35}
36
37impl std::fmt::Display for NixHash {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 write!(f, "{}", self.hash)
40 }
41}
42
43impl NixHash {
44 pub fn new(hash: String, source: HashSource) -> Self {
46 NixHash { hash, source }
47 }
48
49 pub fn short(&self) -> String {
51 self.hash.chars().take(12).collect()
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct FlakeMetadata {
58 pub description: Option<String>,
60 #[serde(rename = "lastModified")]
62 pub last_modified: Option<u64>,
63 pub locks: Option<FlakeLocks>,
65 pub original_url: Option<String>,
67 pub resolved_url: Option<String>,
69 pub revision: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct FlakeLocks {
76 pub version: u32,
78 pub root: String,
80 pub nodes: HashMap<String, FlakeLockNode>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct FlakeLockNode {
87 pub inputs: Option<HashMap<String, serde_json::Value>>,
89 pub locked: Option<LockedRef>,
91 pub original: Option<serde_json::Value>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct LockedRef {
98 pub owner: Option<String>,
100 pub repo: Option<String>,
102 pub rev: Option<String>,
104 #[serde(rename = "type")]
106 pub ref_type: Option<String>,
107 #[serde(rename = "narHash")]
109 pub nar_hash: Option<String>,
110 #[serde(rename = "lastModified")]
112 pub last_modified: Option<u64>,
113}
114
115pub fn generate_environment_hash(flake_path: &Path) -> Result<NixHash> {
125 info!("Generating environment hash for {:?}", flake_path);
126
127 let lock_path = flake_path.join("flake.lock");
129 if lock_path.exists() {
130 debug!("Found flake.lock, using for hash");
131 return hash_flake_lock(&lock_path);
132 }
133
134 let flake_nix = flake_path.join("flake.nix");
136 if flake_nix.exists() {
137 debug!("No flake.lock, trying nix flake metadata");
138 if let Ok(hash) = hash_from_nix_metadata(flake_path) {
139 return Ok(hash);
140 }
141
142 warn!("nix flake metadata failed, hashing flake.nix directly");
144 return hash_flake_nix(&flake_nix);
145 }
146
147 warn!("No flake found, hashing directory contents");
149 hash_directory(flake_path)
150}
151
152fn hash_flake_lock(lock_path: &Path) -> Result<NixHash> {
154 let content = std::fs::read(lock_path)?;
155
156 let locks: FlakeLocks =
158 serde_json::from_slice(&content).map_err(|e| NixError::InvalidFlakeLock(e.to_string()))?;
159
160 let normalized = serde_json::to_vec(&locks)?;
162
163 let mut hasher = Sha256::new();
164 hasher.update(&normalized);
165 let hash = hex::encode(hasher.finalize());
166
167 debug!("Flake.lock hash: {}", &hash[..12]);
168 Ok(NixHash::new(hash, HashSource::FlakeLock))
169}
170
171fn hash_from_nix_metadata(flake_path: &Path) -> Result<NixHash> {
173 let output = Command::new("nix")
174 .args(["flake", "metadata", "--json", "--no-update-lock-file"])
175 .current_dir(flake_path)
176 .output()?;
177
178 if !output.status.success() {
179 let stderr = String::from_utf8_lossy(&output.stderr);
180 return Err(NixError::NixCommandFailed(stderr.to_string()));
181 }
182
183 let mut hasher = Sha256::new();
184 hasher.update(&output.stdout);
185 let hash = hex::encode(hasher.finalize());
186
187 debug!("Metadata hash: {}", &hash[..12]);
188 Ok(NixHash::new(hash, HashSource::Metadata))
189}
190
191fn hash_flake_nix(flake_nix: &Path) -> Result<NixHash> {
193 let content = std::fs::read(flake_nix)?;
194
195 let mut hasher = Sha256::new();
196 hasher.update(&content);
197 let hash = hex::encode(hasher.finalize());
198
199 debug!("Flake.nix hash: {}", &hash[..12]);
200 Ok(NixHash::new(hash, HashSource::FlakeNix))
201}
202
203fn hash_directory(dir: &Path) -> Result<NixHash> {
205 let mut hasher = Sha256::new();
206 hash_directory_recursive(dir, &mut hasher)?;
207 let hash = hex::encode(hasher.finalize());
208
209 debug!("Directory hash: {}", &hash[..12]);
210 Ok(NixHash::new(hash, HashSource::Directory))
211}
212
213fn hash_directory_recursive(dir: &Path, hasher: &mut Sha256) -> Result<()> {
215 if !dir.is_dir() {
216 return Ok(());
217 }
218
219 let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
220
221 entries.sort_by_key(|e| e.path());
223
224 for entry in entries {
225 let path = entry.path();
226 let name = path.file_name().unwrap_or_default().to_string_lossy();
227
228 if name.starts_with('.') || name == "target" || name == "node_modules" {
230 continue;
231 }
232
233 hasher.update(name.as_bytes());
235 hasher.update(b"\0");
236
237 if path.is_file() {
238 let content = std::fs::read(&path)?;
239 hasher.update(&content);
240 hasher.update(b"\0");
241 } else if path.is_dir() {
242 hash_directory_recursive(&path, hasher)?;
243 }
244 }
245
246 Ok(())
247}
248
249pub fn get_flake_metadata(flake_path: &Path) -> Result<FlakeMetadata> {
251 let output = Command::new("nix")
252 .args(["flake", "metadata", "--json", "--no-update-lock-file"])
253 .current_dir(flake_path)
254 .output()?;
255
256 if !output.status.success() {
257 let stderr = String::from_utf8_lossy(&output.stderr);
258 return Err(NixError::NixCommandFailed(stderr.to_string()));
259 }
260
261 let metadata: FlakeMetadata = serde_json::from_slice(&output.stdout)?;
262 Ok(metadata)
263}
264
265#[allow(dead_code)]
267pub fn lock_flake(flake_path: &Path) -> Result<()> {
268 let output = Command::new("nix")
269 .args(["flake", "lock"])
270 .current_dir(flake_path)
271 .output()?;
272
273 if !output.status.success() {
274 let stderr = String::from_utf8_lossy(&output.stderr);
275 return Err(NixError::NixCommandFailed(stderr.to_string()));
276 }
277
278 Ok(())
279}
280
281#[allow(dead_code)]
283pub fn update_flake(flake_path: &Path) -> Result<NixHash> {
284 let output = Command::new("nix")
285 .args(["flake", "update"])
286 .current_dir(flake_path)
287 .output()?;
288
289 if !output.status.success() {
290 let stderr = String::from_utf8_lossy(&output.stderr);
291 return Err(NixError::NixCommandFailed(stderr.to_string()));
292 }
293
294 generate_environment_hash(flake_path)
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use tempfile::tempdir;
302
303 #[test]
304 fn test_nix_hash_display() {
305 let hash = NixHash::new("abc123def456".to_string(), HashSource::FlakeLock);
306 assert_eq!(format!("{}", hash), "abc123def456");
307 }
308
309 #[test]
310 fn test_nix_hash_short() {
311 let hash = NixHash::new(
312 "abc123def456789012345678901234567890123456789012345678901234".to_string(),
313 HashSource::FlakeLock,
314 );
315 assert_eq!(hash.short(), "abc123def456");
316 }
317
318 #[test]
319 fn test_hash_flake_lock() {
320 let dir = tempdir().unwrap();
321 let lock_path = dir.path().join("flake.lock");
322
323 let lock_content = r#"{
324 "version": 7,
325 "root": "root",
326 "nodes": {
327 "root": {
328 "inputs": {}
329 },
330 "nixpkgs": {
331 "locked": {
332 "type": "github",
333 "owner": "NixOS",
334 "repo": "nixpkgs",
335 "rev": "abc123"
336 }
337 }
338 }
339 }"#;
340
341 std::fs::write(&lock_path, lock_content).unwrap();
342
343 let hash = hash_flake_lock(&lock_path).unwrap();
344 assert!(!hash.hash.is_empty());
345 assert_eq!(hash.source, HashSource::FlakeLock);
346 }
347
348 #[test]
349 fn test_changing_flake_input_changes_hash() {
350 let dir = tempdir().unwrap();
351 let lock_path = dir.path().join("flake.lock");
352
353 let lock_v1 = r#"{"version": 7, "root": "root", "nodes": {"root": {"inputs": {}}, "nixpkgs": {"locked": {"rev": "v1"}}}}"#;
355 std::fs::write(&lock_path, lock_v1).unwrap();
356 let hash1 = hash_flake_lock(&lock_path).unwrap();
357
358 let lock_v2 = r#"{"version": 7, "root": "root", "nodes": {"root": {"inputs": {}}, "nixpkgs": {"locked": {"rev": "v2"}}}}"#;
360 std::fs::write(&lock_path, lock_v2).unwrap();
361 let hash2 = hash_flake_lock(&lock_path).unwrap();
362
363 assert_ne!(
364 hash1.hash, hash2.hash,
365 "Different inputs should produce different hashes"
366 );
367 }
368
369 #[test]
370 fn test_hash_flake_nix() {
371 let dir = tempdir().unwrap();
372 let flake_nix = dir.path().join("flake.nix");
373
374 std::fs::write(&flake_nix, r#"{ outputs = { self }: {}; }"#).unwrap();
375
376 let hash = hash_flake_nix(&flake_nix).unwrap();
377 assert!(!hash.hash.is_empty());
378 assert_eq!(hash.source, HashSource::FlakeNix);
379 }
380
381 #[test]
382 fn test_hash_directory() {
383 let dir = tempdir().unwrap();
384
385 std::fs::write(dir.path().join("file1.txt"), "content1").unwrap();
387 std::fs::write(dir.path().join("file2.txt"), "content2").unwrap();
388
389 let hash = hash_directory(dir.path()).unwrap();
390 assert!(!hash.hash.is_empty());
391 assert_eq!(hash.source, HashSource::Directory);
392 }
393
394 #[test]
395 fn test_hash_directory_deterministic() {
396 let dir = tempdir().unwrap();
397
398 std::fs::write(dir.path().join("a.txt"), "aaa").unwrap();
399 std::fs::write(dir.path().join("b.txt"), "bbb").unwrap();
400
401 let hash1 = hash_directory(dir.path()).unwrap();
402 let hash2 = hash_directory(dir.path()).unwrap();
403
404 assert_eq!(hash1.hash, hash2.hash);
405 }
406
407 #[test]
408 fn test_generate_environment_hash_prefers_lock() {
409 let dir = tempdir().unwrap();
410
411 std::fs::write(dir.path().join("flake.nix"), "{ }").unwrap();
413 std::fs::write(
414 dir.path().join("flake.lock"),
415 r#"{"version": 7, "root": "root", "nodes": {"root": {}}}"#,
416 )
417 .unwrap();
418
419 let hash = generate_environment_hash(dir.path()).unwrap();
420 assert_eq!(
421 hash.source,
422 HashSource::FlakeLock,
423 "Should prefer flake.lock when available"
424 );
425 }
426}