use chrono::{TimeZone, Utc};
use tempfile::TempDir;
use super::{
FsStore, LooseObjectWriteMode,
fs_paths::{blobs_dir, hash_path},
};
use crate::{
object::{
Action, Attribution, Blob, ChangeId, ContentHash, Operation, Principal, State, Tree,
TreeEntry,
},
store::{HeddleError, ObjectStore, atomic::temp_path},
};
fn create_test_store() -> (TempDir, FsStore) {
let temp_dir = TempDir::new().unwrap();
let heddle_dir = temp_dir.path().join(".heddle");
let store = FsStore::new(&heddle_dir);
store.init().unwrap();
(temp_dir, store)
}
#[test]
fn test_blob_roundtrip() {
let (_temp, store) = create_test_store();
let blob = Blob::from("hello world");
let hash = store.put_blob(&blob).unwrap();
let retrieved = store.get_blob(&hash).unwrap().unwrap();
assert_eq!(retrieved.content(), blob.content());
}
#[test]
fn test_default_loose_object_write_mode_is_durable_outside_snapshot_batch() {
let (_temp, store) = create_test_store();
assert_eq!(
store.loose_object_write_mode(),
LooseObjectWriteMode::Durable
);
let blob = Blob::from("durable default");
store.put_blob(&blob).unwrap();
assert_eq!(store.pending_directory_sync_count(), 0);
}
#[test]
fn test_durable_loose_object_write_mode_does_not_queue_directory_syncs() {
let temp_dir = TempDir::new().unwrap();
let heddle_dir = temp_dir.path().join(".heddle");
let mut store = FsStore::new(&heddle_dir);
store.set_loose_object_write_mode(LooseObjectWriteMode::Durable);
store.init().unwrap();
let blob = Blob::from("durable sync");
store.put_blob(&blob).unwrap();
assert_eq!(store.pending_directory_sync_count(), 0);
}
#[test]
fn test_snapshot_write_batch_defers_directory_sync_until_flush() {
let (_temp, store) = create_test_store();
store.begin_snapshot_write_batch().unwrap();
let blob = Blob::from("batched sync");
let hash = store.put_blob(&blob).unwrap();
assert_eq!(store.pending_directory_sync_count(), 1);
assert!(store.get_blob(&hash).unwrap().is_some());
store.flush_snapshot_write_batch().unwrap();
assert_eq!(store.pending_directory_sync_count(), 0);
}
#[test]
fn test_abort_snapshot_write_batch_clears_pending_directory_syncs() {
let (_temp, store) = create_test_store();
store.begin_snapshot_write_batch().unwrap();
store.put_blob(&Blob::from("aborted batch")).unwrap();
assert_eq!(store.pending_directory_sync_count(), 1);
store.abort_snapshot_write_batch();
assert_eq!(store.pending_directory_sync_count(), 0);
}
#[test]
fn put_blobs_packed_writes_a_single_packfile_no_loose_blobs() {
use crate::store::pack::PackObjectId;
let (_temp, store) = create_test_store();
let blobs: Vec<(ContentHash, Vec<u8>)> = (0..50)
.map(|i| {
let blob = Blob::from(format!("packed blob {i}"));
(blob.hash(), blob.into_content())
})
.collect();
store.put_blobs_packed(blobs.clone()).unwrap();
let loose_count = std::fs::read_dir(blobs_dir(store.root()))
.map(|iter| iter.count())
.unwrap_or(0);
assert_eq!(loose_count, 0, "expected zero loose blob shards");
for (hash, _) in &blobs {
let id = PackObjectId::Hash(*hash);
assert!(
store.get_pack_object(&id).unwrap().is_some(),
"blob {hash:?} not visible after put_blobs_packed",
);
}
}
#[test]
fn put_blobs_packed_skips_blobs_already_present() {
let (_temp, store) = create_test_store();
let preexisting = Blob::from("already here");
let pre_hash = store.put_blob(&preexisting).unwrap();
let fresh = Blob::from("brand new");
let fresh_hash = fresh.hash();
store
.put_blobs_packed(vec![
(pre_hash, preexisting.into_content()),
(fresh_hash, fresh.into_content()),
])
.unwrap();
assert!(store.get_blob(&pre_hash).unwrap().is_some());
assert!(store.get_blob(&fresh_hash).unwrap().is_some());
}
#[test]
fn second_fs_store_sees_packs_installed_after_its_construction() {
let temp_dir = TempDir::new().unwrap();
let heddle_dir = temp_dir.path().join(".heddle");
let store_a = FsStore::new(&heddle_dir);
store_a.init().unwrap();
let store_b = FsStore::new(&heddle_dir);
let new_blob = Blob::from("worktree-installed content");
let new_hash = new_blob.hash();
assert!(!store_a.has_blob(&new_hash).unwrap());
assert!(!store_b.has_blob(&new_hash).unwrap());
store_b
.put_blobs_packed(vec![(new_hash, new_blob.clone().into_content())])
.unwrap();
assert!(store_b.has_blob(&new_hash).unwrap());
assert!(
store_a.has_blob(&new_hash).unwrap(),
"stale pack manager must recover via reload-on-miss",
);
let recovered = store_a.get_blob(&new_hash).unwrap();
assert_eq!(
recovered.as_ref().map(|b| b.content().to_vec()),
Some(new_blob.into_content())
);
}
#[test]
fn put_blobs_packed_with_empty_input_is_a_noop() {
let (_temp, store) = create_test_store();
store.put_blobs_packed(Vec::new()).unwrap();
let pack_dir = store.root().join("objects").join("packs");
let pack_count = std::fs::read_dir(&pack_dir)
.map(|iter| iter.count())
.unwrap_or(0);
assert_eq!(pack_count, 0, "empty bulk install should not touch packs/");
}
#[test]
fn test_blob_deduplication() {
let (_temp, store) = create_test_store();
let blob1 = Blob::from("same content");
let blob2 = Blob::from("same content");
let hash1 = store.put_blob(&blob1).unwrap();
let hash2 = store.put_blob(&blob2).unwrap();
assert_eq!(hash1, hash2);
}
#[test]
fn test_blob_not_found() {
let (_temp, store) = create_test_store();
let hash = ContentHash::compute(b"nonexistent");
let result = store.get_blob(&hash).unwrap();
assert!(result.is_none());
}
#[test]
fn test_tree_roundtrip() {
let (_temp, store) = create_test_store();
let blob_hash = ContentHash::compute(b"file content");
let tree = Tree::from_entries(vec![TreeEntry::file("foo.txt", blob_hash, false).unwrap()]);
let hash = store.put_tree(&tree).unwrap();
let retrieved = store.get_tree(&hash).unwrap().unwrap();
assert_eq!(retrieved.entries().len(), 1);
assert_eq!(retrieved.get("foo.txt").unwrap().hash, blob_hash);
}
#[test]
fn test_state_roundtrip() {
let (_temp, store) = create_test_store();
let tree_hash = ContentHash::compute(b"tree");
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let state = State::new(tree_hash, vec![], attribution).with_intent("Test state");
store.put_state(&state).unwrap();
let retrieved = store.get_state(&state.change_id).unwrap().unwrap();
assert_eq!(retrieved.change_id, state.change_id);
assert_eq!(retrieved.intent, Some("Test state".to_string()));
}
#[test]
fn test_list_states() {
let (_temp, store) = create_test_store();
let tree_hash = ContentHash::compute(b"tree");
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let state1 = State::new(tree_hash, vec![], attribution.clone());
let state2 = State::new(tree_hash, vec![], attribution);
store.put_state(&state1).unwrap();
store.put_state(&state2).unwrap();
let states = store.list_states().unwrap();
assert_eq!(states.len(), 2);
}
#[test]
fn test_has_blob() {
let (_temp, store) = create_test_store();
let blob = Blob::from("test");
let hash = store.put_blob(&blob).unwrap();
assert!(store.has_blob(&hash).unwrap());
assert!(!store.has_blob(&ContentHash::compute(b"other")).unwrap());
}
#[test]
fn test_empty_blob() {
let (_temp, store) = create_test_store();
let blob = Blob::from("");
let hash = store.put_blob(&blob).unwrap();
let retrieved = store.get_blob(&hash).unwrap().unwrap();
assert_eq!(retrieved.content(), b"");
}
#[test]
fn test_large_blob() {
let (_temp, store) = create_test_store();
let large_content = vec![b'a'; 300 * 1024];
let blob = Blob::from(large_content.as_slice());
let hash = store.put_blob(&blob).unwrap();
let retrieved = store.get_blob(&hash).unwrap().unwrap();
assert_eq!(retrieved.content(), large_content.as_slice());
}
#[test]
fn test_recent_blob_cache_does_not_hide_deleted_loose_object() {
let (_temp, store) = create_test_store();
let blob = Blob::from("cached content");
let hash = store.put_blob(&blob).unwrap();
let path = hash_path(&blobs_dir(store.root()), &hash);
std::fs::remove_file(path).unwrap();
let retrieved = store.get_blob(&hash).unwrap();
assert!(retrieved.is_none());
}
#[test]
fn test_orphaned_temp_blob_file_is_ignored() {
let (_temp, store) = create_test_store();
let blob = Blob::from("orphan temp");
let hash = blob.hash();
let path = hash_path(&blobs_dir(store.root()), &hash);
let temp = temp_path(&path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&temp, b"partial blob data").unwrap();
assert!(!store.has_blob(&hash).unwrap());
assert!(store.get_blob(&hash).unwrap().is_none());
assert!(!store.list_blobs().unwrap().contains(&hash));
}
#[test]
fn test_truncated_blob_file_is_rejected() {
let (_temp, store) = create_test_store();
let blob = Blob::from("full blob payload");
let hash = blob.hash();
let path = hash_path(&blobs_dir(store.root()), &hash);
let parent = path.parent().unwrap();
std::fs::create_dir_all(parent).unwrap();
std::fs::write(&path, b"truncated").unwrap();
let error = store
.get_blob(&hash)
.expect_err("truncated blob should be rejected");
assert!(matches!(
error,
HeddleError::Corruption { .. } | HeddleError::InvalidObject(_)
));
}
#[test]
fn test_get_state_rejects_wrong_object_swap() {
let (_temp, store) = create_test_store();
let tree_hash = ContentHash::compute(b"tree");
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let state1 = State::new(tree_hash, vec![], attribution.clone());
let state2 = State::new(tree_hash, vec![], attribution);
store.put_state(&state1).unwrap();
store.put_state(&state2).unwrap();
let swapped_path = store
.root
.join("objects/states")
.join(format!("{}.state", state1.change_id.to_string_full()));
std::fs::write(&swapped_path, rmp_serde::to_vec(&state2).unwrap()).unwrap();
store.clear_recent_object_caches();
let error = store
.get_state(&state1.change_id)
.expect_err("swapped state should be rejected");
assert!(
matches!(error, HeddleError::InvalidObject(message) if message.contains("state change_id mismatch"))
);
}
#[test]
fn test_get_action_rejects_wrong_object_swap() {
let (_temp, store) = create_test_store();
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let mut action1 = Action::new(
None,
ChangeId::generate(),
Operation::Snapshot,
"first action",
attribution.clone(),
)
.with_timestamp(Utc.timestamp_opt(1_700_000_000, 0).unwrap());
let mut action2 = Action::new(
None,
ChangeId::generate(),
Operation::Snapshot,
"second action",
attribution,
)
.with_timestamp(Utc.timestamp_opt(1_700_000_001, 0).unwrap());
let action1_id = store.put_action(&mut action1).unwrap();
store.put_action(&mut action2).unwrap();
let swapped_path = store
.root
.join("actions")
.join(format!("{}.action", action1_id.as_hash().to_hex()));
std::fs::write(&swapped_path, rmp_serde::to_vec(&action2).unwrap()).unwrap();
let error = store
.get_action(&action1_id)
.expect_err("swapped action should be rejected");
assert!(
matches!(error, HeddleError::InvalidObject(message) if message.contains("action id mismatch"))
);
}
#[test]
fn test_get_tree_rejects_invalid_deserialized_entry_name() {
let (_temp, store) = create_test_store();
let invalid_tree = Tree::from_entries(vec![TreeEntry {
name: "bad/name".to_string(),
mode: crate::object::FileMode::Normal,
entry_type: crate::object::EntryType::Blob,
hash: ContentHash::compute(b"blob"),
}]);
let tree_hash = invalid_tree.hash();
let tree_path = store
.root
.join("objects/trees")
.join(&tree_hash.to_hex()[..2])
.join(&tree_hash.to_hex()[2..]);
let parent = tree_path.parent().unwrap();
std::fs::create_dir_all(parent).unwrap();
std::fs::write(&tree_path, rmp_serde::to_vec(&invalid_tree).unwrap()).unwrap();
let error = store
.get_tree(&tree_hash)
.expect_err("invalid tree should be rejected");
assert!(matches!(error, HeddleError::InvalidTreeEntry(_)));
}