use crate::application::config::{ProcessingError, ProcessingResult};
use std::ops::Range;
#[derive(Debug, Clone)]
pub struct TextChunk {
pub content: String,
pub start_offset: usize,
pub end_offset: usize,
pub has_prefix_overlap: bool,
pub has_suffix_overlap: bool,
pub index: usize,
pub total_chunks: usize,
}
impl TextChunk {
pub fn len(&self) -> usize {
self.content.len()
}
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
pub fn is_first(&self) -> bool {
self.index == 0
}
pub fn is_last(&self) -> bool {
self.index == self.total_chunks - 1
}
pub fn effective_range(&self) -> Range<usize> {
let start = if self.has_prefix_overlap && !self.is_first() {
self.start_offset + self.find_overlap_end()
} else {
self.start_offset
};
let end = if self.has_suffix_overlap && !self.is_last() {
self.end_offset - self.find_overlap_start_from_end()
} else {
self.end_offset
};
start..end
}
fn find_overlap_end(&self) -> usize {
self.content
.char_indices()
.find(|(_, ch)| ch.is_whitespace() || ch.is_ascii_punctuation())
.map(|(i, _)| i)
.unwrap_or(0)
}
fn find_overlap_start_from_end(&self) -> usize {
self.content
.char_indices()
.rev()
.find(|(_, ch)| ch.is_whitespace() || ch.is_ascii_punctuation())
.map(|(i, _)| self.content.len() - i)
.unwrap_or(0)
}
}
#[derive(Debug, Clone)]
pub struct ChunkManager {
chunk_size: usize,
overlap_size: usize,
#[allow(dead_code)]
min_chunk_size: usize,
}
impl ChunkManager {
pub fn new(chunk_size: usize, overlap_size: usize) -> Self {
Self {
chunk_size,
overlap_size,
min_chunk_size: chunk_size / 4, }
}
pub fn chunk_text(&self, text: &str) -> ProcessingResult<Vec<TextChunk>> {
if text.is_empty() {
return Ok(vec![]);
}
if text.len() <= self.chunk_size {
return Ok(vec![TextChunk {
content: text.to_string(),
start_offset: 0,
end_offset: text.len(),
has_prefix_overlap: false,
has_suffix_overlap: false,
index: 0,
total_chunks: 1,
}]);
}
let mut chunks = Vec::new();
let text_bytes = text.as_bytes();
let text_len = text_bytes.len();
let estimated_chunks = text_len.div_ceil(self.chunk_size);
let mut current_pos = 0;
let mut chunk_index = 0;
while current_pos < text_len {
let target_end = (current_pos + self.chunk_size).min(text_len);
let overlap_enabled = self.overlap_size > 0;
let (chunk_start, chunk_end, next_start) = self.find_chunk_boundaries(
text_bytes,
current_pos,
target_end,
overlap_enabled && chunk_index > 0,
overlap_enabled && target_end < text_len,
)?;
let chunk_content = std::str::from_utf8(&text_bytes[chunk_start..chunk_end])
.map_err(|_| ProcessingError::Utf8Error {
position: chunk_start,
})?
.to_string();
chunks.push(TextChunk {
content: chunk_content,
start_offset: chunk_start,
end_offset: chunk_end,
has_prefix_overlap: chunk_start < current_pos,
has_suffix_overlap: chunk_end > next_start,
index: chunk_index,
total_chunks: estimated_chunks,
});
if next_start > current_pos {
current_pos = next_start;
} else {
current_pos = chunk_end;
if current_pos >= text_len {
break;
}
}
chunk_index += 1;
}
let total_chunks = chunks.len();
for chunk in &mut chunks {
chunk.total_chunks = total_chunks;
}
Ok(chunks)
}
fn find_chunk_boundaries(
&self,
text_bytes: &[u8],
start: usize,
target_end: usize,
include_prefix_overlap: bool,
include_suffix_overlap: bool,
) -> ProcessingResult<(usize, usize, usize)> {
let actual_start = self.calculate_chunk_start(text_bytes, start, include_prefix_overlap)?;
let actual_end =
self.calculate_chunk_end(text_bytes, target_end, include_suffix_overlap)?;
let next_start =
self.calculate_next_start(text_bytes, target_end, actual_end, include_suffix_overlap)?;
self.validate_boundaries(actual_start, actual_end, next_start)?;
Ok((actual_start, actual_end, next_start))
}
fn calculate_chunk_start(
&self,
text_bytes: &[u8],
start: usize,
include_prefix_overlap: bool,
) -> ProcessingResult<usize> {
if include_prefix_overlap {
let overlap_start = start.saturating_sub(self.overlap_size);
self.find_utf8_boundary(text_bytes, overlap_start, true)
} else {
Ok(start)
}
}
fn calculate_chunk_end(
&self,
text_bytes: &[u8],
target_end: usize,
include_suffix_overlap: bool,
) -> ProcessingResult<usize> {
if include_suffix_overlap {
let text_len = text_bytes.len();
let overlap_end = (target_end + self.overlap_size).min(text_len);
self.find_utf8_boundary(text_bytes, overlap_end, false)
} else {
self.find_utf8_boundary(text_bytes, target_end, false)
}
}
fn calculate_next_start(
&self,
text_bytes: &[u8],
target_end: usize,
actual_end: usize,
include_suffix_overlap: bool,
) -> ProcessingResult<usize> {
if include_suffix_overlap {
self.find_word_boundary(text_bytes, target_end, false)
} else {
Ok(actual_end)
}
}
fn validate_boundaries(
&self,
actual_start: usize,
actual_end: usize,
next_start: usize,
) -> ProcessingResult<()> {
if actual_start >= actual_end || next_start > actual_end {
Err(ProcessingError::InvalidChunkBoundaries {
start: actual_start,
end: actual_end,
next: next_start,
})
} else {
Ok(())
}
}
fn find_utf8_boundary(
&self,
text_bytes: &[u8],
pos: usize,
forward: bool,
) -> ProcessingResult<usize> {
if pos >= text_bytes.len() {
return Ok(text_bytes.len());
}
if pos == 0 || is_utf8_char_boundary(text_bytes, pos) {
return Ok(pos);
}
if forward {
for i in pos..text_bytes.len().min(pos + 4) {
if is_utf8_char_boundary(text_bytes, i) {
return Ok(i);
}
}
} else {
for i in (pos.saturating_sub(3)..pos).rev() {
if is_utf8_char_boundary(text_bytes, i) {
return Ok(i);
}
}
}
Err(ProcessingError::Utf8BoundaryError { position: pos })
}
fn find_word_boundary(
&self,
text_bytes: &[u8],
pos: usize,
forward: bool,
) -> ProcessingResult<usize> {
let text = std::str::from_utf8(text_bytes)
.map_err(|_| ProcessingError::Utf8Error { position: 0 })?;
if pos >= text.len() {
return Ok(text.len());
}
let safe_pos = self.find_utf8_boundary(text_bytes, pos, forward)?;
let char_pos = text[..safe_pos].chars().count();
let chars: Vec<char> = text.chars().collect();
if forward {
for i in char_pos..chars.len().min(char_pos + 100) {
if is_word_boundary(&chars, i) {
return Ok(char_byte_offset(text, i));
}
}
Ok(self.find_utf8_boundary(text_bytes, pos, true)?)
} else {
for i in (char_pos.saturating_sub(100)..=char_pos).rev() {
if is_word_boundary(&chars, i) {
return Ok(char_byte_offset(text, i));
}
}
Ok(self.find_utf8_boundary(text_bytes, pos, false)?)
}
}
}
fn is_utf8_char_boundary(bytes: &[u8], pos: usize) -> bool {
if pos == 0 || pos >= bytes.len() {
return true;
}
(bytes[pos] & 0b11000000) != 0b10000000
}
fn is_word_boundary(chars: &[char], pos: usize) -> bool {
if pos == 0 || pos >= chars.len() {
return true;
}
let prev = chars[pos - 1];
let curr = chars.get(pos).copied().unwrap_or(' ');
prev.is_whitespace()
|| curr.is_whitespace()
|| (prev.is_alphanumeric() != curr.is_alphanumeric())
|| prev.is_ascii_punctuation()
|| curr.is_ascii_punctuation()
}
fn char_byte_offset(text: &str, char_index: usize) -> usize {
text.char_indices()
.nth(char_index)
.map(|(offset, _)| offset)
.unwrap_or(text.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_chunk() {
let manager = ChunkManager::new(1024, 64);
let text = "This is a short text.";
let chunks = manager.chunk_text(text).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].content, text);
assert!(!chunks[0].has_prefix_overlap);
assert!(!chunks[0].has_suffix_overlap);
}
#[test]
fn test_multiple_chunks() {
let manager = ChunkManager::new(50, 10);
let text = "This is a longer text that will be split into multiple chunks for processing.";
let chunks = manager.chunk_text(text).unwrap();
assert!(chunks.len() > 1);
assert!(!chunks[0].has_prefix_overlap);
assert!(!chunks.last().unwrap().has_suffix_overlap);
if chunks.len() > 2 {
assert!(chunks[1].has_prefix_overlap);
assert!(chunks[1].has_suffix_overlap);
}
}
#[test]
fn test_utf8_boundaries() {
let manager = ChunkManager::new(15, 3); let text = "Hello 世界 World";
let chunks = manager.chunk_text(text).unwrap();
for chunk in &chunks {
let _ = chunk.content.chars().count();
assert!(!chunk.content.is_empty());
}
}
#[test]
fn test_word_boundaries() {
let manager = ChunkManager::new(20, 5);
let text = "The quick brown fox jumps over the lazy dog.";
let chunks = manager.chunk_text(text).unwrap();
for chunk in &chunks {
let trimmed = chunk.content.trim();
assert!(!trimmed.starts_with(' '));
assert!(!trimmed.ends_with(' '));
}
}
#[test]
fn test_empty_text() {
let manager = ChunkManager::new(100, 10);
let chunks = manager.chunk_text("").unwrap();
assert!(chunks.is_empty());
}
#[test]
fn test_chunk_metadata() {
let manager = ChunkManager::new(30, 5);
let text = "This is a test text that will be chunked into several pieces.";
let chunks = manager.chunk_text(text).unwrap();
for (i, chunk) in chunks.iter().enumerate() {
assert_eq!(chunk.index, i);
assert_eq!(chunk.total_chunks, chunks.len());
}
for i in 1..chunks.len() {
assert!(chunks[i].start_offset <= chunks[i - 1].end_offset);
}
}
#[test]
fn test_overlap_larger_than_chunk_size() {
let manager = ChunkManager::new(10, 20); let text = "This is a test of overlapping chunks with large overlap size.";
let result = manager.chunk_text(text);
match result {
Ok(chunks) => {
assert!(!chunks.is_empty());
for chunk in &chunks {
assert!(!chunk.content.is_empty());
}
}
Err(_) => {
}
}
}
#[test]
fn test_text_with_only_multibyte_chars() {
let manager = ChunkManager::new(12, 3); let text = "世界你好世界你好世界你好";
let chunks = manager.chunk_text(text).unwrap();
for chunk in &chunks {
assert!(!chunk.content.is_empty());
let char_count = chunk.content.chars().count();
assert!(char_count > 0);
for ch in chunk.content.chars() {
assert!(ch == '世' || ch == '界' || ch == '你' || ch == '好');
}
}
}
#[test]
fn test_text_with_no_word_boundaries() {
let manager = ChunkManager::new(20, 5);
let text = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let chunks = manager.chunk_text(text).unwrap();
assert!(chunks.len() > 1);
for chunk in &chunks {
assert!(!chunk.content.is_empty());
assert!(text.contains(&chunk.content));
}
}
#[test]
fn test_chunk_boundary_with_emoji() {
let manager = ChunkManager::new(15, 3);
let text = "Hello 😀 World 🌍 Test";
let chunks = manager.chunk_text(text).unwrap();
for chunk in &chunks {
let chars: Vec<char> = chunk.content.chars().collect();
for ch in &chars {
assert!(ch.is_ascii() || *ch == '😀' || *ch == '🌍');
}
}
}
#[test]
fn test_very_small_chunk_size() {
let manager = ChunkManager::new(5, 0); let text = "ABCDEFGHIJ";
let chunks = manager.chunk_text(text).unwrap();
assert_eq!(chunks.len(), 2);
for chunk in &chunks {
assert!(!chunk.content.is_empty());
assert!(chunk.content.len() <= 5);
}
}
#[test]
fn test_chunk_effective_range() {
let manager = ChunkManager::new(20, 5);
let text = "First part. Second part. Third part.";
let chunks = manager.chunk_text(text).unwrap();
for chunk in &chunks {
let range = chunk.effective_range();
assert!(range.start >= chunk.start_offset);
assert!(range.end <= chunk.end_offset);
if chunk.has_prefix_overlap && !chunk.is_first() {
assert!(range.start > chunk.start_offset);
}
if chunk.has_suffix_overlap && !chunk.is_last() {
assert!(range.end < chunk.end_offset);
}
}
}
#[test]
fn test_utf8_boundary_search_limits() {
let manager = ChunkManager::new(10, 2);
let text = "a\u{1F600}b";
let chunks = manager.chunk_text(text).unwrap();
for chunk in &chunks {
assert!(chunk.content.is_char_boundary(0));
assert!(chunk.content.is_char_boundary(chunk.content.len()));
}
}
#[test]
fn test_utf8_boundary_in_japanese_text() {
let manager = ChunkManager::new(10, 2);
let text = "これは日本語のテストです。";
let chunks = manager.chunk_text(text).unwrap();
for chunk in &chunks {
assert!(!chunk.content.is_empty());
let _ = chunk.content.chars().count();
}
}
#[test]
fn test_chunk_boundary_in_multibyte_char() {
let mut text = String::new();
for _ in 0..8 {
text.push('a');
}
text.push('。');
text.push_str("テスト");
let manager = ChunkManager::new(10, 2);
let result = manager.chunk_text(&text);
assert!(result.is_ok());
let chunks = result.unwrap();
for chunk in &chunks {
assert!(std::str::from_utf8(chunk.content.as_bytes()).is_ok());
}
}
#[test]
fn test_large_japanese_text_chunking() {
let manager = ChunkManager::new(100, 10);
let mut text = String::new();
for _ in 0..50 {
text.push_str("これは日本語のテストです。");
}
let chunks = manager.chunk_text(&text).unwrap();
assert!(!chunks.is_empty());
for (i, chunk) in chunks.iter().enumerate() {
assert!(!chunk.content.is_empty());
assert_eq!(chunk.index, i);
assert!(std::str::from_utf8(chunk.content.as_bytes()).is_ok());
assert!(chunk.content.is_char_boundary(0));
assert!(chunk.content.is_char_boundary(chunk.content.len()));
}
}
}