use std::path::Path;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::mirror;
use crate::{Error, Result};
pub const SEGMENTS_SCHEMA: &str = "text-mirror/segments@1";
pub const SEGMENTS_UNIT: &str = "utf8_bytes";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SegmentsSchema;
impl Serialize for SegmentsSchema {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(SEGMENTS_SCHEMA)
}
}
impl<'de> Deserialize<'de> for SegmentsSchema {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
if value == SEGMENTS_SCHEMA {
Ok(SegmentsSchema)
} else {
Err(D::Error::custom(format!(
"unrecognized schema {value:?}, this consumer accepts only {SEGMENTS_SCHEMA:?}"
)))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SegmentKind {
Span,
Page,
Sheet,
Slide,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Segment {
pub schema: SegmentsSchema,
pub kind: SegmentKind,
pub start: u64,
pub end: u64,
pub unit: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub name: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
pub hidden: bool,
}
impl Segment {
pub fn span(start: usize, end: usize, source: &str) -> Segment {
Segment {
schema: SegmentsSchema,
kind: SegmentKind::Span,
start: start as u64,
end: end as u64,
unit: SEGMENTS_UNIT.to_string(),
source: Some(source.to_string()),
name: None,
hidden: false,
}
}
pub fn boundary(kind: SegmentKind, at: usize) -> Segment {
Segment {
schema: SegmentsSchema,
kind,
start: at as u64,
end: at as u64,
unit: SEGMENTS_UNIT.to_string(),
source: None,
name: None,
hidden: false,
}
}
pub fn named(mut self, name: &str) -> Segment {
self.name = Some(name.to_string());
self
}
pub fn hidden(mut self) -> Segment {
self.hidden = true;
self
}
}
pub fn segments_path(
mirror_root: &Path,
division: &str,
relative_source: &Path,
) -> std::path::PathBuf {
let mut mapped = mirror::mirror_path(mirror_root, division, relative_source);
let mut name = mapped
.file_name()
.map(std::ffi::OsString::from)
.unwrap_or_default();
let text_name = name.to_string_lossy().into_owned();
let base = text_name.strip_suffix(".txt").unwrap_or(&text_name);
name = std::ffi::OsString::from(format!("{base}.segments.jsonl"));
mapped.set_file_name(name);
mapped
}
pub fn to_jsonl(segments: &[Segment]) -> Result<String> {
let mut out = String::new();
for segment in segments {
let line = serde_json::to_string(segment).map_err(|e| Error::Encode {
path: std::path::PathBuf::from("segments"),
message: e.to_string(),
})?;
out.push_str(&line);
out.push('\n');
}
Ok(out)
}
pub fn parse_jsonl(content: &str) -> std::result::Result<Vec<Segment>, String> {
content
.lines()
.enumerate()
.map(|(index, line)| {
serde_json::from_str(line).map_err(|e| format!("segments line {}: {e}", index + 1))
})
.collect()
}
pub fn validate(segments: &[Segment], text: &str) -> std::result::Result<(), String> {
let len = text.len() as u64;
let mut previous = (0u64, 0u64);
for (index, segment) in segments.iter().enumerate() {
if segment.start > segment.end {
return Err(format!(
"segment {index}: start {} after end {}",
segment.start, segment.end
));
}
if segment.end > len {
return Err(format!(
"segment {index}: end {} past text length {len}",
segment.end
));
}
if segment.kind != SegmentKind::Span && segment.start != segment.end {
return Err(format!("segment {index}: boundary is not zero-width"));
}
for offset in [segment.start, segment.end] {
if !text.is_char_boundary(offset as usize) {
return Err(format!(
"segment {index}: offset {offset} splits a character"
));
}
}
if (segment.start, segment.end) < previous {
return Err(format!("segment {index}: out of order"));
}
previous = (segment.start, segment.end);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn segments_serialize_with_schema_first() {
let segment = Segment::span(0, 4, "document");
let json = serde_json::to_string(&segment).unwrap();
assert!(json.starts_with(r#"{"schema":"text-mirror/segments@1""#));
let back: Segment = serde_json::from_str(&json).unwrap();
assert_eq!(segment, back);
}
#[test]
fn unknown_segment_schema_is_rejected() {
let json = serde_json::to_string(&Segment::span(0, 1, "document")).unwrap();
let bumped = json.replace("segments@1", "segments@2");
assert!(serde_json::from_str::<Segment>(&bumped).is_err());
}
#[test]
fn segments_path_swaps_the_txt_suffix() {
let path = segments_path(Path::new("/m"), "emea", Path::new("a/Q3 Budget.xlsx"));
assert_eq!(
path,
std::path::PathBuf::from("/m/emea/a/Q3 Budget.xlsx.segments.jsonl")
);
}
#[test]
fn validate_accepts_clean_spans_and_rejects_bad_ones() {
let text = "caf\u{e9} au lait";
let good = vec![
Segment::boundary(SegmentKind::Page, 0),
Segment::span(0, text.len(), "document"),
];
validate(&good, text).unwrap();
let bad = vec![Segment::span(0, 4, "document")];
assert!(validate(&bad, text).unwrap_err().contains("splits"));
let past = vec![Segment::span(0, text.len() + 1, "document")];
assert!(validate(&past, text).unwrap_err().contains("past"));
let unordered = vec![Segment::span(3, 5, "row"), Segment::span(0, 2, "row")];
assert!(validate(&unordered, text).unwrap_err().contains("order"));
}
}