cratefield_core/text_chunks.rs
1//! Chunk-and-reassemble completion for the [`TextModel`] port (issue #847):
2//! split a long document into bounded chunks, complete each one, and stitch
3//! the answers back together in order.
4//!
5//! A buffered [`TextModel::complete`] is one provider exchange, and that
6//! exchange is bounded by [`HttpPolicy`]: a caller-supplied deadline cannot
7//! exceed [`MAX_RESPONSE_TIMEOUT`], because a request must not spend half
8//! its Worker lifetime waiting on one upstream. A ghostwritten 10,000-word
9//! draft does not fit that budget as a single prompt, so callers have been
10//! hand-splitting the document and stitching the outputs back together.
11//! The splitter here does it once, for everyone: the round trip is exact,
12//! boundaries fall on paragraph boundaries, and a fenced code block is
13//! never cut in half.
14//!
15//! Chunking buys the bound with latency: `n` chunks are `n` sequential
16//! round trips. When the job is generation rather than a bounded rewrite —
17//! a whole document being written, a first token wanted as fast as the
18//! provider can send one — use [`TextModel::stream`] (issue #859) instead,
19//! one request with deltas as they arrive. And when each chunk should come
20//! back as a structured value, build the schema into the prompt the
21//! `prompt` closure returns and pair this with
22//! [`TextModelExt`](crate::TextModelExt).
23//!
24//! ```
25//! use async_trait::async_trait;
26//! use cratefield_core::{
27//! Completion, ModelTier, Prompt, TextModel, TextModelChunkedExt, TextModelError,
28//! };
29//!
30//! // A stub rewriter: answers each chunk with its uppercase form, so the
31//! // example can see each chunk went through the model whole.
32//! struct Shouty;
33//!
34//! #[async_trait]
35//! impl TextModel for Shouty {
36//! async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError> {
37//! let chunk = prompt
38//! .messages
39//! .last()
40//! .map(|turn| turn.content.as_str())
41//! .unwrap_or_default();
42//! Ok(Completion::new(chunk.to_uppercase(), "shouty"))
43//! }
44//! }
45//!
46//! let rewritten = pollster::block_on(Shouty.complete_chunked(
47//! "first paragraph\n\nsecond paragraph",
48//! 20,
49//! |index, total, chunk| {
50//! Prompt::new(ModelTier::Fast)
51//! .system(format!("rewriting chunk {index} of {total}"))
52//! .user(chunk)
53//! },
54//! ))
55//! .unwrap();
56//! assert_eq!(rewritten, "FIRST PARAGRAPH\n\nSECOND PARAGRAPH");
57//! ```
58
59use std::ops::Range;
60
61use async_trait::async_trait;
62
63use crate::ports::{Prompt, TextModel, TextModelError};
64
65/// Splits `text` into chunks of at most `max_chars` chars, on paragraph
66/// boundaries wherever packing allows, and never inside a fenced code
67/// block.
68///
69/// The contract, in the order it matters:
70///
71/// - **Round trip.** The concatenation of the returned chunks is `text`
72/// byte-for-byte, always. Separators travel with blocks: a paragraph or
73/// fenced block keeps the blank-line run that follows it, and a blank
74/// run at the very start of the document rides in front of the first
75/// chunk's content whenever the two fit one chunk together (a run that
76/// cannot stands alone as a whitespace-only chunk —
77/// [`TextModelChunkedExt::complete_chunked`] never asks the model about
78/// one). `\r\n` input round-trips too — the split works on byte
79/// offsets, and a `\r\n` blank line counts as blank.
80/// - **The bound.** Every chunk is at most `max_chars` chars (characters,
81/// not bytes), with exactly one exception: a single fenced code block
82/// longer than `max_chars` is one oversized chunk, because chunks are
83/// never split inside a fence.
84/// - **Paragraph preference.** Boundaries fall on blank-line runs whenever
85/// packing allows, so a chunk is a whole number of paragraphs and
86/// blocks. (The blank-line runs themselves are packing items: a run that
87/// no longer fits a chunk starts the next one, still never inside a
88/// paragraph.)
89/// - **Fences.** A line whose leading-whitespace-stripped form starts with
90/// three or more backticks or tildes opens a fence; it closes at a line
91/// whose trimmed form is a run of the same marker at least as long, with
92/// no info string. A blank line inside a fence stays inside it, an
93/// unclosed fence runs to the end of the input as one (possibly
94/// oversized) chunk, and a four-backtick fence does not close at an
95/// inner three-backtick line. Indented (four-space) code blocks are
96/// *not* fence-tracked: they are ordinary lines and may split at line
97/// boundaries.
98/// - **Fallbacks.** A plain block longer than `max_chars` splits at line
99/// boundaries, greedily packing whole lines; a single line longer than
100/// the bound is hard-cut every `max_chars` chars, always on a char
101/// boundary — multibyte input never splits mid-character.
102///
103/// Empty input returns an empty [`Vec`]; whitespace-only input is a single
104/// chunk holding it, however long — there is nothing in it to model.
105///
106/// # Panics
107///
108/// Panics if `max_chars` is zero: a zero-char chunk cannot hold anything.
109///
110/// # Examples
111///
112/// ```
113/// use cratefield_core::{reassemble_text_chunks, split_text_chunks};
114///
115/// let text = "first paragraph\n\nsecond paragraph";
116/// let chunks = split_text_chunks(text, 20);
117/// assert_eq!(chunks, vec!["first paragraph\n\n", "second paragraph"]);
118/// assert_eq!(reassemble_text_chunks(&chunks), text);
119/// ```
120pub fn split_text_chunks(text: &str, max_chars: usize) -> Vec<String> {
121 assert!(max_chars > 0, "max_chars must be at least 1, got 0");
122 if text.is_empty() {
123 return Vec::new();
124 }
125 if text.chars().all(char::is_whitespace) {
126 // Whitespace only, however long: one chunk. Splitting it would
127 // only manufacture chunks of padding for the model to answer.
128 return vec![text.to_owned()];
129 }
130
131 let mut chunks: Vec<String> = Vec::new();
132 let mut current = String::new();
133 let mut current_chars = 0usize;
134
135 for item in scan_items(text) {
136 let item_text = &text[item.range];
137 let item_chars = item_text.chars().count();
138 if current_chars + item_chars <= max_chars {
139 current.push_str(item_text);
140 current_chars += item_chars;
141 continue;
142 }
143 // The item cannot join the current chunk: close it.
144 if current_chars > 0 {
145 chunks.push(std::mem::take(&mut current));
146 current_chars = 0;
147 }
148 if item_chars <= max_chars {
149 // The item starts the next chunk whole.
150 current.push_str(item_text);
151 current_chars = item_chars;
152 } else if item.fenced {
153 // The one bound exception: a fenced block longer than the
154 // bound is atomic, so it stands alone as one oversized chunk.
155 chunks.push(item_text.to_owned());
156 } else {
157 chunks.extend(split_oversized(item_text, max_chars));
158 }
159 }
160 if current_chars > 0 {
161 chunks.push(current);
162 }
163 chunks
164}
165
166/// Reassembles per-chunk completions in order: plain concatenation, with
167/// no separator inserted, because [`split_text_chunks`] kept every
168/// separator inside the chunks. Feeding it the chunks themselves
169/// reproduces the input byte-for-byte; feeding it a model's per-chunk
170/// answers reproduces the document's shape, whatever the model wrote.
171///
172/// ```
173/// use cratefield_core::reassemble_text_chunks;
174///
175/// assert_eq!(reassemble_text_chunks(["one", "two", "three"]), "onetwothree");
176/// assert_eq!(reassemble_text_chunks(Vec::<&str>::new()), "");
177/// ```
178pub fn reassemble_text_chunks(chunks: impl IntoIterator<Item: AsRef<str>>) -> String {
179 // A `map(..).collect()` here would borrow each chunk from the closure's
180 // by-value parameter, which rustc refuses to let escape into a return
181 // (E0515); the loop copies each chunk into the answer instead.
182 let mut text = String::new();
183 for chunk in chunks {
184 text.push_str(chunk.as_ref());
185 }
186 text
187}
188
189/// Chunk-and-reassemble companion to
190/// [`TextModelExt`](crate::TextModelExt) (issue #847). Blanket-implemented
191/// for every `TextModel`, so any model — `Arc<dyn TextModel>` included —
192/// gets it; import the trait to call the method.
193#[async_trait]
194pub trait TextModelChunkedExt: TextModel {
195 /// Completes `text` in chunks of at most `max_chars` chars: splits it
196 /// with [`split_text_chunks`], calls [`TextModel::complete`] once per
197 /// chunk — sequentially, in order, chunk *n* asked only after *n−1*
198 /// answered — and reassembles the answers with
199 /// [`reassemble_text_chunks`].
200 ///
201 /// `prompt(chunk_index, total_chunks, chunk_text)` builds each request;
202 /// the helper never builds a prompt itself, so the tier, the system
203 /// instruction and the token ceiling stay the caller's. Chunk indexes
204 /// are 0-based and `total_chunks` is every chunk sent to the model, so
205 /// a closure can say "part 3 of 7" without counting anything.
206 ///
207 /// Empty `text` returns `Ok(String::new())` without calling the model;
208 /// whitespace-only `text` returns itself unchanged, also without
209 /// calling it. The same rule applies per chunk: a whitespace-only
210 /// chunk — a blank run that could not share a chunk with either
211 /// neighbour — passes through untouched at no round trip, and is not
212 /// counted in `total_chunks`: there is nothing in it to rewrite.
213 ///
214 /// # Errors
215 ///
216 /// Whatever [`TextModel::complete`] returned for the chunk that
217 /// failed, unchanged — there is no wrapper variant on
218 /// [`TextModelError`] that could name the chunk without mislabelling
219 /// the failure (`Transient` must keep its `retry_after`, and the
220 /// message-carrying variants are documented as provider text). The run
221 /// stops at the first failure and completed chunk work is discarded.
222 ///
223 /// # Panics
224 ///
225 /// Panics if `max_chars` is zero, via [`split_text_chunks`].
226 async fn complete_chunked(
227 &self,
228 text: &str,
229 max_chars: usize,
230 // `for<'a>`: an elided `&str` here would be desugared by
231 // `#[async_trait]` into a lifetime of the returned future, and no
232 // chunk borrowed inside the body could ever satisfy it.
233 prompt: impl for<'a> FnMut(usize, usize, &'a str) -> Prompt + Send,
234 ) -> Result<String, TextModelError>;
235}
236
237#[async_trait]
238impl<M: TextModel + ?Sized> TextModelChunkedExt for M {
239 async fn complete_chunked(
240 &self,
241 text: &str,
242 max_chars: usize,
243 mut prompt: impl for<'a> FnMut(usize, usize, &'a str) -> Prompt + Send,
244 ) -> Result<String, TextModelError> {
245 let chunks = split_text_chunks(text, max_chars);
246 let total = chunks
247 .iter()
248 .filter(|chunk| !chunk.chars().all(char::is_whitespace))
249 .count();
250 let mut answers = Vec::with_capacity(chunks.len());
251 let mut index = 0usize;
252 for chunk in &chunks {
253 if chunk.chars().all(char::is_whitespace) {
254 // A blank run can end up in a chunk of its own when it cannot
255 // share one with either neighbour. There is nothing in it to
256 // rewrite: pass it through untouched, at no round trip.
257 answers.push(chunk.clone());
258 continue;
259 }
260 let completion = self.complete(&prompt(index, total, chunk)).await?;
261 answers.push(completion.text);
262 index += 1;
263 }
264 Ok(reassemble_text_chunks(answers))
265 }
266}
267
268/// One scanned run of the document: consecutive lines of one kind, as a
269/// byte range into the input. Ranges are consecutive and cover the input
270/// exactly, which is what makes the round trip byte-for-byte.
271struct Item {
272 range: Range<usize>,
273 /// A fenced code block, opener line through closer line — atomic, and
274 /// the one thing allowed to exceed the bound.
275 fenced: bool,
276}
277
278/// The kind of a plain, non-fenced run of lines. A fence is never
279/// accumulated — it is pushed whole when its closer (or end of input) is
280/// found — so it needs no kind here.
281#[derive(Clone, Copy, PartialEq, Eq)]
282enum TextKind {
283 /// Blank lines, the separators between blocks.
284 Blank,
285 /// Non-blank lines, a paragraph.
286 Paragraph,
287}
288
289/// Scans `text` into ordered items covering it exactly: blank-line runs,
290/// paragraphs (maximal runs of non-blank lines), and fenced code blocks
291/// (opener through closer, or to the end of the input when unclosed).
292/// Fence state is tracked line by line, so a blank line inside a fence
293/// stays inside it instead of ending a text block.
294fn scan_items(text: &str) -> Vec<Item> {
295 let mut items: Vec<Item> = Vec::new();
296 // The plain item being accumulated: its kind and its byte range so
297 // far, `end` always the end of the last line consumed.
298 let mut kind: Option<TextKind> = None;
299 let mut start = 0;
300 let mut end = 0;
301 // The open fence's marker byte and opener run length, plus the byte
302 // offset the fence item started at.
303 let mut fence: Option<(u8, usize)> = None;
304 let mut fence_start = 0;
305
306 let mut i = 0;
307 while i < text.len() {
308 let line_start = i;
309 let line_end = match text[i..].find('\n') {
310 Some(at) => i + at + 1,
311 None => text.len(),
312 };
313 let trimmed = text[line_start..line_end].trim();
314
315 if let Some((marker, count)) = fence {
316 if closes_fence(trimmed, marker, count) {
317 fence = None;
318 items.push(Item {
319 range: fence_start..line_end,
320 fenced: true,
321 });
322 kind = None;
323 }
324 // Any other line — blank included — is fence body.
325 } else if trimmed.is_empty() {
326 if kind != Some(TextKind::Blank) {
327 push_text(&mut items, kind, start, end);
328 kind = Some(TextKind::Blank);
329 start = line_start;
330 }
331 } else if let Some((marker, count)) = opens_fence(trimmed) {
332 push_text(&mut items, kind, start, end);
333 kind = None;
334 fence = Some((marker, count));
335 fence_start = line_start;
336 } else if kind != Some(TextKind::Paragraph) {
337 push_text(&mut items, kind, start, end);
338 kind = Some(TextKind::Paragraph);
339 start = line_start;
340 }
341 end = line_end;
342 i = line_end;
343 }
344
345 if fence.is_some() {
346 // An unclosed fence runs to the end of the input.
347 items.push(Item {
348 range: fence_start..text.len(),
349 fenced: true,
350 });
351 } else {
352 push_text(&mut items, kind, start, end);
353 }
354 items
355}
356
357/// Closes the accumulated plain item, if there is one. Fences are pushed
358/// when their closer is found, or at end of input when unclosed, never
359/// here.
360fn push_text(items: &mut Vec<Item>, kind: Option<TextKind>, start: usize, end: usize) {
361 if matches!(kind, Some(TextKind::Blank | TextKind::Paragraph)) && end > start {
362 items.push(Item {
363 range: start..end,
364 fenced: false,
365 });
366 }
367}
368
369/// The fence a line opens, if any: a leading-whitespace-stripped line
370/// starting with three or more backticks or tildes. The run length is the
371/// fence's opener count; everything after the run is the info string.
372fn opens_fence(trimmed: &str) -> Option<(u8, usize)> {
373 let bytes = trimmed.as_bytes();
374 let marker = *bytes.first()?;
375 if marker != b'`' && marker != b'~' {
376 return None;
377 }
378 let count = bytes.iter().take_while(|&&byte| byte == marker).count();
379 (count >= 3).then_some((marker, count))
380}
381
382/// Whether `trimmed` closes the fence opened with `marker` repeated
383/// `count` times: a run of the same marker at least as long and nothing
384/// else — no info string. The caller trims the line, which allows
385/// surrounding whitespace.
386fn closes_fence(trimmed: &str, marker: u8, count: usize) -> bool {
387 let bytes = trimmed.as_bytes();
388 bytes.len() >= count && bytes.iter().all(|&byte| byte == marker)
389}
390
391/// Splits an oversized plain item at line boundaries, greedily packing
392/// whole lines up to `max_chars`; a single line longer than the bound is
393/// hard-cut every `max_chars` chars, always on a char boundary.
394fn split_oversized(text: &str, max_chars: usize) -> Vec<String> {
395 let mut pieces: Vec<String> = Vec::new();
396 let mut current = String::new();
397 let mut current_chars = 0usize;
398
399 let mut i = 0;
400 while i < text.len() {
401 let line_end = match text[i..].find('\n') {
402 Some(at) => i + at + 1,
403 None => text.len(),
404 };
405 let line = &text[i..line_end];
406 i = line_end;
407 let line_chars = line.chars().count();
408 if line_chars > max_chars {
409 if current_chars > 0 {
410 pieces.push(std::mem::take(&mut current));
411 current_chars = 0;
412 }
413 let mut cut = String::new();
414 let mut count = 0usize;
415 for ch in line.chars() {
416 cut.push(ch);
417 count += 1;
418 if count == max_chars {
419 pieces.push(std::mem::take(&mut cut));
420 count = 0;
421 }
422 }
423 if !cut.is_empty() {
424 pieces.push(cut);
425 }
426 } else if current_chars + line_chars <= max_chars {
427 current.push_str(line);
428 current_chars += line_chars;
429 } else {
430 pieces.push(std::mem::take(&mut current));
431 current.push_str(line);
432 current_chars = line_chars;
433 }
434 }
435 if current_chars > 0 {
436 pieces.push(current);
437 }
438 pieces
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 // ---- The fixture corpus ----
446
447 /// The corpus every property test runs over: the document shapes a
448 /// ghostwriting caller actually has.
449 fn corpus() -> Vec<(&'static str, &'static str)> {
450 vec![
451 (
452 "plain paragraphs",
453 "The quick brown fox.\n\nSecond paragraph here.\n\nThird one.\n",
454 ),
455 (
456 "crlf",
457 "para one\r\n\r\npara two\r\n```rust\r\nlet x = 1;\r\n```\r\n\r\nafter\r\n",
458 ),
459 (
460 "backtick fences with info string and blank lines inside",
461 "intro\n\n```rust\nfn main() {}\n\nlet x = 1;\n```\n\noutro\n",
462 ),
463 ("tilde fence", "before\n\n~~~text\nbody\n~~~\nafter"),
464 ("unclosed fence", "text\n\n```python\nprint(1)\nprint(2)"),
465 (
466 "long fence",
467 "```js\nconst a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nconst e = 5;\n```\n",
468 ),
469 (
470 "four-backtick fence around a three-backtick line",
471 "````\n```\nnot a close\n```\nstill inside\n````\ntail\n",
472 ),
473 (
474 "indented fence",
475 "before\n\n ```js\n const a = 1;\n ```\nafter\n",
476 ),
477 ("blank runs at the edges", "\n\n\nlead\n\ntrail\n\n\n"),
478 ("whitespace only", " \n\t\n \n"),
479 ("empty", ""),
480 ("one line", "one line only"),
481 (
482 "multibyte",
483 "héllo wörld: 日本語のテキストもここに。Émile résume.",
484 ),
485 (
486 "paragraph, fence, paragraph",
487 "alpha\n\n```\ncode\n```\n\nomega\n",
488 ),
489 (
490 "many short lines",
491 "aaa bbb\nccc ddd\neee fff\nggg hhh\niii jjj\nkkk lll\n",
492 ),
493 ]
494 }
495
496 // ---- Round trip and the bound, over the whole corpus ----
497
498 #[test]
499 fn every_fixture_round_trips_byte_for_byte_at_many_bounds() {
500 for (name, text) in corpus() {
501 for max_chars in [1, 2, 3, 5, 8, 13, 40, 10_000] {
502 let chunks = split_text_chunks(text, max_chars);
503 assert_eq!(
504 reassemble_text_chunks(&chunks),
505 text,
506 "{name} at max_chars {max_chars}"
507 );
508 }
509 }
510 }
511
512 #[test]
513 fn every_chunk_respects_the_bound_except_atomic_fences() {
514 for (name, text) in corpus() {
515 if text.chars().all(char::is_whitespace) {
516 continue; // one chunk by contract, however long
517 }
518 for max_chars in [1, 2, 3, 5, 8, 13, 40] {
519 for chunk in split_text_chunks(text, max_chars) {
520 let chars = chunk.chars().count();
521 assert!(
522 chars <= max_chars || is_single_atomic_fence(&chunk),
523 "{name} at max_chars {max_chars}: {chars}-char chunk {chunk:?}"
524 );
525 }
526 }
527 }
528 }
529
530 #[test]
531 fn every_chunk_of_a_fence_heavy_fixture_has_balanced_fences() {
532 for (name, text) in corpus() {
533 for max_chars in [1, 5, 8, 13, 40] {
534 let chunks = split_text_chunks(text, max_chars);
535 // No fence is ever cut: a chunk either balances its
536 // fences, or it is the last chunk, ending inside the one
537 // fence that runs unclosed to the end of the input.
538 let open_at_end: Vec<bool> = chunks
539 .iter()
540 .map(|chunk| open_fence_at_end(chunk).is_some())
541 .collect();
542 let open_count = open_at_end.iter().filter(|open| **open).count();
543 assert!(
544 open_count == 0 || (open_count == 1 && open_at_end[open_at_end.len() - 1]),
545 "{name} at max_chars {max_chars}: a fence crosses a chunk: {chunks:?}"
546 );
547 }
548 }
549 }
550
551 /// Whether `chunk` is exactly one fenced block: the first line opens a
552 /// fence and the fence closes at the chunk's last line (or runs to the
553 /// end of the chunk unclosed) — the shape of the one bound exception.
554 /// The opener line is not a closer candidate for its own fence.
555 fn is_single_atomic_fence(chunk: &str) -> bool {
556 let lines: Vec<&str> = chunk.lines().collect();
557 let Some((marker, count)) = lines
558 .first()
559 .copied()
560 .and_then(|line| opens_fence(line.trim_start()))
561 else {
562 return false;
563 };
564 match lines[1..]
565 .iter()
566 .position(|line| closes_fence(line.trim(), marker, count))
567 {
568 Some(at) => at + 1 == lines.len() - 1,
569 None => true,
570 }
571 }
572
573 /// The fence left open at the chunk's end, if any — the unclosed-fence
574 /// shape, where the fence runs to the end of the input.
575 fn open_fence_at_end(chunk: &str) -> Option<(u8, usize)> {
576 let mut open: Option<(u8, usize)> = None;
577 for line in chunk.lines() {
578 if let Some((marker, count)) = open {
579 if closes_fence(line.trim(), marker, count) {
580 open = None;
581 }
582 } else if let Some((marker, count)) = opens_fence(line.trim_start()) {
583 open = Some((marker, count));
584 }
585 }
586 open
587 }
588
589 // ---- Paragraph preference ----
590
591 #[test]
592 fn breaks_land_on_blank_line_runs_not_inside_paragraphs() {
593 let text = "alpha bravo\n\ncharlie delta\n\necho foxtrot";
594 let chunks = split_text_chunks(text, 30);
595 assert_eq!(
596 chunks,
597 vec!["alpha bravo\n\ncharlie delta\n\n", "echo foxtrot"],
598 "the break is the blank run between the second and third paragraph"
599 );
600 }
601
602 #[test]
603 fn a_crlf_blank_line_counts_as_blank_and_travels_with_its_paragraph() {
604 let text = "para one\r\n\r\npara two is longer\r\n";
605 let chunks = split_text_chunks(text, 14);
606 assert_eq!(
607 chunks[0], "para one\r\n\r\n",
608 "the CRLF blank run is a boundary"
609 );
610 assert_eq!(
611 chunks[1..],
612 ["para two is lo", "nger\r\n"],
613 "the oversized paragraph falls back to line and char splits"
614 );
615 assert_eq!(reassemble_text_chunks(&chunks), text);
616 }
617
618 #[test]
619 fn blank_runs_at_the_edges_stay_with_their_neighbours() {
620 let text = "\n\n\nlead\n\ntrail\n\n\n";
621 let chunks = split_text_chunks(text, 9);
622 assert_eq!(
623 chunks,
624 vec!["\n\n\nlead\n\n", "trail\n\n\n"],
625 "the leading run rides with the first block, the trailing run with the last"
626 );
627 }
628
629 #[test]
630 fn a_leading_blank_run_that_cannot_fit_stands_alone_rather_than_bursting_the_bound() {
631 let chunks = split_text_chunks("\n\n\nfirst paragraph body", 20);
632 assert_eq!(chunks, vec!["\n\n\n", "first paragraph body"]);
633 assert_eq!(
634 reassemble_text_chunks(&chunks),
635 "\n\n\nfirst paragraph body"
636 );
637 }
638
639 // ---- Fences are atomic ----
640
641 #[test]
642 fn a_fence_is_never_split_even_when_it_exceeds_the_bound() {
643 let fence = "```js\nconst a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\n```\n";
644 let text = format!("before\n\n{fence}after\n");
645 let chunks = split_text_chunks(&text, 10);
646 assert!(
647 chunks.contains(&fence.to_owned()),
648 "the oversized fence stands alone as one chunk: {chunks:?}"
649 );
650 assert_eq!(reassemble_text_chunks(&chunks), text);
651 }
652
653 #[test]
654 fn a_four_backtick_fence_does_not_close_at_an_inner_three_backtick_line() {
655 let text = "````\n```\ninner\n```\nmore\n````\ntail\n";
656 // Bounds below the 29-char fence, so it is oversized and must
657 // stand alone, inner ``` and all.
658 for max_chars in [4, 6, 10, 28] {
659 let chunks = split_text_chunks(text, max_chars);
660 let fence = chunks
661 .iter()
662 .find(|chunk| chunk.starts_with("````"))
663 .unwrap_or_else(|| {
664 panic!("the ```` block is one chunk at {max_chars}: {chunks:?}")
665 });
666 assert!(
667 fence.contains("```\ninner") && fence.ends_with("````\n"),
668 "the inner ``` did not close the ```` fence at {max_chars}: {fence:?}"
669 );
670 assert_eq!(reassemble_text_chunks(&chunks), text);
671 }
672 // And when everything fits, the ```` block is intact in the one
673 // chunk.
674 assert_eq!(split_text_chunks(text, 40), vec![text]);
675 }
676
677 #[test]
678 fn an_unclosed_fence_runs_to_the_end_of_the_input() {
679 let text = "intro\n\n```python\nprint(1)\nprint(2)";
680 let chunks = split_text_chunks(text, 8);
681 assert_eq!(reassemble_text_chunks(&chunks), text);
682 let last = chunks
683 .last()
684 .unwrap_or_else(|| panic!("chunks: {chunks:?}"));
685 assert!(last.starts_with("```python\n"), "{last:?}");
686 assert!(
687 is_single_atomic_fence(last),
688 "the unclosed fence is one atomic chunk: {last:?}"
689 );
690 }
691
692 #[test]
693 fn blank_lines_inside_a_fence_stay_inside_it() {
694 let text = "```rust\nfn main() {}\n\n\nlet x = 1;\n```\n";
695 let chunks = split_text_chunks(text, 7);
696 assert_eq!(
697 chunks,
698 vec![text],
699 "the blank lines did not end the fence, so the whole block is one chunk"
700 );
701 }
702
703 // ---- Fallbacks for oversized plain blocks ----
704
705 #[test]
706 fn an_oversized_paragraph_splits_at_line_boundaries() {
707 let chunks = split_text_chunks("aaa bbb\nccc ddd\neee fff", 8);
708 assert_eq!(chunks, vec!["aaa bbb\n", "ccc ddd\n", "eee fff"]);
709 }
710
711 #[test]
712 fn an_oversized_line_is_hard_cut_on_char_boundaries() {
713 let text = "héllo 日本語 wörld";
714 let chunks = split_text_chunks(text, 3);
715 assert_eq!(reassemble_text_chunks(&chunks), text);
716 assert_eq!(
717 chunks,
718 vec!["hél", "lo ", "日本語", " wö", "rld"],
719 "every cut is every third char, never mid-character"
720 );
721 }
722
723 // ---- Edges ----
724
725 #[test]
726 fn empty_input_has_no_chunks() {
727 assert_eq!(split_text_chunks("", 100), Vec::<String>::new());
728 }
729
730 #[test]
731 fn whitespace_only_input_is_one_chunk() {
732 let text = " \n\t\n ";
733 assert_eq!(split_text_chunks(text, 2), vec![text]);
734 }
735
736 #[test]
737 fn text_with_no_split_points_is_one_chunk() {
738 assert_eq!(
739 split_text_chunks("one line only", 100),
740 vec!["one line only"]
741 );
742 }
743
744 #[test]
745 fn a_document_that_fits_is_one_chunk() {
746 let text = "para\n\n```js\ncode\n```\n\nfin\n";
747 assert_eq!(split_text_chunks(text, 1_000), vec![text]);
748 }
749
750 #[test]
751 #[should_panic(expected = "max_chars")]
752 fn a_zero_bound_panics() {
753 split_text_chunks("text", 0);
754 }
755
756 // ---- Reassembly ----
757
758 #[test]
759 fn reassembly_is_plain_concatenation_in_order() {
760 assert_eq!(reassemble_text_chunks(["a", "b", "c"]), "abc");
761 assert_eq!(
762 reassemble_text_chunks(vec![String::new(), "x".to_owned()]),
763 "x"
764 );
765 assert_eq!(reassemble_text_chunks([""]), "");
766 assert_eq!(reassemble_text_chunks(Vec::<&str>::new()), "");
767 }
768}