Skip to main content

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}