use crate::config::IndexConfig;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone)]
#[allow(dead_code)]
pub struct IndexDoc {
pub doc_id: String,
pub source: String,
pub title: String,
pub text: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Chunk {
pub chunk_id: String,
pub doc_id: String,
pub source: String,
pub title: String,
pub text: String,
}
#[allow(dead_code)]
const TEXT_EXTS: &[&str] = &["txt", "md", "markdown", "json", "jsonl"];
#[allow(dead_code)]
fn rel(path: &Path, repo_root: &Path) -> String {
path.strip_prefix(repo_root).unwrap_or(path).to_string_lossy().replace('\\', "/")
}
#[allow(dead_code)]
fn walk_text_files(root: &Path, repo_root: &Path, out: &mut Vec<IndexDoc>) {
let Ok(entries) = std::fs::read_dir(root) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_text_files(&path, repo_root, out);
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if !TEXT_EXTS.contains(&ext.to_lowercase().as_str()) {
continue;
}
match std::fs::read_to_string(&path) {
Ok(text) if !text.trim().is_empty() => {
let source = rel(&path, repo_root);
let title = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
out.push(IndexDoc { doc_id: source.clone(), source, title, text });
}
Ok(_) => {}
Err(e) => eprintln!("kibble: skipping {} ({e})", path.display()),
}
}
}
}
pub fn load_docs(repo_root: &Path, cfg: &IndexConfig, explicit: Option<&Path>) -> Vec<IndexDoc> {
let mut docs = Vec::new();
if let Some(p) = explicit {
if p.is_dir() {
walk_text_files(p, repo_root, &mut docs);
} else if p.is_file() {
let mut one = Vec::new();
walk_text_files(p.parent().unwrap_or(repo_root), repo_root, &mut one);
let target = rel(p, repo_root);
docs.extend(one.into_iter().filter(|d| d.source == target));
}
return docs;
}
for src in &cfg.sources {
let path = repo_root.join(src);
if !path.exists() {
continue;
}
if std::path::Path::new(src).file_name().and_then(|s| s.to_str()) == Some("raw") {
if let Ok(raw) = crate::corpus::load_raw_docs(&path) {
for d in raw {
docs.push(IndexDoc { doc_id: d.doc_id.clone(), source: d.doc_id.clone(), title: d.title, text: d.text });
}
}
} else {
walk_text_files(&path, repo_root, &mut docs);
}
}
docs
}
pub fn chunk_docs(docs: &[IndexDoc], chunk_chars: usize) -> Vec<Chunk> {
let mut chunks = Vec::new();
for d in docs {
for (n, text) in crate::text::chunk_text(&d.text, chunk_chars).into_iter().enumerate() {
chunks.push(Chunk {
chunk_id: format!("{}#{}", d.source, n),
doc_id: d.doc_id.clone(),
source: d.source.clone(),
title: d.title.clone(),
text,
});
}
}
chunks
}
pub async fn build_index(repo_root: &Path, explicit: Option<&Path>) -> std::io::Result<usize> {
let cfg = crate::config::load_config(&repo_root.join(crate::config::CONFIG_FILE));
let docs = load_docs(repo_root, &cfg.index, explicit);
let chunks = chunk_docs(&docs, cfg.index.chunk_chars);
let embed = &cfg.understand.embed;
let mut has_vectors = false;
let mut dim = 0usize;
if chunks.is_empty() {
eprintln!("kibble: no documents found to index");
} else if embed.base_url.is_empty() {
eprintln!("kibble: no embed backend configured — building lexical-only index");
} else {
let texts: Vec<String> = chunks.iter().map(|c| c.text.clone()).collect();
let store = repo_root.join(&embed.store);
match crate::embed::EndpointEmbedder::new(embed, cfg.network.proxy.as_deref()) {
Ok(e) => match crate::vectors::get_or_embed(&e, &store, &embed.model, &texts, embed.batch_size).await {
Ok(vecs) => {
has_vectors = true;
dim = vecs.first().map(|v| v.len()).unwrap_or(0);
}
Err(err) => eprintln!("kibble: index embeddings skipped (embed failed): {err}"),
},
Err(err) => eprintln!("kibble: index embeddings skipped (embed init failed): {err}"),
}
}
let chunk_tokens: Vec<Vec<String>> = chunks.iter().map(|c| crate::bm25::tokenize(&c.text)).collect();
let bm25 = crate::bm25::Bm25Index::build(&chunk_tokens);
let dir = repo_root.join(&cfg.index.dir);
std::fs::create_dir_all(&dir)?;
let mut buf = String::new();
for c in &chunks {
buf.push_str(&serde_json::to_string(c)?);
buf.push('\n');
}
std::fs::write(dir.join("chunks.jsonl"), buf)?;
std::fs::write(dir.join("bm25.json"), serde_json::to_string(&bm25)?)?;
let meta = serde_json::json!({
"embed_model": embed.model,
"dim": dim,
"n_chunks": chunks.len(),
"has_vectors": has_vectors,
});
std::fs::write(dir.join("meta.json"), serde_json::to_string(&meta)?)?;
Ok(chunks.len())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::IndexConfig;
#[test]
fn load_and_chunk_a_dir() {
let dir = std::env::temp_dir().join(format!("kibble_idx_load_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("data/notes")).unwrap();
std::fs::write(dir.join("data/notes/a.md"), "hello world one two three").unwrap();
std::fs::write(dir.join("data/notes/b.txt"), "another document here").unwrap();
std::fs::write(dir.join("data/notes/skip.png"), "not text").unwrap();
let cfg = IndexConfig { sources: vec!["data/notes".into()], chunk_chars: 1000, ..Default::default() };
let docs = load_docs(&dir, &cfg, None);
assert_eq!(docs.len(), 2); let mut sources: Vec<&str> = docs.iter().map(|d| d.source.as_str()).collect();
sources.sort();
assert_eq!(sources, vec!["data/notes/a.md", "data/notes/b.txt"]);
let chunks = chunk_docs(&docs, 1000);
assert_eq!(chunks.len(), 2);
assert!(chunks.iter().any(|c| c.chunk_id == "data/notes/a.md#0"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn explicit_path_overrides_sources() {
let dir = std::env::temp_dir().join(format!("kibble_idx_explicit_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("other")).unwrap();
std::fs::write(dir.join("other/x.md"), "explicit content").unwrap();
let cfg = IndexConfig { sources: vec!["data/raw".into()], ..Default::default() };
let docs = load_docs(&dir, &cfg, Some(&dir.join("other")));
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].source, "other/x.md");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn raw_source_gives_unique_chunk_ids_and_provenance() {
let dir = std::env::temp_dir().join(format!("kibble_idx_raw_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("data/raw/hacking")).unwrap();
std::fs::write(dir.join("data/raw/hacking/alpha.txt"), "first raw document about alpha").unwrap();
std::fs::write(dir.join("data/raw/hacking/beta.txt"), "second raw document about beta").unwrap();
let cfg = crate::config::IndexConfig { sources: vec!["data/raw".into()], chunk_chars: 1000, ..Default::default() };
let docs = load_docs(&dir, &cfg, None);
assert_eq!(docs.len(), 2);
let chunks = chunk_docs(&docs, 1000);
let ids: std::collections::HashSet<&str> = chunks.iter().map(|c| c.chunk_id.as_str()).collect();
assert_eq!(ids.len(), chunks.len());
let sources: std::collections::HashSet<&str> = chunks.iter().map(|c| c.source.as_str()).collect();
assert_eq!(sources.len(), 2);
assert!(chunks.iter().all(|c| c.source != "data/raw"));
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn build_index_lexical_only_writes_files() {
let dir = std::env::temp_dir().join(format!("kibble_idx_build_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("data/notes")).unwrap();
std::fs::write(dir.join("data/notes/a.md"), "the quick brown fox jumps").unwrap();
std::fs::write(
dir.join(crate::config::CONFIG_FILE),
"[index]\nsources=[\"data/notes\"]\nchunk_chars=1000\n[understand.embed]\nbase_url=\"\"\n",
).unwrap();
let n = build_index(&dir, None).await.unwrap();
assert!(n >= 1);
let idx_dir = dir.join("data/index");
assert!(idx_dir.join("chunks.jsonl").is_file());
assert!(idx_dir.join("bm25.json").is_file());
let meta: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(idx_dir.join("meta.json")).unwrap()).unwrap();
assert_eq!(meta["has_vectors"], serde_json::json!(false));
assert_eq!(meta["n_chunks"], serde_json::json!(n));
std::fs::remove_dir_all(&dir).ok();
}
}