use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const CACHE_FILE: &str = "jonesy/cache.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TargetState {
path: PathBuf,
mtime: u128,
pub(crate) panic_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ConfigState {
path: PathBuf,
content_hash: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct WorkspaceState {
members: Vec<String>,
binaries: HashMap<String, PathBuf>,
libraries: HashMap<String, PathBuf>,
}
impl WorkspaceState {
pub fn is_single_package(&self) -> bool {
self.members.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct AnalysisCache {
version: u32,
pub(crate) targets: HashMap<PathBuf, TargetState>,
configs: HashMap<PathBuf, ConfigState>,
workspace: WorkspaceState,
}
impl AnalysisCache {
const VERSION: u32 = 1;
pub fn load(workspace_root: &Path) -> Self {
let cache_path = workspace_root.join("target").join(CACHE_FILE);
let cache = fs::read_to_string(&cache_path)
.ok()
.and_then(|content| serde_json::from_str::<AnalysisCache>(&content).ok())
.filter(|cache| cache.version == Self::VERSION)
.unwrap_or_default();
Self {
version: Self::VERSION,
..cache
}
}
pub fn save(&self, workspace_root: &Path) -> Result<(), String> {
let cache_dir = workspace_root.join("target/jonesy");
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
let cache_path = cache_dir.join("cache.json");
let content = serde_json::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize cache: {}", e))?;
fs::write(&cache_path, content).map_err(|e| format!("Failed to write cache: {}", e))?;
Ok(())
}
pub fn target_needs_analysis(&self, target_path: &Path) -> bool {
let Some(cached) = self.targets.get(target_path) else {
return true; };
let current_mtime = get_mtime(target_path).unwrap_or(0);
current_mtime != cached.mtime
}
pub fn update_target(&mut self, target_path: &Path, panic_count: usize) {
let mtime = get_mtime(target_path).unwrap_or(0);
self.targets.insert(
target_path.to_path_buf(),
TargetState {
path: target_path.to_path_buf(),
mtime,
panic_count,
},
);
}
pub fn config_changed(&self, config_path: &Path) -> bool {
let Some(cached) = self.configs.get(config_path) else {
return true; };
let current_hash = hash_file_content(config_path).unwrap_or(0);
current_hash != cached.content_hash
}
pub fn update_config_with_hash(&mut self, config_path: &Path, content_hash: u64) {
self.configs.insert(
config_path.to_path_buf(),
ConfigState {
path: config_path.to_path_buf(),
content_hash,
},
);
}
pub fn has_config(&self, config_path: &Path) -> bool {
self.configs.contains_key(config_path)
}
pub fn remove_config(&mut self, config_path: &Path) {
self.configs.remove(config_path);
}
pub fn detect_workspace_changes(&self, current: &WorkspaceState) -> WorkspaceChanges {
let mut changes = WorkspaceChanges::default();
for member in ¤t.members {
if !self.workspace.members.contains(member) {
changes.added_members.push(member.clone());
}
}
for member in &self.workspace.members {
if !current.members.contains(member) {
changes.removed_members.push(member.clone());
}
}
for (name, path) in ¤t.binaries {
match self.workspace.binaries.get(name) {
None => changes.added_binaries.push(name.clone()),
Some(old_path) if old_path != path => changes.changed_binaries.push(name.clone()),
_ => {}
}
}
for name in self.workspace.binaries.keys() {
if !current.binaries.contains_key(name) {
changes.removed_binaries.push(name.clone());
}
}
for (name, path) in ¤t.libraries {
match self.workspace.libraries.get(name) {
None => changes.added_libraries.push(name.clone()),
Some(old_path) if old_path != path => changes.changed_libraries.push(name.clone()),
_ => {}
}
}
for name in self.workspace.libraries.keys() {
if !current.libraries.contains_key(name) {
changes.removed_libraries.push(name.clone());
}
}
changes
}
pub fn update_workspace(&mut self, state: WorkspaceState) {
self.workspace = state;
}
pub fn prune_stale_targets(&mut self) {
self.targets.retain(|path, _| path.exists());
}
}
#[derive(Debug, Default)]
pub(crate) struct WorkspaceChanges {
added_members: Vec<String>,
removed_members: Vec<String>,
added_binaries: Vec<String>,
removed_binaries: Vec<String>,
changed_binaries: Vec<String>,
added_libraries: Vec<String>,
removed_libraries: Vec<String>,
changed_libraries: Vec<String>,
}
impl WorkspaceChanges {
pub fn has_changes(&self) -> bool {
!self.added_members.is_empty()
|| !self.removed_members.is_empty()
|| !self.added_binaries.is_empty()
|| !self.removed_binaries.is_empty()
|| !self.changed_binaries.is_empty()
|| !self.added_libraries.is_empty()
|| !self.removed_libraries.is_empty()
|| !self.changed_libraries.is_empty()
}
pub fn needs_full_reanalysis(&self) -> bool {
!self.added_members.is_empty() || !self.removed_members.is_empty()
}
pub fn change_counts(&self) -> (usize, usize, usize) {
(
self.added_members.len() + self.removed_members.len(),
self.added_binaries.len() + self.removed_binaries.len() + self.changed_binaries.len(),
self.added_libraries.len()
+ self.removed_libraries.len()
+ self.changed_libraries.len(),
)
}
pub fn affects_target(&self, target_path: &Path) -> bool {
let stem = target_path
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or_default();
let normalize = |s: &str| s.replace('-', "_");
let binary_name = normalize(stem);
let library_name = normalize(stem.strip_prefix("lib").unwrap_or(stem));
self.added_binaries
.iter()
.any(|n| normalize(n) == binary_name)
|| self
.changed_binaries
.iter()
.any(|n| normalize(n) == binary_name)
|| self
.added_libraries
.iter()
.any(|n| normalize(n) == library_name)
|| self
.changed_libraries
.iter()
.any(|n| normalize(n) == library_name)
}
}
fn get_mtime(path: &Path) -> Option<u128> {
fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_millis())
}
pub(crate) fn hash_file_content(path: &Path) -> Option<u64> {
let content = fs::read(path).ok()?;
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
Some(hasher.finish())
}
pub(crate) fn build_workspace_state(workspace_root: &Path) -> WorkspaceState {
let mut state = WorkspaceState::default();
let cargo_toml = workspace_root.join("Cargo.toml");
let Ok(content) = fs::read_to_string(&cargo_toml) else {
return state;
};
let Ok(manifest) = cargo_toml::Manifest::from_slice(content.as_bytes()) else {
return state;
};
if let Some(workspace) = &manifest.workspace {
for member in &workspace.members {
if member.contains('*') {
if let Ok(paths) = glob::glob(&workspace_root.join(member).to_string_lossy()) {
for path in paths.flatten() {
if path.is_dir() && path.join("Cargo.toml").exists() {
if let Ok(rel_path) = path.strip_prefix(workspace_root) {
state.members.push(rel_path.to_string_lossy().to_string());
}
}
}
}
} else {
state.members.push(member.clone());
}
}
}
collect_targets_from_manifest(&manifest, workspace_root, &mut state);
if let Some(workspace) = &manifest.workspace {
for member in &workspace.members {
let member_paths: Vec<PathBuf> = if member.contains('*') {
glob::glob(&workspace_root.join(member).to_string_lossy())
.ok()
.map(|paths| paths.flatten().collect())
.unwrap_or_default()
} else {
vec![workspace_root.join(member)]
};
for member_path in member_paths {
let member_cargo = member_path.join("Cargo.toml");
if let Ok(content) = fs::read_to_string(&member_cargo) {
if let Ok(member_manifest) =
cargo_toml::Manifest::from_slice(content.as_bytes())
{
collect_targets_from_manifest(&member_manifest, &member_path, &mut state);
}
}
}
}
}
state
}
fn collect_targets_from_manifest(
manifest: &cargo_toml::Manifest,
crate_root: &Path,
state: &mut WorkspaceState,
) {
let Some(pkg) = &manifest.package else {
return;
};
for bin in &manifest.bin {
let name = bin.name.as_deref().unwrap_or(&pkg.name);
let path = bin
.path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| crate_root.join("src/main.rs"));
state.binaries.insert(name.to_string(), path);
}
if crate_root.join("src/main.rs").exists() {
state
.binaries
.entry(pkg.name.clone())
.or_insert_with(|| crate_root.join("src/main.rs"));
}
let bin_dir = crate_root.join("src/bin");
if let Ok(entries) = fs::read_dir(&bin_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|e| e == "rs") {
if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
state.binaries.entry(name.to_string()).or_insert(path);
}
} else if path.is_dir() {
let main_rs = path.join("main.rs");
if main_rs.exists() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
state.binaries.entry(name.to_string()).or_insert(main_rs);
}
}
}
}
}
if let Some(lib) = &manifest.lib {
let name = lib.name.as_deref().unwrap_or(&pkg.name);
let path = lib
.path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| crate_root.join("src/lib.rs"));
state.libraries.insert(name.to_string(), path);
} else if crate_root.join("src/lib.rs").exists() {
state
.libraries
.insert(pkg.name.clone(), crate_root.join("src/lib.rs"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_workspace_changes_detection() {
let old = WorkspaceState {
members: vec!["crate_a".to_string(), "crate_b".to_string()],
binaries: [("app".to_string(), PathBuf::from("src/main.rs"))]
.into_iter()
.collect(),
libraries: [("mylib".to_string(), PathBuf::from("src/lib.rs"))]
.into_iter()
.collect(),
};
let cache = AnalysisCache {
workspace: old,
..Default::default()
};
let new = WorkspaceState {
members: vec![
"crate_a".to_string(),
"crate_c".to_string(), ],
binaries: [
("app".to_string(), PathBuf::from("src/bin/app.rs")), ("cli".to_string(), PathBuf::from("src/bin/cli.rs")), ]
.into_iter()
.collect(),
libraries: HashMap::new(), };
let changes = cache.detect_workspace_changes(&new);
assert_eq!(changes.added_members, vec!["crate_c"]);
assert_eq!(changes.removed_members, vec!["crate_b"]);
assert_eq!(changes.added_binaries, vec!["cli"]);
assert_eq!(changes.changed_binaries, vec!["app"]);
assert_eq!(changes.removed_libraries, vec!["mylib"]);
assert!(changes.has_changes());
assert!(changes.needs_full_reanalysis()); }
#[test]
fn test_is_single_package() {
let state = WorkspaceState::default();
assert!(state.is_single_package());
let state = WorkspaceState {
members: vec!["crate_a".to_string()],
..Default::default()
};
assert!(!state.is_single_package());
}
#[test]
fn test_no_changes() {
let state = WorkspaceState {
members: vec!["crate_a".to_string()],
binaries: [("app".to_string(), PathBuf::from("src/main.rs"))]
.into_iter()
.collect(),
libraries: HashMap::new(),
};
let cache = AnalysisCache {
workspace: state.clone(),
..Default::default()
};
let changes = cache.detect_workspace_changes(&state);
assert!(!changes.has_changes());
assert!(!changes.needs_full_reanalysis());
}
#[test]
fn test_hash_stability() {
let hash1 = {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in b"test content" {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
};
let hash2 = {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in b"test content" {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
};
assert_eq!(hash1, hash2);
let hash3 = {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in b"different content" {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
};
assert_ne!(hash1, hash3);
}
#[test]
fn test_target_needs_analysis_not_in_cache() {
let cache = AnalysisCache::default();
assert!(cache.target_needs_analysis(Path::new("/nonexistent/path")));
}
#[test]
fn test_update_target() {
let mut cache = AnalysisCache::default();
let path = PathBuf::from("/test/target");
cache.update_target(&path, 5);
assert!(cache.targets.contains_key(&path));
let state = cache.targets.get(&path).unwrap();
assert_eq!(state.panic_count, 5);
}
#[test]
fn test_config_changed_not_in_cache() {
let cache = AnalysisCache::default();
assert!(cache.config_changed(Path::new("/nonexistent/config")));
}
#[test]
fn test_update_config_with_hash() {
let mut cache = AnalysisCache::default();
let path = PathBuf::from("/test/config.toml");
cache.update_config_with_hash(&path, 12345);
assert!(cache.configs.contains_key(&path));
}
#[test]
fn test_update_workspace() {
let mut cache = AnalysisCache::default();
let state = WorkspaceState {
members: vec!["member1".to_string()],
binaries: HashMap::new(),
libraries: HashMap::new(),
};
cache.update_workspace(state.clone());
assert_eq!(cache.workspace.members, vec!["member1"]);
}
#[test]
fn test_prune_stale_targets() {
let mut cache = AnalysisCache::default();
cache.targets.insert(
PathBuf::from("/nonexistent/target"),
TargetState {
path: PathBuf::from("/nonexistent/target"),
mtime: 0,
panic_count: 0,
},
);
assert_eq!(cache.targets.len(), 1);
cache.prune_stale_targets();
assert!(cache.targets.is_empty());
}
#[test]
fn test_workspace_changes_affects_target_binary() {
let changes = WorkspaceChanges {
added_binaries: vec!["my-app".to_string()],
..Default::default()
};
assert!(changes.affects_target(Path::new("/target/debug/my_app")));
assert!(changes.affects_target(Path::new("/target/debug/my-app")));
assert!(!changes.affects_target(Path::new("/target/debug/other")));
}
#[test]
fn test_workspace_changes_affects_target_library() {
let changes = WorkspaceChanges {
added_libraries: vec!["mylib".to_string()],
..Default::default()
};
assert!(changes.affects_target(Path::new("/target/debug/libmylib.rlib")));
assert!(changes.affects_target(Path::new("/target/debug/libmylib.dylib")));
assert!(!changes.affects_target(Path::new("/target/debug/libother.rlib")));
}
#[test]
fn test_workspace_changes_affects_target_changed() {
let changes = WorkspaceChanges {
changed_binaries: vec!["cli".to_string()],
changed_libraries: vec!["core".to_string()],
..Default::default()
};
assert!(changes.affects_target(Path::new("/target/debug/cli")));
assert!(changes.affects_target(Path::new("/target/debug/libcore.rlib")));
}
#[test]
fn test_cache_version() {
let cache = AnalysisCache::default();
assert_eq!(cache.version, 0);
}
#[test]
fn test_config_changed_detects_content_change() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("jonesy.toml");
fs::write(&config_path, "deny = [\"unwrap\"]").unwrap();
let mut cache = AnalysisCache::default();
assert!(cache.config_changed(&config_path));
cache.update_config_with_hash(&config_path, hash_file_content(&config_path).unwrap_or(0));
assert!(!cache.config_changed(&config_path));
fs::write(&config_path, "allow = [\"capacity\"]\ndeny = [\"unwrap\"]").unwrap();
assert!(
cache.config_changed(&config_path),
"config_changed should detect modified jonesy.toml content"
);
cache.update_config_with_hash(&config_path, hash_file_content(&config_path).unwrap_or(0));
assert!(!cache.config_changed(&config_path));
}
#[test]
fn test_workspace_changes_no_full_reanalysis_without_member_changes() {
let changes = WorkspaceChanges {
added_binaries: vec!["bin1".to_string()],
changed_libraries: vec!["lib1".to_string()],
..Default::default()
};
assert!(changes.has_changes());
assert!(!changes.needs_full_reanalysis());
}
#[test]
fn test_cache_load_nonexistent() {
let temp_dir = std::env::temp_dir().join(format!("jonesy_test_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
let cache = AnalysisCache::load(&temp_dir);
assert_eq!(cache.version, AnalysisCache::VERSION);
assert!(cache.targets.is_empty());
assert!(cache.configs.is_empty());
}
#[test]
fn test_cache_save_and_load() {
let temp_dir =
std::env::temp_dir().join(format!("jonesy_test_save_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let mut cache = AnalysisCache {
version: AnalysisCache::VERSION,
..Default::default()
};
cache.update_workspace(WorkspaceState {
members: vec!["test_member".to_string()],
binaries: HashMap::new(),
libraries: HashMap::new(),
});
cache.save(&temp_dir).unwrap();
let cache_file = temp_dir.join("target/jonesy/cache.json");
assert!(cache_file.exists());
let loaded = AnalysisCache::load(&temp_dir);
assert_eq!(loaded.version, AnalysisCache::VERSION);
assert_eq!(loaded.workspace.members, vec!["test_member"]);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_cache_load_invalid_json() {
let temp_dir =
std::env::temp_dir().join(format!("jonesy_test_invalid_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
let cache_dir = temp_dir.join("target/jonesy");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("cache.json"), "not valid json").unwrap();
let cache = AnalysisCache::load(&temp_dir);
assert_eq!(cache.version, AnalysisCache::VERSION);
assert!(cache.targets.is_empty());
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_cache_load_wrong_version() {
let temp_dir =
std::env::temp_dir().join(format!("jonesy_test_version_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
let cache_dir = temp_dir.join("target/jonesy");
fs::create_dir_all(&cache_dir).unwrap();
let old_cache = r#"{"version": 0, "targets": {}, "configs": {}, "workspace": {"members": [], "binaries": {}, "libraries": {}}}"#;
fs::write(cache_dir.join("cache.json"), old_cache).unwrap();
let cache = AnalysisCache::load(&temp_dir);
assert_eq!(cache.version, AnalysisCache::VERSION);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_target_needs_analysis_with_real_file() {
let temp_dir =
std::env::temp_dir().join(format!("jonesy_test_target_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let target_file = temp_dir.join("test_binary");
fs::write(&target_file, "test content").unwrap();
let mut cache = AnalysisCache::default();
assert!(cache.target_needs_analysis(&target_file));
cache.update_target(&target_file, 5);
assert!(!cache.target_needs_analysis(&target_file));
std::thread::sleep(std::time::Duration::from_millis(10));
fs::write(&target_file, "modified content").unwrap();
assert!(cache.target_needs_analysis(&target_file));
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_config_changed_with_real_file() {
let temp_dir =
std::env::temp_dir().join(format!("jonesy_test_config_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let config_file = temp_dir.join("jonesy.toml");
fs::write(&config_file, "allow = [\"unwrap\"]").unwrap();
let mut cache = AnalysisCache::default();
assert!(cache.config_changed(&config_file));
cache.update_config_with_hash(&config_file, hash_file_content(&config_file).unwrap_or(0));
assert!(!cache.config_changed(&config_file));
fs::write(&config_file, "allow = [\"panic\"]").unwrap();
assert!(cache.config_changed(&config_file));
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_build_workspace_state_no_cargo_toml() {
let temp_dir =
std::env::temp_dir().join(format!("jonesy_test_no_cargo_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let state = build_workspace_state(&temp_dir);
assert!(state.members.is_empty());
assert!(state.binaries.is_empty());
assert!(state.libraries.is_empty());
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_build_workspace_state_simple_crate() {
let temp_dir =
std::env::temp_dir().join(format!("jonesy_test_simple_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let cargo_toml = r#"
[package]
name = "my-app"
version = "0.1.0"
"#;
fs::write(temp_dir.join("Cargo.toml"), cargo_toml).unwrap();
fs::create_dir_all(temp_dir.join("src")).unwrap();
fs::write(temp_dir.join("src/main.rs"), "fn main() {}").unwrap();
let state = build_workspace_state(&temp_dir);
assert!(state.binaries.contains_key("my-app"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_build_workspace_state_with_lib() {
let temp_dir = std::env::temp_dir().join(format!("jonesy_test_lib_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let cargo_toml = r#"
[package]
name = "my-lib"
version = "0.1.0"
"#;
fs::write(temp_dir.join("Cargo.toml"), cargo_toml).unwrap();
fs::create_dir_all(temp_dir.join("src")).unwrap();
fs::write(temp_dir.join("src/lib.rs"), "pub fn hello() {}").unwrap();
let state = build_workspace_state(&temp_dir);
assert!(state.libraries.contains_key("my-lib"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_build_workspace_state_with_bin_dir() {
let temp_dir =
std::env::temp_dir().join(format!("jonesy_test_bin_dir_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let cargo_toml = r#"
[package]
name = "multi-bin"
version = "0.1.0"
"#;
fs::write(temp_dir.join("Cargo.toml"), cargo_toml).unwrap();
fs::create_dir_all(temp_dir.join("src/bin/bar")).unwrap();
fs::write(temp_dir.join("src/bin/foo.rs"), "fn main() {}").unwrap();
fs::write(temp_dir.join("src/bin/bar/main.rs"), "fn main() {}").unwrap();
let state = build_workspace_state(&temp_dir);
assert!(state.binaries.contains_key("foo"));
assert!(state.binaries.contains_key("bar"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_get_mtime_nonexistent() {
let result = get_mtime(Path::new("/nonexistent/path/to/file"));
assert!(result.is_none());
}
#[test]
fn test_hash_file_content_nonexistent() {
let result = hash_file_content(Path::new("/nonexistent/path/to/file"));
assert!(result.is_none());
}
#[test]
fn test_hash_file_content_consistent() {
let temp_dir =
std::env::temp_dir().join(format!("jonesy_test_hash_{}", std::process::id()));
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let test_file = temp_dir.join("test.txt");
fs::write(&test_file, "hello world").unwrap();
let hash1 = hash_file_content(&test_file);
let hash2 = hash_file_content(&test_file);
assert!(hash1.is_some());
assert_eq!(hash1, hash2);
fs::write(&test_file, "goodbye world").unwrap();
let hash3 = hash_file_content(&test_file);
assert_ne!(hash1, hash3);
let _ = fs::remove_dir_all(&temp_dir);
}
}