use std::io::Cursor;
use std::path::Path;
use calamine::{Data, Reader, open_workbook_auto_from_rs};
use file_format::FileFormat;
use text_splitter::{ChunkConfig, ChunkSizer, TextSplitter};
use crate::adapters::lance::{PreparedChunk, PreparedFile};
use crate::domain::common::{CorpusConfig, FileRef, HallouminateError, Mtime, Result};
use crate::domain::corpus::{
ClaimMark, CorpusChunker, Frontmatter, build_line_starts, byte_to_line, extract_claim_marks,
extract_keywords, extract_summary, marks_to_canonical_json, split_frontmatter,
strip_claim_marks,
};
use super::writer::file_ref_string;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Markdown,
PlainText,
Spreadsheet,
}
pub fn detect_format(path: &Path, bytes: &[u8]) -> Option<Format> {
match format_from_extension(path) {
Some(ext_format) => ext_format,
None => detect_by_magic(bytes),
}
}
pub fn format_from_extension(path: &Path) -> Option<Option<Format>> {
let ext = path.extension().and_then(|e| e.to_str())?;
Some(match ext.to_ascii_lowercase().as_str() {
"md" | "markdown" => Some(Format::Markdown),
"txt" | "text" => Some(Format::PlainText),
"csv" | "xlsx" | "xls" | "ods" => Some(Format::Spreadsheet),
_ => None,
})
}
fn detect_by_magic(bytes: &[u8]) -> Option<Format> {
match FileFormat::from_bytes(bytes) {
FileFormat::OfficeOpenXmlSpreadsheet
| FileFormat::MicrosoftExcelSpreadsheet
| FileFormat::OpendocumentSpreadsheet => Some(Format::Spreadsheet),
FileFormat::PlainText => Some(Format::PlainText),
_ => None,
}
}
pub struct PrepareCtx<'a> {
pub corpus: &'a CorpusConfig,
pub file: &'a FileRef,
pub mtime: Mtime,
pub bytes: &'a [u8],
pub content_hash: String,
pub indexed_at_ms: i64,
}
pub trait FormatHandler: Send + Sync {
fn prepare(&self, ctx: &PrepareCtx<'_>) -> Result<PreparedFile>;
}
pub struct MarkdownHandler {
chunker: Box<dyn CorpusChunker>,
}
impl MarkdownHandler {
pub fn new(chunker: Box<dyn CorpusChunker>) -> Self {
Self { chunker }
}
}
impl FormatHandler for MarkdownHandler {
fn prepare(&self, ctx: &PrepareCtx<'_>) -> Result<PreparedFile> {
let path = ctx.file.as_path();
let body = std::str::from_utf8(ctx.bytes).map_err(|e| {
HallouminateError::Indexer(format!("non-utf8 file {}: {e}", path.display()))
})?;
let (frontmatter, content, fm_lines) = split_frontmatter(body);
let chunks_raw = self.chunker.chunk_text(content);
let marks = extract_claim_marks(content);
let mark_chunk_idx: Vec<Option<usize>> = marks
.iter()
.map(|m| {
chunks_raw
.iter()
.rposition(|c| m.line >= c.line_start && m.line <= c.line_end)
})
.collect();
let fallback = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let summary = extract_summary(content, &fallback);
let keywords = extract_keywords(content);
let file_ref_str = file_ref_string(ctx.file)?;
let mut chunks: Vec<PreparedChunk> = Vec::with_capacity(chunks_raw.len());
for c in chunks_raw {
let chunk_marks: Vec<ClaimMark> = marks
.iter()
.enumerate()
.filter(|(mi, _)| mark_chunk_idx[*mi] == Some(c.ord))
.map(|(_, m)| ClaimMark {
line: m.line + fm_lines,
..m.clone()
})
.collect();
chunks.push(PreparedChunk {
ord: c.ord,
heading_path: c.heading_path,
line_start: c.line_start + fm_lines,
line_end: c.line_end + fm_lines,
text: strip_claim_marks(&c.text),
claim_marks: marks_to_canonical_json(&chunk_marks),
});
}
Ok(PreparedFile {
file_ref: file_ref_str,
corpus: ctx.corpus.name.clone(),
mtime_ms: ctx.mtime.0,
content_hash: ctx.content_hash.clone(),
summary,
keywords,
frontmatter: frontmatter.as_ref().map(Frontmatter::to_canonical_json),
indexed_at_ms: ctx.indexed_at_ms,
chunks,
embeddings: None,
})
}
}
pub struct TextHandler<S: ChunkSizer> {
splitter: TextSplitter<S>,
}
impl<S: ChunkSizer> TextHandler<S> {
pub fn new(sizer: S, budget_tokens: usize) -> Self {
let config: ChunkConfig<S> = ChunkConfig::new(budget_tokens).with_sizer(sizer);
Self {
splitter: TextSplitter::new(config),
}
}
}
impl<S: ChunkSizer + Send + Sync> FormatHandler for TextHandler<S> {
fn prepare(&self, ctx: &PrepareCtx<'_>) -> Result<PreparedFile> {
let path = ctx.file.as_path();
let body = std::str::from_utf8(ctx.bytes).map_err(|e| {
HallouminateError::Indexer(format!("non-utf8 file {}: {e}", path.display()))
})?;
let line_starts = build_line_starts(body);
let mut chunks: Vec<PreparedChunk> = Vec::new();
for (byte_off, slice) in self.splitter.chunk_indices(body) {
if slice.is_empty() {
continue;
}
let line_start = byte_to_line(byte_off, &line_starts);
let end_byte = byte_off + slice.len();
let line_end = if end_byte == 0 {
line_start
} else {
byte_to_line(end_byte - 1, &line_starts)
};
chunks.push(PreparedChunk {
ord: chunks.len(),
heading_path: Vec::new(),
line_start,
line_end,
text: slice.to_string(),
claim_marks: None,
});
}
let fallback = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
Ok(PreparedFile {
file_ref: file_ref_string(ctx.file)?,
corpus: ctx.corpus.name.clone(),
mtime_ms: ctx.mtime.0,
content_hash: ctx.content_hash.clone(),
summary: extract_summary(body, &fallback),
keywords: extract_keywords(body),
frontmatter: None,
indexed_at_ms: ctx.indexed_at_ms,
chunks,
embeddings: None,
})
}
}
pub struct SpreadsheetHandler;
impl FormatHandler for SpreadsheetHandler {
fn prepare(&self, ctx: &PrepareCtx<'_>) -> Result<PreparedFile> {
let path = ctx.file.as_path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
let chunks = match ext.as_deref() {
Some("csv") => csv_chunks(ctx.bytes, path)?,
_ => workbook_chunks(ctx.bytes, path)?,
};
let fallback = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let joined: String = chunks
.iter()
.map(|c| c.text.as_str())
.collect::<Vec<_>>()
.join("\n");
Ok(PreparedFile {
file_ref: file_ref_string(ctx.file)?,
corpus: ctx.corpus.name.clone(),
mtime_ms: ctx.mtime.0,
content_hash: ctx.content_hash.clone(),
summary: extract_summary(&joined, &fallback),
keywords: extract_keywords(&joined),
frontmatter: None,
indexed_at_ms: ctx.indexed_at_ms,
chunks,
embeddings: None,
})
}
}
fn csv_chunks(bytes: &[u8], path: &Path) -> Result<Vec<PreparedChunk>> {
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.from_reader(bytes);
let headers = rdr.headers().map_err(|e| extract_err(path, &e))?.clone();
let header_vec = header_strs(&headers);
let mut chunks: Vec<PreparedChunk> = Vec::new();
for (row_idx, record) in rdr.records().enumerate() {
let record = record.map_err(|e| extract_err(path, &e))?;
let cells: Vec<String> = record.iter().map(|s| s.to_string()).collect();
let text = row_text(&header_vec, &cells);
if text.is_empty() {
continue;
}
push_row_chunk(&mut chunks, "csv", row_idx + 1, row_idx + 2, text);
}
Ok(chunks)
}
fn workbook_chunks(bytes: &[u8], path: &Path) -> Result<Vec<PreparedChunk>> {
let cursor = Cursor::new(bytes);
let mut workbook = open_workbook_auto_from_rs(cursor).map_err(|e| extract_err(path, &e))?;
let sheet_names = workbook.sheet_names().to_vec();
let mut chunks: Vec<PreparedChunk> = Vec::new();
for name in &sheet_names {
let range = workbook
.worksheet_range(name)
.map_err(|e| extract_err(path, &e))?;
let mut rows = range.rows();
let Some(header_row) = rows.next() else {
continue;
};
let headers: Vec<String> = header_row.iter().map(cell_to_string).collect();
for (row_idx, row) in rows.enumerate() {
let cells: Vec<String> = row.iter().map(cell_to_string).collect();
let text = row_text(&headers, &cells);
if text.is_empty() {
continue;
}
push_row_chunk(&mut chunks, name, row_idx + 1, row_idx + 1, text);
}
}
Ok(chunks)
}
fn header_strs(headers: &csv::StringRecord) -> Vec<String> {
headers.iter().map(|s| s.to_string()).collect()
}
fn row_text(headers: &[String], cells: &[String]) -> String {
let mut lines: Vec<String> = Vec::with_capacity(cells.len());
for (i, val) in cells.iter().enumerate() {
if val.trim().is_empty() {
continue;
}
let key = headers
.get(i)
.filter(|h| !h.trim().is_empty())
.cloned()
.unwrap_or_else(|| format!("col_{}", i + 1));
lines.push(format!("{key}: {val}"));
}
lines.join("\n")
}
fn push_row_chunk(
chunks: &mut Vec<PreparedChunk>,
sheet: &str,
row: usize,
line: usize,
text: String,
) {
let ord = chunks.len();
chunks.push(PreparedChunk {
ord,
heading_path: vec![format!("{sheet}:row-{row}")],
line_start: line,
line_end: line,
text,
claim_marks: None,
});
}
fn cell_to_string(cell: &Data) -> String {
match cell {
Data::Empty => String::new(),
other => other.to_string(),
}
}
fn extract_err(path: &Path, e: &dyn std::fmt::Display) -> HallouminateError {
HallouminateError::Indexer(format!("extract {}: {e}", path.display()))
}
pub struct HandlerRegistry {
markdown: Box<dyn FormatHandler>,
text: Box<dyn FormatHandler>,
spreadsheet: Box<dyn FormatHandler>,
}
impl HandlerRegistry {
pub fn new<S>(sizer: S, budget_tokens: usize) -> Self
where
S: ChunkSizer + Clone + Send + Sync + 'static,
{
let markdown_chunker: Box<dyn CorpusChunker> = Box::new(
crate::domain::corpus::MarkdownChunker::new(sizer.clone(), budget_tokens),
);
Self {
markdown: Box::new(MarkdownHandler::new(markdown_chunker)),
text: Box::new(TextHandler::new(sizer, budget_tokens)),
spreadsheet: Box::new(SpreadsheetHandler),
}
}
pub fn handler(&self, format: Format) -> &dyn FormatHandler {
match format {
Format::Markdown => self.markdown.as_ref(),
Format::PlainText => self.text.as_ref(),
Format::Spreadsheet => self.spreadsheet.as_ref(),
}
}
}