use std::fmt;
use std::path::{Path, PathBuf};
use crate::config::schema::FileConfig;
use crate::core::cli::{Cli, Command};
#[derive(Debug, Clone)]
pub struct ConfigCtx {
pub project_dir: PathBuf,
pub cwd: PathBuf,
pub invoked_from_build: bool,
}
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
NotFound {
start: PathBuf,
},
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::Io(ref e) => write!(f, "config error: {e}"),
Self::NotFound { ref start } => write!(f, "config not found in: {}", start.display()),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match *self {
Self::Io(ref e) => Some(e),
Self::NotFound { .. } => None,
}
}
}
impl From<std::io::Error> for ConfigError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
pub fn discover(project_override: Option<&Path>, cli: &Cli) -> Result<ConfigCtx, ConfigError> {
let cwd = std::env::current_dir()?;
let start = project_override.unwrap_or(&cwd);
let command = &cli.command;
let project_dir = match *command {
Command::Init | Command::Boards => cwd.clone(),
_ => find_upwards(start, "kaze.toml").ok_or_else(|| ConfigError::NotFound {
start: start.to_path_buf(),
})?,
};
let invoked_from_build = cwd.starts_with(project_dir.join("build"));
Ok(ConfigCtx {
project_dir,
cwd,
invoked_from_build,
})
}
#[must_use]
fn find_upwards(start: &Path, marker: &str) -> Option<PathBuf> {
let mut cur = start;
loop {
if cur.join(marker).is_file() {
return Some(cur.to_path_buf());
}
cur = cur.parent()?;
}
}
#[derive(Debug, Clone)]
pub struct ResolvedConfig {
pub ctx: ConfigCtx,
pub profile: Option<String>,
pub board: String,
pub runner: Option<String>,
pub build_root: PathBuf,
pub build_dir: PathBuf,
pub sysbuild: bool,
pub zephyr_ws: Option<PathBuf>,
pub zephyr_base: Option<PathBuf>,
pub args_conf: Vec<String>,
pub args_build: Vec<String>,
pub args_flash: Vec<String>,
pub args_run: Vec<String>,
}
#[derive(Debug)]
pub enum ResolveError {
NoBoardResolved,
UnknownProfile(String),
}
impl fmt::Display for ResolveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::NoBoardResolved => write!(f, "no board resolved"),
Self::UnknownProfile(ref profile) => write!(f, "unknown profile: {profile}"),
}
}
}
impl std::error::Error for ResolveError {}
pub fn resolve_single(
cli: &Cli,
ctx: &ConfigCtx,
cfg: &FileConfig,
) -> Result<ResolvedConfig, ResolveError> {
let profile = select_profile(cli, cfg)?;
let mut board = cfg.project.board.clone();
let mut runner = cfg.project.runner.clone();
if let Some(ref p) = profile {
let prof = cfg
.profiles
.get(p)
.ok_or_else(|| ResolveError::UnknownProfile(p.clone()))?;
if prof.board.is_some() {
board.clone_from(&prof.board);
}
if prof.runner.is_some() {
runner.clone_from(&prof.runner);
}
}
if cli.board.is_some() {
board.clone_from(&cli.board);
}
if cli.runner.is_some() {
runner.clone_from(&cli.runner);
}
let command = &cli.command;
let board_resolved = match *command {
Command::Init | Command::Boards => String::default(),
_ => board.ok_or(ResolveError::NoBoardResolved)?,
};
let build_root = ctx.project_dir.join(&cfg.build.root);
let build_dir = profile
.as_ref()
.map_or_else(|| build_root.clone(), |p| build_root.join(p));
let sysbuild = is_sysbuild(&ctx.project_dir);
let (zephyr_ws, zephyr_base) = discover_zephyr(&ctx.project_dir, cfg);
let mut args_conf = cfg.args.conf.to_vec();
let mut args_build = cfg.args.build.to_vec();
let mut args_flash = cfg.args.flash.to_vec();
let mut args_run = cfg.args.run.to_vec();
if let Some(ref p) = profile {
let prof = cfg.profiles.get(p).expect("checked above");
args_conf.extend(prof.args.conf.to_vec());
args_build.extend(prof.args.build.to_vec());
args_flash.extend(prof.args.flash.to_vec());
args_run.extend(prof.args.run.to_vec());
}
Ok(ResolvedConfig {
ctx: ctx.clone(),
profile,
board: board_resolved,
runner,
build_root,
build_dir,
sysbuild,
zephyr_ws,
zephyr_base,
args_conf,
args_build,
args_flash,
args_run,
})
}
#[must_use]
pub fn resolve_all(
cli: &Cli,
ctx: &ConfigCtx,
cfg: &FileConfig,
) -> Vec<Result<ResolvedConfig, ResolveError>> {
if cfg.profiles.is_empty() {
return vec![resolve_single(cli, ctx, cfg)];
}
cfg.profiles
.keys()
.cloned()
.map(|p| {
let mut cli2 = cli.clone();
cli2.profile = Some(p);
cli2.all = false;
resolve_single(&cli2, ctx, cfg)
})
.collect()
}
fn select_profile(cli: &Cli, cfg: &FileConfig) -> Result<Option<String>, ResolveError> {
if cfg.profiles.is_empty() {
return Ok(None);
}
if cli.all {
return Ok(Some(default_profile(cfg)));
}
if let Some(p) = cli.profile.as_deref() {
if !cfg.profiles.contains_key(p) {
return Err(ResolveError::UnknownProfile(p.to_owned()));
}
return Ok(Some(p.to_owned()));
}
Ok(Some(default_profile(cfg)))
}
fn default_profile(cfg: &FileConfig) -> String {
if let Some(p) = cfg.project.default_profile.as_deref() {
if cfg.profiles.contains_key(p) {
return p.to_owned();
}
}
cfg.profiles.keys().next().expect("non-empty").clone()
}
fn is_sysbuild(project_dir: &Path) -> bool {
project_dir.join("sysbuild.conf").is_file() || project_dir.join("sysbuild").is_dir()
}
fn discover_zephyr(project_dir: &Path, cfg: &FileConfig) -> (Option<PathBuf>, Option<PathBuf>) {
let ws = cfg.zephyr.workspace.as_ref().map(PathBuf::from);
let base = cfg.zephyr.base.as_ref().map(PathBuf::from);
if ws.is_some() || base.is_some() {
return (
ws.clone(),
base.or_else(|| ws.as_ref().map(|w| w.join("zephyr"))),
);
}
if let Some(zb) = std::env::var_os("ZEPHYR_BASE") {
let base_env = PathBuf::from(zb);
let ws_env = base_env
.parent()
.and_then(|p| p.parent().map(std::path::Path::to_path_buf));
return (ws_env, Some(base_env));
}
if let Some(ws_find) = find_zephyr_workspace(project_dir) {
return (Some(ws_find.clone()), Some(ws_find.join("zephyr")));
}
(None, None)
}
fn find_zephyr_workspace(start: &Path) -> Option<PathBuf> {
let mut cur = start;
loop {
if cur.join(".west").is_dir() {
return Some(cur.to_path_buf());
}
cur = cur.parent()?;
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use clap::Parser;
use super::*;
use crate::config::schema::{
ArgAtom, ArgList, BuildCfg, FileConfig, PhaseArgsCfg, ProfileCfg, ProjectCfg, ZephyrCfg,
};
use crate::core::cli::PhaseArgs;
fn make_temp_dir(label: &str) -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let mut dir = std::env::temp_dir();
dir.push(format!(
"nishikaze-core-config-{}-{}",
label,
COUNTER.fetch_add(1, Ordering::Relaxed)
));
if dir.exists() {
std::fs::remove_dir_all(&dir).expect("clean temp dir");
}
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn base_cfg() -> FileConfig {
FileConfig {
project: ProjectCfg {
board: Some("b1".into()),
runner: Some("r1".into()),
default_profile: Some("dev".into()),
name: None,
},
build: BuildCfg {
root: "build".into(),
link_compile_commands: None,
},
zephyr: ZephyrCfg::default(),
args: PhaseArgsCfg::default(),
..FileConfig::default()
}
}
fn ctx() -> ConfigCtx {
ConfigCtx {
project_dir: PathBuf::from("/tmp/project"),
cwd: PathBuf::from("/tmp/project"),
invoked_from_build: false,
}
}
fn cli_with_cmd(command: Command) -> Cli {
let mut cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
cli.command = command;
cli
}
#[test]
fn discover_skips_config_for_init_and_boards() {
let dir = make_temp_dir("discover-init");
let old = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&dir).expect("set cwd");
let cli_init = cli_with_cmd(Command::Init);
let ctx_init = discover(None, &cli_init).expect("discover init ok");
assert_eq!(ctx_init.project_dir, dir);
let cli_boards = cli_with_cmd(Command::Boards);
let ctx_boards = discover(None, &cli_boards).expect("discover boards ok");
assert_eq!(ctx_boards.project_dir, dir);
std::env::set_current_dir(old).expect("restore cwd");
}
#[test]
fn discover_errors_when_config_missing() {
let dir = make_temp_dir("discover-missing");
let cli = cli_with_cmd(Command::Clean);
let err = discover(Some(&dir), &cli).expect_err("missing config");
assert!(matches!(&err, ConfigError::NotFound { .. }));
if let ConfigError::NotFound { start } = err {
assert_eq!(start, dir);
}
}
#[test]
fn find_upwards_finds_marker_and_returns_none_when_absent() {
let dir = make_temp_dir("find-upwards");
std::fs::write(dir.join("kaze.toml"), "").expect("write marker");
let nested = dir.join("a").join("b");
std::fs::create_dir_all(&nested).expect("create nested");
let found = find_upwards(&nested, "kaze.toml");
assert_eq!(found, Some(dir));
let missing = find_upwards(&nested, "not-here");
assert_eq!(missing, None);
}
#[test]
fn select_profile_returns_none_when_no_profiles() {
let cfg = base_cfg();
let cli = cli_with_cmd(Command::Clean);
let selected = select_profile(&cli, &cfg).expect("select ok");
assert_eq!(selected, None);
}
#[test]
fn select_profile_falls_back_when_default_missing() {
let mut cfg = base_cfg();
cfg.project.default_profile = Some("missing".into());
cfg.profiles.insert("prod".into(), ProfileCfg::default());
cfg.profiles.insert("dev".into(), ProfileCfg::default());
let cli = cli_with_cmd(Command::Clean);
let selected = select_profile(&cli, &cfg).expect("select ok");
assert_eq!(selected.as_deref(), Some("prod"));
}
#[test]
fn select_profile_unknown_returns_error() {
let mut cfg = base_cfg();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
let mut cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
cli.profile = Some("missing".into());
let err = select_profile(&cli, &cfg).expect_err("unknown profile");
assert!(matches!(err, ResolveError::UnknownProfile(_)));
}
#[test]
fn select_profile_default_used_when_all_and_profiles_exist() {
let mut cfg = base_cfg();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
cfg.profiles.insert("prod".into(), ProfileCfg::default());
let mut cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
cli.all = true;
let selected = select_profile(&cli, &cfg).expect("select ok");
assert_eq!(selected.as_deref(), Some("dev"));
}
#[test]
fn resolve_single_errors_without_board() {
let mut cfg = base_cfg();
cfg.project.board = None;
cfg.profiles.insert("dev".into(), ProfileCfg::default());
let cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
let err = resolve_single(&cli, &ctx(), &cfg).expect_err("missing board");
assert!(matches!(err, ResolveError::NoBoardResolved));
}
#[test]
fn resolve_single_applies_profile_overrides() {
let mut cfg = base_cfg();
cfg.profiles.insert(
"dev".into(),
ProfileCfg {
board: Some("b2".into()),
runner: Some("r2".into()),
args: PhaseArgsCfg::default(),
},
);
let mut cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
cli.profile = Some("dev".into());
let rc = resolve_single(&cli, &ctx(), &cfg).expect("resolve ok");
assert_eq!(rc.board, "b2");
assert_eq!(rc.runner.as_deref(), Some("r2"));
}
#[test]
fn resolve_single_cli_overrides_profile() {
let mut cfg = base_cfg();
cfg.profiles.insert(
"dev".into(),
ProfileCfg {
board: Some("b2".into()),
runner: Some("r2".into()),
args: PhaseArgsCfg::default(),
},
);
let mut cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
cli.profile = Some("dev".into());
cli.board = Some("b3".into());
cli.runner = Some("r3".into());
let rc = resolve_single(&cli, &ctx(), &cfg).expect("resolve ok");
assert_eq!(rc.board, "b3");
assert_eq!(rc.runner.as_deref(), Some("r3"));
}
#[test]
fn resolve_single_allows_init_without_board() {
let mut cfg = base_cfg();
cfg.project.board = None;
let cli = cli_with_cmd(Command::Init);
let rc = resolve_single(&cli, &ctx(), &cfg).expect("resolve ok");
assert_eq!(rc.board, "");
}
#[test]
fn resolve_single_merges_phase_args() {
let mut cfg = base_cfg();
cfg.args.conf = ArgList(vec![ArgAtom::One("-DCFG=1".into())]);
cfg.args.build = ArgList(vec![ArgAtom::One("-DBUILD=1".into())]);
cfg.args.flash = ArgList(vec![ArgAtom::One("-DFLASH=1".into())]);
cfg.args.run = ArgList(vec![ArgAtom::One("-DRUN=1".into())]);
cfg.profiles.insert(
"dev".into(),
ProfileCfg {
board: None,
runner: None,
args: PhaseArgsCfg {
conf: ArgList(vec![ArgAtom::One("-DCFG=2".into())]),
build: ArgList(vec![ArgAtom::One("-DBUILD=2".into())]),
flash: ArgList(vec![ArgAtom::One("-DFLASH=2".into())]),
run: ArgList(vec![ArgAtom::One("-DRUN=2".into())]),
},
},
);
let mut cli = cli_with_cmd(Command::Build(PhaseArgs::default()));
cli.profile = Some("dev".into());
let rc = resolve_single(&cli, &ctx(), &cfg).expect("resolve ok");
assert_eq!(rc.args_conf, vec!["-DCFG=1", "-DCFG=2"]);
assert_eq!(rc.args_build, vec!["-DBUILD=1", "-DBUILD=2"]);
assert_eq!(rc.args_flash, vec!["-DFLASH=1", "-DFLASH=2"]);
assert_eq!(rc.args_run, vec!["-DRUN=1", "-DRUN=2"]);
}
#[test]
fn resolve_all_resolves_each_profile() {
let mut cfg = base_cfg();
cfg.profiles.insert("dev".into(), ProfileCfg::default());
cfg.profiles.insert(
"prod".into(),
ProfileCfg {
board: Some("b2".into()),
runner: None,
args: PhaseArgsCfg::default(),
},
);
let cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
let results = resolve_all(&cli, &ctx(), &cfg);
assert_eq!(results.len(), 2);
assert!(results.iter().all(Result::is_ok));
}
#[test]
fn resolve_all_handles_no_profiles() {
let cfg = base_cfg();
let cli = cli_with_cmd(Command::Clean);
let results = resolve_all(&cli, &ctx(), &cfg);
assert_eq!(results.len(), 1);
let first = results.into_iter().next().expect("one result");
first.expect("resolve ok");
}
#[test]
fn is_sysbuild_detects_conf_and_dir() {
let dir = make_temp_dir("sysbuild-detect");
assert!(!is_sysbuild(&dir));
std::fs::write(dir.join("sysbuild.conf"), "").expect("write sysbuild.conf");
assert!(is_sysbuild(&dir));
std::fs::remove_file(dir.join("sysbuild.conf")).expect("remove sysbuild.conf");
std::fs::create_dir_all(dir.join("sysbuild")).expect("mkdir sysbuild");
assert!(is_sysbuild(&dir));
}
#[test]
fn discover_zephyr_prefers_explicit_config() {
let dir = make_temp_dir("zephyr-explicit");
let mut cfg = base_cfg();
cfg.zephyr.workspace = Some(dir.join("ws").to_string_lossy().into_owned());
cfg.zephyr.base = Some(dir.join("zb").to_string_lossy().into_owned());
let (ws, base) = discover_zephyr(&dir, &cfg);
assert_eq!(ws, Some(dir.join("ws")));
assert_eq!(base, Some(dir.join("zb")));
}
#[test]
fn discover_zephyr_uses_env_base() {
let dir = make_temp_dir("zephyr-env");
let base = dir.join("west").join("zephyr");
std::fs::create_dir_all(&base).expect("mkdir zephyr base");
let old = std::env::var_os("ZEPHYR_BASE");
unsafe {
std::env::set_var("ZEPHYR_BASE", &base);
}
let cfg = base_cfg();
let (ws, zb) = discover_zephyr(&dir, &cfg);
assert_eq!(zb, Some(base.clone()));
let expected_ws = base
.parent()
.expect("base has parent")
.parent()
.expect("base has grandparent")
.to_path_buf();
assert_eq!(ws, Some(expected_ws));
if let Some(old) = old {
unsafe {
std::env::set_var("ZEPHYR_BASE", old);
}
} else {
unsafe {
std::env::remove_var("ZEPHYR_BASE");
}
}
}
#[test]
fn discover_zephyr_finds_workspace() {
let dir = make_temp_dir("zephyr-workspace");
let ws = dir.join("ws");
std::fs::create_dir_all(ws.join(".west")).expect("mkdir .west");
let cfg = base_cfg();
let (found_ws, found_base) = discover_zephyr(&ws, &cfg);
assert_eq!(found_ws, Some(ws.clone()));
assert_eq!(found_base, Some(ws.join("zephyr")));
}
#[test]
fn discover_zephyr_none_when_absent() {
let dir = make_temp_dir("zephyr-none");
let cfg = base_cfg();
let (ws, base) = discover_zephyr(&dir, &cfg);
assert_eq!(ws, None);
assert_eq!(base, None);
}
}