1use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "lowercase")]
20pub enum ChunkBoundary {
21 Paragraph,
24 Sentence,
26 Fixed,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
32#[serde(default)]
33#[schemars(transform = crate::schema::strip_int_formats)]
34pub struct ChunkPolicy {
35 pub max_chunk_bytes: usize,
37 pub overlap_bytes: usize,
40 pub boundary: ChunkBoundary,
42}
43
44impl Default for ChunkPolicy {
45 fn default() -> Self {
46 Self {
47 max_chunk_bytes: 2_048,
48 overlap_bytes: 0,
49 boundary: ChunkBoundary::Paragraph,
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct TextChunk {
57 pub text: String,
59 pub byte_range: core::ops::Range<usize>,
61 pub index: usize,
63}
64
65#[must_use]
67pub fn chunk_text(text: &str, policy: &ChunkPolicy) -> Vec<TextChunk> {
68 if text.is_empty() {
69 return Vec::new();
70 }
71 let max = policy.max_chunk_bytes.max(1);
72 if text.len() <= max {
73 return vec![TextChunk {
74 text: text.to_owned(),
75 byte_range: 0..text.len(),
76 index: 0,
77 }];
78 }
79 let ranges = pack_units(text, &units(text, policy.boundary), max);
80 assemble(text, &ranges, policy.overlap_bytes)
81}
82
83struct Unit {
91 range: core::ops::Range<usize>,
92 atomic: bool,
93}
94
95fn units(text: &str, boundary: ChunkBoundary) -> Vec<Unit> {
98 let mut units = Vec::new();
99 for segment in fence_segments(text) {
100 match segment {
101 Segment::Fence(range) => units.push(Unit {
102 range,
103 atomic: true,
104 }),
105 Segment::Plain(range) => split_plain(text, range, boundary, &mut units),
106 }
107 }
108 units
109}
110
111enum Segment {
113 Fence(core::ops::Range<usize>),
115 Plain(core::ops::Range<usize>),
117}
118
119fn fence_segments(text: &str) -> Vec<Segment> {
122 let mut segments = Vec::new();
123 let mut cursor = 0_usize;
124 let mut fence_start: Option<usize> = None;
125 let mut line_start = 0_usize;
126 for line in text.split_inclusive('\n') {
127 let opens_or_closes = line.trim_start().starts_with("```");
128 let line_end = line_start + line.len();
129 match (fence_start, opens_or_closes) {
130 (None, true) => {
131 if line_start > cursor {
132 segments.push(Segment::Plain(cursor..line_start));
133 }
134 fence_start = Some(line_start);
135 }
136 (Some(start), true) => {
137 segments.push(Segment::Fence(start..line_end));
138 fence_start = None;
139 cursor = line_end;
140 }
141 _ => {}
142 }
143 line_start = line_end;
144 }
145 push_tail(&mut segments, fence_start, cursor, text.len());
146 segments
147}
148
149fn push_tail(segments: &mut Vec<Segment>, fence_start: Option<usize>, cursor: usize, end: usize) {
152 match fence_start {
153 Some(start) => segments.push(Segment::Fence(start..end)),
154 None if cursor < end => segments.push(Segment::Plain(cursor..end)),
155 None => {}
156 }
157}
158
159fn split_plain(
163 text: &str,
164 range: core::ops::Range<usize>,
165 boundary: ChunkBoundary,
166 out: &mut Vec<Unit>,
167) {
168 let slice = &text[range.clone()];
169 let mut piece_start = 0_usize;
170 for cut in boundary_cuts(slice, boundary) {
171 out.push(Unit {
172 range: range.start + piece_start..range.start + cut,
173 atomic: false,
174 });
175 piece_start = cut;
176 }
177 if piece_start < slice.len() {
178 out.push(Unit {
179 range: range.start + piece_start..range.end,
180 atomic: false,
181 });
182 }
183}
184
185fn boundary_cuts(slice: &str, boundary: ChunkBoundary) -> Vec<usize> {
188 match boundary {
189 ChunkBoundary::Paragraph => paragraph_cuts(slice),
190 ChunkBoundary::Sentence => sentence_cuts(slice),
191 ChunkBoundary::Fixed => Vec::new(),
192 }
193}
194
195fn paragraph_cuts(slice: &str) -> Vec<usize> {
197 let mut cuts = Vec::new();
198 let bytes = slice.as_bytes();
199 let mut i = 0_usize;
200 while let Some(found) = find_from(bytes, i, b"\n\n") {
201 let mut end = found + 2;
202 while bytes.get(end) == Some(&b'\n') {
203 end += 1;
204 }
205 cuts.push(end);
206 i = end;
207 }
208 cuts
209}
210
211fn sentence_cuts(slice: &str) -> Vec<usize> {
213 let mut cuts = Vec::new();
214 let mut previous: Option<char> = None;
215 for (offset, ch) in slice.char_indices() {
216 let after_ender = matches!(previous, Some('.' | '!' | '?'));
217 if after_ender && ch.is_whitespace() {
218 cuts.push(offset + ch.len_utf8());
219 }
220 previous = Some(ch);
221 }
222 cuts
223}
224
225fn find_from(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
227 haystack
228 .get(from..)?
229 .windows(needle.len())
230 .position(|window| window == needle)
231 .map(|position| from + position)
232}
233
234fn pack_units(text: &str, units: &[Unit], max: usize) -> Vec<core::ops::Range<usize>> {
238 let mut chunks: Vec<core::ops::Range<usize>> = Vec::new();
239 let mut open: Option<core::ops::Range<usize>> = None;
240 for unit in units {
241 if unit.range.len() > max {
242 flush(&mut chunks, &mut open);
243 append_oversized(text, unit, max, &mut chunks);
244 } else {
245 open = Some(merge_or_flush(&mut chunks, open, unit.range.clone(), max));
246 }
247 }
248 flush(&mut chunks, &mut open);
249 chunks
250}
251
252fn merge_or_flush(
255 chunks: &mut Vec<core::ops::Range<usize>>,
256 open: Option<core::ops::Range<usize>>,
257 unit: core::ops::Range<usize>,
258 max: usize,
259) -> core::ops::Range<usize> {
260 match open {
261 Some(range) if unit.end - range.start <= max => range.start..unit.end,
262 Some(range) => {
263 chunks.push(range);
264 unit
265 }
266 None => unit,
267 }
268}
269
270fn flush(chunks: &mut Vec<core::ops::Range<usize>>, open: &mut Option<core::ops::Range<usize>>) {
272 if let Some(range) = open.take() {
273 chunks.push(range);
274 }
275}
276
277fn append_oversized(
281 text: &str,
282 unit: &Unit,
283 max: usize,
284 chunks: &mut Vec<core::ops::Range<usize>>,
285) {
286 if unit.atomic {
287 chunks.push(unit.range.clone());
288 return;
289 }
290 let mut start = unit.range.start;
291 while start < unit.range.end {
292 let floored = char_floor(text, (start + max).min(unit.range.end));
293 let end = if floored > start {
296 floored
297 } else {
298 char_ceil(text, start + 1).min(unit.range.end)
299 };
300 chunks.push(start..end);
301 start = end;
302 }
303}
304
305fn char_floor(text: &str, at: usize) -> usize {
307 let mut boundary = at.min(text.len());
308 while !text.is_char_boundary(boundary) {
309 boundary -= 1;
310 }
311 boundary
312}
313
314fn assemble(
317 text: &str,
318 ranges: &[core::ops::Range<usize>],
319 overlap_bytes: usize,
320) -> Vec<TextChunk> {
321 ranges
322 .iter()
323 .enumerate()
324 .map(|(index, range)| {
325 let mut chunk_text = String::new();
326 if overlap_bytes > 0 && index > 0 {
327 let overlap_start = char_ceil(text, range.start.saturating_sub(overlap_bytes));
328 chunk_text.push_str(&text[overlap_start..range.start]);
329 }
330 chunk_text.push_str(&text[range.clone()]);
331 TextChunk {
332 text: chunk_text,
333 byte_range: range.clone(),
334 index,
335 }
336 })
337 .collect()
338}
339
340fn char_ceil(text: &str, at: usize) -> usize {
342 let mut boundary = at.min(text.len());
343 while !text.is_char_boundary(boundary) {
344 boundary += 1;
345 }
346 boundary
347}
348
349#[cfg(test)]
350#[path = "chunk_tests.rs"]
351mod tests;