use crate::snapshot::error::SnapshotWriteError;
use crate::snapshot::format::{checked_u32, StringId, NONE_REF};
#[derive(Debug, Default)]
pub(crate) struct StringTableBuilder {
data: Vec<u8>,
offsets: Vec<StringOffsetEntry>,
hashes: Vec<u64>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct StringOffsetEntry {
pub offset: u32,
pub len: u32,
}
impl StringTableBuilder {
pub fn with_capacity(string_hint: usize, data_hint: usize) -> Self {
Self {
data: Vec::with_capacity(data_hint),
offsets: Vec::with_capacity(string_hint),
hashes: Vec::with_capacity(string_hint),
}
}
pub fn intern_optional(&mut self, value: Option<&str>) -> Result<StringId, SnapshotWriteError> {
match value {
Some(s) => self.intern(s),
None => Ok(StringId::NONE),
}
}
pub fn intern(&mut self, value: &str) -> Result<StringId, SnapshotWriteError> {
let bytes = value.as_bytes();
let hash = fnv1a_hash(bytes);
for (i, &existing) in self.hashes.iter().enumerate() {
if existing != hash {
continue;
}
let entry = self.offsets[i];
let start = entry.offset as usize;
let end = start + entry.len as usize;
if self.data[start..end] == *bytes {
return Ok(StringId::new(i as u32));
}
}
let start_len = self.data.len();
let end_len = start_len
.checked_add(bytes.len())
.ok_or(SnapshotWriteError::SectionTooLarge)?;
let offset = checked_u32(start_len).ok_or(SnapshotWriteError::SectionTooLarge)?;
let len = checked_u32(bytes.len()).ok_or(SnapshotWriteError::SectionTooLarge)?;
let _ = checked_u32(end_len).ok_or(SnapshotWriteError::SectionTooLarge)?;
let id_raw = checked_u32(self.offsets.len()).ok_or(SnapshotWriteError::TooManyStrings)?;
if id_raw == NONE_REF {
return Err(SnapshotWriteError::TooManyStrings);
}
self.data.extend_from_slice(bytes);
self.offsets.push(StringOffsetEntry { offset, len });
self.hashes.push(hash);
Ok(StringId::new(id_raw))
}
#[inline]
pub fn offsets(&self) -> &[StringOffsetEntry] {
&self.offsets
}
#[inline]
pub fn data(&self) -> &[u8] {
&self.data
}
}
#[inline]
fn fnv1a_hash(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interning_is_first_seen() {
let mut builder = StringTableBuilder::default();
let a = builder.intern("alpha").unwrap();
let b = builder.intern("beta").unwrap();
let a2 = builder.intern("alpha").unwrap();
assert_eq!(a, StringId::new(0));
assert_eq!(b, StringId::new(1));
assert_eq!(a, a2);
assert_eq!(builder.offsets().len(), 2);
}
#[test]
fn intern_optional_none_returns_sentinel() {
let mut builder = StringTableBuilder::default();
assert!(builder.intern_optional(None).unwrap().is_none());
let id = builder.intern_optional(Some("foo")).unwrap();
assert_eq!(id, StringId::new(0));
}
#[test]
fn empty_string_is_an_ordinary_entry() {
let mut builder = StringTableBuilder::default();
let id = builder.intern("").unwrap();
assert_eq!(id, StringId::new(0));
assert_eq!(builder.offsets().len(), 1);
assert_eq!(builder.offsets()[0].len, 0);
assert_eq!(builder.data().len(), 0);
}
#[test]
fn offsets_match_data_layout() {
let mut builder = StringTableBuilder::default();
builder.intern("hello").unwrap();
builder.intern("world!").unwrap();
let offsets = builder.offsets();
assert_eq!(offsets[0].offset, 0);
assert_eq!(offsets[0].len, 5);
assert_eq!(offsets[1].offset, 5);
assert_eq!(offsets[1].len, 6);
assert_eq!(builder.data(), b"helloworld!");
}
#[test]
fn intern_does_not_allocate_per_lookup_after_first_seen() {
let mut builder = StringTableBuilder::default();
for i in 0..1000 {
let s = match i % 3 {
0 => "alpha",
1 => "beta",
_ => "gamma",
};
let _ = builder.intern(s).unwrap();
}
assert_eq!(builder.offsets().len(), 3);
assert_eq!(builder.data(), b"alphabetagamma");
}
#[test]
fn with_capacity_does_not_change_observable_behaviour() {
let mut builder = StringTableBuilder::with_capacity(8, 64);
let a = builder.intern("alpha").unwrap();
let a2 = builder.intern("alpha").unwrap();
assert_eq!(a, a2);
assert_eq!(builder.offsets().len(), 1);
}
#[test]
fn fnv1a_separates_common_metadata_strings() {
let inputs = ["en", "en-US", "ja", "greeting.mf2", "hello"];
let mut seen = std::collections::HashSet::new();
for input in inputs {
assert!(
seen.insert(fnv1a_hash(input.as_bytes())),
"FNV-1a collision on {input:?}"
);
}
}
}