use indexmap::IndexMap;
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Phase {
Conf,
Build,
Flash,
Run,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FileConfig {
#[serde(default)]
pub project: ProjectCfg,
#[serde(default)]
pub zephyr: ZephyrCfg,
#[serde(default)]
pub build: BuildCfg,
#[serde(default)]
pub bom: BomCfg,
#[serde(default, alias = "profile")]
pub profiles: IndexMap<String, ProfileCfg>,
}
impl FileConfig {
#[must_use]
pub fn overlay(mut self, rhs: Self) -> Self {
self.project = self.project.overlay(rhs.project);
self.zephyr = self.zephyr.overlay(rhs.zephyr);
self.build = self.build.overlay(rhs.build);
self.bom = self.bom.overlay(rhs.bom);
for (name, prof_rhs) in rhs.profiles {
match self.profiles.get_mut(&name) {
Some(prof_lhs) => {
*prof_lhs = prof_lhs.clone().overlay(prof_rhs);
}
None => {
self.profiles.insert(name, prof_rhs);
}
}
}
self
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ProjectCfg {
pub board: Option<String>,
pub runner: Option<String>,
pub default_profile: Option<String>,
pub name: Option<String>,
#[serde(default)]
pub args: PhaseArgsCfg,
}
impl ProjectCfg {
#[must_use]
pub fn overlay(mut self, rhs: Self) -> Self {
if rhs.board.is_some() {
self.board = rhs.board;
}
if rhs.runner.is_some() {
self.runner = rhs.runner;
}
if rhs.default_profile.is_some() {
self.default_profile = rhs.default_profile;
}
if rhs.name.is_some() {
self.name = rhs.name;
}
self.args = self.args.overlay(rhs.args);
self
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ZephyrCfg {
pub workspace: Option<String>,
pub base: Option<String>,
pub url: Option<String>,
pub manifest: Option<String>,
}
impl ZephyrCfg {
#[must_use]
pub fn overlay(mut self, rhs: Self) -> Self {
if rhs.workspace.is_some() {
self.workspace = rhs.workspace;
}
if rhs.base.is_some() {
self.base = rhs.base;
}
if rhs.url.is_some() {
self.url = rhs.url;
}
if self.manifest.is_some() {
self.manifest = rhs.manifest;
}
self
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct BuildCfg {
#[serde(default = "default_build_root")]
pub root: String,
#[serde(default = "default_link_compile_commands")]
pub link_compile_commands: Option<bool>,
}
fn default_build_root() -> String {
"build".into()
}
#[allow(clippy::unnecessary_wraps)]
const fn default_link_compile_commands() -> Option<bool> {
Some(true)
}
impl BuildCfg {
#[must_use]
pub fn overlay(mut self, rhs: Self) -> Self {
if !rhs.root.is_empty() {
self.root = rhs.root;
}
if rhs.link_compile_commands.is_some() {
self.link_compile_commands = rhs.link_compile_commands;
}
self
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct BomCfg {
#[serde(default = "default_bom_build")]
pub build: bool,
#[serde(default = "default_bom_v")]
pub version: String,
}
impl Default for BomCfg {
fn default() -> Self {
Self {
build: default_bom_build(),
version: default_bom_v(),
}
}
}
const fn default_bom_build() -> bool {
false
}
fn default_bom_v() -> String {
"2.2".into()
}
impl BomCfg {
#[must_use]
pub fn overlay(mut self, rhs: Self) -> Self {
let Self { build, version } = rhs;
self.build = build;
if !version.is_empty() {
self.version = version;
}
self
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ProfileCfg {
pub board: Option<String>,
pub runner: Option<String>,
#[serde(default)]
pub args: PhaseArgsCfg,
}
impl ProfileCfg {
#[must_use]
pub fn overlay(mut self, rhs: Self) -> Self {
if rhs.board.is_some() {
self.board = rhs.board;
}
if rhs.runner.is_some() {
self.runner = rhs.runner;
}
self.args = self.args.overlay(rhs.args);
self
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PhaseArgsCfg {
#[serde(default)]
pub conf: ArgList,
#[serde(default)]
pub build: ArgList,
#[serde(default)]
pub flash: ArgList,
#[serde(default)]
pub run: ArgList,
}
impl PhaseArgsCfg {
#[must_use]
pub fn overlay(mut self, rhs: Self) -> Self {
if !rhs.conf.0.is_empty() {
self.conf = rhs.conf;
}
if !rhs.build.0.is_empty() {
self.build = rhs.build;
}
if !rhs.flash.0.is_empty() {
self.flash = rhs.flash;
}
if !rhs.run.0.is_empty() {
self.run = rhs.run;
}
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ArgList(pub Vec<ArgAtom>);
impl<'de> Deserialize<'de> for ArgList {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Helper {
One(String),
Many(Vec<ArgAtom>),
}
match Helper::deserialize(deserializer)? {
Helper::One(s) => Ok(Self(vec![ArgAtom::One(s)])),
Helper::Many(v) => Ok(Self(v)),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ArgAtom {
One(String),
Many(Vec<String>),
}
impl ArgList {
#[must_use]
pub fn to_vec(&self) -> Vec<String> {
let mut out = Vec::new();
for a in &self.0 {
match *a {
ArgAtom::One(ref s) => out.push(s.clone()),
ArgAtom::Many(ref v) => out.extend(v.iter().cloned()),
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arglist_to_vec_flattens() {
let list = ArgList(vec![
ArgAtom::One("a".to_owned()),
ArgAtom::Many(vec!["b".to_owned(), "c".to_owned()]),
]);
assert_eq!(list.to_vec(), vec!["a", "b", "c"]);
}
#[test]
fn arglist_deserialize_single_and_list() {
let toml = r#"
[project.args]
conf = ["-DCONF=1"]
build = [["-DOPT=1", "-DDBG=1"]]
"#;
let cfg: FileConfig = toml::from_str(toml).expect("parse ok");
assert_eq!(cfg.project.args.conf.to_vec(), vec!["-DCONF=1"]);
assert_eq!(cfg.project.args.build.to_vec(), vec!["-DOPT=1", "-DDBG=1"]);
}
#[test]
fn arglist_deserialize_single_string() {
let toml = r#"
[project.args]
conf = "-DCONF=1"
"#;
let cfg: FileConfig = toml::from_str(toml).expect("parse ok");
assert_eq!(cfg.project.args.conf.to_vec(), vec!["-DCONF=1"]);
}
#[test]
fn project_cfg_overlay_prefers_rhs() {
let lhs = ProjectCfg {
board: Some("b1".into()),
runner: Some("r1".into()),
default_profile: Some("p1".into()),
name: Some("n1".into()),
args: PhaseArgsCfg::default(),
};
let rhs = ProjectCfg {
board: Some("b2".into()),
runner: None,
default_profile: Some("p2".into()),
name: None,
args: PhaseArgsCfg {
conf: ArgList(vec![ArgAtom::One("-DCFG=1".into())]),
..PhaseArgsCfg::default()
},
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.board, Some("b2".into()));
assert_eq!(merged.runner, Some("r1".into()));
assert_eq!(merged.default_profile, Some("p2".into()));
assert_eq!(merged.name, Some("n1".into()));
assert_eq!(merged.args.conf.to_vec(), vec!["-DCFG=1"]);
}
#[test]
fn project_cfg_overlay_overrides_name() {
let lhs = ProjectCfg {
board: None,
runner: None,
default_profile: None,
name: Some("lhs".into()),
args: PhaseArgsCfg::default(),
};
let rhs = ProjectCfg {
board: None,
runner: None,
default_profile: None,
name: Some("rhs".into()),
args: PhaseArgsCfg::default(),
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.name, Some("rhs".into()));
}
#[test]
fn zephyr_cfg_overlay_prefers_rhs() {
let lhs = ZephyrCfg {
workspace: Some("ws1".into()),
base: Some("zb1".into()),
url: None,
manifest: None,
};
let rhs = ZephyrCfg {
workspace: None,
base: Some("zb2".into()),
url: None,
manifest: None,
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.workspace, Some("ws1".into()));
assert_eq!(merged.base, Some("zb2".into()));
}
#[test]
fn zephyr_cfg_overlay_overrides_workspace() {
let lhs = ZephyrCfg {
workspace: None,
base: None,
url: None,
manifest: None,
};
let rhs = ZephyrCfg {
workspace: Some("ws2".into()),
base: Some("zb2".into()),
url: None,
manifest: None,
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.workspace, Some("ws2".into()));
assert_eq!(merged.base, Some("zb2".into()));
}
#[test]
fn zephyr_cfg_deserialize_workspace_fields() {
let toml = r#"
[zephyr]
workspace = "ws"
base = "zb"
url = "https://example.com/zephyr"
manifest = "west.yml"
"#;
let cfg: FileConfig = toml::from_str(toml).expect("parse ok");
assert_eq!(cfg.zephyr.workspace.as_deref(), Some("ws"));
assert_eq!(cfg.zephyr.base.as_deref(), Some("zb"));
assert_eq!(
cfg.zephyr.url.as_deref(),
Some("https://example.com/zephyr")
);
assert_eq!(cfg.zephyr.manifest.as_deref(), Some("west.yml"));
}
#[test]
fn zephyr_cfg_overlay_overrides_url() {
let lhs = ZephyrCfg {
workspace: None,
base: None,
url: Some("lhs".into()),
manifest: None,
};
let rhs = ZephyrCfg {
workspace: None,
base: None,
url: Some("rhs".into()),
manifest: None,
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.url, Some("rhs".into()));
}
#[test]
fn zephyr_cfg_overlay_keeps_url_when_rhs_none() {
let lhs = ZephyrCfg {
workspace: None,
base: None,
url: Some("lhs".into()),
manifest: None,
};
let rhs = ZephyrCfg {
workspace: None,
base: None,
url: None,
manifest: None,
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.url, Some("lhs".into()));
}
#[test]
fn zephyr_cfg_overlay_manifest_current_behavior() {
let lhs = ZephyrCfg {
workspace: None,
base: None,
url: None,
manifest: Some("lhs.yml".into()),
};
let rhs_none = ZephyrCfg {
workspace: None,
base: None,
url: None,
manifest: None,
};
let merged_none = lhs.clone().overlay(rhs_none);
assert_eq!(merged_none.manifest, None);
let rhs_some = ZephyrCfg {
workspace: None,
base: None,
url: None,
manifest: Some("rhs.yml".into()),
};
let merged_some = ZephyrCfg {
manifest: None,
..lhs
}
.overlay(rhs_some);
assert_eq!(merged_some.manifest, None);
}
#[test]
fn bom_cfg_defaults_from_toml() {
let cfg: FileConfig = toml::from_str("").expect("parse ok");
assert!(!cfg.bom.build);
assert_eq!(cfg.bom.version, "2.2");
}
#[test]
fn bom_cfg_overlay_prefers_rhs_when_set() {
let lhs = BomCfg {
build: false,
version: "2".into(),
};
let rhs = BomCfg {
build: true,
version: "3".into(),
};
let merged = lhs.overlay(rhs);
assert!(merged.build);
assert_eq!(merged.version, "3");
}
#[test]
fn bom_cfg_overlay_keeps_lhs_version_when_rhs_empty() {
let lhs = BomCfg {
build: false,
version: "2".into(),
};
let rhs = BomCfg {
build: true,
version: String::new(),
};
let merged = lhs.overlay(rhs);
assert!(merged.build);
assert_eq!(merged.version, "2");
}
#[test]
fn build_cfg_overlay_respects_root_and_flags() {
let lhs = BuildCfg {
root: "build".into(),
link_compile_commands: Some(false),
};
let rhs = BuildCfg {
root: String::new(),
link_compile_commands: Some(true),
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.root, "build");
assert_eq!(merged.link_compile_commands, Some(true));
}
#[test]
fn build_cfg_overlay_overrides_root() {
let lhs = BuildCfg {
root: "build".into(),
link_compile_commands: Some(true),
};
let rhs = BuildCfg {
root: "out".into(),
link_compile_commands: None,
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.root, "out");
assert_eq!(merged.link_compile_commands, Some(true));
}
#[test]
fn build_cfg_defaults_root_when_missing() {
let cfg: BuildCfg = toml::from_str("").expect("parse empty build cfg");
assert_eq!(cfg.root, "build");
}
#[test]
fn phase_args_overlay_only_overrides_when_non_empty() {
let lhs = PhaseArgsCfg {
conf: ArgList(vec![ArgAtom::One("c1".into())]),
build: ArgList(vec![ArgAtom::One("b1".into())]),
flash: ArgList(vec![ArgAtom::One("f1".into())]),
run: ArgList(vec![ArgAtom::One("r1".into())]),
};
let rhs = PhaseArgsCfg {
conf: ArgList::default(),
build: ArgList(vec![ArgAtom::One("b2".into())]),
flash: ArgList::default(),
run: ArgList::default(),
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.conf.to_vec(), vec!["c1"]);
assert_eq!(merged.build.to_vec(), vec!["b2"]);
assert_eq!(merged.flash.to_vec(), vec!["f1"]);
assert_eq!(merged.run.to_vec(), vec!["r1"]);
}
#[test]
fn phase_args_overlay_overrides_flash_and_run() {
let lhs = PhaseArgsCfg {
conf: ArgList(vec![ArgAtom::One("c1".into())]),
build: ArgList(vec![ArgAtom::One("b1".into())]),
flash: ArgList(vec![ArgAtom::One("f1".into())]),
run: ArgList(vec![ArgAtom::One("r1".into())]),
};
let rhs = PhaseArgsCfg {
conf: ArgList::default(),
build: ArgList::default(),
flash: ArgList(vec![ArgAtom::One("f2".into())]),
run: ArgList(vec![ArgAtom::One("r2".into())]),
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.conf.to_vec(), vec!["c1"]);
assert_eq!(merged.build.to_vec(), vec!["b1"]);
assert_eq!(merged.flash.to_vec(), vec!["f2"]);
assert_eq!(merged.run.to_vec(), vec!["r2"]);
}
#[test]
fn profile_cfg_overlay_merges_args() {
let lhs = ProfileCfg {
board: Some("b1".into()),
runner: None,
args: PhaseArgsCfg {
build: ArgList(vec![ArgAtom::One("b1".into())]),
..PhaseArgsCfg::default()
},
};
let rhs = ProfileCfg {
board: None,
runner: Some("r1".into()),
args: PhaseArgsCfg {
build: ArgList(vec![ArgAtom::One("b2".into())]),
..PhaseArgsCfg::default()
},
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.board, Some("b1".into()));
assert_eq!(merged.runner, Some("r1".into()));
assert_eq!(merged.args.build.to_vec(), vec!["b2"]);
}
#[test]
fn profile_cfg_overlay_overrides_board_and_runner() {
let lhs = ProfileCfg {
board: Some("b1".into()),
runner: Some("r1".into()),
args: PhaseArgsCfg::default(),
};
let rhs = ProfileCfg {
board: Some("b2".into()),
runner: Some("r2".into()),
args: PhaseArgsCfg::default(),
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.board, Some("b2".into()));
assert_eq!(merged.runner, Some("r2".into()));
}
#[test]
fn file_config_overlay_merges_profiles() {
let mut lhs = FileConfig::default();
lhs.project.board = Some("b1".into());
lhs.profiles.insert(
"dev".into(),
ProfileCfg {
board: Some("b2".into()),
runner: None,
args: PhaseArgsCfg::default(),
},
);
let mut rhs = FileConfig::default();
rhs.project.runner = Some("r1".into());
rhs.profiles.insert(
"dev".into(),
ProfileCfg {
board: None,
runner: Some("r2".into()),
args: PhaseArgsCfg::default(),
},
);
rhs.profiles.insert(
"prod".into(),
ProfileCfg {
board: Some("b3".into()),
runner: Some("r3".into()),
args: PhaseArgsCfg::default(),
},
);
let merged = lhs.overlay(rhs);
assert_eq!(merged.project.board, Some("b1".into()));
assert_eq!(merged.project.runner, Some("r1".into()));
assert_eq!(
merged
.profiles
.get("dev")
.expect("dev profile present")
.board,
Some("b2".into())
);
assert_eq!(
merged
.profiles
.get("dev")
.expect("dev profile present")
.runner,
Some("r2".into())
);
assert!(merged.profiles.get("prod").is_some());
}
#[test]
fn build_cfg_overlay_empty_root_keeps_lhs() {
let lhs = BuildCfg {
root: "build".into(),
link_compile_commands: None,
};
let rhs = BuildCfg {
root: String::new(),
link_compile_commands: None,
};
let merged = lhs.overlay(rhs);
assert_eq!(merged.root, "build");
assert_eq!(merged.link_compile_commands, None);
}
#[test]
fn file_config_overlay_replaces_profiles_when_missing_lhs() {
let lhs = FileConfig::default();
let mut rhs = FileConfig::default();
rhs.profiles.insert("dev".into(), ProfileCfg::default());
let merged = lhs.overlay(rhs);
assert!(merged.profiles.contains_key("dev"));
}
#[test]
fn arglist_deserialize_many_entry() {
let toml = r#"
[project.args]
conf = [["-DCONF=1", "-DDBG=1"]]
"#;
let cfg: FileConfig = toml::from_str(toml).expect("parse ok");
assert_eq!(cfg.project.args.conf.to_vec(), vec!["-DCONF=1", "-DDBG=1"]);
}
}