Skip to main content

ipfrs_semantic/
document_chunker.rs

1//! # Document Chunker
2//!
3//! A production-grade document chunking engine that splits text into semantically
4//! coherent chunks for embedding and retrieval. Supports multiple chunking strategies
5//! including fixed-size windows, sentence boundaries, paragraph-based, and semantic
6//! grouping.
7//!
8//! ## Example
9//!
10//! ```rust
11//! use ipfrs_semantic::{DocumentChunker, DocumentChunkerConfig, ChunkStrategy};
12//!
13//! let config = DocumentChunkerConfig {
14//!     strategy: ChunkStrategy::SentenceBoundary {
15//!         max_chunk_chars: 512,
16//!         overlap_sentences: 1,
17//!     },
18//!     preserve_whitespace: false,
19//!     min_chunk_chars: 10,
20//! };
21//!
22//! let mut chunker = DocumentChunker::new(config);
23//! let chunks = chunker.chunk_text("doc-001", "Hello world. This is a test.");
24//! assert!(!chunks.is_empty());
25//! ```
26
27use std::collections::HashMap;
28
29// ---------------------------------------------------------------------------
30// Public types
31// ---------------------------------------------------------------------------
32
33/// Strategy that controls how a document is split into chunks.
34#[derive(Debug, Clone, PartialEq)]
35pub enum ChunkStrategy {
36    /// Split by character count with configurable overlap.
37    FixedSize {
38        /// Window size in characters.
39        size: usize,
40        /// Number of characters to overlap between consecutive chunks.
41        overlap: usize,
42    },
43    /// Split at sentence boundaries (`.`, `!`, `?` followed by whitespace).
44    SentenceBoundary {
45        /// Maximum chunk length in characters.
46        max_chunk_chars: usize,
47        /// Number of sentences from the end of the previous chunk that are
48        /// prepended to the start of the next chunk as overlap.
49        overlap_sentences: usize,
50    },
51    /// Split at double-newline paragraph boundaries.
52    Paragraph {
53        /// Maximum chunk length in characters; oversized paragraphs are
54        /// further split at sentence boundaries.
55        max_chunk_chars: usize,
56    },
57    /// Group sentences whose cumulative length stays within `max_chunk_chars`.
58    /// `similarity_threshold` is reserved for future embedding-based splitting.
59    Semantic {
60        /// Maximum chunk length in characters.
61        max_chunk_chars: usize,
62        /// Reserved — similarity threshold for future embedding-based grouping.
63        similarity_threshold: f64,
64    },
65}
66
67/// A single text chunk produced by [`DocumentChunker`].
68#[derive(Debug, Clone, PartialEq)]
69pub struct TextChunk {
70    /// Unique chunk identifier in the form `{doc_id}-{index}`.
71    pub id: String,
72    /// The text content of this chunk.
73    pub content: String,
74    /// Byte offset of the first character in the original document.
75    pub start_offset: usize,
76    /// Byte offset one past the last character in the original document.
77    pub end_offset: usize,
78    /// Zero-based position of this chunk in the output sequence.
79    pub chunk_index: usize,
80    /// Arbitrary metadata key-value pairs attached to this chunk.
81    pub metadata: HashMap<String, String>,
82}
83
84/// Aggregate statistics over a collection of [`TextChunk`]s.
85#[derive(Debug, Clone, PartialEq)]
86pub struct ChunkStats {
87    /// Total number of chunks.
88    pub total_chunks: usize,
89    /// Total characters across all chunks.
90    pub total_chars: usize,
91    /// Mean chunk length in characters.
92    pub avg_chunk_chars: f64,
93    /// Minimum chunk length.
94    pub min_chunk_chars: usize,
95    /// Maximum chunk length.
96    pub max_chunk_chars: usize,
97    /// Estimated number of overlapping characters (only meaningful for
98    /// `FixedSize` and `SentenceBoundary` strategies).
99    pub overlap_chars: usize,
100}
101
102/// Configuration for [`DocumentChunker`].
103#[derive(Debug, Clone, PartialEq)]
104pub struct DocumentChunkerConfig {
105    /// The chunking strategy to apply.
106    pub strategy: ChunkStrategy,
107    /// When `true`, leading/trailing whitespace is preserved in each chunk;
108    /// when `false` it is stripped.
109    pub preserve_whitespace: bool,
110    /// Chunks whose content is shorter than this threshold are discarded
111    /// (after whitespace trimming if applicable).
112    pub min_chunk_chars: usize,
113}
114
115impl Default for DocumentChunkerConfig {
116    fn default() -> Self {
117        Self {
118            strategy: ChunkStrategy::SentenceBoundary {
119                max_chunk_chars: 512,
120                overlap_sentences: 1,
121            },
122            preserve_whitespace: false,
123            min_chunk_chars: 10,
124        }
125    }
126}
127
128/// Document chunking engine.
129///
130/// Maintains running counters for chunks produced and documents processed
131/// across the lifetime of the instance. All heavy work is done in the
132/// `chunk_*` family of methods.
133#[derive(Debug, Clone)]
134pub struct DocumentChunker {
135    /// Active configuration.
136    pub config: DocumentChunkerConfig,
137    /// Total number of [`TextChunk`]s emitted since construction.
138    pub chunks_produced: u64,
139    /// Total number of documents submitted to [`DocumentChunker::chunk_text`].
140    pub documents_processed: u64,
141}
142
143// ---------------------------------------------------------------------------
144// Implementation
145// ---------------------------------------------------------------------------
146
147impl DocumentChunker {
148    /// Construct a new `DocumentChunker` with the supplied configuration.
149    pub fn new(config: DocumentChunkerConfig) -> Self {
150        Self {
151            config,
152            chunks_produced: 0,
153            documents_processed: 0,
154        }
155    }
156
157    /// Chunk `text` using the strategy stored in `self.config`, filter chunks
158    /// shorter than `min_chunk_chars`, and update internal counters.
159    pub fn chunk_text(&mut self, doc_id: &str, text: &str) -> Vec<TextChunk> {
160        self.documents_processed += 1;
161
162        let min = self.config.min_chunk_chars;
163        let preserve = self.config.preserve_whitespace;
164
165        let mut chunks = match &self.config.strategy.clone() {
166            ChunkStrategy::FixedSize { size, overlap } => {
167                self.chunk_fixed_size(doc_id, text, *size, *overlap)
168            }
169            ChunkStrategy::SentenceBoundary {
170                max_chunk_chars,
171                overlap_sentences,
172            } => self.chunk_sentence_boundary(doc_id, text, *max_chunk_chars, *overlap_sentences),
173            ChunkStrategy::Paragraph { max_chunk_chars } => {
174                self.chunk_paragraph(doc_id, text, *max_chunk_chars)
175            }
176            ChunkStrategy::Semantic {
177                max_chunk_chars,
178                similarity_threshold,
179            } => self.chunk_semantic(doc_id, text, *max_chunk_chars, *similarity_threshold),
180        };
181
182        // Optionally strip whitespace and filter undersized chunks.
183        if !preserve {
184            for chunk in &mut chunks {
185                let trimmed = chunk.content.trim().to_string();
186                chunk.content = trimmed;
187            }
188        }
189        chunks.retain(|c| c.content.len() >= min);
190
191        // Re-number chunk indices after filtering.
192        for (i, chunk) in chunks.iter_mut().enumerate() {
193            chunk.chunk_index = i;
194            chunk.id = format!("{}-{}", doc_id, i);
195        }
196
197        self.chunks_produced += chunks.len() as u64;
198        chunks
199    }
200
201    /// Split `text` into fixed-size windows of `size` characters stepping by
202    /// `size - overlap` characters between each window.
203    ///
204    /// If `overlap >= size` it is clamped to `size.saturating_sub(1)` to
205    /// guarantee progress.
206    pub fn chunk_fixed_size(
207        &self,
208        doc_id: &str,
209        text: &str,
210        size: usize,
211        overlap: usize,
212    ) -> Vec<TextChunk> {
213        if size == 0 || text.is_empty() {
214            return Vec::new();
215        }
216
217        let overlap = overlap.min(size.saturating_sub(1));
218        let step = size - overlap;
219
220        // Work on the character sequence to avoid splitting UTF-8 scalars.
221        let chars: Vec<char> = text.chars().collect();
222        let total = chars.len();
223
224        let mut chunks = Vec::new();
225        let mut start = 0usize;
226        let mut index = 0usize;
227
228        while start < total {
229            let end = (start + size).min(total);
230            let content: String = chars[start..end].iter().collect();
231
232            // Map character indices back to byte offsets.
233            let byte_start = char_idx_to_byte(text, start);
234            let byte_end = char_idx_to_byte(text, end);
235
236            chunks.push(TextChunk {
237                id: format!("{}-{}", doc_id, index),
238                content,
239                start_offset: byte_start,
240                end_offset: byte_end,
241                chunk_index: index,
242                metadata: HashMap::new(),
243            });
244
245            index += 1;
246            start += step;
247        }
248
249        chunks
250    }
251
252    /// Split `text` at sentence boundaries and group sentences so that each
253    /// chunk stays within `max_chars` characters. The last `overlap_sentences`
254    /// sentences of each chunk are prepended to the next chunk.
255    pub fn chunk_sentence_boundary(
256        &self,
257        doc_id: &str,
258        text: &str,
259        max_chars: usize,
260        overlap_sentences: usize,
261    ) -> Vec<TextChunk> {
262        let sentences = split_into_sentences(text);
263        if sentences.is_empty() {
264            return Vec::new();
265        }
266        group_sentences_into_chunks(doc_id, text, &sentences, max_chars, overlap_sentences)
267    }
268
269    /// Split `text` at `\n\n` paragraph boundaries. Paragraphs that exceed
270    /// `max_chars` are further split at sentence boundaries.
271    pub fn chunk_paragraph(&self, doc_id: &str, text: &str, max_chars: usize) -> Vec<TextChunk> {
272        let paragraphs: Vec<&str> = text.split("\n\n").collect();
273
274        let mut chunks = Vec::new();
275        let mut global_byte_offset = 0usize;
276        let mut index = 0usize;
277
278        for para in &paragraphs {
279            let para_len = para.len();
280
281            if para.len() <= max_chars {
282                // Emit as a single chunk, but only if non-empty.
283                if !para.trim().is_empty() {
284                    chunks.push(TextChunk {
285                        id: format!("{}-{}", doc_id, index),
286                        content: para.to_string(),
287                        start_offset: global_byte_offset,
288                        end_offset: global_byte_offset + para_len,
289                        chunk_index: index,
290                        metadata: HashMap::new(),
291                    });
292                    index += 1;
293                }
294            } else {
295                // Oversized paragraph — sub-split at sentence boundaries.
296                let sub = group_sentences_into_chunks(
297                    doc_id,
298                    para,
299                    &split_into_sentences(para),
300                    max_chars,
301                    0,
302                );
303                for mut sc in sub {
304                    sc.start_offset += global_byte_offset;
305                    sc.end_offset += global_byte_offset;
306                    sc.chunk_index = index;
307                    sc.id = format!("{}-{}", doc_id, index);
308                    chunks.push(sc);
309                    index += 1;
310                }
311            }
312
313            // Advance past the paragraph text plus the "\n\n" separator.
314            global_byte_offset += para_len + 2;
315        }
316
317        chunks
318    }
319
320    /// Group sentences into chunks of at most `max_chars` characters.
321    /// `_threshold` is a placeholder for future embedding-based similarity.
322    pub fn chunk_semantic(
323        &self,
324        doc_id: &str,
325        text: &str,
326        max_chars: usize,
327        _threshold: f64,
328    ) -> Vec<TextChunk> {
329        // For now the implementation is identical to sentence_boundary with
330        // overlap = 0. When embedding support lands the similarity_threshold
331        // will gate merging adjacent sentences.
332        let sentences = split_into_sentences(text);
333        group_sentences_into_chunks(doc_id, text, &sentences, max_chars, 0)
334    }
335
336    /// Merge chunks shorter than `min_chars` into the following chunk.
337    /// The last chunk absorbs any trailing short chunks. Offsets and indices
338    /// are updated in place.
339    pub fn merge_small_chunks(chunks: Vec<TextChunk>, min_chars: usize) -> Vec<TextChunk> {
340        if chunks.is_empty() {
341            return Vec::new();
342        }
343
344        let mut result: Vec<TextChunk> = Vec::new();
345        let mut pending: Option<TextChunk> = None;
346
347        for chunk in chunks {
348            match pending.take() {
349                None => {
350                    if chunk.content.len() < min_chars {
351                        pending = Some(chunk);
352                    } else {
353                        result.push(chunk);
354                    }
355                }
356                Some(mut prev) => {
357                    // Merge `prev` (the short chunk) into `chunk`.
358                    prev.content.push(' ');
359                    prev.content.push_str(&chunk.content);
360                    prev.end_offset = chunk.end_offset;
361
362                    if prev.content.len() < min_chars {
363                        // Still short — keep accumulating.
364                        pending = Some(prev);
365                    } else {
366                        result.push(prev);
367                    }
368                }
369            }
370        }
371
372        // Flush any remaining pending chunk (even if still short).
373        if let Some(last) = pending {
374            // Try to merge into the last emitted chunk.
375            if let Some(prev) = result.last_mut() {
376                prev.content.push(' ');
377                prev.content.push_str(&last.content);
378                prev.end_offset = last.end_offset;
379            } else {
380                result.push(last);
381            }
382        }
383
384        // Re-number indices.
385        for (i, chunk) in result.iter_mut().enumerate() {
386            chunk.chunk_index = i;
387        }
388
389        result
390    }
391
392    /// Compute aggregate statistics over a slice of chunks.
393    pub fn stats(chunks: &[TextChunk]) -> ChunkStats {
394        if chunks.is_empty() {
395            return ChunkStats {
396                total_chunks: 0,
397                total_chars: 0,
398                avg_chunk_chars: 0.0,
399                min_chunk_chars: 0,
400                max_chunk_chars: 0,
401                overlap_chars: 0,
402            };
403        }
404
405        let total_chars: usize = chunks.iter().map(|c| c.content.len()).sum();
406        let min_chunk_chars = chunks.iter().map(|c| c.content.len()).min().unwrap_or(0);
407        let max_chunk_chars = chunks.iter().map(|c| c.content.len()).max().unwrap_or(0);
408
409        // Estimate overlap as the sum of overlapping byte ranges between
410        // consecutive chunks.
411        let overlap_chars: usize = chunks
412            .windows(2)
413            .map(|w| {
414                let prev_end = w[0].end_offset;
415                let next_start = w[1].start_offset;
416                prev_end.saturating_sub(next_start)
417            })
418            .sum();
419
420        ChunkStats {
421            total_chunks: chunks.len(),
422            total_chars,
423            avg_chunk_chars: total_chars as f64 / chunks.len() as f64,
424            min_chunk_chars,
425            max_chunk_chars,
426            overlap_chars,
427        }
428    }
429
430    /// Temporarily override the strategy on this chunker, produce chunks,
431    /// then restore the original strategy.
432    pub fn rechunk_with_strategy(
433        &mut self,
434        doc_id: &str,
435        text: &str,
436        strategy: ChunkStrategy,
437    ) -> Vec<TextChunk> {
438        let saved = self.config.strategy.clone();
439        self.config.strategy = strategy;
440        let chunks = self.chunk_text(doc_id, text);
441        self.config.strategy = saved;
442        chunks
443    }
444
445    /// Add `key = value` metadata to every chunk in `chunks`.
446    pub fn set_metadata(chunks: &mut [TextChunk], key: &str, value: &str) {
447        for chunk in chunks.iter_mut() {
448            chunk.metadata.insert(key.to_string(), value.to_string());
449        }
450    }
451
452    /// Return `(chunks_produced, documents_processed)` counters.
453    pub fn chunker_stats(&self) -> (u64, u64) {
454        (self.chunks_produced, self.documents_processed)
455    }
456}
457
458// ---------------------------------------------------------------------------
459// Private helpers
460// ---------------------------------------------------------------------------
461
462/// Convert a character-level index into the corresponding byte offset within
463/// the UTF-8 string `s`.  Returns `s.len()` when `char_idx >= s.chars().count()`.
464fn char_idx_to_byte(s: &str, char_idx: usize) -> usize {
465    s.char_indices()
466        .nth(char_idx)
467        .map(|(b, _)| b)
468        .unwrap_or(s.len())
469}
470
471/// Split `text` into sentences at `. `, `! `, and `? ` delimiters, preserving
472/// the terminating punctuation on each sentence.
473fn split_into_sentences(text: &str) -> Vec<String> {
474    if text.is_empty() {
475        return Vec::new();
476    }
477
478    let mut sentences: Vec<String> = Vec::new();
479    let mut current = String::new();
480    let chars: Vec<char> = text.chars().collect();
481    let len = chars.len();
482    let mut i = 0;
483
484    while i < len {
485        let ch = chars[i];
486        current.push(ch);
487
488        if (ch == '.' || ch == '!' || ch == '?') && i + 1 < len && chars[i + 1] == ' ' {
489            sentences.push(current.trim_end().to_string());
490            current = String::new();
491            // Skip the trailing space.
492            i += 2;
493            continue;
494        }
495        i += 1;
496    }
497
498    // Push any remaining text as the final (possibly unterminated) sentence.
499    let tail = current.trim_end().to_string();
500    if !tail.is_empty() {
501        sentences.push(tail);
502    }
503
504    sentences
505}
506
507/// Group `sentences` into chunks whose character length stays within
508/// `max_chars`, with `overlap_sentences` sentences copied from the end of the
509/// previous chunk to the start of the next.
510///
511/// Byte offsets are computed relative to `full_text`.
512fn group_sentences_into_chunks(
513    doc_id: &str,
514    full_text: &str,
515    sentences: &[String],
516    max_chars: usize,
517    overlap_sentences: usize,
518) -> Vec<TextChunk> {
519    if sentences.is_empty() {
520        return Vec::new();
521    }
522
523    let effective_max = max_chars.max(1);
524    let mut chunks: Vec<TextChunk> = Vec::new();
525    let mut chunk_index = 0usize;
526    let mut sent_idx = 0usize; // First sentence of the current chunk (in `sentences`).
527
528    while sent_idx < sentences.len() {
529        let mut group: Vec<&str> = Vec::new();
530        let mut char_count = 0usize;
531        let mut i = sent_idx;
532
533        while i < sentences.len() {
534            let s = sentences[i].as_str();
535            // Account for the space separator that will be inserted.
536            let needed = if group.is_empty() {
537                s.len()
538            } else {
539                s.len() + 1
540            };
541
542            if !group.is_empty() && char_count + needed > effective_max {
543                break;
544            }
545
546            group.push(s);
547            char_count += needed;
548            i += 1;
549        }
550
551        // If a single sentence already exceeds max_chars, include it anyway to
552        // avoid an infinite loop.
553        if group.is_empty() {
554            group.push(sentences[i].as_str());
555            i += 1;
556        }
557
558        let content = group.join(" ");
559
560        // Locate this chunk's byte offsets inside `full_text`.
561        let (start_offset, end_offset) = find_chunk_offsets(&content, full_text, chunks.last());
562
563        chunks.push(TextChunk {
564            id: format!("{}-{}", doc_id, chunk_index),
565            content,
566            start_offset,
567            end_offset,
568            chunk_index,
569            metadata: HashMap::new(),
570        });
571        chunk_index += 1;
572
573        // Advance, but keep the last `overlap_sentences` sentences.
574        let advance = (i - sent_idx).saturating_sub(overlap_sentences).max(1);
575        sent_idx += advance;
576    }
577
578    chunks
579}
580
581/// Find the byte range of `chunk_content` within `full_text`.
582///
583/// The search starts after the previous chunk's start offset (if any) to
584/// prefer later occurrences over earlier duplicates.  Returns `(0, 0)` if the
585/// content cannot be located (e.g. after whitespace normalisation).
586fn find_chunk_offsets(
587    chunk_content: &str,
588    full_text: &str,
589    prev_chunk: Option<&TextChunk>,
590) -> (usize, usize) {
591    // Determine the search start position.
592    let search_from = prev_chunk.map(|c| c.start_offset).unwrap_or(0);
593
594    // Try an exact substring match first.
595    if let Some(rel) = full_text[search_from..].find(chunk_content) {
596        let start = search_from + rel;
597        return (start, start + chunk_content.len());
598    }
599
600    // Fallback: try from the very beginning (handles duplicated overlap text).
601    if let Some(rel) = full_text.find(chunk_content) {
602        return (rel, rel + chunk_content.len());
603    }
604
605    // Could not locate; return sentinel values.
606    (0, chunk_content.len())
607}
608
609// ---------------------------------------------------------------------------
610// Tests
611// ---------------------------------------------------------------------------
612
613#[cfg(test)]
614mod tests {
615    use crate::document_chunker::{
616        char_idx_to_byte, group_sentences_into_chunks, split_into_sentences, ChunkStrategy,
617        DocumentChunker, DocumentChunkerConfig, TextChunk,
618    };
619    use std::collections::HashMap;
620
621    fn default_chunker() -> DocumentChunker {
622        DocumentChunker::new(DocumentChunkerConfig::default())
623    }
624
625    // -----------------------------------------------------------------------
626    // Helper / unit tests
627    // -----------------------------------------------------------------------
628
629    #[test]
630    fn test_char_idx_to_byte_ascii() {
631        let s = "hello";
632        assert_eq!(char_idx_to_byte(s, 0), 0);
633        assert_eq!(char_idx_to_byte(s, 3), 3);
634        assert_eq!(char_idx_to_byte(s, 5), 5); // == s.len()
635        assert_eq!(char_idx_to_byte(s, 99), 5); // out of range → s.len()
636    }
637
638    #[test]
639    fn test_char_idx_to_byte_utf8() {
640        let s = "héllo"; // 'é' = 2 bytes
641                         // 'h' at byte 0, 'é' at byte 1, 'l' at byte 3, 'l' at 4, 'o' at 5
642        assert_eq!(char_idx_to_byte(s, 0), 0);
643        assert_eq!(char_idx_to_byte(s, 1), 1);
644        assert_eq!(char_idx_to_byte(s, 2), 3);
645    }
646
647    #[test]
648    fn test_split_into_sentences_basic() {
649        let text = "Hello world. How are you? I am fine!";
650        let sents = split_into_sentences(text);
651        assert_eq!(sents.len(), 3);
652        assert_eq!(sents[0], "Hello world.");
653        assert_eq!(sents[1], "How are you?");
654        assert_eq!(sents[2], "I am fine!");
655    }
656
657    #[test]
658    fn test_split_into_sentences_no_terminator() {
659        let text = "No terminator here";
660        let sents = split_into_sentences(text);
661        assert_eq!(sents.len(), 1);
662        assert_eq!(sents[0], "No terminator here");
663    }
664
665    #[test]
666    fn test_split_into_sentences_empty() {
667        assert!(split_into_sentences("").is_empty());
668    }
669
670    #[test]
671    fn test_split_into_sentences_single_sentence() {
672        let text = "Just one sentence.";
673        let sents = split_into_sentences(text);
674        assert_eq!(sents.len(), 1);
675    }
676
677    // -----------------------------------------------------------------------
678    // Fixed-size chunking
679    // -----------------------------------------------------------------------
680
681    #[test]
682    fn test_fixed_size_basic() {
683        let chunker = default_chunker();
684        let text = "abcdefghij"; // 10 chars
685        let chunks = chunker.chunk_fixed_size("doc", text, 5, 0);
686        assert_eq!(chunks.len(), 2);
687        assert_eq!(chunks[0].content, "abcde");
688        assert_eq!(chunks[1].content, "fghij");
689    }
690
691    #[test]
692    fn test_fixed_size_with_overlap() {
693        let chunker = default_chunker();
694        let text = "abcdefghij"; // 10 chars
695        let chunks = chunker.chunk_fixed_size("doc", text, 6, 2);
696        // step = 4; starts: 0 → "abcdef", 4 → "efghij", 8 → "ij"
697        assert_eq!(chunks.len(), 3);
698        assert_eq!(chunks[0].content, "abcdef");
699        assert_eq!(chunks[1].content, "efghij");
700        assert_eq!(chunks[2].content, "ij");
701    }
702
703    #[test]
704    fn test_fixed_size_overlap_clamp() {
705        // overlap >= size → clamped to size-1=2, step=1
706        // "abcde": starts 0,1,2,3,4 → 5 chunks
707        let chunker = default_chunker();
708        let text = "abcde";
709        let chunks = chunker.chunk_fixed_size("doc", text, 3, 10);
710        assert_eq!(chunks.len(), 5);
711        assert_eq!(chunks[0].content, "abc");
712        assert_eq!(chunks[4].content, "e");
713    }
714
715    #[test]
716    fn test_fixed_size_zero_size_returns_empty() {
717        let chunker = default_chunker();
718        let chunks = chunker.chunk_fixed_size("doc", "hello", 0, 0);
719        assert!(chunks.is_empty());
720    }
721
722    #[test]
723    fn test_fixed_size_empty_text() {
724        let chunker = default_chunker();
725        let chunks = chunker.chunk_fixed_size("doc", "", 5, 0);
726        assert!(chunks.is_empty());
727    }
728
729    #[test]
730    fn test_fixed_size_chunk_ids() {
731        let chunker = default_chunker();
732        let chunks = chunker.chunk_fixed_size("my-doc", "abcdefghij", 5, 0);
733        assert_eq!(chunks[0].id, "my-doc-0");
734        assert_eq!(chunks[1].id, "my-doc-1");
735    }
736
737    #[test]
738    fn test_fixed_size_byte_offsets() {
739        let chunker = default_chunker();
740        let text = "abcde";
741        let chunks = chunker.chunk_fixed_size("d", text, 3, 0);
742        assert_eq!(chunks[0].start_offset, 0);
743        assert_eq!(chunks[0].end_offset, 3);
744        assert_eq!(chunks[1].start_offset, 3);
745        assert_eq!(chunks[1].end_offset, 5);
746    }
747
748    // -----------------------------------------------------------------------
749    // Sentence-boundary chunking
750    // -----------------------------------------------------------------------
751
752    #[test]
753    fn test_sentence_boundary_basic() {
754        let chunker = default_chunker();
755        let text = "Hello world. How are you? I am fine!";
756        let chunks = chunker.chunk_sentence_boundary("doc", text, 100, 0);
757        assert_eq!(chunks.len(), 1); // all fit in 100 chars
758        assert!(chunks[0].content.contains("Hello world"));
759    }
760
761    #[test]
762    fn test_sentence_boundary_splits_on_max_chars() {
763        let chunker = default_chunker();
764        let text = "Hello world. How are you? I am fine!";
765        // Force split by using a small max.
766        let chunks = chunker.chunk_sentence_boundary("doc", text, 15, 0);
767        assert!(chunks.len() >= 2);
768    }
769
770    #[test]
771    fn test_sentence_boundary_overlap() {
772        let chunker = default_chunker();
773        let text = "Sentence one. Sentence two. Sentence three. Sentence four.";
774        let chunks = chunker.chunk_sentence_boundary("doc", text, 30, 1);
775        // With overlap_sentences=1, the last sentence of each chunk is
776        // carried into the next.
777        assert!(chunks.len() >= 2);
778        if chunks.len() >= 2 {
779            // The second chunk should start with the overlapping sentence.
780            let c0_last_sentence = chunks[0]
781                .content
782                .split(". ")
783                .last()
784                .unwrap_or("")
785                .to_string();
786            assert!(
787                chunks[1].content.starts_with(&c0_last_sentence)
788                    || chunks[1].content.contains(&c0_last_sentence)
789            );
790        }
791    }
792
793    #[test]
794    fn test_sentence_boundary_empty_text() {
795        let chunker = default_chunker();
796        let chunks = chunker.chunk_sentence_boundary("doc", "", 512, 1);
797        assert!(chunks.is_empty());
798    }
799
800    // -----------------------------------------------------------------------
801    // Paragraph chunking
802    // -----------------------------------------------------------------------
803
804    #[test]
805    fn test_paragraph_basic() {
806        let chunker = default_chunker();
807        let text = "First paragraph.\n\nSecond paragraph.";
808        let chunks = chunker.chunk_paragraph("doc", text, 200);
809        assert_eq!(chunks.len(), 2);
810        assert_eq!(chunks[0].content, "First paragraph.");
811        assert_eq!(chunks[1].content, "Second paragraph.");
812    }
813
814    #[test]
815    fn test_paragraph_oversized() {
816        let chunker = default_chunker();
817        // Create a paragraph longer than max_chunk_chars.
818        let long = "This is sentence one. This is sentence two. This is sentence three.";
819        let text = format!("{}\n\nShort.", long);
820        let chunks = chunker.chunk_paragraph("doc", &text, 30);
821        // The long paragraph should be split further.
822        assert!(chunks.len() >= 3);
823    }
824
825    #[test]
826    fn test_paragraph_empty_paragraphs_skipped() {
827        let chunker = default_chunker();
828        let text = "Hello.\n\n\n\nWorld.";
829        let chunks = chunker.chunk_paragraph("doc", text, 200);
830        // The empty paragraph between the two real ones should be skipped.
831        assert_eq!(chunks.len(), 2);
832    }
833
834    #[test]
835    fn test_paragraph_single_paragraph() {
836        let chunker = default_chunker();
837        let text = "Single paragraph without any double newline.";
838        let chunks = chunker.chunk_paragraph("doc", text, 200);
839        assert_eq!(chunks.len(), 1);
840    }
841
842    // -----------------------------------------------------------------------
843    // Semantic chunking
844    // -----------------------------------------------------------------------
845
846    #[test]
847    fn test_semantic_basic() {
848        let chunker = default_chunker();
849        let text = "First sentence. Second sentence. Third sentence.";
850        let chunks = chunker.chunk_semantic("doc", text, 200, 0.8);
851        assert_eq!(chunks.len(), 1); // all fit in 200 chars
852    }
853
854    #[test]
855    fn test_semantic_splits() {
856        let chunker = default_chunker();
857        let text = "First sentence here. Second sentence here. Third sentence here.";
858        let chunks = chunker.chunk_semantic("doc", text, 25, 0.8);
859        assert!(chunks.len() >= 2);
860    }
861
862    // -----------------------------------------------------------------------
863    // chunk_text dispatch and counters
864    // -----------------------------------------------------------------------
865
866    #[test]
867    fn test_chunk_text_dispatches_fixed_size() {
868        let mut chunker = DocumentChunker::new(DocumentChunkerConfig {
869            strategy: ChunkStrategy::FixedSize {
870                size: 5,
871                overlap: 0,
872            },
873            preserve_whitespace: false,
874            min_chunk_chars: 1,
875        });
876        let chunks = chunker.chunk_text("d", "abcdefghij");
877        assert_eq!(chunks.len(), 2);
878    }
879
880    #[test]
881    fn test_chunk_text_counters() {
882        let mut chunker = default_chunker();
883        chunker.chunk_text("d1", "Hello world. How are you?");
884        chunker.chunk_text("d2", "Another document.");
885        let (produced, processed) = chunker.chunker_stats();
886        assert_eq!(processed, 2);
887        assert!(produced >= 2);
888    }
889
890    #[test]
891    fn test_chunk_text_min_chunk_filter() {
892        let mut chunker = DocumentChunker::new(DocumentChunkerConfig {
893            strategy: ChunkStrategy::FixedSize {
894                size: 3,
895                overlap: 0,
896            },
897            preserve_whitespace: false,
898            min_chunk_chars: 5,
899        });
900        // All chunks (size=3) will be smaller than min_chunk_chars=5 → filtered out.
901        let chunks = chunker.chunk_text("d", "abcdefghi");
902        assert!(chunks.is_empty());
903    }
904
905    #[test]
906    fn test_chunk_text_whitespace_stripped_by_default() {
907        let mut chunker = DocumentChunker::new(DocumentChunkerConfig {
908            strategy: ChunkStrategy::FixedSize {
909                size: 10,
910                overlap: 0,
911            },
912            preserve_whitespace: false,
913            min_chunk_chars: 1,
914        });
915        let text = "   hello   ";
916        let chunks = chunker.chunk_text("d", text);
917        assert_eq!(chunks.len(), 1);
918        assert_eq!(chunks[0].content.trim(), "hello");
919    }
920
921    #[test]
922    fn test_chunk_text_preserve_whitespace() {
923        let mut chunker = DocumentChunker::new(DocumentChunkerConfig {
924            strategy: ChunkStrategy::FixedSize {
925                size: 11,
926                overlap: 0,
927            },
928            preserve_whitespace: true,
929            min_chunk_chars: 1,
930        });
931        let text = "   hello   ";
932        let chunks = chunker.chunk_text("d", text);
933        assert_eq!(chunks.len(), 1);
934        assert_eq!(chunks[0].content, "   hello   ");
935    }
936
937    // -----------------------------------------------------------------------
938    // merge_small_chunks
939    // -----------------------------------------------------------------------
940
941    fn make_chunk(id: &str, content: &str, idx: usize) -> TextChunk {
942        TextChunk {
943            id: id.to_string(),
944            content: content.to_string(),
945            start_offset: 0,
946            end_offset: content.len(),
947            chunk_index: idx,
948            metadata: HashMap::new(),
949        }
950    }
951
952    #[test]
953    fn test_merge_small_chunks_no_merge_needed() {
954        let chunks = vec![
955            make_chunk("d-0", "long enough content", 0),
956            make_chunk("d-1", "also long enough", 1),
957        ];
958        let merged = DocumentChunker::merge_small_chunks(chunks, 5);
959        assert_eq!(merged.len(), 2);
960    }
961
962    #[test]
963    fn test_merge_small_chunks_merges_short() {
964        let chunks = vec![
965            make_chunk("d-0", "hi", 0),
966            make_chunk("d-1", "there friend of mine", 1),
967        ];
968        let merged = DocumentChunker::merge_small_chunks(chunks, 5);
969        assert_eq!(merged.len(), 1);
970        assert!(merged[0].content.contains("hi"));
971        assert!(merged[0].content.contains("there friend of mine"));
972    }
973
974    #[test]
975    fn test_merge_small_chunks_empty() {
976        let merged = DocumentChunker::merge_small_chunks(vec![], 5);
977        assert!(merged.is_empty());
978    }
979
980    #[test]
981    fn test_merge_small_chunks_re_indices() {
982        let chunks = vec![
983            make_chunk("d-0", "hi", 0),
984            make_chunk("d-1", "there friend of mine", 1),
985            make_chunk("d-2", "another long chunk here", 2),
986        ];
987        let merged = DocumentChunker::merge_small_chunks(chunks, 5);
988        for (i, c) in merged.iter().enumerate() {
989            assert_eq!(c.chunk_index, i);
990        }
991    }
992
993    #[test]
994    fn test_merge_small_chunks_all_short() {
995        // All chunks are short → they cascade into a single merged chunk.
996        let chunks = vec![
997            make_chunk("d-0", "a", 0),
998            make_chunk("d-1", "b", 1),
999            make_chunk("d-2", "c", 2),
1000        ];
1001        let merged = DocumentChunker::merge_small_chunks(chunks, 10);
1002        assert_eq!(merged.len(), 1);
1003    }
1004
1005    // -----------------------------------------------------------------------
1006    // stats
1007    // -----------------------------------------------------------------------
1008
1009    #[test]
1010    fn test_stats_empty() {
1011        let s = DocumentChunker::stats(&[]);
1012        assert_eq!(s.total_chunks, 0);
1013        assert_eq!(s.total_chars, 0);
1014        assert_eq!(s.avg_chunk_chars, 0.0);
1015    }
1016
1017    #[test]
1018    fn test_stats_single_chunk() {
1019        let chunks = vec![make_chunk("d-0", "hello world", 0)];
1020        let s = DocumentChunker::stats(&chunks);
1021        assert_eq!(s.total_chunks, 1);
1022        assert_eq!(s.total_chars, 11);
1023        assert!((s.avg_chunk_chars - 11.0).abs() < 1e-9);
1024        assert_eq!(s.min_chunk_chars, 11);
1025        assert_eq!(s.max_chunk_chars, 11);
1026    }
1027
1028    #[test]
1029    fn test_stats_multiple_chunks() {
1030        let chunks = vec![
1031            make_chunk("d-0", "hello", 0),
1032            make_chunk("d-1", "world!!", 1),
1033        ];
1034        let s = DocumentChunker::stats(&chunks);
1035        assert_eq!(s.total_chunks, 2);
1036        assert_eq!(s.total_chars, 12);
1037        assert_eq!(s.min_chunk_chars, 5);
1038        assert_eq!(s.max_chunk_chars, 7);
1039    }
1040
1041    // -----------------------------------------------------------------------
1042    // rechunk_with_strategy
1043    // -----------------------------------------------------------------------
1044
1045    #[test]
1046    fn test_rechunk_with_strategy() {
1047        // Use min_chunk_chars=1 so FixedSize chunks of size 5 are not filtered.
1048        let mut chunker = DocumentChunker::new(DocumentChunkerConfig {
1049            strategy: ChunkStrategy::SentenceBoundary {
1050                max_chunk_chars: 512,
1051                overlap_sentences: 1,
1052            },
1053            preserve_whitespace: false,
1054            min_chunk_chars: 1,
1055        });
1056        let text = "abcdefghijklmnopqrstuvwxyz";
1057        let chunks = chunker.rechunk_with_strategy(
1058            "doc",
1059            text,
1060            ChunkStrategy::FixedSize {
1061                size: 5,
1062                overlap: 0,
1063            },
1064        );
1065        assert!(!chunks.is_empty());
1066        // Strategy should be restored afterwards.
1067        assert_eq!(
1068            chunker.config.strategy,
1069            ChunkStrategy::SentenceBoundary {
1070                max_chunk_chars: 512,
1071                overlap_sentences: 1
1072            }
1073        );
1074    }
1075
1076    #[test]
1077    fn test_rechunk_strategy_preserved_on_empty() {
1078        let mut chunker = default_chunker();
1079        let original = chunker.config.strategy.clone();
1080        let _ = chunker.rechunk_with_strategy(
1081            "doc",
1082            "",
1083            ChunkStrategy::Paragraph {
1084                max_chunk_chars: 100,
1085            },
1086        );
1087        assert_eq!(chunker.config.strategy, original);
1088    }
1089
1090    // -----------------------------------------------------------------------
1091    // set_metadata
1092    // -----------------------------------------------------------------------
1093
1094    #[test]
1095    fn test_set_metadata() {
1096        let mut chunks = vec![make_chunk("d-0", "hello", 0), make_chunk("d-1", "world", 1)];
1097        DocumentChunker::set_metadata(&mut chunks, "source", "test");
1098        for chunk in &chunks {
1099            assert_eq!(
1100                chunk.metadata.get("source").map(|s| s.as_str()),
1101                Some("test")
1102            );
1103        }
1104    }
1105
1106    #[test]
1107    fn test_set_metadata_overwrites() {
1108        let mut chunks = vec![make_chunk("d-0", "hello", 0)];
1109        DocumentChunker::set_metadata(&mut chunks, "k", "v1");
1110        DocumentChunker::set_metadata(&mut chunks, "k", "v2");
1111        assert_eq!(chunks[0].metadata["k"], "v2");
1112    }
1113
1114    #[test]
1115    fn test_set_metadata_empty_slice() {
1116        let mut chunks: Vec<TextChunk> = Vec::new();
1117        // Should not panic.
1118        DocumentChunker::set_metadata(&mut chunks, "k", "v");
1119        assert!(chunks.is_empty());
1120    }
1121
1122    // -----------------------------------------------------------------------
1123    // group_sentences_into_chunks (low-level)
1124    // -----------------------------------------------------------------------
1125
1126    #[test]
1127    fn test_group_sentences_empty() {
1128        let result = group_sentences_into_chunks("doc", "", &[], 100, 0);
1129        assert!(result.is_empty());
1130    }
1131
1132    #[test]
1133    fn test_group_sentences_single_oversized() {
1134        // A sentence larger than max_chars should still produce one chunk.
1135        let sentences = vec!["This is a rather long sentence.".to_string()];
1136        let result =
1137            group_sentences_into_chunks("doc", "This is a rather long sentence.", &sentences, 5, 0);
1138        assert_eq!(result.len(), 1);
1139    }
1140
1141    // -----------------------------------------------------------------------
1142    // End-to-end / integration tests
1143    // -----------------------------------------------------------------------
1144
1145    #[test]
1146    fn test_chunk_stats_roundtrip() {
1147        let mut chunker = default_chunker();
1148        let text = "The quick brown fox. Jumped over the lazy dog. And ran away quickly.";
1149        let chunks = chunker.chunk_text("doc", text);
1150        let s = DocumentChunker::stats(&chunks);
1151        assert_eq!(s.total_chunks, chunks.len());
1152        assert_eq!(
1153            s.total_chars,
1154            chunks.iter().map(|c| c.content.len()).sum::<usize>()
1155        );
1156    }
1157
1158    #[test]
1159    fn test_chunk_indices_sequential() {
1160        let mut chunker = DocumentChunker::new(DocumentChunkerConfig {
1161            strategy: ChunkStrategy::FixedSize {
1162                size: 3,
1163                overlap: 0,
1164            },
1165            preserve_whitespace: false,
1166            min_chunk_chars: 1,
1167        });
1168        let chunks = chunker.chunk_text("d", "abcdefghi");
1169        for (i, c) in chunks.iter().enumerate() {
1170            assert_eq!(c.chunk_index, i);
1171        }
1172    }
1173
1174    #[test]
1175    fn test_paragraph_strategy_via_chunk_text() {
1176        let mut chunker = DocumentChunker::new(DocumentChunkerConfig {
1177            strategy: ChunkStrategy::Paragraph {
1178                max_chunk_chars: 200,
1179            },
1180            preserve_whitespace: false,
1181            min_chunk_chars: 1,
1182        });
1183        let text = "Alpha beta gamma.\n\nDelta epsilon zeta.";
1184        let chunks = chunker.chunk_text("doc", text);
1185        assert_eq!(chunks.len(), 2);
1186    }
1187
1188    #[test]
1189    fn test_semantic_strategy_via_chunk_text() {
1190        let mut chunker = DocumentChunker::new(DocumentChunkerConfig {
1191            strategy: ChunkStrategy::Semantic {
1192                max_chunk_chars: 200,
1193                similarity_threshold: 0.9,
1194            },
1195            preserve_whitespace: false,
1196            min_chunk_chars: 1,
1197        });
1198        let text = "Sentence alpha. Sentence beta. Sentence gamma.";
1199        let chunks = chunker.chunk_text("doc", text);
1200        assert!(!chunks.is_empty());
1201        // All sentences fit in 200 chars → single chunk.
1202        assert_eq!(chunks.len(), 1);
1203    }
1204
1205    #[test]
1206    fn test_chunker_stats_increments() {
1207        let mut chunker = default_chunker();
1208        assert_eq!(chunker.chunker_stats(), (0, 0));
1209        let _ = chunker.chunk_text("d1", "One sentence.");
1210        let (produced1, processed1) = chunker.chunker_stats();
1211        assert_eq!(processed1, 1);
1212        assert!(produced1 >= 1);
1213        let _ = chunker.chunk_text("d2", "Two sentences. Right here.");
1214        let (produced2, processed2) = chunker.chunker_stats();
1215        assert_eq!(processed2, 2);
1216        assert!(produced2 >= produced1);
1217    }
1218
1219    #[test]
1220    fn test_default_config() {
1221        let cfg = DocumentChunkerConfig::default();
1222        assert_eq!(cfg.min_chunk_chars, 10);
1223        assert!(!cfg.preserve_whitespace);
1224        match cfg.strategy {
1225            ChunkStrategy::SentenceBoundary {
1226                max_chunk_chars,
1227                overlap_sentences,
1228            } => {
1229                assert_eq!(max_chunk_chars, 512);
1230                assert_eq!(overlap_sentences, 1);
1231            }
1232            _ => panic!("Expected SentenceBoundary"),
1233        }
1234    }
1235
1236    #[test]
1237    fn test_chunk_text_returns_correct_ids() {
1238        let mut chunker = DocumentChunker::new(DocumentChunkerConfig {
1239            strategy: ChunkStrategy::FixedSize {
1240                size: 5,
1241                overlap: 0,
1242            },
1243            preserve_whitespace: false,
1244            min_chunk_chars: 1,
1245        });
1246        let chunks = chunker.chunk_text("myid", "abcdefghij");
1247        assert_eq!(chunks[0].id, "myid-0");
1248        assert_eq!(chunks[1].id, "myid-1");
1249    }
1250
1251    #[test]
1252    fn test_stats_overlap_detection() {
1253        // For fixed-size chunks with overlap the offset ranges overlap.
1254        let chunker = default_chunker();
1255        let text = "abcdefghij";
1256        let chunks = chunker.chunk_fixed_size("d", text, 6, 2);
1257        // step=4: chunks start at 0, 4, 8
1258        // pairs: (0..6, 4..10) → 2 overlapping; (4..10, 8..10) → 2 overlapping
1259        // total overlap_chars = 4
1260        let s = DocumentChunker::stats(&chunks);
1261        assert_eq!(s.overlap_chars, 4);
1262    }
1263}