Skip to main content

adk_rag/
chunking.rs

1//! Document chunking strategies.
2//!
3//! This module provides the [`Chunker`] trait and three implementations:
4//!
5//! - [`FixedSizeChunker`] — splits by byte length with configurable overlap
6//! - [`RecursiveChunker`] — splits hierarchically by paragraphs, sentences, then words
7//! - [`MarkdownChunker`] — splits by markdown headers, preserving header context
8//!
9//! Sizes are measured in bytes of UTF-8, not characters. Chunks never split a
10//! character, so a chunk is at most `chunk_size` bytes unless a single character
11//! is wider than `chunk_size`, in which case that character forms a chunk alone.
12
13use tracing::warn;
14
15use crate::document::{Chunk, Document};
16use crate::error::{RagError, Result};
17
18/// MSRV-compatible replacement for `str::floor_char_boundary` (stable since 1.91.0).
19/// Returns the largest byte index `<= index` that is a valid char boundary.
20fn floor_char_boundary(s: &str, index: usize) -> usize {
21    if index >= s.len() {
22        return s.len();
23    }
24    let mut i = index;
25    while i > 0 && !s.is_char_boundary(i) {
26        i -= 1;
27    }
28    i
29}
30
31/// Returns the byte index just past the character that starts at `index`.
32fn next_char_boundary(s: &str, index: usize) -> usize {
33    s[index..].chars().next().map_or(s.len(), |c| index + c.len_utf8())
34}
35
36/// Checks that `chunk_size` is positive and `chunk_overlap` is smaller than it.
37fn validate_sizes(chunk_size: usize, chunk_overlap: usize) -> Result<()> {
38    if chunk_size == 0 {
39        return Err(RagError::ConfigError("chunk_size must be greater than zero".to_string()));
40    }
41    if chunk_overlap >= chunk_size {
42        return Err(RagError::ConfigError(format!(
43            "chunk_overlap ({chunk_overlap}) must be less than chunk_size ({chunk_size})"
44        )));
45    }
46    Ok(())
47}
48
49/// Replaces an invalid size pair with the nearest configuration that still advances.
50///
51/// A zero `chunk_size` becomes one byte and an overlap that is not smaller than
52/// `chunk_size` is dropped, so chunking covers the whole document instead of
53/// stopping after the first chunk.
54fn normalize_sizes(chunker: &str, chunk_size: usize, chunk_overlap: usize) -> (usize, usize) {
55    match validate_sizes(chunk_size, chunk_overlap) {
56        Ok(()) => (chunk_size, chunk_overlap),
57        Err(error) => {
58            let size = chunk_size.max(1);
59            let overlap = if chunk_overlap < size { chunk_overlap } else { 0 };
60            warn!(
61                chunker,
62                error = %error,
63                chunk_size = size,
64                chunk_overlap = overlap,
65                "invalid chunker sizes; use try_new to reject them"
66            );
67            (size, overlap)
68        }
69    }
70}
71
72/// A strategy for splitting documents into chunks.
73///
74/// Implementations produce [`Chunk`]s with text and metadata but no embeddings.
75/// Embeddings are attached later by the pipeline.
76pub trait Chunker: Send + Sync {
77    /// Split a document into chunks.
78    ///
79    /// Returns an empty `Vec` if the document has empty text.
80    /// Each returned chunk has an empty embedding vector.
81    fn chunk(&self, document: &Document) -> Vec<Chunk>;
82}
83
84/// Splits text into fixed-size chunks by byte length with configurable overlap.
85///
86/// Each chunk holds at most `chunk_size` bytes of UTF-8 and starts
87/// `chunk_size - chunk_overlap` bytes after the previous one, rounded down to a
88/// character boundary and always at least one character further on.
89///
90/// Chunk IDs are generated as `{document_id}_{chunk_index}`. Each chunk inherits
91/// the parent document's metadata plus a `chunk_index` field.
92///
93/// # Example
94///
95/// ```rust,ignore
96/// use adk_rag::FixedSizeChunker;
97///
98/// let chunker = FixedSizeChunker::try_new(256, 50)?;
99/// let chunks = chunker.chunk(&document);
100/// ```
101#[derive(Debug, Clone)]
102pub struct FixedSizeChunker {
103    chunk_size: usize,
104    chunk_overlap: usize,
105}
106
107impl FixedSizeChunker {
108    /// Create a new `FixedSizeChunker`.
109    ///
110    /// # Arguments
111    ///
112    /// * `chunk_size` — maximum number of bytes per chunk
113    /// * `chunk_overlap` — number of bytes shared by consecutive chunks
114    ///
115    /// An invalid pair is normalised rather than rejected: a zero `chunk_size`
116    /// becomes 1 and a `chunk_overlap` that is not smaller than `chunk_size` is
117    /// ignored, with a warning logged. Use [`try_new`](Self::try_new) to reject it.
118    pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
119        let (chunk_size, chunk_overlap) =
120            normalize_sizes("FixedSizeChunker", chunk_size, chunk_overlap);
121        Self { chunk_size, chunk_overlap }
122    }
123
124    /// Create a new `FixedSizeChunker`, rejecting an invalid size pair.
125    ///
126    /// # Arguments
127    ///
128    /// * `chunk_size` — maximum number of bytes per chunk
129    /// * `chunk_overlap` — number of bytes shared by consecutive chunks
130    ///
131    /// # Errors
132    ///
133    /// Returns [`RagError::ConfigError`] when `chunk_size` is zero or
134    /// `chunk_overlap` is not smaller than `chunk_size`.
135    ///
136    /// # Example
137    ///
138    /// ```rust
139    /// use adk_rag::FixedSizeChunker;
140    ///
141    /// assert!(FixedSizeChunker::try_new(512, 100).is_ok());
142    /// assert!(FixedSizeChunker::try_new(100, 100).is_err());
143    /// ```
144    pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self> {
145        validate_sizes(chunk_size, chunk_overlap)?;
146        Ok(Self { chunk_size, chunk_overlap })
147    }
148}
149
150impl Chunker for FixedSizeChunker {
151    fn chunk(&self, document: &Document) -> Vec<Chunk> {
152        split_by_size(&document.text, self.chunk_size, self.chunk_overlap)
153            .into_iter()
154            .enumerate()
155            .map(|(chunk_index, text)| {
156                let mut metadata = document.metadata.clone();
157                metadata.insert("chunk_index".to_string(), chunk_index.to_string());
158                Chunk {
159                    id: format!("{}_{chunk_index}", document.id),
160                    text,
161                    embedding: Vec::new(),
162                    metadata,
163                    document_id: document.id.clone(),
164                }
165            })
166            .collect()
167    }
168}
169
170/// Splits text hierarchically: paragraphs → sentences → words.
171///
172/// First splits by paragraph separators (`\n\n`). If a paragraph exceeds
173/// `chunk_size` bytes, splits by sentence boundaries (`. `, `! `, `? `). If a
174/// sentence still exceeds `chunk_size`, splits by word boundaries, and a single
175/// word longer than `chunk_size` is split by byte length with overlap.
176///
177/// # Example
178///
179/// ```rust,ignore
180/// use adk_rag::RecursiveChunker;
181///
182/// let chunker = RecursiveChunker::new(512, 100);
183/// let chunks = chunker.chunk(&document);
184/// ```
185#[derive(Debug, Clone)]
186pub struct RecursiveChunker {
187    chunk_size: usize,
188    chunk_overlap: usize,
189}
190
191impl RecursiveChunker {
192    /// Create a new `RecursiveChunker`.
193    ///
194    /// # Arguments
195    ///
196    /// * `chunk_size` — maximum number of bytes per chunk
197    /// * `chunk_overlap` — number of bytes shared by consecutive chunks
198    ///
199    /// An invalid pair is normalised rather than rejected: a zero `chunk_size`
200    /// becomes 1 and a `chunk_overlap` that is not smaller than `chunk_size` is
201    /// ignored, with a warning logged. Use [`try_new`](Self::try_new) to reject it.
202    pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
203        let (chunk_size, chunk_overlap) =
204            normalize_sizes("RecursiveChunker", chunk_size, chunk_overlap);
205        Self { chunk_size, chunk_overlap }
206    }
207
208    /// Create a new `RecursiveChunker`, rejecting an invalid size pair.
209    ///
210    /// # Arguments
211    ///
212    /// * `chunk_size` — maximum number of bytes per chunk
213    /// * `chunk_overlap` — number of bytes shared by consecutive chunks
214    ///
215    /// # Errors
216    ///
217    /// Returns [`RagError::ConfigError`] when `chunk_size` is zero or
218    /// `chunk_overlap` is not smaller than `chunk_size`.
219    ///
220    /// # Example
221    ///
222    /// ```rust
223    /// use adk_rag::RecursiveChunker;
224    ///
225    /// assert!(RecursiveChunker::try_new(512, 100).is_ok());
226    /// assert!(RecursiveChunker::try_new(100, 100).is_err());
227    /// ```
228    pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self> {
229        validate_sizes(chunk_size, chunk_overlap)?;
230        Ok(Self { chunk_size, chunk_overlap })
231    }
232}
233
234/// Split text by a separator, then merge segments into chunks that respect
235/// `chunk_size`. If a segment exceeds `chunk_size`, it is split further
236/// using the next-level separator.
237fn split_and_merge(
238    text: &str,
239    chunk_size: usize,
240    chunk_overlap: usize,
241    separators: &[&str],
242) -> Vec<String> {
243    if text.len() <= chunk_size || separators.is_empty() {
244        return split_by_size(text, chunk_size, chunk_overlap);
245    }
246
247    let separator = separators[0];
248    let remaining_separators = &separators[1..];
249
250    let segments: Vec<&str> = if separator == " " {
251        text.split(' ').collect()
252    } else {
253        split_keeping_separator(text, separator)
254    };
255
256    let mut chunks = Vec::new();
257    let mut current = String::new();
258
259    for segment in segments {
260        if current.is_empty() {
261            current = segment.to_string();
262        } else if current.len() + segment.len() <= chunk_size {
263            current.push_str(segment);
264        } else {
265            // Current chunk is full — process it
266            if current.len() > chunk_size {
267                chunks.extend(split_and_merge(
268                    &current,
269                    chunk_size,
270                    chunk_overlap,
271                    remaining_separators,
272                ));
273            } else {
274                chunks.push(current);
275            }
276            // Start new chunk with overlap
277            current = segment.to_string();
278        }
279    }
280
281    if !current.is_empty() {
282        if current.len() > chunk_size {
283            chunks.extend(split_and_merge(
284                &current,
285                chunk_size,
286                chunk_overlap,
287                remaining_separators,
288            ));
289        } else {
290            chunks.push(current);
291        }
292    }
293
294    chunks
295}
296
297/// Split text at a separator while keeping the separator attached to the preceding segment.
298fn split_keeping_separator<'a>(text: &'a str, separator: &str) -> Vec<&'a str> {
299    let mut result = Vec::new();
300    let mut start = 0;
301
302    while let Some(pos) = text[start..].find(separator) {
303        let end = start + pos + separator.len();
304        result.push(&text[start..end]);
305        start = end;
306    }
307
308    if start < text.len() {
309        result.push(&text[start..]);
310    }
311
312    result
313}
314
315/// Byte-length splitting with overlap that never splits a character.
316///
317/// Both the chunk end and the next start are rounded down to a character
318/// boundary, then pushed one character forward if rounding left them at
319/// `start`. Without that, a step narrower than a multibyte character would
320/// leave `start` in place forever.
321fn split_by_size(text: &str, chunk_size: usize, chunk_overlap: usize) -> Vec<String> {
322    let step = chunk_size.saturating_sub(chunk_overlap);
323    let mut chunks = Vec::new();
324    let mut start = 0;
325
326    while start < text.len() {
327        let mut end = floor_char_boundary(text, start.saturating_add(chunk_size));
328        if end <= start {
329            end = next_char_boundary(text, start);
330        }
331        chunks.push(text[start..end].to_string());
332
333        let next = floor_char_boundary(text, start.saturating_add(step));
334        start = if next > start { next } else { next_char_boundary(text, start) };
335    }
336
337    chunks
338}
339
340impl Chunker for RecursiveChunker {
341    fn chunk(&self, document: &Document) -> Vec<Chunk> {
342        if document.text.is_empty() {
343            return Vec::new();
344        }
345
346        let separators = ["\n\n", ". ", "! ", "? ", " "];
347        let raw_chunks =
348            split_and_merge(&document.text, self.chunk_size, self.chunk_overlap, &separators);
349
350        raw_chunks
351            .into_iter()
352            .enumerate()
353            .map(|(i, text)| {
354                let mut metadata = document.metadata.clone();
355                metadata.insert("chunk_index".to_string(), i.to_string());
356                Chunk {
357                    id: format!("{}_{i}", document.id),
358                    text,
359                    embedding: Vec::new(),
360                    metadata,
361                    document_id: document.id.clone(),
362                }
363            })
364            .collect()
365    }
366}
367
368/// Splits text by markdown headers, keeping each section as a chunk.
369///
370/// Each section is prefixed with its header hierarchy. Sections exceeding
371/// `chunk_size` bytes are further split using [`RecursiveChunker`] logic.
372/// The `header_path` metadata field records the header hierarchy for each chunk.
373///
374/// # Example
375///
376/// ```rust,ignore
377/// use adk_rag::MarkdownChunker;
378///
379/// let chunker = MarkdownChunker::new(512, 100);
380/// let chunks = chunker.chunk(&document);
381/// ```
382#[derive(Debug, Clone)]
383pub struct MarkdownChunker {
384    chunk_size: usize,
385    chunk_overlap: usize,
386}
387
388impl MarkdownChunker {
389    /// Create a new `MarkdownChunker`.
390    ///
391    /// # Arguments
392    ///
393    /// * `chunk_size` — maximum number of bytes per chunk
394    /// * `chunk_overlap` — number of bytes shared by consecutive chunks
395    ///
396    /// An invalid pair is normalised rather than rejected: a zero `chunk_size`
397    /// becomes 1 and a `chunk_overlap` that is not smaller than `chunk_size` is
398    /// ignored, with a warning logged. Use [`try_new`](Self::try_new) to reject it.
399    pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
400        let (chunk_size, chunk_overlap) =
401            normalize_sizes("MarkdownChunker", chunk_size, chunk_overlap);
402        Self { chunk_size, chunk_overlap }
403    }
404
405    /// Create a new `MarkdownChunker`, rejecting an invalid size pair.
406    ///
407    /// # Arguments
408    ///
409    /// * `chunk_size` — maximum number of bytes per chunk
410    /// * `chunk_overlap` — number of bytes shared by consecutive chunks
411    ///
412    /// # Errors
413    ///
414    /// Returns [`RagError::ConfigError`] when `chunk_size` is zero or
415    /// `chunk_overlap` is not smaller than `chunk_size`.
416    ///
417    /// # Example
418    ///
419    /// ```rust
420    /// use adk_rag::MarkdownChunker;
421    ///
422    /// assert!(MarkdownChunker::try_new(512, 100).is_ok());
423    /// assert!(MarkdownChunker::try_new(100, 100).is_err());
424    /// ```
425    pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self> {
426        validate_sizes(chunk_size, chunk_overlap)?;
427        Ok(Self { chunk_size, chunk_overlap })
428    }
429}
430
431/// A markdown section with its header hierarchy and body text.
432struct MarkdownSection {
433    header_path: String,
434    text: String,
435}
436
437/// Parse markdown text into sections split by headers.
438fn parse_markdown_sections(text: &str) -> Vec<MarkdownSection> {
439    let mut sections = Vec::new();
440    let mut headers: Vec<String> = Vec::new();
441    let mut current_body = String::new();
442    let mut current_header_path = String::new();
443
444    for line in text.lines() {
445        let trimmed = line.trim_start();
446        if trimmed.starts_with('#') {
447            // Save previous section
448            if !current_body.is_empty() || !current_header_path.is_empty() {
449                sections.push(MarkdownSection {
450                    header_path: current_header_path.clone(),
451                    text: current_body.trim().to_string(),
452                });
453                current_body = String::new();
454            }
455
456            // Determine header level
457            let level = trimmed.chars().take_while(|c| *c == '#').count();
458            let header_text = trimmed[level..].trim().to_string();
459
460            // Update header stack
461            headers.truncate(level.saturating_sub(1));
462            headers.push(header_text);
463            current_header_path = headers.join(" > ");
464        } else {
465            if !current_body.is_empty() {
466                current_body.push('\n');
467            }
468            current_body.push_str(line);
469        }
470    }
471
472    // Save final section
473    if !current_body.is_empty() || !current_header_path.is_empty() {
474        sections.push(MarkdownSection {
475            header_path: current_header_path,
476            text: current_body.trim().to_string(),
477        });
478    }
479
480    sections
481}
482
483impl Chunker for MarkdownChunker {
484    fn chunk(&self, document: &Document) -> Vec<Chunk> {
485        if document.text.is_empty() {
486            return Vec::new();
487        }
488
489        let sections = parse_markdown_sections(&document.text);
490        let mut chunks = Vec::new();
491        let mut chunk_index = 0;
492
493        for section in sections {
494            // Build section text with header prefix
495            let section_text = if section.header_path.is_empty() {
496                section.text.clone()
497            } else if section.text.is_empty() {
498                section.header_path.clone()
499            } else {
500                format!("{}\n{}", section.header_path, section.text)
501            };
502
503            if section_text.is_empty() {
504                continue;
505            }
506
507            let sub_chunks = if section_text.len() > self.chunk_size {
508                // Further split using recursive logic
509                let separators = ["\n\n", ". ", "! ", "? ", " "];
510                split_and_merge(&section_text, self.chunk_size, self.chunk_overlap, &separators)
511            } else {
512                vec![section_text]
513            };
514
515            for text in sub_chunks {
516                let mut metadata = document.metadata.clone();
517                metadata.insert("chunk_index".to_string(), chunk_index.to_string());
518                metadata.insert("header_path".to_string(), section.header_path.clone());
519
520                chunks.push(Chunk {
521                    id: format!("{}_{chunk_index}", document.id),
522                    text,
523                    embedding: Vec::new(),
524                    metadata,
525                    document_id: document.id.clone(),
526                });
527                chunk_index += 1;
528            }
529        }
530
531        chunks
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::Document;
539    use proptest::prelude::*;
540    use std::collections::HashMap;
541
542    fn doc(text: &str) -> Document {
543        Document {
544            id: "test".to_string(),
545            text: text.to_string(),
546            metadata: HashMap::new(),
547            source_uri: None,
548        }
549    }
550
551    #[test]
552    fn fixed_chunker_utf8_multibyte() {
553        // Chinese characters are 3 bytes each in UTF-8.
554        // "你好世界" = 4 chars, 12 bytes.
555        // With chunk_size=5 (bytes), naive slicing would panic mid-character.
556        let chunker = FixedSizeChunker::new(5, 0);
557        let chunks = chunker.chunk(&doc("你好世界测试文本"));
558        // Should not panic and all chunks should be valid UTF-8
559        for chunk in &chunks {
560            assert!(chunk.text.is_char_boundary(0));
561            // Verify it's valid UTF-8 by iterating chars
562            let _ = chunk.text.chars().count();
563        }
564        // Should produce multiple chunks
565        assert!(chunks.len() > 1);
566    }
567
568    #[test]
569    fn fixed_chunker_utf8_emoji() {
570        // Emoji are 4 bytes each. "🦀🚀🎉" = 3 chars, 12 bytes.
571        let chunker = FixedSizeChunker::new(6, 0);
572        let chunks = chunker.chunk(&doc("🦀🚀🎉✨🌟💫"));
573        for chunk in &chunks {
574            let _ = chunk.text.chars().count();
575        }
576        assert!(chunks.len() > 1);
577    }
578
579    #[test]
580    fn fixed_chunker_utf8_mixed() {
581        // Mix of ASCII (1 byte), accented (2 bytes), CJK (3 bytes), emoji (4 bytes)
582        let text = "Hello café 你好 🦀";
583        let chunker = FixedSizeChunker::new(7, 2);
584        let chunks = chunker.chunk(&doc(text));
585        for chunk in &chunks {
586            let _ = chunk.text.chars().count();
587        }
588        assert!(!chunks.is_empty());
589    }
590
591    #[test]
592    fn split_by_size_utf8() {
593        let text = "日本語のテスト文字列です";
594        let chunks = split_by_size(text, 10, 3);
595        for chunk in &chunks {
596            let _ = chunk.chars().count();
597        }
598        assert!(chunks.len() > 1);
599    }
600
601    #[test]
602    fn recursive_chunker_utf8() {
603        let text = "第一段落。这是中文文本。\n\n第二段落。更多中文内容在这里。";
604        let chunker = RecursiveChunker::new(15, 3);
605        let chunks = chunker.chunk(&doc(text));
606        for chunk in &chunks {
607            let _ = chunk.text.chars().count();
608        }
609        assert!(!chunks.is_empty());
610    }
611
612    fn texts(chunks: &[Chunk]) -> Vec<&str> {
613        chunks.iter().map(|c| c.text.as_str()).collect()
614    }
615
616    #[test]
617    fn fixed_chunker_advances_when_step_is_narrower_than_a_character() {
618        // Step 2 bytes, characters 3 bytes: rounding down used to leave `start` in place.
619        let chunks = FixedSizeChunker::new(5, 3).chunk(&doc("你好世界"));
620        assert_eq!(texts(&chunks), ["你", "好", "世", "界"]);
621
622        // Step 1 byte, characters 4 bytes.
623        let chunks = FixedSizeChunker::new(6, 5).chunk(&doc("🦀🚀"));
624        assert_eq!(texts(&chunks), ["🦀", "🚀"]);
625    }
626
627    #[test]
628    fn fixed_chunker_emits_characters_wider_than_chunk_size_whole() {
629        let chunks = FixedSizeChunker::new(2, 0).chunk(&doc("a你b"));
630        assert_eq!(texts(&chunks), ["a", "你", "b"]);
631    }
632
633    #[test]
634    fn split_by_size_advances_when_step_is_narrower_than_a_character() {
635        assert_eq!(split_by_size("日本語", 4, 3), ["日", "本", "語"]);
636    }
637
638    #[test]
639    fn recursive_and_markdown_chunkers_terminate_on_narrow_steps() {
640        let text = "# 标题\n\n第一段落很长很长很长。第二句也很长很长。\n\n🦀🦀🦀🦀🦀🦀";
641        let recursive = RecursiveChunker::new(5, 4).chunk(&doc(text));
642        let markdown = MarkdownChunker::new(5, 4).chunk(&doc(text));
643        assert!(!recursive.is_empty());
644        assert!(!markdown.is_empty());
645    }
646
647    #[test]
648    fn try_new_rejects_invalid_sizes() {
649        for (size, overlap) in [(0, 0), (10, 10), (10, 11)] {
650            assert!(
651                matches!(FixedSizeChunker::try_new(size, overlap), Err(RagError::ConfigError(_))),
652                "FixedSizeChunker accepted ({size}, {overlap})"
653            );
654            assert!(
655                matches!(RecursiveChunker::try_new(size, overlap), Err(RagError::ConfigError(_))),
656                "RecursiveChunker accepted ({size}, {overlap})"
657            );
658            assert!(
659                matches!(MarkdownChunker::try_new(size, overlap), Err(RagError::ConfigError(_))),
660                "MarkdownChunker accepted ({size}, {overlap})"
661            );
662        }
663        assert!(FixedSizeChunker::try_new(10, 9).is_ok());
664    }
665
666    #[test]
667    fn new_normalizes_invalid_sizes_instead_of_truncating() {
668        // An overlap equal to chunk_size used to stop after the first chunk.
669        let chunks = FixedSizeChunker::new(4, 4).chunk(&doc("abcdefghij"));
670        assert_eq!(texts(&chunks), ["abcd", "efgh", "ij"]);
671
672        let chunks = FixedSizeChunker::new(0, 0).chunk(&doc("abc"));
673        assert_eq!(texts(&chunks), ["a", "b", "c"]);
674    }
675
676    fn arb_text() -> impl Strategy<Value = String> {
677        prop_oneof![
678            "\\PC{0,120}",
679            "[a 你好世界🦀é\\n.!?#]{0,120}",
680            proptest::collection::vec(any::<char>(), 0..60)
681                .prop_map(|chars| chars.into_iter().collect::<String>()),
682        ]
683    }
684
685    proptest! {
686        #![proptest_config(ProptestConfig::with_cases(256))]
687
688        /// Chunking terminates for any UTF-8 text and any size pair, and every
689        /// chunk is a non-empty slice that advances through the text.
690        #[test]
691        fn prop_chunkers_terminate_for_any_text_and_sizes(
692            text in arb_text(),
693            chunk_size in 0usize..24,
694            chunk_overlap in 0usize..32,
695        ) {
696            let document = doc(&text);
697
698            let fixed = FixedSizeChunker::new(chunk_size, chunk_overlap).chunk(&document);
699            prop_assert!(fixed.len() <= text.len());
700            prop_assert!(fixed.iter().all(|c| !c.text.is_empty()));
701            if let (Some(first), Some(last)) = (fixed.first(), fixed.last()) {
702                prop_assert!(text.starts_with(&first.text));
703                prop_assert!(text.ends_with(&last.text));
704            }
705
706            let recursive = RecursiveChunker::new(chunk_size, chunk_overlap).chunk(&document);
707            prop_assert!(recursive.iter().all(|c| !c.text.is_empty()));
708
709            let markdown = MarkdownChunker::new(chunk_size, chunk_overlap).chunk(&document);
710            prop_assert!(markdown.iter().all(|c| !c.text.is_empty()));
711        }
712
713        /// With no overlap the chunks partition the text, and none exceeds
714        /// `chunk_size` unless it is a single wider character.
715        #[test]
716        fn prop_fixed_chunks_without_overlap_partition_the_text(
717            text in arb_text(),
718            chunk_size in 1usize..24,
719        ) {
720            let chunks = FixedSizeChunker::try_new(chunk_size, 0)
721                .expect("valid sizes")
722                .chunk(&doc(&text));
723            prop_assert_eq!(texts(&chunks).concat(), text.clone());
724            for chunk in &chunks {
725                prop_assert!(
726                    chunk.text.len() <= chunk_size || chunk.text.chars().count() == 1,
727                    "chunk {:?} exceeds {} bytes", chunk.text, chunk_size
728                );
729            }
730        }
731    }
732}