kibble 0.1.0

chew through any source into clean datasets — a fast ingestion, RAG & fine-tuning toolkit
Documentation
//! Retrieval-index corpus loading + chunking. Loads documents from the
//! configured sources (or an explicit path), then chunks them for embedding
//! and BM25 indexing.

use crate::config::IndexConfig;
use serde::{Deserialize, Serialize};
use std::path::Path;

/// A source document before chunking.
#[derive(Clone)]
#[allow(dead_code)]
pub struct IndexDoc {
    pub doc_id: String,
    pub source: String,
    pub title: String,
    pub text: String,
}

/// One indexed passage.
#[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"];

/// Path relative to `repo_root`, using forward slashes; falls back to the
/// full path string if it isn't under `repo_root`.
#[allow(dead_code)]
fn rel(path: &Path, repo_root: &Path) -> String {
    path.strip_prefix(repo_root).unwrap_or(path).to_string_lossy().replace('\\', "/")
}

/// Recursively collect text-like files under `root` as whole-file documents.
#[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()),
            }
        }
    }
}

/// Load documents from an explicit path, or from the configured sources.
/// The `data/raw` source uses `corpus::load_raw_docs` (richer titles/ids);
/// everything else uses the generic text-file walker.
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
}

/// Chunk every document; `chunk_id` = `"{source}#{n}"`.
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
}

/// Build the retrieval index from the configured sources (or an explicit path).
/// Embeds each chunk via the shared vector cache when `[understand.embed]` is
/// configured; otherwise builds a lexical-only index. Writes `chunks.jsonl`,
/// `bm25.json`, and `meta.json` under `[index].dir`. Returns the chunk count.
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);

    // Embed chunk texts (fail-soft → lexical-only).
    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}"),
        }
    }

    // BM25.
    let chunk_tokens: Vec<Vec<String>> = chunks.iter().map(|c| crate::bm25::tokenize(&c.text)).collect();
    let bm25 = crate::bm25::Bm25Index::build(&chunk_tokens);

    // Persist.
    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); // .png skipped
        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);
        // distinct chunk_ids and sources per document (no "data/raw#0" collision)
        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();
        // kibble.toml with sources=data/notes and NO embed backend → lexical-only
        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();
    }
}