use crate::config::unify::default_true;
use crate::error::ConfigError;
use serde::{Deserialize, Serialize};
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 push: bool,
#[serde(default)]
pub sign_tags: bool,
#[serde(default = "default_changelog_path")]
pub changelog_path: String,
#[serde(default)]
pub changelog_relative_to: ChangelogRelativeTo,
#[serde(default)]
pub skip_changelog_for: Vec<String>,
#[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,
}
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,
push: false,
sign_tags: false,
changelog_path: default_changelog_path(),
changelog_relative_to: ChangelogRelativeTo::default(),
skip_changelog_for: Vec::new(),
require_changelog_entries: false,
require_release_notes: true,
release_notes_dir: default_release_notes_dir(),
}
}
}
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".to_string(),
});
}
for crate_name in &self.skip_changelog_for {
if !workspace_members.contains(crate_name) {
warnings.push(format!(
"release.skip_changelog_for contains unknown crate '{}'. \
Available crates: {}",
crate_name,
workspace_members.join(", ")
));
}
}
Ok(warnings)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrateReleaseConfig {
#[serde(default = "default_true")]
pub publish: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogConfig {
pub path: Option<PathBuf>,
#[serde(default)]
pub skip: bool,
}
#[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()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_changelog_relative_to_default() {
let config = ReleaseConfig::default();
assert_eq!(config.changelog_relative_to, ChangelogRelativeTo::Crate);
}
#[test]
fn test_changelog_relative_to_parsing() {
let toml = r#"changelog_relative_to = "crate""#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.changelog_relative_to, ChangelogRelativeTo::Crate);
let toml = r#"changelog_relative_to = "workspace""#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.changelog_relative_to, ChangelogRelativeTo::Workspace);
}
#[test]
fn test_changelog_relative_to_full_config() {
let toml = r#"
changelog_path = "docs/CHANGELOG.md"
changelog_relative_to = "workspace"
"#;
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);
}
#[test]
fn test_changelog_relative_to_defaults_to_crate() {
let toml = r#"
changelog_path = "CHANGELOG.md"
"#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.changelog_relative_to, ChangelogRelativeTo::Crate);
}
#[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"));
}
}