use serde::{Deserialize, Serialize};
use crate::CHUNK_SIZE;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileMeta {
pub path: String,
pub size: u64,
#[serde(default)]
pub mtime: u64,
#[serde(default)]
pub etag: String,
}
impl FileMeta {
pub fn new(path: impl Into<String>, size: u64, mtime: u64) -> Self {
let path = path.into();
let etag = etag_from_size_mtime(size, mtime);
FileMeta {
path,
size,
mtime,
etag,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChunkRange {
pub start: u64,
pub end: u64,
}
impl ChunkRange {
pub fn len(&self) -> u64 {
self.end.saturating_sub(self.start)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChunkMeta {
pub index: u32,
pub offset: u64,
pub size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransferPlan {
pub file: FileMeta,
pub chunk_size: u64,
pub chunks: Vec<ChunkMeta>,
}
impl TransferPlan {
pub fn new(file: FileMeta) -> Self {
TransferPlan::with_chunk_size(file, CHUNK_SIZE)
}
pub fn with_chunk_size(file: FileMeta, chunk_size: u64) -> Self {
debug_assert!(chunk_size > 0);
let mut chunks = Vec::new();
let mut offset = 0u64;
while offset < file.size {
let len = chunk_size.min(file.size - offset);
chunks.push(ChunkMeta {
index: chunks.len() as u32,
offset,
size: len,
});
offset += len;
}
if file.size == 0 {
chunks.push(ChunkMeta {
index: 0,
offset: 0,
size: 0,
});
}
TransferPlan {
file,
chunk_size,
chunks,
}
}
pub fn total_bytes(&self) -> u64 {
self.chunks.iter().map(|c| c.size).sum()
}
}
pub fn etag_from_size_mtime(size: u64, mtime: u64) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(size.to_le_bytes());
h.update(mtime.to_le_bytes());
let digest = h.finalize();
format!("\"{}\"", hex(&digest[..8]))
}
pub fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
pub fn encode_file_meta(meta: &FileMeta) -> String {
serde_json::to_string(meta).expect("FileMeta serializes")
}
pub fn decode_file_meta(header: &str) -> Result<FileMeta, serde_json::Error> {
serde_json::from_str(header)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_chunks_evenly() {
let meta = FileMeta::new("/a/b.bin", 10, 0);
let plan = TransferPlan::with_chunk_size(meta, 4);
assert_eq!(plan.chunks.len(), 3);
assert_eq!(
plan.chunks.iter().map(|c| c.offset).collect::<Vec<_>>(),
vec![0, 4, 8]
);
assert_eq!(
plan.chunks.iter().map(|c| c.size).collect::<Vec<_>>(),
vec![4, 4, 2]
);
assert_eq!(plan.total_bytes(), 10);
}
#[test]
fn plan_exact_multiple() {
let meta = FileMeta::new("/f", 8, 0);
let plan = TransferPlan::with_chunk_size(meta, 4);
assert_eq!(plan.chunks.len(), 2);
assert_eq!(plan.total_bytes(), 8);
}
#[test]
fn plan_empty_file_has_one_zero_chunk() {
let meta = FileMeta::new("/empty", 0, 0);
let plan = TransferPlan::new(meta);
assert_eq!(plan.chunks.len(), 1);
assert_eq!(plan.chunks[0].size, 0);
}
#[test]
fn etag_is_stable_and_quoted() {
let a = etag_from_size_mtime(100, 1_700_000_000);
let b = etag_from_size_mtime(100, 1_700_000_000);
let c = etag_from_size_mtime(101, 1_700_000_000);
assert_eq!(a, b);
assert_ne!(a, c);
assert!(a.starts_with('"') && a.ends_with('"'));
}
#[test]
fn file_meta_json_roundtrip() {
let meta = FileMeta::new("dir/file.txt", 1234, 42);
let encoded = encode_file_meta(&meta);
assert!(encoded.contains("etag"));
let decoded = decode_file_meta(&encoded).unwrap();
assert_eq!(decoded, meta);
}
}