#![allow(clippy::field_reassign_with_default)]
use serde::Deserialize;
use super::{Config, ERR_DEFAULTS_AXIS_MISMATCH, IncludeFilePath, IncludeSpec, IncludeUrlConfig};
use super::{
validate_changelog_groups_depth, validate_changelog_paths, validate_defaults_axis,
validate_format_overrides, validate_homebrew_cask_url_template, validate_tag_sort,
validate_version,
};
use super::GitConfig;
use super::HookEntry;
use super::{ArchivesConfig, ChecksumConfig, ContentSource, ExtraFileSpec};
use super::{BuilderKind, all_builds_prebuilt, validate_builds};
use super::{ChangelogConfig, MilestoneConfig, SbomConfig};
use super::{CrateConfig, CrossStrategy, MetadataConfig};
use super::{
EnvFilesConfig, EnvFilesTokenConfig, load_env_files, load_token_files, read_token_file,
};
use super::{
ForceTokenKind, GitHubUrlsConfig, GitLabUrlsConfig, GiteaUrlsConfig, MakeLatestConfig,
ReleaseConfig,
};
use super::{HumanDuration, StringOrBool};
use super::{
MacOSNativeArtifactKind, MacOSNativeNotarizeConfig, MacOSNativeSignNotarizeConfig,
MacOSNotarizeApiConfig, MacOSSignConfig,
};
use super::string_or_bool::parse_humantime_duration;
#[test]
fn test_minimal_yaml_config() {
let yaml = r#"
project_name: myproject
crates:
- name: myproject
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.project_name, "myproject");
assert_eq!(config.crates.len(), 1);
assert_eq!(config.dist, std::path::PathBuf::from("./dist"));
}
#[test]
fn test_minimal_toml_config() {
let toml_str = r#"
project_name = "myproject"
[[crates]]
name = "myproject"
path = "."
tag_template = "v{{ .Version }}"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.project_name, "myproject");
}
#[test]
fn test_full_config_with_defaults() {
let yaml = r#"
project_name: cfgd
dist: ./dist
defaults:
targets:
- x86_64-unknown-linux-gnu
- aarch64-apple-darwin
cross: auto
archives:
formats: [tar.gz]
format_overrides:
- os: windows
formats: [zip]
checksum:
algorithm: sha256
crates:
- name: cfgd
path: crates/cfgd
tag_template: "v{{ .Version }}"
builds:
- binary: cfgd
features: []
no_default_features: false
archives:
- name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
files:
- LICENSE
release:
github:
owner: tj-smith47
name: cfgd
draft: false
prerelease: auto
name_template: "{{ .Tag }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let defaults = config.defaults.unwrap();
assert_eq!(defaults.targets.unwrap().len(), 2);
assert_eq!(defaults.cross, Some(CrossStrategy::Auto));
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.name_template, Some("{{ .Tag }}".to_string()));
}
#[test]
fn test_snapshot_config() {
let yaml = r#"
project_name: test
snapshot:
version_template: "{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}"
crates:
- name: test
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
config.snapshot.unwrap().version_template,
"{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}"
);
}
#[test]
fn test_archives_false() {
let yaml = r#"
project_name: test
crates:
- name: operator
path: crates/operator
tag_template: "v{{ .Version }}"
archives: false
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(matches!(
config.crates[0].archives,
ArchivesConfig::Disabled
));
}
#[test]
fn test_publish_cargo_present_and_with_options() {
let yaml_present = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
cargo: {}
"#;
let config: Config = serde_yaml_ng::from_str(yaml_present).unwrap();
assert!(config.crates[0].publish.as_ref().unwrap().cargo.is_some());
let yaml_obj = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
cargo:
index_timeout: 120
no_verify: true
allow_dirty: true
features: [foo, bar]
"#;
let config: Config = serde_yaml_ng::from_str(yaml_obj).unwrap();
let cargo = config.crates[0]
.publish
.as_ref()
.unwrap()
.cargo
.as_ref()
.unwrap();
assert_eq!(cargo.index_timeout, Some(120));
assert_eq!(cargo.no_verify, Some(true));
assert_eq!(cargo.allow_dirty, Some(true));
assert_eq!(
cargo.features,
Some(vec!["foo".to_string(), "bar".to_string()])
);
}
#[test]
fn test_publish_cargo_bool_shorthand_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
cargo: true
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"publish.cargo: true must fail to parse (no bool shorthand)"
);
}
#[test]
fn test_publish_cargo_legacy_crates_key_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
crates: true
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"publish.crates is no longer a valid key (renamed to cargo); deny_unknown_fields must reject it"
);
}
#[test]
fn test_make_latest_auto() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
make_latest: auto
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.make_latest, Some(MakeLatestConfig::Auto));
}
#[test]
fn test_make_latest_true() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
make_latest: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.make_latest, Some(MakeLatestConfig::Bool(true)));
}
#[test]
fn test_make_latest_false() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
make_latest: false
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.make_latest, Some(MakeLatestConfig::Bool(false)));
}
#[test]
fn test_make_latest_omitted() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
draft: false
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.make_latest, None);
}
#[test]
fn test_make_latest_template_string() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
make_latest: "{{ if .IsSnapshot }}false{{ else }}true{{ end }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(
release.make_latest,
Some(MakeLatestConfig::String(
"{{ if .IsSnapshot }}false{{ else }}true{{ end }}".to_string()
))
);
}
#[test]
fn test_make_latest_string_true() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
make_latest: "true"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.make_latest, Some(MakeLatestConfig::Bool(true)));
}
#[test]
fn test_make_latest_string_false() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
make_latest: "false"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.make_latest, Some(MakeLatestConfig::Bool(false)));
}
#[test]
fn test_changelog_header_footer() {
let yaml = r##"
project_name: test
changelog:
header: "# My Release Notes"
footer: "---\nGenerated by anodizer"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"##;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cl = config.changelog.as_ref().unwrap();
assert_eq!(
cl.header,
Some(ContentSource::Inline("# My Release Notes".to_string()))
);
assert_eq!(
cl.footer,
Some(ContentSource::Inline(
"---\nGenerated by anodizer".to_string()
))
);
}
#[test]
fn test_changelog_header_from_file_and_url() {
let yaml = r#"
project_name: test
changelog:
header:
from_file: ./HEADER.md
footer:
from_url: https://example.com/footer.md
headers:
Accept: text/markdown
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cl = config.changelog.as_ref().unwrap();
match cl.header.as_ref().unwrap() {
ContentSource::FromFile { from_file } => assert_eq!(from_file, "./HEADER.md"),
other => panic!("expected FromFile, got {other:?}"),
}
match cl.footer.as_ref().unwrap() {
ContentSource::FromUrl { from_url, headers } => {
assert_eq!(from_url, "https://example.com/footer.md");
assert_eq!(
headers
.as_ref()
.and_then(|m| m.get("Accept"))
.map(String::as_str),
Some("text/markdown")
);
}
other => panic!("expected FromUrl, got {other:?}"),
}
}
#[test]
fn test_changelog_disable() {
let yaml = r#"
project_name: test
changelog:
skip: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cl = config.changelog.as_ref().unwrap();
assert_eq!(cl.skip, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_changelog_disable_false() {
let yaml = r#"
project_name: test
changelog:
skip: false
sort: desc
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cl = config.changelog.as_ref().unwrap();
assert_eq!(cl.skip, Some(StringOrBool::Bool(false)));
assert_eq!(cl.sort, Some("desc".to_string()));
}
#[test]
fn test_checksum_resolved_algorithm_default() {
let cfg = ChecksumConfig::default();
assert_eq!(cfg.resolved_algorithm(), "sha256");
}
#[test]
fn test_checksum_resolved_algorithm_user_value_wins() {
let cfg = ChecksumConfig {
algorithm: Some("sha512".to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_algorithm(), "sha512");
}
#[test]
fn test_checksum_resolved_split_default() {
let cfg = ChecksumConfig::default();
assert!(!cfg.resolved_split());
}
#[test]
fn test_checksum_resolved_split_user_value_wins() {
let cfg = ChecksumConfig {
split: Some(true),
..Default::default()
};
assert!(cfg.resolved_split());
}
#[test]
fn test_checksum_resolved_combined_name_template_default() {
let cfg = ChecksumConfig::default();
assert_eq!(
cfg.resolved_combined_name_template(),
"{{ ProjectName }}_{{ Version }}_checksums.txt"
);
}
#[test]
fn test_checksum_resolved_combined_name_template_user_value_wins() {
let cfg = ChecksumConfig {
name_template: Some("custom-{{ Version }}.txt".to_string()),
..Default::default()
};
assert_eq!(
cfg.resolved_combined_name_template(),
"custom-{{ Version }}.txt"
);
}
#[test]
fn test_macos_sign_resolved_timestamp_url_default() {
let cfg = MacOSSignConfig::default();
assert_eq!(
cfg.resolved_timestamp_url(),
"http://timestamp.apple.com/ts01"
);
}
#[test]
fn test_macos_sign_resolved_timestamp_url_user_value_wins() {
let cfg = MacOSSignConfig {
timestamp_url: Some("http://corp.example/ts".to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_timestamp_url(), "http://corp.example/ts");
}
#[test]
fn test_macos_sign_resolved_timestamp_url_blank_falls_back() {
let cfg = MacOSSignConfig {
timestamp_url: Some(" ".to_string()),
..Default::default()
};
assert_eq!(
cfg.resolved_timestamp_url(),
"http://timestamp.apple.com/ts01"
);
}
#[test]
fn test_macos_notarize_api_resolved_wait_default() {
assert!(!MacOSNotarizeApiConfig::default().resolved_wait());
}
#[test]
fn test_macos_notarize_api_resolved_wait_user_value_wins() {
let cfg = MacOSNotarizeApiConfig {
wait: Some(true),
..Default::default()
};
assert!(cfg.resolved_wait());
}
#[test]
fn test_macos_notarize_api_resolved_timeout_default() {
assert_eq!(MacOSNotarizeApiConfig::default().resolved_timeout(), "10m");
}
#[test]
fn test_macos_notarize_api_resolved_timeout_user_value_wins() {
let cfg = MacOSNotarizeApiConfig {
timeout: Some(HumanDuration(parse_humantime_duration("15m").unwrap())),
..Default::default()
};
assert_eq!(cfg.resolved_timeout(), "15m");
}
#[test]
fn test_macos_native_notarize_resolved_wait_default() {
assert!(!MacOSNativeNotarizeConfig::default().resolved_wait());
}
#[test]
fn test_macos_native_notarize_resolved_wait_user_value_wins() {
let cfg = MacOSNativeNotarizeConfig {
wait: Some(true),
..Default::default()
};
assert!(cfg.resolved_wait());
}
#[test]
fn test_macos_native_notarize_resolved_timeout_default() {
assert_eq!(
MacOSNativeNotarizeConfig::default().resolved_timeout(),
"10m"
);
}
#[test]
fn test_macos_native_notarize_resolved_timeout_user_value_wins() {
let cfg = MacOSNativeNotarizeConfig {
timeout: Some(HumanDuration(parse_humantime_duration("30m").unwrap())),
..Default::default()
};
assert_eq!(cfg.resolved_timeout(), "30m");
}
#[test]
fn test_macos_native_sign_notarize_resolved_use_default() {
assert_eq!(
MacOSNativeSignNotarizeConfig::default().resolved_use(),
MacOSNativeArtifactKind::Dmg
);
}
#[test]
fn test_macos_native_sign_notarize_resolved_use_user_value_wins() {
let cfg = MacOSNativeSignNotarizeConfig {
use_: Some(MacOSNativeArtifactKind::Pkg),
..Default::default()
};
assert_eq!(cfg.resolved_use(), MacOSNativeArtifactKind::Pkg);
}
#[test]
fn test_sbom_resolved_id_default() {
assert_eq!(SbomConfig::default().resolved_id(), "default");
}
#[test]
fn test_sbom_resolved_id_user_value_wins() {
let cfg = SbomConfig {
id: Some("custom".to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_id(), "custom");
}
#[test]
fn test_sbom_resolved_cmd_default() {
assert_eq!(SbomConfig::default().resolved_cmd(), "syft");
}
#[test]
fn test_sbom_resolved_cmd_user_value_wins() {
let cfg = SbomConfig {
cmd: Some("trivy".to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_cmd(), "trivy");
}
#[test]
fn test_sbom_resolved_artifacts_default() {
assert_eq!(SbomConfig::default().resolved_artifacts(), "archive");
}
#[test]
fn test_sbom_resolved_artifacts_user_value_wins() {
let cfg = SbomConfig {
artifacts: Some("binary".to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_artifacts(), "binary");
}
#[test]
fn test_sbom_resolved_documents_default_binary() {
assert_eq!(
SbomConfig::default().resolved_documents("binary"),
vec!["{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom.json".to_string()]
);
}
#[test]
fn test_sbom_resolved_documents_default_any() {
assert_eq!(
SbomConfig::default().resolved_documents("any"),
Vec::<String>::new()
);
}
#[test]
fn test_sbom_resolved_documents_default_archive() {
assert_eq!(
SbomConfig::default().resolved_documents("archive"),
vec!["{{ .ArtifactName }}.sbom.json".to_string()]
);
}
#[test]
fn test_sbom_resolved_documents_user_value_wins() {
let cfg = SbomConfig {
documents: Some(vec!["custom-{{ Version }}.sbom.json".to_string()]),
..Default::default()
};
assert_eq!(
cfg.resolved_documents("binary"),
vec!["custom-{{ Version }}.sbom.json".to_string()]
);
}
#[test]
fn test_sbom_resolved_args_default_syft() {
assert_eq!(
SbomConfig::default().resolved_args("syft"),
vec![
"$artifact".to_string(),
"--output".to_string(),
"spdx-json=$document".to_string(),
"--enrich".to_string(),
"all".to_string(),
]
);
}
#[test]
fn test_sbom_resolved_args_default_non_syft_is_empty() {
assert_eq!(
SbomConfig::default().resolved_args("trivy"),
Vec::<String>::new()
);
}
#[test]
fn test_sbom_resolved_args_user_value_wins() {
let custom = vec!["sbom".to_string(), "$artifact".to_string()];
let cfg = SbomConfig {
args: Some(custom.clone()),
..Default::default()
};
assert_eq!(cfg.resolved_args("syft"), custom);
assert_eq!(cfg.resolved_args("trivy"), custom);
}
#[test]
fn test_sbom_default_syft_env_archive() {
assert_eq!(
SbomConfig::default_syft_env_for("syft", "archive"),
vec![(
"SYFT_FILE_METADATA_CATALOGER_ENABLED".to_string(),
"true".to_string(),
)]
);
}
#[test]
fn test_sbom_default_syft_env_source() {
assert_eq!(
SbomConfig::default_syft_env_for("syft", "source"),
vec![(
"SYFT_FILE_METADATA_CATALOGER_ENABLED".to_string(),
"true".to_string(),
)]
);
}
#[test]
fn test_sbom_default_syft_env_other_artifacts_empty() {
assert!(SbomConfig::default_syft_env_for("syft", "binary").is_empty());
assert!(SbomConfig::default_syft_env_for("syft", "any").is_empty());
}
#[test]
fn test_sbom_default_syft_env_non_syft_empty() {
assert!(SbomConfig::default_syft_env_for("trivy", "archive").is_empty());
}
#[test]
fn test_release_resolved_name_template_default() {
assert_eq!(
ReleaseConfig::default().resolved_name_template(),
"{{ Tag }}"
);
}
#[test]
fn test_release_resolved_name_template_user_value_wins() {
let cfg = ReleaseConfig {
name_template: Some("{{ ProjectName }} {{ Version }}".to_string()),
..Default::default()
};
assert_eq!(
cfg.resolved_name_template(),
"{{ ProjectName }} {{ Version }}"
);
}
#[test]
fn test_release_resolved_mode_default() {
assert_eq!(
ReleaseConfig::default().resolved_mode().unwrap(),
"keep-existing"
);
}
#[test]
fn test_release_resolved_mode_empty_string_falls_back() {
let cfg = ReleaseConfig {
mode: Some(String::new()),
..Default::default()
};
assert_eq!(cfg.resolved_mode().unwrap(), "keep-existing");
}
#[test]
fn test_release_resolved_mode_valid_values() {
for mode in ["keep-existing", "append", "prepend", "replace"] {
let cfg = ReleaseConfig {
mode: Some(mode.to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_mode().unwrap(), mode);
}
}
#[test]
fn test_release_resolved_mode_invalid_value_errors() {
let cfg = ReleaseConfig {
mode: Some("clobber".to_string()),
..Default::default()
};
let err = cfg.resolved_mode().unwrap_err();
assert!(
err.to_string().contains("invalid mode 'clobber'"),
"got: {err}"
);
}
#[test]
fn test_release_resolved_bool_defaults_false() {
let cfg = ReleaseConfig::default();
assert!(!cfg.resolved_draft());
assert!(!cfg.resolved_replace_existing_draft());
assert!(!cfg.resolved_replace_existing_artifacts());
assert!(!cfg.resolved_include_meta());
assert!(!cfg.resolved_use_existing_draft());
}
#[test]
fn test_release_resolved_bool_user_values_win() {
let cfg = ReleaseConfig {
draft: Some(true),
replace_existing_draft: Some(true),
replace_existing_artifacts: Some(true),
include_meta: Some(true),
use_existing_draft: Some(true),
..Default::default()
};
assert!(cfg.resolved_draft());
assert!(cfg.resolved_replace_existing_draft());
assert!(cfg.resolved_replace_existing_artifacts());
assert!(cfg.resolved_include_meta());
assert!(cfg.resolved_use_existing_draft());
}
#[test]
fn test_changelog_resolved_sort_default_empty() {
assert_eq!(ChangelogConfig::default().resolved_sort().unwrap(), "");
}
#[test]
fn test_changelog_resolved_sort_valid_values() {
for s in ["", "asc", "desc"] {
let cfg = ChangelogConfig {
sort: Some(s.to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_sort().unwrap(), s);
}
}
#[test]
fn test_changelog_resolved_sort_invalid_errors() {
let cfg = ChangelogConfig {
sort: Some("random".to_string()),
..Default::default()
};
let err = cfg.resolved_sort().unwrap_err().to_string();
assert!(err.contains("invalid sort 'random'"), "got: {err}");
}
#[test]
fn test_changelog_resolved_use_source_default_git() {
assert_eq!(ChangelogConfig::default().resolved_use_source(), "git");
}
#[test]
fn test_changelog_resolved_use_source_user_value_wins() {
let cfg = ChangelogConfig {
use_source: Some("github".to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_use_source(), "github");
}
#[test]
fn test_changelog_resolved_title_default() {
assert_eq!(ChangelogConfig::default().resolved_title(), "Changelog");
}
#[test]
fn test_changelog_resolved_title_explicit_empty_preserved() {
let cfg = ChangelogConfig {
title: Some(String::new()),
..Default::default()
};
assert_eq!(cfg.resolved_title(), "");
}
#[test]
fn test_changelog_resolved_abbrev_default_zero() {
assert_eq!(ChangelogConfig::default().resolved_abbrev(), 0);
}
#[test]
fn test_changelog_resolved_abbrev_user_value_wins() {
let cfg = ChangelogConfig {
abbrev: Some(7),
..Default::default()
};
assert_eq!(cfg.resolved_abbrev(), 7);
}
#[test]
fn test_changelog_resolved_abbrev_clamps_negative_below_minus_one() {
for raw in [-2i32, -5, -100, i32::MIN] {
let cfg = ChangelogConfig {
abbrev: Some(raw),
..Default::default()
};
assert_eq!(
cfg.resolved_abbrev(),
-1,
"abbrev={raw} must clamp to -1 (parity with GoReleaser 88daaf3)"
);
}
}
#[test]
fn test_changelog_resolved_abbrev_minus_one_passes_through() {
let cfg = ChangelogConfig {
abbrev: Some(-1),
..Default::default()
};
assert_eq!(cfg.resolved_abbrev(), -1);
}
#[test]
fn test_changelog_resolved_format_user_value_wins() {
let cfg = ChangelogConfig {
format: Some("custom format".to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_format("git", 0), "custom format");
assert_eq!(cfg.resolved_format("github", -1), "custom format");
}
#[test]
fn test_changelog_resolved_format_default_no_hash() {
let cfg = ChangelogConfig::default();
assert_eq!(cfg.resolved_format("git", -1), "{{ Message }}");
assert_eq!(cfg.resolved_format("github", -1), "{{ Message }}");
}
#[test]
fn test_changelog_resolved_format_default_scm() {
let cfg = ChangelogConfig::default();
for backend in ["github", "gitlab", "gitea"] {
let tmpl = cfg.resolved_format(backend, 0);
assert!(
tmpl.contains("{% if Login %}"),
"expected SCM template for backend {backend}, got: {tmpl}"
);
assert!(
tmpl.starts_with("{{ SHA }}: "),
"SCM default must use full SHA (not ShortSHA) for backend \
{backend}, got: {tmpl}"
);
assert!(
!tmpl.contains("ShortSHA"),
"SCM default must not reference ShortSHA for backend \
{backend}, got: {tmpl}"
);
}
}
#[test]
fn test_changelog_resolved_format_default_git() {
let cfg = ChangelogConfig::default();
assert_eq!(cfg.resolved_format("git", 0), "{{ SHA }} {{ Message }}");
}
#[test]
fn test_changelog_resolved_snapshot_default_false() {
assert!(!ChangelogConfig::default().resolved_snapshot());
}
#[test]
fn test_changelog_resolved_snapshot_user_value_wins() {
let cfg = ChangelogConfig {
snapshot: Some(true),
..Default::default()
};
assert!(cfg.resolved_snapshot());
}
#[test]
fn test_milestone_resolved_name_template_default() {
assert_eq!(
MilestoneConfig::default().resolved_name_template(),
"{{ Tag }}"
);
}
#[test]
fn test_milestone_resolved_name_template_user_value_wins() {
let cfg = MilestoneConfig {
name_template: Some("v{{ Version }}".to_string()),
..Default::default()
};
assert_eq!(cfg.resolved_name_template(), "v{{ Version }}");
}
#[test]
fn test_milestone_resolved_close_default_false() {
assert!(!MilestoneConfig::default().resolved_close());
}
#[test]
fn test_milestone_resolved_close_user_value_wins() {
let cfg = MilestoneConfig {
close: Some(true),
..Default::default()
};
assert!(cfg.resolved_close());
}
#[test]
fn test_milestone_resolved_fail_on_error_default_false() {
assert!(!MilestoneConfig::default().resolved_fail_on_error());
}
#[test]
fn test_milestone_resolved_fail_on_error_user_value_wins() {
let cfg = MilestoneConfig {
fail_on_error: Some(true),
..Default::default()
};
assert!(cfg.resolved_fail_on_error());
}
#[test]
fn test_checksum_disable() {
let yaml = r#"
project_name: test
defaults:
checksum:
skip: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let checksum = config.defaults.as_ref().unwrap().checksum.as_ref().unwrap();
assert_eq!(checksum.skip, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_checksum_disable_per_crate() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
checksum:
skip: true
algorithm: sha512
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let checksum = config.crates[0].checksum.as_ref().unwrap();
assert_eq!(checksum.skip, Some(StringOrBool::Bool(true)));
assert_eq!(checksum.algorithm, Some("sha512".to_string()));
}
#[test]
fn test_checksum_disable_template_string() {
let yaml = r#"
project_name: test
defaults:
checksum:
skip: "{{ if .IsSnapshot }}true{{ end }}"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let checksum = config.defaults.as_ref().unwrap().checksum.as_ref().unwrap();
match &checksum.skip {
Some(StringOrBool::String(s)) => {
assert!(s.contains("IsSnapshot"));
}
other => panic!("expected StringOrBool::String, got {:?}", other),
}
}
#[test]
fn test_checksum_extra_files_object_form() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
checksum:
extra_files:
- "dist/*.bin"
- glob: "release/*.deb"
name_template: "{{ .ArtifactName }}.checksum"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let checksum = config.crates[0].checksum.as_ref().unwrap();
let extra = checksum.extra_files.as_ref().unwrap();
assert_eq!(extra.len(), 2);
assert_eq!(extra[0], ExtraFileSpec::Glob("dist/*.bin".to_string()));
match &extra[1] {
ExtraFileSpec::Detailed {
glob,
name_template,
..
} => {
assert_eq!(glob, "release/*.deb");
assert_eq!(
name_template.as_deref(),
Some("{{ .ArtifactName }}.checksum")
);
}
other => panic!("expected ExtraFileSpec::Detailed, got {:?}", other),
}
}
#[test]
fn test_make_latest_serialize_roundtrip() {
let auto = MakeLatestConfig::Auto;
let json = serde_json::to_string(&auto).unwrap();
assert_eq!(json, "\"auto\"");
let bool_true = MakeLatestConfig::Bool(true);
let json = serde_json::to_string(&bool_true).unwrap();
assert_eq!(json, "true");
let bool_false = MakeLatestConfig::Bool(false);
let json = serde_json::to_string(&bool_false).unwrap();
assert_eq!(json, "false");
let tmpl =
MakeLatestConfig::String("{{ if .IsSnapshot }}false{{ else }}true{{ end }}".to_string());
let json = serde_json::to_string(&tmpl).unwrap();
assert_eq!(json, "\"{{ if .IsSnapshot }}false{{ else }}true{{ end }}\"");
}
#[test]
fn test_release_header_footer_inline() {
let yaml = r###"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
header: "## Custom Header"
footer: "---\nPowered by anodizer"
"###;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(
release.header,
Some(ContentSource::Inline("## Custom Header".to_string()))
);
assert_eq!(
release.footer,
Some(ContentSource::Inline(
"---\nPowered by anodizer".to_string()
))
);
}
#[test]
fn test_release_header_footer_from_file() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
header:
from_file: ./RELEASE_HEADER.md
footer:
from_file: ./RELEASE_FOOTER.md
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(
release.header,
Some(ContentSource::FromFile {
from_file: "./RELEASE_HEADER.md".to_string()
})
);
assert_eq!(
release.footer,
Some(ContentSource::FromFile {
from_file: "./RELEASE_FOOTER.md".to_string()
})
);
}
#[test]
fn test_release_header_footer_from_url() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
header:
from_url: https://example.com/header.md
footer:
from_url: https://example.com/footer.md
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(
release.header,
Some(ContentSource::FromUrl {
from_url: "https://example.com/header.md".to_string(),
headers: None,
})
);
assert_eq!(
release.footer,
Some(ContentSource::FromUrl {
from_url: "https://example.com/footer.md".to_string(),
headers: None,
})
);
}
#[test]
fn test_release_header_footer_omitted() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
draft: false
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.header, None);
assert_eq!(release.footer, None);
}
#[test]
fn test_release_extra_files_glob_strings() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
extra_files:
- "dist/*.sig"
- "CHANGELOG.md"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
let files = release.extra_files.as_ref().unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0], ExtraFileSpec::Glob("dist/*.sig".to_string()));
assert_eq!(files[1], ExtraFileSpec::Glob("CHANGELOG.md".to_string()));
}
#[test]
fn test_release_extra_files_detailed_objects() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
extra_files:
- glob: "dist/*.sig"
name_template: "{{ .ArtifactName }}.sig"
- glob: "docs/*.pdf"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
let files = release.extra_files.as_ref().unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0].glob(), "dist/*.sig");
assert_eq!(files[0].name_template(), Some("{{ .ArtifactName }}.sig"));
assert_eq!(files[1].glob(), "docs/*.pdf");
assert_eq!(files[1].name_template(), None);
}
#[test]
fn test_release_extra_files_mixed() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
extra_files:
- "dist/*.sig"
- glob: "docs/*.pdf"
name_template: "{{ .ArtifactName }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
let files = release.extra_files.as_ref().unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0], ExtraFileSpec::Glob("dist/*.sig".to_string()));
assert_eq!(files[1].glob(), "docs/*.pdf");
}
#[test]
fn test_release_extra_files_omitted() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
draft: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.extra_files, None);
}
#[test]
fn test_release_templated_extra_files_parsed() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
templated_extra_files:
- src: LICENSE.tpl
dst: LICENSE.txt
- src: README.md.tpl
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
let tpl = release.templated_extra_files.as_ref().unwrap();
assert_eq!(tpl.len(), 2);
assert_eq!(tpl[0].src, "LICENSE.tpl");
assert_eq!(tpl[0].dst.as_deref(), Some("LICENSE.txt"));
assert_eq!(tpl[1].src, "README.md.tpl");
assert_eq!(tpl[1].dst, None);
}
#[test]
fn test_release_templated_extra_files_defaults_to_none() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
draft: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.templated_extra_files, None);
}
#[test]
fn test_checksum_templated_extra_files_parsed() {
let yaml = r#"
name_template: "checksums.txt"
templated_extra_files:
- src: "notes.tpl"
dst: "RELEASE_NOTES.txt"
"#;
let cfg: ChecksumConfig = serde_yaml_ng::from_str(yaml).unwrap();
let tpl = cfg.templated_extra_files.as_ref().unwrap();
assert_eq!(tpl.len(), 1);
assert_eq!(tpl[0].src, "notes.tpl");
assert_eq!(tpl[0].dst.as_deref(), Some("RELEASE_NOTES.txt"));
}
#[test]
fn test_release_skip_upload() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
skip_upload: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.skip_upload, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_release_skip_upload_false() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
skip_upload: false
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.skip_upload, Some(StringOrBool::Bool(false)));
}
#[test]
fn test_release_skip_upload_auto() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
skip_upload: "auto"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(
release.skip_upload,
Some(StringOrBool::String("auto".to_string()))
);
}
#[test]
fn test_release_replace_existing_draft() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
replace_existing_draft: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.replace_existing_draft, Some(true));
}
#[test]
fn test_release_replace_existing_artifacts() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
replace_existing_artifacts: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.replace_existing_artifacts, Some(true));
}
#[test]
fn test_release_tag_override_parsed() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "myapp/v{{ .Version }}"
release:
tag: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.tag, Some("v{{ .Version }}".to_string()));
}
#[test]
fn test_release_tag_override_omitted() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
draft: false
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(release.tag, None);
}
#[test]
fn test_release_all_new_fields() {
let yaml = r##"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
github:
owner: myorg
name: myrepo
draft: true
make_latest: auto
header: "# Release Notes"
footer: "Thank you!"
extra_files:
- "dist/extra.zip"
skip_upload: false
replace_existing_draft: true
replace_existing_artifacts: false
target_commitish: main
discussion_category_name: Announcements
include_meta: true
use_existing_draft: false
tag: "v{{ .Version }}"
"##;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
assert_eq!(
release.header,
Some(ContentSource::Inline("# Release Notes".to_string()))
);
assert_eq!(
release.footer,
Some(ContentSource::Inline("Thank you!".to_string()))
);
assert_eq!(
release.extra_files.as_ref().unwrap(),
&[ExtraFileSpec::Glob("dist/extra.zip".to_string())]
);
assert_eq!(release.skip_upload, Some(StringOrBool::Bool(false)));
assert_eq!(release.replace_existing_draft, Some(true));
assert_eq!(release.replace_existing_artifacts, Some(false));
assert_eq!(release.make_latest, Some(MakeLatestConfig::Auto));
assert_eq!(release.target_commitish, Some("main".to_string()));
assert_eq!(
release.discussion_category_name,
Some("Announcements".to_string())
);
assert_eq!(release.include_meta, Some(true));
assert_eq!(release.use_existing_draft, Some(false));
assert_eq!(release.tag, Some("v{{ .Version }}".to_string()));
}
#[test]
fn test_signs_single_object() {
let yaml = r#"
project_name: test
signs:
artifacts: all
cmd: gpg
args:
- "--detach-sig"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.signs.len(), 1);
assert_eq!(config.signs[0].artifacts, Some("all".to_string()));
assert_eq!(config.signs[0].cmd, Some("gpg".to_string()));
assert_eq!(config.signs[0].args.as_ref().unwrap().len(), 1);
}
#[test]
fn test_signs_array_format() {
let yaml = r#"
project_name: test
signs:
- id: gpg-sign
artifacts: checksum
cmd: gpg
args:
- "--detach-sig"
- id: cosign-sign
artifacts: binary
cmd: cosign
args:
- "sign"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.signs.len(), 2);
assert_eq!(config.signs[0].id, Some("gpg-sign".to_string()));
assert_eq!(config.signs[0].artifacts, Some("checksum".to_string()));
assert_eq!(config.signs[1].id, Some("cosign-sign".to_string()));
assert_eq!(config.signs[1].artifacts, Some("binary".to_string()));
}
#[test]
fn test_signs_omitted_is_empty() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.signs.is_empty());
}
#[test]
fn test_signs_new_fields() {
let yaml = r#"
project_name: test
signs:
- id: my-signer
artifacts: archive
cmd: gpg
args:
- "--detach-sig"
signature: "{{ .Artifact }}.asc"
stdin: "my-passphrase"
ids:
- my-archive
- my-binary
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.signs.len(), 1);
let sign = &config.signs[0];
assert_eq!(sign.id, Some("my-signer".to_string()));
assert_eq!(sign.artifacts, Some("archive".to_string()));
assert_eq!(sign.signature, Some("{{ .Artifact }}.asc".to_string()));
assert_eq!(sign.stdin, Some("my-passphrase".to_string()));
assert_eq!(sign.ids.as_ref().unwrap().len(), 2);
assert_eq!(sign.ids.as_ref().unwrap()[0], "my-archive");
}
#[test]
fn test_signs_stdin_file_field() {
let yaml = r#"
project_name: test
signs:
- artifacts: all
cmd: gpg
stdin_file: "/path/to/passphrase.txt"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.signs.len(), 1);
assert_eq!(
config.signs[0].stdin_file,
Some("/path/to/passphrase.txt".to_string())
);
}
#[test]
fn test_signs_single_object_with_new_fields() {
let yaml = r#"
project_name: test
signs:
id: default
artifacts: package
cmd: gpg
signature: "{{ .Artifact }}.sig"
stdin: "pass"
ids:
- pkg-id
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.signs.len(), 1);
let sign = &config.signs[0];
assert_eq!(sign.id, Some("default".to_string()));
assert_eq!(sign.artifacts, Some("package".to_string()));
assert_eq!(sign.signature, Some("{{ .Artifact }}.sig".to_string()));
assert_eq!(sign.stdin, Some("pass".to_string()));
assert_eq!(sign.ids.as_ref().unwrap(), &["pkg-id"]);
}
#[test]
fn test_signs_toml_single_object() {
let toml_str = r#"
project_name = "test"
[signs]
artifacts = "checksum"
cmd = "gpg"
[[crates]]
name = "a"
path = "."
tag_template = "v{{ .Version }}"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.signs.len(), 1);
assert_eq!(config.signs[0].artifacts, Some("checksum".to_string()));
}
#[test]
fn test_signs_toml_array() {
let toml_str = r#"
project_name = "test"
[[signs]]
id = "first"
artifacts = "all"
cmd = "gpg"
[[signs]]
id = "second"
artifacts = "binary"
cmd = "cosign"
[[crates]]
name = "a"
path = "."
tag_template = "v{{ .Version }}"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.signs.len(), 2);
assert_eq!(config.signs[0].id, Some("first".to_string()));
assert_eq!(config.signs[1].id, Some("second".to_string()));
}
#[test]
fn test_signs_default_config_has_empty_signs() {
let config = Config::default();
assert!(config.signs.is_empty());
}
#[test]
fn test_binary_signs_artifacts_binary_accepted() {
let yaml = r#"
project_name: test
binary_signs:
- id: cosign-binary
artifacts: binary
cmd: cosign
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.binary_signs.len(), 1);
assert_eq!(config.binary_signs[0].artifacts.as_deref(), Some("binary"));
}
#[test]
fn test_binary_signs_artifacts_none_accepted() {
let yaml = r#"
project_name: test
binary_signs:
- artifacts: none
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.binary_signs[0].artifacts.as_deref(), Some("none"));
}
#[test]
fn test_binary_signs_artifacts_omitted_accepted() {
let yaml = r#"
project_name: test
binary_signs:
- id: implicit
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.binary_signs[0].artifacts, None);
}
#[test]
fn test_binary_signs_artifacts_archive_rejected() {
let yaml = r#"
project_name: test
binary_signs:
- artifacts: archive
crates: []
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"binary_signs[].artifacts: archive must be rejected"
);
}
#[test]
fn test_binary_signs_artifacts_all_rejected() {
let yaml = r#"
project_name: test
binary_signs:
- artifacts: all
crates: []
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"binary_signs[].artifacts: all must be rejected"
);
}
#[test]
fn test_binary_signs_artifacts_schema_is_runtime_constrained() {
let schema = schemars::schema_for!(Config);
let json = serde_json::to_value(&schema).expect("schema must serialize");
let sign_artifacts = json
.pointer("/definitions/SignConfig/properties/artifacts")
.expect("SignConfig.artifacts must appear in the generated schema");
assert!(
sign_artifacts.get("enum").is_none(),
"binary_signs[].artifacts schema must remain unconstrained \
(constraint lives in deserialize_binary_signs); got: {sign_artifacts}"
);
}
#[test]
fn test_report_sizes_true() {
let yaml = r#"
project_name: test
report_sizes: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.report_sizes, Some(true));
}
#[test]
fn test_report_sizes_false() {
let yaml = r#"
project_name: test
report_sizes: false
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.report_sizes, Some(false));
}
#[test]
fn test_report_sizes_omitted() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.report_sizes, None);
}
#[test]
fn test_env_field_parsed() {
let yaml = r#"
project_name: test
env:
- MY_VAR=hello
- DEPLOY_ENV=staging
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env = config.env.as_ref().unwrap();
assert!(env.contains(&"MY_VAR=hello".to_string()));
assert!(env.contains(&"DEPLOY_ENV=staging".to_string()));
}
#[test]
fn test_env_field_omitted() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.env, None);
}
#[test]
fn test_env_field_toml() {
let toml_str = r#"
project_name = "test"
env = ["API_KEY=secret123", "STAGE=prod"]
[[crates]]
name = "a"
path = "."
tag_template = "v{{ .Version }}"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let env = config.env.as_ref().unwrap();
assert!(env.contains(&"API_KEY=secret123".to_string()));
assert!(env.contains(&"STAGE=prod".to_string()));
}
#[test]
fn test_env_list_form_toml() {
let toml_str = r#"
project_name = "test"
env = ["MY_VAR=hello", "STAGE=prod"]
crates = []
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let env = config.env.as_ref().unwrap();
assert!(env.contains(&"MY_VAR=hello".to_string()));
assert!(env.contains(&"STAGE=prod".to_string()));
}
#[test]
fn test_env_list_form_parsed() {
let yaml = r#"
project_name: test
env:
- MY_VAR=hello
- DEPLOY_ENV=staging
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env = config.env.as_ref().unwrap();
assert!(env.contains(&"MY_VAR=hello".to_string()));
assert!(env.contains(&"DEPLOY_ENV=staging".to_string()));
}
#[test]
fn test_env_list_form_with_template_expressions() {
let yaml = r#"
project_name: test
env:
- "MY_VERSION={{ .Tag }}"
- "BUILD_DATE={{ .Date }}"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env = config.env.as_ref().unwrap();
assert!(env.contains(&"MY_VERSION={{ .Tag }}".to_string()));
assert!(env.contains(&"BUILD_DATE={{ .Date }}".to_string()));
}
#[test]
fn test_env_list_form_value_with_equals() {
let yaml = r#"
project_name: test
env:
- "LDFLAGS=-X main.version=1.0.0"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env = config.env.as_ref().unwrap();
assert!(
env.contains(&"LDFLAGS=-X main.version=1.0.0".to_string()),
"only first = should split key from value"
);
}
#[test]
fn test_env_list_form_empty_value() {
let yaml = r#"
project_name: test
env:
- "EMPTY_VAR="
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env = config.env.as_ref().unwrap();
assert!(env.contains(&"EMPTY_VAR=".to_string()));
}
#[test]
fn test_env_list_form_no_equals_is_error() {
let yaml = r#"
project_name: test
env:
- "NO_EQUALS"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env = config.env.as_ref().unwrap();
let err = super::parse_env_entries(env).unwrap_err();
assert!(
err.to_string().contains("KEY=VALUE"),
"parse_env_entries should mention KEY=VALUE format, got: {}",
err
);
}
#[test]
fn test_env_list_form_empty_key_is_error() {
let yaml = r#"
project_name: test
env:
- "=orphan_value"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env = config.env.as_ref().unwrap();
let err = super::parse_env_entries(env).unwrap_err();
assert!(
err.to_string().contains("empty key"),
"parse_env_entries should mention empty key, got: {}",
err
);
}
#[test]
fn test_env_list_form_last_wins_on_duplicates() {
let yaml = r#"
project_name: test
env:
- "DUPED=first"
- "DUPED=second"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env = config.env.as_ref().unwrap();
assert!(
env.contains(&"DUPED=second".to_string()),
"later entries should be present"
);
}
#[test]
fn test_workspace_env_list_form() {
let yaml = r#"
project_name: test
crates: []
workspaces:
- name: ws1
crates: []
env:
- "WS_VAR=from-workspace"
- "WS_BUILD={{ .Tag }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let ws = &config.workspaces.as_ref().unwrap()[0];
let env = ws.env.as_ref().unwrap();
assert!(env.contains(&"WS_VAR=from-workspace".to_string()));
assert!(env.contains(&"WS_BUILD={{ .Tag }}".to_string()));
}
#[test]
fn test_malformed_yaml_syntax_error() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
invalid_indentation
this_is_broken: [
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err(), "malformed YAML should fail to parse");
let err = result.unwrap_err().to_string();
assert!(!err.is_empty(), "error message should not be empty");
}
#[test]
fn test_type_mismatch_string_where_array_expected() {
let yaml = r#"
project_name: test
crates: "this should be an array"
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err(), "string where array expected should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("invalid type") || err.contains("expected a sequence"),
"error should mention type mismatch, got: {err}"
);
}
#[test]
fn test_type_mismatch_object_where_string_expected() {
let yaml = r#"
project_name:
nested: object
another: field
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"mapping where string expected should fail to parse"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("invalid type") || err.contains("expected a string"),
"error should mention type mismatch, got: {err}"
);
}
#[test]
fn test_type_mismatch_bool_where_array_expected_for_targets() {
let yaml = r#"
project_name: test
defaults:
targets: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"bool where array expected for targets should fail"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("invalid type")
|| err.contains("expected a sequence")
|| err.contains("targets"),
"error should mention type mismatch for targets, got: {err}"
);
}
#[test]
fn test_invalid_cross_strategy_value() {
let yaml = r#"
project_name: test
defaults:
cross: invalid_strategy
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"invalid cross strategy should fail to parse"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("unknown variant") || err.contains("invalid_strategy"),
"error should mention the invalid variant, got: {err}"
);
}
#[test]
fn test_prerelease_invalid_string_value() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
prerelease: "always"
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"prerelease: 'always' should fail (only 'auto' or bool accepted)"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("auto") || err.contains("always"),
"error should mention expected values, got: {err}"
);
}
#[test]
fn test_archives_true_is_invalid() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
archives: true
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"archives: true should be rejected (only false or array accepted)"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("true is not valid") || err.contains("false or a list"),
"error should explain valid archives values, got: {err}"
);
}
#[test]
fn test_completely_empty_yaml() {
let yaml = "";
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
let config =
result.unwrap_or_else(|e| panic!("empty YAML should parse to Config defaults: {e}"));
assert!(
config.project_name.is_empty(),
"default project_name should be empty"
);
assert!(config.crates.is_empty(), "default crates should be empty");
assert_eq!(
config.dist,
std::path::PathBuf::from("./dist"),
"default dist should be ./dist"
);
}
#[test]
fn test_binstall_config_parsed() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
binstall:
enabled: true
pkg_url: "https://example.com/{{ .Version }}/{ target }"
bin_dir: "{ bin }{ binary-ext }"
pkg_fmt: tgz
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let bs = config.crates[0].binstall.as_ref().unwrap();
assert_eq!(bs.enabled, Some(true));
assert_eq!(
bs.pkg_url,
Some("https://example.com/{{ .Version }}/{ target }".to_string())
);
assert_eq!(bs.bin_dir, Some("{ bin }{ binary-ext }".to_string()));
assert_eq!(bs.pkg_fmt, Some("tgz".to_string()));
}
#[test]
fn test_binstall_config_omitted() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.crates[0].binstall.is_none());
}
#[test]
fn test_binstall_config_partial() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
binstall:
enabled: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let bs = config.crates[0].binstall.as_ref().unwrap();
assert_eq!(bs.enabled, Some(true));
assert_eq!(bs.pkg_url, None);
assert_eq!(bs.bin_dir, None);
assert_eq!(bs.pkg_fmt, None);
}
#[test]
fn test_binstall_config_overrides_parsed() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
binstall:
enabled: true
overrides:
x86_64-unknown-linux-gnu:
pkg_url: "https://example.com/{{ .Version }}/myapp-linux-amd64.tar.gz"
pkg_fmt: tgz
bin_dir: "{ bin }{ binary-ext }"
aarch64-apple-darwin:
pkg_url: "https://example.com/{{ .Version }}/myapp-darwin-arm64.tar.gz"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let bs = config.crates[0].binstall.as_ref().unwrap();
let overrides = bs.overrides.as_ref().expect("overrides should parse");
assert_eq!(overrides.len(), 2);
let linux = overrides.get("x86_64-unknown-linux-gnu").unwrap();
assert_eq!(
linux.pkg_url,
Some("https://example.com/{{ .Version }}/myapp-linux-amd64.tar.gz".to_string())
);
assert_eq!(linux.pkg_fmt, Some("tgz".to_string()));
assert_eq!(linux.bin_dir, Some("{ bin }{ binary-ext }".to_string()));
let darwin = overrides.get("aarch64-apple-darwin").unwrap();
assert_eq!(
darwin.pkg_url,
Some("https://example.com/{{ .Version }}/myapp-darwin-arm64.tar.gz".to_string())
);
assert_eq!(darwin.pkg_fmt, None);
assert_eq!(darwin.bin_dir, None);
}
#[test]
fn test_version_sync_config_parsed() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
version_sync:
enabled: true
mode: tag
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let vs = config.crates[0].version_sync.as_ref().unwrap();
assert_eq!(vs.enabled, Some(true));
assert_eq!(vs.mode, Some("tag".to_string()));
}
#[test]
fn test_version_sync_config_explicit_mode() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
version_sync:
enabled: true
mode: explicit
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let vs = config.crates[0].version_sync.as_ref().unwrap();
assert_eq!(vs.mode, Some("explicit".to_string()));
}
#[test]
fn test_version_sync_config_omitted() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.crates[0].version_sync.is_none());
}
#[test]
fn test_binstall_and_version_sync_together() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
binstall:
enabled: true
pkg_fmt: zip
version_sync:
enabled: true
mode: tag
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.crates[0].binstall.is_some());
assert!(config.crates[0].version_sync.is_some());
}
#[test]
fn test_binstall_config_toml() {
let toml_str = r#"
project_name = "test"
[[crates]]
name = "myapp"
path = "."
tag_template = "v{{ .Version }}"
[crates.binstall]
enabled = true
pkg_url = "https://example.com"
pkg_fmt = "tgz"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let bs = config.crates[0].binstall.as_ref().unwrap();
assert_eq!(bs.enabled, Some(true));
assert_eq!(bs.pkg_url, Some("https://example.com".to_string()));
}
#[test]
fn test_version_sync_config_toml() {
let toml_str = r#"
project_name = "test"
[[crates]]
name = "myapp"
path = "."
tag_template = "v{{ .Version }}"
[crates.version_sync]
enabled = true
mode = "tag"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let vs = config.crates[0].version_sync.as_ref().unwrap();
assert_eq!(vs.enabled, Some(true));
assert_eq!(vs.mode, Some("tag".to_string()));
}
#[test]
fn test_crate_config_default_has_none_binstall_version_sync() {
let config = CrateConfig::default();
assert!(config.binstall.is_none());
assert!(config.version_sync.is_none());
}
#[test]
fn test_unknown_top_level_fields_rejected() {
let yaml = r#"
project_name: test
unknown_top_level_field: "this should be rejected"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"unknown top-level fields should be rejected"
);
assert!(
result.unwrap_err().to_string().contains("unknown field"),
"error should mention unknown field"
);
}
#[test]
fn test_unknown_crate_level_fields_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
nonexistent_field: true
something_else: "hello"
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"unknown crate-level fields should be rejected"
);
assert!(
result.unwrap_err().to_string().contains("unknown field"),
"error should mention unknown field"
);
}
#[test]
fn test_unknown_nested_fields_rejected() {
let cases = [
(
"nested section (defaults)",
r#"
project_name: test
defaults:
targets:
- x86_64-unknown-linux-gnu
unknown_default_field: "boom"
"#,
),
(
"nested section (changelog)",
"project_name: test\nchangelog:\n sort: asc\n mystery_option: true\n",
),
(
"per-crate leaf (checksum)",
r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
checksum:
algorithm: sha256
future_field: "boom"
"#,
),
];
for (label, yaml) in cases {
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
let err = result.expect_err(&format!("{label}: unknown field must be rejected"));
assert!(
err.to_string().contains("unknown field"),
"{label}: error should name the unknown field, got: {err}"
);
}
}
#[test]
fn test_build_config_reproducible_true() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: myapp
reproducible: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let build = &config.crates[0].builds.as_ref().unwrap()[0];
assert_eq!(build.reproducible, Some(true));
}
#[test]
fn test_build_config_reproducible_false() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: myapp
reproducible: false
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let build = &config.crates[0].builds.as_ref().unwrap()[0];
assert_eq!(build.reproducible, Some(false));
}
#[test]
fn test_build_config_reproducible_omitted() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: myapp
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let build = &config.crates[0].builds.as_ref().unwrap()[0];
assert_eq!(build.reproducible, None);
}
#[test]
fn test_workspace_config_parses() {
let yaml = r#"
project_name: monorepo
crates: []
workspaces:
- name: frontend
crates:
- name: frontend-app
path: "apps/frontend"
tag_template: "frontend-v{{ .Version }}"
changelog:
sort: asc
- name: backend
crates:
- name: backend-api
path: "apps/backend"
tag_template: "backend-v{{ .Version }}"
- name: backend-worker
path: "apps/worker"
tag_template: "worker-v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let workspaces = config.workspaces.as_ref().unwrap();
assert_eq!(workspaces.len(), 2);
assert_eq!(workspaces[0].name, "frontend");
assert_eq!(workspaces[0].crates.len(), 1);
assert_eq!(workspaces[0].crates[0].name, "frontend-app");
assert!(workspaces[0].changelog.is_some());
assert_eq!(workspaces[1].name, "backend");
assert_eq!(workspaces[1].crates.len(), 2);
}
#[test]
fn test_workspace_config_with_signs_and_hooks() {
let yaml = r#"
project_name: monorepo
crates: []
workspaces:
- name: myws
crates:
- name: mylib
path: "."
tag_template: "v{{ .Version }}"
signs:
- artifacts: all
cmd: gpg
before:
hooks:
- echo before
after:
hooks:
- echo after
env:
- MY_VAR=hello
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let ws = &config.workspaces.as_ref().unwrap()[0];
assert_eq!(ws.name, "myws");
assert_eq!(ws.signs.len(), 1);
assert!(ws.before.is_some());
assert!(ws.after.is_some());
assert!(
ws.env
.as_ref()
.unwrap()
.contains(&"MY_VAR=hello".to_string())
);
}
#[test]
fn test_workspace_config_omitted() {
let yaml = r#"
project_name: simple
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.workspaces.is_none());
}
#[test]
fn test_workspace_config_empty_array() {
let yaml = r#"
project_name: test
crates: []
workspaces: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let workspaces = config.workspaces.as_ref().unwrap();
assert!(workspaces.is_empty());
}
#[test]
fn test_chocolatey_config_toml() {
let toml_str = r#"
project_name = "test"
[[crates]]
name = "mytool"
path = "."
tag_template = "v{{ .Version }}"
[crates.publish.chocolatey]
description = "A tool"
license = "MIT"
authors = "Author"
tags = ["cli"]
[crates.publish.chocolatey.repository]
owner = "org"
name = "tool"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let choco = config.crates[0]
.publish
.as_ref()
.unwrap()
.chocolatey
.as_ref()
.unwrap();
assert_eq!(choco.description, Some("A tool".to_string()));
let repo = choco.repository.as_ref().unwrap();
assert_eq!(repo.owner.as_deref(), Some("org"));
}
#[test]
fn test_changelog_snapshot_field_parses() {
let yaml = r#"
project_name: test
changelog:
snapshot: true
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cl = config.changelog.as_ref().unwrap();
assert_eq!(cl.snapshot, Some(true));
}
#[test]
fn test_changelog_snapshot_omitted_is_none() {
let yaml = r#"
project_name: test
changelog:
sort: asc
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cl = config.changelog.as_ref().unwrap();
assert_eq!(cl.snapshot, None);
}
#[test]
fn test_top_level_plural_canonical_keys_parse() {
let yaml = r#"
project_name: test
defaults:
nfpms:
formats: [deb]
dmgs:
name: test
msis:
name: test
flatpaks:
runtime: org.freedesktop.Platform
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let d = config.defaults.unwrap();
assert!(d.nfpms.is_some());
assert!(d.dmgs.is_some());
assert!(d.msis.is_some());
assert!(d.flatpaks.is_some());
}
#[test]
fn test_makeself_filename_field() {
let yaml = r#"
project_name: test
makeselfs:
- id: default
filename: "myapp-{{ .Version }}.run"
script: install.sh
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
config.makeselfs[0].filename.as_deref(),
Some("myapp-{{ .Version }}.run")
);
}
#[test]
fn test_announce_smtp_aliases_email() {
let yaml = r#"
project_name: test
announce:
smtp:
enabled: true
host: smtp.example.com
port: 587
username: user
from: from@example.com
to: ["to@example.com"]
subject_template: "Release {{ .Version }}"
message_template: "Body"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(
config.announce.unwrap().email.is_some(),
"smtp: should alias to email:"
);
}
#[test]
fn test_announce_canonical_email_still_works() {
let yaml = r#"
project_name: test
announce:
email:
enabled: true
host: smtp.example.com
port: 587
username: user
from: from@example.com
to: ["to@example.com"]
subject_template: "Release {{ .Version }}"
message_template: "Body"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.announce.unwrap().email.is_some());
}
#[test]
fn test_legacy_docker_field_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
dockers_v2:
- images: [registry/img]
tags: ["{{ .Version }}"]
dockerfile: Dockerfile
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.crates[0].dockers_v2.is_some());
}
#[test]
fn test_homebrew_legacy_commit_author_flat_fields_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew:
commit_author_name: TJ
commit_author_email: tj@example.com
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"homebrew.commit_author_name must be rejected; use commit_author block"
);
}
#[test]
fn test_scoop_legacy_commit_author_flat_fields_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
scoop:
commit_author_name: TJ
commit_author_email: tj@example.com
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"scoop.commit_author_name must be rejected; use commit_author block"
);
}
#[test]
fn test_aur_legacy_url_field_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
aur:
url: "https://example.com/a"
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err(), "aur.url must be rejected; use homepage");
}
#[test]
fn test_homebrew_legacy_tap_field_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew:
tap:
owner: x
name: y
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err(), "homebrew.tap must be rejected");
}
#[test]
fn test_scoop_legacy_bucket_field_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
scoop:
bucket:
owner: x
name: y
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err(), "scoop.bucket must be rejected");
}
#[test]
fn test_winget_legacy_manifests_repo_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
winget:
manifests_repo:
owner: x
name: y
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err(), "winget.manifests_repo must be rejected");
}
#[test]
fn test_chocolatey_legacy_project_repo_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
chocolatey:
project_repo:
owner: x
name: y
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"chocolatey.project_repo must be rejected (use repository)"
);
}
#[test]
fn test_krew_legacy_manifests_repo_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
krew:
manifests_repo:
owner: x
name: y
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err(), "krew.manifests_repo must be rejected");
}
#[test]
fn test_krew_legacy_upstream_repo_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
krew:
upstream_repo:
owner: x
name: y
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err(), "krew.upstream_repo must be rejected");
}
#[test]
fn test_notarize_macos_skip_roundtrip() {
let yaml = r#"
notarize:
macos:
- skip: false
sign:
certificate: /tmp/cert.p12
password: pw
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let macos = config.notarize.unwrap().macos.unwrap();
assert_eq!(macos[0].skip, Some(StringOrBool::Bool(false)));
}
#[test]
fn test_notarize_macos_enabled_alias_inverts_to_skip() {
let yaml_true = r#"
notarize:
macos:
- enabled: true
sign:
certificate: /tmp/cert.p12
password: pw
crates: []
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml_true).expect("enabled alias should parse");
let macos = cfg.notarize.unwrap().macos.unwrap();
assert_eq!(
macos[0].skip,
Some(StringOrBool::Bool(false)),
"`enabled: true` must invert to `skip: false`"
);
let yaml_false = r#"
notarize:
macos:
- enabled: false
sign:
certificate: /tmp/cert.p12
password: pw
crates: []
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml_false).expect("enabled alias should parse");
let macos = cfg.notarize.unwrap().macos.unwrap();
assert_eq!(
macos[0].skip,
Some(StringOrBool::Bool(true)),
"`enabled: false` must invert to `skip: true`"
);
}
#[test]
fn test_notarize_macos_native_enabled_alias_inverts_to_skip() {
let yaml = r#"
notarize:
macos_native:
- enabled: true
crates: []
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).expect("enabled alias should parse");
let macos_native = cfg.notarize.unwrap().macos_native.unwrap();
assert_eq!(
macos_native[0].skip,
Some(StringOrBool::Bool(false)),
"`enabled: true` must invert to `skip: false`"
);
}
#[test]
fn test_notarize_top_level_unknown_field_rejected() {
let yaml = r#"
notarize:
enabled: true
crates: []
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"unknown field `enabled` on NotarizeConfig must be rejected"
);
}
#[test]
fn test_nfpm_content_canonical_keys_in_srpm_full() {
let yaml = r#"
project_name: test
srpm:
enabled: true
contents:
- src: ./LICENSE
dst: /usr/share/doc/myapp/LICENSE
type: doc
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let contents = config.srpms.as_ref().unwrap().contents.as_ref().unwrap();
assert_eq!(contents.len(), 1);
assert_eq!(contents[0].src, "./LICENSE");
assert_eq!(contents[0].dst, "/usr/share/doc/myapp/LICENSE");
assert_eq!(contents[0].content_type.as_deref(), Some("doc"));
}
#[test]
fn test_nfpm_content_canonical_keys_in_srpm() {
let yaml = r#"
project_name: test
srpm:
enabled: true
contents:
- src: ./README.md
dst: /usr/share/doc/myapp/README.md
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let contents = config.srpms.as_ref().unwrap().contents.as_ref().unwrap();
assert_eq!(contents[0].src, "./README.md");
}
#[test]
fn test_nfpm_signature_canonical_passphrase() {
let yaml = r#"
project_name: test
srpm:
enabled: true
signature:
key_file: /keys/srpm.gpg
key_passphrase: "s3cret"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let sig = config.srpms.as_ref().unwrap().signature.as_ref().unwrap();
assert_eq!(sig.key_file.as_deref(), Some("/keys/srpm.gpg"));
assert_eq!(sig.key_passphrase.as_deref(), Some("s3cret"));
}
#[test]
fn test_srpm_singular_alias_still_accepted() {
let yaml_legacy = r#"
project_name: test
srpm:
enabled: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let yaml_canonical = r#"
project_name: test
srpms:
enabled: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let legacy: Config = serde_yaml_ng::from_str(yaml_legacy).unwrap();
let canonical: Config = serde_yaml_ng::from_str(yaml_canonical).unwrap();
assert!(legacy.srpms.is_some(), "srpm: alias must populate srpms");
assert!(canonical.srpms.is_some(), "srpms: must populate srpms");
assert_eq!(
legacy.srpms.as_ref().unwrap().enabled,
canonical.srpms.as_ref().unwrap().enabled
);
}
#[test]
fn test_nfpm_singular_alias_still_accepted() {
let yaml_legacy = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
nfpm:
- id: deb
formats: [deb]
"#;
let yaml_canonical = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
nfpms:
- id: deb
formats: [deb]
"#;
let legacy: Config = serde_yaml_ng::from_str(yaml_legacy).unwrap();
let canonical: Config = serde_yaml_ng::from_str(yaml_canonical).unwrap();
assert_eq!(legacy.crates[0].nfpms.as_ref().unwrap().len(), 1);
assert_eq!(canonical.crates[0].nfpms.as_ref().unwrap().len(), 1);
assert_eq!(
legacy.crates[0].nfpms.as_ref().unwrap()[0].id,
canonical.crates[0].nfpms.as_ref().unwrap()[0].id
);
}
#[test]
fn test_aur_config_yaml() {
let yaml = r#"
project_name: test
crates:
- name: mytool
path: "."
tag_template: "v{{ .Version }}"
publish:
aur:
git_url: "ssh://aur@aur.archlinux.org/mytool.git"
name: mytool-bin
description: "A great tool"
license: MIT
maintainers:
- "Jane Doe <jane@example.com>"
depends:
- glibc
- openssl
optdepends:
- "git: for VCS support"
conflicts:
- mytool-git
provides:
- mytool
replaces:
- old-mytool
backup:
- etc/mytool/config.toml
homepage: "https://github.com/org/mytool"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let aur = config.crates[0]
.publish
.as_ref()
.unwrap()
.aur
.as_ref()
.unwrap();
assert_eq!(
aur.git_url,
Some("ssh://aur@aur.archlinux.org/mytool.git".to_string())
);
assert_eq!(aur.name, Some("mytool-bin".to_string()));
assert_eq!(aur.description, Some("A great tool".to_string()));
assert_eq!(aur.license, Some("MIT".to_string()));
assert_eq!(
aur.maintainers,
Some(vec!["Jane Doe <jane@example.com>".to_string()])
);
assert_eq!(
aur.depends,
Some(vec!["glibc".to_string(), "openssl".to_string()])
);
assert_eq!(
aur.optdepends,
Some(vec!["git: for VCS support".to_string()])
);
assert_eq!(aur.conflicts, Some(vec!["mytool-git".to_string()]));
assert_eq!(aur.provides, Some(vec!["mytool".to_string()]));
assert_eq!(aur.replaces, Some(vec!["old-mytool".to_string()]));
assert_eq!(aur.backup, Some(vec!["etc/mytool/config.toml".to_string()]));
assert_eq!(
aur.homepage,
Some("https://github.com/org/mytool".to_string())
);
}
#[test]
fn test_aur_config_minimal() {
let yaml = r#"
project_name: test
crates:
- name: mytool
path: "."
tag_template: "v{{ .Version }}"
publish:
aur:
git_url: "ssh://aur@aur.archlinux.org/mytool.git"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let aur = config.crates[0]
.publish
.as_ref()
.unwrap()
.aur
.as_ref()
.unwrap();
assert_eq!(
aur.git_url,
Some("ssh://aur@aur.archlinux.org/mytool.git".to_string())
);
assert!(aur.name.is_none());
assert!(aur.description.is_none());
assert!(aur.license.is_none());
assert!(aur.maintainers.is_none());
assert!(aur.depends.is_none());
assert!(aur.optdepends.is_none());
assert!(aur.conflicts.is_none());
assert!(aur.provides.is_none());
assert!(aur.replaces.is_none());
assert!(aur.backup.is_none());
}
#[test]
fn test_aur_config_toml() {
let toml_str = r#"
project_name = "test"
[[crates]]
name = "mytool"
path = "."
tag_template = "v{{ .Version }}"
[crates.publish.aur]
git_url = "ssh://aur@aur.archlinux.org/mytool.git"
description = "A tool"
license = "MIT"
depends = ["glibc"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let aur = config.crates[0]
.publish
.as_ref()
.unwrap()
.aur
.as_ref()
.unwrap();
assert_eq!(
aur.git_url,
Some("ssh://aur@aur.archlinux.org/mytool.git".to_string())
);
assert_eq!(aur.description, Some("A tool".to_string()));
assert_eq!(aur.depends, Some(vec!["glibc".to_string()]));
}
#[test]
fn test_version_field_none_is_valid() {
let config = Config::default();
assert!(validate_version(&config).is_ok());
}
#[test]
fn test_version_field_1_is_valid() {
let yaml = r#"
project_name: test
version: 1
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.version, Some(1));
assert!(validate_version(&config).is_ok());
}
#[test]
fn test_version_field_2_is_valid() {
let yaml = r#"
project_name: test
version: 2
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.version, Some(2));
assert!(validate_version(&config).is_ok());
}
#[test]
fn test_version_field_99_is_rejected() {
let config = Config {
version: Some(99),
..Default::default()
};
let result = validate_version(&config);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("unsupported config version: 99")
);
}
#[test]
fn test_env_files_list_form_parses() {
let yaml = r#"
project_name: test
env_files:
- ".env"
- ".release.env"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env_files = config.env_files.unwrap();
let files = env_files
.as_list()
.unwrap_or_else(|| panic!("expected List variant"));
assert_eq!(files.len(), 2);
assert_eq!(files[0], ".env");
assert_eq!(files[1], ".release.env");
}
#[test]
fn test_env_files_struct_form_parses() {
let yaml = r#"
project_name: test
env_files:
github_token: "~/.config/goreleaser/github_token"
gitlab_token: "/etc/tokens/gitlab"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env_files = config.env_files.unwrap();
let tokens = env_files
.as_token_files()
.unwrap_or_else(|| panic!("expected TokenFiles variant"));
assert_eq!(
tokens.github_token.as_deref(),
Some("~/.config/goreleaser/github_token")
);
assert_eq!(tokens.gitlab_token.as_deref(), Some("/etc/tokens/gitlab"));
assert!(tokens.gitea_token.is_none());
}
#[test]
fn test_env_files_struct_form_empty_mapping() {
let yaml = r#"
project_name: test
env_files:
gitea_token: "/tmp/gitea"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let env_files = config.env_files.unwrap();
let tokens = env_files
.as_token_files()
.unwrap_or_else(|| panic!("expected TokenFiles variant"));
assert!(tokens.github_token.is_none());
assert!(tokens.gitlab_token.is_none());
assert_eq!(tokens.gitea_token.as_deref(), Some("/tmp/gitea"));
}
#[test]
fn test_env_files_field_omitted() {
let yaml = r#"
project_name: test
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.env_files.is_none());
}
#[test]
fn test_read_token_file_reads_first_line() {
use std::io::Write;
let dir = tempfile::TempDir::new().unwrap();
let token_path = dir.path().join("github_token");
let mut f = std::fs::File::create(&token_path).unwrap();
writeln!(f, "ghp_abc123xyz").unwrap();
writeln!(f, "this line should be ignored").unwrap();
drop(f);
let result = read_token_file(&token_path.to_string_lossy()).unwrap();
assert_eq!(result, Some("ghp_abc123xyz".to_string()));
}
#[test]
fn test_read_token_file_trims_whitespace() {
use std::io::Write;
let dir = tempfile::TempDir::new().unwrap();
let token_path = dir.path().join("token");
let mut f = std::fs::File::create(&token_path).unwrap();
writeln!(f, " spaced_token ").unwrap();
drop(f);
let result = read_token_file(&token_path.to_string_lossy()).unwrap();
assert_eq!(result, Some("spaced_token".to_string()));
}
#[test]
fn test_read_token_file_nonexistent_returns_none() {
let result = read_token_file("/tmp/nonexistent_token_file_99999").unwrap();
assert!(result.is_none());
}
#[test]
fn test_read_token_file_empty_returns_none() {
let dir = tempfile::TempDir::new().unwrap();
let token_path = dir.path().join("empty_token");
std::fs::write(&token_path, "").unwrap();
let result = read_token_file(&token_path.to_string_lossy()).unwrap();
assert!(result.is_none());
}
#[test]
#[serial_test::serial]
fn test_load_token_files_reads_tokens() {
use std::io::Write;
let dir = tempfile::TempDir::new().unwrap();
let gh_path = dir.path().join("github_token");
let mut f = std::fs::File::create(&gh_path).unwrap();
writeln!(f, "ghp_test123").unwrap();
drop(f);
let gl_path = dir.path().join("gitlab_token");
let mut f = std::fs::File::create(&gl_path).unwrap();
writeln!(f, "glpat-test456").unwrap();
drop(f);
let config = EnvFilesTokenConfig {
github_token: Some(gh_path.to_string_lossy().to_string()),
gitlab_token: Some(gl_path.to_string_lossy().to_string()),
gitea_token: None, };
let orig_gh = std::env::var("GITHUB_TOKEN").ok();
let orig_gl = std::env::var("GITLAB_TOKEN").ok();
let orig_gt = std::env::var("GITEA_TOKEN").ok();
unsafe {
std::env::remove_var("GITHUB_TOKEN");
std::env::remove_var("GITLAB_TOKEN");
std::env::remove_var("GITEA_TOKEN");
}
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
let vars = load_token_files(&config, &log).unwrap();
unsafe {
if let Some(v) = orig_gh {
std::env::set_var("GITHUB_TOKEN", v);
}
if let Some(v) = orig_gl {
std::env::set_var("GITLAB_TOKEN", v);
}
if let Some(v) = orig_gt {
std::env::set_var("GITEA_TOKEN", v);
}
}
assert_eq!(vars.get("GITHUB_TOKEN").unwrap(), "ghp_test123");
assert_eq!(vars.get("GITLAB_TOKEN").unwrap(), "glpat-test456");
assert!(!vars.contains_key("GITEA_TOKEN"));
}
#[test]
#[serial_test::serial]
fn test_load_token_files_env_var_takes_precedence() {
use std::io::Write;
let dir = tempfile::TempDir::new().unwrap();
let gh_path = dir.path().join("github_token");
let mut f = std::fs::File::create(&gh_path).unwrap();
writeln!(f, "file_token").unwrap();
drop(f);
let config = EnvFilesTokenConfig {
github_token: Some(gh_path.to_string_lossy().to_string()),
gitlab_token: None,
gitea_token: None,
};
let orig = std::env::var("GITHUB_TOKEN").ok();
unsafe {
std::env::set_var("GITHUB_TOKEN", "env_token");
}
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
let vars = load_token_files(&config, &log).unwrap();
unsafe {
match orig {
Some(v) => std::env::set_var("GITHUB_TOKEN", v),
None => std::env::remove_var("GITHUB_TOKEN"),
}
}
assert!(
!vars.contains_key("GITHUB_TOKEN"),
"env var should take precedence; file should not be loaded"
);
}
#[test]
fn test_read_token_file_tilde_expansion() {
let dir = tempfile::TempDir::new().unwrap();
let token_path = dir.path().join(".config/goreleaser/github_token");
std::fs::create_dir_all(token_path.parent().unwrap()).unwrap();
std::fs::write(&token_path, "tilde_token\n").unwrap();
let orig_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", dir.path());
}
let result = read_token_file("~/.config/goreleaser/github_token").unwrap();
unsafe {
match orig_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
assert_eq!(result, Some("tilde_token".to_string()));
}
#[test]
fn test_load_env_files_sets_vars() {
use std::io::Write;
let dir = tempfile::TempDir::new().unwrap();
let env_path = dir.path().join(".env");
let mut f = std::fs::File::create(&env_path).unwrap();
writeln!(f, "# comment line").unwrap();
writeln!(f).unwrap();
writeln!(f, "TEST_ANODIZER_KEY=hello_world").unwrap();
writeln!(f, "TEST_ANODIZER_QUOTED=\"with quotes\"").unwrap();
writeln!(f, "TEST_ANODIZER_SINGLE='single_quoted'").unwrap();
writeln!(f, "export TEST_ANODIZER_EXPORT=exported_val").unwrap();
drop(f);
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
let vars = load_env_files(&[env_path.to_string_lossy().to_string()], &log, false).unwrap();
assert_eq!(vars.get("TEST_ANODIZER_KEY").unwrap(), "hello_world");
assert_eq!(vars.get("TEST_ANODIZER_QUOTED").unwrap(), "with quotes");
assert_eq!(
vars.get("TEST_ANODIZER_SINGLE").unwrap(),
"single_quoted",
"single-quoted values should have quotes stripped"
);
assert_eq!(
vars.get("TEST_ANODIZER_EXPORT").unwrap(),
"exported_val",
"export prefix should be stripped"
);
}
#[test]
fn test_load_env_files_edge_cases() {
use std::io::Write;
let dir = tempfile::TempDir::new().unwrap();
let env_path = dir.path().join(".env-edge");
let mut f = std::fs::File::create(&env_path).unwrap();
writeln!(f, "TEST_ANODIZER_SINGLEQ=\"").unwrap();
writeln!(f, "=orphan_value").unwrap();
writeln!(f, "NO_EQUALS_HERE").unwrap();
drop(f);
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
let vars = load_env_files(&[env_path.to_string_lossy().to_string()], &log, false).unwrap();
assert_eq!(vars.get("TEST_ANODIZER_SINGLEQ").unwrap(), "\"");
assert!(!vars.contains_key(""), "empty key should be skipped");
}
#[test]
fn test_load_env_files_nonexistent_skips_with_warning() {
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
let result = load_env_files(
&["/tmp/nonexistent_anodizer_env_file_12345".to_string()],
&log,
false,
);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_load_env_files_nonexistent_strict_mode_errors() {
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Normal);
let result = load_env_files(
&["/tmp/nonexistent_anodizer_env_file_12345".to_string()],
&log,
true,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("strict mode"));
}
#[test]
fn test_env_files_list_form_toml() {
#[derive(Deserialize)]
struct Wrapper {
env_files: EnvFilesConfig,
}
let toml_str = r#"env_files = [".env", ".env.local"]"#;
let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
let files = wrapper
.env_files
.as_list()
.unwrap_or_else(|| panic!("expected List variant"));
assert_eq!(files.len(), 2);
assert_eq!(files[0], ".env");
assert_eq!(files[1], ".env.local");
}
#[test]
fn test_env_files_struct_form_toml() {
#[derive(Deserialize)]
struct Wrapper {
env_files: EnvFilesConfig,
}
let toml_str = r#"
[env_files]
github_token = "~/.config/goreleaser/github_token"
gitlab_token = "/etc/tokens/gitlab"
"#;
let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
let tokens = wrapper
.env_files
.as_token_files()
.unwrap_or_else(|| panic!("expected TokenFiles variant"));
assert_eq!(
tokens.github_token.as_deref(),
Some("~/.config/goreleaser/github_token")
);
assert_eq!(tokens.gitlab_token.as_deref(), Some("/etc/tokens/gitlab"));
assert!(tokens.gitea_token.is_none());
}
#[test]
fn test_env_files_token_config_toml_rejects_unknown_fields() {
let toml_str = r#"github_tokne = "~/.config/goreleaser/github_token""#;
let result = toml::from_str::<EnvFilesTokenConfig>(toml_str);
assert!(
result.is_err(),
"EnvFilesTokenConfig should reject unknown fields like 'github_tokne'"
);
}
#[test]
fn test_build_ignore_parses() {
let yaml = r#"
project_name: test
defaults:
targets:
- x86_64-unknown-linux-gnu
- aarch64-unknown-linux-gnu
builds:
ignore:
- os: windows
arch: arm64
- os: linux
arch: "386"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let defaults = config.defaults.unwrap();
let ignores = defaults.builds.unwrap().ignore.unwrap();
assert_eq!(ignores.len(), 2);
assert_eq!(ignores[0].os, "windows");
assert_eq!(ignores[0].arch, "arm64");
assert_eq!(ignores[1].os, "linux");
assert_eq!(ignores[1].arch, "386");
}
#[test]
fn test_build_ignore_omitted() {
let yaml = r#"
project_name: test
defaults:
targets:
- x86_64-unknown-linux-gnu
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let defaults = config.defaults.unwrap();
assert!(defaults.builds.is_none());
}
#[test]
fn test_build_override_parses() {
let yaml = r#"
project_name: test
defaults:
builds:
overrides:
- targets:
- "x86_64-*"
features:
- simd
flags:
- "--release"
env:
- CC=gcc
- targets:
- "*-apple-darwin"
features:
- metal
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let defaults = config.defaults.unwrap();
let overrides = defaults.builds.unwrap().overrides.unwrap();
assert_eq!(overrides.len(), 2);
assert_eq!(overrides[0].targets, vec!["x86_64-*"]);
assert_eq!(overrides[0].features, Some(vec!["simd".to_string()]));
assert_eq!(overrides[0].flags, Some(vec!["--release".to_string()]));
assert!(
overrides[0]
.env
.as_ref()
.unwrap()
.contains(&"CC=gcc".to_string())
);
assert_eq!(overrides[1].targets, vec!["*-apple-darwin"]);
assert_eq!(overrides[1].features, Some(vec!["metal".to_string()]));
assert!(overrides[1].env.is_none());
}
#[test]
fn test_build_override_omitted() {
let yaml = r#"
project_name: test
defaults:
targets:
- x86_64-unknown-linux-gnu
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let defaults = config.defaults.unwrap();
assert!(defaults.builds.is_none());
}
#[test]
fn test_json_schema_generation() {
let schema = schemars::schema_for!(Config);
let json = serde_json::to_string_pretty(&schema).unwrap();
assert!(json.contains("project_name"));
assert!(json.contains("env_files"));
assert!(json.contains("version"));
assert!(json.contains("BuildIgnore"));
assert!(json.contains("BuildOverride"));
}
#[test]
fn test_git_config_all_fields() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
git:
tag_sort: "-version:creatordate"
ignore_tags:
- "nightly*"
- "legacy-*"
ignore_tag_prefixes:
- "internal/"
- "test-"
prerelease_suffix: "-rc"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let git = config
.git
.unwrap_or_else(|| panic!("git section should be present"));
assert_eq!(git.tag_sort.as_deref(), Some("-version:creatordate"));
assert_eq!(
git.ignore_tags.as_deref(),
Some(&["nightly*".to_string(), "legacy-*".to_string()][..])
);
assert_eq!(
git.ignore_tag_prefixes.as_deref(),
Some(&["internal/".to_string(), "test-".to_string()][..])
);
assert_eq!(git.prerelease_suffix.as_deref(), Some("-rc"));
}
#[test]
fn test_git_config_omitted_is_none() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.git.is_none());
}
#[test]
fn test_git_config_partial_only_tag_sort() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
git:
tag_sort: "-version:refname"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let git = config
.git
.unwrap_or_else(|| panic!("git section should be present"));
assert_eq!(git.tag_sort.as_deref(), Some("-version:refname"));
assert!(git.ignore_tags.is_none());
assert!(git.ignore_tag_prefixes.is_none());
assert!(git.prerelease_suffix.is_none());
}
#[test]
fn test_git_config_ignore_tags_accepts_array() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
git:
ignore_tags:
- "alpha*"
- "beta*"
- "rc-*"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let tags = config.git.unwrap().ignore_tags.unwrap();
assert_eq!(tags.len(), 3);
assert_eq!(tags[0], "alpha*");
assert_eq!(tags[1], "beta*");
assert_eq!(tags[2], "rc-*");
}
#[test]
fn test_validate_tag_sort_valid_refname() {
let config = Config {
git: Some(GitConfig {
tag_sort: Some("-version:refname".to_string()),
..Default::default()
}),
..Default::default()
};
assert!(validate_tag_sort(&config).is_ok());
}
#[test]
fn test_validate_tag_sort_valid_creatordate() {
let config = Config {
git: Some(GitConfig {
tag_sort: Some("-version:creatordate".to_string()),
..Default::default()
}),
..Default::default()
};
assert!(validate_tag_sort(&config).is_ok());
}
#[test]
fn test_validate_tag_sort_none_is_valid() {
let config = Config {
git: Some(GitConfig::default()),
..Default::default()
};
assert!(validate_tag_sort(&config).is_ok());
}
#[test]
fn test_validate_tag_sort_no_git_config_is_valid() {
let config = Config::default();
assert!(validate_tag_sort(&config).is_ok());
}
#[test]
fn test_validate_tag_sort_invalid_rejected() {
let config = Config {
git: Some(GitConfig {
tag_sort: Some("alphabetical".to_string()),
..Default::default()
}),
..Default::default()
};
let result = validate_tag_sort(&config);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("alphabetical"),
"error should contain the bad value: {}",
err
);
assert!(
err.contains("-version:refname"),
"error should list accepted values: {}",
err
);
}
#[test]
fn test_validate_tag_sort_valid_semver() {
let config = Config {
git: Some(GitConfig {
tag_sort: Some("semver".to_string()),
..Default::default()
}),
..Default::default()
};
assert!(validate_tag_sort(&config).is_ok());
}
#[test]
fn test_validate_tag_sort_valid_smartsemver() {
let config = Config {
git: Some(GitConfig {
tag_sort: Some("smartsemver".to_string()),
..Default::default()
}),
..Default::default()
};
assert!(validate_tag_sort(&config).is_ok());
}
#[test]
fn test_validate_tag_sort_invalid_lists_semver_modes() {
let config = Config {
git: Some(GitConfig {
tag_sort: Some("bogus".to_string()),
..Default::default()
}),
..Default::default()
};
let err = validate_tag_sort(&config).unwrap_err();
assert!(err.contains("semver"), "error should mention semver: {err}");
assert!(
err.contains("smartsemver"),
"error should mention smartsemver: {err}"
);
}
#[test]
fn test_git_config_yaml_accepts_semver() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
git:
tag_sort: "semver"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
config.git.as_ref().unwrap().tag_sort.as_deref(),
Some("semver")
);
assert!(validate_tag_sort(&config).is_ok());
}
#[test]
fn test_git_config_yaml_accepts_smartsemver() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
git:
tag_sort: "smartsemver"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
config.git.as_ref().unwrap().tag_sort.as_deref(),
Some("smartsemver")
);
assert!(validate_tag_sort(&config).is_ok());
}
#[test]
fn test_validate_partial_none_is_ok() {
let config = Config::default();
assert!(super::validate_partial(&config).is_ok());
}
#[test]
fn test_validate_partial_accepts_os_and_target() {
for by in ["os", "target"] {
let config = Config {
partial: Some(super::PartialConfig {
by: Some(by.to_string()),
}),
..Default::default()
};
assert!(
super::validate_partial(&config).is_ok(),
"partial.by={by} must validate"
);
}
}
#[test]
fn test_validate_partial_rejects_pre_rename_goos() {
let config = Config {
partial: Some(super::PartialConfig {
by: Some("goos".to_string()),
}),
..Default::default()
};
let err = super::validate_partial(&config).unwrap_err();
assert!(
err.contains("goos"),
"error should name the bad value: {err}"
);
assert!(
err.contains("\"os\"") && err.contains("\"target\""),
"error should list accepted values: {err}"
);
}
#[test]
fn test_validate_defaults_axis_no_defaults_is_ok() {
let config = Config::default();
assert!(validate_defaults_axis(&config).is_ok());
}
#[test]
fn test_validate_defaults_axis_crates_block_with_top_level_crates_is_ok() {
let yaml = r#"
project_name: test
defaults:
crates: {}
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(validate_defaults_axis(&config).is_ok());
}
#[test]
fn test_validate_defaults_axis_workspaces_block_with_top_level_workspaces_is_ok() {
let yaml = r#"
project_name: test
defaults:
workspaces: {}
workspaces:
- name: ws1
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(validate_defaults_axis(&config).is_ok());
}
#[test]
fn test_validate_defaults_axis_crates_block_without_top_level_crates_errors() {
let yaml = r#"
project_name: test
defaults:
crates: {}
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_defaults_axis(&config).unwrap_err();
assert!(
err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH),
"error should be tagged with the {ERR_DEFAULTS_AXIS_MISMATCH} marker prefix: {err}"
);
assert!(
err.contains("defaults.crates"),
"error should mention defaults.crates: {err}"
);
}
#[test]
fn test_validate_defaults_axis_workspaces_block_without_top_level_workspaces_errors() {
let yaml = r#"
project_name: test
defaults:
workspaces: {}
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_defaults_axis(&config).unwrap_err();
assert!(
err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH),
"error should be tagged with the {ERR_DEFAULTS_AXIS_MISMATCH} marker prefix: {err}"
);
assert!(
err.contains("defaults.workspaces"),
"error should mention defaults.workspaces: {err}"
);
}
#[test]
fn test_validate_defaults_axis_both_blocks_set_errors() {
let yaml = r#"
project_name: test
defaults:
crates: {}
workspaces: {}
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_defaults_axis(&config).unwrap_err();
assert!(
err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH),
"error should be tagged with the {ERR_DEFAULTS_AXIS_MISMATCH} marker prefix: {err}"
);
assert!(
err.contains("mutually exclusive"),
"error should mention mutual exclusion: {err}"
);
}
#[test]
fn test_validate_defaults_axis_wrong_axis_errors() {
let yaml = r#"
project_name: test
defaults:
crates: {}
workspaces:
- name: ws1
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_defaults_axis(&config).unwrap_err();
assert!(
err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH),
"error should be tagged with the {ERR_DEFAULTS_AXIS_MISMATCH} marker prefix: {err}"
);
assert!(
err.contains("workspaces"),
"error should mention top-level workspaces: {err}"
);
}
#[test]
fn test_validate_homebrew_cask_url_template_both_set_rejected() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew_cask:
url_template: "https://example.com/{{ .Tag }}/myapp.dmg"
url:
template: "https://example.com/{{ .Tag }}/myapp.dmg"
verified: "example.com"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_homebrew_cask_url_template(&config).unwrap_err();
assert!(
err.contains("url_template") && err.contains("url.template"),
"error should mention both conflicting fields: {err}"
);
assert!(
err.contains("mutually exclusive"),
"error should say they are mutually exclusive: {err}"
);
}
#[test]
fn test_validate_homebrew_cask_url_template_only_url_template_is_ok() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew_cask:
url_template: "https://example.com/{{ .Tag }}/myapp.dmg"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(validate_homebrew_cask_url_template(&config).is_ok());
}
#[test]
fn test_validate_homebrew_cask_url_template_only_url_is_ok() {
let yaml = r#"
project_name: test
homebrew_casks:
- name: myapp
url:
template: "https://example.com/{{ .Tag }}/myapp.dmg"
verified: "example.com"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(validate_homebrew_cask_url_template(&config).is_ok());
}
#[test]
fn test_validate_homebrew_cask_url_template_top_level_both_set_rejected() {
let yaml = r#"
project_name: test
homebrew_casks:
- name: myapp
url_template: "https://example.com/{{ .Tag }}/myapp.dmg"
url:
template: "https://example.com/{{ .Tag }}/myapp.dmg"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_homebrew_cask_url_template(&config).unwrap_err();
assert!(
err.contains("homebrew_casks[0]"),
"error should identify the offending entry: {err}"
);
}
#[test]
fn test_validate_homebrew_cask_url_template_defaults_axis_both_set_rejected() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
defaults:
publish:
homebrew_cask:
url_template: "https://example.com/{{ .Tag }}/myapp.dmg"
url:
template: "https://example.com/{{ .Tag }}/myapp.dmg"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_homebrew_cask_url_template(&config).unwrap_err();
assert!(
err.contains("defaults.publish.homebrew_cask"),
"error should identify the defaults axis location: {err}"
);
assert!(
err.contains("mutually exclusive"),
"error should say they are mutually exclusive: {err}"
);
}
#[test]
fn test_validate_homebrew_cask_url_template_workspace_axis_both_set_rejected() {
let yaml = r#"
project_name: test
crates: []
workspaces:
- name: ws1
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew_cask:
url_template: "https://example.com/{{ .Tag }}/myapp.dmg"
url:
template: "https://example.com/{{ .Tag }}/myapp.dmg"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_homebrew_cask_url_template(&config).unwrap_err();
assert!(
err.contains("workspaces[ws1].crates[myapp].publish.homebrew_cask"),
"error should identify the workspace axis location: {err}"
);
}
#[test]
fn test_git_config_ignore_tag_prefixes_accepts_array() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
git:
ignore_tag_prefixes:
- "wip/"
- "experiment/"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let prefixes = config.git.unwrap().ignore_tag_prefixes.unwrap();
assert_eq!(prefixes.len(), 2);
assert_eq!(prefixes[0], "wip/");
assert_eq!(prefixes[1], "experiment/");
}
#[test]
fn test_metadata_config_with_mod_timestamp() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
metadata:
mod_timestamp: "{{ .CommitTimestamp }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let meta = config.metadata.unwrap();
assert_eq!(meta.mod_timestamp.unwrap(), "{{ .CommitTimestamp }}");
}
#[test]
fn test_metadata_config_omitted_is_none() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.metadata.is_none());
}
#[test]
fn test_metadata_config_empty_section() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
metadata: {}
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let meta = config.metadata.unwrap();
assert!(meta.mod_timestamp.is_none());
}
#[test]
fn test_variables_config_parsed() {
let yaml = r#"
project_name: test
variables:
description: "my project description"
somethingElse: "yada yada yada"
empty: ""
crates:
- name: test
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let vars = config.variables.as_ref().unwrap();
assert_eq!(vars.get("description").unwrap(), "my project description");
assert_eq!(vars.get("somethingElse").unwrap(), "yada yada yada");
assert_eq!(vars.get("empty").unwrap(), "");
assert_eq!(vars.len(), 3);
}
#[test]
fn test_variables_config_omitted_is_none() {
let yaml = r#"
project_name: test
crates:
- name: test
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.variables.is_none());
}
#[test]
fn test_meta_accessors_return_values_when_set() {
let yaml = r#"
project_name: test
metadata:
homepage: "https://example.com"
description: "shared description"
license: "MIT"
maintainers:
- "Alice <a@example.com>"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.meta_homepage(), Some("https://example.com"));
assert_eq!(config.meta_description(), Some("shared description"));
assert_eq!(config.meta_license(), Some("MIT"));
assert_eq!(
config.meta_first_maintainer(),
Some("Alice <a@example.com>")
);
}
#[test]
fn test_meta_accessors_return_none_when_metadata_omitted() {
let yaml = r#"
project_name: test
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.meta_homepage().is_none());
assert!(config.meta_description().is_none());
assert!(config.meta_license().is_none());
assert!(config.meta_first_maintainer().is_none());
}
fn write_cargo(dir: &std::path::Path, body: &str) {
std::fs::write(dir.join("Cargo.toml"), body).unwrap();
}
#[test]
fn derived_metadata_fills_publisher_fields_without_yaml_block() {
let base = tempfile::tempdir().unwrap();
let crate_dir = base.path().join("app");
std::fs::create_dir_all(&crate_dir).unwrap();
write_cargo(
&crate_dir,
r#"
[package]
name = "app"
description = "the app crate"
license = "MIT"
homepage = "https://app.example"
authors = ["Ada <ada@example.com>"]
"#,
);
let mut config = Config {
project_name: "test".into(),
crates: vec![CrateConfig {
name: "app".into(),
path: "app".into(),
tag_template: "v{{ .Version }}".into(),
..Default::default()
}],
..Default::default()
};
assert!(config.metadata.is_none());
config.populate_derived_metadata(base.path());
assert_eq!(config.meta_description_for("app"), Some("the app crate"));
assert_eq!(config.meta_license_for("app"), Some("MIT"));
assert_eq!(config.meta_homepage_for("app"), Some("https://app.example"));
assert_eq!(
config.meta_first_maintainer_for("app"),
Some("Ada <ada@example.com>")
);
assert!(config.meta_description().is_none());
assert!(config.meta_license().is_none());
}
#[test]
fn yaml_metadata_block_wins_over_cargo_toml() {
let base = tempfile::tempdir().unwrap();
let crate_dir = base.path().join("app");
std::fs::create_dir_all(&crate_dir).unwrap();
write_cargo(
&crate_dir,
r#"
[package]
name = "app"
description = "from cargo"
license = "MIT"
"#,
);
let mut config = Config {
project_name: "test".into(),
metadata: Some(MetadataConfig {
description: Some("from yaml".into()),
license: Some("Apache-2.0".into()),
..Default::default()
}),
crates: vec![CrateConfig {
name: "app".into(),
path: "app".into(),
tag_template: "v{{ .Version }}".into(),
..Default::default()
}],
..Default::default()
};
config.populate_derived_metadata(base.path());
assert_eq!(config.meta_description_for("app"), Some("from yaml"));
assert_eq!(config.meta_license_for("app"), Some("Apache-2.0"));
}
#[test]
fn per_crate_workspace_each_crate_gets_its_own_cargo_metadata() {
let base = tempfile::tempdir().unwrap();
for (name, desc) in [("core", "the core lib"), ("csi", "the csi plugin")] {
let dir = base.path().join(name);
std::fs::create_dir_all(&dir).unwrap();
write_cargo(
&dir,
&format!("[package]\nname = \"{name}\"\ndescription = \"{desc}\"\nlicense = \"MIT\"\n"),
);
}
let mut config = Config {
project_name: "test".into(),
crates: vec![
CrateConfig {
name: "core".into(),
path: "core".into(),
tag_template: "v{{ .Version }}".into(),
..Default::default()
},
CrateConfig {
name: "csi".into(),
path: "csi".into(),
tag_template: "csi-v{{ .Version }}".into(),
..Default::default()
},
],
..Default::default()
};
config.populate_derived_metadata(base.path());
assert_eq!(config.meta_description_for("core"), Some("the core lib"));
assert_eq!(config.meta_description_for("csi"), Some("the csi plugin"));
}
#[test]
fn license_file_only_leaves_license_empty_no_fabrication() {
let base = tempfile::tempdir().unwrap();
let crate_dir = base.path().join("app");
std::fs::create_dir_all(&crate_dir).unwrap();
write_cargo(
&crate_dir,
r#"
[package]
name = "app"
description = "has a license file, not an SPDX id"
license-file = "LICENSE"
"#,
);
let mut config = Config {
project_name: "test".into(),
crates: vec![CrateConfig {
name: "app".into(),
path: "app".into(),
tag_template: "v{{ .Version }}".into(),
..Default::default()
}],
..Default::default()
};
config.populate_derived_metadata(base.path());
assert_eq!(
config.meta_description_for("app"),
Some("has a license file, not an SPDX id")
);
assert!(config.meta_license_for("app").is_none());
}
#[test]
fn test_snapcraft_disable_bool_true() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
snapcrafts:
- skip: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let snap = &config.crates[0].snapcrafts.as_ref().unwrap()[0];
assert_eq!(snap.skip, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_snapcraft_disable_bool_false() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
snapcrafts:
- skip: false
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let snap = &config.crates[0].snapcrafts.as_ref().unwrap()[0];
assert_eq!(snap.skip, Some(StringOrBool::Bool(false)));
}
#[test]
fn test_snapcraft_disable_template_string() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
snapcrafts:
- skip: "{{ if .IsSnapshot }}true{{ end }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let snap = &config.crates[0].snapcrafts.as_ref().unwrap()[0];
match &snap.skip {
Some(StringOrBool::String(s)) => {
assert!(s.contains("IsSnapshot"));
}
other => panic!("expected StringOrBool::String, got {:?}", other),
}
}
#[test]
fn test_snapcraft_disable_omitted() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
snapcrafts:
- name: mysnap
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let snap = &config.crates[0].snapcrafts.as_ref().unwrap()[0];
assert!(snap.skip.is_none());
}
#[test]
fn test_docker_v2_skip_push_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
dockers_v2:
- dockerfile: Dockerfile
images: ["ghcr.io/owner/app"]
tags: ["{{ .Version }}"]
skip_push: true
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"docker_v2[].skip_push must be rejected (use canonical `skip:`)"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("skip_push") || err.contains("unknown field"),
"error should mention the rejected field; got: {err}"
);
}
#[test]
fn test_snapcraft_top_level_slots_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
snapcrafts:
- name: mysnap
slots:
dbus-svc:
interface: dbus
bus: session
name: com.example.svc
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
assert!(
result.is_err(),
"snapcrafts[].slots must be rejected (use apps.<name>.slots)"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("slots") || err.contains("unknown field"),
"error should mention the rejected field; got: {err}"
);
}
#[test]
fn test_aur_disable_bool_true() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
aur:
skip: true
git_url: "ssh://aur@aur.archlinux.org/a.git"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let aur = config.crates[0]
.publish
.as_ref()
.unwrap()
.aur
.as_ref()
.unwrap();
assert_eq!(aur.skip, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_aur_disable_template_string() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
aur:
skip: "{{ if .IsSnapshot }}true{{ end }}"
git_url: "ssh://aur@aur.archlinux.org/a.git"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let aur = config.crates[0]
.publish
.as_ref()
.unwrap()
.aur
.as_ref()
.unwrap();
match &aur.skip {
Some(StringOrBool::String(s)) => {
assert!(s.contains("IsSnapshot"));
}
other => panic!("expected StringOrBool::String, got {:?}", other),
}
}
#[test]
fn test_aur_disable_omitted() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
aur:
git_url: "ssh://aur@aur.archlinux.org/a.git"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let aur = config.crates[0]
.publish
.as_ref()
.unwrap()
.aur
.as_ref()
.unwrap();
assert!(aur.skip.is_none());
}
#[test]
fn test_publisher_disable_bool_true() {
let yaml = r#"
project_name: test
publishers:
- cmd: "echo hello"
skip: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let pub_cfg = &config.publishers.as_ref().unwrap()[0];
assert_eq!(pub_cfg.skip, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_publisher_disable_template_string() {
let yaml = r#"
project_name: test
publishers:
- cmd: "echo hello"
skip: "{{ if .IsSnapshot }}true{{ end }}"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let pub_cfg = &config.publishers.as_ref().unwrap()[0];
match &pub_cfg.skip {
Some(StringOrBool::String(s)) => {
assert!(s.contains("IsSnapshot"));
}
other => panic!("expected StringOrBool::String, got {:?}", other),
}
}
#[test]
fn test_publisher_disable_omitted() {
let yaml = r#"
project_name: test
publishers:
- cmd: "echo hello"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let pub_cfg = &config.publishers.as_ref().unwrap()[0];
assert!(pub_cfg.skip.is_none());
}
#[test]
fn test_disable_alias_sets_skip_truthy() {
let yaml = r#"
project_name: test
publishers:
- cmd: "echo hello"
disable: true
sboms:
- artifacts: archive
disable: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
checksum:
disable: true
docker_digest:
disable: true
flatpaks:
- app_id: org.example.App
disable: true
blobs:
- provider: s3
bucket: my-bucket
disable: true
publish:
aur:
disable: true
aur_source:
disable: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let crate_cfg = &config.crates[0];
let publish = crate_cfg.publish.as_ref().unwrap();
let truthy: &[(&str, &Option<StringOrBool>)] = &[
("publisher", &config.publishers.as_ref().unwrap()[0].skip),
("sbom", &config.sboms[0].skip),
("checksum", &crate_cfg.checksum.as_ref().unwrap().skip),
(
"docker_digest",
&crate_cfg.docker_digest.as_ref().unwrap().skip,
),
("flatpak", &crate_cfg.flatpaks.as_ref().unwrap()[0].skip),
("blob", &crate_cfg.blobs.as_ref().unwrap()[0].skip),
("aur", &publish.aur.as_ref().unwrap().skip),
("aur_source", &publish.aur_source.as_ref().unwrap().skip),
];
for (label, skip) in truthy {
assert!(
skip.as_ref().is_some_and(StringOrBool::as_bool),
"{label} `disable: true` should set skip truthy"
);
}
}
#[test]
fn test_canonical_skip_still_parses_truthy() {
let yaml = r#"
project_name: test
publishers:
- cmd: "echo hello"
skip: true
sboms:
- artifacts: archive
skip: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(
config.publishers.as_ref().unwrap()[0]
.skip
.as_ref()
.is_some_and(StringOrBool::as_bool),
"publisher `skip: true` should set skip truthy"
);
assert!(
config.sboms[0]
.skip
.as_ref()
.is_some_and(StringOrBool::as_bool),
"sbom `skip: true` should set skip truthy"
);
}
#[test]
fn test_aur_skip_upload_bool_true() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
aur:
skip_upload: true
git_url: "ssh://aur@aur.archlinux.org/a.git"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let aur = config.crates[0]
.publish
.as_ref()
.unwrap()
.aur
.as_ref()
.unwrap();
assert_eq!(aur.skip_upload, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_nix_skip_upload_template() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
nix:
skip_upload: "{{ .Env.SKIP }}"
repository:
owner: org
name: nixpkgs
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let nix = config.crates[0]
.publish
.as_ref()
.unwrap()
.nix
.as_ref()
.unwrap();
match &nix.skip_upload {
Some(StringOrBool::String(s)) => {
assert!(s.contains(".Env.SKIP"));
}
other => panic!("expected StringOrBool::String, got {:?}", other),
}
}
#[test]
fn test_template_files_parses_from_yaml() {
let yaml = r#"
project_name: myproject
crates: []
template_files:
- id: install-script
src: install.sh.tpl
dst: install.sh
mode: "0755"
- src: README.md.tpl
dst: README.md
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let tfs = config.template_files.unwrap();
assert_eq!(tfs.len(), 2);
assert_eq!(tfs[0].id.as_deref(), Some("install-script"));
assert_eq!(tfs[0].src, "install.sh.tpl");
assert_eq!(tfs[0].dst, "install.sh");
assert_eq!(tfs[0].mode, Some("0755".to_string()));
assert_eq!(tfs[1].id, None);
assert_eq!(tfs[1].src, "README.md.tpl");
assert_eq!(tfs[1].dst, "README.md");
assert_eq!(tfs[1].mode, None);
}
#[test]
fn test_template_files_defaults_to_none() {
let yaml = r#"
project_name: myproject
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.template_files.is_none());
}
#[test]
fn test_include_spec_plain_string() {
let yaml = r#"
project_name: test
includes:
- ./defaults.yaml
- extra.yaml
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let includes = config.includes.unwrap();
assert_eq!(includes.len(), 2);
assert_eq!(
includes[0],
IncludeSpec::Path("./defaults.yaml".to_string())
);
assert_eq!(includes[1], IncludeSpec::Path("extra.yaml".to_string()));
}
#[test]
fn test_include_spec_from_file() {
let yaml = r#"
project_name: test
includes:
- from_file:
path: ./config/goreleaser.yaml
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let includes = config.includes.unwrap();
assert_eq!(includes.len(), 1);
assert_eq!(
includes[0],
IncludeSpec::FromFile {
from_file: IncludeFilePath {
path: "./config/goreleaser.yaml".to_string(),
},
}
);
}
#[test]
fn test_include_spec_from_url_without_headers() {
let yaml = r#"
project_name: test
includes:
- from_url:
url: https://example.com/config.yaml
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let includes = config.includes.unwrap();
assert_eq!(includes.len(), 1);
assert_eq!(
includes[0],
IncludeSpec::FromUrl {
from_url: IncludeUrlConfig {
url: "https://example.com/config.yaml".to_string(),
headers: None,
},
}
);
}
#[test]
fn test_include_spec_from_url_with_headers() {
let yaml = r#"
project_name: test
includes:
- from_url:
url: https://api.mycompany.com/configs/release.yaml
headers:
x-api-token: "${MYCOMPANY_TOKEN}"
Authorization: "Bearer ${GITHUB_TOKEN}"
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let includes = config.includes.unwrap();
assert_eq!(includes.len(), 1);
match &includes[0] {
IncludeSpec::FromUrl { from_url } => {
assert_eq!(
from_url.url,
"https://api.mycompany.com/configs/release.yaml"
);
let headers = from_url.headers.as_ref().unwrap();
assert_eq!(headers.len(), 2);
assert_eq!(headers["x-api-token"], "${MYCOMPANY_TOKEN}");
assert_eq!(headers["Authorization"], "Bearer ${GITHUB_TOKEN}");
}
other => panic!("expected FromUrl, got: {:?}", other),
}
}
#[test]
fn test_include_spec_mixed_forms() {
let yaml = r#"
project_name: test
includes:
- ./defaults.yaml
- from_file:
path: ./config/shared.yaml
- from_url:
url: https://example.com/config.yaml
headers:
x-token: secret
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let includes = config.includes.unwrap();
assert_eq!(includes.len(), 3);
assert!(matches!(&includes[0], IncludeSpec::Path(s) if s == "./defaults.yaml"));
assert!(
matches!(&includes[1], IncludeSpec::FromFile { from_file } if from_file.path == "./config/shared.yaml")
);
assert!(
matches!(&includes[2], IncludeSpec::FromUrl { from_url } if from_url.url == "https://example.com/config.yaml")
);
}
#[test]
fn test_include_spec_no_includes_field() {
let yaml = r#"
project_name: test
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.includes.is_none());
}
#[test]
fn test_include_spec_empty_includes() {
let yaml = r#"
project_name: test
includes: []
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.includes, Some(vec![]));
}
#[test]
fn test_include_spec_github_shorthand_url() {
let yaml = r#"
project_name: test
includes:
- from_url:
url: caarlos0/goreleaserfiles/main/packages.yml
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let includes = config.includes.unwrap();
assert_eq!(includes.len(), 1);
match &includes[0] {
IncludeSpec::FromUrl { from_url } => {
assert_eq!(from_url.url, "caarlos0/goreleaserfiles/main/packages.yml");
}
other => panic!("expected FromUrl, got: {:?}", other),
}
}
#[test]
fn test_github_urls_config_all_fields() {
let yaml = r#"
api: https://github.example.com/api/v3/
upload: https://github.example.com/api/uploads/
download: https://github.example.com/
skip_tls_verify: true
"#;
let cfg: GitHubUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
cfg.api.as_deref(),
Some("https://github.example.com/api/v3/")
);
assert_eq!(
cfg.upload.as_deref(),
Some("https://github.example.com/api/uploads/")
);
assert_eq!(cfg.download.as_deref(), Some("https://github.example.com/"));
assert_eq!(cfg.skip_tls_verify, Some(true));
}
#[test]
fn test_github_urls_config_defaults() {
let yaml = "{}";
let cfg: GitHubUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.api, None);
assert_eq!(cfg.upload, None);
assert_eq!(cfg.download, None);
assert_eq!(cfg.skip_tls_verify, None);
}
#[test]
fn test_gitlab_urls_config_all_fields() {
let yaml = r#"
api: https://gitlab.example.com/api/v4/
download: https://gitlab.example.com/
skip_tls_verify: false
use_package_registry: true
use_job_token: true
"#;
let cfg: GitLabUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
cfg.api.as_deref(),
Some("https://gitlab.example.com/api/v4/")
);
assert_eq!(cfg.download.as_deref(), Some("https://gitlab.example.com/"));
assert_eq!(cfg.skip_tls_verify, Some(false));
assert_eq!(cfg.use_package_registry, Some(true));
assert_eq!(cfg.use_job_token, Some(true));
}
#[test]
fn test_gitlab_urls_config_defaults() {
let yaml = "{}";
let cfg: GitLabUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.api, None);
assert_eq!(cfg.download, None);
assert_eq!(cfg.skip_tls_verify, None);
assert_eq!(cfg.use_package_registry, None);
assert_eq!(cfg.use_job_token, None);
}
#[test]
fn test_gitea_urls_config_all_fields() {
let yaml = r#"
api: https://gitea.example.com/api/v1/
download: https://gitea.example.com/
skip_tls_verify: true
"#;
let cfg: GiteaUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
cfg.api.as_deref(),
Some("https://gitea.example.com/api/v1/")
);
assert_eq!(cfg.download.as_deref(), Some("https://gitea.example.com/"));
assert_eq!(cfg.skip_tls_verify, Some(true));
}
#[test]
fn test_gitea_urls_config_defaults() {
let yaml = "{}";
let cfg: GiteaUrlsConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.api, None);
assert_eq!(cfg.download, None);
assert_eq!(cfg.skip_tls_verify, None);
}
#[test]
fn test_release_config_gitlab_gitea_fields() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
release:
github:
owner: gh-owner
name: gh-repo
gitlab:
owner: gitlab-owner
name: gitlab-repo
gitea:
owner: gitea-owner
name: gitea-repo
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let release = config.crates[0].release.as_ref().unwrap();
let github = release.github.as_ref().unwrap();
assert_eq!(github.owner, "gh-owner");
assert_eq!(github.name, "gh-repo");
let gitlab = release.gitlab.as_ref().unwrap();
assert_eq!(gitlab.owner, "gitlab-owner");
assert_eq!(gitlab.name, "gitlab-repo");
let gitea = release.gitea.as_ref().unwrap();
assert_eq!(gitea.owner, "gitea-owner");
assert_eq!(gitea.name, "gitea-repo");
}
#[test]
fn test_config_github_urls_field() {
let yaml = r#"
project_name: test
github_urls:
api: https://ghe.corp.com/api/v3/
upload: https://ghe.corp.com/api/uploads/
download: https://ghe.corp.com/
skip_tls_verify: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let urls = config.github_urls.as_ref().unwrap();
assert_eq!(urls.api.as_deref(), Some("https://ghe.corp.com/api/v3/"));
assert_eq!(
urls.upload.as_deref(),
Some("https://ghe.corp.com/api/uploads/")
);
assert_eq!(urls.download.as_deref(), Some("https://ghe.corp.com/"));
assert_eq!(urls.skip_tls_verify, Some(true));
}
#[test]
fn test_config_gitlab_urls_field() {
let yaml = r#"
project_name: test
gitlab_urls:
api: https://gitlab.corp.com/api/v4/
download: https://gitlab.corp.com/
skip_tls_verify: false
use_package_registry: true
use_job_token: false
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let urls = config.gitlab_urls.as_ref().unwrap();
assert_eq!(urls.api.as_deref(), Some("https://gitlab.corp.com/api/v4/"));
assert_eq!(urls.download.as_deref(), Some("https://gitlab.corp.com/"));
assert_eq!(urls.skip_tls_verify, Some(false));
assert_eq!(urls.use_package_registry, Some(true));
assert_eq!(urls.use_job_token, Some(false));
}
#[test]
fn test_config_gitea_urls_field() {
let yaml = r#"
project_name: test
gitea_urls:
api: https://gitea.corp.com/api/v1/
download: https://gitea.corp.com/
skip_tls_verify: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let urls = config.gitea_urls.as_ref().unwrap();
assert_eq!(urls.api.as_deref(), Some("https://gitea.corp.com/api/v1/"));
assert_eq!(urls.download.as_deref(), Some("https://gitea.corp.com/"));
assert_eq!(urls.skip_tls_verify, Some(true));
}
#[test]
fn test_config_force_token_field() {
let yaml = r#"
project_name: test
force_token: gitlab
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.force_token, Some(ForceTokenKind::GitLab));
}
#[test]
fn test_config_force_token_omitted() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.force_token, None::<ForceTokenKind>);
}
#[test]
fn test_config_all_platform_urls_and_force_token() {
let yaml = r#"
project_name: test
github_urls:
api: https://ghe.corp.com/api/v3/
gitlab_urls:
api: https://gitlab.corp.com/api/v4/
use_job_token: true
gitea_urls:
api: https://gitea.corp.com/api/v1/
force_token: github
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
config.github_urls.as_ref().unwrap().api.as_deref(),
Some("https://ghe.corp.com/api/v3/")
);
assert_eq!(
config.gitlab_urls.as_ref().unwrap().api.as_deref(),
Some("https://gitlab.corp.com/api/v4/")
);
assert_eq!(
config.gitlab_urls.as_ref().unwrap().use_job_token,
Some(true)
);
assert_eq!(
config.gitea_urls.as_ref().unwrap().api.as_deref(),
Some("https://gitea.corp.com/api/v1/")
);
assert_eq!(config.force_token, Some(ForceTokenKind::GitHub));
}
#[test]
fn test_dockerhub_config_parse() {
let yaml = r#"
project_name: test
dockerhub:
- username: myuser
secret_name: DOCKER_TOKEN
images:
- myorg/myapp
description: "My app"
skip: true
full_description:
from_file:
path: ./README.md
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let dh = &cfg.dockerhub.unwrap()[0];
assert_eq!(dh.username.as_deref(), Some("myuser"));
assert_eq!(dh.secret_name.as_deref(), Some("DOCKER_TOKEN"));
assert_eq!(dh.images.as_ref().unwrap(), &["myorg/myapp"]);
assert_eq!(dh.description.as_deref(), Some("My app"));
assert_eq!(dh.skip, Some(StringOrBool::Bool(true)));
let fd = dh.full_description.as_ref().unwrap();
assert!(fd.from_url.is_none());
let ff = fd.from_file.as_ref().unwrap();
assert_eq!(ff.path, "./README.md");
}
#[test]
fn test_dockerhub_from_url_parse() {
let yaml = r#"
project_name: test
dockerhub:
- username: myuser
full_description:
from_url:
url: "https://raw.githubusercontent.com/org/repo/main/README.md"
headers:
Authorization: "Bearer {{ .Env.GH_TOKEN }}"
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let dh = &cfg.dockerhub.unwrap()[0];
let fu = dh
.full_description
.as_ref()
.unwrap()
.from_url
.as_ref()
.unwrap();
assert_eq!(
fu.url,
"https://raw.githubusercontent.com/org/repo/main/README.md"
);
let headers = fu.headers.as_ref().unwrap();
assert_eq!(
headers.get("Authorization").unwrap(),
"Bearer {{ .Env.GH_TOKEN }}"
);
}
#[test]
fn test_artifactory_config_parse() {
let yaml = r#"
project_name: test
artifactories:
- name: production
target: "https://artifactory.example.com/repo/{{ .ProjectName }}/{{ .Version }}/"
username: deployer
mode: archive
skip: "{{ .Env.SKIP }}"
ids:
- default
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let art = &cfg.artifactories.unwrap()[0];
assert_eq!(art.name.as_deref(), Some("production"));
assert_eq!(
art.target.as_deref(),
Some("https://artifactory.example.com/repo/{{ .ProjectName }}/{{ .Version }}/")
);
assert_eq!(art.username.as_deref(), Some("deployer"));
assert_eq!(art.mode.as_deref(), Some("archive"));
assert_eq!(
art.skip,
Some(StringOrBool::String("{{ .Env.SKIP }}".to_string()))
);
assert_eq!(art.ids.as_ref().unwrap(), &["default"]);
}
#[test]
fn test_cloudsmith_config_parse() {
let yaml = r#"
project_name: test
cloudsmiths:
- organization: myorg
repository: myrepo
formats:
- deb
distributions:
deb: "ubuntu/focal"
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cs = &cfg.cloudsmiths.unwrap()[0];
assert_eq!(cs.organization.as_deref(), Some("myorg"));
assert_eq!(cs.repository.as_deref(), Some("myrepo"));
assert_eq!(cs.formats.as_ref().unwrap(), &["deb"]);
let dists = cs.distributions.as_ref().unwrap();
match dists.get("deb").unwrap() {
crate::config::CloudSmithDistributions::Single(s) => {
assert_eq!(s, "ubuntu/focal");
}
other => panic!("expected Single, got {:?}", other),
}
}
#[test]
fn test_cloudsmith_distributions_array_form() {
let yaml = r#"
project_name: test
cloudsmiths:
- organization: myorg
repository: myrepo
distributions:
deb:
- ubuntu/focal
- ubuntu/jammy
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cs = &cfg.cloudsmiths.unwrap()[0];
let dists = cs.distributions.as_ref().unwrap();
match dists.get("deb").unwrap() {
crate::config::CloudSmithDistributions::Multiple(v) => {
assert_eq!(
v,
&vec!["ubuntu/focal".to_string(), "ubuntu/jammy".to_string()]
);
}
other => panic!("expected Multiple, got {:?}", other),
}
}
#[test]
fn test_docker_sign_env_list_format() {
let yaml = r#"
project_name: test
docker_signs:
- cmd: cosign
env:
- COSIGN_PASSWORD=hunter2
- COSIGN_KEY=/path/to/key
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let ds = &cfg.docker_signs.as_ref().unwrap()[0];
let env = ds.env.as_ref().expect("env should be Some");
assert_eq!(
env,
&vec!["COSIGN_PASSWORD=hunter2", "COSIGN_KEY=/path/to/key"]
);
}
#[test]
fn test_docker_sign_env_map_form_rejected() {
let yaml = r#"
project_name: test
docker_signs:
- cmd: cosign
env:
COSIGN_PASSWORD: hunter2
"#;
let result = serde_yaml_ng::from_str::<Config>(yaml);
assert!(
result.is_err(),
"map form should be rejected after Vec<String> migration"
);
}
#[test]
fn test_docker_sign_env_null() {
let yaml = r#"
project_name: test
docker_signs:
- cmd: cosign
env: ~
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let ds = &cfg.docker_signs.as_ref().unwrap()[0];
assert!(ds.env.is_none());
}
#[test]
fn test_docker_sign_env_missing() {
let yaml = r#"
project_name: test
docker_signs:
- cmd: cosign
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let ds = &cfg.docker_signs.as_ref().unwrap()[0];
assert!(ds.env.is_none());
}
#[test]
fn test_sign_config_env_list_format() {
let yaml = r#"
project_name: test
signs:
- cmd: gpg
env:
- GPG_KEY=ABCDEF
- GPG_TTY=/dev/pts/0
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let s = &cfg.signs[0];
let env = s.env.as_ref().expect("env should be Some");
assert_eq!(env, &vec!["GPG_KEY=ABCDEF", "GPG_TTY=/dev/pts/0"]);
}
#[test]
fn test_publisher_env_list_format() {
let yaml = r#"
project_name: test
publishers:
- name: mypub
cmd: publish.sh
env:
- API_TOKEN=secret123
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let p = &cfg.publishers.as_ref().unwrap()[0];
let env = p.env.as_ref().expect("env should be Some");
assert_eq!(env, &vec!["API_TOKEN=secret123"]);
}
#[test]
fn test_publisher_if_condition_parses() {
let yaml = r#"
project_name: test
publishers:
- name: snapshot-only
cmd: publish.sh
if: "{{ IsSnapshot }}"
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).expect("config parses with if:");
let p = &cfg.publishers.as_ref().unwrap()[0];
assert_eq!(p.if_condition.as_deref(), Some("{{ IsSnapshot }}"));
}
#[test]
fn test_archive_if_condition_parses() {
let yaml = r#"
project_name: test
defaults:
archives:
id: linux-only
formats: [tar.gz]
if: "{{ eq .Os \"linux\" }}"
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).expect("archives.if: parses");
let archive = cfg.defaults.unwrap().archives.unwrap();
assert_eq!(
archive.if_condition.as_deref(),
Some("{{ eq .Os \"linux\" }}"),
"ArchiveConfig must round-trip the `if:` field",
);
}
#[test]
fn test_hook_if_condition_parses() {
let yaml = r#"
project_name: test
before:
hooks:
- cmd: "go mod tidy"
if: "{{ IsSnapshot }}"
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).expect("hook.if: parses");
let hooks = cfg.before.unwrap().hooks.unwrap();
match &hooks[0] {
crate::config::HookEntry::Structured(h) => {
assert_eq!(h.if_condition.as_deref(), Some("{{ IsSnapshot }}"));
}
other => panic!("expected structured hook, got {other:?}"),
}
}
#[test]
fn test_build_override_env_list_format() {
let yaml = r#"
project_name: test
defaults:
targets:
- x86_64-unknown-linux-gnu
builds:
overrides:
- targets:
- "x86_64-*"
env:
- CC=gcc-12
- CFLAGS=-O2 -Wall
crates: []
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let overrides = config.defaults.unwrap().builds.unwrap().overrides.unwrap();
let env = overrides[0].env.as_ref().expect("env should be Some");
assert_eq!(env, &vec!["CC=gcc-12", "CFLAGS=-O2 -Wall"]);
}
#[test]
fn test_structured_hook_env_list_format() {
let yaml = r#"
project_name: test
before:
hooks:
- cmd: echo hello
env:
- MY_VAR=foo
- OTHER=bar=baz
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let hooks = cfg.before.as_ref().unwrap().hooks.as_ref().unwrap();
match &hooks[0] {
HookEntry::Structured(h) => {
let env = h.env.as_ref().expect("env should be Some");
assert_eq!(env, &vec!["MY_VAR=foo", "OTHER=bar=baz"]);
}
HookEntry::Simple(_) => panic!("expected Structured hook"),
}
}
#[test]
fn test_sbom_config_env_list_format() {
let yaml = r#"
project_name: test
sboms:
- cmd: syft
env:
- SYFT_FILE_METADATA_CATALOGER_ENABLED=true
- SYFT_SCOPE=all-layers
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let s = &cfg.sboms[0];
let env = s.env.as_ref().expect("env should be Some");
assert_eq!(
env,
&vec![
"SYFT_FILE_METADATA_CATALOGER_ENABLED=true",
"SYFT_SCOPE=all-layers"
]
);
}
#[test]
fn test_sbom_config_env_missing() {
let yaml = r#"
project_name: test
sboms:
- cmd: syft
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let s = &cfg.sboms[0];
assert!(s.env.is_none());
}
#[track_caller]
fn assert_env_map_rejected(yaml: &str, label: &str) {
let result = serde_yaml_ng::from_str::<Config>(yaml);
assert!(
result.is_err(),
"{label}.env map form should be rejected after Vec<String> migration"
);
}
#[test]
fn test_top_level_env_map_form_rejected() {
let yaml = r#"
project_name: test
crates: []
env:
MY_VAR: hello
"#;
assert_env_map_rejected(yaml, "top-level Config");
}
#[test]
fn test_build_override_env_map_form_rejected() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ version }}"
builds:
- binary: app
defaults:
builds:
overrides:
- targets: ["x86_64-unknown-linux-gnu"]
env:
MY_VAR: hello
"#;
assert_env_map_rejected(yaml, "BuildOverride");
}
#[test]
fn test_sign_config_env_map_form_rejected() {
let yaml = r#"
project_name: test
crates: []
signs:
- cmd: cosign
env:
COSIGN_PASSWORD: hunter2
"#;
assert_env_map_rejected(yaml, "SignConfig");
}
#[test]
fn test_sbom_config_env_map_form_rejected() {
let yaml = r#"
project_name: test
sboms:
- cmd: syft
env:
MY_VAR: value
"#;
assert_env_map_rejected(yaml, "SbomConfig");
}
#[test]
fn test_workspace_env_map_form_rejected() {
let yaml = r#"
project_name: test
workspaces:
- name: myws
crates: []
env:
MY_VAR: value
"#;
assert_env_map_rejected(yaml, "WorkspaceConfig");
}
#[test]
fn test_publisher_config_env_map_form_rejected() {
let yaml = r#"
project_name: test
crates: []
publishers:
- cmd: "my-publisher"
env:
MY_VAR: value
"#;
assert_env_map_rejected(yaml, "PublisherConfig");
}
#[test]
fn test_structured_hook_env_map_form_rejected() {
let yaml = r#"
project_name: test
crates: []
before:
hooks:
- cmd: "echo hello"
env:
MY_VAR: value
"#;
assert_env_map_rejected(yaml, "StructuredHook");
}
#[test]
fn test_validate_format_overrides_in_defaults_block_rejects_unknown_os() {
let yaml = r#"
project_name: test
defaults:
archives:
format_overrides:
- os: pc-windows-msvc
formats: [zip]
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_format_overrides(&config).unwrap_err();
assert!(
err.contains("defaults.archives"),
"error should locate the offender at defaults.archives: {err}"
);
assert!(
err.contains("pc-windows-msvc"),
"error should echo the bad os value: {err}"
);
}
#[test]
fn test_validate_format_overrides_in_defaults_block_accepts_known_os() {
let yaml = r#"
project_name: test
defaults:
archives:
format_overrides:
- os: windows
formats: [zip]
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
validate_format_overrides(&config).expect("known os value should pass");
}
#[test]
fn test_defaults_crates_block_rejects_unknown_field() {
let yaml = r#"
project_name: test
defaults:
crates:
foo: bar
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
let err = result.expect_err("unknown field under defaults.crates should be rejected");
let msg = err.to_string();
assert!(
msg.contains("unknown field"),
"error should mention 'unknown field': {msg}"
);
}
#[test]
fn test_defaults_workspaces_block_rejects_unknown_field() {
let yaml = r#"
project_name: test
defaults:
workspaces:
foo: bar
workspaces:
- name: ws1
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
crates: []
"#;
let result: Result<Config, _> = serde_yaml_ng::from_str(yaml);
let err = result.expect_err("unknown field under defaults.workspaces should be rejected");
let msg = err.to_string();
assert!(
msg.contains("unknown field"),
"error should mention 'unknown field': {msg}"
);
}
#[test]
fn test_try_evaluates_to_true_plain_literal_invokes_render() {
let calls = std::cell::RefCell::new(Vec::<String>::new());
let render = |s: &str| -> anyhow::Result<String> {
calls.borrow_mut().push(s.to_string());
Ok(s.to_string())
};
let val = StringOrBool::String("true".to_string());
let got = val
.try_evaluates_to_true(render)
.expect("plain literal 'true' should evaluate without error");
assert!(got, "plain literal 'true' should resolve to true");
assert_eq!(
calls.borrow().as_slice(),
&["true".to_string()],
"render closure must be invoked exactly once with the raw value, even for plain literals",
);
calls.borrow_mut().clear();
let val = StringOrBool::String("false".to_string());
let got = val.try_evaluates_to_true(render).expect("plain false ok");
assert!(!got);
assert_eq!(
calls.borrow().as_slice(),
&["false".to_string()],
"render closure must run for plain-literal 'false' too",
);
}
#[test]
fn test_try_evaluates_to_true_invalid_template_surfaces_error() {
let render = |_: &str| -> anyhow::Result<String> { anyhow::bail!("tera parse error") };
let val = StringOrBool::String("{{ broken".to_string());
let err = val
.try_evaluates_to_true(render)
.expect_err("malformed template must surface as Err");
assert!(
err.to_string().contains("tera parse error"),
"error must propagate from render closure: {err}",
);
}
#[test]
fn test_try_evaluates_to_true_bool_variant_skips_render() {
let render = |_: &str| -> anyhow::Result<String> {
panic!("render closure must not be called for StringOrBool::Bool");
};
assert!(
StringOrBool::Bool(true)
.try_evaluates_to_true(render)
.unwrap()
);
assert!(
!StringOrBool::Bool(false)
.try_evaluates_to_true(render)
.unwrap()
);
}
#[test]
fn test_evaluate_if_condition_none_proceeds() {
use super::evaluate_if_condition;
let render = |_: &str| -> anyhow::Result<String> {
panic!("render must not run when condition is None")
};
assert!(
evaluate_if_condition(None, "x", render).unwrap(),
"no condition set must always proceed",
);
}
#[test]
fn test_evaluate_if_condition_empty_proceeds() {
use super::evaluate_if_condition;
let render = |_: &str| -> anyhow::Result<String> {
panic!("render must not run for empty literal — empty gate is a no-op")
};
assert!(
evaluate_if_condition(Some(""), "x", render).unwrap(),
"empty `if:` literal must be a no-op (matches GR's no-`if:`=always-run)",
);
}
#[test]
fn test_evaluate_if_condition_falsy_values_skip() {
use super::evaluate_if_condition;
for falsy in ["false", "0", "no", " false ", ""] {
let v = falsy.to_string();
let render = move |_: &str| Ok(v.clone());
let proceed = evaluate_if_condition(Some("anything"), "x", render).unwrap();
assert!(
!proceed,
"rendered value {falsy:?} (trimmed) must skip the resource",
);
}
}
#[test]
fn test_evaluate_if_condition_truthy_proceeds() {
use super::evaluate_if_condition;
for truthy in ["true", "1", "yes", "TRUE", "any-non-falsy"] {
let v = truthy.to_string();
let render = move |_: &str| Ok(v.clone());
let proceed = evaluate_if_condition(Some("anything"), "x", render).unwrap();
assert!(
proceed,
"rendered value {truthy:?} must proceed (only false/0/no/empty are falsy)",
);
}
}
#[test]
fn test_evaluate_if_condition_render_error_propagates() {
use super::evaluate_if_condition;
let render = |_: &str| -> anyhow::Result<String> { anyhow::bail!("tera parse error") };
let err = evaluate_if_condition(Some("{{ broken"), "publisher 'foo'", render).unwrap_err();
let chain = format!("{err:#}");
assert!(
chain.contains("publisher 'foo'") && chain.contains("template render failed"),
"render error must carry label + diagnostic: {chain}",
);
}
#[test]
fn test_docker_v2_disable_alias_accepts_legacy_spelling() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
dockers_v2:
- images: [ghcr.io/example/app]
disable: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("disable: alias must parse");
let docker = &config.crates[0].dockers_v2.as_ref().unwrap()[0];
assert_eq!(docker.skip, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_snapcraft_disable_alias_accepts_legacy_spelling() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
snapcrafts:
- disable: "{{ if .IsSnapshot }}true{{ end }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("disable: alias must parse");
let snap = &config.crates[0].snapcrafts.as_ref().unwrap()[0];
match &snap.skip {
Some(StringOrBool::String(s)) => assert!(s.contains("IsSnapshot")),
other => panic!("expected template string, got {:?}", other),
}
}
#[test]
fn test_msi_disable_alias_accepts_legacy_spelling() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
msis:
- disable: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("disable: alias must parse");
let msi = &config.crates[0].msis.as_ref().unwrap()[0];
assert_eq!(msi.skip, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_nsis_disable_alias_accepts_legacy_spelling() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
nsis:
- disable: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("disable: alias must parse");
let nsis = &config.crates[0].nsis.as_ref().unwrap()[0];
assert_eq!(nsis.skip, Some(StringOrBool::Bool(true)));
}
#[test]
fn test_release_disable_alias_accepts_legacy_spelling() {
let yaml = r#"
project_name: test
release:
disable: "{{ .IsSnapshot }}"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("disable: alias must parse");
let rel = config.release.as_ref().unwrap();
match &rel.skip {
Some(StringOrBool::String(s)) => assert!(s.contains("IsSnapshot")),
other => panic!("expected template string, got {:?}", other),
}
}
#[test]
fn test_dmg_amd64_variant_field_deserializes() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
dmgs:
- id: my_dmg
amd64_variant: v3
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("dmg amd64_variant must parse");
let dmg = &config.crates[0].dmgs.as_ref().unwrap()[0];
assert_eq!(dmg.amd64_variant.as_deref(), Some("v3"));
}
#[test]
fn test_msi_amd64_variant_field_deserializes() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
msis:
- id: my_msi
amd64_variant: v2
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("msi amd64_variant must parse");
let msi = &config.crates[0].msis.as_ref().unwrap()[0];
assert_eq!(msi.amd64_variant.as_deref(), Some("v2"));
}
#[test]
fn test_nsis_amd64_variant_field_deserializes() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
nsis:
- id: my_nsis
amd64_variant: v4
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("nsis amd64_variant must parse");
let nsis = &config.crates[0].nsis.as_ref().unwrap()[0];
assert_eq!(nsis.amd64_variant.as_deref(), Some("v4"));
}
#[test]
fn test_nfpm_amd64_variant_field_deserializes_as_list() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
nfpms:
- id: my_nfpm
formats: [deb]
amd64_variant: [v2, v3]
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("nfpm amd64_variant list must parse");
let nfpm = &config.crates[0].nfpms.as_ref().unwrap()[0];
assert_eq!(
nfpm.amd64_variant.as_deref(),
Some(&[String::from("v2"), String::from("v3")][..])
);
}
#[test]
fn test_nfpm_amd64_variant_omitted_is_none() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
nfpms:
- id: my_nfpm
formats: [deb]
"#;
let config: Config =
serde_yaml_ng::from_str(yaml).expect("nfpm without amd64_variant must parse");
let nfpm = &config.crates[0].nfpms.as_ref().unwrap()[0];
assert!(nfpm.amd64_variant.is_none());
}
#[test]
fn test_archives_id_uniqueness_rejects_duplicate() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
archives:
- id: foo
formats: [tar.gz]
- id: foo
formats: [zip]
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = super::validate_id_uniqueness(&config).unwrap_err();
assert!(
err.contains("archives id \"foo\""),
"expected duplicate-id error, got: {}",
err
);
}
#[test]
fn test_archives_id_uniqueness_accepts_distinct() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
archives:
- id: foo
formats: [tar.gz]
- id: bar
formats: [zip]
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::validate_id_uniqueness(&config).expect("distinct ids must pass");
}
#[test]
fn test_universal_binaries_id_uniqueness_rejects_duplicate() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
universal_binaries:
- id: ub
name_template: "{{ .ProjectName }}_macos"
- id: ub
name_template: "{{ .ProjectName }}_macos2"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = super::validate_id_uniqueness(&config).unwrap_err();
assert!(
err.contains("universal_binaries id \"ub\""),
"expected duplicate-id error, got: {}",
err
);
}
#[test]
fn test_universal_binaries_id_uniqueness_accepts_distinct() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
universal_binaries:
- id: ub_main
name_template: "{{ .ProjectName }}_macos"
- id: ub_alt
name_template: "{{ .ProjectName }}_macos_alt"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::validate_id_uniqueness(&config).expect("distinct ids must pass");
}
#[test]
fn test_source_prefix_template_defaults_to_name_template_when_unset() {
use super::SourceConfig;
let mut src = SourceConfig {
enabled: Some(true),
name_template: Some("{{ .ProjectName }}-{{ .Version }}".to_string()),
prefix_template: None,
..Default::default()
};
src.apply_prefix_template_default();
assert_eq!(
src.prefix_template.as_deref(),
Some("{{ .ProjectName }}-{{ .Version }}")
);
}
#[test]
fn test_source_prefix_template_preserved_when_user_set() {
use super::SourceConfig;
let mut src = SourceConfig {
enabled: Some(true),
name_template: Some("default-name".to_string()),
prefix_template: Some("custom-prefix".to_string()),
..Default::default()
};
src.apply_prefix_template_default();
assert_eq!(src.prefix_template.as_deref(), Some("custom-prefix"));
}
#[test]
fn test_source_prefix_template_remains_none_when_name_template_unset() {
use super::SourceConfig;
let mut src = SourceConfig::default();
src.apply_prefix_template_default();
assert!(src.prefix_template.is_none());
}
#[test]
fn test_homebrew_conflicts_string_form_accepted() {
use super::publishers::HomebrewConflict;
let yaml = r#"
- foo
- bar
"#;
let conflicts: Vec<HomebrewConflict> = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(conflicts.len(), 2);
assert_eq!(conflicts[0].name(), "foo");
assert!(conflicts[0].because().is_none());
assert_eq!(conflicts[1].name(), "bar");
}
#[test]
fn test_homebrew_conflicts_object_form_accepted() {
use super::publishers::HomebrewConflict;
let yaml = r#"
- name: foo
because: "both install bin/foo"
"#;
let conflicts: Vec<HomebrewConflict> = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(conflicts[0].name(), "foo");
assert_eq!(conflicts[0].because(), Some("both install bin/foo"));
}
#[test]
fn test_homebrew_conflicts_mixed_form_accepted() {
use super::publishers::HomebrewConflict;
let yaml = r#"
- foo
- name: bar
because: "shared symlink"
- baz
"#;
let conflicts: Vec<HomebrewConflict> = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(conflicts.len(), 3);
assert_eq!(conflicts[0].name(), "foo");
assert!(conflicts[0].because().is_none());
assert_eq!(conflicts[1].name(), "bar");
assert_eq!(conflicts[1].because(), Some("shared symlink"));
assert_eq!(conflicts[2].name(), "baz");
}
#[test]
fn test_homebrew_conflicts_full_yaml_with_string_list_form() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew:
repository:
owner: example
name: tap
conflicts: [foo, bar]
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("string-form conflicts must parse");
let brew = config.crates[0]
.publish
.as_ref()
.unwrap()
.homebrew
.as_ref()
.unwrap();
let conflicts = brew.conflicts.as_ref().unwrap();
assert_eq!(conflicts.len(), 2);
assert_eq!(conflicts[0].name(), "foo");
assert_eq!(conflicts[1].name(), "bar");
}
#[test]
fn test_v1_dockers_block_rejected_with_migration_message() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
dockers:
- image_templates: ["ghcr.io/example/app:{{ .Version }}"]
dockerfile: Dockerfile
"#;
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(yaml).unwrap();
let err = super::validate_no_docker_v1(&raw).unwrap_err();
assert!(
err.contains("dockers_v2") && err.contains("dockers"),
"expected migration message naming dockers_v2 and dockers, got: {}",
err
);
}
#[test]
fn test_no_dockers_block_passes() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
dockers_v2:
- images: [ghcr.io/example/app]
"#;
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(yaml).unwrap();
super::validate_no_docker_v1(&raw).expect("dockers_v2 only must pass");
}
#[test]
fn test_archives_format_singular_alias_folds_into_formats() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
archives:
- id: legacy
format: tar.gz
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let arch = match &config.crates[0].archives {
super::ArchivesConfig::Configs(list) => &list[0],
_ => panic!("expected configs variant"),
};
assert_eq!(arch.formats.as_deref(), Some(&[String::from("tar.gz")][..]));
}
#[test]
fn test_archives_builds_alias_deserializes_into_ids() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
archives:
- id: legacy
builds: [foo, bar]
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let arch = match &config.crates[0].archives {
super::ArchivesConfig::Configs(list) => &list[0],
_ => panic!("expected configs variant"),
};
assert_eq!(
arch.ids.as_deref(),
Some(&[String::from("foo"), String::from("bar")][..])
);
}
#[test]
fn test_archives_format_overrides_singular_alias_folds() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
archives:
- id: legacy
formats: [tar.gz]
format_overrides:
- os: windows
format: zip
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let arch = match &config.crates[0].archives {
super::ArchivesConfig::Configs(list) => &list[0],
_ => panic!("expected configs variant"),
};
let over = &arch.format_overrides.as_ref().unwrap()[0];
assert_eq!(over.formats.as_deref(), Some(&[String::from("zip")][..]));
}
#[test]
fn test_archive_config_rejects_unknown_field() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
archives:
- id: typo
name_templat: "{{ .ProjectName }}"
"#;
let err = serde_yaml_ng::from_str::<Config>(yaml).unwrap_err();
assert!(
err.to_string().contains("name_templat"),
"ArchiveConfig must reject the unknown `name_templat` field, got: {err}"
);
}
#[test]
fn test_format_override_rejects_unknown_field() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
archives:
- id: legacy
formats: [tar.gz]
format_overrides:
- os: windows
formatz: [zip]
"#;
let err = serde_yaml_ng::from_str::<Config>(yaml).unwrap_err();
assert!(
err.to_string().contains("formatz"),
"FormatOverride must reject the unknown `formatz` field, got: {err}"
);
}
#[test]
fn test_hooks_config_rejects_unknown_field() {
let yaml = r#"
project_name: test
before:
hookz:
- echo hi
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let err = serde_yaml_ng::from_str::<Config>(yaml).unwrap_err();
assert!(
err.to_string().contains("hookz"),
"HooksConfig must reject the unknown `hookz` field, got: {err}"
);
}
#[test]
fn test_hooks_config_accepts_post_alias_after_outer_deny() {
let yaml = r#"
project_name: test
after:
post:
- echo done
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml)
.expect("deprecated `post:` alias must still deserialize under outer deny");
let after = config.after.as_ref().expect("after block present");
let hooks = after.hooks.as_deref().expect("post: folded into hooks:");
assert_eq!(hooks.len(), 1);
assert_eq!(hooks[0], "echo done");
assert!(after.post.is_none(), "post: must be cleared after folding");
}
#[test]
fn test_snapshot_name_template_alias_deserializes_as_version_template() {
let yaml = r#"
project_name: test
snapshot:
name_template: "{{ .Version }}-snap"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let snap = config.snapshot.as_ref().unwrap();
assert_eq!(snap.version_template, "{{ .Version }}-snap");
}
#[test]
fn test_builds_gobinary_field_is_accepted_and_ignored() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
builds:
- id: legacy
gobinary: /usr/local/bin/go
"#;
let config: Config = serde_yaml_ng::from_str(yaml)
.expect("gobinary: must parse as an accepted-but-ignored legacy field");
let build = &config.crates[0].builds.as_ref().unwrap()[0];
assert_eq!(build.gobinary.as_deref(), Some("/usr/local/bin/go"));
}
#[test]
fn test_makeself_config_rejects_renamed_go_fields() {
for old in ["goos", "goarch"] {
let err = serde_yaml_ng::from_str::<super::MakeselfConfig>(&format!("{old}: [linux]\n"))
.unwrap_err();
assert!(
err.to_string().contains(old),
"makeself {old}: must be rejected, got: {err}"
);
}
}
#[test]
fn test_makeself_config_accepts_disable_alias() {
let cfg: super::MakeselfConfig = serde_yaml_ng::from_str("disable: true\n").unwrap();
match cfg.skip {
Some(super::StringOrBool::Bool(true)) => {}
other => panic!("expected disable: true to populate skip=Bool(true), got: {other:?}"),
}
}
#[test]
fn test_makeself_file_accepts_src_dst_aliases() {
let f: super::MakeselfFile =
serde_yaml_ng::from_str("src: bin/app\ndst: usr/bin/app\n").unwrap();
assert_eq!(f.source, "bin/app");
assert_eq!(f.destination.as_deref(), Some("usr/bin/app"));
}
#[test]
fn test_appimages_parse_full_block_from_top_level_config() {
let yaml = r#"
project_name: helix
appimages:
- id: helix
ids: [helix-bin]
desktop: contrib/Helix.desktop
icon: contrib/helix.png
appdir_extra:
- src: runtime/
dst: usr/lib/helix/runtime
update_information: "gh-releases-zsync|helix-editor|helix|latest|helix-*.AppImage.zsync"
runtime_harvest:
command: "{{ .ArtifactPath }} --populate {{ .HarvestDir }}"
dir: usr/lib/helix/runtime
"#;
let cfg: super::Config = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.appimages.len(), 1);
let ai = &cfg.appimages[0];
assert_eq!(ai.id.as_deref(), Some("helix"));
assert_eq!(ai.ids.as_deref(), Some(&["helix-bin".to_string()][..]));
assert_eq!(ai.desktop.as_deref(), Some("contrib/Helix.desktop"));
assert_eq!(ai.icon.as_deref(), Some("contrib/helix.png"));
let extra = ai.appdir_extra.as_ref().unwrap();
assert_eq!(extra[0].src, "runtime/");
assert_eq!(extra[0].dst, "usr/lib/helix/runtime");
assert!(
ai.update_information
.as_deref()
.unwrap()
.starts_with("gh-releases-zsync")
);
let h = ai.runtime_harvest.as_ref().unwrap();
assert!(h.command.contains("{{ .HarvestDir }}"));
assert_eq!(h.dir, "usr/lib/helix/runtime");
}
#[test]
fn test_appimage_config_rejects_unknown_field() {
let err = serde_yaml_ng::from_str::<super::AppImageConfig>("bogus_field: 1\n").unwrap_err();
assert!(err.to_string().contains("bogus_field"));
}
#[test]
fn test_appimage_extra_accepts_source_destination_aliases() {
let e: super::AppImageExtra =
serde_yaml_ng::from_str("source: runtime/\ndestination: usr/lib/x\n").unwrap();
assert_eq!(e.src, "runtime/");
assert_eq!(e.dst, "usr/lib/x");
}
#[test]
fn test_nfpm_config_accepts_builds_alias_into_ids() {
let cfg: super::NfpmConfig = serde_yaml_ng::from_str("builds: [foo, bar]\n").unwrap();
assert_eq!(
cfg.ids.as_deref(),
Some(&[String::from("foo"), String::from("bar")][..])
);
}
#[test]
fn test_installer_and_nfpm_configs_accept_legacy_goamd64_alias() {
let dmg: super::DmgConfig = serde_yaml_ng::from_str("goamd64: v3\n").unwrap();
assert_eq!(dmg.amd64_variant.as_deref(), Some("v3"));
let msi: super::MsiConfig = serde_yaml_ng::from_str("goamd64: v3\n").unwrap();
assert_eq!(msi.amd64_variant.as_deref(), Some("v3"));
let nsis: super::NsisConfig = serde_yaml_ng::from_str("goamd64: v3\n").unwrap();
assert_eq!(nsis.amd64_variant.as_deref(), Some("v3"));
let nfpm: super::NfpmConfig = serde_yaml_ng::from_str("goamd64: [v3]\n").unwrap();
assert_eq!(
nfpm.amd64_variant.as_deref(),
Some(&[String::from("v3")][..])
);
}
#[test]
fn test_builds_all_known_fields_still_parse() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
builds:
- id: main
binary: a
skip: false
targets: ["x86_64-unknown-linux-gnu"]
features: ["foo"]
no_default_features: true
env:
x86_64-unknown-linux-gnu:
RUSTFLAGS: "-C target-cpu=native"
copy_from: other
flags: ["--locked"]
reproducible: true
hooks:
pre: ["echo pre"]
post: ["echo post"]
ignore:
- os: windows
arch: arm64
overrides:
- targets: ["*-linux-*"]
env: ["FOO=bar"]
flags: ["--offline"]
features: ["bar"]
cross_tool: /usr/bin/cross
mod_timestamp: "{{ .CommitTimestamp }}"
command: "auditable build"
no_unique_dist_dir: true
builder: prebuilt
prebuilt:
path: "bin/{{ .Target }}/a"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("known-good build config must parse");
let build = &config.crates[0].builds.as_ref().unwrap()[0];
assert_eq!(build.id.as_deref(), Some("main"));
assert_eq!(build.command.as_deref(), Some("auditable build"));
assert_eq!(build.prebuilt.as_ref().unwrap().path, "bin/{{ .Target }}/a");
assert_eq!(build.ignore.as_ref().unwrap()[0].os, "windows");
assert_eq!(
build.overrides.as_ref().unwrap()[0].features,
Some(vec!["bar".to_owned()])
);
}
#[test]
fn test_builds_structured_hook_unknown_field_rejected() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
builds:
- id: app
hooks:
pre:
- cmd: "echo hi"
typo_here: true
"#;
let err = serde_yaml_ng::from_str::<Config>(yaml)
.expect_err("a typo'd structured-hook field must be rejected");
assert!(
err.to_string().contains("typo_here"),
"error should name the unknown hook field, got: {err}"
);
}
#[test]
fn test_builds_structured_hook_all_fields_parse() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
builds:
- id: app
hooks:
pre:
- cmd: "echo hi"
dir: "./sub"
env: ["FOO=bar"]
output: true
if: '{{ eq .Os "linux" }}'
ids: ["app"]
artifacts: all
"#;
let config: Config =
serde_yaml_ng::from_str(yaml).expect("fully-populated structured hook must parse");
let hooks = config.crates[0].builds.as_ref().unwrap()[0]
.hooks
.as_ref()
.unwrap();
let pre = &hooks.pre.as_ref().unwrap()[0];
let HookEntry::Structured(hook) = pre else {
panic!("expected object-form hook to deserialize as HookEntry::Structured, got {pre:?}");
};
assert_eq!(hook.cmd, "echo hi");
assert_eq!(hook.dir.as_deref(), Some("./sub"));
assert_eq!(hook.env.as_deref(), Some(&["FOO=bar".to_owned()][..]));
assert_eq!(hook.output, Some(true));
assert_eq!(
hook.if_condition.as_deref(),
Some(r#"{{ eq .Os "linux" }}"#)
);
assert_eq!(hook.ids.as_deref(), Some(&["app".to_owned()][..]));
assert_eq!(
hook.artifacts,
Some(super::BeforePublishArtifactFilter::All)
);
}
#[test]
fn test_archives_id_uniqueness_workspace_crate() {
let yaml = r#"
project_name: test
workspaces:
- name: ws1
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
archives:
- id: dup
formats: [tar.gz]
- id: dup
formats: [zip]
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = super::validate_id_uniqueness(&config).unwrap_err();
assert!(err.contains("workspaces[ws1]"), "got: {}", err);
}
#[test]
fn submitter_required_true_warns() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
chocolatey:
required: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert!(
!warnings.is_empty(),
"expected a warning for chocolatey required: true"
);
assert!(
warnings.iter().any(|w| w.contains("chocolatey")),
"expected chocolatey in warning, got: {:?}",
warnings
);
}
#[test]
fn manager_required_true_no_warning() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew:
required: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert!(
warnings.is_empty(),
"homebrew (Manager group) should not trigger a warning, got: {:?}",
warnings
);
}
#[test]
fn submitter_required_none_no_warning() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
chocolatey: {}
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert!(
warnings.is_empty(),
"chocolatey with required: None should not warn, got: {:?}",
warnings
);
}
#[test]
fn submitter_required_false_no_warning() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
winget:
required: false
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert!(
warnings.is_empty(),
"winget with required: false should not warn, got: {:?}",
warnings
);
}
#[test]
fn multiple_submitters_required_true_warns_each() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
chocolatey:
required: true
winget:
required: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert_eq!(
warnings.len(),
2,
"expected one warning per submitter, got: {:?}",
warnings
);
assert!(
warnings.iter().any(|w| w.contains("chocolatey")),
"missing chocolatey warning"
);
assert!(
warnings.iter().any(|w| w.contains("winget")),
"missing winget warning"
);
}
#[test]
fn submitter_aur_source_nested_required_true_warns() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
aur_source:
required: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert_eq!(
warnings.len(),
1,
"expected one warning for nested aur_source, got: {:?}",
warnings
);
assert!(
warnings[0].contains("aur_source"),
"expected aur_source in warning, got: {}",
warnings[0]
);
assert!(
warnings[0].contains("crate 'a'"),
"expected crate-name prefix in warning, got: {}",
warnings[0]
);
}
#[test]
fn submitter_aur_sources_top_level_required_true_warns() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
aur_sources:
- name: mypkg
required: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert_eq!(
warnings.len(),
1,
"expected one warning for top-level aur_sources entry, got: {:?}",
warnings
);
assert!(
warnings[0].contains("aur_source"),
"expected aur_source in warning, got: {}",
warnings[0]
);
assert!(
warnings[0].contains("top-level aur_sources"),
"expected top-level prefix in warning, got: {}",
warnings[0]
);
}
#[test]
fn submitter_warning_message_matches_spec_verbatim() {
let yaml = r#"
project_name: test
crates:
- name: myapp
path: "."
tag_template: "v{{ .Version }}"
publish:
chocolatey:
required: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert_eq!(warnings.len(), 1, "expected exactly one warning");
let msg = &warnings[0];
for clause in [
"chocolatey",
"submitter",
"external moderation queue",
"no meaningful effect",
"push time",
"crate 'myapp'",
] {
assert!(
msg.contains(clause),
"warning missing clause {:?}, got: {}",
clause,
msg
);
}
}
#[test]
fn submitter_required_true_warns_on_defaults_publish_axis() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
defaults:
publish:
chocolatey:
required: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert_eq!(
warnings.len(),
1,
"expected one warning for defaults.publish chocolatey, got: {:?}",
warnings
);
assert!(
warnings[0].contains("chocolatey"),
"expected chocolatey in warning, got: {}",
warnings[0]
);
assert!(
warnings[0].contains("defaults.publish"),
"expected defaults.publish location in warning, got: {}",
warnings[0]
);
}
#[test]
fn submitter_required_true_warns_on_workspace_crate_publish_axis() {
let yaml = r#"
project_name: test
crates: []
workspaces:
- name: ws1
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
winget:
required: true
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::submitter_required_warnings(&config);
assert_eq!(
warnings.len(),
1,
"expected one warning for workspace crate winget, got: {:?}",
warnings
);
assert!(
warnings[0].contains("winget"),
"expected winget in warning, got: {}",
warnings[0]
);
assert!(
warnings[0].contains("workspaces[ws1].crates[a]"),
"expected workspace crate location in warning, got: {}",
warnings[0]
);
}
#[test]
fn legacy_homebrew_formula_crate_publish_warns() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew:
repository:
owner: o
name: tap
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_homebrew_formula_warnings(&config);
assert_eq!(
warnings.len(),
1,
"expected one warning for crate publish.homebrew, got: {:?}",
warnings
);
assert!(
warnings[0].contains("crate 'a'"),
"expected crate-name prefix, got: {}",
warnings[0]
);
}
#[test]
fn legacy_homebrew_formula_workspace_crate_publish_warns() {
let yaml = r#"
project_name: test
workspaces:
- name: ws1
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew:
repository:
owner: o
name: tap
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_homebrew_formula_warnings(&config);
assert_eq!(
warnings.len(),
1,
"expected one warning for workspace crate publish.homebrew, got: {:?}",
warnings
);
assert!(
warnings[0].contains("workspaces[ws1].crates[a]"),
"expected workspace-crate prefix, got: {}",
warnings[0]
);
}
#[test]
fn legacy_homebrew_formula_defaults_publish_warns() {
let yaml = r#"
project_name: test
defaults:
publish:
homebrew:
repository:
owner: o
name: tap
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_homebrew_formula_warnings(&config);
assert_eq!(
warnings.len(),
1,
"expected one warning for defaults.publish.homebrew, got: {:?}",
warnings
);
assert!(
warnings[0].contains("defaults.publish"),
"expected defaults.publish prefix, got: {}",
warnings[0]
);
}
#[test]
fn legacy_homebrew_formula_no_warning_when_only_cask() {
let yaml = r#"
project_name: test
homebrew_casks:
- name: a
repository:
owner: o
name: tap
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew_cask:
repository:
owner: o
name: tap
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_homebrew_formula_warnings(&config);
assert!(
warnings.is_empty(),
"homebrew_casks / publish.homebrew_cask should not warn, got: {:?}",
warnings
);
}
#[test]
fn legacy_homebrew_formula_no_warning_when_publish_has_no_homebrew() {
let yaml = r#"
project_name: x
crates:
- name: x
path: "."
tag_template: "v{{ .Version }}"
publish:
cargo: {}
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_homebrew_formula_warnings(&config);
assert!(
warnings.is_empty(),
"expected no warning, got: {warnings:?}"
);
}
#[test]
fn legacy_homebrew_formula_multiple_axes_each_warn() {
let yaml = r#"
project_name: test
defaults:
publish:
homebrew:
repository:
owner: o
name: tap
crates:
- name: top
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew:
repository:
owner: o
name: tap
workspaces:
- name: ws1
crates:
- name: nested
path: ws1
tag_template: "v{{ .Version }}"
publish:
homebrew:
repository:
owner: o
name: tap
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_homebrew_formula_warnings(&config);
assert_eq!(
warnings.len(),
3,
"expected three warnings (one per placement axis), got: {:?}",
warnings
);
}
#[test]
fn legacy_homebrew_formula_warning_message_includes_migration_pointer() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew:
repository:
owner: o
name: tap
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_homebrew_formula_warnings(&config);
assert_eq!(warnings.len(), 1);
let msg = &warnings[0];
for clause in [
"DEPRECATION",
"publish.homebrew",
"GoReleaser v2.16",
"homebrew_casks",
"https://anodize.dev/docs/publish/homebrew-casks/",
] {
assert!(
msg.contains(clause),
"warning missing clause {:?}, got: {}",
clause,
msg
);
}
}
#[test]
fn test_changelog_disable_alias_parses() {
let yaml = r#"
project_name: test
changelog:
disable: true
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cl = config.changelog.as_ref().expect("changelog present");
assert_eq!(
cl.skip,
Some(StringOrBool::Bool(true)),
"disable: alias should populate skip"
);
}
#[test]
fn test_changelog_disable_template_string_alias() {
let yaml = r#"
project_name: test
changelog:
disable: "{{ if IsSnapshot }}true{{ endif }}"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let cl = config.changelog.as_ref().expect("changelog present");
match cl.skip.as_ref() {
Some(StringOrBool::String(s)) => assert!(s.contains("IsSnapshot")),
other => panic!(
"expected templated skip via disable alias, got: {:?}",
other
),
}
}
#[test]
fn test_validate_changelog_groups_depth_accepts_one_level() {
let yaml = r#"
project_name: test
changelog:
groups:
- title: Features
regexp: "^feat"
groups:
- title: Core
regexp: "^feat\\(core\\)"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(validate_changelog_groups_depth(&config).is_ok());
}
#[test]
fn test_validate_changelog_groups_depth_rejects_three_levels() {
let yaml = r#"
project_name: test
changelog:
groups:
- title: Features
regexp: "^feat"
groups:
- title: Core
regexp: "^feat\\(core\\)"
groups:
- title: Auth
regexp: "^feat\\(core/auth\\)"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_changelog_groups_depth(&config).unwrap_err();
assert!(err.contains("Features"), "{err}");
assert!(err.contains("Core"), "{err}");
assert!(err.contains("one level"), "{err}");
}
#[test]
fn test_validate_changelog_groups_depth_walks_workspaces() {
let yaml = r#"
project_name: test
workspaces:
- name: w
changelog:
groups:
- title: A
groups:
- title: B
groups:
- title: C
crates:
- name: a
path: a
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_changelog_groups_depth(&config).unwrap_err();
assert!(err.contains("workspaces[w].changelog"), "{err}");
}
#[test]
fn test_validate_changelog_paths_rejects_leading_slash() {
let yaml = r#"
project_name: test
changelog:
paths:
- "/src/lib.rs"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_changelog_paths(&config).unwrap_err();
assert!(err.contains("repo-root-relative"), "{err}");
assert!(err.contains("/src/lib.rs"), "{err}");
}
#[test]
fn test_validate_changelog_paths_rejects_empty_entry() {
let yaml = r#"
project_name: test
changelog:
paths:
- ""
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_changelog_paths(&config).unwrap_err();
assert!(err.contains("empty"), "{err}");
}
#[test]
fn test_validate_changelog_paths_accepts_relative() {
let yaml = r#"
project_name: test
changelog:
paths:
- "src/**/*.rs"
- "crates/core"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(validate_changelog_paths(&config).is_ok());
}
#[test]
fn legacy_docker_retry_warns_for_docker_v2() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
dockers_v2:
- dockerfile: Dockerfile
images: ["ghcr.io/example/app"]
retry:
attempts: 3
delay: 1s
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_docker_retry_warnings(&config);
assert_eq!(warnings.len(), 1, "expected one warning, got: {warnings:?}");
let msg = &warnings[0];
assert!(msg.contains("DEPRECATION"));
assert!(msg.contains("crates[a].dockers_v2[0]"));
assert!(msg.contains("dockers_v2.retry"));
assert!(msg.contains("v2.15.3"));
assert!(msg.contains("top-level `retry:`"));
}
#[test]
fn legacy_docker_retry_warns_for_docker_manifests() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
docker_manifests:
- name_template: "ghcr.io/example/app:{{ .Version }}"
image_templates: ["ghcr.io/example/app:{{ .Version }}-amd64"]
retry:
attempts: 5
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_docker_retry_warnings(&config);
assert_eq!(warnings.len(), 1, "expected one warning, got: {warnings:?}");
assert!(warnings[0].contains("crates[a].docker_manifests[0]"));
assert!(warnings[0].contains("docker_manifests.retry"));
}
#[test]
fn legacy_docker_retry_no_warning_when_top_level_retry_only() {
let yaml = r#"
project_name: test
retry:
attempts: 7
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
docker_v2:
- dockerfile: Dockerfile
images: ["ghcr.io/example/app"]
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_docker_retry_warnings(&config);
assert!(
warnings.is_empty(),
"expected no warnings, got: {warnings:?}"
);
}
#[test]
fn legacy_docker_retry_warns_in_workspaces_and_defaults() {
let yaml = r#"
project_name: test
defaults:
dockers_v2:
dockerfile: Dockerfile
images: ["ghcr.io/example/app"]
retry:
attempts: 2
workspaces:
- name: ws1
crates:
- name: nested
path: ws1
tag_template: "v{{ .Version }}"
dockers_v2:
- dockerfile: Dockerfile
images: ["ghcr.io/example/app"]
retry:
attempts: 4
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let warnings = super::legacy_docker_retry_warnings(&config);
assert_eq!(
warnings.len(),
2,
"expected defaults + workspace warnings, got: {warnings:?}"
);
assert!(warnings.iter().any(|w| w.contains("defaults.dockers_v2")));
assert!(
warnings
.iter()
.any(|w| w.contains("workspaces[ws1].crates[nested].dockers_v2[0]"))
);
}
#[test]
fn legacy_v1_dockers_block_error_mentions_v2_migration() {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(
r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
dockers:
- dockerfile: Dockerfile
image_templates: ["ghcr.io/example/app:{{ .Version }}"]
"#,
)
.unwrap();
let err = super::validate_no_docker_v1(&raw).expect_err("v1 dockers must reject");
assert!(err.contains("dockers"), "missing legacy name: {err}");
assert!(err.contains("dockers_v2"), "missing v2 pointer: {err}");
}
#[test]
fn legacy_furies_top_level_parses_as_gemfury() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
furies:
- id: my-fury
account: my-account
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let gemfury = config.gemfury.expect("furies: alias must populate gemfury");
assert_eq!(gemfury.len(), 1);
assert_eq!(gemfury[0].id.as_deref(), Some("my-fury"));
assert_eq!(gemfury[0].account.as_deref(), Some("my-account"));
}
#[test]
fn legacy_furies_alias_detected_in_raw_yaml() {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(
r#"
project_name: test
furies:
- id: my-fury
crates: []
"#,
)
.unwrap();
assert!(raw.get("furies").is_some());
}
#[test]
fn gemfury_canonical_key_does_not_trigger_furies_probe() {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(
r#"
project_name: test
gemfury:
- id: my-fury
crates: []
"#,
)
.unwrap();
assert!(
raw.get("furies").is_none(),
"canonical gemfury key must not look like the legacy furies alias"
);
assert!(raw.get("gemfury").is_some());
}
#[test]
fn legacy_nfpm_builds_warn_runs_for_crates_nfpms() {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(
r#"
project_name: test
crates:
- name: app
nfpms:
- id: deb
builds: [foo]
- id: rpm
ids: [bar]
"#,
)
.unwrap();
super::warn_on_legacy_nfpm_builds(&raw);
}
#[test]
fn legacy_nfpm_builds_warn_runs_for_defaults_nfpms() {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(
r#"
project_name: test
crates: []
defaults:
nfpms:
- id: deb
builds: [foo, bar]
"#,
)
.unwrap();
super::warn_on_legacy_nfpm_builds(&raw);
}
#[test]
fn legacy_nfpm_builds_warn_runs_for_nested_workspace_crates() {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(
r#"
project_name: test
crates: []
workspaces:
- members:
- core
crates:
- name: core
nfpm:
id: deb
builds: [foo]
"#,
)
.unwrap();
super::warn_on_legacy_nfpm_builds(&raw);
}
#[test]
fn legacy_nfpm_builds_warn_runs_cleanly_without_builds_key() {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(
r#"
project_name: test
crates:
- name: app
nfpms:
- id: deb
ids: [bar]
archives:
- id: default
builds: [foo]
"#,
)
.unwrap();
super::warn_on_legacy_nfpm_builds(&raw);
}
fn disable_alias_warnings(yaml: &str) -> Vec<String> {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(yaml).unwrap();
super::legacy_disable_alias_warnings(&raw)
}
#[test]
fn legacy_disable_alias_top_level_release_warns_with_path() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
release:
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
assert!(
warnings[0].contains("release.disable"),
"warning must name the path: {}",
warnings[0]
);
}
#[test]
fn legacy_disable_alias_top_level_changelog_warns() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
changelog:
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("changelog.disable"));
}
#[test]
fn legacy_disable_alias_top_level_dockerhub_warns() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
dockerhub:
- username: u
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].contains("dockerhub[0].disable"),
"{}",
warnings[0]
);
}
#[test]
fn legacy_disable_alias_top_level_mcp_warns() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
mcp:
name: srv
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("mcp.disable"));
}
#[test]
fn legacy_disable_alias_top_level_makeselfs_warns() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
makeselfs:
- id: app
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("makeselfs[0].disable"));
}
#[test]
fn legacy_disable_alias_top_level_appimages_warns() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
appimages:
- id: app
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("appimages[0].disable"));
}
#[test]
fn legacy_disable_alias_nested_crates_snapcrafts_warns() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates:
- name: app
snapcrafts:
- name: app
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].contains("crates[0].snapcrafts[0].disable"),
"{}",
warnings[0]
);
}
#[test]
fn legacy_disable_alias_nested_defaults_installers_warn() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
defaults:
msis:
disable: true
pkgs:
disable: true
nsis:
disable: true
makeselves:
disable: true
dockers_v2:
disable: true
"#,
);
assert_eq!(warnings.len(), 5, "{warnings:?}");
for block in ["msis", "pkgs", "nsis", "makeselves", "dockers_v2"] {
let needle = format!("defaults.{block}.disable");
assert!(
warnings.iter().any(|w| w.contains(&needle)),
"missing warning for {needle}: {warnings:?}"
);
}
}
#[test]
fn legacy_disable_alias_nested_workspace_crates_release_warns() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
workspaces:
- members: [core]
crates:
- name: core
release:
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].contains("workspaces[0].crates[0].release.disable"),
"{}",
warnings[0]
);
}
#[test]
fn legacy_disable_alias_warns_on_npm_disable() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
npms:
- disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("npms[0].disable"), "{}", warnings[0]);
}
#[test]
fn legacy_disable_alias_warns_on_gemfury_disable() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
gemfury:
- id: x
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].contains("gemfury[0].disable"),
"{}",
warnings[0]
);
}
#[test]
fn legacy_disable_alias_warns_on_furies_legacy_alias_key() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
furies:
- id: x
disable: true
"#,
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("furies[0].disable"), "{}", warnings[0]);
}
#[test]
fn legacy_disable_alias_silent_for_canonical_skip() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
release:
skip: true
changelog:
skip: true
"#,
);
assert!(
warnings.is_empty(),
"canonical skip must not warn: {warnings:?}"
);
}
#[test]
fn legacy_disable_alias_skips_freeform_map_user_key() {
let warnings = disable_alias_warnings(
r#"
project_name: test
crates: []
variables:
disable: hello
dockers_v2:
- build_args:
disable: "1"
"#,
);
assert!(
warnings.is_empty(),
"free-form map keys named disable must not warn: {warnings:?}"
);
}
#[test]
fn legacy_mcp_github_block_rejected_with_migration_message() {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(
r#"
project_name: test
crates: []
mcp:
github:
owner: my-org
name: my-repo
"#,
)
.unwrap();
let err = super::validate_no_mcp_github(&raw).expect_err("nested mcp.github must reject");
assert!(err.contains("mcp.github"));
assert!(err.contains("v2.13.1"));
assert!(err.contains("mcp.repository"));
}
#[test]
fn canonical_mcp_top_level_passes() {
let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(
r#"
project_name: test
crates: []
mcp:
name: io.github.user/server
repository:
url: https://github.com/my-org/my-repo
source: github
"#,
)
.unwrap();
super::validate_no_mcp_github(&raw).expect("canonical mcp.* must pass");
}
#[test]
fn legacy_homebrew_cask_binary_singular_folds_into_binaries() {
let yaml = r#"
project_name: test
crates: []
homebrew_casks:
- name: mycask
repository:
owner: o
name: tap
binary: my-cli
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::apply_homebrew_cask_legacy_singulars(&mut config);
let cask = &config.homebrew_casks.as_ref().unwrap()[0];
let binaries = cask
.binaries
.as_ref()
.expect("legacy binary must fold into binaries");
assert_eq!(binaries.len(), 1);
assert_eq!(binaries[0].name(), "my-cli");
assert!(
cask.legacy_binary.is_none(),
"legacy_binary must be consumed"
);
}
#[test]
fn legacy_homebrew_cask_binary_prepended_when_both_present() {
let yaml = r#"
project_name: test
crates: []
homebrew_casks:
- name: mycask
repository:
owner: o
name: tap
binary: legacy-cli
binaries:
- new-cli
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::apply_homebrew_cask_legacy_singulars(&mut config);
let binaries = config.homebrew_casks.as_ref().unwrap()[0]
.binaries
.as_ref()
.unwrap();
assert_eq!(binaries.len(), 2);
assert_eq!(binaries[0].name(), "legacy-cli");
assert_eq!(binaries[1].name(), "new-cli");
}
#[test]
fn legacy_homebrew_cask_binary_canonical_form_unchanged() {
let yaml = r#"
project_name: test
crates: []
homebrew_casks:
- name: mycask
repository:
owner: o
name: tap
binaries:
- just-cli
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::apply_homebrew_cask_legacy_singulars(&mut config);
let binaries = config.homebrew_casks.as_ref().unwrap()[0]
.binaries
.as_ref()
.unwrap();
assert_eq!(binaries.len(), 1);
assert_eq!(binaries[0].name(), "just-cli");
}
#[test]
fn legacy_homebrew_cask_binary_folds_in_per_crate_publish() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew_cask:
repository:
owner: o
name: tap
binary: per-crate-cli
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::apply_homebrew_cask_legacy_singulars(&mut config);
let cask = config.crates[0]
.publish
.as_ref()
.unwrap()
.homebrew_cask
.as_ref()
.unwrap();
let binaries = cask.binaries.as_ref().unwrap();
assert_eq!(binaries.len(), 1);
assert_eq!(binaries[0].name(), "per-crate-cli");
assert!(cask.legacy_binary.is_none());
}
#[test]
fn legacy_homebrew_cask_manpage_singular_folds_into_manpages() {
let yaml = r#"
project_name: test
crates: []
homebrew_casks:
- name: mycask
repository:
owner: o
name: tap
manpage: foo.1
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::apply_homebrew_cask_legacy_singulars(&mut config);
let cask = &config.homebrew_casks.as_ref().unwrap()[0];
let manpages = cask
.manpages
.as_ref()
.expect("legacy manpage must fold into manpages");
assert_eq!(manpages, &vec!["foo.1".to_string()]);
assert!(
cask.legacy_manpage.is_none(),
"legacy_manpage must be consumed"
);
}
#[test]
fn legacy_homebrew_cask_manpage_appended_when_both_present() {
let yaml = r#"
project_name: test
crates: []
homebrew_casks:
- name: mycask
repository:
owner: o
name: tap
manpage: legacy.1
manpages:
- new.1
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::apply_homebrew_cask_legacy_singulars(&mut config);
let manpages = config.homebrew_casks.as_ref().unwrap()[0]
.manpages
.as_ref()
.unwrap();
assert_eq!(manpages, &vec!["new.1".to_string(), "legacy.1".to_string()]);
}
#[test]
fn legacy_homebrew_cask_manpage_not_reserialized() {
let yaml = r#"
project_name: test
crates: []
homebrew_casks:
- name: mycask
repository:
owner: o
name: tap
manpage: foo.1
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::apply_homebrew_cask_legacy_singulars(&mut config);
let out = serde_yaml_ng::to_string(&config).unwrap();
assert!(
!out.contains("manpage:"),
"legacy `manpage:` must not be re-serialized:\n{out}"
);
assert!(
out.contains("manpages:"),
"canonical `manpages:` must be serialized:\n{out}"
);
}
#[test]
fn legacy_homebrew_cask_manpage_folds_in_per_crate_publish() {
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew_cask:
repository:
owner: o
name: tap
manpage: per-crate.1
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::apply_homebrew_cask_legacy_singulars(&mut config);
let cask = config.crates[0]
.publish
.as_ref()
.unwrap()
.homebrew_cask
.as_ref()
.unwrap();
assert_eq!(
cask.manpages.as_ref().unwrap(),
&vec!["per-crate.1".to_string()]
);
assert!(cask.legacy_manpage.is_none());
}
#[test]
fn legacy_homebrew_cask_manpage_folds_in_workspace_per_crate_publish() {
let yaml = r#"
project_name: test
crates: []
workspaces:
- name: ws1
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
publish:
homebrew_cask:
repository:
owner: o
name: tap
manpage: ws.1
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).unwrap();
super::apply_homebrew_cask_legacy_singulars(&mut config);
let cask = config.workspaces.as_ref().unwrap()[0].crates[0]
.publish
.as_ref()
.unwrap()
.homebrew_cask
.as_ref()
.unwrap();
assert_eq!(cask.manpages.as_ref().unwrap(), &vec!["ws.1".to_string()]);
assert!(
cask.legacy_manpage.is_none(),
"legacy_manpage must be consumed in the workspace arm"
);
}
#[test]
fn builder_field_defaults_to_none_serde() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let build = &cfg.crates[0].builds.as_ref().unwrap()[0];
assert!(build.builder.is_none());
assert!(build.prebuilt.is_none());
}
#[test]
fn builder_kind_prebuilt_parses_lowercase() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
prebuilt:
path: "output/app_{{ .Target }}"
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let build = &cfg.crates[0].builds.as_ref().unwrap()[0];
assert!(matches!(build.builder, Some(BuilderKind::Prebuilt)));
assert_eq!(
build.prebuilt.as_ref().unwrap().path,
"output/app_{{ .Target }}"
);
}
#[test]
fn validate_builds_accepts_default_cargo() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
validate_builds(&cfg).expect("default cargo build must validate cleanly");
}
#[test]
fn validate_builds_rejects_prebuilt_without_path() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_builds(&cfg).unwrap_err();
assert!(
err.contains("`builder: prebuilt` requires a non-empty `prebuilt.path`"),
"unexpected error: {err}"
);
}
#[test]
fn validate_builds_rejects_prebuilt_with_empty_path() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
prebuilt:
path: " "
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_builds(&cfg).unwrap_err();
assert!(
err.contains("non-empty `prebuilt.path`"),
"unexpected: {err}"
);
}
#[test]
fn validate_builds_rejects_prebuilt_without_targets() {
let yaml = r#"
project_name: test
defaults:
targets: ["x86_64-unknown-linux-gnu"]
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
prebuilt:
path: "output/app_{{ .Target }}"
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_builds(&cfg).unwrap_err();
assert!(
err.contains("no explicit `targets:`"),
"unexpected error: {err}"
);
}
#[test]
fn validate_builds_rejects_prebuilt_with_cross_tool() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
cross_tool: "/usr/local/bin/my-cross"
prebuilt:
path: "output/app_{{ .Target }}"
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_builds(&cfg).unwrap_err();
assert!(err.contains("`cross_tool`") && err.contains("mutually exclusive"));
}
#[test]
fn validate_builds_rejects_prebuilt_with_command_override() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
command: "auditable build"
prebuilt:
path: "output/app_{{ .Target }}"
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_builds(&cfg).unwrap_err();
assert!(err.contains("`command:` override"));
}
#[test]
fn validate_builds_rejects_prebuilt_with_features() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
features: ["foo"]
prebuilt:
path: "output/app_{{ .Target }}"
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_builds(&cfg).unwrap_err();
assert!(err.contains("`features:`"));
}
#[test]
fn validate_builds_rejects_prebuilt_with_no_default_features() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
no_default_features: true
prebuilt:
path: "output/app_{{ .Target }}"
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_builds(&cfg).unwrap_err();
assert!(err.contains("`no_default_features:`"));
}
#[test]
fn validate_builds_rejects_crate_cross_with_prebuilt_build() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
cross: zigbuild
builds:
- binary: app
builder: prebuilt
prebuilt:
path: "output/app_{{ .Target }}"
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
let err = validate_builds(&cfg).unwrap_err();
assert!(err.contains("crate-level `cross:`") && err.contains("builder: prebuilt"));
}
#[test]
fn validate_builds_accepts_prebuilt_minimal() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
prebuilt:
path: "output/app_{{ .Target }}"
targets: ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
validate_builds(&cfg).expect("minimal prebuilt config should validate");
}
#[test]
fn all_builds_prebuilt_true_when_every_build_is_prebuilt() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
prebuilt:
path: "output/app_{{ .Target }}"
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(all_builds_prebuilt(&cfg));
}
#[test]
fn all_builds_prebuilt_false_when_any_build_is_cargo() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
builds:
- binary: app
builder: prebuilt
prebuilt:
path: "output/app_{{ .Target }}"
targets: ["x86_64-unknown-linux-gnu"]
- binary: app
targets: ["x86_64-unknown-linux-gnu"]
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(!all_builds_prebuilt(&cfg));
}
#[test]
fn all_builds_prebuilt_false_when_no_builds_declared() {
let yaml = r#"
project_name: test
crates:
- name: app
path: "."
tag_template: "v{{ .Version }}"
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(!all_builds_prebuilt(&cfg));
}