1use crate::config::{ChunkingConfig, ChunkingStrategy};
10use crate::tokenizer::TokenCounter;
11use crate::types::TextChunk;
12
13const MAX_RECURSION_DEPTH: usize = 10;
15
16pub fn chunk_text(
21 text: &str,
22 config: &ChunkingConfig,
23 token_counter: &dyn TokenCounter,
24) -> Vec<TextChunk> {
25 if text.trim().is_empty() {
26 return Vec::new();
27 }
28
29 if config.strategy == ChunkingStrategy::Plain
33 && text.len() <= config.max_size
34 && text.len() >= config.min_size
35 {
36 return vec![TextChunk {
37 index: 0,
38 content: text.to_string(),
39 token_count_estimate: token_counter.count_tokens(text),
40 }];
41 }
42
43 let raw_chunks = match config.strategy {
44 ChunkingStrategy::Plain => plain_split(text, config),
45 ChunkingStrategy::Sentence => sentence_split(text, config),
46 ChunkingStrategy::Code => code_split(text, config),
47 ChunkingStrategy::Markdown => markdown_split(text, config),
48 };
49
50 let merged = merge_small_chunks(raw_chunks, config.target_size, config.min_size);
52
53 let overlapped = apply_overlap(merged, config.overlap);
55
56 overlapped
58 .into_iter()
59 .enumerate()
60 .map(|(i, content)| {
61 let token_count_estimate = token_counter.count_tokens(&content);
62 TextChunk {
63 index: i,
64 content,
65 token_count_estimate,
66 }
67 })
68 .collect()
69}
70
71fn plain_split(text: &str, config: &ChunkingConfig) -> Vec<String> {
77 recursive_split(text, config, 0)
78}
79
80fn sentence_split(text: &str, config: &ChunkingConfig) -> Vec<String> {
89 let sentences = split_into_sentences(text);
90 if sentences.is_empty() {
91 return vec![text.to_string()];
92 }
93
94 let mut chunks = Vec::new();
96 let mut current = String::new();
97
98 for sentence in &sentences {
99 if !current.is_empty() && current.len() + sentence.len() > config.target_size {
100 chunks.push(std::mem::take(&mut current));
101 }
102 current.push_str(sentence);
103 }
104 if !current.is_empty() {
105 chunks.push(current);
106 }
107
108 let mut result = Vec::new();
110 for chunk in chunks {
111 if chunk.len() > config.max_size {
112 result.extend(recursive_split(&chunk, config, 0));
113 } else {
114 result.push(chunk);
115 }
116 }
117
118 if result.len() <= 1 {
119 return result;
120 }
121
122 if config.overlap == 0 {
125 return result;
126 }
127
128 let mut overlapped = Vec::with_capacity(result.len());
129 overlapped.push(result[0].clone());
130
131 for chunk in result.iter().skip(1) {
132 let Some(prev) = overlapped.last() else {
133 continue;
134 };
135 let overlap_start = find_sentence_overlap_start(prev, config.overlap);
136 let mut combined = String::new();
137 if overlap_start < prev.len() {
138 combined.push_str(&prev[overlap_start..]);
139 }
140 combined.push_str(chunk);
141 overlapped.push(combined);
142 }
143
144 overlapped
145}
146
147fn split_into_sentences(text: &str) -> Vec<String> {
151 let mut sentences = Vec::new();
152 let mut current = String::new();
153 let chars: Vec<char> = text.chars().collect();
154 let mut i = 0;
155
156 while i < chars.len() {
157 let ch = chars[i];
158 current.push(ch);
159
160 if (ch == '.' || ch == '!' || ch == '?')
162 && (i + 1 >= chars.len() || chars[i + 1].is_whitespace() || chars[i + 1] == '\n')
163 {
164 if ch == '.' && i > 0 && chars[i - 1].is_ascii_digit() {
166 i += 1;
167 continue;
168 }
169
170 if is_likely_abbreviation(¤t) {
173 i += 1;
174 continue;
175 }
176
177 let mut j = i + 1;
179 while j < chars.len() && chars[j].is_whitespace() {
180 current.push(chars[j]);
181 j += 1;
182 }
183
184 sentences.push(std::mem::take(&mut current));
185 i = j;
186 continue;
187 }
188
189 if ch == '\n' && i + 1 < chars.len() && chars[i + 1] == '\n' {
191 current.push('\n');
192 sentences.push(std::mem::take(&mut current));
193 i += 2;
194 continue;
195 }
196
197 i += 1;
198 }
199
200 if !current.is_empty() {
201 sentences.push(current);
202 }
203
204 sentences
205}
206
207fn is_likely_abbreviation(text: &str) -> bool {
210 let trimmed = text.trim_end();
211 if trimmed.len() == 2 {
213 let bytes = trimmed.as_bytes();
214 return bytes[0].is_ascii_uppercase() && bytes[1] == b'.';
215 }
216 let bytes = trimmed.as_bytes();
219 if bytes.len() >= 4 && bytes[bytes.len() - 1] == b'.' {
220 let n = bytes.len();
222 if bytes[n - 3] == b'.' && bytes[n - 2].is_ascii_alphabetic() {
223 return true;
224 }
225 }
226 let lower = trimmed.to_lowercase();
228 for abbr in &[
229 "e.g", "i.e", "etc", "vs", "mr", "mrs", "dr", "prof", "inc", "ltd",
230 ] {
231 if lower.ends_with(abbr) {
232 return true;
233 }
234 }
235 false
236}
237fn find_sentence_overlap_start(prev: &str, overlap: usize) -> usize {
239 if prev.len() <= overlap {
240 return 0;
241 }
242
243 let raw_start = prev.len().saturating_sub(overlap);
244 let safe_start = safe_split_at(prev, raw_start);
245
246 let tail = &prev[safe_start..];
248
249 let search_limit = tail.len().min(overlap);
252 let search_region = &tail[..search_limit];
253
254 for (idx, ch) in search_region.char_indices() {
255 if (ch == '.' || ch == '!' || ch == '?') && idx + 1 < search_region.len() {
256 let after = &search_region[idx + 1..];
258 if after.starts_with(|c: char| c.is_whitespace()) {
259 let ws_end = after
261 .char_indices()
262 .skip_while(|(_, c)| c.is_whitespace())
263 .next()
264 .map(|(i, _)| i)
265 .unwrap_or(after.len());
266 return safe_start + idx + 1 + ws_end;
267 }
268 }
269 }
270
271 let word_start = prev[safe_start..]
273 .find(' ')
274 .map(|pos| safe_start + pos + 1)
275 .unwrap_or(safe_start);
276
277 word_start
278}
279
280fn code_split(text: &str, config: &ChunkingConfig) -> Vec<String> {
288 let boundaries = detect_code_block_boundaries(text);
289
290 if boundaries.is_empty() {
291 return recursive_split(text, config, 0);
293 }
294
295 let segments = build_code_segments(text, &boundaries);
298
299 let mut chunks = Vec::new();
300 let mut current = String::new();
301
302 for segment in &segments {
303 if segment.len() > config.max_size {
306 if !current.is_empty() {
307 chunks.push(std::mem::take(&mut current));
308 }
309 for part in force_split(segment, config.max_size) {
311 chunks.push(part);
312 }
313 continue;
314 }
315
316 if !current.is_empty() && current.len() + segment.len() > config.target_size {
317 chunks.push(std::mem::take(&mut current));
318 }
319 current.push_str(segment);
320 }
321
322 if !current.is_empty() {
323 chunks.push(current);
324 }
325
326 let mut result = Vec::new();
328 for chunk in chunks {
329 if chunk.len() > config.max_size {
330 result.extend(recursive_split(&chunk, config, 0));
331 } else {
332 result.push(chunk);
333 }
334 }
335
336 if result.is_empty() {
337 vec![text.to_string()]
338 } else {
339 result
340 }
341}
342
343#[derive(Debug, Clone)]
346struct CodeBlockBoundary {
347 start: usize,
349 end: usize,
351}
352
353fn detect_code_block_boundaries(text: &str) -> Vec<CodeBlockBoundary> {
357 let mut boundaries = Vec::new();
358
359 boundaries.extend(detect_brace_functions(text));
361 boundaries.extend(detect_python_functions(text));
363
364 if boundaries.is_empty() {
365 return Vec::new();
366 }
367
368 boundaries.sort_by_key(|b| b.start);
370
371 let mut result = Vec::new();
373 for b in boundaries {
374 if result
375 .last()
376 .map_or(true, |last: &CodeBlockBoundary| last.end <= b.start)
377 {
378 result.push(b);
379 }
380 }
381
382 result
383}
384
385fn detect_brace_functions(text: &str) -> Vec<CodeBlockBoundary> {
395 let mut boundaries = Vec::new();
396 let lines: Vec<(usize, &str)> = text
397 .lines()
398 .scan(0usize, |offset, line| {
399 let start = *offset;
400 *offset += line.len() + 1; Some((start, line))
402 })
403 .collect();
404
405 for (i, &(line_start, line)) in lines.iter().enumerate() {
406 let trimmed = line.trim_start();
407
408 let is_fn = trimmed.starts_with("fn ")
410 || trimmed.starts_with("pub fn ")
411 || trimmed.starts_with("pub(crate) fn ")
412 || trimmed.starts_with("async fn ")
413 || trimmed.starts_with("pub async fn ")
414 || trimmed.starts_with("function ")
415 || trimmed.starts_with("async function ")
416 || trimmed.starts_with("export function ")
417 || trimmed.starts_with("export async function ")
418 || trimmed.starts_with("export default function ")
419 || (trimmed.starts_with("const ") && trimmed.contains("= function"))
420 || (trimmed.starts_with("const ") && trimmed.contains("=>"))
421 || (trimmed.starts_with("pub const ") && trimmed.contains("=>"));
422
423 if !is_fn {
424 continue;
425 }
426
427 let mut brace_offset = None;
429
430 for (j, &(ls, l)) in lines.iter().enumerate().skip(i) {
431 if let Some(pos) = l.find('{') {
432 brace_offset = Some(ls + pos);
433 break;
434 }
435 if l.contains(';') && !l.contains('{') && j > i {
438 break;
439 }
440 }
441
442 let Some(brace_start) = brace_offset else {
443 continue;
444 };
445
446 if let Some(close_end) = match_braces(text, brace_start) {
448 boundaries.push(CodeBlockBoundary {
449 start: line_start,
450 end: close_end,
451 });
452 }
453 }
454
455 boundaries
456}
457
458fn match_braces(text: &str, brace_start: usize) -> Option<usize> {
461 let bytes = text.as_bytes();
462 let mut depth: i32 = 0;
463 let mut in_string = false;
464 let mut string_char = b'"';
465 let mut in_char = false;
466 let mut in_line_comment = false;
467 let mut in_block_comment = false;
468 let mut prev_char = b'\0';
469
470 let mut i = brace_start;
471 while i < bytes.len() {
472 let ch = bytes[i];
473
474 if in_line_comment {
475 if ch == b'\n' {
476 in_line_comment = false;
477 }
478 prev_char = ch;
479 i += 1;
480 continue;
481 }
482
483 if in_block_comment {
484 if prev_char == b'*' && ch == b'/' {
485 in_block_comment = false;
486 }
487 prev_char = ch;
488 i += 1;
489 continue;
490 }
491
492 if in_string {
493 if ch == string_char && prev_char != b'\\' {
494 in_string = false;
495 }
496 prev_char = ch;
497 i += 1;
498 continue;
499 }
500
501 if in_char {
502 if ch == b'\'' && prev_char != b'\\' {
503 in_char = false;
504 }
505 prev_char = ch;
506 i += 1;
507 continue;
508 }
509
510 if ch == b'/' && i + 1 < bytes.len() {
512 if bytes[i + 1] == b'/' {
513 in_line_comment = true;
514 prev_char = ch;
515 i += 2;
516 continue;
517 }
518 if bytes[i + 1] == b'*' {
519 in_block_comment = true;
520 prev_char = ch;
521 i += 2;
522 continue;
523 }
524 }
525
526 if ch == b'"' || ch == b'`' {
528 in_string = true;
529 string_char = ch;
530 prev_char = ch;
531 i += 1;
532 continue;
533 }
534
535 if ch == b'\'' {
536 in_char = true;
537 prev_char = ch;
538 i += 1;
539 continue;
540 }
541
542 if ch == b'{' {
543 depth += 1;
544 } else if ch == b'}' {
545 depth -= 1;
546 if depth == 0 {
547 return Some(i + 1);
548 }
549 }
550
551 prev_char = ch;
552 i += 1;
553 }
554
555 None
556}
557
558fn detect_python_functions(text: &str) -> Vec<CodeBlockBoundary> {
563 let mut boundaries = Vec::new();
564 let lines: Vec<(usize, &str)> = text
565 .lines()
566 .scan(0usize, |offset, line| {
567 let start = *offset;
568 *offset += line.len() + 1;
569 Some((start, line))
570 })
571 .collect();
572
573 for (i, &(line_start, line)) in lines.iter().enumerate() {
574 let trimmed = line.trim_start();
575
576 if !(trimmed.starts_with("def ")
578 || trimmed.starts_with("async def ")
579 || trimmed.starts_with("class "))
580 {
581 continue;
582 }
583
584 let mut colon_found = false;
587 let mut end_line_idx = i;
588 for (j, &(_, l)) in lines.iter().enumerate().skip(i) {
589 if l.contains(':') {
590 colon_found = true;
591 end_line_idx = j;
592 break;
593 }
594 if j > i && l.trim().is_empty() {
596 break;
597 }
598 }
599
600 if !colon_found {
601 continue;
602 }
603
604 let def_indent = line.len() - line.trim_start().len();
607 let mut body_start_line = None;
608 let mut body_end_line = end_line_idx;
609
610 for (k, &(_, l)) in lines.iter().enumerate().skip(end_line_idx + 1) {
611 if l.trim().is_empty() {
612 continue;
613 }
614 let line_indent = l.len() - l.trim_start().len();
615
616 if body_start_line.is_none() {
617 if line_indent > def_indent {
618 body_start_line = Some(k);
619 } else {
620 break;
622 }
623 } else {
624 if line_indent <= def_indent && !l.trim().is_empty() {
626 body_end_line = k.saturating_sub(1);
627 break;
628 }
629 body_end_line = k;
630 }
631 }
632
633 if let Some(bsl) = body_start_line {
634 let end_offset = if body_end_line + 1 < lines.len() {
635 lines[body_end_line + 1].0
636 } else {
637 text.len()
639 };
640 boundaries.push(CodeBlockBoundary {
641 start: line_start,
642 end: end_offset.max(lines[bsl].0),
643 });
644 }
645 }
646
647 boundaries
648}
649
650fn build_code_segments(text: &str, boundaries: &[CodeBlockBoundary]) -> Vec<String> {
654 let mut segments = Vec::new();
655 let mut cursor = 0;
656
657 for b in boundaries {
658 if b.start > cursor {
660 let prefix = &text[cursor..b.start];
661 if !prefix.is_empty() {
662 segments.push(prefix.to_string());
663 }
664 }
665 if b.end > b.start {
667 segments.push(text[b.start..b.end].to_string());
668 }
669 cursor = b.end;
670 }
671
672 if cursor < text.len() {
674 let suffix = &text[cursor..];
675 if !suffix.is_empty() {
676 segments.push(suffix.to_string());
677 }
678 }
679
680 segments
681}
682
683fn markdown_split(text: &str, config: &ChunkingConfig) -> Vec<String> {
691 let sections = split_markdown_by_headers(text);
692
693 if sections.is_empty() {
694 return recursive_split(text, config, 0);
695 }
696
697 let mut chunks = Vec::new();
699 let mut current = String::new();
700
701 for section in §ions {
702 if !current.is_empty() && current.len() + section.len() > config.target_size {
703 chunks.push(std::mem::take(&mut current));
704 }
705 current.push_str(section);
706 }
707
708 if !current.is_empty() {
709 chunks.push(current);
710 }
711
712 let mut result = Vec::new();
714 for chunk in chunks {
715 if chunk.len() > config.max_size {
716 result.extend(recursive_split(&chunk, config, 0));
717 } else {
718 result.push(chunk);
719 }
720 }
721
722 if result.is_empty() {
723 vec![text.to_string()]
724 } else {
725 result
726 }
727}
728
729fn split_markdown_by_headers(text: &str) -> Vec<String> {
734 let lines: Vec<(usize, &str)> = text
735 .lines()
736 .scan(0usize, |offset, line| {
737 let start = *offset;
738 *offset += line.len() + 1;
739 Some((start, line))
740 })
741 .collect();
742
743 if lines.is_empty() {
744 return Vec::new();
745 }
746
747 let header_positions: Vec<(usize, usize)> = lines
749 .iter()
750 .filter_map(|&(offset, line)| {
751 let level = markdown_header_level(line);
752 if level > 0 {
753 Some((offset, level))
754 } else {
755 None
756 }
757 })
758 .collect();
759
760 if header_positions.is_empty() {
761 return vec![text.to_string()];
762 }
763
764 let mut sections = Vec::new();
765 let mut cursor = 0;
766
767 for (idx, &(header_offset, header_level)) in header_positions.iter().enumerate() {
768 if idx == 0 && header_offset > 0 {
771 let preamble = &text[0..header_offset];
772 if !preamble.trim().is_empty() {
773 sections.push(preamble.to_string());
774 }
775 }
776
777 let section_end = header_positions
779 .iter()
780 .skip(idx + 1)
781 .find(|&&(_, level)| level <= header_level)
782 .map(|&(offset, _)| offset)
783 .unwrap_or(text.len());
784
785 cursor = section_end;
786 let section_text = &text[header_offset..section_end];
787 if !section_text.trim().is_empty() {
788 sections.push(section_text.to_string());
789 }
790 }
791
792 if cursor < text.len() {
794 let trailing = &text[cursor..];
795 if !trailing.trim().is_empty() {
796 sections.push(trailing.to_string());
797 }
798 }
799
800 sections
801}
802
803fn markdown_header_level(line: &str) -> usize {
806 let trimmed = line.trim_start();
807
808 if trimmed.starts_with('#') {
810 let count = trimmed.chars().take_while(|&c| c == '#').count();
811 if count >= 1 && count <= 6 {
812 let after_hashes = &trimmed[count..];
814 if after_hashes.is_empty() {
815 return 0; }
817 if after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
818 if after_hashes.trim().is_empty() {
820 return 0;
821 }
822 return count;
823 }
824 }
825 return 0;
826 }
827
828 0
829}
830
831fn safe_split_at(text: &str, pos: usize) -> usize {
837 let mut split = pos.min(text.len());
838 while split > 0 && !text.is_char_boundary(split) {
839 split -= 1;
840 }
841 split
842}
843
844fn force_split(text: &str, max_size: usize) -> Vec<String> {
846 let mut chunks = Vec::new();
847 let mut start = 0;
848 while start < text.len() {
849 let end = safe_split_at(text, start + max_size);
850 if end <= start {
851 let mut next = start + 1;
853 while next < text.len() && !text.is_char_boundary(next) {
854 next += 1;
855 }
856 if next <= text.len() {
857 chunks.push(text[start..next].to_string());
858 }
859 start = next;
860 continue;
861 }
862 chunks.push(text[start..end].to_string());
863 start = end;
864 }
865 chunks
866}
867
868fn recursive_split(text: &str, config: &ChunkingConfig, depth: usize) -> Vec<String> {
870 if text.len() <= config.max_size {
871 return vec![text.to_string()];
872 }
873
874 if depth >= MAX_RECURSION_DEPTH {
875 tracing::warn!("Chunker hit max recursion depth, force-splitting at max_size");
876 return force_split(text, config.max_size);
877 }
878
879 let separators: &[&str] = &["\n\n", ". ", "? ", "! ", " "];
881
882 for sep in separators {
883 let parts: Vec<&str> = text.split(sep).collect();
884 if parts.len() <= 1 {
885 continue;
886 }
887
888 let mut chunks = Vec::new();
889 let mut current = String::new();
890
891 for (i, part) in parts.iter().enumerate() {
892 let piece = if i + 1 < parts.len() {
893 format!("{}{}", part, sep)
894 } else {
895 part.to_string()
896 };
897
898 if current.len() + piece.len() > config.target_size && !current.is_empty() {
899 chunks.push(current);
900 current = piece;
901 } else {
902 current.push_str(&piece);
903 }
904 }
905
906 if !current.is_empty() {
907 chunks.push(current);
908 }
909
910 let mut result = Vec::new();
912 for chunk in chunks {
913 if chunk.len() > config.max_size {
914 result.extend(recursive_split(&chunk, config, depth + 1));
915 } else {
916 result.push(chunk);
917 }
918 }
919
920 if result.len() > 1 {
921 return result;
922 }
923 }
924
925 force_split(text, config.max_size)
927}
928
929fn merge_small_chunks(chunks: Vec<String>, target_size: usize, min_size: usize) -> Vec<String> {
931 if chunks.is_empty() {
932 return chunks;
933 }
934
935 let mut merged = Vec::new();
936 let mut current = chunks[0].clone();
937
938 for chunk in chunks.iter().skip(1) {
939 if (current.len() < min_size || chunk.len() < min_size)
940 && current.len() + chunk.len() <= target_size
941 {
942 current.push_str(chunk);
943 } else {
944 merged.push(current);
945 current = chunk.clone();
946 }
947 }
948
949 merged.push(current);
950 if merged.len() > 1 {
951 if let Some(last) = merged.last() {
952 if last.len() < min_size {
953 let last = merged.pop().unwrap_or_default();
954 if let Some(previous) = merged.last_mut() {
955 previous.push_str(&last);
956 } else {
957 merged.push(last);
958 }
959 }
960 }
961 }
962 merged
963}
964
965fn apply_overlap(chunks: Vec<String>, overlap: usize) -> Vec<String> {
967 if chunks.len() <= 1 || overlap == 0 {
968 return chunks;
969 }
970
971 let mut result = Vec::with_capacity(chunks.len());
972 result.push(chunks[0].clone());
973
974 for i in 1..chunks.len() {
975 let prev = result.last().map(String::as_str).unwrap_or(&chunks[i - 1]);
976 let overlap_start = if prev.len() > overlap {
977 let raw_start = prev.len() - overlap;
979 let safe_start = safe_split_at(prev, raw_start);
980 prev[safe_start..]
982 .find(' ')
983 .map(|pos| safe_start + pos + 1)
984 .unwrap_or(safe_start)
985 } else {
986 0
987 };
988
989 let overlap_text = &prev[overlap_start..];
990 let mut chunk_with_overlap = overlap_text.to_string();
991 chunk_with_overlap.push_str(&chunks[i]);
992 result.push(chunk_with_overlap);
993 }
994
995 result
996}
997
998#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use crate::tokenizer::EstimateTokenCounter;
1006
1007 fn make_config(strategy: ChunkingStrategy) -> ChunkingConfig {
1008 ChunkingConfig {
1009 target_size: 100,
1010 min_size: 10,
1011 max_size: 200,
1012 overlap: 0,
1013 strategy,
1014 }
1015 }
1016
1017 fn default_config() -> ChunkingConfig {
1018 ChunkingConfig::default()
1019 }
1020
1021 fn token_counter() -> EstimateTokenCounter {
1022 EstimateTokenCounter
1023 }
1024
1025 #[test]
1028 fn test_plain_empty_string() {
1029 let config = make_config(ChunkingStrategy::Plain);
1030 let counter = token_counter();
1031 let chunks = chunk_text("", &config, &counter);
1032 assert!(chunks.is_empty());
1033 }
1034
1035 #[test]
1036 fn test_plain_short_text_single_chunk() {
1037 let config = make_config(ChunkingStrategy::Plain);
1038 let counter = token_counter();
1039 let text = "Hello world.";
1040 let chunks = chunk_text(text, &config, &counter);
1041 assert_eq!(chunks.len(), 1);
1042 assert_eq!(chunks[0].content, text);
1043 }
1044
1045 #[test]
1046 fn test_plain_paragraph_split() {
1047 let config = ChunkingConfig {
1048 target_size: 30,
1049 min_size: 5,
1050 max_size: 60,
1051 overlap: 0,
1052 strategy: ChunkingStrategy::Plain,
1053 };
1054 let counter = token_counter();
1055 let text = "First paragraph here with enough text to exceed the limit.\n\nSecond paragraph here with enough text to exceed the limit.\n\nThird one here with enough text.";
1056 let chunks = chunk_text(text, &config, &counter);
1057 assert!(chunks.len() > 1);
1058 }
1059
1060 #[test]
1061 fn test_plain_default_strategy_is_plain() {
1062 let config = default_config();
1064 assert_eq!(config.strategy, ChunkingStrategy::Plain);
1065 }
1066
1067 #[test]
1068 fn test_plain_backward_compatible_output() {
1069 let text = "This is a test sentence. This is another one. And a third here. ";
1071 let counter = token_counter();
1072
1073 let default_config = default_config();
1074 let plain_config = ChunkingConfig {
1075 strategy: ChunkingStrategy::Plain,
1076 ..default_config.clone()
1077 };
1078
1079 let default_chunks = chunk_text(text, &default_config, &counter);
1080 let plain_chunks = chunk_text(text, &plain_config, &counter);
1081
1082 assert_eq!(default_chunks.len(), plain_chunks.len());
1083 for (d, p) in default_chunks.iter().zip(plain_chunks.iter()) {
1084 assert_eq!(d.content, p.content);
1085 assert_eq!(d.index, p.index);
1086 }
1087 }
1088
1089 #[test]
1090 fn test_plain_unicode_safe() {
1091 let config = default_config();
1092 let counter = token_counter();
1093 let text =
1094 "日本語のテストです。これは非常に長いテキストで、チャンク分割が必要です。".repeat(20);
1095 let chunks = chunk_text(&text, &config, &counter);
1096 for chunk in &chunks {
1098 assert!(std::str::from_utf8(chunk.content.as_bytes()).is_ok());
1099 }
1100 }
1101
1102 #[test]
1105 fn test_sentence_empty_string() {
1106 let config = make_config(ChunkingStrategy::Sentence);
1107 let counter = token_counter();
1108 let chunks = chunk_text("", &config, &counter);
1109 assert!(chunks.is_empty());
1110 }
1111
1112 #[test]
1113 fn test_sentence_short_text_single_chunk() {
1114 let config = make_config(ChunkingStrategy::Sentence);
1115 let counter = token_counter();
1116 let text = "Hello world.";
1117 let chunks = chunk_text(text, &config, &counter);
1118 assert_eq!(chunks.len(), 1);
1119 assert_eq!(chunks[0].content, text);
1120 }
1121
1122 #[test]
1123 fn test_sentence_splits_on_boundaries() {
1124 let config = ChunkingConfig {
1125 target_size: 30,
1126 min_size: 5,
1127 max_size: 60,
1128 overlap: 0,
1129 strategy: ChunkingStrategy::Sentence,
1130 };
1131 let counter = token_counter();
1132 let text = "First sentence here. Second one follows. Third one now. Fourth.";
1133 let chunks = chunk_text(text, &config, &counter);
1134 assert!(
1135 chunks.len() > 1,
1136 "expected multiple chunks, got {}",
1137 chunks.len()
1138 );
1139 for chunk in &chunks {
1141 assert!(
1142 chunk.content.len() <= 60,
1143 "chunk len {} exceeds max_size 60",
1144 chunk.content.len()
1145 );
1146 }
1147 }
1148
1149 #[test]
1150 fn test_sentence_with_overlap() {
1151 let config = ChunkingConfig {
1152 target_size: 30,
1153 min_size: 5,
1154 max_size: 60,
1155 overlap: 10,
1156 strategy: ChunkingStrategy::Sentence,
1157 };
1158 let counter = token_counter();
1159 let text = "First sentence here. Second one follows. Third one now. Fourth.";
1160 let chunks = chunk_text(text, &config, &counter);
1161 assert!(chunks.len() > 1);
1162 if chunks.len() >= 2 {
1165 assert!(!chunks[1].content.is_empty());
1167 }
1168 }
1169
1170 #[test]
1171 fn test_sentence_no_split_on_abbreviations() {
1172 let sentences = split_into_sentences("Hello e.g. world. This is a test.");
1173 assert!(
1176 sentences.len() <= 3,
1177 "got {} sentences: {:?}",
1178 sentences.len(),
1179 sentences
1180 );
1181 }
1182
1183 #[test]
1184 fn test_sentence_no_split_on_decimals() {
1185 let sentences = split_into_sentences("The value is 3.14 today. End.");
1186 assert_eq!(sentences.len(), 2);
1188 }
1189
1190 #[test]
1191 fn test_sentence_paragraph_break() {
1192 let sentences = split_into_sentences("First paragraph.\n\nSecond paragraph.");
1193 assert!(sentences.len() >= 2);
1195 }
1196
1197 #[test]
1200 fn test_code_empty_string() {
1201 let config = make_config(ChunkingStrategy::Code);
1202 let counter = token_counter();
1203 let chunks = chunk_text("", &config, &counter);
1204 assert!(chunks.is_empty());
1205 }
1206
1207 #[test]
1208 fn test_code_rust_function_not_split() {
1209 let config = ChunkingConfig {
1210 target_size: 50,
1211 min_size: 5,
1212 max_size: 500,
1213 overlap: 0,
1214 strategy: ChunkingStrategy::Code,
1215 };
1216 let counter = token_counter();
1217 let text = r#"// Header comment
1218fn foo() {
1219 let x = 1;
1220 let y = 2;
1221 let z = x + y;
1222 println!("{}", z);
1223 // More code
1224 let a = 10;
1225 let b = 20;
1226 let c = a + b;
1227}
1228
1229fn bar() {
1230 println!("hello");
1231}
1232"#;
1233 let chunks = chunk_text(text, &config, &counter);
1234 let all_content: String = chunks.iter().map(|c| c.content.as_str()).collect();
1236 assert!(all_content.contains("fn foo()") || all_content.contains("fn bar()"));
1237 for chunk in &chunks {
1239 assert!(chunk.content.len() <= 500, "chunk exceeds max_size");
1240 }
1241 }
1242
1243 #[test]
1244 fn test_code_python_function_not_split() {
1245 let config = ChunkingConfig {
1246 target_size: 40,
1247 min_size: 5,
1248 max_size: 500,
1249 overlap: 0,
1250 strategy: ChunkingStrategy::Code,
1251 };
1252 let counter = token_counter();
1253 let text = "# Header comment\ndef foo(x, y):\n result = x + y\n print(result)\n return result\n\ndef bar():\n print('hello')\n";
1254 let chunks = chunk_text(text, &config, &counter);
1255 let all_content: String = chunks.iter().map(|c| c.content.as_str()).collect();
1257 assert!(all_content.contains("def foo") || all_content.contains("def bar"));
1258 }
1259
1260 #[test]
1261 fn test_code_typescript_function_not_split() {
1262 let config = ChunkingConfig {
1263 target_size: 50,
1264 min_size: 5,
1265 max_size: 500,
1266 overlap: 0,
1267 strategy: ChunkingStrategy::Code,
1268 };
1269 let counter = token_counter();
1270 let text = "// Header\nfunction add(a: number, b: number): number {\n return a + b;\n}\n\nfunction sub(a: number, b: number): number {\n return a - b;\n}\n";
1271 let chunks = chunk_text(text, &config, &counter);
1272 let all_content: String = chunks.iter().map(|c| c.content.as_str()).collect();
1273 assert!(all_content.contains("function add"));
1274 }
1275
1276 #[test]
1277 fn test_code_falls_back_to_plain_for_non_code() {
1278 let config = make_config(ChunkingStrategy::Code);
1279 let counter = token_counter();
1280 let text = "This is just plain text with no functions at all. Just sentences.";
1281 let chunks = chunk_text(text, &config, &counter);
1282 assert!(!chunks.is_empty());
1284 }
1285
1286 #[test]
1287 fn test_code_brace_matching_with_strings() {
1288 let text = "fn test() { let s = \"{ not a brace }\"; let x = 1; }";
1289 let boundaries = detect_brace_functions(text);
1290 assert_eq!(boundaries.len(), 1);
1291 assert!(boundaries[0].end > boundaries[0].start);
1293 }
1294
1295 #[test]
1296 fn test_code_brace_matching_with_comments() {
1297 let text = "fn test() { // { comment brace\n let x = 1; /* } */ let y = 2; }";
1298 let boundaries = detect_brace_functions(text);
1299 assert_eq!(boundaries.len(), 1);
1300 }
1301
1302 #[test]
1303 fn test_code_async_function_detection() {
1304 let text = "async fn fetch() { let resp = reqwest::get(\"url\").await; resp }";
1305 let boundaries = detect_brace_functions(text);
1306 assert_eq!(boundaries.len(), 1);
1307 }
1308
1309 #[test]
1312 fn test_markdown_empty_string() {
1313 let config = make_config(ChunkingStrategy::Markdown);
1314 let counter = token_counter();
1315 let chunks = chunk_text("", &config, &counter);
1316 assert!(chunks.is_empty());
1317 }
1318
1319 #[test]
1320 fn test_markdown_splits_on_headers() {
1321 let config = ChunkingConfig {
1322 target_size: 100,
1323 min_size: 5,
1324 max_size: 500,
1325 overlap: 0,
1326 strategy: ChunkingStrategy::Markdown,
1327 };
1328 let counter = token_counter();
1329 let text = "# Title\n\nSome content here.\n\n## Section A\n\nContent for A.\n\n## Section B\n\nContent for B.";
1330 let chunks = chunk_text(text, &config, &counter);
1331 assert!(
1333 chunks.len() > 1,
1334 "expected multiple chunks, got {}",
1335 chunks.len()
1336 );
1337 }
1338
1339 #[test]
1340 fn test_markdown_header_level_detection() {
1341 assert_eq!(markdown_header_level("# Header"), 1);
1342 assert_eq!(markdown_header_level("## Header"), 2);
1343 assert_eq!(markdown_header_level("### Header"), 3);
1344 assert_eq!(markdown_header_level("###### Header"), 6);
1345 assert_eq!(markdown_header_level("####### Too many"), 0);
1346 assert_eq!(markdown_header_level("Not a header"), 0);
1347 assert_eq!(markdown_header_level("##"), 0); assert_eq!(markdown_header_level(""), 0);
1349 assert_eq!(markdown_header_level("Some ## text"), 0);
1350 }
1351
1352 #[test]
1353 fn test_markdown_section_grouping() {
1354 let text = "# Main\n\nIntro.\n\n## Sub 1\n\nSub 1 content.\n\n## Sub 2\n\nSub 2 content.";
1355 let sections = split_markdown_by_headers(text);
1356 assert!(sections.len() >= 2, "got {} sections", sections.len());
1358 assert!(sections[0].contains("# Main"));
1360 }
1361
1362 #[test]
1363 fn test_markdown_no_headers_returns_single_chunk() {
1364 let config = make_config(ChunkingStrategy::Markdown);
1365 let counter = token_counter();
1366 let text = "Just some plain text. No headers here.";
1367 let chunks = chunk_text(text, &config, &counter);
1368 assert!(!chunks.is_empty());
1370 }
1371
1372 #[test]
1373 fn test_markdown_nested_headers() {
1374 let text = "# Top\n\nIntro.\n\n## Sub\n\nSub content.\n\n### Subsub\n\nDeep.\n\n# Next Top\n\nContent.";
1375 let sections = split_markdown_by_headers(text);
1376 assert!(sections.len() >= 2);
1379 assert!(sections[0].contains("# Top"));
1381 let last = sections.last().unwrap();
1383 assert!(last.contains("# Next Top"));
1384 }
1385
1386 #[test]
1389 fn test_all_strategies_handle_unicode() {
1390 let counter = token_counter();
1391 let text = "日本語テスト。これは長いテキストです。".repeat(10);
1392
1393 for strategy in [
1394 ChunkingStrategy::Plain,
1395 ChunkingStrategy::Sentence,
1396 ChunkingStrategy::Code,
1397 ChunkingStrategy::Markdown,
1398 ] {
1399 let config = ChunkingConfig {
1400 target_size: 50,
1401 min_size: 5,
1402 max_size: 100,
1403 overlap: 0,
1404 strategy,
1405 };
1406 let chunks = chunk_text(&text, &config, &counter);
1407 for chunk in &chunks {
1409 assert!(
1410 std::str::from_utf8(chunk.content.as_bytes()).is_ok(),
1411 "Invalid UTF-8 in {:?} strategy",
1412 strategy
1413 );
1414 }
1415 }
1416 }
1417
1418 #[test]
1419 fn test_all_strategies_empty_input() {
1420 let counter = token_counter();
1421 for strategy in [
1422 ChunkingStrategy::Plain,
1423 ChunkingStrategy::Sentence,
1424 ChunkingStrategy::Code,
1425 ChunkingStrategy::Markdown,
1426 ] {
1427 let config = make_config(strategy);
1428 let chunks = chunk_text("", &config, &counter);
1429 assert!(
1430 chunks.is_empty(),
1431 "{:?} should return empty for empty input",
1432 strategy
1433 );
1434 }
1435 }
1436
1437 #[test]
1438 fn test_token_counts_use_counter() {
1439 let config = make_config(ChunkingStrategy::Plain);
1440 let counter = token_counter();
1441 let text = "Hello world this is a test.";
1442 let chunks = chunk_text(text, &config, &counter);
1443 for chunk in &chunks {
1444 let expected = (chunk.content.len() / 4).max(1);
1446 assert_eq!(chunk.token_count_estimate, expected);
1447 }
1448 }
1449
1450 #[test]
1453 fn test_chunking_strategy_serde() {
1454 let plain: ChunkingStrategy = serde_json::from_str("\"plain\"").unwrap();
1456 assert_eq!(plain, ChunkingStrategy::Plain);
1457
1458 let sentence: ChunkingStrategy = serde_json::from_str("\"sentence\"").unwrap();
1459 assert_eq!(sentence, ChunkingStrategy::Sentence);
1460
1461 let code: ChunkingStrategy = serde_json::from_str("\"code\"").unwrap();
1462 assert_eq!(code, ChunkingStrategy::Code);
1463
1464 let markdown: ChunkingStrategy = serde_json::from_str("\"markdown\"").unwrap();
1465 assert_eq!(markdown, ChunkingStrategy::Markdown);
1466 }
1467
1468 #[test]
1469 fn test_chunking_config_default_strategy() {
1470 let config = ChunkingConfig::default();
1472 assert_eq!(config.strategy, ChunkingStrategy::Plain);
1473
1474 let json = r#"{"target_size":100,"min_size":10,"max_size":200,"overlap":0}"#;
1476 let config: ChunkingConfig = serde_json::from_str(json).unwrap();
1477 assert_eq!(config.strategy, ChunkingStrategy::Plain);
1478 }
1479}