use std::fmt;
use std::ops::Range;
use crate::{Block, Document};
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SegmentId(String);
impl SegmentId {
pub fn of(text: &str) -> Self {
let normalized = normalize(text);
let mut hash = FNV_OFFSET_BASIS;
for &byte in normalized.as_bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
SegmentId(format!("{hash:016x}"))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SegmentId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Segment {
pub index: usize,
pub id: SegmentId,
pub text: String,
pub block_range: Range<usize>,
}
pub fn segments(document: &Document) -> Vec<Segment> {
let mut segments = Vec::new();
for (block_index, block) in document.blocks.iter().enumerate() {
let text = block_text(block);
if normalize(&text).is_empty() {
continue;
}
let index = segments.len();
segments.push(Segment {
index,
id: SegmentId::of(&text),
text,
block_range: block_index..block_index + 1,
});
}
segments
}
fn block_text(block: &Block) -> String {
match block {
Block::Heading(heading) => heading.text.clone(),
Block::Paragraph(paragraph) => paragraph.text.clone(),
Block::List(list) => list.items.join("\n"),
Block::Table(table) => {
let mut lines = Vec::with_capacity(table.rows.len() + 1);
if !table.headers.is_empty() {
lines.push(table.headers.join(" | "));
}
for row in &table.rows {
lines.push(row.join(" | "));
}
lines.join("\n")
}
Block::Figure(figure) => figure.caption.clone().unwrap_or_default(),
Block::CodeBlock(code) => code.text.clone(),
Block::Quote(quote) => quote.text.clone(),
}
}
fn normalize(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Heading, List, Metadata, Paragraph};
fn doc(blocks: Vec<Block>) -> Document {
Document {
title: None,
metadata: Metadata::default(),
blocks,
block_pages: Vec::new(),
citations: Vec::new(),
}
}
fn sample() -> Document {
doc(vec![
Block::Heading(Heading {
level: 1,
text: "Digital Twins".to_string(),
}),
Block::Paragraph(Paragraph {
text: "A digital twin mirrors a physical asset.".to_string(),
}),
Block::List(List {
ordered: false,
items: vec!["sensor feeds".to_string(), "a model".to_string()],
}),
])
}
#[test]
fn segment_id_is_deterministic_and_content_sensitive() {
assert_eq!(SegmentId::of("hello world"), SegmentId::of("hello world"));
assert_ne!(SegmentId::of("hello world"), SegmentId::of("hello worlD"));
}
#[test]
fn segment_id_is_stable_under_whitespace_reflow() {
assert_eq!(
SegmentId::of("A digital twin mirrors\na physical asset."),
SegmentId::of("A digital twin mirrors a physical asset."),
);
}
#[test]
fn segment_id_known_answer_pins_the_hash() {
assert_eq!(
SegmentId::of(" ").as_str(),
format!("{:016x}", 0xcbf2_9ce4_8422_2325u64),
);
assert_eq!(SegmentId::of("digital twin").as_str(), "976c60764a911fc7");
}
#[test]
fn segments_are_deterministic_and_order_stable() {
let document = sample();
let a = segments(&document);
let b = segments(&document);
assert_eq!(a, b);
assert_eq!(a.len(), 3);
assert_eq!(a[0].index, 0);
assert_eq!(a[1].index, 1);
assert_eq!(a[2].index, 2);
assert_eq!(a[0].id, SegmentId::of("Digital Twins"));
assert_eq!(a[2].id, SegmentId::of("sensor feeds\na model"));
}
#[test]
fn changing_one_block_changes_exactly_one_segment_id() {
let original = segments(&sample());
let mut revised_doc = sample();
revised_doc.blocks[1] = Block::Paragraph(Paragraph {
text: "A digital twin mirrors a physical asset in real time.".to_string(),
});
let revised = segments(&revised_doc);
assert_eq!(revised.len(), original.len());
assert_eq!(revised[0].id, original[0].id); assert_ne!(revised[1].id, original[1].id); assert_eq!(revised[2].id, original[2].id); }
#[test]
fn blocks_without_text_are_skipped_but_indices_stay_contiguous() {
use crate::Figure;
let document = doc(vec![
Block::Heading(Heading {
level: 2,
text: "Method".to_string(),
}),
Block::Figure(Figure {
caption: None,
image_path: None,
}),
Block::Paragraph(Paragraph {
text: "We measured throughput.".to_string(),
}),
]);
let segs = segments(&document);
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].index, 0);
assert_eq!(segs[0].block_range, 0..1);
assert_eq!(segs[1].index, 1);
assert_eq!(segs[1].block_range, 2..3);
}
}