kibble 0.1.0

chew through any source into clean datasets — a fast ingestion, RAG & fine-tuning toolkit
Documentation
//! `kibble init` — scaffold a ready-to-run project (config + sample corpus) in the CWD.

use std::io;
use std::path::Path;

/// What `run_init` did, so the CLI can print a summary + `next:` hint and tests can assert.
#[derive(Debug, Default)]
pub struct InitReport {
    pub config_written: bool,
    pub samples_written: Vec<String>,
    pub samples_skipped: Vec<String>,
}

// The four sample docs, embedded so `init` works from an installed binary and can never
// drift from the committed examples/notes/*.md (a test asserts equality).
const SAMPLES: &[(&str, &str)] = &[
    ("examples/notes/ownership.md", include_str!("../examples/notes/ownership.md")),
    ("examples/notes/borrowing.md", include_str!("../examples/notes/borrowing.md")),
    ("examples/notes/async.md", include_str!("../examples/notes/async.md")),
    ("examples/notes/error-handling.md", include_str!("../examples/notes/error-handling.md")),
];
const CONFIG_TEMPLATE: &str = include_str!("../kibble.toml.example");

/// Scaffold `kibble.toml` + the sample corpus under `root`. Refuses to overwrite an
/// existing config unless `force`; pre-existing sample files are skipped, not clobbered.
pub fn run_init(root: &Path, force: bool) -> io::Result<InitReport> {
    let mut report = InitReport::default();
    let cfg = root.join("kibble.toml");
    if cfg.exists() && !force {
        return Err(io::Error::new(
            io::ErrorKind::AlreadyExists,
            "kibble.toml already exists (use --force to overwrite)",
        ));
    }
    std::fs::write(&cfg, CONFIG_TEMPLATE)?;
    report.config_written = true;

    for (rel, contents) in SAMPLES {
        let dest = root.join(rel);
        if dest.exists() {
            report.samples_skipped.push((*rel).to_string());
            continue;
        }
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(&dest, contents)?;
        report.samples_written.push((*rel).to_string());
    }
    Ok(report)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tmp(tag: &str) -> std::path::PathBuf {
        let d = std::env::temp_dir().join(format!("kibble_init_{tag}_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    #[test]
    fn init_writes_config_and_samples() {
        let d = tmp("fresh");
        let r = run_init(&d, false).unwrap();
        assert!(r.config_written);
        assert_eq!(r.samples_written.len(), 4);
        assert!(d.join("kibble.toml").is_file());
        assert!(d.join("examples/notes/ownership.md").is_file());
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn init_refuses_existing_config_without_force() {
        let d = tmp("exists");
        std::fs::write(d.join("kibble.toml"), "keep me").unwrap();
        let err = run_init(&d, false).unwrap_err();
        assert!(err.to_string().contains("already exists"), "got: {err}");
        assert_eq!(std::fs::read_to_string(d.join("kibble.toml")).unwrap(), "keep me");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn init_force_overwrites_config() {
        let d = tmp("force");
        std::fs::write(d.join("kibble.toml"), "old").unwrap();
        let r = run_init(&d, true).unwrap();
        assert!(r.config_written);
        assert_ne!(std::fs::read_to_string(d.join("kibble.toml")).unwrap(), "old");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn init_skips_existing_sample_files() {
        let d = tmp("skip");
        std::fs::create_dir_all(d.join("examples/notes")).unwrap();
        std::fs::write(d.join("examples/notes/ownership.md"), "mine").unwrap();
        let r = run_init(&d, false).unwrap();
        assert!(r.samples_skipped.iter().any(|s| s.contains("ownership.md")));
        assert_eq!(std::fs::read_to_string(d.join("examples/notes/ownership.md")).unwrap(), "mine");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn embedded_samples_are_nonempty_and_on_topic() {
        // include_str! makes the scaffolded bytes structurally identical to the committed
        // files, so a "does the embed match disk" check is a tautology under cargo. Instead
        // assert the payload is actually usable: each sample is non-trivial and its filename
        // topic appears in its text (so `search "ownership"` etc. return the right doc).
        for (rel, embedded) in SAMPLES {
            assert!(embedded.len() > 40, "{rel} sample is suspiciously short");
            let topic = std::path::Path::new(rel).file_stem().unwrap().to_str().unwrap();
            let keyword = topic.split('-').next().unwrap(); // "error-handling" -> "error"
            assert!(
                embedded.to_lowercase().contains(keyword),
                "{rel} should mention its topic {keyword:?} so search finds it"
            );
        }
    }
}