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 prev = overlapped.last().unwrap();
133 let overlap_start = find_sentence_overlap_start(prev, config.overlap);
134 let mut combined = String::new();
135 if overlap_start < prev.len() {
136 combined.push_str(&prev[overlap_start..]);
137 }
138 combined.push_str(chunk);
139 overlapped.push(combined);
140 }
141
142 overlapped
143}
144
145fn split_into_sentences(text: &str) -> Vec<String> {
149 let mut sentences = Vec::new();
150 let mut current = String::new();
151 let chars: Vec<char> = text.chars().collect();
152 let mut i = 0;
153
154 while i < chars.len() {
155 let ch = chars[i];
156 current.push(ch);
157
158 if (ch == '.' || ch == '!' || ch == '?') && (i + 1 >= chars.len() || chars[i + 1].is_whitespace() || chars[i + 1] == '\n') {
160
161 if ch == '.' && i > 0 && chars[i - 1].is_ascii_digit() {
163 i += 1;
164 continue;
165 }
166
167 if is_likely_abbreviation(¤t) {
170 i += 1;
171 continue;
172 }
173
174 let mut j = i + 1;
176 while j < chars.len() && chars[j].is_whitespace() {
177 current.push(chars[j]);
178 j += 1;
179 }
180
181 sentences.push(std::mem::take(&mut current));
182 i = j;
183 continue;
184 }
185
186 if ch == '\n' && i + 1 < chars.len() && chars[i + 1] == '\n' {
188 current.push('\n');
189 sentences.push(std::mem::take(&mut current));
190 i += 2;
191 continue;
192 }
193
194 i += 1;
195 }
196
197 if !current.is_empty() {
198 sentences.push(current);
199 }
200
201 sentences
202}
203
204fn is_likely_abbreviation(text: &str) -> bool {
207 let trimmed = text.trim_end();
208 if trimmed.len() == 2 {
210 let bytes = trimmed.as_bytes();
211 return bytes[0].is_ascii_uppercase() && bytes[1] == b'.';
212 }
213 let bytes = trimmed.as_bytes();
216 if bytes.len() >= 4 && bytes[bytes.len() - 1] == b'.' {
217 let n = bytes.len();
219 if bytes[n - 3] == b'.' && bytes[n - 2].is_ascii_alphabetic() {
220 return true;
221 }
222 }
223 let lower = trimmed.to_lowercase();
225 for abbr in &[
226 "e.g", "i.e", "etc", "vs", "mr", "mrs", "dr", "prof", "inc", "ltd",
227 ] {
228 if lower.ends_with(abbr) {
229 return true;
230 }
231 }
232 false
233}
234fn find_sentence_overlap_start(prev: &str, overlap: usize) -> usize {
236 if prev.len() <= overlap {
237 return 0;
238 }
239
240 let raw_start = prev.len().saturating_sub(overlap);
241 let safe_start = safe_split_at(prev, raw_start);
242
243 let tail = &prev[safe_start..];
245
246 let search_limit = tail.len().min(overlap);
249 let search_region = &tail[..search_limit];
250
251 for (idx, ch) in search_region.char_indices() {
252 if (ch == '.' || ch == '!' || ch == '?') && idx + 1 < search_region.len() {
253 let after = &search_region[idx + 1..];
255 if after.starts_with(|c: char| c.is_whitespace()) {
256 let ws_end = after
258 .char_indices()
259 .skip_while(|(_, c)| c.is_whitespace())
260 .next()
261 .map(|(i, _)| i)
262 .unwrap_or(after.len());
263 return safe_start + idx + 1 + ws_end;
264 }
265 }
266 }
267
268 let word_start = prev[safe_start..]
270 .find(' ')
271 .map(|pos| safe_start + pos + 1)
272 .unwrap_or(safe_start);
273
274 word_start
275}
276
277fn code_split(text: &str, config: &ChunkingConfig) -> Vec<String> {
285 let boundaries = detect_code_block_boundaries(text);
286
287 if boundaries.is_empty() {
288 return recursive_split(text, config, 0);
290 }
291
292 let segments = build_code_segments(text, &boundaries);
295
296 let mut chunks = Vec::new();
297 let mut current = String::new();
298
299 for segment in &segments {
300 if segment.len() > config.max_size {
303 if !current.is_empty() {
304 chunks.push(std::mem::take(&mut current));
305 }
306 for part in force_split(segment, config.max_size) {
308 chunks.push(part);
309 }
310 continue;
311 }
312
313 if !current.is_empty() && current.len() + segment.len() > config.target_size {
314 chunks.push(std::mem::take(&mut current));
315 }
316 current.push_str(segment);
317 }
318
319 if !current.is_empty() {
320 chunks.push(current);
321 }
322
323 let mut result = Vec::new();
325 for chunk in chunks {
326 if chunk.len() > config.max_size {
327 result.extend(recursive_split(&chunk, config, 0));
328 } else {
329 result.push(chunk);
330 }
331 }
332
333 if result.is_empty() {
334 vec![text.to_string()]
335 } else {
336 result
337 }
338}
339
340#[derive(Debug, Clone)]
343struct CodeBlockBoundary {
344 start: usize,
346 end: usize,
348}
349
350fn detect_code_block_boundaries(text: &str) -> Vec<CodeBlockBoundary> {
354 let mut boundaries = Vec::new();
355
356 boundaries.extend(detect_brace_functions(text));
358 boundaries.extend(detect_python_functions(text));
360
361 if boundaries.is_empty() {
362 return Vec::new();
363 }
364
365 boundaries.sort_by_key(|b| b.start);
367
368 let mut result = Vec::new();
370 for b in boundaries {
371 if result.last().map_or(true, |last: &CodeBlockBoundary| last.end <= b.start) {
372 result.push(b);
373 }
374 }
375
376 result
377}
378
379fn detect_brace_functions(text: &str) -> Vec<CodeBlockBoundary> {
389 let mut boundaries = Vec::new();
390 let lines: Vec<(usize, &str)> = text
391 .lines()
392 .scan(0usize, |offset, line| {
393 let start = *offset;
394 *offset += line.len() + 1; Some((start, line))
396 })
397 .collect();
398
399 for (i, &(line_start, line)) in lines.iter().enumerate() {
400 let trimmed = line.trim_start();
401
402 let is_fn = trimmed.starts_with("fn ")
404 || trimmed.starts_with("pub fn ")
405 || trimmed.starts_with("pub(crate) fn ")
406 || trimmed.starts_with("async fn ")
407 || trimmed.starts_with("pub async fn ")
408 || trimmed.starts_with("function ")
409 || trimmed.starts_with("async function ")
410 || trimmed.starts_with("export function ")
411 || trimmed.starts_with("export async function ")
412 || trimmed.starts_with("export default function ")
413 || (trimmed.starts_with("const ") && trimmed.contains("= function"))
414 || (trimmed.starts_with("const ") && trimmed.contains("=>"))
415 || (trimmed.starts_with("pub const ") && trimmed.contains("=>"));
416
417 if !is_fn {
418 continue;
419 }
420
421 let mut brace_offset = None;
423
424 for (j, &(ls, l)) in lines.iter().enumerate().skip(i) {
425 if let Some(pos) = l.find('{') {
426 brace_offset = Some(ls + pos);
427 break;
428 }
429 if l.contains(';') && !l.contains('{') && j > i {
432 break;
433 }
434 }
435
436 let Some(brace_start) = brace_offset else {
437 continue;
438 };
439
440 if let Some(close_end) = match_braces(text, brace_start) {
442 boundaries.push(CodeBlockBoundary {
443 start: line_start,
444 end: close_end,
445 });
446 }
447 }
448
449 boundaries
450}
451
452fn match_braces(text: &str, brace_start: usize) -> Option<usize> {
455 let bytes = text.as_bytes();
456 let mut depth: i32 = 0;
457 let mut in_string = false;
458 let mut string_char = b'"';
459 let mut in_char = false;
460 let mut in_line_comment = false;
461 let mut in_block_comment = false;
462 let mut prev_char = b'\0';
463
464 let mut i = brace_start;
465 while i < bytes.len() {
466 let ch = bytes[i];
467
468 if in_line_comment {
469 if ch == b'\n' {
470 in_line_comment = false;
471 }
472 prev_char = ch;
473 i += 1;
474 continue;
475 }
476
477 if in_block_comment {
478 if prev_char == b'*' && ch == b'/' {
479 in_block_comment = false;
480 }
481 prev_char = ch;
482 i += 1;
483 continue;
484 }
485
486 if in_string {
487 if ch == string_char && prev_char != b'\\' {
488 in_string = false;
489 }
490 prev_char = ch;
491 i += 1;
492 continue;
493 }
494
495 if in_char {
496 if ch == b'\'' && prev_char != b'\\' {
497 in_char = false;
498 }
499 prev_char = ch;
500 i += 1;
501 continue;
502 }
503
504 if ch == b'/' && i + 1 < bytes.len() {
506 if bytes[i + 1] == b'/' {
507 in_line_comment = true;
508 prev_char = ch;
509 i += 2;
510 continue;
511 }
512 if bytes[i + 1] == b'*' {
513 in_block_comment = true;
514 prev_char = ch;
515 i += 2;
516 continue;
517 }
518 }
519
520 if ch == b'"' || ch == b'`' {
522 in_string = true;
523 string_char = ch;
524 prev_char = ch;
525 i += 1;
526 continue;
527 }
528
529 if ch == b'\'' {
530 in_char = true;
531 prev_char = ch;
532 i += 1;
533 continue;
534 }
535
536 if ch == b'{' {
537 depth += 1;
538 } else if ch == b'}' {
539 depth -= 1;
540 if depth == 0 {
541 return Some(i + 1);
542 }
543 }
544
545 prev_char = ch;
546 i += 1;
547 }
548
549 None
550}
551
552fn detect_python_functions(text: &str) -> Vec<CodeBlockBoundary> {
557 let mut boundaries = Vec::new();
558 let lines: Vec<(usize, &str)> = text
559 .lines()
560 .scan(0usize, |offset, line| {
561 let start = *offset;
562 *offset += line.len() + 1;
563 Some((start, line))
564 })
565 .collect();
566
567 for (i, &(line_start, line)) in lines.iter().enumerate() {
568 let trimmed = line.trim_start();
569
570 if !(trimmed.starts_with("def ")
572 || trimmed.starts_with("async def ")
573 || trimmed.starts_with("class "))
574 {
575 continue;
576 }
577
578 let mut colon_found = false;
581 let mut end_line_idx = i;
582 for (j, &(_, l)) in lines.iter().enumerate().skip(i) {
583 if l.contains(':') {
584 colon_found = true;
585 end_line_idx = j;
586 break;
587 }
588 if j > i && l.trim().is_empty() {
590 break;
591 }
592 }
593
594 if !colon_found {
595 continue;
596 }
597
598 let def_indent = line.len() - line.trim_start().len();
601 let mut body_start_line = None;
602 let mut body_end_line = end_line_idx;
603
604 for (k, &(_, l)) in lines.iter().enumerate().skip(end_line_idx + 1) {
605 if l.trim().is_empty() {
606 continue;
607 }
608 let line_indent = l.len() - l.trim_start().len();
609
610 if body_start_line.is_none() {
611 if line_indent > def_indent {
612 body_start_line = Some(k);
613 } else {
614 break;
616 }
617 } else {
618 if line_indent <= def_indent && !l.trim().is_empty() {
620 body_end_line = k.saturating_sub(1);
621 break;
622 }
623 body_end_line = k;
624 }
625 }
626
627 if let Some(bsl) = body_start_line {
628 let end_offset = if body_end_line + 1 < lines.len() {
629 lines[body_end_line + 1].0
630 } else {
631 text.len()
633 };
634 boundaries.push(CodeBlockBoundary {
635 start: line_start,
636 end: end_offset.max(lines[bsl].0),
637 });
638 }
639 }
640
641 boundaries
642}
643
644fn build_code_segments(text: &str, boundaries: &[CodeBlockBoundary]) -> Vec<String> {
648 let mut segments = Vec::new();
649 let mut cursor = 0;
650
651 for b in boundaries {
652 if b.start > cursor {
654 let prefix = &text[cursor..b.start];
655 if !prefix.is_empty() {
656 segments.push(prefix.to_string());
657 }
658 }
659 if b.end > b.start {
661 segments.push(text[b.start..b.end].to_string());
662 }
663 cursor = b.end;
664 }
665
666 if cursor < text.len() {
668 let suffix = &text[cursor..];
669 if !suffix.is_empty() {
670 segments.push(suffix.to_string());
671 }
672 }
673
674 segments
675}
676
677fn markdown_split(text: &str, config: &ChunkingConfig) -> Vec<String> {
685 let sections = split_markdown_by_headers(text);
686
687 if sections.is_empty() {
688 return recursive_split(text, config, 0);
689 }
690
691 let mut chunks = Vec::new();
693 let mut current = String::new();
694
695 for section in §ions {
696 if !current.is_empty() && current.len() + section.len() > config.target_size {
697 chunks.push(std::mem::take(&mut current));
698 }
699 current.push_str(section);
700 }
701
702 if !current.is_empty() {
703 chunks.push(current);
704 }
705
706 let mut result = Vec::new();
708 for chunk in chunks {
709 if chunk.len() > config.max_size {
710 result.extend(recursive_split(&chunk, config, 0));
711 } else {
712 result.push(chunk);
713 }
714 }
715
716 if result.is_empty() {
717 vec![text.to_string()]
718 } else {
719 result
720 }
721}
722
723fn split_markdown_by_headers(text: &str) -> Vec<String> {
728 let lines: Vec<(usize, &str)> = text
729 .lines()
730 .scan(0usize, |offset, line| {
731 let start = *offset;
732 *offset += line.len() + 1;
733 Some((start, line))
734 })
735 .collect();
736
737 if lines.is_empty() {
738 return Vec::new();
739 }
740
741 let header_positions: Vec<(usize, usize)> = lines
743 .iter()
744 .filter_map(|&(offset, line)| {
745 let level = markdown_header_level(line);
746 if level > 0 {
747 Some((offset, level))
748 } else {
749 None
750 }
751 })
752 .collect();
753
754 if header_positions.is_empty() {
755 return vec![text.to_string()];
756 }
757
758 let mut sections = Vec::new();
759 let mut cursor = 0;
760
761 for (idx, &(header_offset, header_level)) in header_positions.iter().enumerate() {
762 if idx == 0 && header_offset > 0 {
765 let preamble = &text[0..header_offset];
766 if !preamble.trim().is_empty() {
767 sections.push(preamble.to_string());
768 }
769 }
770
771 let section_end = header_positions
773 .iter()
774 .skip(idx + 1)
775 .find(|&&(_, level)| level <= header_level)
776 .map(|&(offset, _)| offset)
777 .unwrap_or(text.len());
778
779 cursor = section_end;
780 let section_text = &text[header_offset..section_end];
781 if !section_text.trim().is_empty() {
782 sections.push(section_text.to_string());
783 }
784 }
785
786 if cursor < text.len() {
788 let trailing = &text[cursor..];
789 if !trailing.trim().is_empty() {
790 sections.push(trailing.to_string());
791 }
792 }
793
794 sections
795}
796
797fn markdown_header_level(line: &str) -> usize {
800 let trimmed = line.trim_start();
801
802 if trimmed.starts_with('#') {
804 let count = trimmed.chars().take_while(|&c| c == '#').count();
805 if count >= 1 && count <= 6 {
806 let after_hashes = &trimmed[count..];
808 if after_hashes.is_empty() {
809 return 0; }
811 if after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
812 if after_hashes.trim().is_empty() {
814 return 0;
815 }
816 return count;
817 }
818 }
819 return 0;
820 }
821
822 0
823}
824
825fn safe_split_at(text: &str, pos: usize) -> usize {
831 let mut split = pos.min(text.len());
832 while split > 0 && !text.is_char_boundary(split) {
833 split -= 1;
834 }
835 split
836}
837
838fn force_split(text: &str, max_size: usize) -> Vec<String> {
840 let mut chunks = Vec::new();
841 let mut start = 0;
842 while start < text.len() {
843 let end = safe_split_at(text, start + max_size);
844 if end <= start {
845 let mut next = start + 1;
847 while next < text.len() && !text.is_char_boundary(next) {
848 next += 1;
849 }
850 if next <= text.len() {
851 chunks.push(text[start..next].to_string());
852 }
853 start = next;
854 continue;
855 }
856 chunks.push(text[start..end].to_string());
857 start = end;
858 }
859 chunks
860}
861
862fn recursive_split(text: &str, config: &ChunkingConfig, depth: usize) -> Vec<String> {
864 if text.len() <= config.max_size {
865 return vec![text.to_string()];
866 }
867
868 if depth >= MAX_RECURSION_DEPTH {
869 tracing::warn!("Chunker hit max recursion depth, force-splitting at max_size");
870 return force_split(text, config.max_size);
871 }
872
873 let separators: &[&str] = &["\n\n", ". ", "? ", "! ", " "];
875
876 for sep in separators {
877 let parts: Vec<&str> = text.split(sep).collect();
878 if parts.len() <= 1 {
879 continue;
880 }
881
882 let mut chunks = Vec::new();
883 let mut current = String::new();
884
885 for (i, part) in parts.iter().enumerate() {
886 let piece = if i + 1 < parts.len() {
887 format!("{}{}", part, sep)
888 } else {
889 part.to_string()
890 };
891
892 if current.len() + piece.len() > config.target_size && !current.is_empty() {
893 chunks.push(current);
894 current = piece;
895 } else {
896 current.push_str(&piece);
897 }
898 }
899
900 if !current.is_empty() {
901 chunks.push(current);
902 }
903
904 let mut result = Vec::new();
906 for chunk in chunks {
907 if chunk.len() > config.max_size {
908 result.extend(recursive_split(&chunk, config, depth + 1));
909 } else {
910 result.push(chunk);
911 }
912 }
913
914 if result.len() > 1 {
915 return result;
916 }
917 }
918
919 force_split(text, config.max_size)
921}
922
923fn merge_small_chunks(chunks: Vec<String>, target_size: usize, min_size: usize) -> Vec<String> {
925 if chunks.is_empty() {
926 return chunks;
927 }
928
929 let mut merged = Vec::new();
930 let mut current = chunks[0].clone();
931
932 for chunk in chunks.iter().skip(1) {
933 if (current.len() < min_size || chunk.len() < min_size)
934 && current.len() + chunk.len() <= target_size
935 {
936 current.push_str(chunk);
937 } else {
938 merged.push(current);
939 current = chunk.clone();
940 }
941 }
942
943 merged.push(current);
944 if merged.len() > 1 {
945 if let Some(last) = merged.last() {
946 if last.len() < min_size {
947 let last = merged.pop().unwrap_or_default();
948 if let Some(previous) = merged.last_mut() {
949 previous.push_str(&last);
950 } else {
951 merged.push(last);
952 }
953 }
954 }
955 }
956 merged
957}
958
959fn apply_overlap(chunks: Vec<String>, overlap: usize) -> Vec<String> {
961 if chunks.len() <= 1 || overlap == 0 {
962 return chunks;
963 }
964
965 let mut result = Vec::with_capacity(chunks.len());
966 result.push(chunks[0].clone());
967
968 for i in 1..chunks.len() {
969 let prev = result.last().map(String::as_str).unwrap_or(&chunks[i - 1]);
970 let overlap_start = if prev.len() > overlap {
971 let raw_start = prev.len() - overlap;
973 let safe_start = safe_split_at(prev, raw_start);
974 prev[safe_start..]
976 .find(' ')
977 .map(|pos| safe_start + pos + 1)
978 .unwrap_or(safe_start)
979 } else {
980 0
981 };
982
983 let overlap_text = &prev[overlap_start..];
984 let mut chunk_with_overlap = overlap_text.to_string();
985 chunk_with_overlap.push_str(&chunks[i]);
986 result.push(chunk_with_overlap);
987 }
988
989 result
990}
991
992#[cfg(test)]
997mod tests {
998 use super::*;
999 use crate::tokenizer::EstimateTokenCounter;
1000
1001 fn make_config(strategy: ChunkingStrategy) -> ChunkingConfig {
1002 ChunkingConfig {
1003 target_size: 100,
1004 min_size: 10,
1005 max_size: 200,
1006 overlap: 0,
1007 strategy,
1008 }
1009 }
1010
1011 fn default_config() -> ChunkingConfig {
1012 ChunkingConfig::default()
1013 }
1014
1015 fn token_counter() -> EstimateTokenCounter {
1016 EstimateTokenCounter
1017 }
1018
1019 #[test]
1022 fn test_plain_empty_string() {
1023 let config = make_config(ChunkingStrategy::Plain);
1024 let counter = token_counter();
1025 let chunks = chunk_text("", &config, &counter);
1026 assert!(chunks.is_empty());
1027 }
1028
1029 #[test]
1030 fn test_plain_short_text_single_chunk() {
1031 let config = make_config(ChunkingStrategy::Plain);
1032 let counter = token_counter();
1033 let text = "Hello world.";
1034 let chunks = chunk_text(text, &config, &counter);
1035 assert_eq!(chunks.len(), 1);
1036 assert_eq!(chunks[0].content, text);
1037 }
1038
1039 #[test]
1040 fn test_plain_paragraph_split() {
1041 let config = ChunkingConfig {
1042 target_size: 30,
1043 min_size: 5,
1044 max_size: 60,
1045 overlap: 0,
1046 strategy: ChunkingStrategy::Plain,
1047 };
1048 let counter = token_counter();
1049 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.";
1050 let chunks = chunk_text(text, &config, &counter);
1051 assert!(chunks.len() > 1);
1052 }
1053
1054 #[test]
1055 fn test_plain_default_strategy_is_plain() {
1056 let config = default_config();
1058 assert_eq!(config.strategy, ChunkingStrategy::Plain);
1059 }
1060
1061 #[test]
1062 fn test_plain_backward_compatible_output() {
1063 let text = "This is a test sentence. This is another one. And a third here. ";
1065 let counter = token_counter();
1066
1067 let default_config = default_config();
1068 let plain_config = ChunkingConfig {
1069 strategy: ChunkingStrategy::Plain,
1070 ..default_config.clone()
1071 };
1072
1073 let default_chunks = chunk_text(text, &default_config, &counter);
1074 let plain_chunks = chunk_text(text, &plain_config, &counter);
1075
1076 assert_eq!(default_chunks.len(), plain_chunks.len());
1077 for (d, p) in default_chunks.iter().zip(plain_chunks.iter()) {
1078 assert_eq!(d.content, p.content);
1079 assert_eq!(d.index, p.index);
1080 }
1081 }
1082
1083 #[test]
1084 fn test_plain_unicode_safe() {
1085 let config = default_config();
1086 let counter = token_counter();
1087 let text = "日本語のテストです。これは非常に長いテキストで、チャンク分割が必要です。".repeat(20);
1088 let chunks = chunk_text(&text, &config, &counter);
1089 for chunk in &chunks {
1091 assert!(std::str::from_utf8(chunk.content.as_bytes()).is_ok());
1092 }
1093 }
1094
1095 #[test]
1098 fn test_sentence_empty_string() {
1099 let config = make_config(ChunkingStrategy::Sentence);
1100 let counter = token_counter();
1101 let chunks = chunk_text("", &config, &counter);
1102 assert!(chunks.is_empty());
1103 }
1104
1105 #[test]
1106 fn test_sentence_short_text_single_chunk() {
1107 let config = make_config(ChunkingStrategy::Sentence);
1108 let counter = token_counter();
1109 let text = "Hello world.";
1110 let chunks = chunk_text(text, &config, &counter);
1111 assert_eq!(chunks.len(), 1);
1112 assert_eq!(chunks[0].content, text);
1113 }
1114
1115 #[test]
1116 fn test_sentence_splits_on_boundaries() {
1117 let config = ChunkingConfig {
1118 target_size: 30,
1119 min_size: 5,
1120 max_size: 60,
1121 overlap: 0,
1122 strategy: ChunkingStrategy::Sentence,
1123 };
1124 let counter = token_counter();
1125 let text = "First sentence here. Second one follows. Third one now. Fourth.";
1126 let chunks = chunk_text(text, &config, &counter);
1127 assert!(chunks.len() > 1, "expected multiple chunks, got {}", chunks.len());
1128 for chunk in &chunks {
1130 assert!(
1131 chunk.content.len() <= 60,
1132 "chunk len {} exceeds max_size 60",
1133 chunk.content.len()
1134 );
1135 }
1136 }
1137
1138 #[test]
1139 fn test_sentence_with_overlap() {
1140 let config = ChunkingConfig {
1141 target_size: 30,
1142 min_size: 5,
1143 max_size: 60,
1144 overlap: 10,
1145 strategy: ChunkingStrategy::Sentence,
1146 };
1147 let counter = token_counter();
1148 let text = "First sentence here. Second one follows. Third one now. Fourth.";
1149 let chunks = chunk_text(text, &config, &counter);
1150 assert!(chunks.len() > 1);
1151 if chunks.len() >= 2 {
1154 assert!(!chunks[1].content.is_empty());
1156 }
1157 }
1158
1159 #[test]
1160 fn test_sentence_no_split_on_abbreviations() {
1161 let sentences = split_into_sentences("Hello e.g. world. This is a test.");
1162 assert!(
1165 sentences.len() <= 3,
1166 "got {} sentences: {:?}",
1167 sentences.len(),
1168 sentences
1169 );
1170 }
1171
1172 #[test]
1173 fn test_sentence_no_split_on_decimals() {
1174 let sentences = split_into_sentences("The value is 3.14 today. End.");
1175 assert_eq!(sentences.len(), 2);
1177 }
1178
1179 #[test]
1180 fn test_sentence_paragraph_break() {
1181 let sentences = split_into_sentences("First paragraph.\n\nSecond paragraph.");
1182 assert!(sentences.len() >= 2);
1184 }
1185
1186 #[test]
1189 fn test_code_empty_string() {
1190 let config = make_config(ChunkingStrategy::Code);
1191 let counter = token_counter();
1192 let chunks = chunk_text("", &config, &counter);
1193 assert!(chunks.is_empty());
1194 }
1195
1196 #[test]
1197 fn test_code_rust_function_not_split() {
1198 let config = ChunkingConfig {
1199 target_size: 50,
1200 min_size: 5,
1201 max_size: 500,
1202 overlap: 0,
1203 strategy: ChunkingStrategy::Code,
1204 };
1205 let counter = token_counter();
1206 let text = r#"// Header comment
1207fn foo() {
1208 let x = 1;
1209 let y = 2;
1210 let z = x + y;
1211 println!("{}", z);
1212 // More code
1213 let a = 10;
1214 let b = 20;
1215 let c = a + b;
1216}
1217
1218fn bar() {
1219 println!("hello");
1220}
1221"#;
1222 let chunks = chunk_text(text, &config, &counter);
1223 let all_content: String = chunks.iter().map(|c| c.content.as_str()).collect();
1225 assert!(all_content.contains("fn foo()") || all_content.contains("fn bar()"));
1226 for chunk in &chunks {
1228 assert!(chunk.content.len() <= 500, "chunk exceeds max_size");
1229 }
1230 }
1231
1232 #[test]
1233 fn test_code_python_function_not_split() {
1234 let config = ChunkingConfig {
1235 target_size: 40,
1236 min_size: 5,
1237 max_size: 500,
1238 overlap: 0,
1239 strategy: ChunkingStrategy::Code,
1240 };
1241 let counter = token_counter();
1242 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";
1243 let chunks = chunk_text(text, &config, &counter);
1244 let all_content: String = chunks.iter().map(|c| c.content.as_str()).collect();
1246 assert!(all_content.contains("def foo") || all_content.contains("def bar"));
1247 }
1248
1249 #[test]
1250 fn test_code_typescript_function_not_split() {
1251 let config = ChunkingConfig {
1252 target_size: 50,
1253 min_size: 5,
1254 max_size: 500,
1255 overlap: 0,
1256 strategy: ChunkingStrategy::Code,
1257 };
1258 let counter = token_counter();
1259 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";
1260 let chunks = chunk_text(text, &config, &counter);
1261 let all_content: String = chunks.iter().map(|c| c.content.as_str()).collect();
1262 assert!(all_content.contains("function add"));
1263 }
1264
1265 #[test]
1266 fn test_code_falls_back_to_plain_for_non_code() {
1267 let config = make_config(ChunkingStrategy::Code);
1268 let counter = token_counter();
1269 let text = "This is just plain text with no functions at all. Just sentences.";
1270 let chunks = chunk_text(text, &config, &counter);
1271 assert!(!chunks.is_empty());
1273 }
1274
1275 #[test]
1276 fn test_code_brace_matching_with_strings() {
1277 let text = "fn test() { let s = \"{ not a brace }\"; let x = 1; }";
1278 let boundaries = detect_brace_functions(text);
1279 assert_eq!(boundaries.len(), 1);
1280 assert!(boundaries[0].end > boundaries[0].start);
1282 }
1283
1284 #[test]
1285 fn test_code_brace_matching_with_comments() {
1286 let text = "fn test() { // { comment brace\n let x = 1; /* } */ let y = 2; }";
1287 let boundaries = detect_brace_functions(text);
1288 assert_eq!(boundaries.len(), 1);
1289 }
1290
1291 #[test]
1292 fn test_code_async_function_detection() {
1293 let text = "async fn fetch() { let resp = reqwest::get(\"url\").await; resp }";
1294 let boundaries = detect_brace_functions(text);
1295 assert_eq!(boundaries.len(), 1);
1296 }
1297
1298 #[test]
1301 fn test_markdown_empty_string() {
1302 let config = make_config(ChunkingStrategy::Markdown);
1303 let counter = token_counter();
1304 let chunks = chunk_text("", &config, &counter);
1305 assert!(chunks.is_empty());
1306 }
1307
1308 #[test]
1309 fn test_markdown_splits_on_headers() {
1310 let config = ChunkingConfig {
1311 target_size: 100,
1312 min_size: 5,
1313 max_size: 500,
1314 overlap: 0,
1315 strategy: ChunkingStrategy::Markdown,
1316 };
1317 let counter = token_counter();
1318 let text = "# Title\n\nSome content here.\n\n## Section A\n\nContent for A.\n\n## Section B\n\nContent for B.";
1319 let chunks = chunk_text(text, &config, &counter);
1320 assert!(chunks.len() > 1, "expected multiple chunks, got {}", chunks.len());
1322 }
1323
1324 #[test]
1325 fn test_markdown_header_level_detection() {
1326 assert_eq!(markdown_header_level("# Header"), 1);
1327 assert_eq!(markdown_header_level("## Header"), 2);
1328 assert_eq!(markdown_header_level("### Header"), 3);
1329 assert_eq!(markdown_header_level("###### Header"), 6);
1330 assert_eq!(markdown_header_level("####### Too many"), 0);
1331 assert_eq!(markdown_header_level("Not a header"), 0);
1332 assert_eq!(markdown_header_level("##"), 0); assert_eq!(markdown_header_level(""), 0);
1334 assert_eq!(markdown_header_level("Some ## text"), 0);
1335 }
1336
1337 #[test]
1338 fn test_markdown_section_grouping() {
1339 let text = "# Main\n\nIntro.\n\n## Sub 1\n\nSub 1 content.\n\n## Sub 2\n\nSub 2 content.";
1340 let sections = split_markdown_by_headers(text);
1341 assert!(sections.len() >= 2, "got {} sections", sections.len());
1343 assert!(sections[0].contains("# Main"));
1345 }
1346
1347 #[test]
1348 fn test_markdown_no_headers_returns_single_chunk() {
1349 let config = make_config(ChunkingStrategy::Markdown);
1350 let counter = token_counter();
1351 let text = "Just some plain text. No headers here.";
1352 let chunks = chunk_text(text, &config, &counter);
1353 assert!(!chunks.is_empty());
1355 }
1356
1357 #[test]
1358 fn test_markdown_nested_headers() {
1359 let text = "# Top\n\nIntro.\n\n## Sub\n\nSub content.\n\n### Subsub\n\nDeep.\n\n# Next Top\n\nContent.";
1360 let sections = split_markdown_by_headers(text);
1361 assert!(sections.len() >= 2);
1364 assert!(sections[0].contains("# Top"));
1366 let last = sections.last().unwrap();
1368 assert!(last.contains("# Next Top"));
1369 }
1370
1371 #[test]
1374 fn test_all_strategies_handle_unicode() {
1375 let counter = token_counter();
1376 let text = "日本語テスト。これは長いテキストです。".repeat(10);
1377
1378 for strategy in [
1379 ChunkingStrategy::Plain,
1380 ChunkingStrategy::Sentence,
1381 ChunkingStrategy::Code,
1382 ChunkingStrategy::Markdown,
1383 ] {
1384 let config = ChunkingConfig {
1385 target_size: 50,
1386 min_size: 5,
1387 max_size: 100,
1388 overlap: 0,
1389 strategy,
1390 };
1391 let chunks = chunk_text(&text, &config, &counter);
1392 for chunk in &chunks {
1394 assert!(
1395 std::str::from_utf8(chunk.content.as_bytes()).is_ok(),
1396 "Invalid UTF-8 in {:?} strategy",
1397 strategy
1398 );
1399 }
1400 }
1401 }
1402
1403 #[test]
1404 fn test_all_strategies_empty_input() {
1405 let counter = token_counter();
1406 for strategy in [
1407 ChunkingStrategy::Plain,
1408 ChunkingStrategy::Sentence,
1409 ChunkingStrategy::Code,
1410 ChunkingStrategy::Markdown,
1411 ] {
1412 let config = make_config(strategy);
1413 let chunks = chunk_text("", &config, &counter);
1414 assert!(chunks.is_empty(), "{:?} should return empty for empty input", strategy);
1415 }
1416 }
1417
1418 #[test]
1419 fn test_token_counts_use_counter() {
1420 let config = make_config(ChunkingStrategy::Plain);
1421 let counter = token_counter();
1422 let text = "Hello world this is a test.";
1423 let chunks = chunk_text(text, &config, &counter);
1424 for chunk in &chunks {
1425 let expected = (chunk.content.len() / 4).max(1);
1427 assert_eq!(chunk.token_count_estimate, expected);
1428 }
1429 }
1430
1431 #[test]
1434 fn test_chunking_strategy_serde() {
1435 let plain: ChunkingStrategy = serde_json::from_str("\"plain\"").unwrap();
1437 assert_eq!(plain, ChunkingStrategy::Plain);
1438
1439 let sentence: ChunkingStrategy = serde_json::from_str("\"sentence\"").unwrap();
1440 assert_eq!(sentence, ChunkingStrategy::Sentence);
1441
1442 let code: ChunkingStrategy = serde_json::from_str("\"code\"").unwrap();
1443 assert_eq!(code, ChunkingStrategy::Code);
1444
1445 let markdown: ChunkingStrategy = serde_json::from_str("\"markdown\"").unwrap();
1446 assert_eq!(markdown, ChunkingStrategy::Markdown);
1447 }
1448
1449 #[test]
1450 fn test_chunking_config_default_strategy() {
1451 let config = ChunkingConfig::default();
1453 assert_eq!(config.strategy, ChunkingStrategy::Plain);
1454
1455 let json = r#"{"target_size":100,"min_size":10,"max_size":200,"overlap":0}"#;
1457 let config: ChunkingConfig = serde_json::from_str(json).unwrap();
1458 assert_eq!(config.strategy, ChunkingStrategy::Plain);
1459 }
1460}