use std::collections::HashSet;
use std::ffi::OsString;
use std::fmt::Write;
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, WsCommand};
use crate::core::config::{ResolveError, ResolvedConfig, discover, resolve_all, resolve_single};
use crate::core::exec::{Cmd, ExecError, Runner};
use crate::core::log;
#[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> {
log::set_verbosity(cli.verbose);
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(_)
| Command::Menuconfig(_)
| Command::Runners
| Command::Bom(_)
);
log_command_selection(command);
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) => {
log_profile_status(&rc, &file_cfg);
if let Err(e) = run_cmd(
&rc,
cli.verbose,
cli.dry_run,
cli.preclean,
command,
is_profile_cmd,
&file_cfg,
) {
any_failed = true;
log::error(format!(
"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:?}")))?;
if is_profile_cmd {
log_profile_status(&rc, &file_cfg);
}
run_cmd(
&rc,
cli.verbose,
cli.dry_run,
cli.preclean,
command,
is_profile_cmd,
&file_cfg,
)?;
Ok(())
}
fn log_command_selection(command: &Command) {
let name = match *command {
Command::Init(_) => "init",
Command::Boards => "boards",
Command::Runners => "runners",
Command::Profiles => "profiles",
Command::Clean => "clean",
Command::Conf(_) => "conf",
Command::Build(_) => "build",
Command::Run(_) => "run",
Command::Flash(_) => "flash",
Command::Menuconfig(_) => "menuconfig",
Command::Bom(_) => "bom",
Command::Workspace(_) => "workspace",
};
log::info(format!("Running command '{name}'"));
}
fn log_profile_status(rc: &ResolvedConfig, file_cfg: &FileConfig) {
if file_cfg.profiles.is_empty() || rc.profile.is_none() {
log::info("Profiles not configured - running in profile-less mode");
} else if let Some(ref profile) = rc.profile {
log::info(format!("Using profile '{profile}'"));
}
}
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: match command {
&Command::Boards => 3,
_ => verbose,
},
dry_run,
};
if preclean && is_profile_cmd {
clean_dir_safe(&rc.build_dir, &rc.ctx.project_dir, dry_run)?;
}
if is_profile_cmd {
if let Err(err) = ensure_compile_commands_link(rc, file_cfg) {
log::error(format!("failed to link compile_commands.json: {err:?}"));
}
}
match *command {
Command::Init(ref i) => cmd_init(rc, i.force)?,
Command::Profiles => cmd_profiles(file_cfg),
Command::Clean => cmd_clean_root(&rc.build_root, &rc.ctx.project_dir, dry_run)?,
Command::Conf(ref p) => {
let args = merge_args(&rc.args_conf, &p.extra);
cmd_conf(runner, rc, &args)?;
}
Command::Build(ref p) => {
let args = merge_args(&rc.args_build, &p.extra);
cmd_build(runner, rc, &args)?;
}
Command::Flash(ref f) => {
let args = merge_args(&rc.args_flash, &f.phase.extra);
cmd_flash(runner, rc, f.sys.list, f.sys.image.clone(), &args)?;
}
Command::Run(ref r) => {
let args = merge_args(&rc.args_run, &r.phase.extra);
cmd_run(
runner,
rc,
r.norebuild,
r.sys.list,
r.sys.image.clone(),
&args,
)?;
}
Command::Boards => cmd_west_boards(runner)?,
Command::Runners => cmd_runners(rc)?,
Command::Menuconfig(ref m) => cmd_menuconfig(runner, rc, m.sys.list, m.sys.image.clone())?,
Command::Bom(ref b) => cmd_bom(runner, rc, b.sys.list, b.sys.image.clone())?,
Command::Workspace(ref w) => cmd_workspace(runner, rc, &w.command)?,
}
Ok(())
}
fn merge_args(config: &[String], extra: &[String]) -> Vec<String> {
let mut merged = Vec::with_capacity(config.len().saturating_add(extra.len()));
for arg in config.iter().chain(extra.iter()) {
if arg.trim().is_empty() {
continue;
}
merged.push(arg.clone());
}
merged
}
fn push_filtered_args(cmd: &mut Cmd, extra: &[String]) {
for arg in extra {
if arg.trim().is_empty() {
continue;
}
cmd.args.push(OsString::from(arg));
}
}
fn run_with_spinner_message(runner: Runner, cmd: &Cmd, message: &str) -> Result<(), KazeError> {
if runner.verbose == 2 {
runner.run_with_spinner(cmd, message)?;
} else {
log::info(message);
runner.run(cmd)?;
}
Ok(())
}
fn ensure_compile_commands_link(
rc: &ResolvedConfig,
file_cfg: &FileConfig,
) -> Result<(), KazeError> {
if file_cfg.profiles.is_empty() {
return Ok(());
}
if matches!(file_cfg.build.link_compile_commands, Some(false)) {
return Ok(());
}
let Some(profile) = resolve_default_profile(file_cfg) else {
return Ok(());
};
let link_path = rc.build_root.join("compile_commands.json");
if let Ok(meta) = fs::symlink_metadata(&link_path) {
let ft = meta.file_type();
if ft.is_symlink() {
return Ok(());
}
if meta.is_file() {
fs::remove_file(&link_path)?;
} else {
return Ok(());
}
}
fs::create_dir_all(&rc.build_root)?;
let target = rc.build_root.join(profile).join("compile_commands.json");
create_symlink(&target, &link_path)?;
Ok(())
}
fn resolve_default_profile(file_cfg: &FileConfig) -> Option<String> {
if let Some(ref name) = file_cfg.project.default_profile {
if file_cfg.profiles.contains_key(name) {
return Some(name.clone());
}
}
file_cfg.profiles.keys().next().cloned()
}
fn create_symlink(target: &Path, link: &Path) -> Result<(), KazeError> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)?;
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(target, link)?;
}
Ok(())
}
fn cmd_init(rc: &ResolvedConfig, force: bool) -> Result<(), KazeError> {
log::info("Initializing kaze.toml");
let path = &rc.ctx.project_dir.join("kaze.toml");
if path.exists() && !force {
return Err(KazeError::Msg("kaze.toml already exists".into()));
}
let template = format!(
r#"# Project config
[project]
board = {:?}
runner = {:?}
# args = {{ conf = [""], build = [""], run = [""], flash = [""] }}
# default_profile = "sim"
# Build dir config
[build]
root = "build"
# Zephyr workspace override
# [zephyr]
# workspace = ""
# base = ""
# SPDX BoM generation
# [bom]
# build = true
# version = "2.2"
# Build profiles
# [profile.sim]
# board = "native_sim"
# runner = "native"
# args = {{ conf = [""], build = [""], run = [""], flash = [""] }}
# [profile.prod]
# args = {{ conf = [""], build = [""], run = [""], flash = [""] }}
"#,
&rc.board.clone(),
rc.runner
.as_ref()
.map_or_else(|| "openocd".to_owned(), std::clone::Clone::clone)
);
if force && fs::exists(path)? {
fs::remove_file(path)?;
}
fs::write(path, template)?;
log::info(format!("created {}", path.display()));
Ok(())
}
fn cmd_profiles(file_cfg: &FileConfig) {
log::info("Listing configured profiles");
if file_cfg.profiles.is_empty() {
println!("(no profiles configured)");
return;
}
println!("Configured profiles:\n");
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> {
log::info("Cleaning build directory");
clean_dir_safe(build_root, project_dir, dry_run)?;
log::info("Clean completed");
Ok(())
}
fn cmd_conf(runner: Runner, rc: &ResolvedConfig, extra: &[String]) -> Result<(), KazeError> {
fs::create_dir_all(&rc.build_dir)?;
let message = if rc.sysbuild {
"Configuring sysbuild project"
} else {
"Configuring project"
};
let cmd = build_conf_cmd(rc, extra)?;
run_with_spinner_message(runner, &cmd, message)?;
log::info("Configure completed");
Ok(())
}
fn cmd_bom(
runner: Runner,
rc: &ResolvedConfig,
list: bool,
img: Option<String>,
) -> Result<(), KazeError> {
if rc.sysbuild && list {
return cmd_list(rc);
} else if !rc.sysbuild && list {
log::info("Option --list is only supported for sysbuild projects");
return Err(KazeError::Msg(
"Non-sysbuild project. No images to list".into(),
));
}
if !rc.sysbuild && img.is_some() {
log::info("Option --image is only supported for sysbuild projects");
return Err(KazeError::Msg(
"Non-sysbuild project. No images to select".into(),
));
}
let mut build_dir = rc.build_dir.clone();
if rc.sysbuild {
let image = resolve_sysbuild_image(&rc.build_dir, img).inspect_err(|_| {
log::info("Failed to resolve image");
})?;
log::info(format!("Flashing sysbuild image: {image}"));
build_dir = build_dir.join(image);
}
ensure_bom_conf(runner, rc, &build_dir)?;
let cmd = Cmd::new("west")
.arg("spdx")
.arg("--spdx-version")
.arg(rc.bom_v.clone())
.arg("-d")
.arg(os(build_dir.as_path()));
runner.run_with_spinner(&cmd, "Generating SPDX BoM")?;
log::info(format!(
"SPDX BoM generated in: {}",
build_dir.join("spdx").display()
));
Ok(())
}
fn ensure_bom_conf(runner: Runner, rc: &ResolvedConfig, build_dir: &Path) -> Result<(), KazeError> {
if !rc.bom_build {
log::info("BoM generation was not configured for this project.");
return Err(KazeError::Msg("BoM generation not configured.".into()));
}
if build_dir.join(".cmake/api/v1/query/codemodel-v2").is_file() {
return Ok(());
}
if is_configured(&rc.build_dir) {
log::info("Project built w/o BoM configuration, rebuilding");
clean_dir_safe(build_dir, &rc.ctx.project_dir, runner.dry_run)?;
}
let cmd = Cmd::new("west")
.arg("spdx")
.arg("--spdx-version")
.arg(rc.bom_v.clone())
.arg("--init")
.arg("-d")
.arg(os(build_dir));
runner.run_with_spinner(&cmd, "Configuring BoM generation")?;
ensure_built(runner, rc)?;
Ok(())
}
fn cmd_menuconfig(
runner: Runner,
rc: &ResolvedConfig,
list: bool,
img: Option<String>,
) -> Result<(), KazeError> {
ensure_configured(runner, rc)?;
if rc.sysbuild && list {
return cmd_list(rc);
} else if !rc.sysbuild && list {
log::info("Option -- list is only supported for sysbuild projects");
return Err(KazeError::Msg(
"Non-sysbuild project. No images to list".into(),
));
}
let mut build_dir = rc.build_dir.clone();
if rc.sysbuild {
let image = resolve_sysbuild_image(&rc.build_dir, img).inspect_err(|_| {
log::info("Failed to resolve image");
})?;
log::info(format!("Running menuconfig for image: {image}"));
build_dir = build_dir.join(image);
}
log::info("Running menuconfig...");
let cmd = Cmd::new("ninja")
.arg("-C")
.arg(os(build_dir.as_path()))
.arg("menuconfig");
let menuconf_runner = Runner {
verbose: 3,
dry_run: runner.dry_run,
};
menuconf_runner.run(&cmd)?;
Ok(())
}
fn build_conf_cmd(rc: &ResolvedConfig, extra: &[String]) -> Result<Cmd, KazeError> {
let source_dir = if rc.sysbuild {
let zb = rc
.zephyr_base
.as_ref()
.ok_or_else(|| KazeError::Msg("Sysbuild requires ZEPHYR_BASE to be set".into()))?;
zb.join("share").join("sysbuild")
} else {
rc.ctx.project_dir.clone()
};
let mut cmd = Cmd::new("cmake")
.arg("-B")
.arg(os(rc.build_dir.as_path()))
.arg("-S")
.arg(os(source_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 rc.sysbuild {
cmd = cmd.arg(format!("-DAPP_DIR={}", rc.ctx.project_dir.display()));
}
if rc.bom_build {
cmd = cmd.arg("-DCONFIG_BUILD_OUTPUT_META=y");
}
if let Some(rn) = rc.runner.as_ref() {
cmd = cmd.arg(format!("-DBOARD_FLASH_RUNNER={rn}"));
}
push_filtered_args(&mut cmd, extra);
Ok(cmd)
}
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 {
log::info("Board changed, cleaning build directory");
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()));
push_filtered_args(&mut cmd, extra);
run_with_spinner_message(runner, &cmd, "Building project")?;
log::info("Build completed");
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,
list: bool,
img: Option<String>,
extra: &[String],
) -> Result<(), KazeError> {
ensure_built(runner, rc)?;
if rc.sysbuild && list {
return cmd_list(rc);
} else if !rc.sysbuild && list {
log::info("Option --list is only supported for sysbuild projects");
return Err(KazeError::Msg(
"Non-sysbuild project. No images to list".into(),
));
}
if !rc.sysbuild && img.is_some() {
log::info("Option --image is only supported for sysbuild projects");
return Err(KazeError::Msg(
"Non-sysbuild project. No images to select".into(),
));
}
let mut build_dir = rc.build_dir.clone();
if rc.sysbuild {
let image = resolve_sysbuild_image(&rc.build_dir, img).inspect_err(|_| {
log::info("Failed to resolve image");
})?;
log::info(format!("Flashing sysbuild image: {image}"));
build_dir = build_dir.join(image);
}
let mut cmd = Cmd::new("west")
.arg("flash")
.arg("-d")
.arg(os(build_dir.as_path()));
if let Some(rn) = rc.runner.as_ref() {
cmd = cmd.arg("--runner").arg(rn);
}
push_filtered_args(&mut cmd, extra);
run_with_spinner_message(runner, &cmd, "Flashing project")?;
log::info("Flash completed");
Ok(())
}
fn cmd_run(
runner: Runner,
rc: &ResolvedConfig,
norebuild: bool,
list: bool,
img: Option<String>,
extra: &[String],
) -> Result<(), KazeError> {
if rc.sysbuild && list {
return cmd_list(rc);
} else if !rc.sysbuild && list {
log::info("Option --list is only supported for sysbuild projects");
return Err(KazeError::Msg(
"Non-sysbuild project. No images to list.".into(),
));
}
if !rc.sysbuild && img.is_some() {
log::info("Option --image is only supported for sysbuild projects");
return Err(KazeError::Msg(
"Non-sysbuild project. No images to select.".into(),
));
}
let mut build_dir = rc.build_dir.clone();
if rc.sysbuild {
let image = resolve_sysbuild_image(&rc.build_dir, img).inspect_err(|_| {
log::info("Failed to resolve image");
})?;
log::info(format!("Running sysbuild image: {image}"));
build_dir = build_dir.join(image);
}
let candidates = [
PathBuf::from("zephyr/zephyr.exe"),
PathBuf::from("zephyr/zephyr"),
];
let run_runner = Runner {
verbose: 3,
dry_run: runner.dry_run,
};
for rel in candidates {
let full = build_dir.join(&rel);
if full.is_file() {
if !norebuild {
ensure_built(runner, rc)?;
}
let mut cmd = Cmd::new(os(&rel)).cwd(build_dir.as_path());
push_filtered_args(&mut cmd, extra);
run_with_spinner_message(run_runner, &cmd, "Running project")?;
log::info("Run completed");
return Ok(());
}
}
if !norebuild {
ensure_built(runner, rc)?;
}
let mut cmd = Cmd::new("ninja")
.arg("-C")
.arg(os(build_dir.as_path()))
.arg("run");
push_filtered_args(&mut cmd, extra);
run_with_spinner_message(run_runner, &cmd, "Running project")?;
log::info("Run completed");
Ok(())
}
fn cmd_list(rc: &ResolvedConfig) -> Result<(), KazeError> {
let images = find_images(&rc.build_dir)?;
log::info("Listing sysbuild images");
println!("Available sysbuild images:\n");
for (i, name) in images.iter().enumerate() {
println!("{}: {name}", i.saturating_add(1));
}
println!();
Ok(())
}
fn find_images(build_dir: &Path) -> Result<Vec<String>, KazeError> {
let non_image_dirs: HashSet<&'static str> = [
".cmake",
"CMakeFiles",
"Kconfig",
"modules",
"zephyr",
"_sysbuild",
]
.into_iter()
.collect();
let mut images = Vec::new();
for entry in fs::read_dir(build_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if non_image_dirs.contains(name) {
continue;
}
images.push(name.to_owned());
}
images.sort();
Ok(images)
}
fn resolve_sysbuild_image(build_dir: &Path, img: Option<String>) -> Result<String, KazeError> {
let images = find_images(build_dir)?;
if images.is_empty() {
return Err(KazeError::Msg("No sysbuild images found".into()));
}
if let Some(img) = img {
if let Ok(index) = img.parse::<usize>() {
if index == 0 || index > images.len() {
return Err(KazeError::Msg(format!(
"Invalid sysbuild image index: {index}"
)));
}
if let Some(selected) = images.get(index.saturating_sub(1)) {
return Ok(selected.clone());
}
return Err(KazeError::Msg(format!(
"Invalid sysbuild image index: {index}"
)));
}
if images.iter().any(|name| name == &img) {
return Ok(img);
}
return Err(KazeError::Msg(format!("Unknown sysbuild image: {img}")));
}
if let Some(image) = images.iter().find(|name| name.as_str() != "mcuboot") {
return Ok(image.clone());
}
Ok(images.first().expect("images not empty").clone())
}
fn cmd_runners(rc: &ResolvedConfig) -> Result<(), KazeError> {
log::info("Listing available runners");
let yaml_path = &rc.build_dir.join("zephyr/runners.yaml");
if !yaml_path.is_file() {
log::info(
"Runner config not found in build dir. Try building the project first with `kaze \
build`.",
);
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();
let output = format_runners_output(&board, &runners, flash_runner);
print!("{output}");
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 format_runners_output(board: &str, runners: &[String], flash_runner: Option<String>) -> String {
let mut out = String::new();
if writeln!(out, "Runners available for `{board}`:\n").is_err() {
return out;
}
if runners.is_empty() {
if writeln!(out, "None.\n").is_err() {
return out;
}
} else {
for (i, rn) in runners.iter().enumerate() {
if writeln!(out, "{i:>2}: {rn}").is_err() {
return out;
}
}
out.push('\n');
}
if let Some(rn) = flash_runner {
if writeln!(out, "Configured flash runner: {rn}").is_err() {
return out;
}
}
out
}
fn cmd_west_boards(runner: Runner) -> Result<(), KazeError> {
log::info("Listing available boards");
let cmd = Cmd::new("west").arg("boards");
runner.run(&cmd)?;
Ok(())
}
fn cmd_workspace(runner: Runner, rc: &ResolvedConfig, cmd: &WsCommand) -> Result<(), KazeError> {
log::info("Managing zephyr workspace");
if rc.zephyr_url.is_some() && rc.zephyr_manifest.is_some() {
return Err(KazeError::Msg(
"Conflicting west workspace config: both manifest repo url and local manifest file \
path are set."
.to_owned(),
));
}
if let Some(ws) = rc.zephyr_ws.as_deref() {
match *cmd {
WsCommand::Init(ref i) => return cmd_ws_init(runner, ws, rc, i.force),
WsCommand::Update => return cmd_ws_update(runner, ws),
WsCommand::Export => return cmd_ws_export(runner, ws),
WsCommand::Apply(ref a) => {
return cmd_ws_apply(runner, ws, rc, a.noupdate, a.force);
}
}
}
Err(KazeError::Msg(
"Zephyr workspace could not be resolved.".to_owned(),
))
}
fn is_ws_initialized(ws: &Path) -> bool {
if ws.join(".west").is_dir() {
return true;
}
false
}
fn cmd_ws_init(
runner: Runner,
ws: &Path,
rc: &ResolvedConfig,
force: bool,
) -> Result<(), KazeError> {
if is_ws_initialized(ws) {
if !force {
return Err(KazeError::Msg(
"Zephyr workspace already initialized.".to_owned(),
));
}
clean_dir_safe(&ws.join(".west"), ws, runner.dry_run)?;
}
let mut cmd = Cmd::new("west").arg("init");
if let Some(url) = rc.zephyr_url.as_deref() {
cmd = cmd.arg("-m").arg(url).arg(ws);
} else if let Some(manifest) = rc.zephyr_manifest.as_deref() {
if let Some(path) = manifest.parent()
&& let Some(file) = manifest.file_name()
{
cmd = cmd.arg("-l").arg("--mf").arg(file).arg(path);
} else {
return Err(KazeError::Msg("Invalid manifest path".to_owned()));
}
} else {
return Err(KazeError::Msg(
"No manifest repo url or manifest file path specified".to_owned(),
));
}
runner.run_with_spinner(&cmd, "Initializing zephyr workspace")?;
Ok(())
}
fn cmd_ws_update(runner: Runner, ws: &Path) -> Result<(), KazeError> {
let cmd = Cmd::new("west").arg("update").cwd(ws);
runner.run_with_spinner(&cmd, "Updating zephyr workspace")?;
Ok(())
}
fn cmd_ws_export(runner: Runner, ws: &Path) -> Result<(), KazeError> {
let cmd = Cmd::new("west").arg("zephyr-export").cwd(ws);
runner.run_with_spinner(&cmd, "Exporting zephyr workspace")?;
Ok(())
}
fn cmd_ws_apply(
runner: Runner,
ws: &Path,
rc: &ResolvedConfig,
noupdate: bool,
force: bool,
) -> Result<(), KazeError> {
log::info("Applying zephyr workspace");
if !is_ws_initialized(ws) {
cmd_ws_init(runner, ws, rc, force)?;
}
if !noupdate {
cmd_ws_update(runner, ws)?;
}
cmd_ws_export(runner, ws)?;
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 {
log::info(format!("Cleaning {}", target.display()));
} else {
fs::remove_dir_all(&target)?;
log::info(format!("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::{BuildCfg, ProfileCfg, ProjectCfg};
use crate::core::cli::{Cli, Command, PhaseArgs, RunArgs, WsApplyArgs, WsInitArgs};
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,
zephyr_url: None,
zephyr_manifest: None,
bom_build: false,
bom_v: "2".into(),
args_conf: Vec::new(),
args_build: Vec::new(),
args_flash: Vec::new(),
args_run: Vec::new(),
}
}
fn make_file_cfg() -> FileConfig {
FileConfig {
project: ProjectCfg {
default_profile: Some("dev".into()),
..ProjectCfg::default()
},
build: BuildCfg {
root: "build".into(),
link_compile_commands: Some(true),
},
..FileConfig::default()
}
}
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");
let mut rc = make_rc(&dir, dir.join("build"));
rc.board = "native_sim".into();
cmd_init(&rc, false).expect("init ok");
assert!(dir.join("kaze.toml").is_file());
let err = cmd_init(&rc, false).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 cmd_workspace_errors_on_conflicting_manifest_config() {
let dir = make_temp_dir("ws-conflict");
let build_dir = dir.join("build");
let mut rc = make_rc(&dir, build_dir);
rc.zephyr_ws = Some(dir.join("ws"));
rc.zephyr_url = Some("https://example.com/zephyr".into());
rc.zephyr_manifest = Some(PathBuf::from("west.yml"));
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = cmd_workspace(runner, &rc, &WsCommand::Update).expect_err("should fail");
assert!(
matches!(err, KazeError::Msg(ref msg) if msg.contains("Conflicting west workspace config"))
);
}
#[test]
fn cmd_workspace_errors_when_workspace_missing() {
let dir = make_temp_dir("ws-missing");
let build_dir = dir.join("build");
let rc = make_rc(&dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = cmd_workspace(runner, &rc, &WsCommand::Update).expect_err("should fail");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("could not be resolved")));
}
#[test]
fn cmd_workspace_update_succeeds_with_dry_run() {
let dir = make_temp_dir("ws-update");
let ws = dir.join("ws");
fs::create_dir_all(ws.join(".west")).expect("mkdir .west");
let build_dir = dir.join("build");
let mut rc = make_rc(&dir, build_dir);
rc.zephyr_ws = Some(ws);
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_workspace(runner, &rc, &WsCommand::Update).expect("update ok");
}
#[test]
fn ws_initialized_detects_west_dir() {
let dir = make_temp_dir("ws-init-check");
let ws = dir.join("ws");
fs::create_dir_all(ws.join(".west")).expect("mkdir .west");
assert!(is_ws_initialized(&ws));
let ws_missing = dir.join("ws-missing");
fs::create_dir_all(&ws_missing).expect("mkdir ws");
assert!(!is_ws_initialized(&ws_missing));
}
#[test]
fn cmd_ws_init_errors_when_initialized_without_force() {
let dir = make_temp_dir("ws-init-no-force");
let ws = dir.join("ws");
let ws_init = ws.join(".west");
fs::create_dir_all(&ws_init).expect("mkdir .west");
let mut rc = make_rc(&dir, dir.join("build"));
rc.zephyr_ws = Some(ws.clone());
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = cmd_ws_init(runner, &ws, &rc, false).expect_err("should fail");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("already initialized")));
}
#[test]
fn cmd_ws_init_force_removes_west_dir() {
let dir = make_temp_dir("ws-init-force");
let ws = dir.join("ws");
let ws_init = ws.join(".west");
fs::create_dir_all(&ws_init).expect("mkdir .west");
let mut rc = make_rc(&dir, dir.join("build"));
rc.zephyr_ws = Some(ws.clone());
rc.zephyr_url = Some("https://example.com/zephyr".into());
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_ws_init(runner, &ws, &rc, true).expect("init ok");
assert!(ws_init.exists());
}
#[test]
fn cmd_ws_export_and_apply_succeed_with_dry_run() {
let dir = make_temp_dir("ws-export-apply");
let ws = dir.join("ws");
fs::create_dir_all(ws.join(".west")).expect("mkdir .west");
let mut rc = make_rc(&dir, dir.join("build"));
rc.zephyr_ws = Some(ws.clone());
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_ws_export(runner, &ws).expect("export ok");
cmd_ws_apply(runner, &ws, &rc, false, false).expect("apply ok");
}
#[test]
fn cmd_workspace_branches_init_export_apply() {
let dir = make_temp_dir("ws-branches");
let ws = dir.join("ws");
fs::create_dir_all(ws.join(".west")).expect("mkdir .west");
let mut rc = make_rc(&dir, dir.join("build"));
rc.zephyr_ws = Some(ws);
rc.zephyr_url = Some("https://example.com/zephyr".into());
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_workspace(runner, &rc, &WsCommand::Init(WsInitArgs { force: true }))
.expect("workspace init ok");
cmd_workspace(runner, &rc, &WsCommand::Export).expect("workspace export ok");
cmd_workspace(
runner,
&rc,
&WsCommand::Apply(WsApplyArgs {
force: false,
noupdate: true,
}),
)
.expect("workspace apply ok");
}
#[test]
fn cmd_menuconfig_sysbuild_runs_with_image() {
let dir = make_temp_dir("menuconfig-sysbuild");
let build_dir = dir.join("build");
let image_dir = build_dir.join("app");
fs::create_dir_all(&image_dir).expect("create image dir");
let zephyr_base = dir.join("zephyr");
fs::create_dir_all(zephyr_base.join("share").join("sysbuild"))
.expect("create sysbuild dir");
let mut rc = make_rc(&dir, build_dir);
rc.sysbuild = true;
rc.zephyr_base = Some(zephyr_base);
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_menuconfig(runner, &rc, false, Some("app".into())).expect("menuconfig ok");
}
#[test]
fn cmd_bom_sysbuild_runs_with_image() {
let dir = make_temp_dir("bom-sysbuild");
let build_dir = dir.join("build");
let image_dir = build_dir.join("app");
fs::create_dir_all(&image_dir).expect("create image dir");
let zephyr_base = dir.join("zephyr");
fs::create_dir_all(zephyr_base.join("share").join("sysbuild"))
.expect("create sysbuild dir");
let mut rc = make_rc(&dir, build_dir);
rc.sysbuild = true;
rc.bom_build = true;
rc.zephyr_base = Some(zephyr_base);
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_bom(runner, &rc, false, Some("app".into())).expect("bom ok");
}
#[test]
fn cmd_run_prefers_zephyr_binary_when_norebuild() {
let dir = make_temp_dir("run-zephyr-direct");
let build_dir = 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(&dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_run(runner, &rc, true, false, None, &Vec::new()).expect("run ok");
}
#[test]
fn merge_args_appends_extra_after_config() {
let config = vec!["--conf_arg_1".to_owned(), "--conf_arg_2".to_owned()];
let extra = vec!["--extra_arg".to_owned()];
let merged = merge_args(&config, &extra);
assert_eq!(
merged,
vec![
"--conf_arg_1".to_owned(),
"--conf_arg_2".to_owned(),
"--extra_arg".to_owned()
]
);
}
#[test]
fn merge_args_skips_empty_and_preserves_order() {
let config = vec!["a".to_owned(), " ".to_owned(), "b".to_owned()];
let extra = vec![String::new(), "c".to_owned(), "\t".to_owned()];
let merged = merge_args(&config, &extra);
assert_eq!(merged, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
}
#[test]
fn push_filtered_args_skips_empty() {
let mut cmd = Cmd::new("tool");
let extra = vec![String::new(), " ".to_owned(), "arg".to_owned()];
push_filtered_args(&mut cmd, &extra);
assert_eq!(cmd.args, vec![OsString::from("arg")]);
}
#[test]
fn compile_commands_link_uses_default_profile() {
let project_dir = make_temp_dir("compile-default");
let build_dir = project_dir.join("build").join("dev");
fs::create_dir_all(&build_dir).expect("create build dir");
let mut cfg = make_file_cfg();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
cfg.profiles.insert("prod".into(), ProfileCfg::default());
let mut rc = make_rc(&project_dir, build_dir);
rc.profile = Some("dev".into());
ensure_compile_commands_link(&rc, &cfg).expect("link ok");
let link = rc.build_root.join("compile_commands.json");
let meta = fs::symlink_metadata(&link).expect("link meta");
assert!(meta.file_type().is_symlink());
}
#[test]
fn compile_commands_link_falls_back_to_first_profile() {
let project_dir = make_temp_dir("compile-first");
let build_dir = project_dir.join("build").join("alpha");
fs::create_dir_all(&build_dir).expect("create build dir");
let mut cfg = make_file_cfg();
cfg.project.default_profile = None;
cfg.profiles.insert("alpha".into(), ProfileCfg::default());
cfg.profiles.insert("beta".into(), ProfileCfg::default());
let mut rc = make_rc(&project_dir, build_dir);
rc.profile = Some("alpha".into());
ensure_compile_commands_link(&rc, &cfg).expect("link ok");
let link = rc.build_root.join("compile_commands.json");
let meta = fs::symlink_metadata(&link).expect("link meta");
assert!(meta.file_type().is_symlink());
}
#[test]
fn compile_commands_link_skips_without_profiles() {
let project_dir = make_temp_dir("compile-no-profiles");
let build_dir = project_dir.join("build").join("dev");
fs::create_dir_all(&build_dir).expect("create build dir");
let cfg = FileConfig::default();
let mut rc = make_rc(&project_dir, build_dir);
rc.profile = Some("dev".into());
ensure_compile_commands_link(&rc, &cfg).expect("link ok");
let link = rc.build_root.join("compile_commands.json");
assert!(!link.exists());
}
#[test]
fn compile_commands_link_skips_without_default_or_first_profile() {
let project_dir = make_temp_dir("compile-no-default");
let build_dir = project_dir.join("build").join("dev");
fs::create_dir_all(&build_dir).expect("create build dir");
let mut cfg = make_file_cfg();
cfg.project.default_profile = Some("missing".into());
cfg.profiles.insert("dev".into(), ProfileCfg::default());
let mut rc = make_rc(&project_dir, build_dir);
rc.profile = Some("dev".into());
ensure_compile_commands_link(&rc, &cfg).expect("link ok");
let link = rc.build_root.join("compile_commands.json");
assert!(!link.exists());
}
#[test]
fn compile_commands_link_skips_when_disabled() {
let project_dir = make_temp_dir("compile-disabled");
let build_dir = project_dir.join("build").join("dev");
fs::create_dir_all(&build_dir).expect("create build dir");
let mut cfg = make_file_cfg();
cfg.build.link_compile_commands = Some(false);
cfg.profiles.insert("dev".into(), ProfileCfg::default());
let mut rc = make_rc(&project_dir, build_dir);
rc.profile = Some("dev".into());
ensure_compile_commands_link(&rc, &cfg).expect("link ok");
let link = rc.build_root.join("compile_commands.json");
assert!(!link.exists());
}
#[test]
fn compile_commands_link_replaces_existing_file() {
let project_dir = make_temp_dir("compile-replace");
let build_dir = project_dir.join("build").join("dev");
fs::create_dir_all(&build_dir).expect("create build dir");
let mut cfg = make_file_cfg();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
let mut rc = make_rc(&project_dir, build_dir);
rc.profile = Some("dev".into());
let link = rc.build_root.join("compile_commands.json");
fs::create_dir_all(&rc.build_root).expect("create build root");
fs::write(&link, "stale").expect("write file");
ensure_compile_commands_link(&rc, &cfg).expect("link ok");
let meta = fs::symlink_metadata(&link).expect("link meta");
assert!(meta.file_type().is_symlink());
}
#[test]
fn compile_commands_link_skips_when_existing_symlink() {
let project_dir = make_temp_dir("compile-symlink");
let build_dir = project_dir.join("build").join("dev");
fs::create_dir_all(&build_dir).expect("create build dir");
let mut cfg = make_file_cfg();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
let mut rc = make_rc(&project_dir, build_dir);
rc.profile = Some("dev".into());
let link = rc.build_root.join("compile_commands.json");
fs::create_dir_all(&rc.build_root).expect("create build root");
let target = rc.build_root.join("dev").join("compile_commands.json");
fs::write(&target, "data").expect("write target");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &link).expect("create symlink");
#[cfg(windows)]
std::os::windows::fs::symlink_file(&target, &link).expect("create symlink");
ensure_compile_commands_link(&rc, &cfg).expect("link ok");
let meta = fs::symlink_metadata(&link).expect("link meta");
assert!(meta.file_type().is_symlink());
}
#[test]
fn compile_commands_link_skips_when_existing_dir() {
let project_dir = make_temp_dir("compile-dir");
let build_dir = project_dir.join("build").join("dev");
fs::create_dir_all(&build_dir).expect("create build dir");
let mut cfg = make_file_cfg();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
let mut rc = make_rc(&project_dir, build_dir);
rc.profile = Some("dev".into());
let link = rc.build_root.join("compile_commands.json");
fs::create_dir_all(&link).expect("create dir");
ensure_compile_commands_link(&rc, &cfg).expect("link ok");
assert!(link.is_dir());
}
#[test]
fn resolve_default_profile_handles_default_and_fallback() {
let mut cfg = make_file_cfg();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
cfg.profiles.insert("prod".into(), ProfileCfg::default());
assert_eq!(resolve_default_profile(&cfg), Some("dev".into()));
cfg.project.default_profile = Some("missing".into());
assert_eq!(resolve_default_profile(&cfg), Some("dev".into()));
}
#[test]
fn kaze_error_from_conversions() {
let io_err = std::io::Error::other("io");
let kaze_err: KazeError = io_err.into();
assert!(matches!(kaze_err, KazeError::Io(_)));
let exec_err = ExecError::from(std::io::Error::other("exec"));
let kaze_exec_err: KazeError = exec_err.into();
assert!(matches!(kaze_exec_err, KazeError::Exec(_)));
let load_err = LoadError::Io {
path: PathBuf::from("kaze.toml"),
source: std::io::Error::other("load"),
};
let kaze_load_err: KazeError = load_err.into();
assert!(matches!(kaze_load_err, KazeError::Load(_)));
let resolve_err = ResolveError::NoBoardResolved;
let kaze_resolve_err: KazeError = resolve_err.into();
assert!(matches!(kaze_resolve_err, KazeError::Resolve(_)));
let yaml_err = serde_yaml::from_str::<serde_yaml::Value>("[").expect_err("invalid yaml");
let kaze_yaml_err: KazeError = yaml_err.into();
assert!(
matches!(kaze_yaml_err, KazeError::Msg(ref msg) if msg.contains("failed to load yaml"))
);
}
#[test]
fn build_conf_cmd_sysbuild_uses_app_dir_and_sysbuild_source() {
let project_dir = make_temp_dir("conf-sysbuild");
let build_dir = project_dir.join("build");
let zephyr_base = project_dir.join("zephyr");
let sysbuild_dir = zephyr_base.join("share").join("sysbuild");
fs::create_dir_all(&sysbuild_dir).expect("create sysbuild dir");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
rc.zephyr_base = Some(zephyr_base);
let cmd = build_conf_cmd(&rc, &[]).expect("build cmd");
let args: Vec<String> = cmd
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert!(args.contains(&"-S".to_owned()));
assert!(args.contains(&sysbuild_dir.display().to_string()));
assert!(args.contains(&format!("-DAPP_DIR={}", project_dir.display())));
}
#[test]
fn build_conf_cmd_non_sysbuild_uses_project_source() {
let project_dir = make_temp_dir("conf-non-sysbuild");
let build_dir = project_dir.join("build");
let rc = make_rc(&project_dir, build_dir);
let cmd = build_conf_cmd(&rc, &[]).expect("build cmd");
let args: Vec<String> = cmd
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert!(args.contains(&"-S".to_owned()));
assert!(args.contains(&project_dir.display().to_string()));
assert!(!args.iter().any(|a| a.starts_with("-DAPP_DIR=")));
}
#[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 clean_dir_safe_removes_dir() {
let project_dir = make_temp_dir("clean-remove");
let build_dir = project_dir.join("build");
fs::create_dir_all(&build_dir).expect("create build dir");
fs::write(build_dir.join("file.txt"), "data").expect("write file");
clean_dir_safe(&build_dir, &project_dir, false).expect("remove ok");
assert!(!build_dir.exists());
}
#[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 get_cache_var_skips_malformed_lines() {
let build_dir = make_temp_dir("cmake-malformed");
fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
fs::write(
build_dir.join("CMakeCache.txt"),
"NOEQ\nNOCOLON=VALUE\nGOOD:STRING=ok\n",
)
.expect("write cache");
assert_eq!(
get_cache_var(&build_dir, "GOOD").expect("read cache"),
Some("ok".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, false, None, &[]).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, false, None, &[]).expect("run ok");
}
#[test]
fn cmd_flash_uses_runner_when_set() {
let project_dir = make_temp_dir("cmd-flash-runner");
let build_dir = project_dir.join("build");
let mut rc = make_rc(&project_dir, build_dir);
rc.runner = Some("openocd".into());
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_flash(runner, &rc, false, None, &["--foo".into()]).expect("flash ok");
}
#[test]
fn cmd_flash_allows_no_runner() {
let project_dir = make_temp_dir("cmd-flash-no-runner");
let build_dir = project_dir.join("build");
let rc = make_rc(&project_dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_flash(runner, &rc, false, None, &[]).expect("flash ok");
}
#[test]
fn cmd_flash_list_errors_for_non_sysbuild() {
let project_dir = make_temp_dir("cmd-flash-list");
let build_dir = project_dir.join("build");
let rc = make_rc(&project_dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = cmd_flash(runner, &rc, true, None, &[]).expect_err("list should error");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
}
#[test]
fn cmd_flash_list_sysbuild_succeeds() {
let project_dir = make_temp_dir("cmd-flash-list-sysbuild");
let build_dir = project_dir.join("build");
let zephyr_base = project_dir.join("zephyr");
fs::create_dir_all(zephyr_base.join("share").join("sysbuild"))
.expect("create sysbuild dir");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
fs::create_dir_all(build_dir.join("net")).expect("create net dir");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
rc.zephyr_base = Some(zephyr_base);
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_flash(runner, &rc, true, None, &[]).expect("list ok");
}
#[test]
fn cmd_bom_list_errors_for_non_sysbuild() {
let project_dir = make_temp_dir("cmd-bom-list");
let build_dir = project_dir.join("build");
let rc = make_rc(&project_dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = cmd_bom(runner, &rc, true, None).expect_err("list should error");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
}
#[test]
fn cmd_bom_image_errors_for_non_sysbuild() {
let project_dir = make_temp_dir("cmd-bom-image");
let build_dir = project_dir.join("build");
let rc = make_rc(&project_dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = cmd_bom(runner, &rc, false, Some("app".into())).expect_err("image error");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
}
#[test]
fn cmd_bom_list_sysbuild_succeeds() {
let project_dir = make_temp_dir("cmd-bom-list-sysbuild");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_bom(runner, &rc, true, None).expect("list ok");
}
#[test]
fn cmd_menuconfig_list_errors_for_non_sysbuild() {
let project_dir = make_temp_dir("cmd-menuconfig-list");
let build_dir = project_dir.join("build");
let rc = make_rc(&project_dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = cmd_menuconfig(runner, &rc, true, None).expect_err("list should error");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
}
#[test]
fn cmd_menuconfig_list_sysbuild_succeeds() {
let project_dir = make_temp_dir("cmd-menuconfig-list-sysbuild");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
rc.zephyr_base = Some(project_dir.join("zephyr"));
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_menuconfig(runner, &rc, true, None).expect("list ok");
}
#[test]
fn cmd_menuconfig_sysbuild_accepts_explicit_image() {
let project_dir = make_temp_dir("cmd-menuconfig-image");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
rc.zephyr_base = Some(project_dir.join("zephyr"));
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_menuconfig(runner, &rc, false, Some("app".into())).expect("menuconfig ok");
}
#[test]
fn cmd_menuconfig_sysbuild_defaults_to_first_image() {
let project_dir = make_temp_dir("cmd-menuconfig-default");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
fs::create_dir_all(build_dir.join("net")).expect("create net dir");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
rc.zephyr_base = Some(project_dir.join("zephyr"));
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_menuconfig(runner, &rc, false, None).expect("menuconfig ok");
}
#[test]
fn ensure_bom_conf_errors_when_not_configured() {
let project_dir = make_temp_dir("bom-conf-missing");
let build_dir = project_dir.join("build");
let rc = make_rc(&project_dir, build_dir.clone());
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = ensure_bom_conf(runner, &rc, &build_dir).expect_err("missing bom config");
assert!(
matches!(err, KazeError::Msg(ref msg) if msg.contains("BoM generation not configured"))
);
}
#[test]
fn cmd_run_list_errors_for_non_sysbuild() {
let project_dir = make_temp_dir("cmd-run-list");
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,
};
let err = cmd_run(runner, &rc, true, true, None, &[]).expect_err("list should error");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
}
#[test]
fn cmd_run_image_errors_for_non_sysbuild() {
let project_dir = make_temp_dir("cmd-run-image");
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,
};
let err =
cmd_run(runner, &rc, true, false, Some("app".into()), &[]).expect_err("image error");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
}
#[test]
fn cmd_flash_image_errors_for_non_sysbuild() {
let project_dir = make_temp_dir("cmd-flash-image");
let build_dir = project_dir.join("build");
let rc = make_rc(&project_dir, build_dir);
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = cmd_flash(runner, &rc, false, Some("app".into()), &[]).expect_err("image error");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
}
#[test]
fn cmd_run_list_sysbuild_succeeds() {
let project_dir = make_temp_dir("cmd-run-list-sysbuild");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_run(runner, &rc, true, true, None, &[]).expect("list ok");
}
#[test]
fn cmd_run_sysbuild_requires_image() {
let project_dir = make_temp_dir("cmd-run-sysbuild-no-image");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_run(runner, &rc, true, false, None, &[]).expect("run ok");
}
#[test]
fn cmd_run_sysbuild_uses_image_dir() {
let project_dir = make_temp_dir("cmd-run-sysbuild-image");
let build_dir = project_dir.join("build");
let image_dir = build_dir.join("app");
let zephyr_dir = image_dir.join("zephyr");
fs::create_dir_all(&zephyr_dir).expect("create zephyr dir");
fs::write(zephyr_dir.join("zephyr"), "").expect("write binary");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_run(runner, &rc, true, false, Some("1".into()), &[]).expect("run ok");
}
#[test]
fn cmd_run_sysbuild_errors_when_no_images() {
let project_dir = make_temp_dir("cmd-run-sysbuild-empty");
let build_dir = project_dir.join("build");
fs::create_dir_all(&build_dir).expect("create build dir");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err =
cmd_run(runner, &rc, true, false, Some("app".into()), &[]).expect_err("no images");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("No sysbuild images")));
}
#[test]
fn cmd_flash_sysbuild_uses_image_dir() {
let project_dir = make_temp_dir("cmd-flash-sysbuild-image");
let build_dir = project_dir.join("build");
let image_dir = build_dir.join("app");
fs::create_dir_all(&image_dir).expect("create image dir");
fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
fs::write(
build_dir.join("CMakeCache.txt"),
"BOARD:STRING=native_sim\n",
)
.expect("write cache");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
let runner = Runner {
verbose: 0,
dry_run: true,
};
cmd_flash(runner, &rc, false, Some("app".into()), &[]).expect("flash ok");
}
#[test]
fn cmd_flash_sysbuild_errors_when_no_images() {
let project_dir = make_temp_dir("cmd-flash-sysbuild-empty");
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=native_sim\n",
)
.expect("write cache");
let mut rc = make_rc(&project_dir, build_dir);
rc.sysbuild = true;
let runner = Runner {
verbose: 0,
dry_run: true,
};
let err = cmd_flash(runner, &rc, false, None, &[]).expect_err("no images");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("No sysbuild images")));
}
#[test]
fn find_images_filters_non_image_dirs() {
let project_dir = make_temp_dir("find-images");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
fs::create_dir_all(build_dir.join("net")).expect("create net dir");
fs::create_dir_all(build_dir.join("zephyr")).expect("create zephyr dir");
fs::create_dir_all(build_dir.join("_sysbuild")).expect("create sysbuild dir");
fs::write(build_dir.join("README.txt"), "").expect("write file");
let images = find_images(&build_dir).expect("find images");
assert_eq!(images, vec!["app".to_owned(), "net".to_owned()]);
}
#[test]
fn resolve_sysbuild_image_defaults_to_non_bootloader() {
let project_dir = make_temp_dir("resolve-default");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("mcuboot")).expect("create mcuboot dir");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
let image = resolve_sysbuild_image(&build_dir, None).expect("resolve image");
assert_eq!(image, "app");
}
#[test]
fn resolve_sysbuild_image_accepts_index_and_name() {
let project_dir = make_temp_dir("resolve-index-name");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
fs::create_dir_all(build_dir.join("net")).expect("create net dir");
let image_index = resolve_sysbuild_image(&build_dir, Some("2".into())).expect("index ok");
assert_eq!(image_index, "net");
let image_name = resolve_sysbuild_image(&build_dir, Some("app".into())).expect("name ok");
assert_eq!(image_name, "app");
}
#[test]
fn resolve_sysbuild_image_rejects_invalid_selection() {
let project_dir = make_temp_dir("resolve-invalid");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
let err_index =
resolve_sysbuild_image(&build_dir, Some("3".into())).expect_err("bad index");
assert!(
matches!(err_index, KazeError::Msg(ref msg) if msg.contains("Invalid sysbuild image index"))
);
let err_name =
resolve_sysbuild_image(&build_dir, Some("nope".into())).expect_err("bad name");
assert!(
matches!(err_name, KazeError::Msg(ref msg) if msg.contains("Unknown sysbuild image"))
);
}
#[test]
fn resolve_sysbuild_image_errors_when_empty() {
let project_dir = make_temp_dir("resolve-empty");
let build_dir = project_dir.join("build");
fs::create_dir_all(&build_dir).expect("create build dir");
let err = resolve_sysbuild_image(&build_dir, None).expect_err("no images");
assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("No sysbuild images")));
}
#[test]
fn resolve_sysbuild_image_rejects_zero_index() {
let project_dir = make_temp_dir("resolve-zero");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("app")).expect("create app dir");
let err = resolve_sysbuild_image(&build_dir, Some("0".into())).expect_err("bad index");
assert!(
matches!(err, KazeError::Msg(ref msg) if msg.contains("Invalid sysbuild image index"))
);
}
#[test]
fn resolve_sysbuild_image_defaults_to_mcuboot_when_only() {
let project_dir = make_temp_dir("resolve-mcuboot");
let build_dir = project_dir.join("build");
fs::create_dir_all(build_dir.join("mcuboot")).expect("create mcuboot dir");
let image = resolve_sysbuild_image(&build_dir, None).expect("resolve image");
assert_eq!(image, "mcuboot");
}
#[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 cmd_runners_formats_empty_and_flash_runner() {
let output = format_runners_output("native_sim", &[], Some("openocd".to_owned()));
assert!(output.contains("None."));
assert!(output.contains("Configured flash runner: openocd"));
}
#[test]
fn cmd_runners_formats_listed_runners() {
let output = format_runners_output(
"native_sim",
&["openocd".to_owned(), "jlink".to_owned()],
None,
);
assert!(output.contains(" 0: openocd"));
assert!(output.contains(" 1: jlink"));
}
#[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(crate::core::cli::InitArgs::default());
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");
}
}