kibble 0.1.0

chew through any source into clean datasets — a fast ingestion, RAG & fine-tuning toolkit
Documentation
//! `kibble train` — run a user-configured trainer command against the tune package
//! produced by `kibble tune`. Backend-agnostic: the argv is entirely user-specified.

use crate::config::KibbleConfig;
use std::io;
use std::path::{Path, PathBuf};

/// Default tune output dir when [tune] is absent (mirrors config::d_tune_out).
const DEFAULT_TUNE_OUT: &str = "data/tune";

/// Resolve the [train] command into a concrete argv + working directory.
/// Pure except for existence checks on the tune package. No process is spawned.
pub fn resolve_train(cfg: &KibbleConfig, repo_root: &Path, extra: &[String]) -> io::Result<(Vec<String>, PathBuf)> {
    let train = cfg.train.clone().unwrap_or_default();
    if train.command.is_empty() {
        return Err(io::Error::new(io::ErrorKind::InvalidInput,
            "[train].command is not set in kibble.toml. Examples:\n  \
             # unsloth (Kaggle):  command = [\"python\", \"scripts/train_kaggle.py\", \"--config\", \"{config}\"]\n  \
             # MLX (local):       command = [\"mlx_lm.lora\", \"--config\", \"{config}\"]"));
    }
    // Absolutize repo_root so every placeholder path (and the default cwd) is absolute.
    // Without this a relative root like "." yields relative args that then break once the
    // child is spawned with current_dir = out_dir (the arg would resolve against the new cwd).
    let repo_root = abs_root(repo_root)?;
    let tune_out = cfg.tune.as_ref().map(|t| t.out.as_str()).unwrap_or(DEFAULT_TUNE_OUT);
    let out_dir = repo_root.join(tune_out);
    let config_yaml = out_dir.join("config.yaml");
    if !config_yaml.exists() || !sprint_has_phase(&out_dir) {
        return Err(io::Error::new(io::ErrorKind::NotFound,
            format!("tune package not found at {} — run 'kibble tune' first", out_dir.display())));
    }
    let model = cfg.tune.as_ref().map(|t| t.model.clone()).unwrap_or_default();
    let config_s = config_yaml.to_string_lossy();
    let out_s = out_dir.to_string_lossy();
    let sprint_s = out_dir.join("sprint").to_string_lossy().into_owned();
    let subst = |arg: &str| -> io::Result<String> {
        let s = arg
            .replace("{config}", &config_s)
            .replace("{out_dir}", &out_s)
            .replace("{sprint_dir}", &sprint_s)
            .replace("{model}", &model);
        if let Some(bad) = unknown_token(&s) {
            return Err(io::Error::new(io::ErrorKind::InvalidInput,
                format!("unknown placeholder '{{{bad}}}' in [train].command")));
        }
        Ok(s)
    };
    let mut argv = Vec::with_capacity(train.command.len() + extra.len());
    for a in train.command.iter().chain(extra.iter()) {
        argv.push(subst(a)?);
    }
    let cwd = train.cwd.as_ref().map(|c| repo_root.join(c)).unwrap_or_else(|| out_dir.clone());
    Ok((argv, cwd))
}

/// Load config, resolve the command, and either print it (`--dry-run`) or spawn it
/// (inheriting stdio so output streams live) and propagate its exit code.
pub fn run_train(repo_root: &Path, dry_run: bool, extra: &[String]) -> io::Result<()> {
    let cfg = crate::config::load_config(&repo_root.join(crate::config::CONFIG_FILE));
    let (argv, cwd) = resolve_train(&cfg, repo_root, extra)?;
    if dry_run {
        println!("would run: {}", argv.join(" "));
        println!("  cwd: {}", cwd.display());
        return Ok(());
    }
    let status = std::process::Command::new(&argv[0])
        .args(&argv[1..])
        .current_dir(&cwd)
        .status()
        .map_err(|e| io::Error::new(e.kind(), format!("failed to spawn '{}': {e}", argv[0])))?;
    if !status.success() {
        return Err(io::Error::other(format!("training command exited with status {status}")));
    }
    Ok(())
}

/// Make `repo_root` absolute without resolving symlinks (so placeholder paths and the
/// default cwd are absolute regardless of the caller's working directory). An already
/// absolute path passes through unchanged; `.` maps to the current directory.
fn abs_root(repo_root: &Path) -> io::Result<PathBuf> {
    if repo_root.is_absolute() {
        Ok(repo_root.to_path_buf())
    } else if repo_root == Path::new(".") {
        std::env::current_dir()
    } else {
        Ok(std::env::current_dir()?.join(repo_root))
    }
}

/// True if at least one `sprint/<phase>/train.jsonl` exists under the tune out dir.
fn sprint_has_phase(out_dir: &Path) -> bool {
    let Ok(entries) = std::fs::read_dir(out_dir.join("sprint")) else { return false; };
    entries.flatten().any(|e| e.path().join("train.jsonl").exists())
}

