use crate::error::NixError;
use crate::Result;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NixHash {
pub hash: String,
pub source: HashSource,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HashSource {
FlakeLock,
FlakeNix,
Metadata,
Directory,
}
impl std::fmt::Display for NixHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.hash)
}
}
impl NixHash {
pub fn new(hash: String, source: HashSource) -> Self {
NixHash { hash, source }
}
pub fn short(&self) -> &str {
&self.hash[..12.min(self.hash.len())]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlakeMetadata {
pub description: Option<String>,
#[serde(rename = "lastModified")]
pub last_modified: Option<u64>,
pub locks: Option<FlakeLocks>,
pub original_url: Option<String>,
pub resolved_url: Option<String>,
pub revision: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlakeLocks {
pub version: u32,
pub root: String,
pub nodes: HashMap<String, FlakeLockNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlakeLockNode {
pub inputs: Option<HashMap<String, serde_json::Value>>,
pub locked: Option<LockedRef>,
pub original: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedRef {
pub owner: Option<String>,
pub repo: Option<String>,
pub rev: Option<String>,
#[serde(rename = "type")]
pub ref_type: Option<String>,
#[serde(rename = "narHash")]
pub nar_hash: Option<String>,
#[serde(rename = "lastModified")]
pub last_modified: Option<u64>,
}
pub fn generate_environment_hash(flake_path: &Path) -> Result<NixHash> {
info!("Generating environment hash for {:?}", flake_path);
let lock_path = flake_path.join("flake.lock");
if lock_path.exists() {
debug!("Found flake.lock, using for hash");
return hash_flake_lock(&lock_path);
}
let flake_nix = flake_path.join("flake.nix");
if flake_nix.exists() {
debug!("No flake.lock, trying nix flake metadata");
if let Ok(hash) = hash_from_nix_metadata(flake_path) {
return Ok(hash);
}
warn!("nix flake metadata failed, hashing flake.nix directly");
return hash_flake_nix(&flake_nix);
}
warn!("No flake found, hashing directory contents");
hash_directory(flake_path)
}
fn hash_flake_lock(lock_path: &Path) -> Result<NixHash> {
let content = std::fs::read(lock_path)?;
let locks: FlakeLocks =
serde_json::from_slice(&content).map_err(|e| NixError::InvalidFlakeLock(e.to_string()))?;
let normalized = serde_json::to_vec(&locks)?;
let mut hasher = Sha256::new();
hasher.update(&normalized);
let hash = hex::encode(hasher.finalize());
debug!("Flake.lock hash: {}", &hash[..12]);
Ok(NixHash::new(hash, HashSource::FlakeLock))
}
fn hash_from_nix_metadata(flake_path: &Path) -> Result<NixHash> {
let output = Command::new("nix")
.args(["flake", "metadata", "--json", "--no-update-lock-file"])
.current_dir(flake_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(NixError::NixCommandFailed(stderr.to_string()));
}
let mut hasher = Sha256::new();
hasher.update(&output.stdout);
let hash = hex::encode(hasher.finalize());
debug!("Metadata hash: {}", &hash[..12]);
Ok(NixHash::new(hash, HashSource::Metadata))
}
fn hash_flake_nix(flake_nix: &Path) -> Result<NixHash> {
let content = std::fs::read(flake_nix)?;
let mut hasher = Sha256::new();
hasher.update(&content);
let hash = hex::encode(hasher.finalize());
debug!("Flake.nix hash: {}", &hash[..12]);
Ok(NixHash::new(hash, HashSource::FlakeNix))
}
fn hash_directory(dir: &Path) -> Result<NixHash> {
let mut hasher = Sha256::new();
hash_directory_recursive(dir, &mut hasher)?;
let hash = hex::encode(hasher.finalize());
debug!("Directory hash: {}", &hash[..12]);
Ok(NixHash::new(hash, HashSource::Directory))
}
fn hash_directory_recursive(dir: &Path, hasher: &mut Sha256) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.path());
for entry in entries {
let path = entry.path();
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') || name == "target" || name == "node_modules" {
continue;
}
hasher.update(name.as_bytes());
hasher.update(b"\0");
if path.is_file() {
let content = std::fs::read(&path)?;
hasher.update(&content);
hasher.update(b"\0");
} else if path.is_dir() {
hash_directory_recursive(&path, hasher)?;
}
}
Ok(())
}
pub fn get_flake_metadata(flake_path: &Path) -> Result<FlakeMetadata> {
let output = Command::new("nix")
.args(["flake", "metadata", "--json", "--no-update-lock-file"])
.current_dir(flake_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(NixError::NixCommandFailed(stderr.to_string()));
}
let metadata: FlakeMetadata = serde_json::from_slice(&output.stdout)?;
Ok(metadata)
}
#[allow(dead_code)]
pub fn lock_flake(flake_path: &Path) -> Result<()> {
let output = Command::new("nix")
.args(["flake", "lock"])
.current_dir(flake_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(NixError::NixCommandFailed(stderr.to_string()));
}
Ok(())
}
#[allow(dead_code)]
pub fn update_flake(flake_path: &Path) -> Result<NixHash> {
let output = Command::new("nix")
.args(["flake", "update"])
.current_dir(flake_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(NixError::NixCommandFailed(stderr.to_string()));
}
generate_environment_hash(flake_path)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_nix_hash_display() {
let hash = NixHash::new("abc123def456".to_string(), HashSource::FlakeLock);
assert_eq!(format!("{}", hash), "abc123def456");
}
#[test]
fn test_nix_hash_short() {
let hash = NixHash::new(
"abc123def456789012345678901234567890123456789012345678901234".to_string(),
HashSource::FlakeLock,
);
assert_eq!(hash.short(), "abc123def456");
}
#[test]
fn test_hash_flake_lock() {
let dir = tempdir().unwrap();
let lock_path = dir.path().join("flake.lock");
let lock_content = r#"{
"version": 7,
"root": "root",
"nodes": {
"root": {
"inputs": {}
},
"nixpkgs": {
"locked": {
"type": "github",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "abc123"
}
}
}
}"#;
std::fs::write(&lock_path, lock_content).unwrap();
let hash = hash_flake_lock(&lock_path).unwrap();
assert!(!hash.hash.is_empty());
assert_eq!(hash.source, HashSource::FlakeLock);
}
#[test]
fn test_changing_flake_input_changes_hash() {
let dir = tempdir().unwrap();
let lock_path = dir.path().join("flake.lock");
let lock_v1 = r#"{"version": 7, "root": "root", "nodes": {"root": {"inputs": {}}, "nixpkgs": {"locked": {"rev": "v1"}}}}"#;
std::fs::write(&lock_path, lock_v1).unwrap();
let hash1 = hash_flake_lock(&lock_path).unwrap();
let lock_v2 = r#"{"version": 7, "root": "root", "nodes": {"root": {"inputs": {}}, "nixpkgs": {"locked": {"rev": "v2"}}}}"#;
std::fs::write(&lock_path, lock_v2).unwrap();
let hash2 = hash_flake_lock(&lock_path).unwrap();
assert_ne!(
hash1.hash, hash2.hash,
"Different inputs should produce different hashes"
);
}
#[test]
fn test_hash_flake_nix() {
let dir = tempdir().unwrap();
let flake_nix = dir.path().join("flake.nix");
std::fs::write(&flake_nix, r#"{ outputs = { self }: {}; }"#).unwrap();
let hash = hash_flake_nix(&flake_nix).unwrap();
assert!(!hash.hash.is_empty());
assert_eq!(hash.source, HashSource::FlakeNix);
}
#[test]
fn test_hash_directory() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("file1.txt"), "content1").unwrap();
std::fs::write(dir.path().join("file2.txt"), "content2").unwrap();
let hash = hash_directory(dir.path()).unwrap();
assert!(!hash.hash.is_empty());
assert_eq!(hash.source, HashSource::Directory);
}
#[test]
fn test_hash_directory_deterministic() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "aaa").unwrap();
std::fs::write(dir.path().join("b.txt"), "bbb").unwrap();
let hash1 = hash_directory(dir.path()).unwrap();
let hash2 = hash_directory(dir.path()).unwrap();
assert_eq!(hash1.hash, hash2.hash);
}
#[test]
fn test_generate_environment_hash_prefers_lock() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("flake.nix"), "{ }").unwrap();
std::fs::write(
dir.path().join("flake.lock"),
r#"{"version": 7, "root": "root", "nodes": {"root": {}}}"#,
)
.unwrap();
let hash = generate_environment_hash(dir.path()).unwrap();
assert_eq!(
hash.source,
HashSource::FlakeLock,
"Should prefer flake.lock when available"
);
}
}