1#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Chunk {
12 pub text: String,
13 pub starts_paragraph: bool,
15}
16
17pub fn chunk(text: &str, target_chars: usize) -> Vec<Chunk> {
19 let target = target_chars.max(1);
20 let mut out: Vec<Chunk> = Vec::new();
21
22 for para in text.split("\n\n") {
23 let para = para.trim();
24 if para.is_empty() {
25 continue;
26 }
27 let mut first_of_para = true;
28 let mut buf = String::new();
29
30 for sentence in sentences(para) {
31 for piece in split_oversized(&sentence, target) {
32 if !buf.is_empty() && buf.chars().count() + 1 + piece.chars().count() > target {
33 out.push(Chunk {
34 text: std::mem::take(&mut buf),
35 starts_paragraph: first_of_para,
36 });
37 first_of_para = false;
38 }
39 if !buf.is_empty() {
40 buf.push(' ');
41 }
42 buf.push_str(&piece);
43 }
44 }
45 if !buf.is_empty() {
46 out.push(Chunk { text: buf, starts_paragraph: first_of_para });
47 }
48 }
49 out
50}
51
52fn sentences(text: &str) -> Vec<String> {
61 let mut out = Vec::new();
62 let mut cur = String::new();
63 let mut chars = text.chars().peekable();
64 while let Some(ch) = chars.next() {
65 cur.push(ch);
66 if matches!(ch, '.' | '!' | '?' | ';' | ':' | '\n') {
67 if ch != '\n' {
68 while let Some(&next) = chars.peek() {
69 if matches!(next, '.' | '!' | '?' | ';' | ':') {
70 cur.push(next);
71 chars.next();
72 } else {
73 break;
74 }
75 }
76 }
77 let t = cur.trim().to_string();
78 if !t.is_empty() {
79 out.push(t);
80 }
81 cur.clear();
82 }
83 }
84 let t = cur.trim().to_string();
85 if !t.is_empty() {
86 out.push(t);
87 }
88 out
89}
90
91fn split_oversized(sentence: &str, target: usize) -> Vec<String> {
93 if sentence.chars().count() <= target {
94 return vec![sentence.to_string()];
95 }
96 let mut out = Vec::new();
97 let mut rest = sentence.to_string();
98 while rest.chars().count() > target {
99 let limit = match rest.char_indices().nth(target) {
100 Some((i, _)) => i,
101 None => break,
102 };
103 let head = &rest[..limit];
104 let cut = head
112 .rfind(", ")
113 .map(|i| i + 1)
114 .or_else(|| head.rfind(' '))
115 .or_else(|| rest[limit..].find(' ').map(|off| limit + off))
116 .unwrap_or(rest.len());
117 let (a, b) = rest.split_at(cut);
118 let a = a.trim().to_string();
119 if a.is_empty() {
120 break; }
122 out.push(a);
123 rest = b.trim().to_string();
124 }
125 if !rest.is_empty() {
126 out.push(rest);
127 }
128 out
129}
130
131pub fn refit(chunks: Vec<Chunk>, fits: impl Fn(&str) -> bool) -> Vec<Chunk> {
138 let mut out = Vec::with_capacity(chunks.len());
139 for c in chunks {
140 if fits(&c.text) {
141 out.push(c);
142 continue;
143 }
144 let mut first = true;
145 for piece in halve_until(&c.text, &fits) {
146 out.push(Chunk {
147 text: piece,
148 starts_paragraph: first && c.starts_paragraph,
149 });
150 first = false;
151 }
152 }
153 out
154}
155
156fn halve_until(text: &str, fits: &impl Fn(&str) -> bool) -> Vec<String> {
159 if fits(text) {
160 return vec![text.to_string()];
161 }
162 let words: Vec<&str> = text.split_whitespace().collect();
163 if words.len() < 2 {
164 return vec![text.to_string()]; }
166 let mid = words.len() / 2;
167 let left = words[..mid].join(" ");
168 let right = words[mid..].join(" ");
169 let mut out = halve_until(&left, fits);
170 out.extend(halve_until(&right, fits));
171 out
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn splits_on_sentence_boundaries() {
180 let cs = chunk("One. Two. Three.", 6);
181 assert_eq!(cs.len(), 3);
182 assert_eq!(cs[0].text, "One.");
183 assert_eq!(cs[2].text, "Three.");
184 }
185
186 #[test]
187 fn merges_short_sentences_up_to_the_target() {
188 let cs = chunk("One. Two. Three.", 100);
189 assert_eq!(cs.len(), 1);
190 assert_eq!(cs[0].text, "One. Two. Three.");
191 }
192
193 #[test]
194 fn marks_paragraph_starts() {
195 let cs = chunk("First para.\n\nSecond para.", 100);
196 assert_eq!(cs.len(), 2, "a blank line must force a chunk break");
197 assert!(cs[0].starts_paragraph);
198 assert!(cs[1].starts_paragraph);
199 }
200
201 #[test]
202 fn splits_an_oversized_sentence_on_commas_then_spaces() {
203 let long = "alpha bravo, charlie delta, echo foxtrot golf hotel india juliet";
204 let cs = chunk(long, 20);
205 assert!(cs.len() > 1);
206 for c in &cs {
207 assert!(c.text.chars().count() <= 25, "chunk too long: {:?}", c.text);
208 }
209 }
210
211 #[test]
212 fn never_produces_an_empty_chunk() {
213 for input in ["", " ", "\n\n\n", ".", "a"] {
214 for c in chunk(input, 50) {
215 assert!(!c.text.trim().is_empty(), "empty chunk from {input:?}");
216 }
217 }
218 }
219
220 #[test]
221 fn preserves_all_words() {
222 let input = "The quick brown fox. Jumps over the lazy dog, twice.";
223 let rejoined: String = chunk(input, 15)
224 .iter()
225 .map(|c| c.text.clone())
226 .collect::<Vec<_>>()
227 .join(" ");
228 for word in ["quick", "brown", "jumps", "lazy", "twice"] {
229 assert!(
230 rejoined.to_lowercase().contains(word),
231 "lost {word:?} in {rejoined:?}"
232 );
233 }
234 }
235
236 #[test]
237 fn refit_splits_chunks_that_overrun_the_token_budget() {
238 let cs = vec![Chunk { text: "aaa bbb ccc ddd".into(), starts_paragraph: true }];
240 let out = refit(cs, |s| s.chars().count() <= 7);
242 assert!(out.len() > 1, "expected a split, got {out:?}");
243 for c in &out {
244 assert!(c.text.chars().count() <= 7, "still too long: {:?}", c.text);
245 }
246 }
247
248 #[test]
249 fn refit_keeps_chunks_that_already_fit() {
250 let cs = vec![Chunk { text: "short".into(), starts_paragraph: false }];
251 let out = refit(cs.clone(), |_| true);
252 assert_eq!(out, cs);
253 }
254
255 #[test]
256 fn refit_only_the_first_piece_keeps_the_paragraph_flag() {
257 let cs = vec![Chunk { text: "aaa bbb ccc".into(), starts_paragraph: true }];
258 let out = refit(cs, |s| s.chars().count() <= 3);
259 assert!(out[0].starts_paragraph);
260 assert!(out[1..].iter().all(|c| !c.starts_paragraph));
261 }
262
263 #[test]
264 fn refit_gives_up_on_an_unsplittable_chunk_rather_than_looping() {
265 let cs = vec![Chunk { text: "supercalifragilistic".into(), starts_paragraph: false }];
267 let out = refit(cs, |s| s.chars().count() <= 3);
268 assert_eq!(out.len(), 1, "unsplittable input must be passed through, not looped on");
269 }
270
271 fn assert_no_word_is_shredded(input: &str, target: usize) {
275 let cs = chunk(input, target);
276 let input_words: std::collections::HashSet<String> = input
277 .split_whitespace()
278 .map(|w| w.trim_matches(|c: char| ",.;:!?".contains(c)).to_string())
279 .collect();
280 for c in &cs {
281 for piece in c.text.split_whitespace() {
282 let stripped = piece.trim_matches(|c: char| ",.;:!?".contains(c));
283 assert!(
284 input_words.contains(stripped),
285 "chunk {:?} contains fragment {:?} not present as a whole word in {:?}",
286 c.text,
287 stripped,
288 input
289 );
290 }
291 }
292 }
293
294 #[test]
295 fn split_oversized_keeps_a_long_word_whole_when_alone() {
296 let word = "supercalifragilisticexpialidocious";
297 let cs = chunk(word, 10);
298 assert_eq!(cs.len(), 1);
299 assert_eq!(cs[0].text, word, "a lone unsplittable word must come back whole");
300 }
301
302 #[test]
303 fn split_oversized_keeps_a_long_word_whole_when_embedded() {
304 assert_no_word_is_shredded(
307 "alpha bravo, charlie delta, echo foxtrot golf hotel india juliet",
308 6,
309 );
310 }
311
312 #[test]
313 fn supercalifragilisticexpialidocious_is_long_at_a_small_target() {
314 let input = "supercalifragilisticexpialidocious is long";
315 assert_no_word_is_shredded(input, 20);
316 let cs = chunk(input, 20);
317 let rejoined: String = cs.iter().map(|c| c.text.as_str()).collect::<Vec<_>>().join(" ");
318 assert!(
319 rejoined.split_whitespace().any(|w| w == "supercalifragilisticexpialidocious"),
320 "the long word must survive whole: {rejoined:?}"
321 );
322 }
323
324 #[test]
325 fn word_preservation_property() {
326 let inputs = [
327 "The quick brown fox jumps over the lazy dog.",
328 "alpha bravo, charlie delta, echo foxtrot golf hotel india juliet",
329 "supercalifragilisticexpialidocious is a very long word indeed.",
330 "One. Two. Three. Four. Five. Six. Seven.",
331 "Short sentence here, followed by another, and yet another one for good measure.",
332 "Yes!!! What?! Wait... okay.",
333 ];
334 let targets = [1usize, 3, 6, 10, 20, 50, 100];
335 let strip = |w: &str| w.trim_matches(|c: char| ",.;:!?".contains(c)).to_string();
336
337 for input in inputs {
338 let expected: Vec<String> =
339 input.split_whitespace().map(strip).filter(|w| !w.is_empty()).collect();
340
341 for &target in &targets {
342 let cs = chunk(input, target);
343 let rejoined: String =
344 cs.iter().map(|c| c.text.as_str()).collect::<Vec<_>>().join(" ");
345 let actual: Vec<String> =
346 rejoined.split_whitespace().map(strip).filter(|w| !w.is_empty()).collect();
347 assert_eq!(
348 actual, expected,
349 "word sequence mismatch for {input:?} at target {target}: got chunks {cs:?}"
350 );
351 }
352 }
353 }
354
355 #[test]
356 fn sentences_keeps_runs_of_terminal_punctuation_together() {
357 assert_eq!(sentences("Yes!!!"), vec!["Yes!!!".to_string()]);
358 assert_eq!(sentences("What?!"), vec!["What?!".to_string()]);
359 assert_eq!(sentences("Wait..."), vec!["Wait...".to_string()]);
360 }
361
362 #[test]
363 fn chunk_does_not_insert_spaces_into_a_run_of_terminal_punctuation() {
364 let cs = chunk("... Yes!!!", 100);
367 assert_eq!(cs.len(), 1);
368 assert_eq!(cs[0].text, "... Yes!!!");
369 }
370
371 #[test]
372 fn sentences_still_breaks_at_a_newline_after_terminal_punctuation() {
373 let got = sentences("Wait...\nNext line.");
374 assert_eq!(got, vec!["Wait...".to_string(), "Next line.".to_string()]);
375 }
376}