/// Return the first well-formed unresolved `{token}` in `s`, if any.
fn unknown_token(s: &str) -> Option<String> {
    let start = s.find('{')?;
    let rest = &s[start + 1..];
    let end = rest.find('}')?;
    Some(rest[..end].to_string())
}

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

    /// Build a temp repo with a kibble.toml body + a valid tune package, return its root.
    fn scaffold(toml_body: &str, with_package: bool) -> PathBuf {
        let root = std::env::temp_dir().join(format!("kibble_train_{}_{}", std::process::id(), toml_body.len()));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(&root).unwrap();
        fs::write(root.join(crate::config::CONFIG_FILE), toml_body).unwrap();
        if with_package {
            let tune = root.join("data/tune");
            fs::create_dir_all(tune.join("sprint/p1")).unwrap();
            fs::write(tune.join("config.yaml"), "{}\n").unwrap();
            fs::write(tune.join("sprint/p1/train.jsonl"), "{}\n").unwrap();
        }
        root
    }

    #[test]
    fn resolves_placeholders_to_absolute_paths() {
        let root = scaffold("[tune]\nmodel = \"m/x\"\n[train]\ncommand = [\"python\", \"t.py\", \"--config\", \"{config}\", \"--model\", \"{model}\", \"--sprint\", \"{sprint_dir}\", \"--out\", \"{out_dir}\"]\n", true);
        let cfg = crate::config::load_config(&root.join(crate::config::CONFIG_FILE));
        let (argv, cwd) = resolve_train(&cfg, &root, &[]).unwrap();
        assert_eq!(argv[0], "python");
        assert_eq!(argv[3], root.join("data/tune/config.yaml").to_string_lossy());
        assert_eq!(argv[5], "m/x");
        assert_eq!(argv[7], root.join("data/tune/sprint").to_string_lossy());
        assert_eq!(argv[9], root.join("data/tune").to_string_lossy());
        // every substituted path is absolute (the {model} arg at idx 5 is not a path)
        assert!(Path::new(&argv[3]).is_absolute() && Path::new(&argv[7]).is_absolute() && Path::new(&argv[9]).is_absolute());
        // cwd defaults to the tune out dir
        assert_eq!(cwd, root.join("data/tune"));
        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn appends_extra_args_substituted() {
        let root = scaffold("[train]\ncommand = [\"t\"]\n", true);
        let cfg = crate::config::load_config(&root.join(crate::config::CONFIG_FILE));
        let (argv, _) = resolve_train(&cfg, &root, &["--config".into(), "{config}".into()]).unwrap();
        assert_eq!(argv[0], "t");
        assert_eq!(argv[1], "--config");
        assert_eq!(argv[2], root.join("data/tune/config.yaml").to_string_lossy());
        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn explicit_cwd_is_honored() {
        let root = scaffold("[train]\ncommand = [\"t\"]\ncwd = \"data/tune/sprint\"\n", true);
        let cfg = crate::config::load_config(&root.join(crate::config::CONFIG_FILE));
        let (_, cwd) = resolve_train(&cfg, &root, &[]).unwrap();
        assert_eq!(cwd, root.join("data/tune/sprint"));
        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn empty_command_errors_with_both_backends() {
        let root = scaffold("[train]\ncommand = []\n", true);
        let cfg = crate::config::load_config(&root.join(crate::config::CONFIG_FILE));
        let e = resolve_train(&cfg, &root, &[]).unwrap_err().to_string();
        assert!(e.contains("train_kaggle.py"), "mentions unsloth example: {e}");
        assert!(e.contains("mlx_lm.lora"), "mentions MLX example: {e}");
        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn unknown_placeholder_errors() {
        let root = scaffold("[train]\ncommand = [\"t\", \"{bogus}\"]\n", true);
        let cfg = crate::config::load_config(&root.join(crate::config::CONFIG_FILE));
        let e = resolve_train(&cfg, &root, &[]).unwrap_err().to_string();
        assert!(e.contains("bogus"), "names the bad token: {e}");
        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn abs_root_makes_relative_absolute() {
        let cwd = std::env::current_dir().unwrap();
        // "." maps to cwd; a relative subpath is joined onto cwd; absolute passes through.
        assert_eq!(abs_root(Path::new(".")).unwrap(), cwd);
        let rel = abs_root(Path::new("data/tune")).unwrap();
        assert!(rel.is_absolute());
        assert_eq!(rel, cwd.join("data/tune"));
        assert_eq!(abs_root(&cwd).unwrap(), cwd);
    }

    #[test]
    fn missing_package_errors_pointing_at_tune() {
        let root = scaffold("[train]\ncommand = [\"t\", \"{config}\"]\n", false);
        let cfg = crate::config::load_config(&root.join(crate::config::CONFIG_FILE));
        let e = resolve_train(&cfg, &root, &[]).unwrap_err().to_string();
        assert!(e.contains("kibble tune"), "points at kibble tune: {e}");
        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn run_train_spawns_and_succeeds() {
        let root = scaffold("[train]\ncommand = [\"sh\", \"-c\", \"exit 0\"]\n# ok\n", true);
        assert!(run_train(&root, false, &[]).is_ok());
        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn run_train_propagates_nonzero() {
        let root = scaffold("[train]\ncommand = [\"sh\", \"-c\", \"exit 3\"]\n# nonzero exit\n", true);
        let e = run_train(&root, false, &[]).unwrap_err().to_string();
        assert!(e.contains("status"), "reports the nonzero status: {e}");
        fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn dry_run_does_not_spawn() {
        // A command that WOULD fail if spawned; --dry-run must return Ok without running it.
        let root = scaffold("[train]\ncommand = [\"sh\", \"-c\", \"exit 1\"]\n# dry run\n", true);
        assert!(run_train(&root, true, &[]).is_ok());
        fs::remove_dir_all(&root).ok();
    }
}