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 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
145/// Split text into a list of sentence strings, preserving trailing
146/// whitespace and punctuation. Each returned string includes its
147/// terminating punctuation (`.`, `!`, `?`) followed by any whitespace.
148fn 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        // Detect sentence-ending punctuation followed by whitespace or EOF
159        if (ch == '.' || ch == '!' || ch == '?') && (i + 1 >= chars.len() || chars[i + 1].is_whitespace() || chars[i + 1] == '\n') {
160
161            // Don't split on decimals like "3.14" (digit before and after)
162            if ch == '.' && i > 0 && chars[i - 1].is_ascii_digit() {
163                i += 1;
164                continue;
165            }
166
167            // Check for multi-period abbreviations like "e.g." or "i.e."
168            // by looking at the accumulated text
169            if is_likely_abbreviation(&current) {
170                i += 1;
171                continue;
172            }
173
174            // Consume following whitespace as part of this sentence
175            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        // Also treat newlines (double newline = paragraph break) as sentence boundaries
187        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
204/// Check whether the accumulated text ends with a known abbreviation
205/// pattern that should NOT be treated as a sentence boundary.
206fn is_likely_abbreviation(text: &str) -> bool {
207    let trimmed = text.trim_end();
208    // Single capital letter followed by period: "J." "A." etc.
209    if trimmed.len() == 2 {
210        let bytes = trimmed.as_bytes();
211        return bytes[0].is_ascii_uppercase() && bytes[1] == b'.';
212    }
213    // Multi-period abbreviations: check if last few chars match "X.X" pattern
214    // e.g. "e.g" "i.e" where a single letter sits between two periods
215    let bytes = trimmed.as_bytes();
216    if bytes.len() >= 4 && bytes[bytes.len() - 1] == b'.' {
217        // Check pattern ".X." at end (period, single letter, period)
218        let n = bytes.len();
219        if bytes[n - 3] == b'.' && bytes[n - 2].is_ascii_alphabetic() {
220            return true;
221        }
222    }
223    // Common abbreviations ending with period
224    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}
234/// `overlap` characters, snapped to a sentence boundary if possible.
235fn 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    // Try to find the next sentence start after safe_start
244    let tail = &prev[safe_start..];
245
246    // Look for a period/exclamation/question mark followed by whitespace
247    // within the first ~50% of the overlap region
248    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            // Check if followed by whitespace
254            let after = &search_region[idx + 1..];
255            if after.starts_with(|c: char| c.is_whitespace()) {
256                // Skip past the whitespace
257                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    // Fall back to word boundary
269    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
277// ────────────────────────────────────────────────────────────────
278// Code strategy
279// ────────────────────────────────────────────────────────────────
280
281/// Split code text while avoiding breaks inside function bodies.
282/// Detects Rust, Python, and TypeScript/JavaScript function boundaries.
283/// Falls back to plain splitting for non-code or unrecognized content.
284fn code_split(text: &str, config: &ChunkingConfig) -> Vec<String> {
285    let boundaries = detect_code_block_boundaries(text);
286
287    if boundaries.is_empty() {
288        // No function blocks detected — fall back to plain split
289        return recursive_split(text, config, 0);
290    }
291
292    // Build segments: text between boundaries, where each function body
293    // is kept as a single unit if possible.
294    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 the segment itself exceeds max_size, it must be split.
301        // For function bodies, we use force_split as a last resort.
302        if segment.len() > config.max_size {
303            if !current.is_empty() {
304                chunks.push(std::mem::take(&mut current));
305            }
306            // Force-split oversized function bodies
307            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    // Recursively split any remaining oversized chunks
324    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/// A code block boundary: the byte offset where a function body starts
341/// and the byte offset where it ends.
342#[derive(Debug, Clone)]
343struct CodeBlockBoundary {
344    /// Byte offset of the first character of the function definition line
345    start: usize,
346    /// Byte offset one past the last character of the function body
347    end: usize,
348}
349
350/// Detect function block boundaries in Rust, Python, and TS/JS code.
351///
352/// Returns a list of non-overlapping boundaries sorted by start position.
353fn detect_code_block_boundaries(text: &str) -> Vec<CodeBlockBoundary> {
354    let mut boundaries = Vec::new();
355
356    // Try Rust/TS/JS style (brace-delimited functions)
357    boundaries.extend(detect_brace_functions(text));
358    // Try Python style (indentation-based functions)
359    boundaries.extend(detect_python_functions(text));
360
361    if boundaries.is_empty() {
362        return Vec::new();
363    }
364
365    // Sort by start position
366    boundaries.sort_by_key(|b| b.start);
367
368    // Remove overlapping boundaries (prefer the earlier one)
369    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
379/// Detect Rust/TypeScript/JavaScript function boundaries using brace matching.
380///
381/// Looks for lines matching patterns like:
382/// - `fn name(` (Rust)
383/// - `function name(` (TS/JS)
384/// - `async fn name(` (Rust async)
385/// - `async function name(` (TS/JS async)
386/// - `const name = (` / `const name = function` (TS/JS arrow/named)
387/// - Arrow functions `=>` at top level
388fn 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; // +1 for newline
395            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        // Detect function definitions
403        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        // Find the opening brace `{` on this line or subsequent lines
422        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 we hit a semicolon before a brace, this isn't a function body
430            // (e.g. a type alias or forward declaration)
431            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        // Match braces from the opening to find the closing brace
441        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
452/// Match braces starting at `brace_start` (the position of `{`).
453/// Returns the byte offset one past the matching closing `}`.
454fn 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        // Check for comments
505        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        // Check for strings
521        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
552/// Detect Python function boundaries using indentation tracking.
553///
554/// Looks for `def ` at any indentation level and tracks the function body
555/// as all subsequent lines with greater indentation.
556fn 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        // Detect Python function/class definitions
571        if !(trimmed.starts_with("def ")
572            || trimmed.starts_with("async def ")
573            || trimmed.starts_with("class "))
574        {
575            continue;
576        }
577
578        // Check for colon at end (possibly after parameters)
579        // Look forward for the colon on the same line or continuation lines
580        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 we hit a blank line or dedent without colon, not a function
589            if j > i && l.trim().is_empty() {
590                break;
591            }
592        }
593
594        if !colon_found {
595            continue;
596        }
597
598        // Find the body: the first non-blank line after the colon line
599        // with greater indentation than the def line
600        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                    // No body found
615                    break;
616                }
617            } else {
618                // Body continues while indentation > def_indent
619                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                // Body extends to end of text
632                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
644/// Build text segments from code block boundaries.
645/// Text outside boundaries becomes its own segment; each function body
646/// becomes a single segment.
647fn 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        // Text before the function
653        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        // The function body itself
660        if b.end > b.start {
661            segments.push(text[b.start..b.end].to_string());
662        }
663        cursor = b.end;
664    }
665
666    // Trailing text after last boundary
667    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
677// ────────────────────────────────────────────────────────────────
678// Markdown strategy
679// ────────────────────────────────────────────────────────────────
680
681/// Split markdown text on header boundaries. Each header and its content
682/// up to the next header of the same or higher level become a chunk.
683/// Oversized chunks are recursively split.
684fn 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    // Pack sections into chunks up to target_size
692    let mut chunks = Vec::new();
693    let mut current = String::new();
694
695    for section in &sections {
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    // Recursively split any chunks that are still too large
707    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
723/// Split markdown text into sections based on header boundaries.
724/// A section starts at a header line (`# `, `## `, etc.) and includes
725/// all content up to the next header of the same or higher level.
726/// Content before the first header becomes its own section.
727fn 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    // Find header positions and levels
742    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        // Content before this header (if any) belongs to the previous section
763        // or is a preamble
764        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        // Find the end of this section: the next header with level <= header_level
772        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    // Trailing content after the last section's end
787    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
797/// Return the header level (1-6) for a markdown header line, or 0 if not a header.
798/// Supports ATX-style headers (`# Header`, `## Subheader`).
799fn markdown_header_level(line: &str) -> usize {
800    let trimmed = line.trim_start();
801
802    // ATX-style header
803    if trimmed.starts_with('#') {
804        let count = trimmed.chars().take_while(|&c| c == '#').count();
805        if count >= 1 && count <= 6 {
806            // Must be followed by whitespace then content
807            let after_hashes = &trimmed[count..];
808            if after_hashes.is_empty() {
809                return 0; // "##" with no content is not a header
810            }
811            if after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
812                // Check there's actual content after the space
813                if after_hashes.trim().is_empty() {
814                    return 0;
815                }
816                return count;
817            }
818        }
819        return 0;
820    }
821
822    0
823}
824
825// ────────────────────────────────────────────────────────────────
826// Shared utilities
827// ────────────────────────────────────────────────────────────────
828
829/// Snap a byte offset to the nearest valid UTF-8 char boundary (searching backward).
830fn 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
838/// Force-split text at max_size boundaries, respecting UTF-8.
839fn 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            // Can't make progress — advance past the current character
846            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
862/// Recursively split text using a hierarchy of separators.
863fn 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    // Separator hierarchy: paragraph > sentence > word > force
874    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        // Recursively split any chunks that are still too large
905        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    // All separators exhausted — force split
920    force_split(text, config.max_size)
921}
922
923/// Merge adjacent chunks that are smaller than min_size.
924fn 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
959/// Apply overlap between adjacent chunks.
960fn 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            // Find a word boundary for the overlap start
972            let raw_start = prev.len() - overlap;
973            let safe_start = safe_split_at(prev, raw_start);
974            // Try to find a space after safe_start to avoid cutting mid-word
975            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// ────────────────────────────────────────────────────────────────
993// Tests
994// ────────────────────────────────────────────────────────────────
995
996#[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    // ─── Plain strategy (backward compatibility) ───────────────────
1020
1021    #[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        // Default config must use Plain strategy
1057        let config = default_config();
1058        assert_eq!(config.strategy, ChunkingStrategy::Plain);
1059    }
1060
1061    #[test]
1062    fn test_plain_backward_compatible_output() {
1063        // The default strategy must produce the same output as Plain
1064        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        // Should not panic and should produce valid UTF-8 chunks
1090        for chunk in &chunks {
1091            assert!(std::str::from_utf8(chunk.content.as_bytes()).is_ok());
1092        }
1093    }
1094
1095    // ─── Sentence strategy ─────────────────────────────────────────
1096
1097    #[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        // Each chunk should not exceed max_size
1129        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        // With overlap, the second chunk should start with some content
1152        // from the end of the first chunk
1153        if chunks.len() >= 2 {
1154            // The overlap should bring some characters from chunk[0] into chunk[1]
1155            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        // "e.g." should not be split as a sentence boundary
1163        // So "Hello e.g. world." should be one sentence
1164        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        // "3.14" should not trigger a sentence split
1176        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        // Double newline should create a boundary
1183        assert!(sentences.len() >= 2);
1184    }
1185
1186    // ─── Code strategy ─────────────────────────────────────────────
1187
1188    #[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        // The function body should not be split mid-body
1224        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        // At least the function signatures should be intact
1227        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        // Should not split inside function bodies
1245        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        // Should not panic and should produce valid chunks
1272        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        // The boundary should span the entire function
1281        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    // ─── Markdown strategy ─────────────────────────────────────────
1299
1300    #[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        // Should produce multiple chunks based on headers
1321        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); // No space after hashes
1333        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        // # Main should contain its intro, and sub-sections should be separate
1342        assert!(sections.len() >= 2, "got {} sections", sections.len());
1343        // The first section should contain "# Main"
1344        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        // Without headers, should still chunk (may be single chunk if short)
1354        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        // # Top should include ## Sub and ### Subsub content
1362        // # Next Top should be a separate section
1363        assert!(sections.len() >= 2);
1364        // First section should start with # Top
1365        assert!(sections[0].contains("# Top"));
1366        // Last section should contain # Next Top
1367        let last = sections.last().unwrap();
1368        assert!(last.contains("# Next Top"));
1369    }
1370
1371    // ─── Cross-strategy tests ──────────────────────────────────────
1372
1373    #[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            // All chunks should be valid UTF-8
1393            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            // EstimateTokenCounter uses chars/4 with min 1
1426            let expected = (chunk.content.len() / 4).max(1);
1427            assert_eq!(chunk.token_count_estimate, expected);
1428        }
1429    }
1430
1431    // ─── ChunkingStrategy serde ────────────────────────────────────
1432
1433    #[test]
1434    fn test_chunking_strategy_serde() {
1435        // Plain should deserialize from "plain"
1436        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        // Default config should have Plain strategy
1452        let config = ChunkingConfig::default();
1453        assert_eq!(config.strategy, ChunkingStrategy::Plain);
1454
1455        // Also test that deserializing without strategy field gives Plain
1456        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}