use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::commit::CommitType;
use crate::error::ReleaseError;
use crate::version::BumpLevel;
use crate::version_files::detect_version_files;
pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub git: GitConfig,
pub commit: CommitConfig,
pub changelog: ChangelogConfig,
pub channels: ChannelsConfig,
pub vcs: VcsConfig,
#[serde(default = "default_packages")]
pub packages: Vec<PackageConfig>,
}
impl Default for Config {
fn default() -> Self {
Self {
git: GitConfig::default(),
commit: CommitConfig::default(),
changelog: ChangelogConfig::default(),
channels: ChannelsConfig::default(),
vcs: VcsConfig::default(),
packages: default_packages(),
}
}
}
fn default_packages() -> Vec<PackageConfig> {
vec![PackageConfig {
path: ".".into(),
..Default::default()
}]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GitConfig {
pub tag_prefix: String,
pub floating_tag: bool,
pub sign_tags: bool,
pub v0_protection: bool,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
tag_prefix: "v".into(),
floating_tag: true,
sign_tags: false,
v0_protection: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CommitConfig {
pub types: CommitTypesConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CommitTypesConfig {
pub minor: Vec<String>,
pub patch: Vec<String>,
pub none: Vec<String>,
}
impl Default for CommitTypesConfig {
fn default() -> Self {
Self {
minor: vec!["feat".into()],
patch: vec!["fix".into(), "perf".into(), "refactor".into()],
none: vec![
"docs".into(),
"revert".into(),
"chore".into(),
"ci".into(),
"test".into(),
"build".into(),
"style".into(),
],
}
}
}
impl CommitTypesConfig {
pub fn all_type_names(&self) -> Vec<&str> {
self.minor
.iter()
.chain(self.patch.iter())
.chain(self.none.iter())
.map(|s| s.as_str())
.collect()
}
pub fn into_commit_types(&self) -> Vec<CommitType> {
let mut types = Vec::new();
for name in &self.minor {
types.push(CommitType {
name: name.clone(),
bump: Some(BumpLevel::Minor),
});
}
for name in &self.patch {
types.push(CommitType {
name: name.clone(),
bump: Some(BumpLevel::Patch),
});
}
for name in &self.none {
types.push(CommitType {
name: name.clone(),
bump: None,
});
}
types
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ChangelogConfig {
pub file: Option<String>,
pub template: Option<String>,
pub groups: Vec<ChangelogGroup>,
}
impl Default for ChangelogConfig {
fn default() -> Self {
Self {
file: Some("CHANGELOG.md".into()),
template: None,
groups: default_changelog_groups(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogGroup {
pub name: String,
pub content: Vec<String>,
}
pub fn default_changelog_groups() -> Vec<ChangelogGroup> {
vec![
ChangelogGroup {
name: "breaking".into(),
content: vec!["breaking".into()],
},
ChangelogGroup {
name: "features".into(),
content: vec!["feat".into()],
},
ChangelogGroup {
name: "bug-fixes".into(),
content: vec!["fix".into()],
},
ChangelogGroup {
name: "performance".into(),
content: vec!["perf".into()],
},
ChangelogGroup {
name: "refactoring".into(),
content: vec!["refactor".into()],
},
ChangelogGroup {
name: "misc".into(),
content: vec![
"docs".into(),
"revert".into(),
"chore".into(),
"ci".into(),
"test".into(),
"build".into(),
"style".into(),
],
},
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ChannelsConfig {
pub default: String,
pub branch: String,
pub content: Vec<ChannelConfig>,
}
impl Default for ChannelsConfig {
fn default() -> Self {
Self {
default: "stable".into(),
branch: "main".into(),
content: vec![ChannelConfig {
name: "stable".into(),
prerelease: None,
draft: false,
}],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelConfig {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prerelease: Option<String>,
#[serde(default)]
pub draft: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct VcsConfig {
pub github: GitHubConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct GitHubConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_name_template: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PackageConfig {
pub path: String,
pub independent: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag_prefix: Option<String>,
pub version_files: Vec<String>,
pub version_files_strict: bool,
pub stage_files: Vec<String>,
pub artifacts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changelog: Option<ChangelogConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hooks: Option<HooksConfig>,
}
impl Default for PackageConfig {
fn default() -> Self {
Self {
path: ".".into(),
independent: false,
tag_prefix: None,
version_files: vec![],
version_files_strict: false,
stage_files: vec![],
artifacts: vec![],
changelog: None,
hooks: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct HooksConfig {
pub pre_release: Vec<String>,
pub post_release: Vec<String>,
}
impl Config {
pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
for &candidate in CONFIG_CANDIDATES {
let path = dir.join(candidate);
if path.exists() {
let is_legacy = candidate == LEGACY_CONFIG_FILE;
return Some((path, is_legacy));
}
}
None
}
pub fn load(path: &Path) -> Result<Self, ReleaseError> {
if !path.exists() {
return Ok(Self::default());
}
let contents =
std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
let config: Self =
serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), ReleaseError> {
let mut seen = std::collections::HashSet::new();
for name in self.commit.types.all_type_names() {
if !seen.insert(name) {
return Err(ReleaseError::Config(format!(
"duplicate commit type: {name}"
)));
}
}
if self.commit.types.minor.is_empty() && self.commit.types.patch.is_empty() {
return Err(ReleaseError::Config(
"commit.types must have at least one minor or patch type".into(),
));
}
let mut channel_names = std::collections::HashSet::new();
for ch in &self.channels.content {
if !channel_names.insert(&ch.name) {
return Err(ReleaseError::Config(format!(
"duplicate channel name: {}",
ch.name
)));
}
}
Ok(())
}
pub fn resolve_channel(&self, name: &str) -> Result<&ChannelConfig, ReleaseError> {
self.channels
.content
.iter()
.find(|ch| ch.name == name)
.ok_or_else(|| {
let available: Vec<&str> = self
.channels
.content
.iter()
.map(|c| c.name.as_str())
.collect();
ReleaseError::Config(format!(
"channel '{name}' not found. Available: {}",
if available.is_empty() {
"(none)".to_string()
} else {
available.join(", ")
}
))
})
}
pub fn default_channel(&self) -> Result<&ChannelConfig, ReleaseError> {
self.resolve_channel(&self.channels.default)
}
pub fn find_package(&self, path: &str) -> Result<&PackageConfig, ReleaseError> {
self.packages
.iter()
.find(|p| p.path == path)
.ok_or_else(|| {
let available: Vec<&str> = self.packages.iter().map(|p| p.path.as_str()).collect();
ReleaseError::Config(format!(
"package '{path}' not found. Available: {}",
if available.is_empty() {
"(none)".to_string()
} else {
available.join(", ")
}
))
})
}
pub fn find_package_by_name(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
self.packages
.iter()
.find(|p| p.path.rsplit('/').next().unwrap_or(&p.path) == name)
.ok_or_else(|| {
let available: Vec<&str> = self
.packages
.iter()
.map(|p| p.path.rsplit('/').next().unwrap_or(&p.path))
.collect();
ReleaseError::Config(format!(
"package '{name}' not found. Available: {}",
if available.is_empty() {
"(none)".to_string()
} else {
available.join(", ")
}
))
})
}
pub fn tag_prefix_for(&self, pkg: &PackageConfig) -> String {
if let Some(ref prefix) = pkg.tag_prefix {
return prefix.clone();
}
if pkg.path == "." {
self.git.tag_prefix.clone()
} else {
let dir_name = pkg.path.rsplit('/').next().unwrap_or(&pkg.path);
format!("{}/v", dir_name)
}
}
pub fn changelog_for<'a>(&'a self, pkg: &'a PackageConfig) -> &'a ChangelogConfig {
pkg.changelog.as_ref().unwrap_or(&self.changelog)
}
pub fn version_files_for(&self, pkg: &PackageConfig) -> Vec<String> {
if !pkg.version_files.is_empty() {
return pkg.version_files.clone();
}
let detected = detect_version_files(Path::new(&pkg.path));
if pkg.path == "." {
detected
} else {
detected
.into_iter()
.map(|f| format!("{}/{f}", pkg.path))
.collect()
}
}
pub fn fixed_packages(&self) -> Vec<&PackageConfig> {
self.packages.iter().filter(|p| !p.independent).collect()
}
pub fn independent_packages(&self) -> Vec<&PackageConfig> {
self.packages.iter().filter(|p| p.independent).collect()
}
pub fn all_artifacts(&self) -> Vec<String> {
self.packages
.iter()
.flat_map(|p| p.artifacts.clone())
.collect()
}
}
pub fn default_config_template(version_files: &[String]) -> String {
let vf = if version_files.is_empty() {
" version_files: []\n".to_string()
} else {
let mut s = " version_files:\n".to_string();
for f in version_files {
s.push_str(&format!(" - {f}\n"));
}
s
};
format!(
r#"# sr configuration
# Full reference: https://github.com/urmzd/sr#configuration
git:
tag_prefix: "v"
floating_tag: true
sign_tags: false
v0_protection: true
commit:
types:
minor:
- feat
patch:
- fix
- perf
- refactor
none:
- docs
- revert
- chore
- ci
- test
- build
- style
changelog:
file: CHANGELOG.md
# template: changelog.md.j2
groups:
- name: breaking
content:
- breaking
- name: features
content:
- feat
- name: bug-fixes
content:
- fix
- name: performance
content:
- perf
- name: misc
content:
- chore
- ci
- test
- build
- style
channels:
default: stable
branch: main
content:
- name: stable
# - name: rc
# prerelease: rc
# draft: true
# - name: canary
# branch: develop
# prerelease: canary
# vcs:
# github:
# release_name_template: "{{{{ tag_name }}}}"
packages:
- path: .
{vf} # version_files_strict: false
# stage_files: []
# artifacts: []
# hooks:
# pre_release:
# - cargo build --release
# post_release:
# - cargo publish
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn default_values() {
let config = Config::default();
assert_eq!(config.git.tag_prefix, "v");
assert!(config.git.floating_tag);
assert!(!config.git.sign_tags);
assert_eq!(config.commit.types.minor, vec!["feat"]);
assert!(config.commit.types.patch.contains(&"fix".to_string()));
assert!(config.commit.types.none.contains(&"chore".to_string()));
assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
assert!(!config.changelog.groups.is_empty());
assert_eq!(config.channels.default, "stable");
assert_eq!(config.channels.content.len(), 1);
assert_eq!(config.channels.content[0].name, "stable");
assert_eq!(config.channels.branch, "main");
assert_eq!(config.packages.len(), 1);
assert_eq!(config.packages[0].path, ".");
}
#[test]
fn load_missing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.yml");
let config = Config::load(&path).unwrap();
assert_eq!(config.git.tag_prefix, "v");
}
#[test]
fn load_partial_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yml");
std::fs::write(&path, "git:\n tag_prefix: rel-\n").unwrap();
let config = Config::load(&path).unwrap();
assert_eq!(config.git.tag_prefix, "rel-");
assert_eq!(config.channels.default, "stable");
}
#[test]
fn load_yaml_with_packages() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yml");
std::fs::write(
&path,
"packages:\n - path: crates/core\n version_files:\n - crates/core/Cargo.toml\n",
)
.unwrap();
let config = Config::load(&path).unwrap();
assert_eq!(config.packages.len(), 1);
assert_eq!(config.packages[0].path, "crates/core");
}
#[test]
fn commit_types_conversion() {
let types = CommitTypesConfig::default();
let commit_types = types.into_commit_types();
let feat = commit_types.iter().find(|t| t.name == "feat").unwrap();
assert_eq!(feat.bump, Some(BumpLevel::Minor));
let fix = commit_types.iter().find(|t| t.name == "fix").unwrap();
assert_eq!(fix.bump, Some(BumpLevel::Patch));
let chore = commit_types.iter().find(|t| t.name == "chore").unwrap();
assert_eq!(chore.bump, None);
}
#[test]
fn all_type_names() {
let types = CommitTypesConfig::default();
let names = types.all_type_names();
assert!(names.contains(&"feat"));
assert!(names.contains(&"fix"));
assert!(names.contains(&"chore"));
}
#[test]
fn resolve_channel() {
let config = Config::default();
let channel = config.resolve_channel("stable").unwrap();
assert!(channel.prerelease.is_none());
}
#[test]
fn resolve_channel_not_found() {
let config = Config::default();
assert!(config.resolve_channel("missing").is_err());
}
#[test]
fn tag_prefix_root_package() {
let config = Config::default();
let pkg = &config.packages[0];
assert_eq!(config.tag_prefix_for(pkg), "v");
}
#[test]
fn tag_prefix_subpackage() {
let config = Config::default();
let pkg = PackageConfig {
path: "crates/core".into(),
..Default::default()
};
assert_eq!(config.tag_prefix_for(&pkg), "core/v");
}
#[test]
fn tag_prefix_override() {
let config = Config::default();
let pkg = PackageConfig {
path: "crates/cli".into(),
tag_prefix: Some("cli-v".into()),
..Default::default()
};
assert_eq!(config.tag_prefix_for(&pkg), "cli-v");
}
#[test]
fn validate_duplicate_types() {
let config = Config {
commit: CommitConfig {
types: CommitTypesConfig {
minor: vec!["feat".into()],
patch: vec!["feat".into()],
none: vec![],
},
},
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn validate_no_bump_types() {
let config = Config {
commit: CommitConfig {
types: CommitTypesConfig {
minor: vec![],
patch: vec![],
none: vec!["chore".into()],
},
},
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn validate_duplicate_channels() {
let config = Config {
channels: ChannelsConfig {
default: "stable".into(),
branch: "main".into(),
content: vec![
ChannelConfig {
name: "stable".into(),
prerelease: None,
draft: false,
},
ChannelConfig {
name: "stable".into(),
prerelease: None,
draft: false,
},
],
},
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn default_template_parses() {
let template = default_config_template(&[]);
let config: Config = serde_yaml_ng::from_str(&template).unwrap();
assert_eq!(config.git.tag_prefix, "v");
assert!(config.git.floating_tag);
assert_eq!(config.channels.default, "stable");
}
#[test]
fn default_template_with_version_files() {
let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
let config: Config = serde_yaml_ng::from_str(&template).unwrap();
assert_eq!(
config.packages[0].version_files,
vec!["Cargo.toml", "package.json"]
);
}
#[test]
fn find_package_by_name_works() {
let config = Config {
packages: vec![
PackageConfig {
path: "crates/core".into(),
..Default::default()
},
PackageConfig {
path: "crates/cli".into(),
..Default::default()
},
],
..Default::default()
};
let pkg = config.find_package_by_name("core").unwrap();
assert_eq!(pkg.path, "crates/core");
}
#[test]
fn collect_all_artifacts() {
let config = Config {
packages: vec![
PackageConfig {
path: "crates/core".into(),
artifacts: vec!["core-*".into()],
..Default::default()
},
PackageConfig {
path: "crates/cli".into(),
artifacts: vec!["cli-*".into()],
..Default::default()
},
],
..Default::default()
};
let artifacts = config.all_artifacts();
assert_eq!(artifacts, vec!["core-*", "cli-*"]);
}
}