1use std::path::Path;
11
12use serde::de::Error as _;
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use crate::mirror;
16use crate::{Error, Result};
17
18pub const SEGMENTS_SCHEMA: &str = "text-mirror/segments@1";
20
21pub const SEGMENTS_UNIT: &str = "utf8_bytes";
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27pub struct SegmentsSchema;
28
29impl Serialize for SegmentsSchema {
30 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
31 serializer.serialize_str(SEGMENTS_SCHEMA)
32 }
33}
34
35impl<'de> Deserialize<'de> for SegmentsSchema {
36 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
37 let value = String::deserialize(deserializer)?;
38 if value == SEGMENTS_SCHEMA {
39 Ok(SegmentsSchema)
40 } else {
41 Err(D::Error::custom(format!(
42 "unrecognized schema {value:?}, this consumer accepts only {SEGMENTS_SCHEMA:?}"
43 )))
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum SegmentKind {
52 Span,
54 Page,
56 Sheet,
58 Slide,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct Segment {
66 pub schema: SegmentsSchema,
68 pub kind: SegmentKind,
70 pub start: u64,
72 pub end: u64,
74 pub unit: String,
76 #[serde(skip_serializing_if = "Option::is_none", default)]
79 pub source: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none", default)]
82 pub name: Option<String>,
83 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
85 pub hidden: bool,
86}
87
88impl Segment {
89 pub fn span(start: usize, end: usize, source: &str) -> Segment {
91 Segment {
92 schema: SegmentsSchema,
93 kind: SegmentKind::Span,
94 start: start as u64,
95 end: end as u64,
96 unit: SEGMENTS_UNIT.to_string(),
97 source: Some(source.to_string()),
98 name: None,
99 hidden: false,
100 }
101 }
102
103 pub fn boundary(kind: SegmentKind, at: usize) -> Segment {
105 Segment {
106 schema: SegmentsSchema,
107 kind,
108 start: at as u64,
109 end: at as u64,
110 unit: SEGMENTS_UNIT.to_string(),
111 source: None,
112 name: None,
113 hidden: false,
114 }
115 }
116
117 pub fn named(mut self, name: &str) -> Segment {
119 self.name = Some(name.to_string());
120 self
121 }
122
123 pub fn hidden(mut self) -> Segment {
125 self.hidden = true;
126 self
127 }
128}
129
130pub fn segments_path(
133 mirror_root: &Path,
134 division: &str,
135 relative_source: &Path,
136) -> std::path::PathBuf {
137 let mut mapped = mirror::mirror_path(mirror_root, division, relative_source);
138 let mut name = mapped
139 .file_name()
140 .map(std::ffi::OsString::from)
141 .unwrap_or_default();
142 let text_name = name.to_string_lossy().into_owned();
144 let base = text_name.strip_suffix(".txt").unwrap_or(&text_name);
145 name = std::ffi::OsString::from(format!("{base}.segments.jsonl"));
146 mapped.set_file_name(name);
147 mapped
148}
149
150pub fn to_jsonl(segments: &[Segment]) -> Result<String> {
152 let mut out = String::new();
153 for segment in segments {
154 let line = serde_json::to_string(segment).map_err(|e| Error::Encode {
155 path: std::path::PathBuf::from("segments"),
156 message: e.to_string(),
157 })?;
158 out.push_str(&line);
159 out.push('\n');
160 }
161 Ok(out)
162}
163
164pub fn parse_jsonl(content: &str) -> std::result::Result<Vec<Segment>, String> {
167 content
168 .lines()
169 .enumerate()
170 .map(|(index, line)| {
171 serde_json::from_str(line).map_err(|e| format!("segments line {}: {e}", index + 1))
172 })
173 .collect()
174}
175
176pub fn validate(segments: &[Segment], text: &str) -> std::result::Result<(), String> {
182 let len = text.len() as u64;
183 let mut previous = (0u64, 0u64);
184 for (index, segment) in segments.iter().enumerate() {
185 if segment.start > segment.end {
186 return Err(format!(
187 "segment {index}: start {} after end {}",
188 segment.start, segment.end
189 ));
190 }
191 if segment.end > len {
192 return Err(format!(
193 "segment {index}: end {} past text length {len}",
194 segment.end
195 ));
196 }
197 if segment.kind != SegmentKind::Span && segment.start != segment.end {
198 return Err(format!("segment {index}: boundary is not zero-width"));
199 }
200 for offset in [segment.start, segment.end] {
201 if !text.is_char_boundary(offset as usize) {
202 return Err(format!(
203 "segment {index}: offset {offset} splits a character"
204 ));
205 }
206 }
207 if (segment.start, segment.end) < previous {
208 return Err(format!("segment {index}: out of order"));
209 }
210 previous = (segment.start, segment.end);
211 }
212 Ok(())
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn segments_serialize_with_schema_first() {
221 let segment = Segment::span(0, 4, "document");
222 let json = serde_json::to_string(&segment).unwrap();
223 assert!(json.starts_with(r#"{"schema":"text-mirror/segments@1""#));
224 let back: Segment = serde_json::from_str(&json).unwrap();
225 assert_eq!(segment, back);
226 }
227
228 #[test]
229 fn unknown_segment_schema_is_rejected() {
230 let json = serde_json::to_string(&Segment::span(0, 1, "document")).unwrap();
231 let bumped = json.replace("segments@1", "segments@2");
232 assert!(serde_json::from_str::<Segment>(&bumped).is_err());
233 }
234
235 #[test]
236 fn segments_path_swaps_the_txt_suffix() {
237 let path = segments_path(Path::new("/m"), "emea", Path::new("a/Q3 Budget.xlsx"));
238 assert_eq!(
239 path,
240 std::path::PathBuf::from("/m/emea/a/Q3 Budget.xlsx.segments.jsonl")
241 );
242 }
243
244 #[test]
245 fn validate_accepts_clean_spans_and_rejects_bad_ones() {
246 let text = "caf\u{e9} au lait";
247 let good = vec![
248 Segment::boundary(SegmentKind::Page, 0),
249 Segment::span(0, text.len(), "document"),
250 ];
251 validate(&good, text).unwrap();
252
253 let bad = vec![Segment::span(0, 4, "document")];
255 assert!(validate(&bad, text).unwrap_err().contains("splits"));
256
257 let past = vec![Segment::span(0, text.len() + 1, "document")];
258 assert!(validate(&past, text).unwrap_err().contains("past"));
259
260 let unordered = vec![Segment::span(3, 5, "row"), Segment::span(0, 2, "row")];
261 assert!(validate(&unordered, text).unwrap_err().contains("order"));
262 }
263}