1use std::collections::HashMap;
28
29#[derive(Debug, Clone, PartialEq)]
35pub enum ChunkStrategy {
36 FixedSize {
38 size: usize,
40 overlap: usize,
42 },
43 SentenceBoundary {
45 max_chunk_chars: usize,
47 overlap_sentences: usize,
50 },
51 Paragraph {
53 max_chunk_chars: usize,
56 },
57 Semantic {
60 max_chunk_chars: usize,
62 similarity_threshold: f64,
64 },
65}
66
67#[derive(Debug, Clone, PartialEq)]
69pub struct TextChunk {
70 pub id: String,
72 pub content: String,
74 pub start_offset: usize,
76 pub end_offset: usize,
78 pub chunk_index: usize,
80 pub metadata: HashMap<String, String>,
82}
83
84#[derive(Debug, Clone, PartialEq)]
86pub struct ChunkStats {
87 pub total_chunks: usize,
89 pub total_chars: usize,
91 pub avg_chunk_chars: f64,
93 pub min_chunk_chars: usize,
95 pub max_chunk_chars: usize,
97 pub overlap_chars: usize,
100}
101
102#[derive(Debug, Clone, PartialEq)]
104pub struct DocumentChunkerConfig {
105 pub strategy: ChunkStrategy,
107 pub preserve_whitespace: bool,
110 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#[derive(Debug, Clone)]
134pub struct DocumentChunker {
135 pub config: DocumentChunkerConfig,
137 pub chunks_produced: u64,
139 pub documents_processed: u64,
141}
142
143impl DocumentChunker {
148 pub fn new(config: DocumentChunkerConfig) -> Self {
150 Self {
151 config,
152 chunks_produced: 0,
153 documents_processed: 0,
154 }
155 }
156
157 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 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 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 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 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 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 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 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 ¶graphs {
279 let para_len = para.len();
280
281 if para.len() <= max_chars {
282 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 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 global_byte_offset += para_len + 2;
315 }
316
317 chunks
318 }
319
320 pub fn chunk_semantic(
323 &self,
324 doc_id: &str,
325 text: &str,
326 max_chars: usize,
327 _threshold: f64,
328 ) -> Vec<TextChunk> {
329 let sentences = split_into_sentences(text);
333 group_sentences_into_chunks(doc_id, text, &sentences, max_chars, 0)
334 }
335
336 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 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 pending = Some(prev);
365 } else {
366 result.push(prev);
367 }
368 }
369 }
370 }
371
372 if let Some(last) = pending {
374 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 for (i, chunk) in result.iter_mut().enumerate() {
386 chunk.chunk_index = i;
387 }
388
389 result
390 }
391
392 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 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 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 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 pub fn chunker_stats(&self) -> (u64, u64) {
454 (self.chunks_produced, self.documents_processed)
455 }
456}
457
458fn 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
471fn 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 i += 2;
493 continue;
494 }
495 i += 1;
496 }
497
498 let tail = current.trim_end().to_string();
500 if !tail.is_empty() {
501 sentences.push(tail);
502 }
503
504 sentences
505}
506
507fn 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; 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 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 group.is_empty() {
554 group.push(sentences[i].as_str());
555 i += 1;
556 }
557
558 let content = group.join(" ");
559
560 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 let advance = (i - sent_idx).saturating_sub(overlap_sentences).max(1);
575 sent_idx += advance;
576 }
577
578 chunks
579}
580
581fn find_chunk_offsets(
587 chunk_content: &str,
588 full_text: &str,
589 prev_chunk: Option<&TextChunk>,
590) -> (usize, usize) {
591 let search_from = prev_chunk.map(|c| c.start_offset).unwrap_or(0);
593
594 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 if let Some(rel) = full_text.find(chunk_content) {
602 return (rel, rel + chunk_content.len());
603 }
604
605 (0, chunk_content.len())
607}
608
609#[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 #[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); assert_eq!(char_idx_to_byte(s, 99), 5); }
637
638 #[test]
639 fn test_char_idx_to_byte_utf8() {
640 let s = "héllo"; 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 #[test]
682 fn test_fixed_size_basic() {
683 let chunker = default_chunker();
684 let text = "abcdefghij"; 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"; let chunks = chunker.chunk_fixed_size("doc", text, 6, 2);
696 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 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 #[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); 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 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 assert!(chunks.len() >= 2);
778 if chunks.len() >= 2 {
779 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 #[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 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 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 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 #[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); }
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 #[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 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 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 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 #[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 #[test]
1046 fn test_rechunk_with_strategy() {
1047 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 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 #[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 DocumentChunker::set_metadata(&mut chunks, "k", "v");
1119 assert!(chunks.is_empty());
1120 }
1121
1122 #[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 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 #[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 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 let chunker = default_chunker();
1255 let text = "abcdefghij";
1256 let chunks = chunker.chunk_fixed_size("d", text, 6, 2);
1257 let s = DocumentChunker::stats(&chunks);
1261 assert_eq!(s.overlap_chars, 4);
1262 }
1263}