use crate::config::unify::default_true;
use crate::error::ConfigError;
use serde::{Deserialize, Deserializer, Serialize, de};
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize)]
pub struct ReleaseConfig {
#[serde(default)]
pub source: ReleaseSource,
#[serde(default = "default_tag_prefix")]
pub tag_prefix: String,
#[serde(default = "default_tag_format")]
pub tag_format: String,
#[serde(default = "default_true")]
pub require_clean: bool,
#[serde(default = "default_publish_delay")]
pub publish_delay: u64,
#[serde(default)]
pub remote_effects: ReleaseRemoteEffects,
#[serde(default)]
pub sign_tags: bool,
#[serde(default)]
pub require_changelog_entries: bool,
#[serde(default = "default_true")]
pub require_release_notes: bool,
#[serde(default = "default_release_notes_dir")]
pub release_notes_dir: String,
#[serde(default = "default_change_dir")]
pub change_dir: String,
#[serde(default)]
pub pre_1_breaking_bump: Pre1BreakingBump,
#[serde(default)]
pub unconventional_commits: CommitPolicy,
#[serde(default)]
pub semver_check: SemverCheckPolicy,
#[serde(default)]
pub require_change_files: RequireChangeFiles,
#[serde(default)]
pub version_groups: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub changelog: ChangelogShape,
}
impl Default for ReleaseConfig {
fn default() -> Self {
Self {
source: ReleaseSource::default(),
tag_prefix: default_tag_prefix(),
tag_format: default_tag_format(),
require_clean: true,
publish_delay: default_publish_delay(),
remote_effects: ReleaseRemoteEffects::default(),
sign_tags: false,
require_changelog_entries: false,
require_release_notes: true,
release_notes_dir: default_release_notes_dir(),
change_dir: default_change_dir(),
pre_1_breaking_bump: Pre1BreakingBump::default(),
unconventional_commits: CommitPolicy::default(),
semver_check: SemverCheckPolicy::default(),
require_change_files: RequireChangeFiles::default(),
version_groups: BTreeMap::new(),
changelog: ChangelogShape::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReleaseSource {
#[default]
Changes,
Commits,
Both,
}
impl ReleaseSource {
pub const fn uses_commits(self) -> bool {
matches!(self, Self::Commits | Self::Both)
}
pub const fn uses_changes(self) -> bool {
matches!(self, Self::Changes | Self::Both)
}
}
impl ReleaseConfig {
pub fn requires_change_file(&self, crate_name: &str) -> bool {
self.source == ReleaseSource::Changes || self.require_change_files.applies_to(crate_name)
}
pub fn validate(&self, workspace_members: &[String]) -> Result<Vec<String>, ConfigError> {
let mut warnings = Vec::new();
if self.tag_format.trim().is_empty() {
return Err(ConfigError::InvalidField {
field: "release.tag_format".to_string(),
reason: "tag_format cannot be empty".to_string(),
});
}
let is_monorepo = workspace_members.len() > 1;
if is_monorepo && !self.tag_format.contains("{crate}") {
warnings.push(
"release.tag_format does not contain {crate} placeholder. \
In monorepos, this may cause tag collisions between crates."
.to_string(),
);
}
if !self.tag_format.contains("{version}") && !self.tag_format.contains("{prefix}") {
warnings.push(
"release.tag_format does not contain {version} or {prefix} placeholder. \
Tags may not be identifiable."
.to_string(),
);
}
validate_change_dir(&self.change_dir)?;
if let RequireChangeFiles::Crates(crates) = &self.require_change_files {
for crate_name in crates {
if !workspace_members.contains(crate_name) {
warnings.push(format!(
"release.require_change_files contains unknown crate '{}'. \
Available crates: {}",
crate_name,
workspace_members.join(", ")
));
}
}
}
self.validate_version_groups(workspace_members)?;
self.changelog.filters.validate("release.changelog.filters")?;
Ok(warnings)
}
fn validate_version_groups(&self, workspace_members: &[String]) -> Result<(), ConfigError> {
let mut owners: BTreeMap<&str, &str> = BTreeMap::new();
for (group_name, members) in &self.version_groups {
if group_name.trim().is_empty() {
return Err(ConfigError::InvalidField {
field: "release.version_groups".to_string(),
reason: "version group names cannot be empty".to_string(),
});
}
if members.is_empty() {
return Err(ConfigError::InvalidField {
field: format!("release.version_groups.{}", group_name),
reason: "version groups must contain at least one crate".to_string(),
});
}
for crate_name in members {
if !workspace_members.contains(crate_name) {
return Err(ConfigError::InvalidField {
field: format!("release.version_groups.{}", group_name),
reason: format!("unknown workspace crate '{}'", crate_name),
});
}
if let Some(previous) = owners.insert(crate_name, group_name) {
return Err(ConfigError::InvalidField {
field: "release.version_groups".to_string(),
reason: format!(
"crate '{}' belongs to multiple version groups: {}, {}",
crate_name, previous, group_name
),
});
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Pre1BreakingBump {
#[default]
Minor,
Major,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CommitPolicy {
Allow,
#[default]
Warn,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SemverCheckPolicy {
Off,
#[default]
Warn,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReleaseRemoteEffects {
#[default]
None,
Push,
Auto,
Github,
Gitlab,
}
impl ReleaseRemoteEffects {
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Push => "push",
Self::Auto => "auto",
Self::Github => "github",
Self::Gitlab => "gitlab",
}
}
pub const fn pushes(self) -> bool {
!matches!(self, Self::None)
}
pub const fn creates_forge_release(self) -> bool {
matches!(self, Self::Auto | Self::Github | Self::Gitlab)
}
}
#[derive(Deserialize)]
#[serde(default)]
struct ReleaseConfigInput {
source: ReleaseSource,
tag_prefix: String,
tag_format: String,
require_clean: bool,
publish_delay: u64,
remote_effects: Option<ReleaseRemoteEffects>,
create_github_release: Option<bool>,
forge: Option<LegacyReleaseForge>,
push: Option<bool>,
sign_tags: bool,
require_changelog_entries: bool,
require_release_notes: bool,
release_notes_dir: String,
change_dir: String,
pre_1_breaking_bump: Pre1BreakingBump,
unconventional_commits: CommitPolicy,
semver_check: SemverCheckPolicy,
require_change_files: RequireChangeFiles,
version_groups: BTreeMap<String, Vec<String>>,
changelog: ChangelogShape,
}
impl Default for ReleaseConfigInput {
fn default() -> Self {
let config = ReleaseConfig::default();
Self {
source: config.source,
tag_prefix: config.tag_prefix,
tag_format: config.tag_format,
require_clean: config.require_clean,
publish_delay: config.publish_delay,
remote_effects: None,
create_github_release: None,
forge: None,
push: None,
sign_tags: config.sign_tags,
require_changelog_entries: config.require_changelog_entries,
require_release_notes: config.require_release_notes,
release_notes_dir: config.release_notes_dir,
change_dir: config.change_dir,
pre_1_breaking_bump: config.pre_1_breaking_bump,
unconventional_commits: config.unconventional_commits,
semver_check: config.semver_check,
require_change_files: config.require_change_files,
version_groups: config.version_groups,
changelog: config.changelog,
}
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
enum LegacyReleaseForge {
#[default]
Auto,
Github,
Gitlab,
}
impl<'de> Deserialize<'de> for ReleaseConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let input = ReleaseConfigInput::deserialize(deserializer)?;
let has_legacy = input.create_github_release.is_some() || input.forge.is_some() || input.push.is_some();
if input.remote_effects.is_some() && has_legacy {
return Err(de::Error::custom(
"release.remote_effects cannot be combined with deprecated release.push, release.create_github_release, or release.forge; run `cargo rail config migrate`",
));
}
let remote_effects = if let Some(remote_effects) = input.remote_effects {
remote_effects
} else {
let push = input.push.unwrap_or(false);
let create_release = input.create_github_release.unwrap_or(false);
if create_release && !push {
return Err(de::Error::custom(
"deprecated release.create_github_release = true requires release.push = true before it can be migrated",
));
}
if create_release {
match input.forge.unwrap_or_default() {
LegacyReleaseForge::Auto => ReleaseRemoteEffects::Auto,
LegacyReleaseForge::Github => ReleaseRemoteEffects::Github,
LegacyReleaseForge::Gitlab => ReleaseRemoteEffects::Gitlab,
}
} else if push {
ReleaseRemoteEffects::Push
} else {
ReleaseRemoteEffects::None
}
};
Ok(Self {
source: input.source,
tag_prefix: input.tag_prefix,
tag_format: input.tag_format,
require_clean: input.require_clean,
publish_delay: input.publish_delay,
remote_effects,
sign_tags: input.sign_tags,
require_changelog_entries: input.require_changelog_entries,
require_release_notes: input.require_release_notes,
release_notes_dir: input.release_notes_dir,
change_dir: input.change_dir,
pre_1_breaking_bump: input.pre_1_breaking_bump,
unconventional_commits: input.unconventional_commits,
semver_check: input.semver_check,
require_change_files: input.require_change_files,
version_groups: input.version_groups,
changelog: input.changelog,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RequireChangeFiles {
All(bool),
Crates(Vec<String>),
}
impl Default for RequireChangeFiles {
fn default() -> Self {
Self::All(false)
}
}
impl RequireChangeFiles {
pub fn applies_to(&self, crate_name: &str) -> bool {
match self {
Self::All(enabled) => *enabled,
Self::Crates(crates) => crates.iter().any(|c| c == crate_name),
}
}
pub fn is_enabled(&self) -> bool {
match self {
Self::All(enabled) => *enabled,
Self::Crates(crates) => !crates.is_empty(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogShape {
#[serde(default = "default_changelog_path")]
pub path: String,
#[serde(default)]
pub relative_to: ChangelogRelativeTo,
#[serde(default = "default_entry_format")]
pub entry_format: String,
#[serde(default = "default_true")]
pub emoji: bool,
#[serde(default = "default_group_order")]
pub group_order: Vec<String>,
#[serde(default = "default_fallback")]
pub fallback: String,
#[serde(default)]
pub groups: Vec<GroupSpec>,
#[serde(default)]
pub filters: ChangelogFilters,
#[serde(default)]
pub commit_url: Option<String>,
#[serde(default)]
pub pr_url: Option<String>,
}
impl Default for ChangelogShape {
fn default() -> Self {
Self {
path: default_changelog_path(),
relative_to: ChangelogRelativeTo::default(),
entry_format: default_entry_format(),
emoji: true,
group_order: default_group_order(),
fallback: default_fallback(),
groups: Vec::new(),
filters: ChangelogFilters::default(),
commit_url: None,
pr_url: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupSpec {
pub types: Vec<String>,
pub title: String,
#[serde(default)]
pub emoji: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChangelogFilters {
#[serde(default)]
pub skip_types: Vec<String>,
#[serde(default)]
pub skip_scopes: Vec<String>,
#[serde(default)]
pub include_paths: Vec<String>,
#[serde(default)]
pub exclude_paths: Vec<String>,
}
impl ChangelogFilters {
pub fn validate(&self, field_prefix: &str) -> Result<(), ConfigError> {
validate_globs(field_prefix, "include_paths", &self.include_paths)?;
validate_globs(field_prefix, "exclude_paths", &self.exclude_paths)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrateReleaseConfig {
#[serde(default = "default_true")]
pub publish: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChangelogConfig {
pub path: Option<PathBuf>,
pub relative_to: Option<ChangelogRelativeTo>,
#[serde(default)]
pub skip: bool,
pub entry_format: Option<String>,
pub emoji: Option<bool>,
pub group_order: Option<Vec<String>>,
pub fallback: Option<String>,
#[serde(default)]
pub groups: Vec<GroupSpec>,
pub filters: Option<ChangelogFilters>,
pub commit_url: Option<String>,
pub pr_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangelogRelativeTo {
#[default]
Crate,
Workspace,
}
fn default_tag_prefix() -> String {
"v".to_string()
}
fn default_tag_format() -> String {
"{crate}-{prefix}{version}".to_string()
}
fn default_publish_delay() -> u64 {
5
}
fn default_changelog_path() -> String {
"CHANGELOG.md".to_string()
}
fn default_release_notes_dir() -> String {
"release-notes".to_string()
}
fn default_change_dir() -> String {
crate::release::change_files::DEFAULT_CHANGE_DIR.to_string()
}
fn validate_change_dir(change_dir: &str) -> Result<(), ConfigError> {
if let Some(reason) = crate::release::change_files::change_dir_validation_error(change_dir) {
return Err(ConfigError::InvalidField {
field: "release.change_dir".to_string(),
reason: reason.to_string(),
});
}
Ok(())
}
fn validate_globs(field_prefix: &str, key: &str, patterns: &[String]) -> Result<(), ConfigError> {
for pattern in patterns {
if let Err(e) = glob::Pattern::new(pattern) {
return Err(ConfigError::InvalidGlobPattern {
pattern: format!("{}.{} = {}", field_prefix, key, pattern),
message: e.to_string(),
});
}
}
Ok(())
}
fn default_entry_format() -> String {
"- {scope}{breaking}{description}{prs} ({sha_link})".to_string()
}
fn default_fallback() -> String {
"other".to_string()
}
pub(crate) fn default_group_order() -> Vec<String> {
[
"breaking", "feat", "fix", "build", "chore", "ci", "deps", "docs", "other", "perf", "refactor", "style", "test",
]
.iter()
.map(|s| (*s).to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changelog_shape_defaults() {
let shape = ChangelogShape::default();
assert_eq!(shape.path, "CHANGELOG.md");
assert_eq!(shape.relative_to, ChangelogRelativeTo::Crate);
assert!(shape.emoji);
assert_eq!(shape.fallback, "other");
assert_eq!(shape.group_order.first().map(String::as_str), Some("breaking"));
assert!(shape.groups.is_empty());
}
#[test]
fn changelog_shape_parses_nested_table() {
let toml = r#"
[changelog]
path = "docs/CHANGELOG.md"
relative_to = "workspace"
emoji = false
group_order = ["breaking", "feat", "sec"]
fallback = "skip"
[[changelog.groups]]
types = ["sec", "security"]
title = "Security"
emoji = "🔒"
[changelog.filters]
skip_types = ["chore"]
"#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.changelog.path, "docs/CHANGELOG.md");
assert_eq!(config.changelog.relative_to, ChangelogRelativeTo::Workspace);
assert!(!config.changelog.emoji);
assert_eq!(config.changelog.fallback, "skip");
assert_eq!(config.changelog.groups.len(), 1);
assert_eq!(config.changelog.groups[0].types, vec!["sec", "security"]);
assert_eq!(config.changelog.filters.skip_types, vec!["chore"]);
}
#[test]
fn release_policy_defaults() {
let config = ReleaseConfig::default();
assert_eq!(config.source, ReleaseSource::Changes);
assert_eq!(config.pre_1_breaking_bump, Pre1BreakingBump::Minor);
assert_eq!(config.unconventional_commits, CommitPolicy::Warn);
assert_eq!(config.semver_check, SemverCheckPolicy::Warn);
assert_eq!(config.remote_effects, ReleaseRemoteEffects::None);
assert_eq!(config.change_dir, ".changes");
assert!(!config.require_change_files.is_enabled());
assert!(config.requires_change_file("any-crate"));
assert!(config.version_groups.is_empty());
assert!(config.require_release_notes);
}
#[test]
fn release_policies_parse() {
let toml = r#"
pre_1_breaking_bump = "major"
source = "commits"
unconventional_commits = "deny"
semver_check = "off"
change_dir = "changes"
remote_effects = "gitlab"
"#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.pre_1_breaking_bump, Pre1BreakingBump::Major);
assert_eq!(config.source, ReleaseSource::Commits);
assert_eq!(config.unconventional_commits, CommitPolicy::Deny);
assert_eq!(config.semver_check, SemverCheckPolicy::Off);
assert_eq!(config.change_dir, "changes");
assert_eq!(config.remote_effects, ReleaseRemoteEffects::Gitlab);
}
#[test]
fn compatibility_sources_retain_configured_change_coverage() {
let config: ReleaseConfig = toml_edit::de::from_str("source = \"commits\"").unwrap();
assert!(!config.requires_change_file("core"));
let config: ReleaseConfig =
toml_edit::de::from_str("source = \"both\"\nrequire_change_files = [\"core\"]").unwrap();
assert!(config.requires_change_file("core"));
assert!(!config.requires_change_file("cli"));
}
#[test]
fn valid_legacy_release_effects_map_to_typed_policy() {
let config: ReleaseConfig =
toml_edit::de::from_str("push = true\ncreate_github_release = true\nforge = \"github\"\n").unwrap();
assert_eq!(config.remote_effects, ReleaseRemoteEffects::Github);
let config: ReleaseConfig = toml_edit::de::from_str("push = true\n").unwrap();
assert_eq!(config.remote_effects, ReleaseRemoteEffects::Push);
}
#[test]
fn require_change_files_forms() {
let toml = r#"require_change_files = true"#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert!(config.require_change_files.applies_to("anything"));
let toml = r#"require_change_files = ["core"]"#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert!(config.require_change_files.applies_to("core"));
assert!(!config.require_change_files.applies_to("cli"));
assert!(config.require_change_files.is_enabled());
}
#[test]
fn require_change_files_unknown_crate_warns() {
let config = ReleaseConfig {
require_change_files: RequireChangeFiles::Crates(vec!["ghost".to_string()]),
..ReleaseConfig::default()
};
let warnings = config.validate(&["real".to_string()]).unwrap();
assert!(warnings.iter().any(|w| w.contains("ghost")));
}
#[test]
fn crate_changelog_overrides_parse() {
let toml = r#"
path = "HISTORY.md"
relative_to = "workspace"
emoji = false
group_order = ["feat"]
commit_url = "https://example.com/{sha}"
"#;
let config: ChangelogConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.path, Some(PathBuf::from("HISTORY.md")));
assert_eq!(config.relative_to, Some(ChangelogRelativeTo::Workspace));
assert_eq!(config.emoji, Some(false));
assert_eq!(config.group_order, Some(vec!["feat".to_string()]));
assert_eq!(config.commit_url.as_deref(), Some("https://example.com/{sha}"));
assert!(config.fallback.is_none());
assert!(!config.skip);
}
#[test]
fn test_require_release_notes_default_true() {
let config = ReleaseConfig::default();
assert!(config.require_release_notes);
}
#[test]
fn test_require_release_notes_parsing_false() {
let toml = r#"require_release_notes = false"#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert!(!config.require_release_notes);
}
#[test]
fn legacy_forge_release_without_push_cannot_be_constructed() {
let err = toml_edit::de::from_str::<ReleaseConfig>("create_github_release = true\npush = false\n").unwrap_err();
assert!(err.to_string().contains("requires release.push = true"));
}
#[test]
fn test_change_dir_rejects_legacy_path() {
let config = ReleaseConfig {
change_dir: ".rail/changes".to_string(),
..ReleaseConfig::default()
};
let err = config.validate(&["crate-a".to_string()]).unwrap_err();
assert!(err.to_string().contains("removed"));
}
#[test]
fn test_version_groups_validate_unknown_and_duplicate_members() {
let mut config = ReleaseConfig::default();
config
.version_groups
.insert("core".to_string(), vec!["real".to_string(), "ghost".to_string()]);
let err = config.validate(&["real".to_string()]).unwrap_err();
assert!(err.to_string().contains("ghost"));
let mut config = ReleaseConfig::default();
config
.version_groups
.insert("core".to_string(), vec!["real".to_string()]);
config
.version_groups
.insert("cli".to_string(), vec!["real".to_string()]);
let err = config.validate(&["real".to_string()]).unwrap_err();
assert!(err.to_string().contains("multiple version groups"));
}
}