use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value as Json;
use crate::config::{
AppBundleConfig, ArchiveConfig, ArchivesConfig, Config, CrateConfig, Defaults, DmgConfig,
DockerV2Config, FlatpakConfig, MsiConfig, NfpmConfig, NsisConfig, PkgConfig, PublishConfig,
PublishDefaults, SnapcraftConfig,
};
fn is_skipped<T: Serialize>(value: &T) -> bool {
let Ok(json) = serde_json::to_value(value) else {
return false;
};
json_skip_truthy(&json)
}
fn json_skip_truthy(value: &Json) -> bool {
let Some(obj) = value.as_object() else {
return false;
};
let Some(skip) = obj.get("skip") else {
return false;
};
match skip {
Json::Bool(b) => *b,
Json::String(s) => matches!(s.trim(), "true" | "1"),
_ => false,
}
}
pub fn apply_defaults(config: &mut Config) {
let defaults = match config.defaults.clone() {
Some(d) => d,
None => return,
};
for crate_cfg in &mut config.crates {
apply_to_crate(&defaults, crate_cfg);
}
if let Some(ref mut workspaces) = config.workspaces {
for ws in workspaces {
for crate_cfg in &mut ws.crates {
apply_to_crate(&defaults, crate_cfg);
}
}
}
apply_top_level_defaults(config, &defaults);
}
fn apply_top_level_defaults(config: &mut Config, defaults: &Defaults) {
deep_merge_option(&mut config.source, defaults.source.as_ref());
deep_merge_option(&mut config.notarize, defaults.notarize.as_ref());
deep_merge_option(&mut config.srpms, defaults.srpms.as_ref());
if config.upx.is_empty()
&& let Some(ref d) = defaults.upx
{
config.upx = vec![d.clone()];
}
if config.signs.is_empty()
&& let Some(ref d) = defaults.sign
{
config.signs = vec![d.clone()];
}
if config.binary_signs.is_empty()
&& let Some(ref d) = defaults.binary_signs
{
config.binary_signs = vec![d.clone()];
}
if config.sboms.is_empty()
&& let Some(ref d) = defaults.sbom
{
config.sboms = vec![d.clone()];
}
if config.makeselfs.is_empty()
&& let Some(ref d) = defaults.makeselves
{
config.makeselfs = vec![d.clone()];
}
if config.docker_signs.as_ref().is_none_or(|v| v.is_empty())
&& let Some(ref d) = defaults.docker_signs
{
config.docker_signs = Some(vec![d.clone()]);
}
}
pub fn apply_to_crate(defaults: &Defaults, crate_cfg: &mut CrateConfig) {
if crate_cfg.cross.is_none() && defaults.cross.is_some() {
crate_cfg.cross = defaults.cross.clone();
}
deep_merge_option(&mut crate_cfg.checksum, defaults.checksum.as_ref());
merge_archives(&mut crate_cfg.archives, defaults.archives.as_ref());
merge_list_by_identity(&mut crate_cfg.nfpms, defaults.nfpms.as_ref(), nfpm_identity);
merge_list_by_identity(
&mut crate_cfg.snapcrafts,
defaults.snapcrafts.as_ref(),
snapcraft_identity,
);
merge_list_by_identity(&mut crate_cfg.dmgs, defaults.dmgs.as_ref(), dmg_identity);
merge_list_by_identity(&mut crate_cfg.pkgs, defaults.pkgs.as_ref(), pkg_identity);
merge_list_by_identity(&mut crate_cfg.msis, defaults.msis.as_ref(), msi_identity);
merge_list_by_identity(&mut crate_cfg.nsis, defaults.nsis.as_ref(), nsis_identity);
merge_list_by_identity(
&mut crate_cfg.app_bundles,
defaults.app_bundles.as_ref(),
app_bundle_identity,
);
merge_list_by_identity(
&mut crate_cfg.flatpaks,
defaults.flatpaks.as_ref(),
flatpak_identity,
);
merge_list_by_identity(
&mut crate_cfg.docker_v2,
defaults.docker_v2.as_ref(),
docker_v2_identity,
);
if let Some(ref tpl) = defaults.builds {
match crate_cfg.builds.as_mut() {
Some(list) if !list.is_empty() => {
for entry in list {
deep_merge_struct_inplace(entry, tpl);
}
}
_ => {
crate_cfg.builds = Some(vec![tpl.clone()]);
}
}
}
if let Some(ref pubd) = defaults.publish {
let target = crate_cfg.publish.get_or_insert_with(PublishConfig::default);
merge_publish_defaults(target, pubd);
}
}
fn deep_merge_option<T: Serialize + DeserializeOwned + Clone>(
target: &mut Option<T>,
defaults: Option<&T>,
) {
let Some(defaults_val) = defaults else {
return;
};
match target {
None => {
*target = Some(defaults_val.clone());
}
Some(crate_val) => {
if is_skipped(crate_val) {
return;
}
deep_merge_struct_inplace(crate_val, defaults_val);
}
}
}
fn deep_merge_struct_inplace<T: Serialize + DeserializeOwned>(target: &mut T, defaults: &T) {
let type_name = std::any::type_name::<T>();
let mut crate_json = match serde_json::to_value(&*target) {
Ok(v) => v,
Err(err) => {
tracing::warn!(
target = "defaults_merge",
type_name,
error = %err,
"failed to serialize target; defaults inheritance skipped for this field"
);
return;
}
};
let defaults_json = match serde_json::to_value(defaults) {
Ok(v) => v,
Err(err) => {
tracing::warn!(
target = "defaults_merge",
type_name,
error = %err,
"failed to serialize defaults; defaults inheritance skipped for this field"
);
return;
}
};
deep_merge_json(&mut crate_json, &defaults_json);
match serde_json::from_value::<T>(crate_json) {
Ok(merged) => *target = merged,
Err(err) => {
tracing::warn!(
target = "defaults_merge",
type_name,
error = %err,
"failed to deserialize merged value; defaults inheritance skipped for this field"
);
}
}
}
fn deep_merge_json(target: &mut Json, defaults: &Json) {
match (target, defaults) {
(Json::Object(t), Json::Object(d)) => {
for (k, v) in d {
match t.get_mut(k) {
Some(existing) if !existing.is_null() => {
deep_merge_json(existing, v);
}
_ => {
t.insert(k.clone(), v.clone());
}
}
}
}
(target_slot, defaults_val) => {
if target_slot.is_null() {
*target_slot = defaults_val.clone();
}
}
}
}
fn merge_archives(target: &mut ArchivesConfig, defaults: Option<&ArchiveConfig>) {
let Some(default_entry) = defaults else {
return;
};
match target {
ArchivesConfig::Disabled => {}
ArchivesConfig::Configs(list) => {
merge_one_into_list(list, default_entry, archive_identity);
}
}
}
fn archive_identity(a: &ArchiveConfig) -> Option<String> {
a.formats.as_ref().and_then(|f| f.first().cloned())
}
fn merge_list_by_identity<T, F>(target: &mut Option<Vec<T>>, defaults: Option<&T>, identity: F)
where
T: Clone + Serialize + DeserializeOwned,
F: Fn(&T) -> Option<String>,
{
let Some(default_entry) = defaults else {
return;
};
match target.as_mut() {
Some(list) => merge_one_into_list(list, default_entry, identity),
None => {
*target = Some(vec![default_entry.clone()]);
}
}
}
fn merge_one_into_list<T, F>(list: &mut Vec<T>, default_entry: &T, identity: F)
where
T: Clone + Serialize + DeserializeOwned,
F: Fn(&T) -> Option<String>,
{
if list.is_empty() {
list.push(default_entry.clone());
return;
}
let default_id = identity(default_entry);
let mut handled = false;
for entry in list.iter_mut() {
if !identity_matches(&identity(entry), &default_id) {
continue;
}
if !is_skipped(entry) {
deep_merge_struct_inplace(entry, default_entry);
}
handled = true;
break;
}
if !handled {
list.push(default_entry.clone());
}
}
fn identity_matches(a: &Option<String>, b: &Option<String>) -> bool {
matches!((a, b), (Some(x), Some(y)) if x == y)
}
fn nfpm_identity(c: &NfpmConfig) -> Option<String> {
if let Some(ref id) = c.id {
return Some(id.clone());
}
if let Some(ref pkg) = c.package_name {
return Some(pkg.clone());
}
None
}
fn snapcraft_identity(c: &SnapcraftConfig) -> Option<String> {
c.name.clone()
}
fn dmg_identity(c: &DmgConfig) -> Option<String> {
c.id.clone()
}
fn pkg_identity(c: &PkgConfig) -> Option<String> {
c.id.clone()
}
fn msi_identity(c: &MsiConfig) -> Option<String> {
c.id.clone()
}
fn nsis_identity(c: &NsisConfig) -> Option<String> {
c.id.clone()
}
fn app_bundle_identity(c: &AppBundleConfig) -> Option<String> {
c.id.clone()
}
fn flatpak_identity(c: &FlatpakConfig) -> Option<String> {
c.id.clone()
}
fn docker_v2_identity(c: &DockerV2Config) -> Option<String> {
c.id.clone()
}
fn merge_publish_defaults(target: &mut PublishConfig, defaults: &PublishDefaults) {
deep_merge_option(&mut target.homebrew, defaults.homebrew.as_ref());
deep_merge_option(&mut target.homebrew_cask, defaults.homebrew_cask.as_ref());
deep_merge_option(&mut target.cargo, defaults.cargo.as_ref());
deep_merge_option(&mut target.scoop, defaults.scoop.as_ref());
deep_merge_option(&mut target.winget, defaults.winget.as_ref());
deep_merge_option(&mut target.chocolatey, defaults.chocolatey.as_ref());
deep_merge_option(&mut target.krew, defaults.krew.as_ref());
deep_merge_option(&mut target.nix, defaults.nix.as_ref());
deep_merge_option(&mut target.aur, defaults.aur.as_ref());
deep_merge_option(&mut target.aur_source, defaults.aur_source.as_ref());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
ArchiveConfig, ArchivesConfig, ChecksumConfig, CrossStrategy, HomebrewCaskConfig,
HomebrewCaskUninstall, HomebrewConfig, StringOrBool,
};
fn make_crate(name: &str) -> CrateConfig {
CrateConfig {
name: name.to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
..Default::default()
}
}
#[test]
fn map_deep_merge_combines_disjoint_fields() {
let defaults = Defaults {
publish: Some(PublishDefaults {
homebrew: Some(HomebrewConfig {
description: Some("hoisted-desc".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.publish = Some(PublishConfig {
homebrew: Some(HomebrewConfig {
license: Some("MIT".to_string()),
..Default::default()
}),
..Default::default()
});
apply_to_crate(&defaults, &mut crate_cfg);
let hb = crate_cfg.publish.unwrap().homebrew.unwrap();
assert_eq!(hb.description, Some("hoisted-desc".to_string()));
assert_eq!(hb.license, Some("MIT".to_string()));
}
#[test]
fn list_append_keeps_both_when_identity_differs() {
let defaults = Defaults {
archives: Some(ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.archives = ArchivesConfig::Configs(vec![ArchiveConfig {
formats: Some(vec!["zip".to_string()]),
..Default::default()
}]);
apply_to_crate(&defaults, &mut crate_cfg);
if let ArchivesConfig::Configs(list) = &crate_cfg.archives {
assert_eq!(list.len(), 2);
let formats: Vec<_> = list
.iter()
.map(|a| a.formats.as_ref().and_then(|f| f.first().cloned()))
.collect();
assert!(formats.contains(&Some("tar.gz".to_string())));
assert!(formats.contains(&Some("zip".to_string())));
} else {
panic!("expected Configs variant");
}
}
#[test]
fn list_merge_by_identity_combines_fields_crate_wins() {
let defaults = Defaults {
archives: Some(ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
name_template: Some("DEFAULT".to_string()),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.archives = ArchivesConfig::Configs(vec![ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
name_template: Some("CRATE".to_string()),
..Default::default()
}]);
apply_to_crate(&defaults, &mut crate_cfg);
if let ArchivesConfig::Configs(list) = &crate_cfg.archives {
assert_eq!(list.len(), 1, "should merge into single entry");
assert_eq!(list[0].name_template, Some("CRATE".to_string()));
assert_eq!(
list[0].formats.as_deref(),
Some(&["tar.gz".to_string()][..])
);
} else {
panic!("expected Configs variant");
}
}
#[test]
fn list_merge_by_identity_fills_unset_fields_from_defaults() {
let defaults = Defaults {
archives: Some(ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
name_template: Some("DEFAULT".to_string()),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.archives = ArchivesConfig::Configs(vec![ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
..Default::default()
}]);
apply_to_crate(&defaults, &mut crate_cfg);
if let ArchivesConfig::Configs(list) = &crate_cfg.archives {
assert_eq!(list.len(), 1);
assert_eq!(list[0].name_template, Some("DEFAULT".to_string()));
} else {
panic!("expected Configs variant");
}
}
#[test]
fn empty_per_crate_block_inherits_all_from_defaults() {
let defaults = Defaults {
checksum: Some(ChecksumConfig {
algorithm: Some("sha512".to_string()),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.checksum = Some(ChecksumConfig::default());
apply_to_crate(&defaults, &mut crate_cfg);
let checksum = crate_cfg.checksum.unwrap();
assert_eq!(checksum.algorithm, Some("sha512".to_string()));
}
#[test]
fn per_crate_skip_true_suppresses_inherited_block() {
let defaults = Defaults {
checksum: Some(ChecksumConfig {
algorithm: Some("sha512".to_string()),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.checksum = Some(ChecksumConfig {
skip: Some(StringOrBool::Bool(true)),
..Default::default()
});
apply_to_crate(&defaults, &mut crate_cfg);
let checksum = crate_cfg.checksum.unwrap();
assert_eq!(checksum.skip, Some(StringOrBool::Bool(true)));
assert_eq!(checksum.algorithm, None);
}
#[test]
fn per_crate_scalar_wins_over_defaults() {
let defaults = Defaults {
cross: Some(CrossStrategy::Auto),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.cross = Some(CrossStrategy::Zigbuild);
apply_to_crate(&defaults, &mut crate_cfg);
assert_eq!(crate_cfg.cross, Some(CrossStrategy::Zigbuild));
}
#[test]
fn defaults_scalar_fills_when_crate_unset() {
let defaults = Defaults {
cross: Some(CrossStrategy::Cross),
..Default::default()
};
let mut crate_cfg = make_crate("a");
apply_to_crate(&defaults, &mut crate_cfg);
assert_eq!(crate_cfg.cross, Some(CrossStrategy::Cross));
}
#[test]
fn apply_defaults_is_idempotent() {
let mut config = Config {
crates: vec![make_crate("a")],
defaults: Some(Defaults {
cross: Some(CrossStrategy::Auto),
archives: Some(ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
apply_defaults(&mut config);
let after_first = config.clone();
apply_defaults(&mut config);
assert_eq!(config.crates[0].cross, after_first.crates[0].cross);
if let (ArchivesConfig::Configs(a), ArchivesConfig::Configs(b)) =
(&config.crates[0].archives, &after_first.crates[0].archives)
{
assert_eq!(a.len(), b.len());
}
}
#[test]
fn cargo_defaults_merge_into_per_crate_publish_cargo_when_unset() {
use crate::config::CargoPublishConfig;
let defaults = Defaults {
publish: Some(PublishDefaults {
cargo: Some(CargoPublishConfig {
index_timeout: Some(600),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("mycrate");
apply_to_crate(&defaults, &mut crate_cfg);
let publish = crate_cfg.publish.expect("publish block should be created");
let cargo = publish
.cargo
.expect("publish.cargo should be inherited from defaults");
assert_eq!(
cargo.index_timeout,
Some(600),
"expected index_timeout=600 inherited from defaults.cargo"
);
}
#[test]
fn cargo_defaults_fill_missing_fields_but_per_crate_wins_on_collision() {
use crate::config::CargoPublishConfig;
let defaults = Defaults {
publish: Some(PublishDefaults {
cargo: Some(CargoPublishConfig {
index_timeout: Some(600),
no_verify: Some(true),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("mycrate");
crate_cfg.publish = Some(PublishConfig {
cargo: Some(CargoPublishConfig {
index_timeout: Some(120),
..Default::default()
}),
..Default::default()
});
apply_to_crate(&defaults, &mut crate_cfg);
let publish = crate_cfg.publish.unwrap();
let cargo = publish.cargo.unwrap();
assert_eq!(
cargo.index_timeout,
Some(120),
"per-crate index_timeout should win over defaults"
);
assert_eq!(
cargo.no_verify,
Some(true),
"no_verify should be filled from defaults (per-crate left it unset)"
);
}
#[test]
fn defaults_builds_fills_per_build_settings_when_crate_unset() {
use crate::config::BuildConfig;
let defaults = Defaults {
builds: Some(BuildConfig {
binary: None,
flags: Some(vec!["--release".to_string(), "--locked".to_string()]),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.builds = Some(vec![BuildConfig {
binary: Some("myapp".to_string()),
..Default::default()
}]);
apply_to_crate(&defaults, &mut crate_cfg);
let builds = crate_cfg.builds.unwrap();
assert_eq!(builds.len(), 1);
assert_eq!(
builds[0].binary,
Some("myapp".to_string()),
"crate field should win"
);
assert_eq!(
builds[0].flags,
Some(vec!["--release".to_string(), "--locked".to_string()])
);
}
#[test]
fn defaults_apply_to_workspace_crates() {
use crate::config::WorkspaceConfig;
let mut config = Config {
workspaces: Some(vec![WorkspaceConfig {
name: "ws1".to_string(),
crates: vec![make_crate("a")],
..Default::default()
}]),
defaults: Some(Defaults {
cross: Some(CrossStrategy::Auto),
..Default::default()
}),
..Default::default()
};
apply_defaults(&mut config);
let workspaces = config.workspaces.expect("workspaces should remain Some");
assert_eq!(workspaces.len(), 1);
assert_eq!(workspaces[0].crates.len(), 1);
assert_eq!(
workspaces[0].crates[0].cross,
Some(CrossStrategy::Auto),
"defaults.cross should fold into workspace crates"
);
}
#[test]
fn per_crate_skip_true_suppresses_arbitrary_block() {
use crate::config::SnapcraftConfig;
let defaults = Defaults {
snapcrafts: Some(SnapcraftConfig {
name: Some("mysnap".to_string()),
summary: Some("DEFAULT-SUMMARY".to_string()),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.snapcrafts = Some(vec![SnapcraftConfig {
name: Some("mysnap".to_string()),
skip: Some(StringOrBool::Bool(true)),
..Default::default()
}]);
apply_to_crate(&defaults, &mut crate_cfg);
let snaps = crate_cfg.snapcrafts.unwrap();
assert_eq!(snaps.len(), 1, "skip:true should not append a duplicate");
assert_eq!(
snaps[0].summary, None,
"skip:true must suppress defaults.summary inheritance"
);
}
#[test]
fn defaults_entry_does_not_fan_out_into_multiple_matching_entries() {
let defaults = Defaults {
archives: Some(ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
name_template: Some("DEFAULT".to_string()),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.archives = ArchivesConfig::Configs(vec![
ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
..Default::default()
},
ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
name_template: Some("EXISTING".to_string()),
..Default::default()
},
]);
apply_to_crate(&defaults, &mut crate_cfg);
if let ArchivesConfig::Configs(list) = &crate_cfg.archives {
assert_eq!(list.len(), 2, "should not append a third entry");
assert_eq!(list[0].name_template, Some("DEFAULT".to_string()));
assert_eq!(list[1].name_template, Some("EXISTING".to_string()));
} else {
panic!("expected Configs variant");
}
}
#[test]
fn unkeyed_entries_stay_distinct_no_collapse() {
let defaults = Defaults {
archives: Some(ArchiveConfig {
formats: Some(vec!["tar.gz".to_string()]),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.archives = ArchivesConfig::Configs(vec![
ArchiveConfig {
name_template: Some("FIRST".to_string()),
..Default::default()
},
ArchiveConfig {
name_template: Some("SECOND".to_string()),
..Default::default()
},
]);
apply_to_crate(&defaults, &mut crate_cfg);
if let ArchivesConfig::Configs(list) = &crate_cfg.archives {
assert_eq!(list.len(), 3, "unkeyed entries must stay distinct");
assert_eq!(list[0].name_template, Some("FIRST".to_string()));
assert_eq!(list[1].name_template, Some("SECOND".to_string()));
assert_eq!(
list[2].formats.as_deref(),
Some(&["tar.gz".to_string()][..])
);
} else {
panic!("expected Configs variant");
}
}
#[test]
fn homebrew_cask_top_level_yaml_parses_with_unified_type() {
let yaml = r#"
project_name: myapp
homebrew_casks:
- name: myapp
description: "My app cask"
homepage: "https://example.com"
repository:
owner: myorg
name: homebrew-tap
directory: Casks
skip_upload: "auto"
"#;
let cfg: crate::config::Config = serde_yaml_ng::from_str(yaml)
.expect("homebrew_casks with unified HomebrewCaskConfig should parse");
let casks = cfg
.homebrew_casks
.expect("homebrew_casks should be present");
assert_eq!(casks.len(), 1);
assert_eq!(casks[0].name.as_deref(), Some("myapp"));
assert_eq!(casks[0].description.as_deref(), Some("My app cask"));
assert_eq!(casks[0].directory.as_deref(), Some("Casks"));
}
#[test]
fn homebrew_cask_per_crate_yaml_parses_with_unified_type() {
let yaml = r#"
project_name: myapp
crates:
- name: myapp
path: .
tag_template: "v{{ .Version }}"
publish:
homebrew_cask:
name: myapp
url_template: "https://releases.example.com/{{ .Version }}/myapp_{{ .Os }}_{{ .Arch }}.dmg"
app: "MyApp.app"
caveats: "Check the docs."
"#;
let cfg: crate::config::Config = serde_yaml_ng::from_str(yaml)
.expect("per-crate publish.homebrew_cask with unified type should parse");
let crate_publish = cfg.crates[0]
.publish
.as_ref()
.expect("publish block should be present");
let cask = crate_publish
.homebrew_cask
.as_ref()
.expect("homebrew_cask should be present");
assert_eq!(cask.name.as_deref(), Some("myapp"));
assert_eq!(cask.app.as_deref(), Some("MyApp.app"));
assert_eq!(cask.caveats.as_deref(), Some("Check the docs."));
}
#[test]
fn homebrew_cask_defaults_merge_into_per_crate_publish_when_unset() {
let defaults = Defaults {
publish: Some(PublishDefaults {
homebrew_cask: Some(HomebrewCaskConfig {
homepage: Some("https://default.example.com".to_string()),
license: Some("MIT".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("mycrate");
apply_to_crate(&defaults, &mut crate_cfg);
let publish = crate_cfg.publish.expect("publish block should be created");
let cask = publish
.homebrew_cask
.expect("publish.homebrew_cask should be inherited from defaults");
assert_eq!(
cask.homepage.as_deref(),
Some("https://default.example.com"),
"homepage should be filled from defaults"
);
assert_eq!(
cask.license.as_deref(),
Some("MIT"),
"license should be filled from defaults"
);
}
#[test]
fn homebrew_cask_defaults_fill_missing_fields_but_per_crate_wins_on_collision() {
let defaults = Defaults {
publish: Some(PublishDefaults {
homebrew_cask: Some(HomebrewCaskConfig {
homepage: Some("https://default.example.com".to_string()),
license: Some("MIT".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("mycrate");
crate_cfg.publish = Some(PublishConfig {
homebrew_cask: Some(HomebrewCaskConfig {
homepage: Some("https://crate.example.com".to_string()),
..Default::default()
}),
..Default::default()
});
apply_to_crate(&defaults, &mut crate_cfg);
let publish = crate_cfg.publish.unwrap();
let cask = publish.homebrew_cask.unwrap();
assert_eq!(
cask.homepage.as_deref(),
Some("https://crate.example.com"),
"per-crate homepage should win over defaults"
);
assert_eq!(
cask.license.as_deref(),
Some("MIT"),
"license should be filled from defaults (per-crate left it unset)"
);
}
#[test]
fn homebrew_cask_uninstall_nested_struct_deep_merges() {
let defaults = Defaults {
publish: Some(PublishDefaults {
homebrew_cask: Some(HomebrewCaskConfig {
uninstall: Some(HomebrewCaskUninstall {
launchctl: Some(vec!["com.example.myapp".to_string()]),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("mycrate");
crate_cfg.publish = Some(PublishConfig {
homebrew_cask: Some(HomebrewCaskConfig {
uninstall: Some(HomebrewCaskUninstall {
quit: Some(vec!["com.example.myapp.helper".to_string()]),
..Default::default()
}),
..Default::default()
}),
..Default::default()
});
apply_to_crate(&defaults, &mut crate_cfg);
let publish = crate_cfg.publish.unwrap();
let cask = publish.homebrew_cask.unwrap();
let uninstall = cask
.uninstall
.expect("uninstall should be present after merge");
let expected_launchctl = vec!["com.example.myapp".to_string()];
assert_eq!(
uninstall.launchctl.as_deref(),
Some(expected_launchctl.as_slice()),
"launchctl from defaults should survive deep merge"
);
let expected_quit = vec!["com.example.myapp.helper".to_string()];
assert_eq!(
uninstall.quit.as_deref(),
Some(expected_quit.as_slice()),
"quit from crate should survive deep merge"
);
}
#[test]
fn nfpm_umask_string_or_u32_inherits_from_defaults() {
use crate::config::{NfpmConfig, StringOrU32};
let defaults = Defaults {
nfpms: Some(NfpmConfig {
package_name: Some("myapp".to_string()),
umask: Some(StringOrU32(0o022)),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
crate_cfg.nfpms = Some(vec![NfpmConfig {
package_name: Some("myapp".to_string()),
..Default::default()
}]);
apply_to_crate(&defaults, &mut crate_cfg);
let nfpm = crate_cfg.nfpms.expect("nfpm vec");
assert_eq!(nfpm.len(), 1, "identity match collapses to one entry");
assert_eq!(
nfpm[0].umask,
Some(StringOrU32(0o022)),
"StringOrU32 must round-trip through the JSON-backed deep-merge"
);
}
#[test]
fn notarize_timeout_duration_does_not_inherit_today() {
use crate::config::{
HumanDuration, MacOSNotarizeApiConfig, MacOSSignNotarizeConfig, NotarizeConfig,
};
let defaults = Defaults {
notarize: Some(NotarizeConfig {
macos: Some(vec![MacOSSignNotarizeConfig {
notarize: Some(MacOSNotarizeApiConfig {
timeout: Some(HumanDuration(std::time::Duration::from_secs(900))),
..Default::default()
}),
..Default::default()
}]),
..Default::default()
}),
..Default::default()
};
let mut crate_cfg = make_crate("a");
apply_to_crate(&defaults, &mut crate_cfg);
let _ = crate_cfg; }
#[test]
fn changelog_header_content_source_does_not_inherit_today() {
use crate::config::{ChangelogConfig, ContentSource};
let _defaults_changelog = ChangelogConfig {
header: Some(ContentSource::Inline("## Notes".to_string())),
..Default::default()
};
let defaults = Defaults::default();
let mut crate_cfg = make_crate("a");
apply_to_crate(&defaults, &mut crate_cfg);
let _ = crate_cfg; }
#[test]
fn top_level_source_fills_from_defaults_when_unset() {
use crate::config::{Config, SourceConfig};
let mut config = Config {
defaults: Some(Defaults {
source: Some(SourceConfig {
enabled: Some(true),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
apply_defaults(&mut config);
assert_eq!(
config.source.as_ref().and_then(|s| s.enabled),
Some(true),
"defaults.source should fill Config.source"
);
}
#[test]
fn top_level_source_user_overrides_defaults() {
use crate::config::{Config, SourceConfig};
let mut config = Config {
defaults: Some(Defaults {
source: Some(SourceConfig {
enabled: Some(false),
name_template: Some("default-name".to_string()),
..Default::default()
}),
..Default::default()
}),
source: Some(SourceConfig {
enabled: Some(true),
..Default::default()
}),
..Default::default()
};
apply_defaults(&mut config);
let s = config.source.as_ref().expect("source set");
assert_eq!(s.enabled, Some(true), "user value wins");
assert_eq!(
s.name_template.as_deref(),
Some("default-name"),
"field unset by user is filled from defaults"
);
}
#[test]
fn top_level_upx_fills_when_empty() {
use crate::config::{Config, UpxConfig};
let mut config = Config {
defaults: Some(Defaults {
upx: Some(UpxConfig {
id: Some("from-defaults".to_string()),
enabled: Some(StringOrBool::Bool(true)),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
apply_defaults(&mut config);
assert_eq!(config.upx.len(), 1);
assert_eq!(config.upx[0].id.as_deref(), Some("from-defaults"));
}
#[test]
fn top_level_upx_does_not_override_user_vec() {
use crate::config::{Config, UpxConfig};
let mut config = Config {
defaults: Some(Defaults {
upx: Some(UpxConfig {
id: Some("from-defaults".to_string()),
..Default::default()
}),
..Default::default()
}),
upx: vec![UpxConfig {
id: Some("user-upx".to_string()),
..Default::default()
}],
..Default::default()
};
apply_defaults(&mut config);
assert_eq!(config.upx.len(), 1);
assert_eq!(
config.upx[0].id.as_deref(),
Some("user-upx"),
"user vec wins"
);
}
#[test]
fn top_level_signs_and_friends_fill_when_empty() {
use crate::config::{
Config, DockerSignConfig, MakeselfConfig, SbomConfig, SignConfig, SrpmConfig,
};
let mut config = Config {
defaults: Some(Defaults {
sign: Some(SignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
}),
binary_signs: Some(SignConfig {
cmd: Some("gpg".to_string()),
..Default::default()
}),
docker_signs: Some(DockerSignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
}),
sbom: Some(SbomConfig {
cmd: Some("syft".to_string()),
..Default::default()
}),
makeselves: Some(MakeselfConfig {
id: Some("from-defaults".to_string()),
..Default::default()
}),
srpms: Some(SrpmConfig {
enabled: Some(true),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
apply_defaults(&mut config);
assert_eq!(config.signs.len(), 1);
assert_eq!(config.signs[0].cmd.as_deref(), Some("cosign"));
assert_eq!(config.binary_signs.len(), 1);
assert_eq!(config.binary_signs[0].cmd.as_deref(), Some("gpg"));
assert_eq!(config.docker_signs.as_ref().unwrap().len(), 1);
assert_eq!(config.sboms.len(), 1);
assert_eq!(config.sboms[0].cmd.as_deref(), Some("syft"));
assert_eq!(config.makeselfs.len(), 1);
assert_eq!(config.makeselfs[0].id.as_deref(), Some("from-defaults"));
assert_eq!(config.srpms.as_ref().and_then(|s| s.enabled), Some(true));
}
#[test]
fn top_level_notarize_deep_merges() {
use crate::config::{
Config, MacOSNativeArtifactKind, MacOSNativeSignNotarizeConfig, NotarizeConfig,
};
let mut config = Config {
defaults: Some(Defaults {
notarize: Some(NotarizeConfig {
macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
use_: Some(MacOSNativeArtifactKind::Dmg),
..Default::default()
}]),
..Default::default()
}),
..Default::default()
}),
notarize: Some(NotarizeConfig::default()),
..Default::default()
};
apply_defaults(&mut config);
let n = config.notarize.as_ref().expect("notarize set");
let mac = n.macos_native.as_ref().expect("macos_native set");
assert!(
matches!(
mac.first().and_then(|m| m.use_.as_ref()),
Some(MacOSNativeArtifactKind::Dmg)
),
"deep-merge should fold defaults.notarize.macos_native[0].use_ into Config.notarize"
);
}
}