use std::ffi::OsString;
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde_yaml::{Value, from_reader};
use crate::config::load::{LoadError, load_config};
use crate::config::schema::FileConfig;
use crate::core::cli::{Cli, Command};
use crate::core::config::{ResolveError, ResolvedConfig, discover, resolve_all, resolve_single};
use crate::core::exec::{Cmd, ExecError, Runner};
#[derive(Debug)]
pub enum KazeError {
Io(std::io::Error),
Exec(ExecError),
Load(LoadError),
Resolve(ResolveError),
Msg(String),
}
impl From<std::io::Error> for KazeError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<ExecError> for KazeError {
fn from(value: ExecError) -> Self {
Self::Exec(value)
}
}
impl From<LoadError> for KazeError {
fn from(value: LoadError) -> Self {
Self::Load(value)
}
}
impl From<ResolveError> for KazeError {
fn from(value: ResolveError) -> Self {
Self::Resolve(value)
}
}
impl From<serde_yaml::Error> for KazeError {
fn from(value: serde_yaml::Error) -> Self {
Self::Msg(format!("failed to load yaml: {value:?}"))
}
}
pub fn run(cli: &Cli) -> Result<(), KazeError> {
let ctx = discover(cli.project.as_deref(), cli)
.map_err(|e| KazeError::Msg(format!("project_dir discovery failed: {e:?}")))?;
let command = &cli.command;
let file_cfg = match *command {
Command::Init | Command::Boards => FileConfig::default(),
_ => load_config(&ctx.project_dir)
.map_err(|e| KazeError::Msg(format!("load failed: {e:?}")))?,
};
let is_profile_cmd = matches!(
command,
Command::Conf(_) | Command::Build(_) | Command::Run(_) | Command::Flash(_)
);
if cli.all && is_profile_cmd && !file_cfg.profiles.is_empty() {
let rcs = resolve_all(cli, &ctx, &file_cfg);
let mut any_failed = false;
for item in rcs {
match item {
Ok(rc) => {
if let Err(e) = run_cmd(
&rc,
cli.verbose,
cli.dry_run,
cli.preclean,
command,
is_profile_cmd,
&file_cfg,
) {
any_failed = true;
eprintln!("command failed for profile {:?}: {e:?}", rc.profile.clone());
}
}
Err(e) => {
return Err(KazeError::Msg(format!("resolve failed: {e:?}")));
}
}
}
if any_failed {
return Err(KazeError::Msg(
"command failed for one or more profiles".into(),
));
}
return Ok(());
}
let rc = resolve_single(cli, &ctx, &file_cfg)
.map_err(|e| KazeError::Msg(format!("resolve failed: {e:?}")))?;
run_cmd(
&rc,
cli.verbose,
cli.dry_run,
cli.preclean,
command,
is_profile_cmd,
&file_cfg,
)?;
Ok(())
}
fn run_cmd(
rc: &ResolvedConfig,
verbose: u8,
dry_run: bool,
preclean: bool,
command: &Command,
is_profile_cmd: bool,
file_cfg: &FileConfig,
) -> Result<(), KazeError> {
let runner = Runner { verbose, dry_run };
if preclean && is_profile_cmd {
clean_dir_safe(&rc.build_dir, &rc.ctx.project_dir, dry_run)?;
}
match *command {
Command::Init => cmd_init(&rc.ctx.project_dir)?,
Command::Profiles => cmd_profiles(file_cfg),
Command::Clean => cmd_clean_root(&rc.build_root, &rc.ctx.project_dir, dry_run)?,
Command::Conf(ref p) => cmd_conf(runner, rc, &p.extra)?,
Command::Build(ref p) => cmd_build(runner, rc, &p.extra)?,
Command::Flash(ref f) => cmd_flash(runner, rc, &f.phase.extra)?,
Command::Run(ref r) => cmd_run(runner, rc, r.norebuild, &r.phase.extra)?,
Command::Boards => cmd_west_boards(runner)?,
Command::Runners => cmd_runners(rc)?,
}
Ok(())
}
fn cmd_init(project_dir: &Path) -> Result<(), KazeError> {
let path = project_dir.join("kaze.toml");
if path.exists() {
return Err(KazeError::Msg("kaze.toml already exists".into()));
}
let template = r#"[project]
board = "nucleo_f767zi"
runner = "openocd"
[build]
root = "build"
"#;
fs::write(&path, template)?;
eprintln!("created {}", path.display());
Ok(())
}
fn cmd_profiles(file_cfg: &FileConfig) {
if file_cfg.profiles.is_empty() {
println!("(no profiles configured)");
return;
}
println!("Configured profiles:");
for (i, name) in file_cfg.profiles.keys().enumerate() {
println!("{i:>2}: {name}");
}
}
fn cmd_clean_root(build_root: &Path, project_dir: &Path, dry_run: bool) -> Result<(), KazeError> {
clean_dir_safe(build_root, project_dir, dry_run)?;
Ok(())
}
fn cmd_conf(runner: Runner, rc: &ResolvedConfig, extra: &[String]) -> Result<(), KazeError> {
fs::create_dir_all(&rc.build_dir)?;
let mut cmd = Cmd::new("cmake")
.arg("-B")
.arg(os(rc.build_dir.as_path()))
.arg("-S")
.arg(os(rc.ctx.project_dir.as_path()))
.arg("-G")
.arg("Ninja")
.arg(format!("-DBOARD={}", rc.board));
if let Some(zb) = rc.zephyr_base.as_ref() {
cmd = cmd.arg(format!("-DZEPHYR_BASE={}", zb.display()));
}
if let Some(rn) = rc.runner.as_ref() {
cmd = cmd.arg(format!("-DBOARD_FLASH_RUNNER={rn}"));
}
cmd = cmd.args(extra.iter().cloned());
runner.run(&cmd)?;
Ok(())
}
fn ensure_configured(runner: Runner, rc: &ResolvedConfig) -> Result<(), KazeError> {
if is_configured(&rc.build_dir) {
if let Some(cached) = get_cache_var(&rc.build_dir, "BOARD")? {
if cached != rc.board {
eprint!("Detected board mismatch. Re-configuring...");
clean_dir_safe(&rc.build_dir, &rc.ctx.project_dir, runner.dry_run)?;
cmd_conf(runner, rc, &rc.args_conf)?;
}
}
return Ok(());
}
cmd_conf(runner, rc, &rc.args_conf)?;
Ok(())
}
fn cmd_build(runner: Runner, rc: &ResolvedConfig, extra: &[String]) -> Result<(), KazeError> {
ensure_configured(runner, rc)?;
let mut cmd = Cmd::new("ninja").arg("-C").arg(os(rc.build_dir.as_path()));
cmd = cmd.args(extra.iter().cloned());
runner.run(&cmd)?;
Ok(())
}
fn ensure_built(runner: Runner, rc: &ResolvedConfig) -> Result<(), KazeError> {
cmd_build(runner, rc, &rc.args_build)?;
Ok(())
}
fn cmd_flash(runner: Runner, rc: &ResolvedConfig, extra: &[String]) -> Result<(), KazeError> {
ensure_built(runner, rc)?;
let mut cmd = Cmd::new("west")
.arg("flash")
.arg("-d")
.arg(os(rc.build_dir.as_path()));
if let Some(rn) = rc.runner.as_ref() {
cmd = cmd.arg("--runner").arg(rn);
}
cmd = cmd.args(extra.iter().cloned());
runner.run(&cmd)?;
Ok(())
}
fn cmd_run(
runner: Runner,
rc: &ResolvedConfig,
norebuild: bool,
extra: &[String],
) -> Result<(), KazeError> {
if !norebuild {
ensure_built(runner, rc)?;
}
let candidates = [
PathBuf::from("zephyr/zephyr.exe"),
PathBuf::from("zephyr/zephyr"),
];
for rel in candidates {
let full = rc.build_dir.join(&rel);
if full.is_file() {
let cmd = Cmd::new(os(&rel))
.cwd(rc.build_dir.clone())
.args(extra.iter().cloned());
runner.run(&cmd)?;
return Ok(());
}
}
let cmd = Cmd::new("ninja")
.arg("-C")
.arg(os(rc.build_dir.as_path()))
.arg("run");
runner.run(&cmd)?;
Ok(())
}
fn cmd_runners(rc: &ResolvedConfig) -> Result<(), KazeError> {
let yaml_path = &rc.build_dir.join("zephyr/runners.yaml");
if !yaml_path.is_file() {
return Err(KazeError::Msg(
"Runner config not found in build dir. Try buidling the project first with `kaze \
build`."
.into(),
));
}
let runners_yml = read_runners_yaml(yaml_path.as_path())?;
let runners = yaml_str_list(&runners_yml, "runners");
let flash_runner = yaml_str(&runners_yml, "flash-runner");
let board = rc.board.clone();
println!("Runners available for `{board}`:\n");
if runners.is_empty() {
println!("None.\n");
} else {
for (i, rn) in runners.iter().enumerate() {
println!("{i:>2}: {rn}");
}
println!();
}
if let Some(rn) = flash_runner {
println!("Configured flash runner: {rn}");
}
Ok(())
}
fn read_runners_yaml(path: &Path) -> Result<Value, KazeError> {
let f = File::open(path)?;
Ok(from_reader(f)?)
}
fn yaml_str(v: &Value, key: &str) -> Option<String> {
v.get(key)?.as_str().map(std::convert::Into::into)
}
fn yaml_str_list(v: &Value, key: &str) -> Vec<String> {
v.get(key)
.and_then(|x| x.as_sequence())
.into_iter()
.flatten()
.filter_map(|x| x.as_str().map(std::convert::Into::into))
.collect()
}
fn cmd_west_boards(runner: Runner) -> Result<(), KazeError> {
let cmd = Cmd::new("west").arg("boards");
runner.run(&cmd)?;
Ok(())
}
fn is_configured(build_dir: &Path) -> bool {
build_dir.join("CMakeCache.txt").is_file() && build_dir.join("build.ninja").is_file()
}
fn get_cache_var(build_dir: &Path, key: &str) -> Result<Option<String>, KazeError> {
let path = build_dir.join("CMakeCache.txt");
if !path.is_file() {
return Ok(None);
}
let f = File::open(path)?;
let r = BufReader::new(f);
for line in r.lines() {
let line = line?;
let s = line.trim();
if s.is_empty() || s.starts_with('#') || s.starts_with("//") {
continue;
}
let Some((lhs, rhs)) = s.split_once('=') else {
continue;
};
let Some((var, _ty)) = lhs.split_once(':') else {
continue;
};
if var == key {
return Ok(Some(rhs.into()));
}
}
Ok(None)
}
fn clean_dir_safe(dir: &Path, project_dir: &Path, dry_run: bool) -> Result<(), KazeError> {
if !dir.exists() && !dry_run {
return Ok(());
}
let proj = canonical_or(project_dir);
let target = canonical_or(dir);
if !target.starts_with(&proj) {
return Err(KazeError::Msg(format!(
"refusing to delete outside project dir: {}",
target.display()
)));
}
if proj == target {
return Err(KazeError::Msg(format!(
"refusing to delete project_root: {}",
target.display()
)));
}
if dry_run {
println!("Cleaning {}", target.display());
} else {
fs::remove_dir_all(&target)?;
eprintln!("removed {}", target.display());
}
Ok(())
}
fn canonical_or(p: &Path) -> PathBuf {
p.canonicalize().unwrap_or_else(|_| p.to_path_buf())
}
fn os(p: &Path) -> OsString {
p.as_os_str().to_os_string()
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use crate::config::schema::ProfileCfg;
use crate::core::cli::{Cli, Command, PhaseArgs, RunArgs};
use crate::core::config::ConfigCtx;
fn make_temp_dir(label: &str) -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let mut dir = std::env::temp_dir();
dir.push(format!(
"nishikaze-cmd-test-{}-{}",
label,
COUNTER.fetch_add(1, Ordering::Relaxed)
));
if dir.exists() {
fs::remove_dir_all(&dir).expect("clean temp dir");
}
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn make_ctx(project_dir: &Path) -> ConfigCtx {
ConfigCtx {
project_dir: project_dir.to_path_buf(),
cwd: project_dir.to_path_buf(),
invoked_from_build: false,
}
}
fn make_rc(project_dir: &Path, build_dir: PathBuf) -> ResolvedConfig {
let build_root = project_dir.join("build");
ResolvedConfig {
ctx: make_ctx(project_dir),
profile: None,
board: "native_sim".into(),
runner: None,
build_root,
build_dir,
sysbuild: false,
zephyr_ws: None,
zephyr_base: None,
args_conf: Vec::new(),
args_build: Vec::new(),
args_flash: Vec::new(),
args_run: Vec::new(),
}
}
fn write_kaze_toml(project_dir: &Path, body: &str) {
let path = project_dir.join("kaze.toml");
fs::write(&path, body).expect("write kaze.toml");
}
#[test]
fn cmd_init_creates_and_rejects_existing_config() {
let dir = make_temp_dir("cmd-init");
cmd_init(&dir).expect("init ok");
assert!(dir.join("kaze.toml").is_file());
let err = cmd_init(&dir).expect_err("should fail when exists");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("already exists")));
}
#[test]
fn cmd_profiles_handles_empty_and_non_empty() {
let empty = FileConfig::default();
cmd_profiles(&empty);
let mut cfg = FileConfig::default();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
cmd_profiles(&cfg);
}
#[test]
fn clean_dir_safe_covers_error_and_dry_run_paths() {
let project_dir = make_temp_dir("clean-root");
let build_dir = project_dir.join("build");
fs::create_dir_all(&build_dir).expect("create build dir");
clean_dir_safe(&build_dir, &project_dir, true).expect("dry run ok");
assert!(build_dir.is_dir());
let outside_dir = make_temp_dir("clean-outside");
let err_outside =
clean_dir_safe(&outside_dir, &project_dir, true).expect_err("outside root");
assert!(
matches!(err_outside, KazeError::Msg(ref msg) if msg.contains("outside project dir"))
);
let err_root = clean_dir_safe(&project_dir, &project_dir, true).expect_err("reject root");
assert!(matches!(err_root, KazeError::Msg(ref msg) if msg.contains("project_root")));
let missing_dir = project_dir.join("missing");
clean_dir_safe(&missing_dir, &project_dir, false).expect("missing ok");
}
#[test]
fn is_configured_and_get_cache_var_cover_cache_paths() {
let build_dir = make_temp_dir("cmake-cache");
assert!(!is_configured(&build_dir));
assert!(matches!(get_cache_var(&build_dir, "BOARD"), Ok(None)));
fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
fs::write(
build_dir.join("CMakeCache.txt"),
"# comment\nFOO:STRING=bar\n// skip\nBOARD:STRING=native\n",
)
.expect("write cache");
assert!(is_configured(&build_dir));
assert_eq!(
get_cache_var(&build_dir, "BOARD").expect("read cache"),
Some("native".into())
);
assert_eq!(
get_cache_var(&build_dir, "MISSING").expect("read cache"),
None
);
}
#[test]
fn ensure_configured_reconfigures_on_board_mismatch() {
let project_dir = make_temp_dir("ensure-configured");
let build_dir = project_dir.join("build");
fs::create_dir_all(&build_dir).expect("create build dir");
fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
fs::write(build_dir.join("CMakeCache.txt"), "BOARD:STRING=old_board\n")
.expect("write cache");
let mut rc = make_rc(&project_dir, build_dir);
rc.board = "new_board".into();
let runner = Runner {
verbose: 0,
dry_run: true,
};
ensure_configured(runner, &rc).expect("ensure configured ok");
}
#[test]
fn cmd_run_prefers_zephyr_binary_when_present() {
let project_dir = make_temp_dir("cmd-run-bin");
let build_dir = project_dir.join("build");
let zephyr_dir = build_dir.join("zephyr");
fs::create_dir_all(&zephyr_dir).expect("create zephyr dir");
fs::write(zephyr_dir.join("zephyr"), "").expect("write binary");
let rc = make_rc(&project_dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_run(runner, &rc, true, &[]).expect("run ok");
}
#[test]
fn cmd_run_falls_back_to_ninja() {
let project_dir = make_temp_dir("cmd-run-ninja");
let build_dir = project_dir.join("build");
fs::create_dir_all(&build_dir).expect("create build dir");
let rc = make_rc(&project_dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_run(runner, &rc, true, &[]).expect("run ok");
}
#[test]
fn cmd_runners_errors_without_yaml() {
let project_dir = make_temp_dir("cmd-runners-missing");
let build_dir = project_dir.join("build");
fs::create_dir_all(&build_dir).expect("create build dir");
let rc = make_rc(&project_dir, build_dir);
let err = cmd_runners(&rc).expect_err("missing runners yaml");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Runner config not found")));
}
#[test]
fn cmd_runners_reads_yaml() {
let project_dir = make_temp_dir("cmd-runners-ok");
let build_dir = project_dir.join("build");
let zephyr_dir = build_dir.join("zephyr");
fs::create_dir_all(&zephyr_dir).expect("create zephyr dir");
fs::write(
zephyr_dir.join("runners.yaml"),
"runners:\n - openocd\n - jlink\nflash-runner: openocd\n",
)
.expect("write runners yaml");
let rc = make_rc(&project_dir, build_dir);
cmd_runners(&rc).expect("runners ok");
}
#[test]
fn run_cmd_dispatches_across_commands() {
let project_dir = make_temp_dir("run-cmd");
let build_dir = project_dir.join("build");
fs::create_dir_all(&build_dir).expect("create build dir");
let mut cfg = FileConfig::default();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
let rc = make_rc(&project_dir, build_dir.clone());
run_cmd(&rc, 0, true, false, &Command::Profiles, false, &cfg).expect("profiles ok");
let build_cmd = Command::Build(PhaseArgs::default());
run_cmd(&rc, 0, true, true, &build_cmd, true, &cfg).expect("build ok");
let conf_cmd = Command::Conf(PhaseArgs::default());
run_cmd(&rc, 0, true, false, &conf_cmd, true, &cfg).expect("conf ok");
let run_cmd_args = Command::Run(RunArgs::default());
run_cmd(&rc, 0, true, false, &run_cmd_args, true, &cfg).expect("run ok");
let flash_cmd = Command::Flash(crate::core::cli::FlashArgs::default());
run_cmd(&rc, 0, true, false, &flash_cmd, true, &cfg).expect("flash ok");
let clean_cmd = Command::Clean;
run_cmd(&rc, 0, true, false, &clean_cmd, false, &cfg).expect("clean ok");
let init_cmd = Command::Init;
run_cmd(&rc, 0, true, false, &init_cmd, false, &cfg).expect("init ok");
let boards_cmd = Command::Boards;
run_cmd(&rc, 0, true, false, &boards_cmd, false, &cfg).expect("boards ok");
let runners_dir = build_dir.join("zephyr");
fs::create_dir_all(&runners_dir).expect("create runners dir");
fs::write(
runners_dir.join("runners.yaml"),
"runners:\n - openocd\nflash-runner: openocd\n",
)
.expect("write runners yaml");
let runners_cmd = Command::Runners;
run_cmd(&rc, 0, true, false, &runners_cmd, false, &cfg).expect("runners ok");
}
#[test]
fn run_uses_config_and_profiles() {
let project_dir = make_temp_dir("run-single");
write_kaze_toml(
&project_dir,
r#"[project]
board = "native_sim"
[build]
root = "build"
[profile.dev]
runner = "openocd"
"#,
);
let cli = Cli {
preclean: false,
all: false,
profile: None,
board: None,
runner: None,
project: Some(project_dir),
verbose: 0,
dry_run: true,
command: Command::Profiles,
};
run(&cli).expect("run ok");
}
#[test]
fn run_all_profiles_reports_any_failed() {
let project_dir = make_temp_dir("run-all-fail");
write_kaze_toml(
&project_dir,
r#"[project]
board = "native_sim"
[build]
root = ".."
[profile.dev]
runner = "openocd"
[profile.prod]
runner = "jlink"
"#,
);
let build_root = project_dir.join("..");
let build_dev = build_root.join("dev");
let build_prod = build_root.join("prod");
fs::create_dir_all(&build_dev).expect("create build dev");
fs::create_dir_all(&build_prod).expect("create build prod");
let cli = Cli {
preclean: true,
all: true,
profile: None,
board: None,
runner: None,
project: Some(project_dir),
verbose: 0,
dry_run: true,
command: Command::Build(PhaseArgs::default()),
};
let err = run(&cli).expect_err("run should fail");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("one or more profiles")));
}
#[test]
fn run_all_profiles_succeeds_with_dry_run() {
let project_dir = make_temp_dir("run-all-ok");
write_kaze_toml(
&project_dir,
r#"[project]
board = "native_sim"
[build]
root = "build"
[profile.dev]
runner = "openocd"
[profile.prod]
runner = "jlink"
"#,
);
let cli = Cli {
preclean: true,
all: true,
profile: None,
board: None,
runner: None,
project: Some(project_dir),
verbose: 0,
dry_run: true,
command: Command::Build(PhaseArgs::default()),
};
run(&cli).expect("run ok");
}
}