use crate::binary::{read_u32, read_string, write_string};
const MAGIC: &[u8; 4] = b"LUCE";
const CURRENT_VERSION: u32 = 2;
pub struct SnapshotIndex<'a> {
pub path: &'a str,
pub files: Vec<(String, Vec<u8>)>,
}
#[derive(Debug)]
pub struct ImportedIndex {
pub path: String,
pub files: Vec<(String, Vec<u8>)>,
}
#[derive(Debug)]
pub struct ImportedSnapshot {
pub root_files: Vec<(String, Vec<u8>)>,
pub indexes: Vec<ImportedIndex>,
pub is_sharded: bool,
}
pub fn export_snapshot(indexes: &[SnapshotIndex<'_>]) -> Vec<u8> {
export_snapshot_sharded(indexes, &[])
}
pub fn export_snapshot_sharded(
indexes: &[SnapshotIndex<'_>],
root_files: &[(String, Vec<u8>)],
) -> Vec<u8> {
let is_sharded = !root_files.is_empty();
let mut buf = Vec::new();
buf.extend_from_slice(MAGIC);
buf.extend_from_slice(&CURRENT_VERSION.to_le_bytes());
buf.push(if is_sharded { 1 } else { 0 });
if is_sharded {
buf.extend_from_slice(&(root_files.len() as u32).to_le_bytes());
for (name, data) in root_files {
write_string(&mut buf, name);
buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
buf.extend_from_slice(data);
}
}
buf.extend_from_slice(&(indexes.len() as u32).to_le_bytes());
for index in indexes {
write_string(&mut buf, index.path);
buf.extend_from_slice(&(index.files.len() as u32).to_le_bytes());
for (name, data) in &index.files {
write_string(&mut buf, name);
buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
buf.extend_from_slice(data);
}
}
buf
}
pub fn import_snapshot(data: &[u8]) -> Result<ImportedSnapshot, String> {
let mut pos = 0;
if data.len() < 12 {
return Err("snapshot too small: missing header".into());
}
if &data[pos..pos + 4] != MAGIC {
return Err("invalid snapshot: bad magic (expected LUCE)".into());
}
pos += 4;
let version = read_u32(data, &mut pos)?;
let (is_sharded, root_files) = match version {
1 => {
(false, Vec::new())
}
2 => {
if pos >= data.len() {
return Err("snapshot truncated: missing is_sharded byte".into());
}
let flag = data[pos];
pos += 1;
if flag == 1 {
let num_root = read_u32(data, &mut pos)? as usize;
let mut files = Vec::with_capacity(num_root);
for _ in 0..num_root {
let name = read_string(data, &mut pos)?;
let data_len = read_u32(data, &mut pos)? as usize;
if pos + data_len > data.len() {
return Err(format!(
"snapshot truncated: expected {data_len} bytes for root file '{name}'"
));
}
files.push((name, data[pos..pos + data_len].to_vec()));
pos += data_len;
}
(true, files)
} else {
(false, Vec::new())
}
}
_ => return Err(format!("unsupported snapshot version: {version} (expected 1 or 2)")),
};
let num_indexes = read_u32(data, &mut pos)?;
let mut indexes = Vec::with_capacity(num_indexes as usize);
for _ in 0..num_indexes {
let path = read_string(data, &mut pos)?;
let num_files = read_u32(data, &mut pos)?;
let mut files = Vec::with_capacity(num_files as usize);
for _ in 0..num_files {
let name = read_string(data, &mut pos)?;
let data_len = read_u32(data, &mut pos)? as usize;
if pos + data_len > data.len() {
return Err(format!(
"snapshot truncated: expected {data_len} bytes for file '{name}' in index '{path}'"
));
}
files.push((name, data[pos..pos + data_len].to_vec()));
pos += data_len;
}
indexes.push(ImportedIndex { path, files });
}
Ok(ImportedSnapshot {
root_files,
indexes,
is_sharded,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_roundtrip_empty() {
let blob = export_snapshot(&[]);
let result = import_snapshot(&blob).unwrap();
assert!(!result.is_sharded);
assert!(result.root_files.is_empty());
assert!(result.indexes.is_empty());
}
#[test]
fn test_roundtrip_single() {
let snap = SnapshotIndex {
path: "/my/index",
files: vec![
("meta.json".into(), b"{}".to_vec()),
("data.bin".into(), vec![0, 1, 2, 255]),
],
};
let blob = export_snapshot(&[snap]);
let result = import_snapshot(&blob).unwrap();
assert!(!result.is_sharded);
assert_eq!(result.indexes.len(), 1);
assert_eq!(result.indexes[0].path, "/my/index");
assert_eq!(result.indexes[0].files.len(), 2);
}
#[test]
fn test_roundtrip_sharded() {
let root_files = vec![
("_shard_config.json".into(), b"{\"shards\":4}".to_vec()),
("_shard_stats.bin".into(), vec![1, 2, 3, 4]),
];
let shards = vec![
SnapshotIndex {
path: "shard_0",
files: vec![("meta.json".into(), b"{}".to_vec())],
},
SnapshotIndex {
path: "shard_1",
files: vec![
("meta.json".into(), b"{}".to_vec()),
("seg.data".into(), vec![10, 20]),
],
},
];
let blob = export_snapshot_sharded(&shards, &root_files);
let result = import_snapshot(&blob).unwrap();
assert!(result.is_sharded);
assert_eq!(result.root_files.len(), 2);
assert_eq!(result.root_files[0].0, "_shard_config.json");
assert_eq!(result.root_files[1].0, "_shard_stats.bin");
assert_eq!(result.indexes.len(), 2);
assert_eq!(result.indexes[0].path, "shard_0");
assert_eq!(result.indexes[1].files.len(), 2);
}
#[test]
fn test_v1_compat() {
let mut blob = Vec::new();
blob.extend_from_slice(b"LUCE");
blob.extend_from_slice(&1u32.to_le_bytes()); blob.extend_from_slice(&1u32.to_le_bytes()); let path = b"/old/index";
blob.extend_from_slice(&(path.len() as u32).to_le_bytes());
blob.extend_from_slice(path);
blob.extend_from_slice(&0u32.to_le_bytes());
let result = import_snapshot(&blob).unwrap();
assert!(!result.is_sharded);
assert_eq!(result.indexes.len(), 1);
assert_eq!(result.indexes[0].path, "/old/index");
}
#[test]
fn test_bad_magic() {
let err = import_snapshot(b"BADx\x01\x00\x00\x00\x00\x00\x00\x00").unwrap_err();
assert!(err.contains("bad magic"));
}
#[test]
fn test_bad_version() {
let mut blob = Vec::new();
blob.extend_from_slice(b"LUCE");
blob.extend_from_slice(&99u32.to_le_bytes());
blob.extend_from_slice(&[0u8; 4]); let err = import_snapshot(&blob).unwrap_err();
assert!(err.contains("unsupported snapshot version"), "got: {err}");
}
#[test]
fn test_non_sharded_v2_compat() {
let snap = SnapshotIndex {
path: "test",
files: vec![("f.bin".into(), vec![42])],
};
let blob = export_snapshot(&[snap]);
let result = import_snapshot(&blob).unwrap();
assert!(!result.is_sharded);
assert_eq!(result.indexes.len(), 1);
assert_eq!(result.indexes[0].files[0].1, vec![42]);
}
}