use crate::config::KibbleConfig;
use std::io;
use std::path::{Path, PathBuf};
const DEFAULT_TUNE_OUT: &str = "data/tune";
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}\"]"));
}
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))
}
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(())
}
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))
}
}
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())
}
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;
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());
assert!(Path::new(&argv[3]).is_absolute() && Path::new(&argv[7]).is_absolute() && Path::new(&argv[9]).is_absolute());
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();
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() {
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();
}
}