Apply rustfmt to entire workspace

Runs cargo fmt --all; all 573 tests still passing, clippy still clean.
No logic changes — formatting only.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
osobh
2026-04-04 20:31:33 -05:00
co-authored by Claude Sonnet 4.6
parent 3d524e2d63
commit 1c107fe58a
69 changed files with 2790 additions and 1325 deletions
+6 -10
View File
@@ -37,10 +37,7 @@ use crate::writer::OnionFile;
/// let (bytes, onion) = open_revision("agent.h5", 7)?;
/// let file = clawhdf5::File::from_bytes(bytes)?;
/// ```
pub fn open_revision(
h5_path: &Path,
revision: u64,
) -> Result<(Vec<u8>, OnionFile), OnionError> {
pub fn open_revision(h5_path: &Path, revision: u64) -> Result<(Vec<u8>, OnionFile), OnionError> {
let h5_base = std::fs::read(h5_path)?;
let onion = OnionFile::open(h5_path)?;
let reconstructed = onion.reconstruct_revision(revision, &h5_base)?;
@@ -48,10 +45,7 @@ pub fn open_revision(
}
/// Open the HEAD of a named branch.
pub fn open_branch(
h5_path: &Path,
branch: &str,
) -> Result<(Vec<u8>, OnionFile), OnionError> {
pub fn open_branch(h5_path: &Path, branch: &str) -> Result<(Vec<u8>, OnionFile), OnionError> {
let h5_base = std::fs::read(h5_path)?;
let onion = OnionFile::open(h5_path)?;
let reconstructed = onion.open_rev(OpenRevision::Branch(branch.to_owned()), &h5_base)?;
@@ -66,8 +60,10 @@ pub fn open_branch_at(
) -> Result<(Vec<u8>, OnionFile), OnionError> {
let h5_base = std::fs::read(h5_path)?;
let onion = OnionFile::open(h5_path)?;
let reconstructed =
onion.open_rev(OpenRevision::BranchAt(branch.to_owned(), revision), &h5_base)?;
let reconstructed = onion.open_rev(
OpenRevision::BranchAt(branch.to_owned(), revision),
&h5_base,
)?;
Ok((reconstructed, onion))
}
+101 -59
View File
@@ -41,11 +41,7 @@ impl OnionFile {
/// Create a new named branch forked from the current HEAD of `source_branch`.
///
/// Returns the new branch ID.
pub fn create_branch(
&mut self,
name: &str,
source_branch: &str,
) -> Result<u32, OnionError> {
pub fn create_branch(&mut self, name: &str, source_branch: &str) -> Result<u32, OnionError> {
if self.branch_by_name(name).is_some() {
return Err(OnionError::BranchExists(name.to_string()));
}
@@ -133,11 +129,7 @@ impl OnionFile {
}
}
fn merge_latest_wins(
&mut self,
source_id: u32,
target_id: u32,
) -> Result<u64, OnionError> {
fn merge_latest_wins(&mut self, source_id: u32, target_id: u32) -> Result<u64, OnionError> {
let fork_rev = self
.branches
.iter()
@@ -231,21 +223,21 @@ impl OnionFile {
self.commit_session(session, Some(&annotation))
}
fn merge_three_way(
&mut self,
source_id: u32,
target_id: u32,
) -> Result<u64, OnionError> {
fn merge_three_way(&mut self, source_id: u32, target_id: u32) -> Result<u64, OnionError> {
let source_head = self
.index
.branch_head(source_id)
.ok_or_else(|| OnionError::Malformed(format!("source branch {source_id} has no revisions")))?
.ok_or_else(|| {
OnionError::Malformed(format!("source branch {source_id} has no revisions"))
})?
.revision;
let target_head = self
.index
.branch_head(target_id)
.ok_or_else(|| OnionError::Malformed(format!("target branch {target_id} has no revisions")))?
.ok_or_else(|| {
OnionError::Malformed(format!("target branch {target_id} has no revisions"))
})?
.revision;
// Find common ancestor of the two branch HEADs
@@ -253,13 +245,24 @@ impl OnionFile {
.index
.common_ancestor(source_head, target_head)
.ok_or_else(|| {
let src_name = self.branches.iter().find(|b| b.id == source_id)
let src_name = self
.branches
.iter()
.find(|b| b.id == source_id)
.and_then(|b| self.annotations.get(b.name_off))
.unwrap_or("?").to_owned();
let tgt_name = self.branches.iter().find(|b| b.id == target_id)
.unwrap_or("?")
.to_owned();
let tgt_name = self
.branches
.iter()
.find(|b| b.id == target_id)
.and_then(|b| self.annotations.get(b.name_off))
.unwrap_or("?").to_owned();
OnionError::NoCommonAncestor { a: src_name, b: tgt_name }
.unwrap_or("?")
.to_owned();
OnionError::NoCommonAncestor {
a: src_name,
b: tgt_name,
}
})?;
// Collect source delta since ancestor (latest write per offset)
@@ -321,7 +324,8 @@ impl OnionFile {
if merged.is_empty() {
return Err(OnionError::Malformed(
"three-way merge: no source changes to apply (target already has all changes)".to_string(),
"three-way merge: no source changes to apply (target already has all changes)"
.to_string(),
));
}
@@ -329,9 +333,8 @@ impl OnionFile {
for (h5_off, bytes) in &merged {
session.record_page(*h5_off, bytes);
}
let annotation = format!(
"3-way merge {source_id}{target_id} [ancestor rev {ancestor_rev}]"
);
let annotation =
format!("3-way merge {source_id}{target_id} [ancestor rev {ancestor_rev}]");
self.commit_session(session, Some(&annotation))
}
@@ -371,11 +374,7 @@ impl OnionFile {
.iter()
.map(|b| BranchInfo {
id: b.id,
name: self
.annotations
.get(b.name_off)
.unwrap_or("?")
.to_owned(),
name: self.annotations.get(b.name_off).unwrap_or("?").to_owned(),
head_rev: b.head_rev,
fork_rev: b.fork_rev,
})
@@ -383,7 +382,10 @@ impl OnionFile {
}
/// Return revision entries for a named branch in chronological order.
pub fn branch_history(&self, name: &str) -> Result<Vec<&crate::format::RevisionEntry>, OnionError> {
pub fn branch_history(
&self,
name: &str,
) -> Result<Vec<&crate::format::RevisionEntry>, OnionError> {
let branch = self
.branch_by_name(name)
.ok_or_else(|| OnionError::BranchNotFound(name.to_string()))?;
@@ -414,7 +416,9 @@ impl OnionFile {
.branch_by_name(name)
.ok_or_else(|| OnionError::BranchNotFound(name.to_string()))?;
if branch.id == crate::format::BRANCH_MAIN {
return Err(OnionError::Malformed("cannot delete the main branch".into()));
return Err(OnionError::Malformed(
"cannot delete the main branch".into(),
));
}
let id = branch.id;
self.branches.retain(|b| b.id != id);
@@ -605,14 +609,18 @@ mod tests {
fn merge_latest_wins_produces_new_revision() {
let (_h5, mut onion, _feat_id) = make_fork_scenario();
let pre_count = onion.revision_count();
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
onion
.merge_into("feat", "main", MergeStrategy::LatestWins)
.unwrap();
assert_eq!(onion.revision_count(), pre_count + 1);
}
#[test]
fn merge_latest_wins_page_content_correct() {
let (h5, mut onion, _feat_id) = make_fork_scenario();
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
onion
.merge_into("feat", "main", MergeStrategy::LatestWins)
.unwrap();
onion.flush().unwrap();
// Reload from disk and reconstruct main HEAD
@@ -621,8 +629,10 @@ mod tests {
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(main_head, &base).unwrap();
// Page 0 should now be BB (from feat)
assert!(state[0..4096].iter().all(|&b| b == 0xBB),
"page 0 should be BB after latest-wins merge");
assert!(
state[0..4096].iter().all(|&b| b == 0xBB),
"page 0 should be BB after latest-wins merge"
);
}
#[test]
@@ -646,15 +656,19 @@ mod tests {
s2.record_page(0, &vec![0x22u8; 4096]);
onion.commit_session(s2, None).unwrap();
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
onion
.merge_into("feat", "main", MergeStrategy::LatestWins)
.unwrap();
onion.flush().unwrap();
let onion2 = OnionFile::open(&h5).unwrap();
let head = onion2.revision_count() - 1;
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(head, &base).unwrap();
assert!(state[0..4096].iter().all(|&b| b == 0x22),
"latest write (0x22) must win in LatestWins merge");
assert!(
state[0..4096].iter().all(|&b| b == 0x22),
"latest write (0x22) must win in LatestWins merge"
);
}
#[test]
@@ -666,7 +680,9 @@ mod tests {
onion.commit_session(s, None).unwrap();
// fork but make no commits on feat
onion.create_branch("feat", "main").unwrap();
let err = onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap_err();
let err = onion
.merge_into("feat", "main", MergeStrategy::LatestWins)
.unwrap_err();
assert!(matches!(err, OnionError::Malformed(_)));
}
@@ -682,20 +698,30 @@ mod tests {
source.to_vec() // just return source
};
onion
.merge_into("feat", "main", MergeStrategy::DatasetLevel(Box::new(resolver)))
.merge_into(
"feat",
"main",
MergeStrategy::DatasetLevel(Box::new(resolver)),
)
.unwrap();
assert!(called.load(std::sync::atomic::Ordering::SeqCst), "resolver must be called");
assert!(
called.load(std::sync::atomic::Ordering::SeqCst),
"resolver must be called"
);
}
#[test]
fn merge_dataset_level_resolver_controls_output() {
let (h5, mut onion, _feat_id) = make_fork_scenario();
// Resolver always returns 0xCC regardless of inputs
let resolver = |_path: &str, _target: &[u8], _source: &[u8]| -> Vec<u8> {
vec![0xCCu8; 4096]
};
let resolver =
|_path: &str, _target: &[u8], _source: &[u8]| -> Vec<u8> { vec![0xCCu8; 4096] };
onion
.merge_into("feat", "main", MergeStrategy::DatasetLevel(Box::new(resolver)))
.merge_into(
"feat",
"main",
MergeStrategy::DatasetLevel(Box::new(resolver)),
)
.unwrap();
onion.flush().unwrap();
@@ -703,8 +729,10 @@ mod tests {
let head = onion2.revision_count() - 1;
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(head, &base).unwrap();
assert!(state[0..4096].iter().all(|&b| b == 0xCC),
"resolver output 0xCC should be in merged state");
assert!(
state[0..4096].iter().all(|&b| b == 0xCC),
"resolver output 0xCC should be in merged state"
);
}
// ── Merge: ThreeWay ──────────────────────────────────────────────────────
@@ -736,7 +764,9 @@ mod tests {
sf.record_page(0, &vec![0xBBu8; 4096]);
onion.commit_session(sf, None).unwrap();
onion.merge_into("feat", "main", MergeStrategy::ThreeWay).unwrap();
onion
.merge_into("feat", "main", MergeStrategy::ThreeWay)
.unwrap();
onion.flush().unwrap();
let onion2 = OnionFile::open(&h5).unwrap();
@@ -744,10 +774,14 @@ mod tests {
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(head, &base).unwrap();
assert!(state[0..4096].iter().all(|&b| b == 0xBB),
"page 0 should be BB (from feat)");
assert!(state[4096..8192].iter().all(|&b| b == 0xCC),
"page 4096 should stay CC (from main — not overwritten by 3-way)");
assert!(
state[0..4096].iter().all(|&b| b == 0xBB),
"page 0 should be BB (from feat)"
);
assert!(
state[4096..8192].iter().all(|&b| b == 0xCC),
"page 4096 should stay CC (from main — not overwritten by 3-way)"
);
}
#[test]
@@ -772,22 +806,28 @@ mod tests {
sf.record_page(0, &vec![0xCCu8; 4096]);
onion.commit_session(sf, None).unwrap();
onion.merge_into("feat", "main", MergeStrategy::ThreeWay).unwrap();
onion
.merge_into("feat", "main", MergeStrategy::ThreeWay)
.unwrap();
onion.flush().unwrap();
let onion2 = OnionFile::open(&h5).unwrap();
let head = onion2.revision_count() - 1;
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(head, &base).unwrap();
assert!(state[0..4096].iter().all(|&b| b == 0xCC),
"on conflict, source (CC) should win");
assert!(
state[0..4096].iter().all(|&b| b == 0xCC),
"on conflict, source (CC) should win"
);
}
#[test]
fn merge_nonexistent_source_errors() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let err = onion.merge_into("no-such", "main", MergeStrategy::LatestWins).unwrap_err();
let err = onion
.merge_into("no-such", "main", MergeStrategy::LatestWins)
.unwrap_err();
assert!(matches!(err, OnionError::BranchNotFound(_)));
}
@@ -799,7 +839,9 @@ mod tests {
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
onion.create_branch("feat", "main").unwrap();
let err = onion.merge_into("feat", "no-target", MergeStrategy::LatestWins).unwrap_err();
let err = onion
.merge_into("feat", "no-target", MergeStrategy::LatestWins)
.unwrap_err();
assert!(matches!(err, OnionError::BranchNotFound(_)));
}
}
+6 -10
View File
@@ -13,26 +13,22 @@ use crate::tdt;
pub fn compress_page(data: &[u8], codec: Codec) -> Result<Vec<u8>, OnionError> {
match codec {
Codec::None => Ok(data.to_vec()),
Codec::Zstd => zstd::bulk::compress(data, 3)
.map_err(|e| OnionError::Compress(e.to_string())),
Codec::Zstd => {
zstd::bulk::compress(data, 3).map_err(|e| OnionError::Compress(e.to_string()))
}
Codec::Lz4 => Ok(lz4_flex::compress_prepend_size(data)),
Codec::Brotli => Err(OnionError::Compress(
"brotli not yet implemented".to_string(),
)),
Codec::ZstdTdt => {
let interleaved = tdt::encode(data, 4);
zstd::bulk::compress(&interleaved, 3)
.map_err(|e| OnionError::Compress(e.to_string()))
zstd::bulk::compress(&interleaved, 3).map_err(|e| OnionError::Compress(e.to_string()))
}
}
}
/// Decompress `data` using the given codec back to `orig_size` bytes.
pub fn decompress_page(
data: &[u8],
codec: Codec,
orig_size: u32,
) -> Result<Vec<u8>, OnionError> {
pub fn decompress_page(data: &[u8], codec: Codec, orig_size: u32) -> Result<Vec<u8>, OnionError> {
match codec {
Codec::None => {
if data.len() != orig_size as usize {
@@ -161,7 +157,7 @@ mod tests {
}
let orig = data.len() as u32;
let zstd_size = compress_page(&data, Codec::Zstd).unwrap().len();
let tdt_size = compress_page(&data, Codec::ZstdTdt).unwrap().len();
let tdt_size = compress_page(&data, Codec::ZstdTdt).unwrap().len();
assert!(
tdt_size < zstd_size,
"ZstdTdt ({tdt_size} B) should beat plain Zstd ({zstd_size} B) on smooth f32 data ({orig} B)"
+12 -3
View File
@@ -94,7 +94,10 @@ mod tests {
.unwrap();
let f = vf.current().unwrap();
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![1.0, 2.0, 3.0]);
assert_eq!(
f.dataset("data").unwrap().read_f64().unwrap(),
vec![1.0, 2.0, 3.0]
);
assert_eq!(vf.onion().revision_count(), 0);
}
@@ -106,7 +109,10 @@ mod tests {
let _ = make_builder(&[1.0]).with_onion(&h5_path, 4096).unwrap();
let onion_path = PathBuf::from(format!("{}.onion", h5_path.display()));
assert!(onion_path.exists(), "sidecar should be flushed by with_onion");
assert!(
onion_path.exists(),
"sidecar should be flushed by with_onion"
);
}
#[test]
@@ -140,6 +146,9 @@ mod tests {
vf.page_size().is_power_of_two(),
"auto page size must be a power of two"
);
assert!(vf.page_size() >= 4096, "auto page size must be at least 4 KiB");
assert!(
vf.page_size() >= 4096,
"auto page size must be at least 4 KiB"
);
}
}
+53 -47
View File
@@ -49,9 +49,9 @@ pub const HEADER_SIZE: usize = 128;
pub mod feature_flags {
pub const COMPRESSION: u64 = 1 << 0;
pub const BRANCHING: u64 = 1 << 1;
pub const PROVENANCE: u64 = 1 << 2;
pub const SNAPSHOTS: u64 = 1 << 3;
pub const BRANCHING: u64 = 1 << 1;
pub const PROVENANCE: u64 = 1 << 2;
pub const SNAPSHOTS: u64 = 1 << 3;
}
pub const DEFAULT_FEATURE_FLAGS: u64 =
@@ -64,13 +64,13 @@ pub const DEFAULT_FEATURE_FLAGS: u64 =
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Codec {
None = 0,
Zstd = 1,
Lz4 = 2,
Brotli = 3,
None = 0,
Zstd = 1,
Lz4 = 2,
Brotli = 3,
/// zstd applied after TDT byte-interleaving transform (arXiv:2506.18062).
/// Improves compression ratio ~16% for `f32`/`f16` numeric pages.
ZstdTdt = 4,
ZstdTdt = 4,
}
impl Codec {
@@ -108,19 +108,19 @@ impl Codec {
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable, Debug, Clone)]
#[repr(C)]
pub struct OnionHeader {
pub magic: [u8; 9],
pub magic: [u8; 9],
pub format_version: u8,
pub _pad_align: [u8; 6],
pub feature_flags: u64,
pub page_size: u32,
pub _pad_ps: [u8; 4],
pub _pad_align: [u8; 6],
pub feature_flags: u64,
pub page_size: u32,
pub _pad_ps: [u8; 4],
pub revision_count: u64,
pub branch_count: u32,
pub _pad_bc: [u8; 4],
pub index_offset: u64,
pub branch_offset: u64,
pub created_at: f64,
pub reserved: [u8; 56],
pub branch_count: u32,
pub _pad_bc: [u8; 4],
pub index_offset: u64,
pub branch_offset: u64,
pub created_at: f64,
pub reserved: [u8; 56],
}
const _: () = assert!(size_of::<OnionHeader>() == HEADER_SIZE);
@@ -180,19 +180,19 @@ impl OnionHeader {
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable, Debug, Clone)]
#[repr(C)]
pub struct RevisionEntry {
pub revision: u64,
pub branch_id: u32,
pub _pad_bi: [u8; 4],
pub parent_rev: u64,
pub page_count: u32,
pub _pad_pc: [u8; 4],
pub revision: u64,
pub branch_id: u32,
pub _pad_bi: [u8; 4],
pub parent_rev: u64,
pub page_count: u32,
pub _pad_pc: [u8; 4],
pub page_table_off: u64,
pub timestamp: f64,
pub blake3: [u8; 32],
pub session_uuid: [u8; 16],
pub timestamp: f64,
pub blake3: [u8; 32],
pub session_uuid: [u8; 16],
pub annotation_off: u64,
pub flags: u8,
pub _pad_flags: [u8; 7],
pub flags: u8,
pub _pad_flags: [u8; 7],
}
pub const REV_FLAG_SNAPSHOT: u8 = 1 << 0;
@@ -233,11 +233,11 @@ impl RevisionEntry {
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable, Debug, Clone)]
#[repr(C)]
pub struct BranchEntry {
pub id: u32,
pub _pad_id: [u8; 4],
pub name_off: u64,
pub head_rev: u64,
pub fork_rev: u64,
pub id: u32,
pub _pad_id: [u8; 4],
pub name_off: u64,
pub head_rev: u64,
pub fork_rev: u64,
pub created_at: f64,
}
@@ -255,25 +255,25 @@ pub struct BranchEntry {
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable, Debug, Clone)]
#[repr(C)]
pub struct PageTableEntry {
pub h5_offset: u64,
pub h5_offset: u64,
pub data_offset: u64,
pub orig_size: u32,
pub data_size: u32,
pub codec: u8,
pub _pad: [u8; 7],
pub orig_size: u32,
pub data_size: u32,
pub codec: u8,
pub _pad: [u8; 7],
}
// ─────────────────────────────────────────────────────────────────────────────
// Compile-time size/alignment assertions
// ─────────────────────────────────────────────────────────────────────────────
const _: () = assert!(size_of::<OnionHeader>() == 128);
const _: () = assert!(size_of::<RevisionEntry>() == 112);
const _: () = assert!(size_of::<BranchEntry>() == 40);
const _: () = assert!(size_of::<OnionHeader>() == 128);
const _: () = assert!(size_of::<RevisionEntry>() == 112);
const _: () = assert!(size_of::<BranchEntry>() == 40);
const _: () = assert!(size_of::<PageTableEntry>() == 32);
const _: () = assert!(size_of::<RevisionEntry>() % 8 == 0);
const _: () = assert!(size_of::<BranchEntry>() % 8 == 0);
const _: () = assert!(size_of::<RevisionEntry>() % 8 == 0);
const _: () = assert!(size_of::<BranchEntry>() % 8 == 0);
const _: () = assert!(size_of::<PageTableEntry>() % 8 == 0);
// ─────────────────────────────────────────────────────────────────────────────
@@ -321,7 +321,10 @@ mod tests {
fn header_unknown_version_rejected() {
let mut hdr = OnionHeader::new(4096, DEFAULT_FEATURE_FLAGS, 0.0);
hdr.format_version = 42;
assert!(matches!(hdr.validate(), Err(OnionError::UnknownVersion(42))));
assert!(matches!(
hdr.validate(),
Err(OnionError::UnknownVersion(42))
));
}
#[test]
@@ -488,7 +491,10 @@ mod tests {
#[test]
fn codec_unknown_returns_error() {
assert!(matches!(Codec::from_u8(99), Err(OnionError::UnknownCodec(99))));
assert!(matches!(
Codec::from_u8(99),
Err(OnionError::UnknownCodec(99))
));
}
#[test]
+58 -25
View File
@@ -69,7 +69,10 @@ impl OnionFile {
entry.set_epoch(EPOCH_DEAD);
}
}
Ok(GcStats { revisions_removed: count, bytes_reclaimed: 0 })
Ok(GcStats {
revisions_removed: count,
bytes_reclaimed: 0,
})
}
_ => {
// ── Immediate path: compact now ───────────────────────────
@@ -100,11 +103,7 @@ impl OnionFile {
GcPolicy::KeepSince(cutoff) => all_revs
.iter()
.copied()
.filter(|&rev| {
self.index
.get(rev)
.is_some_and(|e| e.timestamp >= *cutoff)
})
.filter(|&rev| self.index.get(rev).is_some_and(|e| e.timestamp >= *cutoff))
.collect(),
GcPolicy::KeepRevisions(explicit) => {
let mut set: HashSet<u64> = explicit.iter().copied().collect();
@@ -118,7 +117,11 @@ impl OnionFile {
GcPolicy::EpochFlip(inner) => return self.compute_to_remove(inner),
};
all_revs.iter().copied().filter(|rev| !keep.contains(rev)).collect()
all_revs
.iter()
.copied()
.filter(|rev| !keep.contains(rev))
.collect()
}
/// Consolidate + compact the given revision set immediately.
@@ -204,8 +207,7 @@ impl OnionFile {
}
// Compact page_data blob.
let surviving_revs: Vec<u64> =
self.index.entries().iter().map(|e| e.revision).collect();
let surviving_revs: Vec<u64> = self.index.entries().iter().map(|e| e.revision).collect();
let mut new_page_data: Vec<u8> = Vec::new();
for &rev in &surviving_revs {
if let Some(table) = self.page_tables.get_mut(rev as usize) {
@@ -222,7 +224,10 @@ impl OnionFile {
}
self.page_data = new_page_data;
Ok(GcStats { revisions_removed, bytes_reclaimed })
Ok(GcStats {
revisions_removed,
bytes_reclaimed,
})
}
/// Find all entries marked [`EPOCH_DEAD`] and compact them.
@@ -270,9 +275,7 @@ mod tests {
} else {
None
};
onion
.commit_session(s, annotation.as_deref())
.unwrap();
onion.commit_session(s, annotation.as_deref()).unwrap();
}
onion
}
@@ -369,7 +372,10 @@ mod tests {
assert_eq!(stats.revisions_removed, 4);
let after = onion.page_data.len();
// page_data must be smaller (or at most equal for all-same pages that compress to 0)
assert!(after <= before, "page_data must shrink after GC: {before} -> {after}");
assert!(
after <= before,
"page_data must shrink after GC: {before} -> {after}"
);
}
/// After GC + flush, the on-disk file is smaller than before.
@@ -421,7 +427,10 @@ mod tests {
// The three surviving revisions must still reconstruct without error.
for rev in 3..6u64 {
let bytes = onion.reconstruct_revision(rev, &h5_base).unwrap();
assert!(!bytes.is_empty(), "rev {rev} should produce non-empty bytes");
assert!(
!bytes.is_empty(),
"rev {rev} should produce non-empty bytes"
);
}
}
@@ -456,7 +465,11 @@ mod tests {
let before_gc = onion.reconstruct_revision(2, &h5_base).unwrap();
assert_eq!(&before_gc[0..4096], &vec![0xAA_u8; 4096], "pre-GC page0");
assert_eq!(&before_gc[4096..8192], &vec![0xBB_u8; 4096], "pre-GC page1");
assert_eq!(&before_gc[8192..12288], &vec![0xCC_u8; 4096], "pre-GC page2");
assert_eq!(
&before_gc[8192..12288],
&vec![0xCC_u8; 4096],
"pre-GC page2"
);
// GC: keep only rev 2 — revs 0 and 1 are ancestors that will be pruned.
onion.gc(GcPolicy::KeepLastN(1)).unwrap();
@@ -490,15 +503,20 @@ mod tests {
let mut onion = make_onion_with_n_revisions(10);
let page_data_len_before = onion.page_data.len();
let stats = onion.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepLastN(3)))).unwrap();
let stats = onion
.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepLastN(3))))
.unwrap();
// Reports the count that will be removed.
assert_eq!(stats.revisions_removed, 7);
// bytes_reclaimed is 0 until flush compacts.
assert_eq!(stats.bytes_reclaimed, 0);
// page_data must NOT have changed yet.
assert_eq!(onion.page_data.len(), page_data_len_before,
"epoch flip must not compact page_data immediately");
assert_eq!(
onion.page_data.len(),
page_data_len_before,
"epoch flip must not compact page_data immediately"
);
// Index still has all 10 entries (removal deferred).
assert_eq!(onion.revision_count(), 10);
// Entries 0..6 should be marked EPOCH_DEAD.
@@ -525,7 +543,9 @@ mod tests {
}
// Epoch flip: defer compaction.
onion.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepLastN(2)))).unwrap();
onion
.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepLastN(2))))
.unwrap();
assert_eq!(onion.revision_count(), 5, "not compacted yet");
// After flush, deferred compaction runs.
@@ -533,7 +553,11 @@ mod tests {
// Reload and verify.
let reloaded = OnionFile::open(&h5).unwrap();
assert_eq!(reloaded.revision_count(), 2, "2 revisions survive after flush");
assert_eq!(
reloaded.revision_count(),
2,
"2 revisions survive after flush"
);
// Surviving revisions are still reconstructable.
for rev in 3u64..5 {
@@ -559,16 +583,25 @@ mod tests {
#[test]
fn gc_epoch_flip_nested_policy_keep_tagged() {
let mut onion = make_onion_with_n_revisions(9); // tagged at 0,3,6
let stats = onion.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepTagged))).unwrap();
let stats = onion
.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepTagged)))
.unwrap();
assert_eq!(stats.revisions_removed, 6); // keeps 0,3,6
assert_eq!(onion.revision_count(), 9, "deferred — index intact");
// 0,3,6 are live; rest are dead.
for rev in [0u64, 3, 6] {
assert_eq!(onion.index.get(rev).unwrap().epoch(), 0, "tagged rev {rev} must be live");
assert_eq!(
onion.index.get(rev).unwrap().epoch(),
0,
"tagged rev {rev} must be live"
);
}
for rev in [1u64, 2, 4, 5, 7, 8] {
assert_eq!(onion.index.get(rev).unwrap().epoch(), EPOCH_DEAD,
"untagged rev {rev} must be EPOCH_DEAD");
assert_eq!(
onion.index.get(rev).unwrap().epoch(),
EPOCH_DEAD,
"untagged rev {rev} must be EPOCH_DEAD"
);
}
}
+14 -10
View File
@@ -1,6 +1,6 @@
//! RevisionIndex: O(1) lookup + branch-filtered iteration.
use crate::format::{RevisionEntry, NO_PARENT};
use crate::format::{NO_PARENT, RevisionEntry};
/// In-memory index of all revision entries.
///
@@ -53,7 +53,9 @@ impl RevisionIndex {
/// monotonically greater than the current maximum.
pub fn append(&mut self, entry: RevisionEntry) {
debug_assert!(
self.entries.last().is_none_or(|e| e.revision < entry.revision),
self.entries
.last()
.is_none_or(|e| e.revision < entry.revision),
"revision numbers must be monotonically increasing"
);
self.entries.push(entry);
@@ -61,15 +63,14 @@ impl RevisionIndex {
/// Iterate over all revisions on a specific branch (by `branch_id`).
pub fn branch_revisions(&self, branch_id: u32) -> impl Iterator<Item = &RevisionEntry> {
self.entries.iter().filter(move |e| e.branch_id == branch_id)
self.entries
.iter()
.filter(move |e| e.branch_id == branch_id)
}
/// Return the HEAD revision entry for a branch (highest revision number).
pub fn branch_head(&self, branch_id: u32) -> Option<&RevisionEntry> {
self.entries
.iter()
.rev()
.find(|e| e.branch_id == branch_id)
self.entries.iter().rev().find(|e| e.branch_id == branch_id)
}
/// Walk the DAG from `start_rev` to the root, following `parent_rev`.
@@ -191,8 +192,7 @@ mod tests {
.collect();
assert_eq!(main_revs, vec![0, 1, 4]);
let branch1_revs: Vec<u64> =
idx.branch_revisions(1).map(|e| e.revision).collect();
let branch1_revs: Vec<u64> = idx.branch_revisions(1).map(|e| e.revision).collect();
assert_eq!(branch1_revs, vec![2, 3]);
}
@@ -252,7 +252,11 @@ mod tests {
fn remove_revisions() {
let mut idx = RevisionIndex::new();
for i in 0u64..5 {
idx.append(make_entry(i, BRANCH_MAIN, if i == 0 { NO_PARENT } else { i - 1 }));
idx.append(make_entry(
i,
BRANCH_MAIN,
if i == 0 { NO_PARENT } else { i - 1 },
));
}
let to_remove = [1u64, 2].into_iter().collect();
idx.remove_revisions(&to_remove);
+10 -11
View File
@@ -36,19 +36,18 @@ pub mod tdt;
pub mod versioned_file;
pub mod writer;
pub use error::OnionError;
pub use format::{
BranchEntry, Codec, OnionHeader, PageTableEntry, RevisionEntry,
BRANCH_MAIN, DEFAULT_FEATURE_FLAGS, EPOCH_DEAD, FORMAT_VERSION, HEADER_SIZE, MAGIC, NO_PARENT,
REV_FLAG_SNAPSHOT, feature_flags,
};
pub use api::{
open_revision, open_branch, open_branch_at,
list_revisions, rollback,
list_branches, create_branch, delete_branch, rename_branch,
create_branch, delete_branch, list_branches, list_revisions, open_branch, open_branch_at,
open_revision, rename_branch, rollback,
};
pub use branch::{BranchInfo, DatasetResolver, MergeStrategy};
pub use merkle::{MerkleError, MerkleNode, RevisionMerkleTree, WalkStep};
pub use error::OnionError;
pub use ext::FileBuilderExt;
pub use versioned_file::{VersionedFile, open_at_revision, open_at_branch};
pub use format::{
BRANCH_MAIN, BranchEntry, Codec, DEFAULT_FEATURE_FLAGS, EPOCH_DEAD, FORMAT_VERSION,
HEADER_SIZE, MAGIC, NO_PARENT, OnionHeader, PageTableEntry, REV_FLAG_SNAPSHOT, RevisionEntry,
feature_flags,
};
pub use merkle::{MerkleError, MerkleNode, RevisionMerkleTree, WalkStep};
pub use versioned_file::{VersionedFile, open_at_branch, open_at_revision};
pub use writer::eof_to_page_size;
+16 -4
View File
@@ -97,7 +97,11 @@ impl RevisionMerkleTree {
/// The slice **must** be sorted by `revision` in ascending order.
pub fn build(entries: &[(u64, [u8; 32])]) -> Self {
let leaf_count = entries.len();
let capacity = if leaf_count == 0 { 1 } else { leaf_count.next_power_of_two() };
let capacity = if leaf_count == 0 {
1
} else {
leaf_count.next_power_of_two()
};
// 1-indexed BFS array; index 0 unused.
let mut hashes = vec![[0u8; 32]; 2 * capacity + 1];
@@ -113,7 +117,12 @@ impl RevisionMerkleTree {
// Build internal nodes bottom-up
build_internal(&mut hashes, capacity);
RevisionMerkleTree { hashes, leaf_revisions, leaf_count, capacity }
RevisionMerkleTree {
hashes,
leaf_revisions,
leaf_count,
capacity,
}
}
// ── Queries ───────────────────────────────────────────────────────────────
@@ -311,7 +320,7 @@ impl RevisionMerkleTree {
/// `hashes` must have length `2 * capacity + 1`.
fn build_internal(hashes: &mut [[u8; 32]], capacity: usize) {
for i in (1..capacity).rev() {
let left = hashes[2 * i];
let left = hashes[2 * i];
let right = hashes[2 * i + 1];
hashes[i] = node_hash(&left, &right);
}
@@ -484,7 +493,10 @@ mod tests {
let header_only = &full[..13]; // 5 + 8 bytes
assert_eq!(
RevisionMerkleTree::deserialise(header_only),
Err(MerkleError::TruncatedPayload { expected: 5 + 8 + 5 * 40, got: 13 })
Err(MerkleError::TruncatedPayload {
expected: 5 + 8 + 5 * 40,
got: 13
})
);
}
+4 -1
View File
@@ -96,7 +96,10 @@ mod tests {
// Same pages, different insertion order
let h1 = hash_pages(&[(0, a.as_ref()), (4096, b.as_ref())]);
let h2 = hash_pages(&[(4096, b.as_ref()), (0, a.as_ref())]);
assert_eq!(h1, h2, "hash must be order-independent (sorted by h5_offset)");
assert_eq!(
h1, h2,
"hash must be order-independent (sorted by h5_offset)"
);
}
#[test]
+21 -25
View File
@@ -3,11 +3,11 @@
//! Opening a historical revision reads only the `.onion` sidecar —
//! the primary `.h5` file is always at the latest committed state.
use std::collections::BTreeMap;
use crate::compress::decompress_page;
use crate::error::OnionError;
use crate::format::Codec;
use crate::writer::OnionFile;
use std::collections::BTreeMap;
/// `(h5_offset, compressed_data, codec_byte, orig_size)` — raw page as stored.
pub type RawPage = (u64, Vec<u8>, u8, u32);
@@ -41,7 +41,11 @@ impl OnionFile {
// Collect all ancestor revisions in order from oldest → newest
let ancestors: Vec<u64> = {
let mut chain = self.index.ancestors(rev).map(|e| e.revision).collect::<Vec<_>>();
let mut chain = self
.index
.ancestors(rev)
.map(|e| e.revision)
.collect::<Vec<_>>();
chain.reverse(); // oldest first
chain
};
@@ -53,13 +57,11 @@ impl OnionFile {
// Optimisation: find the newest snapshot in the ancestor chain and
// start from there instead of from `h5_base`. This bounds
// reconstruction depth to O(N_since_snapshot · P).
let snapshot_start_idx = ancestors
.iter()
.rposition(|&r| {
self.index
.get(r)
.is_some_and(|e| e.flags & REV_FLAG_SNAPSHOT != 0)
});
let snapshot_start_idx = ancestors.iter().rposition(|&r| {
self.index
.get(r)
.is_some_and(|e| e.flags & REV_FLAG_SNAPSHOT != 0)
});
let (start_idx, mut file_bytes) = match snapshot_start_idx {
Some(idx) => {
@@ -76,7 +78,9 @@ impl OnionFile {
let table = self
.page_tables
.get(*ancestor_rev as usize)
.ok_or_else(|| OnionError::Malformed(format!("missing page table for rev {ancestor_rev}")))?;
.ok_or_else(|| {
OnionError::Malformed(format!("missing page table for rev {ancestor_rev}"))
})?;
for pt_entry in table {
let codec = Codec::from_u8(pt_entry.codec)?;
@@ -111,11 +115,7 @@ impl OnionFile {
}
/// Open a specific revision using an [`OpenRevision`] selector.
pub fn open_rev(
&self,
selector: OpenRevision,
h5_base: &[u8],
) -> Result<Vec<u8>, OnionError> {
pub fn open_rev(&self, selector: OpenRevision, h5_base: &[u8]) -> Result<Vec<u8>, OnionError> {
let rev = self.resolve_selector(selector)?;
self.reconstruct_revision(rev, h5_base)
}
@@ -128,12 +128,11 @@ impl OnionFile {
self.branch_head_rev(crate::format::BRANCH_MAIN)
.ok_or(OnionError::RevisionNotFound(0))
}
OpenRevision::At(rev) => {
self.index
.get(rev)
.map(|_| rev)
.ok_or(OnionError::RevisionNotFound(rev))
}
OpenRevision::At(rev) => self
.index
.get(rev)
.map(|_| rev)
.ok_or(OnionError::RevisionNotFound(rev)),
OpenRevision::Branch(name) => {
let branch = self
.branch_by_name(&name)
@@ -201,10 +200,7 @@ impl OnionFile {
/// compressed bytes directly and let the receiver decompress.
///
/// Returns `(h5_offset, compressed_data, codec_byte, orig_size)`.
pub fn revision_pages_raw(
&self,
rev: u64,
) -> Result<Vec<RawPage>, OnionError> {
pub fn revision_pages_raw(&self, rev: u64) -> Result<Vec<RawPage>, OnionError> {
let table = self
.page_tables
.get(rev as usize)
+6 -2
View File
@@ -117,7 +117,10 @@ mod tests {
// byte_pos=1: elements 1,5,9,13
// byte_pos=2: elements 2,6,10,14
// byte_pos=3: elements 3,7,11,15
assert_eq!(enc, vec![0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15]);
assert_eq!(
enc,
vec![0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15]
);
let dec = decode(&enc, 4, 16);
assert_eq!(dec, data);
}
@@ -180,7 +183,8 @@ mod tests {
assert!(
tdt_compressed.len() <= raw_compressed.len(),
"TDT+zstd ({} bytes) should be no worse than raw zstd ({} bytes) on structured float data",
tdt_compressed.len(), raw_compressed.len()
tdt_compressed.len(),
raw_compressed.len()
);
}
+167 -53
View File
@@ -57,21 +57,21 @@ use crate::writer::OnionFile;
/// [`export_revision`](VersionedFile::export_revision) to materialise a
/// specific revision to disk.
pub struct VersionedFile {
onion: OnionFile,
h5_path: PathBuf,
onion: OnionFile,
h5_path: PathBuf,
/// Original `.h5` bytes — the reconstruction base. **Never mutated.**
h5_base: Vec<u8>,
h5_base: Vec<u8>,
/// In-memory state of the most recently committed revision on the **default**
/// branch. Kept in sync with `branch_state[BRANCH_MAIN]`.
/// Used by [`snapshot`](VersionedFile::snapshot) and
/// [`auto_snapshot`](VersionedFile::auto_snapshot).
current_h5: Vec<u8>,
current_h5: Vec<u8>,
/// Per-branch diff baseline: `branch_id → last-committed bytes on that branch`.
///
/// Ensures that interleaved commits on different branches always diff against
/// the correct prior state for each branch, not just the globally last commit.
branch_state: HashMap<u32, Vec<u8>>,
page_size: u32,
page_size: u32,
}
impl VersionedFile {
@@ -105,7 +105,14 @@ impl VersionedFile {
.cloned()
.unwrap_or_else(|| h5_base.clone());
Ok(Self { onion, h5_path, h5_base, current_h5, branch_state, page_size })
Ok(Self {
onion,
h5_path,
h5_base,
current_h5,
branch_state,
page_size,
})
}
/// Create a new versioned HDF5 file from a [`clawhdf5::FileBuilder`].
@@ -144,7 +151,14 @@ impl VersionedFile {
let h5_base = std::fs::read(&h5_path)?;
let current_h5 = h5_base.clone();
let onion = OnionFile::create(&h5_path, page_size)?;
Ok(Self { onion, h5_path, h5_base, current_h5, branch_state: HashMap::new(), page_size })
Ok(Self {
onion,
h5_path,
h5_base,
current_h5,
branch_state: HashMap::new(),
page_size,
})
}
/// Create a new versioned HDF5 file, automatically choosing the page size.
@@ -163,7 +177,14 @@ impl VersionedFile {
let current_h5 = h5_base.clone();
let onion = OnionFile::create_auto(&h5_path)?;
let page_size = onion.page_size();
Ok(Self { onion, h5_path, h5_base, current_h5, branch_state: HashMap::new(), page_size })
Ok(Self {
onion,
h5_path,
h5_base,
current_h5,
branch_state: HashMap::new(),
page_size,
})
}
// ── Read API ──────────────────────────────────────────────────────────────
@@ -352,7 +373,9 @@ impl VersionedFile {
/// vf.auto_snapshot(500)?; // snapshot every 500 revisions
/// ```
pub fn auto_snapshot(&mut self, interval: u64) -> Result<Option<u64>, OnionError> {
let result = self.onion.auto_snapshot_if_needed(&self.current_h5, interval)?;
let result = self
.onion
.auto_snapshot_if_needed(&self.current_h5, interval)?;
if result.is_some() {
self.onion.flush()?;
}
@@ -383,7 +406,8 @@ impl VersionedFile {
// Refresh the target branch's cached state so future commits on it
// use the merged content as their diff baseline.
let (target_id, target_head) = {
let entry = self.onion
let entry = self
.onion
.branch_by_name(target)
.ok_or_else(|| OnionError::BranchNotFound(target.to_string()))?;
(entry.id, entry.head_rev)
@@ -435,8 +459,7 @@ impl VersionedFile {
// ── Internal ─────────────────────────────────────────────────────────────
fn hdf5_from_bytes(&self, bytes: Vec<u8>) -> Result<clawhdf5::File, OnionError> {
clawhdf5::File::from_bytes(bytes)
.map_err(|e| OnionError::Hdf5(e.to_string()))
clawhdf5::File::from_bytes(bytes).map_err(|e| OnionError::Hdf5(e.to_string()))
}
}
@@ -510,11 +533,17 @@ mod tests {
// Read back rev 0 — should still be [1.0, 2.0]
let f0 = vf.revision(0).unwrap();
assert_eq!(f0.dataset("data").unwrap().read_f64().unwrap(), vec![1.0, 2.0]);
assert_eq!(
f0.dataset("data").unwrap().read_f64().unwrap(),
vec![1.0, 2.0]
);
// Read back rev 1 — should be [3.0, 4.0]
let f1 = vf.revision(1).unwrap();
assert_eq!(f1.dataset("data").unwrap().read_f64().unwrap(), vec![3.0, 4.0]);
assert_eq!(
f1.dataset("data").unwrap().read_f64().unwrap(),
vec![3.0, 4.0]
);
}
#[test]
@@ -580,9 +609,15 @@ mod tests {
let vf2 = VersionedFile::open(&h5_path).unwrap();
assert_eq!(vf2.onion().revision_count(), 2);
let f0 = vf2.revision(0).unwrap();
assert_eq!(f0.dataset("data").unwrap().read_f64().unwrap(), vec![10.0, 20.0]);
assert_eq!(
f0.dataset("data").unwrap().read_f64().unwrap(),
vec![10.0, 20.0]
);
let f1 = vf2.revision(1).unwrap();
assert_eq!(f1.dataset("data").unwrap().read_f64().unwrap(), vec![30.0, 40.0]);
assert_eq!(
f1.dataset("data").unwrap().read_f64().unwrap(),
vec![30.0, 40.0]
);
}
// ── merge ────────────────────────────────────────────────────────────────
@@ -598,7 +633,8 @@ mod tests {
// Fork feat
let feat_id = vf.onion_mut().create_branch("feat", "main").unwrap();
// Rev 1: feat writes [3.0, 4.0]
vf.commit_on(Some("feat"), make_h5(&[3.0, 4.0]), Some("feat-v1")).unwrap();
vf.commit_on(Some("feat"), make_h5(&[3.0, 4.0]), Some("feat-v1"))
.unwrap();
// Merge feat → main
let merge_rev = vf.merge("feat", "main", MergeStrategy::LatestWins).unwrap();
@@ -610,7 +646,10 @@ mod tests {
// Content at merge revision reflects feat's state (latest wins)
let f = vf.revision(merge_rev).unwrap();
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![3.0, 4.0]);
assert_eq!(
f.dataset("data").unwrap().read_f64().unwrap(),
vec![3.0, 4.0]
);
let _ = feat_id;
}
@@ -622,7 +661,8 @@ mod tests {
vf.commit(make_h5(&[1.0]), Some("main-v1")).unwrap();
vf.onion_mut().create_branch("feat", "main").unwrap();
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1")).unwrap();
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1"))
.unwrap();
vf.merge("feat", "main", MergeStrategy::LatestWins).unwrap();
@@ -640,7 +680,9 @@ mod tests {
let mut vf = VersionedFile::create(&h5_path, 4096).unwrap();
vf.commit(make_h5(&[1.0]), None).unwrap();
let err = vf.merge("ghost", "main", MergeStrategy::LatestWins).unwrap_err();
let err = vf
.merge("ghost", "main", MergeStrategy::LatestWins)
.unwrap_err();
assert!(matches!(err, OnionError::BranchNotFound(_)));
}
@@ -653,11 +695,12 @@ mod tests {
vf.commit(make_h5(&[1.0]), Some("main-v1")).unwrap();
let feat_id = vf.onion_mut().create_branch("feat", "main").unwrap();
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1")).unwrap();
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1"))
.unwrap();
let revs = vf.onion().list_revisions();
assert_eq!(revs.len(), 2);
assert_eq!(revs[0].branch_id, 0, "rev 0 is on main");
assert_eq!(revs[0].branch_id, 0, "rev 0 is on main");
assert_eq!(revs[1].branch_id, feat_id, "rev 1 is on feat");
}
@@ -679,7 +722,9 @@ mod tests {
let (_tmp, h5_path) = tmp_h5(&[1.0]);
let mut vf = VersionedFile::create(&h5_path, 4096).unwrap();
let err = vf.commit_on(Some("ghost"), make_h5(&[2.0]), None).unwrap_err();
let err = vf
.commit_on(Some("ghost"), make_h5(&[2.0]), None)
.unwrap_err();
assert!(matches!(err, OnionError::BranchNotFound(_)));
}
@@ -698,7 +743,8 @@ mod tests {
vf.onion_mut().create_branch("feat", "main").unwrap();
// Commit v2 on feat (rev 1: [2.0])
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1")).unwrap();
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1"))
.unwrap();
// Commit v2 on main (rev 2: [3.0]) — diff must be against main's last
// commit ([1.0]), not feat's last commit ([2.0]).
@@ -706,16 +752,25 @@ mod tests {
// Reconstruct each revision and verify round-trip fidelity
let f0 = vf.revision(0).unwrap();
assert_eq!(f0.dataset("data").unwrap().read_f64().unwrap(), vec![1.0],
"rev 0 (main-v1)");
assert_eq!(
f0.dataset("data").unwrap().read_f64().unwrap(),
vec![1.0],
"rev 0 (main-v1)"
);
let f1 = vf.revision(1).unwrap();
assert_eq!(f1.dataset("data").unwrap().read_f64().unwrap(), vec![2.0],
"rev 1 (feat-v1)");
assert_eq!(
f1.dataset("data").unwrap().read_f64().unwrap(),
vec![2.0],
"rev 1 (feat-v1)"
);
let f2 = vf.revision(2).unwrap();
assert_eq!(f2.dataset("data").unwrap().read_f64().unwrap(), vec![3.0],
"rev 2 (main-v2) — would be wrong if diffed against feat state");
assert_eq!(
f2.dataset("data").unwrap().read_f64().unwrap(),
vec![3.0],
"rev 2 (main-v2) — would be wrong if diffed against feat state"
);
}
/// After a reload, `open()` must restore per-branch state from the sidecar
@@ -730,13 +785,21 @@ mod tests {
let mut s = vf.onion_mut().begin_session(Some(feat_id)).unwrap();
let feat_bytes = make_h5(&[9.0]);
let ps = 4096usize;
let base = vf.onion().reconstruct_revision(0,
&std::fs::read(&h5_path).unwrap()).unwrap();
let base = vf
.onion()
.reconstruct_revision(0, &std::fs::read(&h5_path).unwrap())
.unwrap();
for i in 0..(feat_bytes.len().max(base.len())).div_ceil(ps) {
let start = i * ps;
if start >= feat_bytes.len() { break; }
let ns = &feat_bytes[start..feat_bytes.len().min(start+ps)];
let os: &[u8] = if start < base.len() { &base[start..base.len().min(start+ps)] } else { &[] };
if start >= feat_bytes.len() {
break;
}
let ns = &feat_bytes[start..feat_bytes.len().min(start + ps)];
let os: &[u8] = if start < base.len() {
&base[start..base.len().min(start + ps)]
} else {
&[]
};
if ns != os {
let mut pad = vec![0u8; ps];
pad[..ns.len()].copy_from_slice(ns);
@@ -752,8 +815,11 @@ mod tests {
vf2.commit(make_h5(&[2.0]), Some("main-v2")).unwrap();
let f_main = vf2.revision(2).unwrap();
assert_eq!(f_main.dataset("data").unwrap().read_f64().unwrap(), vec![2.0],
"main-v2 content must be [2.0] after reload");
assert_eq!(
f_main.dataset("data").unwrap().read_f64().unwrap(),
vec![2.0],
"main-v2 content must be [2.0] after reload"
);
}
// ── create_auto ──────────────────────────────────────────────────────────
@@ -767,7 +833,10 @@ mod tests {
"auto page size must be a power of two, got {}",
vf.page_size()
);
assert!(vf.page_size() >= 4096, "auto page size must be at least 4 KiB");
assert!(
vf.page_size() >= 4096,
"auto page size must be at least 4 KiB"
);
}
#[test]
@@ -778,7 +847,10 @@ mod tests {
vf.commit(make_h5(&[7.0, 8.0]), Some("auto-v1")).unwrap();
let f = vf.revision(0).unwrap();
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![7.0, 8.0]);
assert_eq!(
f.dataset("data").unwrap().read_f64().unwrap(),
vec![7.0, 8.0]
);
}
// ── snapshot / auto_snapshot ─────────────────────────────────────────────
@@ -797,7 +869,10 @@ mod tests {
// The snapshot revision must be flagged as a snapshot.
let revs = vf.onion().list_revisions();
let snap = revs.iter().find(|r| r.revision == snap_rev).unwrap();
assert!(snap.is_snapshot, "snapshot revision must have is_snapshot == true");
assert!(
snap.is_snapshot,
"snapshot revision must have is_snapshot == true"
);
}
#[test]
@@ -843,7 +918,10 @@ mod tests {
vf.commit(make_h5(&[i as f64]), None).unwrap();
}
let result = vf.auto_snapshot(5).unwrap();
assert!(result.is_some(), "auto_snapshot should fire when count % interval == 0");
assert!(
result.is_some(),
"auto_snapshot should fire when count % interval == 0"
);
}
#[test]
@@ -855,7 +933,10 @@ mod tests {
vf.commit(make_h5(&[2.0]), None).unwrap();
// 2 revisions, interval 5 — should not trigger
let result = vf.auto_snapshot(5).unwrap();
assert!(result.is_none(), "auto_snapshot should be a no-op when count % interval != 0");
assert!(
result.is_none(),
"auto_snapshot should be a no-op when count % interval != 0"
);
}
#[test]
@@ -908,12 +989,21 @@ mod tests {
// Record a changed page for the feat branch rev
let feat_bytes = make_h5(&[3.0]);
let ps = 4096usize;
let current = vf.onion().reconstruct_revision(1, &std::fs::read(&h5_path).unwrap()).unwrap();
let current = vf
.onion()
.reconstruct_revision(1, &std::fs::read(&h5_path).unwrap())
.unwrap();
for i in 0..(feat_bytes.len().max(current.len())).div_ceil(ps) {
let start = i * ps;
if start >= feat_bytes.len() { break; }
if start >= feat_bytes.len() {
break;
}
let new_sl = &feat_bytes[start..feat_bytes.len().min(start + ps)];
let old_sl: &[u8] = if start < current.len() { &current[start..current.len().min(start + ps)] } else { &[] };
let old_sl: &[u8] = if start < current.len() {
&current[start..current.len().min(start + ps)]
} else {
&[]
};
if new_sl != old_sl {
let mut pad = vec![0u8; ps];
pad[..new_sl.len()].copy_from_slice(new_sl);
@@ -1009,7 +1099,10 @@ mod tests {
p.set_extension(&new_ext);
p
};
assert!(onion_path.exists(), ".onion sidecar should exist after from_builder: checked {onion_path:?}");
assert!(
onion_path.exists(),
".onion sidecar should exist after from_builder: checked {onion_path:?}"
);
}
#[test]
@@ -1026,7 +1119,10 @@ mod tests {
assert_eq!(rev, 0);
let f = vf.revision(0).unwrap();
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![3.0, 4.0]);
assert_eq!(
f.dataset("data").unwrap().read_f64().unwrap(),
vec![3.0, 4.0]
);
}
// ── export_revision ───────────────────────────────────────────────────────
@@ -1046,7 +1142,10 @@ mod tests {
// Re-open the exported file as a raw clawhdf5::File
let bytes = std::fs::read(&export_path).unwrap();
let f = clawhdf5::File::from_bytes(bytes).unwrap();
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![1.0, 2.0]);
assert_eq!(
f.dataset("data").unwrap().read_f64().unwrap(),
vec![1.0, 2.0]
);
}
#[test]
@@ -1075,7 +1174,10 @@ mod tests {
let bytes = std::fs::read(&export_path).unwrap();
let f = clawhdf5::File::from_bytes(bytes).unwrap();
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![8.0, 9.0]);
assert_eq!(
f.dataset("data").unwrap().read_f64().unwrap(),
vec![8.0, 9.0]
);
}
// ── multiple datasets ─────────────────────────────────────────────────────
@@ -1098,11 +1200,23 @@ mod tests {
vf.commit(bytes_v2, Some("update x only")).unwrap();
let f0 = vf.revision(0).unwrap();
assert_eq!(f0.dataset("x").unwrap().read_f64().unwrap(), vec![1.0, 2.0, 3.0]);
assert_eq!(f0.dataset("y").unwrap().read_f64().unwrap(), vec![4.0, 5.0, 6.0]);
assert_eq!(
f0.dataset("x").unwrap().read_f64().unwrap(),
vec![1.0, 2.0, 3.0]
);
assert_eq!(
f0.dataset("y").unwrap().read_f64().unwrap(),
vec![4.0, 5.0, 6.0]
);
let f1 = vf.revision(1).unwrap();
assert_eq!(f1.dataset("x").unwrap().read_f64().unwrap(), vec![10.0, 20.0, 30.0]);
assert_eq!(f1.dataset("y").unwrap().read_f64().unwrap(), vec![4.0, 5.0, 6.0]);
assert_eq!(
f1.dataset("x").unwrap().read_f64().unwrap(),
vec![10.0, 20.0, 30.0]
);
assert_eq!(
f1.dataset("y").unwrap().read_f64().unwrap(),
vec![4.0, 5.0, 6.0]
);
}
}
+24 -17
View File
@@ -18,8 +18,8 @@ use crate::annotation::AnnotationHeap;
use crate::compress::compress_page;
use crate::error::OnionError;
use crate::format::{
BranchEntry, Codec, OnionHeader, PageTableEntry, RevisionEntry,
BRANCH_MAIN, DEFAULT_FEATURE_FLAGS, HEADER_SIZE, NO_PARENT,
BRANCH_MAIN, BranchEntry, Codec, DEFAULT_FEATURE_FLAGS, HEADER_SIZE, NO_PARENT, OnionHeader,
PageTableEntry, RevisionEntry,
};
use crate::index::RevisionIndex;
use crate::provenance::{SessionId, hash_pages};
@@ -204,9 +204,7 @@ impl OnionFile {
annotation: Option<&str>,
) -> Result<u64, OnionError> {
let revision = self.index.len() as u64;
let parent_rev = self
.branch_head_rev(session.branch_id)
.unwrap_or(NO_PARENT);
let parent_rev = self.branch_head_rev(session.branch_id).unwrap_or(NO_PARENT);
// Compress pages and build BLAKE3 input
let mut pages: Vec<(u64, Vec<u8>, u32, Codec)> = Vec::new();
@@ -226,9 +224,7 @@ impl OnionFile {
);
// Annotation
let annotation_off = annotation
.map(|a| self.annotations.push(a))
.unwrap_or(0);
let annotation_off = annotation.map(|a| self.annotations.push(a)).unwrap_or(0);
// Append page table entries
let mut table: Vec<PageTableEntry> = Vec::new();
@@ -451,9 +447,9 @@ impl OnionFile {
}
pub fn branch_by_name(&self, name: &str) -> Option<&BranchEntry> {
self.branches.iter().find(|b| {
self.annotations.get(b.name_off) == Some(name)
})
self.branches
.iter()
.find(|b| self.annotations.get(b.name_off) == Some(name))
}
/// Serialise the entire `.onion` file to bytes.
@@ -480,8 +476,7 @@ impl OnionFile {
let branch_offset = index_offset + (n_revisions * rev_entry_size) as u64;
// Calculate PageTable section start
let page_table_section_start =
branch_offset + (n_branches * branch_entry_size) as u64;
let page_table_section_start = branch_offset + (n_branches * branch_entry_size) as u64;
// Calculate per-revision page_table_off values and total page table size
let mut page_table_offsets: Vec<u64> = Vec::with_capacity(n_revisions);
@@ -599,7 +594,11 @@ impl OnionFile {
// 0-4). We extend the Vec with empty entries so that
// page_tables[rev_number] is always valid.
let max_rev = entries.iter().map(|e| e.revision).max().unwrap_or(0);
let table_len = if entries.is_empty() { 0 } else { max_rev as usize + 1 };
let table_len = if entries.is_empty() {
0
} else {
max_rev as usize + 1
};
let mut page_tables: Vec<Vec<PageTableEntry>> = vec![Vec::new(); table_len];
for entry in &entries {
let pt_start = entry.page_table_off as usize;
@@ -792,7 +791,10 @@ mod tests {
fn create_accepts_valid_page_sizes() {
for &size in &[512u32, 1024, 4096, 65536] {
let path = Path::new("/tmp/dummy.h5");
assert!(OnionFile::create(path, size).is_ok(), "size {size} rejected");
assert!(
OnionFile::create(path, size).is_ok(),
"size {size} rejected"
);
}
}
@@ -973,7 +975,9 @@ mod tests {
.list_revisions()
.into_iter()
.filter(|r| {
onion.index.get(r.revision)
onion
.index
.get(r.revision)
.map_or(false, |e| e.flags & crate::format::REV_FLAG_SNAPSHOT != 0)
})
.collect();
@@ -1041,7 +1045,10 @@ mod tests {
fn eof_to_page_size_always_power_of_two() {
for eof in [1, 100, 1_000, 10_000, 100_000, 1_000_000, 100_000_000u64] {
let ps = eof_to_page_size(eof);
assert!(ps.is_power_of_two(), "page_size {ps} is not a power of two for eof={eof}");
assert!(
ps.is_power_of_two(),
"page_size {ps} is not a power of two for eof={eof}"
);
}
}