use crate::config::unify::default_true;
use crate::error::ConfigError;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseConfig {
#[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 create_github_release: bool,
#[serde(default)]
pub forge: ReleaseForgeConfig,
#[serde(default)]
pub push: bool,
#[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 {
tag_prefix: default_tag_prefix(),
tag_format: default_tag_format(),
require_clean: true,
publish_delay: default_publish_delay(),
create_github_release: false,
forge: ReleaseForgeConfig::default(),
push: false,
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(),
}
}
}
impl ReleaseConfig {
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(),
);
}
if self.create_github_release && !self.push {
return Err(ConfigError::InvalidField {
field: "release.create_github_release".to_string(),
reason: "create_github_release = true requires push = true so cargo-rail owns the pushed tag before creating a forge release".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 ReleaseForgeConfig {
#[default]
Auto,
Github,
Gitlab,
}
#[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.pre_1_breaking_bump, Pre1BreakingBump::Minor);
assert_eq!(config.unconventional_commits, CommitPolicy::Warn);
assert_eq!(config.semver_check, SemverCheckPolicy::Warn);
assert_eq!(config.forge, ReleaseForgeConfig::Auto);
assert_eq!(config.change_dir, ".changes");
assert!(!config.require_change_files.is_enabled());
assert!(config.version_groups.is_empty());
assert!(config.require_release_notes);
}
#[test]
fn release_policies_parse() {
let toml = r#"
pre_1_breaking_bump = "major"
unconventional_commits = "deny"
semver_check = "off"
change_dir = "changes"
forge = "gitlab"
"#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.pre_1_breaking_bump, Pre1BreakingBump::Major);
assert_eq!(config.unconventional_commits, CommitPolicy::Deny);
assert_eq!(config.semver_check, SemverCheckPolicy::Off);
assert_eq!(config.change_dir, "changes");
assert_eq!(config.forge, ReleaseForgeConfig::Gitlab);
}
#[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 test_github_release_requires_owned_push() {
let config = ReleaseConfig {
create_github_release: true,
push: false,
..ReleaseConfig::default()
};
let err = config.validate(&["crate-a".to_string()]).unwrap_err();
assert!(err.to_string().contains("requires 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"));
}
}