use anyhow::Context as _;
use cargo_metadata::camino::Utf8Path;
use cargo_utils::to_utf8_pathbuf;
use release_plz_core::{
GitReleaseConfig, ReleaseRequest,
fs_utils::to_utf8_path,
set_version::SetVersionRequest,
update_request::{DEFAULT_MAX_ANALYZE_COMMITS, UpdateRequest},
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, path::PathBuf, time::Duration};
use url::Url;
use crate::changelog_config::ChangelogCfg;
#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Debug, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(extend("$id" = "https://raw.githubusercontent.com/release-plz/release-plz/main/.schema/latest.json"))]
pub struct Config {
#[serde(default)]
pub workspace: Workspace,
#[serde(default)]
pub changelog: ChangelogCfg,
#[serde(default)]
package: Vec<PackageSpecificConfigWithName>,
}
impl Config {
fn packages(&self) -> HashMap<&str, &PackageSpecificConfig> {
self.package
.iter()
.map(|p| (p.name.as_str(), &p.config))
.collect()
}
pub fn fill_update_config(
&self,
is_changelog_update_disabled: bool,
update_request: UpdateRequest,
) -> anyhow::Result<UpdateRequest> {
validate_git_only_settings(
self.workspace.packages_defaults.git_only,
self.workspace.packages_defaults.publish,
)
.context("Wrong workspace context")?;
let mut default_update_config = self.workspace.packages_defaults.clone();
if is_changelog_update_disabled {
default_update_config.changelog_update = false.into();
}
let mut update_request =
update_request.with_default_package_config(default_update_config.into());
for (package, config) in self.packages() {
let mut update_config = config.clone();
update_config = update_config.merge(self.workspace.packages_defaults.clone());
let effective_git_only = update_config
.common
.git_only
.or(self.workspace.packages_defaults.git_only);
validate_git_only_settings(effective_git_only, update_config.common.publish)
.with_context(|| format!("Wrong configuration of package {package}"))?;
if is_changelog_update_disabled {
update_config.common.changelog_update = false.into();
}
update_request = update_request.with_package_config(package, update_config.into());
}
Ok(update_request)
}
pub fn fill_set_version_config(
&self,
set_version_request: &mut SetVersionRequest,
) -> anyhow::Result<()> {
for (package, config) in self.packages() {
if let Some(changelog_path) = config.common.changelog_path.clone() {
let changelog_path = to_utf8_pathbuf(changelog_path)?;
set_version_request.set_changelog_path(package, changelog_path);
}
}
Ok(())
}
pub fn fill_release_config(
&self,
allow_dirty: bool,
no_verify: bool,
release_request: ReleaseRequest,
) -> anyhow::Result<ReleaseRequest> {
validate_git_only_settings(
self.workspace.packages_defaults.git_only,
self.workspace.packages_defaults.publish,
)
.context("Wrong workspace context")?;
let mut default_config = self.workspace.packages_defaults.clone();
if no_verify {
default_config.publish_no_verify = Some(true);
}
if allow_dirty {
default_config.publish_allow_dirty = Some(true);
}
let mut release_request =
release_request.with_default_package_config(default_config.into());
for (package, config) in self.packages() {
let mut release_config = config.clone();
release_config = release_config.merge(self.workspace.packages_defaults.clone());
let effective_git_only = release_config
.common
.git_only
.or(self.workspace.packages_defaults.git_only);
validate_git_only_settings(effective_git_only, release_config.common.publish)
.with_context(|| format!("Wrong configuration of package {package}"))?;
if no_verify {
release_config.common.publish_no_verify = Some(true);
}
if allow_dirty {
release_config.common.publish_allow_dirty = Some(true);
}
release_request =
release_request.with_package_config(package, release_config.common.into());
}
Ok(release_request)
}
}
fn validate_git_only_settings(git_only: Option<bool>, publish: Option<bool>) -> anyhow::Result<()> {
if git_only == Some(true) && publish == Some(true) {
anyhow::bail!(
"Config options 'git_only' and 'publish' are mutually exclusive. \
When git_only is enabled, publish must be explicitly set to false."
);
}
Ok(())
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Workspace {
#[serde(flatten)]
pub packages_defaults: PackageConfig,
pub allow_dirty: Option<bool>,
pub changelog_config: Option<PathBuf>,
pub dependencies_update: Option<bool>,
pub pr_name: Option<String>,
pub pr_body: Option<String>,
#[serde(default)]
pub pr_draft: bool,
#[serde(default)]
pub pr_labels: Vec<String>,
pub pr_branch_prefix: Option<String>,
pub publish_timeout: Option<String>,
pub repo_url: Option<Url>,
pub release_commits: Option<String>,
pub release_always: Option<bool>,
#[serde(default = "default_max_analyze_commits")]
#[schemars(default = "default_max_analyze_commits")]
pub max_analyze_commits: Option<u32>,
}
impl Default for Workspace {
fn default() -> Self {
Self {
packages_defaults: PackageConfig::default(),
allow_dirty: None,
changelog_config: None,
dependencies_update: None,
repo_url: None,
pr_name: None,
pr_body: None,
pr_draft: false,
pr_labels: Vec::new(),
pr_branch_prefix: None,
publish_timeout: None,
release_commits: None,
release_always: None,
max_analyze_commits: default_max_analyze_commits(),
}
}
}
impl Workspace {
pub fn publish_timeout(&self) -> anyhow::Result<Duration> {
let publish_timeout = self.publish_timeout.as_deref().unwrap_or("30m");
parse_duration(publish_timeout)
.with_context(|| format!("invalid publish_timeout '{publish_timeout}'"))
}
}
fn default_max_analyze_commits() -> Option<u32> {
Some(DEFAULT_MAX_ANALYZE_COMMITS)
}
fn parse_duration(input: &str) -> anyhow::Result<Duration> {
let (number_str, unit) = parse_duration_unit(input)?;
let number = number_str
.parse::<u64>()
.context("invalid duration number")?;
match unit {
DurationUnit::Seconds => Ok(Duration::from_secs(number)),
DurationUnit::Minutes => Ok(Duration::from_secs(number * 60)),
DurationUnit::Hours => Ok(Duration::from_secs(number * 60 * 60)),
}
}
enum DurationUnit {
Seconds,
Minutes,
Hours,
}
fn parse_duration_unit(input: &str) -> anyhow::Result<(&str, DurationUnit)> {
if let Some(stripped) = input.strip_suffix('s') {
Ok((stripped, DurationUnit::Seconds))
} else if let Some(stripped) = input.strip_suffix('m') {
Ok((stripped, DurationUnit::Minutes))
} else if let Some(stripped) = input.strip_suffix('h') {
Ok((stripped, DurationUnit::Hours))
} else if let Some(last_char) = input.chars().last() {
anyhow::ensure!(
!last_char.is_ascii_alphabetic(),
"'{last_char}' is not a valid time unit. Valid units are: 's', 'm' and 'h'"
);
Ok((input, DurationUnit::Seconds))
} else {
anyhow::bail!("input cannot be empty");
}
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PackageSpecificConfig {
#[serde(flatten)]
common: PackageConfig,
changelog_include: Option<Vec<String>>,
version_group: Option<String>,
}
impl PackageSpecificConfig {
pub fn merge(self, default: PackageConfig) -> Self {
Self {
common: self.common.merge(default),
changelog_include: self.changelog_include,
version_group: self.version_group,
}
}
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, JsonSchema)]
pub struct PackageSpecificConfigWithName {
pub name: String,
#[serde(flatten)]
pub config: PackageSpecificConfig,
}
impl From<PackageConfig> for release_plz_core::ReleaseConfig {
fn from(value: PackageConfig) -> Self {
let is_publish_enabled = value.publish != Some(false);
let is_git_tag_enabled = value.git_tag_enable != Some(false);
let git_tag_name = value.git_tag_name.clone();
let release = value.release != Some(false);
let mut cfg = Self::default()
.with_publish(release_plz_core::PublishConfig::enabled(is_publish_enabled))
.with_git_release(git_release(&value))
.with_git_tag(
release_plz_core::GitTagConfig::enabled(is_git_tag_enabled)
.set_name_template(git_tag_name),
)
.with_release(release);
if let Some(changelog_update) = value.changelog_update {
cfg = cfg.with_changelog_update(changelog_update);
}
if let Some(changelog_path) = value.changelog_path {
cfg = cfg.with_changelog_path(to_utf8_pathbuf(changelog_path).unwrap());
}
if let Some(no_verify) = value.publish_no_verify {
cfg = cfg.with_no_verify(no_verify);
}
if let Some(features) = value.publish_features {
cfg = cfg.with_features(features);
}
if let Some(all_features) = value.publish_all_features {
cfg = cfg.with_all_features(all_features);
}
if let Some(allow_dirty) = value.publish_allow_dirty {
cfg = cfg.with_allow_dirty(allow_dirty);
}
cfg
}
}
fn git_release(config: &PackageConfig) -> GitReleaseConfig {
let is_git_release_enabled = config.git_release_enable != Some(false);
let git_release_type: release_plz_core::ReleaseType = config
.git_release_type
.map(|release_type| release_type.into())
.unwrap_or_default();
let is_git_release_draft = config.git_release_draft == Some(true);
let git_release_name = config.git_release_name.clone();
let git_release_body = config.git_release_body.clone();
let mut git_release = release_plz_core::GitReleaseConfig::enabled(is_git_release_enabled)
.set_draft(is_git_release_draft)
.set_release_type(git_release_type)
.set_name_template(git_release_name)
.set_body_template(git_release_body);
if config.git_release_latest == Some(false) {
git_release = git_release.set_latest(false);
}
git_release
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Default, Clone, JsonSchema)]
pub struct PackageConfig {
pub changelog_path: Option<PathBuf>,
pub changelog_update: Option<bool>,
pub features_always_increment_minor: Option<bool>,
pub git_only: Option<bool>,
pub git_release_enable: Option<bool>,
pub git_release_body: Option<String>,
pub git_release_type: Option<ReleaseType>,
pub git_release_draft: Option<bool>,
pub git_release_latest: Option<bool>,
pub git_release_name: Option<String>,
pub git_tag_enable: Option<bool>,
pub git_tag_name: Option<String>,
pub publish: Option<bool>,
pub publish_allow_dirty: Option<bool>,
pub publish_no_verify: Option<bool>,
pub publish_features: Option<Vec<String>>,
pub publish_all_features: Option<bool>,
pub semver_check: Option<bool>,
pub release: Option<bool>,
pub custom_minor_increment_regex: Option<String>,
pub custom_major_increment_regex: Option<String>,
}
impl From<PackageConfig> for release_plz_core::UpdateConfig {
fn from(config: PackageConfig) -> Self {
Self {
semver_check: config.semver_check != Some(false),
changelog_update: config.changelog_update != Some(false),
release: config.release != Some(false),
publish: config.publish != Some(false),
tag_name_template: config.git_tag_name,
features_always_increment_minor: config.features_always_increment_minor == Some(true),
changelog_path: config.changelog_path.map(|p| to_utf8_pathbuf(p).unwrap()),
custom_minor_increment_regex: config.custom_minor_increment_regex,
custom_major_increment_regex: config.custom_major_increment_regex,
git_only: config.git_only,
}
}
}
impl From<PackageSpecificConfig> for release_plz_core::PackageUpdateConfig {
fn from(config: PackageSpecificConfig) -> Self {
Self {
generic: config.common.into(),
changelog_include: config.changelog_include.unwrap_or_default(),
version_group: config.version_group,
}
}
}
impl PackageConfig {
pub fn merge(self, default: Self) -> Self {
Self {
semver_check: self.semver_check.or(default.semver_check),
changelog_path: self.changelog_path.or(default.changelog_path),
changelog_update: self.changelog_update.or(default.changelog_update),
features_always_increment_minor: self
.features_always_increment_minor
.or(default.features_always_increment_minor),
git_release_enable: self.git_release_enable.or(default.git_release_enable),
git_release_type: self.git_release_type.or(default.git_release_type),
git_release_draft: self.git_release_draft.or(default.git_release_draft),
git_release_latest: self.git_release_latest.or(default.git_release_latest),
git_release_name: self.git_release_name.or(default.git_release_name),
git_release_body: self.git_release_body.or(default.git_release_body),
publish: self.publish.or(default.publish),
publish_allow_dirty: self.publish_allow_dirty.or(default.publish_allow_dirty),
publish_no_verify: self.publish_no_verify.or(default.publish_no_verify),
publish_features: self.publish_features.or(default.publish_features),
publish_all_features: self.publish_all_features.or(default.publish_all_features),
git_tag_enable: self.git_tag_enable.or(default.git_tag_enable),
git_tag_name: self.git_tag_name.or(default.git_tag_name),
release: self.release.or(default.release),
custom_minor_increment_regex: self
.custom_minor_increment_regex
.or(default.custom_minor_increment_regex),
custom_major_increment_regex: self
.custom_major_increment_regex
.or(default.custom_major_increment_regex),
git_only: self.git_only.or(default.git_only),
}
}
pub fn changelog_path(&self) -> Option<&Utf8Path> {
self.changelog_path
.as_ref()
.map(|p| to_utf8_path(p.as_ref()).unwrap())
}
}
#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Debug, Clone, Copy, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReleaseType {
#[default]
Prod,
Pre,
Auto,
}
impl From<ReleaseType> for release_plz_core::ReleaseType {
fn from(value: ReleaseType) -> Self {
match value {
ReleaseType::Prod => Self::Prod,
ReleaseType::Pre => Self::Pre,
ReleaseType::Auto => Self::Auto,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const BASE_WORKSPACE_CONFIG: &str = r#"
[workspace]
dependencies_update = false
allow_dirty = false
changelog_config = "../git-cliff.toml"
repo_url = "https://github.com/release-plz/release-plz"
git_release_enable = true
git_release_type = "prod"
git_release_draft = false
pr_branch_prefix = "f-"
publish_timeout = "10m"
release_commits = "^feat:"
"#;
const BASE_PACKAGE_CONFIG: &str = r#"
[[package]]
name = "crate1"
"#;
fn create_base_workspace_config() -> Config {
Config {
changelog: ChangelogCfg::default(),
workspace: Workspace {
dependencies_update: Some(false),
changelog_config: Some("../git-cliff.toml".into()),
allow_dirty: Some(false),
repo_url: Some(
"https://github.com/release-plz/release-plz"
.parse()
.unwrap(),
),
packages_defaults: PackageConfig {
semver_check: None,
changelog_update: None,
git_release_enable: Some(true),
git_release_type: Some(ReleaseType::Prod),
git_release_draft: Some(false),
..Default::default()
},
pr_name: None,
pr_body: None,
pr_draft: false,
pr_labels: vec![],
pr_branch_prefix: Some("f-".to_string()),
publish_timeout: Some("10m".to_string()),
release_commits: Some("^feat:".to_string()),
release_always: None,
max_analyze_commits: default_max_analyze_commits(),
},
package: [].into(),
}
}
fn create_base_package_config() -> PackageSpecificConfigWithName {
PackageSpecificConfigWithName {
name: "crate1".to_string(),
config: PackageSpecificConfig {
common: PackageConfig {
semver_check: None,
changelog_update: None,
git_release_enable: None,
git_release_type: None,
git_release_draft: None,
..Default::default()
},
changelog_include: None,
version_group: None,
},
}
}
#[test]
fn config_without_update_config_is_deserialized() {
let expected_config = create_base_workspace_config();
let config: Config = toml::from_str(BASE_WORKSPACE_CONFIG).unwrap();
assert_eq!(config, expected_config);
}
#[test]
fn config_is_deserialized() {
let config = &format!(
"{BASE_WORKSPACE_CONFIG}\
changelog_update = true"
);
let mut expected_config = create_base_workspace_config();
expected_config.workspace.packages_defaults.changelog_update = true.into();
let config: Config = toml::from_str(config).unwrap();
assert_eq!(config, expected_config);
}
fn config_package_release_is_deserialized(config_flag: &str, expected_value: bool) {
let config = &format!(
"{BASE_WORKSPACE_CONFIG}\n{BASE_PACKAGE_CONFIG}\
release = {config_flag}"
);
let mut expected_config = create_base_workspace_config();
let mut package_config = create_base_package_config();
package_config.config.common.release = expected_value.into();
expected_config.package = [package_config].into();
let config: Config = toml::from_str(config).unwrap();
assert_eq!(config, expected_config);
}
#[test]
fn config_package_release_is_deserialized_true() {
config_package_release_is_deserialized("true", true);
}
#[test]
fn config_package_release_is_deserialized_false() {
config_package_release_is_deserialized("false", false);
}
fn config_workspace_release_is_deserialized(config_flag: &str, expected_value: bool) {
let config = &format!(
"{BASE_WORKSPACE_CONFIG}\
release = {config_flag}"
);
let mut expected_config = create_base_workspace_config();
expected_config.workspace.packages_defaults.release = expected_value.into();
let config: Config = toml::from_str(config).unwrap();
assert_eq!(config, expected_config);
}
#[test]
fn config_workspace_release_is_deserialized_true() {
config_workspace_release_is_deserialized("true", true);
}
#[test]
fn config_workspace_release_is_deserialized_false() {
config_workspace_release_is_deserialized("false", false);
}
#[test]
fn config_is_serialized() {
let config = Config {
changelog: ChangelogCfg::default(),
workspace: Workspace {
dependencies_update: None,
changelog_config: Some("../git-cliff.toml".into()),
allow_dirty: None,
repo_url: Some(
"https://github.com/release-plz/release-plz"
.parse()
.unwrap(),
),
pr_name: None,
pr_body: None,
pr_draft: false,
pr_labels: vec!["label1".to_string()],
pr_branch_prefix: Some("f-".to_string()),
packages_defaults: PackageConfig {
semver_check: None,
changelog_update: true.into(),
git_release_enable: true.into(),
git_release_type: Some(ReleaseType::Prod),
git_release_draft: Some(false),
release: Some(true),
changelog_path: Some("./CHANGELOG.md".into()),
..Default::default()
},
publish_timeout: Some("10m".to_string()),
release_commits: Some("^feat:".to_string()),
release_always: None,
max_analyze_commits: default_max_analyze_commits(),
},
package: [PackageSpecificConfigWithName {
name: "crate1".to_string(),
config: PackageSpecificConfig {
common: PackageConfig {
semver_check: Some(false),
changelog_update: true.into(),
git_release_enable: true.into(),
git_release_type: Some(ReleaseType::Prod),
git_release_draft: Some(false),
release: Some(false),
..Default::default()
},
changelog_include: Some(vec!["pkg1".to_string()]),
version_group: None,
},
}]
.into(),
};
expect_test::expect![[r#"
[workspace]
changelog_path = "./CHANGELOG.md"
changelog_update = true
git_release_enable = true
git_release_type = "prod"
git_release_draft = false
release = true
changelog_config = "../git-cliff.toml"
pr_draft = false
pr_labels = ["label1"]
pr_branch_prefix = "f-"
publish_timeout = "10m"
repo_url = "https://github.com/release-plz/release-plz"
release_commits = "^feat:"
max_analyze_commits = 1000
[changelog]
[[package]]
name = "crate1"
changelog_update = true
git_release_enable = true
git_release_type = "prod"
git_release_draft = false
semver_check = false
release = false
changelog_include = ["pkg1"]
"#]]
.assert_eq(&toml::to_string(&config).unwrap());
}
#[test]
fn wrong_config_section_is_not_deserialized() {
let config = "[unknown]";
let error = toml::from_str::<Config>(config).unwrap_err().to_string();
expect_test::expect![[r"
TOML parse error at line 1, column 2
|
1 | [unknown]
| ^^^^^^^
unknown field `unknown`, expected one of `workspace`, `changelog`, `package`
"]]
.assert_eq(&error);
}
#[test]
fn wrong_workspace_section_is_not_deserialized() {
let config = r"
[workspace]
unknown = false
allow_dirty = true";
let error = toml::from_str::<Config>(config).unwrap_err().to_string();
expect_test::expect![[r"
TOML parse error at line 2, column 1
|
2 | [workspace]
| ^^^^^^^^^^^
unknown field `unknown`
"]]
.assert_eq(&error);
}
#[test]
fn wrong_changelog_section_is_not_deserialized() {
let config = r"
[changelog]
trim = true
unknown = false";
let error = toml::from_str::<Config>(config).unwrap_err().to_string();
expect_test::expect![[r"
TOML parse error at line 4, column 1
|
4 | unknown = false
| ^^^^^^^
unknown field `unknown`, expected one of `header`, `body`, `trim`, `commit_preprocessors`, `postprocessors`, `sort_commits`, `link_parsers`, `commit_parsers`, `protect_breaking_commits`, `tag_pattern`
"]]
.assert_eq(&error);
}
#[test]
fn wrong_package_section_is_not_deserialized() {
let config = r#"
[[package]]
name = "crate1"
unknown = false"#;
let error = toml::from_str::<Config>(config).unwrap_err().to_string();
expect_test::expect![[r"
TOML parse error at line 2, column 1
|
2 | [[package]]
| ^^^^^^^^^^^
unknown field `unknown`
"]]
.assert_eq(&error);
}
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
assert_eq!(parse_duration("60").unwrap(), Duration::from_secs(60)); assert_eq!(
parse_duration("").unwrap_err().to_string(),
"input cannot be empty"
);
assert_eq!(
parse_duration("30x").unwrap_err().to_string(),
"'x' is not a valid time unit. Valid units are: 's', 'm' and 'h'"
);
assert_eq!(
parse_duration("-30s").unwrap_err().to_string(),
"invalid duration number"
);
}
#[test]
fn custom_minor_increment_regex_is_deserialized() {
let config = &format!(
"{BASE_WORKSPACE_CONFIG}\
custom_minor_increment_regex = \"minor|enhancement\""
);
let mut expected_config = create_base_workspace_config();
expected_config
.workspace
.packages_defaults
.custom_minor_increment_regex = Some("minor|enhancement".to_string());
let config: Config = toml::from_str(config).unwrap();
assert_eq!(config, expected_config);
}
#[test]
fn custom_minor_increment_regex_is_serialized() {
let mut config = create_base_workspace_config();
config
.workspace
.packages_defaults
.custom_minor_increment_regex = Some("minor|enhancement".to_string());
let serialized = toml::to_string(&config).unwrap();
assert!(serialized.contains(r#"custom_minor_increment_regex = "minor|enhancement""#));
}
}