Skip to main content

semantic_memory/
chunker.rs

1//! Text chunking with multiple content-aware strategies.
2//!
3//! Strategies:
4//! - [`ChunkingStrategy::Plain`]: recursive splitting (original behavior, default)
5//! - [`ChunkingStrategy::Sentence`]: sentence-boundary-aware splitting with overlap
6//! - [`ChunkingStrategy::Code`]: code-aware splitting that avoids breaking function bodies
7//! - [`ChunkingStrategy::Markdown`]: markdown-header-based splitting on header boundaries
8
9use crate::config::{ChunkingConfig, ChunkingStrategy};
10use crate::tokenizer::TokenCounter;
11use crate::types::TextChunk;
12
13/// Maximum recursion depth to prevent infinite loops.
14const MAX_RECURSION_DEPTH: usize = 10;
15
16/// Chunk text using the configured strategy and token counter.
17///
18/// Returns an empty vec for empty input. Short text (≤ max_size) is
19/// returned as a single chunk.
20pub 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    // Short text (≤ max_size) returned as a single chunk.
30    // But only if using Plain strategy -- other strategies should still
31    // attempt structural splitting even for short text.
32    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    // Merge small adjacent chunks
51    let merged = merge_small_chunks(raw_chunks, config.target_size, config.min_size);
52
53    // Apply overlap
54    let overlapped = apply_overlap(merged, config.overlap);
55
56    // Convert to TextChunk
57    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
71// ────────────────────────────────────────────────────────────────
72// Plain strategy (original behavior)
73// ────────────────────────────────────────────────────────────────
74
75/// Plain recursive split — identical to the original algorithm.
76fn plain_split(text: &str, config: &ChunkingConfig) -> Vec<String> {
77    recursive_split(text, config, 0)
78}
79
80// ────────────────────────────────────────────────────────────────
81// Sentence strategy
82// ────────────────────────────────────────────────────────────────
83
84/// Split text on sentence boundaries, accumulating sentences up to
85/// `target_size`. When a chunk exceeds `max_size`, it is recursively
86/// split. Each chunk (except the first) includes `overlap` characters
87/// of the previous chunk's tail for context continuity.
88fn 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    // Greedily pack sentences into chunks up to target_size.
95    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    // Recursively split any chunks that are still too large
109    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    // Apply sentence-level overlap: prepend the last N chars of the
123    // previous chunk, snapped to a sentence boundary.
124    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
147/// Split text into a list of sentence strings, preserving trailing
148/// whitespace and punctuation. Each returned string includes its
149/// terminating punctuation (`.`, `!`, `?`) followed by any whitespace.
150fn 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        // Detect sentence-ending punctuation followed by whitespace or EOF
161        if (ch == '.' || ch == '!' || ch == '?')
162            && (i + 1 >= chars.len() || chars[i + 1].is_whitespace() || chars[i + 1] == '\n')
163        {
164            // Don't split on decimals like "3.14" (digit before and after)
165            if ch == '.' && i > 0 && chars[i - 1].is_ascii_digit() {
166                i += 1;
167                continue;
168            }
169
170            // Check for multi-period abbreviations like "e.g." or "i.e."
171            // by looking at the accumulated text
172            if is_likely_abbreviation(&current) {
173                i += 1;
174                continue;
175            }
176
177            // Consume following whitespace as part of this sentence
178            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        // Also treat newlines (double newline = paragraph break) as sentence boundaries
190        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
207/// Check whether the accumulated text ends with a known abbreviation
208/// pattern that should NOT be treated as a sentence boundary.
209fn is_likely_abbreviation(text: &str) -> bool {
210    let trimmed = text.trim_end();
211    // Single capital letter followed by period: "J." "A." etc.
212    if trimmed.len() == 2 {
213        let bytes = trimmed.as_bytes();
214        return bytes[0].is_ascii_uppercase() && bytes[1] == b'.';
215    }
216    // Multi-period abbreviations: check if last few chars match "X.X" pattern
217    // e.g. "e.g" "i.e" where a single letter sits between two periods
218    let bytes = trimmed.as_bytes();
219    if bytes.len() >= 4 && bytes[bytes.len() - 1] == b'.' {
220        // Check pattern ".X." at end (period, single letter, period)
221        let n = bytes.len();
222        if bytes[n - 3] == b'.' && bytes[n - 2].is_ascii_alphabetic() {
223            return true;
224        }
225    }
226    // Common abbreviations ending with period
227    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}
237/// `overlap` characters, snapped to a sentence boundary if possible.
238fn 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    // Try to find the next sentence start after safe_start
247    let tail = &prev[safe_start..];
248
249    // Look for a period/exclamation/question mark followed by whitespace
250    // within the first ~50% of the overlap region
251    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            // Check if followed by whitespace
257            let after = &search_region[idx + 1..];
258            if after.starts_with(|c: char| c.is_whitespace()) {
259                // Skip past the whitespace
260                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    // Fall back to word boundary
272    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
280// ────────────────────────────────────────────────────────────────
281// Code strategy
282// ────────────────────────────────────────────────────────────────
283
284/// Split code text while avoiding breaks inside function bodies.
285/// Detects Rust, Python, and TypeScript/JavaScript function boundaries.
286/// Falls back to plain splitting for non-code or unrecognized content.
287fn code_split(text: &str, config: &ChunkingConfig) -> Vec<String> {
288    let boundaries = detect_code_block_boundaries(text);
289
290    if boundaries.is_empty() {
291        // No function blocks detected — fall back to plain split
292        return recursive_split(text, config, 0);
293    }
294
295    // Build segments: text between boundaries, where each function body
296    // is kept as a single unit if possible.
297    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 the segment itself exceeds max_size, it must be split.
304        // For function bodies, we use force_split as a last resort.
305        if segment.len() > config.max_size {
306            if !current.is_empty() {
307                chunks.push(std::mem::take(&mut current));
308            }
309            // Force-split oversized function bodies
310            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    // Recursively split any remaining oversized chunks
327    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/// A code block boundary: the byte offset where a function body starts
344/// and the byte offset where it ends.
345#[derive(Debug, Clone)]
346struct CodeBlockBoundary {
347    /// Byte offset of the first character of the function definition line
348    start: usize,
349    /// Byte offset one past the last character of the function body
350    end: usize,
351}
352
353/// Detect function block boundaries in Rust, Python, and TS/JS code.
354///
355/// Returns a list of non-overlapping boundaries sorted by start position.
356fn detect_code_block_boundaries(text: &str) -> Vec<CodeBlockBoundary> {
357    let mut boundaries = Vec::new();
358
359    // Try Rust/TS/JS style (brace-delimited functions)
360    boundaries.extend(detect_brace_functions(text));
361    // Try Python style (indentation-based functions)
362    boundaries.extend(detect_python_functions(text));
363
364    if boundaries.is_empty() {
365        return Vec::new();
366    }
367
368    // Sort by start position
369    boundaries.sort_by_key(|b| b.start);
370
371    // Remove overlapping boundaries (prefer the earlier one)
372    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
385/// Detect Rust/TypeScript/JavaScript function boundaries using brace matching.
386///
387/// Looks for lines matching patterns like:
388/// - `fn name(` (Rust)
389/// - `function name(` (TS/JS)
390/// - `async fn name(` (Rust async)
391/// - `async function name(` (TS/JS async)
392/// - `const name = (` / `const name = function` (TS/JS arrow/named)
393/// - Arrow functions `=>` at top level
394fn 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; // +1 for newline
401            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        // Detect function definitions
409        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        // Find the opening brace `{` on this line or subsequent lines
428        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 we hit a semicolon before a brace, this isn't a function body
436            // (e.g. a type alias or forward declaration)
437            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        // Match braces from the opening to find the closing brace
447        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
458/// Match braces starting at `brace_start` (the position of `{`).
459/// Returns the byte offset one past the matching closing `}`.
460fn 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        // Check for comments
511        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        // Check for strings
527        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
558/// Detect Python function boundaries using indentation tracking.
559///
560/// Looks for `def ` at any indentation level and tracks the function body
561/// as all subsequent lines with greater indentation.
562fn 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        // Detect Python function/class definitions
577        if !(trimmed.starts_with("def ")
578            || trimmed.starts_with("async def ")
579            || trimmed.starts_with("class "))
580        {
581            continue;
582        }
583
584        // Check for colon at end (possibly after parameters)
585        // Look forward for the colon on the same line or continuation lines
586        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 we hit a blank line or dedent without colon, not a function
595            if j > i && l.trim().is_empty() {
596                break;
597            }
598        }
599
600        if !colon_found {
601            continue;
602        }
603
604        // Find the body: the first non-blank line after the colon line
605        // with greater indentation than the def line
606        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                    // No body found
621                    break;
622                }
623            } else {
624                // Body continues while indentation > def_indent
625                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                // Body extends to end of text
638                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
650/// Build text segments from code block boundaries.
651/// Text outside boundaries becomes its own segment; each function body
652/// becomes a single segment.
653fn 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        // Text before the function
659        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        // The function body itself
666        if b.end > b.start {
667            segments.push(text[b.start..b.end].to_string());
668        }
669        cursor = b.end;
670    }
671
672    // Trailing text after last boundary
673    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
683// ────────────────────────────────────────────────────────────────
684// Markdown strategy
685// ────────────────────────────────────────────────────────────────
686
687/// Split markdown text on header boundaries. Each header and its content
688/// up to the next header of the same or higher level become a chunk.
689/// Oversized chunks are recursively split.
690fn 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    // Pack sections into chunks up to target_size
698    let mut chunks = Vec::new();
699    let mut current = String::new();
700
701    for section in &sections {
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    // Recursively split any chunks that are still too large
713    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
729/// Split markdown text into sections based on header boundaries.
730/// A section starts at a header line (`# `, `## `, etc.) and includes
731/// all content up to the next header of the same or higher level.
732/// Content before the first header becomes its own section.
733fn 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    // Find header positions and levels
748    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        // Content before this header (if any) belongs to the previous section
769        // or is a preamble
770        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        // Find the end of this section: the next header with level <= header_level
778        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    // Trailing content after the last section's end
793    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
803/// Return the header level (1-6) for a markdown header line, or 0 if not a header.
804/// Supports ATX-style headers (`# Header`, `## Subheader`).
805fn markdown_header_level(line: &str) -> usize {
806    let trimmed = line.trim_start();
807
808    // ATX-style header
809    if trimmed.starts_with('#') {
810        let count = trimmed.chars().take_while(|&c| c == '#').count();
811        if count >= 1 && count <= 6 {
812            // Must be followed by whitespace then content
813            let after_hashes = &trimmed[count..];
814            if after_hashes.is_empty() {
815                return 0; // "##" with no content is not a header
816            }
817            if after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
818                // Check there's actual content after the space
819                if after_hashes.trim().is_empty() {
820                    return 0;
821                }
822                return count;
823            }
824        }
825        return 0;
826    }
827
828    0
829}
830
831// ────────────────────────────────────────────────────────────────
832// Shared utilities
833// ────────────────────────────────────────────────────────────────
834
835/// Snap a byte offset to the nearest valid UTF-8 char boundary (searching backward).
836fn 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
844/// Force-split text at max_size boundaries, respecting UTF-8.
845fn 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            // Can't make progress — advance past the current character
852            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
868/// Recursively split text using a hierarchy of separators.
869fn 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    // Separator hierarchy: paragraph > sentence > word > force
880    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        // Recursively split any chunks that are still too large
911        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    // All separators exhausted — force split
926    force_split(text, config.max_size)
927}
928
929/// Merge adjacent chunks that are smaller than min_size.
930fn 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
965/// Apply overlap between adjacent chunks.
966fn 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            // Find a word boundary for the overlap start
978            let raw_start = prev.len() - overlap;
979            let safe_start = safe_split_at(prev, raw_start);
980            // Try to find a space after safe_start to avoid cutting mid-word
981            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// ────────────────────────────────────────────────────────────────
999// Tests
1000// ────────────────────────────────────────────────────────────────
1001
1002#[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    // ─── Plain strategy (backward compatibility) ───────────────────
1026
1027    #[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        // Default config must use Plain strategy
1063        let config = default_config();
1064        assert_eq!(config.strategy, ChunkingStrategy::Plain);
1065    }
1066
1067    #[test]
1068    fn test_plain_backward_compatible_output() {
1069        // The default strategy must produce the same output as Plain
1070        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        // Should not panic and should produce valid UTF-8 chunks
1097        for chunk in &chunks {
1098            assert!(std::str::from_utf8(chunk.content.as_bytes()).is_ok());
1099        }
1100    }
1101
1102    // ─── Sentence strategy ─────────────────────────────────────────
1103
1104    #[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        // Each chunk should not exceed max_size
1140        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        // With overlap, the second chunk should start with some content
1163        // from the end of the first chunk
1164        if chunks.len() >= 2 {
1165            // The overlap should bring some characters from chunk[0] into chunk[1]
1166            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        // "e.g." should not be split as a sentence boundary
1174        // So "Hello e.g. world." should be one sentence
1175        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        // "3.14" should not trigger a sentence split
1187        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        // Double newline should create a boundary
1194        assert!(sentences.len() >= 2);
1195    }
1196
1197    // ─── Code strategy ─────────────────────────────────────────────
1198
1199    #[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        // The function body should not be split mid-body
1235        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        // At least the function signatures should be intact
1238        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        // Should not split inside function bodies
1256        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        // Should not panic and should produce valid chunks
1283        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        // The boundary should span the entire function
1292        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    // ─── Markdown strategy ─────────────────────────────────────────
1310
1311    #[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        // Should produce multiple chunks based on headers
1332        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); // No space after hashes
1348        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        // # Main should contain its intro, and sub-sections should be separate
1357        assert!(sections.len() >= 2, "got {} sections", sections.len());
1358        // The first section should contain "# Main"
1359        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        // Without headers, should still chunk (may be single chunk if short)
1369        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        // # Top should include ## Sub and ### Subsub content
1377        // # Next Top should be a separate section
1378        assert!(sections.len() >= 2);
1379        // First section should start with # Top
1380        assert!(sections[0].contains("# Top"));
1381        // Last section should contain # Next Top
1382        let last = sections.last().unwrap();
1383        assert!(last.contains("# Next Top"));
1384    }
1385
1386    // ─── Cross-strategy tests ──────────────────────────────────────
1387
1388    #[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            // All chunks should be valid UTF-8
1408            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            // EstimateTokenCounter uses chars/4 with min 1
1445            let expected = (chunk.content.len() / 4).max(1);
1446            assert_eq!(chunk.token_count_estimate, expected);
1447        }
1448    }
1449
1450    // ─── ChunkingStrategy serde ────────────────────────────────────
1451
1452    #[test]
1453    fn test_chunking_strategy_serde() {
1454        // Plain should deserialize from "plain"
1455        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        // Default config should have Plain strategy
1471        let config = ChunkingConfig::default();
1472        assert_eq!(config.strategy, ChunkingStrategy::Plain);
1473
1474        // Also test that deserializing without strategy field gives Plain
1475        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}