use cargo_metadata::camino::Utf8Path;
use cargo_utils::to_utf8_pathbuf;
use release_plz_core::{
fs_utils::to_utf8_path, set_version::SetVersionRequest, GitReleaseConfig, ReleaseRequest,
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)]
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,
) -> UpdateRequest {
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());
if is_changelog_update_disabled {
update_config.common.changelog_update = false.into();
}
update_request = update_request.with_package_config(package, update_config.into());
}
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,
) -> ReleaseRequest {
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());
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());
}
release_request
}
}
#[derive(Serialize, Deserialize, Default, 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>,
#[serde(default)]
pub pr_draft: bool,
#[serde(default)]
pub pr_labels: Vec<String>,
pub publish_timeout: Option<String>,
pub repo_url: Option<Url>,
pub release_commits: Option<String>,
pub release_always: Option<bool>,
}
impl Workspace {
pub fn publish_timeout(&self) -> anyhow::Result<Duration> {
let publish_timeout = self.publish_timeout.as_deref().unwrap_or("30m");
duration_str::parse(publish_timeout)
.map_err(|e| anyhow::anyhow!("invalid publish_timeout {publish_timeout}: {e}"))
}
}
#[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) -> PackageSpecificConfig {
PackageSpecificConfig {
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(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_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 semver_check: Option<bool>,
pub release: Option<bool>,
}
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),
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()),
}
}
}
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),
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),
}
}
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)]
#[serde(rename_all = "snake_case")]
pub enum SemverCheck {
#[default]
Yes,
No,
}
#[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/MarcoIeni/release-plz"
git_release_enable = true
git_release_type = "prod"
git_release_draft = false
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/MarcoIeni/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_draft: false,
pr_labels: vec![],
publish_timeout: Some("10m".to_string()),
release_commits: Some("^feat:".to_string()),
release_always: None,
},
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/MarcoIeni/release-plz".parse().unwrap()),
pr_draft: false,
pr_labels: vec!["label1".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,
},
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"]
publish_timeout = "10m"
repo_url = "https://github.com/MarcoIeni/release-plz"
release_commits = "^feat:"
[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`, `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);
}
}