use serde_json::json;
use super::common::{
current_section_level, extract_heading_text, heading_level_txt, heading_path_strings,
parse_txt_blocks, update_heading_stack, ChunkRecordInput, ContentType,
};
const MAX_SECTION_CHARS: usize = 2000;
struct SectionBody {
parts: Vec<(String, &'static str)>,
section_heading: String,
section_level: u8,
heading_path: Vec<String>,
}
impl SectionBody {
fn joined(&self) -> String {
self.parts.iter().map(|(c, _)| c.as_str()).collect::<Vec<_>>().join("\n\n")
}
fn char_count(&self) -> usize {
self.parts.iter().map(|(c, _)| c.len()).sum::<usize>()
+ self.parts.len().saturating_sub(1) * 2
}
fn block_types(&self) -> Vec<&'static str> {
let mut seen: Vec<&'static str> = Vec::new();
for (_, t) in &self.parts { if !seen.contains(t) { seen.push(t); } }
seen
}
fn paragraph_count(&self) -> usize {
self.parts.iter().filter(|(_, t)| *t == "paragraph" || *t == "list").count()
}
}
fn split_large_section(text: &str, max_chars: usize) -> Vec<String> {
if text.len() <= max_chars { return vec![text.trim().to_string()]; }
let mut chunks = Vec::new();
let mut current = String::new();
for para in text.split("\n\n") {
let candidate = if current.is_empty() { para.to_string() }
else { format!("{}\n\n{}", current, para) };
if candidate.len() <= max_chars { current = candidate; }
else {
if !current.is_empty() { chunks.push(current.trim().to_string()); }
current = para.to_string();
}
}
if !current.trim().is_empty() { chunks.push(current.trim().to_string()); }
if chunks.is_empty() { vec![text.trim().to_string()] } else { chunks }
}
fn flush_section(
result: &mut Vec<ChunkRecordInput>,
body: SectionBody,
chunk_index: &mut usize,
total: usize,
) {
let joined = body.joined();
if joined.trim().is_empty() { return; }
let block_types = body.block_types();
let paragraph_count = body.paragraph_count();
let parts = split_large_section(&joined, MAX_SECTION_CHARS);
let part_count = parts.len();
for (i, content) in parts.into_iter().enumerate() {
if content.is_empty() { continue; }
result.push(ChunkRecordInput {
content_type: ContentType::Section,
content: content.clone(),
metadata: json!({
"section_heading": body.section_heading,
"section_level": body.section_level,
"heading_path": body.heading_path,
"paragraph_count": paragraph_count,
"block_types": block_types,
"char_count": content.len(),
"split_part": if part_count > 1 { json!(i + 1) } else { serde_json::Value::Null },
"split_total": if part_count > 1 { json!(part_count) } else { serde_json::Value::Null },
"chunk_index": *chunk_index,
"document_metadata": { "source_type": "txt", "total_input_blocks": total }
}),
});
*chunk_index += 1;
}
}
fn block_type_str(ct: ContentType) -> &'static str {
match ct {
ContentType::PlainParagraph
| ContentType::LongSingleParagraph
| ContentType::ShortDisconnectedParagraph => "paragraph",
ContentType::BulletNumberedList => "list",
ContentType::CodeBlock => "code_block",
ContentType::Table => "table",
_ => "other",
}
}
pub fn build_section_chunks(bytes: &[u8]) -> Result<Vec<ChunkRecordInput>, String> {
let text = std::str::from_utf8(bytes)
.map(|s| s.to_string())
.unwrap_or_else(|_| String::from_utf8_lossy(bytes).to_string());
if text.trim().is_empty() { return Err("TXT file is empty".to_string()); }
let blocks = parse_txt_blocks(&text);
let total = blocks.len();
let mut result: Vec<ChunkRecordInput> = Vec::new();
let mut heading_stack: Vec<(u8, String)> = Vec::new();
let mut current: Option<SectionBody> = None;
let mut chunk_index = 0usize;
for block in &blocks {
if block.content_type == ContentType::HeadingSection {
if let Some(body) = current.take() {
flush_section(&mut result, body, &mut chunk_index, total);
}
let level = heading_level_txt(&block.content);
let text = extract_heading_text(&block.content);
update_heading_stack(&mut heading_stack, level, text.clone());
result.push(ChunkRecordInput {
content_type: ContentType::HeadingSection,
content: text.clone(),
metadata: json!({
"section_heading": text.clone(),
"section_level": level,
"heading_path": heading_path_strings(&heading_stack),
"paragraph_count": 0,
"block_types": ["heading"],
"char_count": text.len(),
"chunk_index": chunk_index,
"document_metadata": { "source_type": "txt", "total_input_blocks": total }
}),
});
chunk_index += 1;
current = Some(SectionBody {
parts: Vec::new(),
section_heading: text,
section_level: level,
heading_path: heading_path_strings(&heading_stack),
});
} else {
let bts = block_type_str(block.content_type);
let a = current.get_or_insert_with(|| SectionBody {
parts: Vec::new(),
section_heading: "Preamble".to_string(),
section_level: current_section_level(&heading_stack),
heading_path: heading_path_strings(&heading_stack),
});
if a.char_count() + block.content.len() + 2 > MAX_SECTION_CHARS && !a.parts.is_empty() {
let next_heading = a.section_heading.clone();
let next_level = a.section_level;
let next_path = a.heading_path.clone();
let body = current.take().unwrap();
flush_section(&mut result, body, &mut chunk_index, total);
current = Some(SectionBody {
parts: Vec::new(),
section_heading: next_heading,
section_level: next_level,
heading_path: next_path,
});
current.as_mut().unwrap().parts.push((block.content.clone(), bts));
} else {
a.parts.push((block.content.clone(), bts));
}
}
}
if let Some(body) = current.take() {
flush_section(&mut result, body, &mut chunk_index, total);
}
if result.is_empty() { return Err("No section chunks generated".to_string()); }
Ok(result)
}