use semver::Version;
use serde::Serialize;
use crate::changelog::{ChangelogEntry, ChangelogFormatter};
use crate::commit::{CommitParser, ConventionalCommit, DefaultCommitClassifier};
use crate::config::{Config, PackageConfig};
use crate::error::ReleaseError;
use crate::git::GitRepository;
use crate::stages::{StageContext, default_pipeline};
use crate::version::{BumpLevel, apply_bump, apply_prerelease_bump, determine_bump};
#[derive(Debug, Serialize)]
pub struct ReleasePlan {
pub current_version: Option<Version>,
pub next_version: Version,
pub bump: BumpLevel,
pub commits: Vec<ConventionalCommit>,
pub tag_name: String,
pub floating_tag_name: Option<String>,
pub prerelease: bool,
}
pub trait ReleaseStrategy: Send + Sync {
fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
}
pub trait VcsProvider: Send + Sync {
fn create_release(
&self,
tag: &str,
name: &str,
body: &str,
prerelease: bool,
draft: bool,
) -> Result<String, ReleaseError>;
fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
fn repo_url(&self) -> Option<String> {
None
}
fn update_release(
&self,
_tag: &str,
_name: &str,
_body: &str,
_prerelease: bool,
_draft: bool,
) -> Result<String, ReleaseError> {
Err(ReleaseError::Vcs(
"update_release not implemented for this provider".into(),
))
}
fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
Ok(())
}
fn list_assets(&self, _tag: &str) -> Result<Vec<String>, ReleaseError> {
Ok(Vec::new())
}
fn fetch_asset(&self, _tag: &str, _name: &str) -> Result<Option<Vec<u8>>, ReleaseError> {
Ok(None)
}
fn verify_release(&self, _tag: &str) -> Result<(), ReleaseError> {
Ok(())
}
}
pub struct NoopVcsProvider;
impl VcsProvider for NoopVcsProvider {
fn create_release(
&self,
_tag: &str,
_name: &str,
_body: &str,
_prerelease: bool,
_draft: bool,
) -> Result<String, ReleaseError> {
Ok(String::new())
}
fn compare_url(&self, _base: &str, _head: &str) -> Result<String, ReleaseError> {
Ok(String::new())
}
fn release_exists(&self, _tag: &str) -> Result<bool, ReleaseError> {
Ok(false)
}
fn delete_release(&self, _tag: &str) -> Result<(), ReleaseError> {
Ok(())
}
}
pub struct TrunkReleaseStrategy<G, V, C, F> {
pub git: G,
pub vcs: V,
pub parser: C,
pub formatter: F,
pub config: Config,
pub prerelease_id: Option<String>,
pub draft: bool,
}
impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
where
G: GitRepository,
V: VcsProvider,
C: CommitParser,
F: ChangelogFormatter,
{
fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
let today = today_string();
let compare_url = match &plan.current_version {
Some(v) => {
let base = format!("{}{v}", self.config.git.tag_prefix);
self.vcs
.compare_url(&base, &plan.tag_name)
.ok()
.filter(|s| !s.is_empty())
}
None => None,
};
let entry = ChangelogEntry {
version: plan.next_version.to_string(),
date: today,
commits: plan.commits.clone(),
compare_url,
repo_url: self.vcs.repo_url(),
};
self.formatter.format(&[entry])
}
fn release_name(&self, plan: &ReleasePlan) -> String {
if let Some(ref template_str) = self.config.vcs.github.release_name_template {
let mut env = minijinja::Environment::new();
if env.add_template("release_name", template_str).is_ok()
&& let Ok(tmpl) = env.get_template("release_name")
&& let Ok(rendered) = tmpl.render(minijinja::context! {
version => plan.next_version.to_string(),
tag_name => &plan.tag_name,
tag_prefix => &self.config.git.tag_prefix,
})
{
return rendered;
}
eprintln!("warning: invalid release_name_template, falling back to tag name");
}
plan.tag_name.clone()
}
fn active_package(&self) -> Option<&PackageConfig> {
self.config
.packages
.iter()
.find(|p| p.path == ".")
.or_else(|| self.config.packages.first())
}
}
impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
where
G: GitRepository,
V: VcsProvider,
C: CommitParser,
F: ChangelogFormatter,
{
fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
let is_prerelease = self.prerelease_id.is_some();
let all_tags = self.git.all_tags(&self.config.git.tag_prefix)?;
let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
let latest_any = all_tags.last();
if let Some(latest) = all_tags.last() {
match crate::manifest::check_release_status(&self.vcs, &latest.name)? {
crate::manifest::ReleaseStatus::Incomplete {
missing_artifacts, ..
} => {
eprintln!(
"warning: previous release {} is incomplete: {} declared asset(s) missing ({}). \
Continuing — the broken release will remain as a dangling record.",
latest.name,
missing_artifacts.len(),
missing_artifacts.join(", "),
);
}
crate::manifest::ReleaseStatus::Complete(_)
| crate::manifest::ReleaseStatus::Unknown => {}
}
}
let tag_info = if is_prerelease {
latest_any
} else {
latest_stable.or(latest_any)
};
let (current_version, from_sha) = match tag_info {
Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
None => (None, None),
};
let default_pkg = PackageConfig::default();
let pkg = self.active_package().unwrap_or(&default_pkg);
let path_filter = if pkg.path != "." {
Some(pkg.path.as_str())
} else {
None
};
let raw_commits = if let Some(path) = path_filter {
self.git.commits_since_in_path(from_sha, path)?
} else {
self.git.commits_since(from_sha)?
};
if raw_commits.is_empty() {
let (tag, sha) = match tag_info {
Some(info) => (info.name.clone(), info.sha.clone()),
None => ("(none)".into(), "(none)".into()),
};
return Err(ReleaseError::NoCommits { tag, sha });
}
let skip_patterns = &self.config.git.skip_patterns;
let conventional_commits: Vec<ConventionalCommit> = raw_commits
.iter()
.filter(|c| !c.message.starts_with("chore(release):"))
.filter(|c| !skip_patterns.iter().any(|p| c.message.contains(p.as_str())))
.filter_map(|c| self.parser.parse(c).ok())
.collect();
let classifier = DefaultCommitClassifier::new(self.config.commit.types.into_commit_types());
let tag_for_err = tag_info
.map(|i| i.name.clone())
.unwrap_or_else(|| "(none)".into());
let commit_count = conventional_commits.len();
let bump = match determine_bump(&conventional_commits, &classifier) {
Some(b) => b,
None => {
return Err(ReleaseError::NoBump {
tag: tag_for_err,
commit_count,
});
}
};
let base_version = if is_prerelease {
latest_stable
.map(|t| t.version.clone())
.or(current_version.clone())
.unwrap_or(Version::new(0, 0, 0))
} else {
current_version.clone().unwrap_or(Version::new(0, 0, 0))
};
let bump =
if base_version.major == 0 && bump == BumpLevel::Major && self.config.git.v0_protection
{
eprintln!(
"v0 protection: breaking change detected at v{base_version}, \
downshifting major → minor (set git.v0_protection: false to bump to v1)"
);
BumpLevel::Minor
} else {
bump
};
let next_version = if let Some(ref prerelease_id) = self.prerelease_id {
let existing_versions: Vec<Version> =
all_tags.iter().map(|t| t.version.clone()).collect();
apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
} else {
apply_bump(&base_version, bump)
};
let tag_name = format!("{}{next_version}", self.config.git.tag_prefix);
let floating_tag_name = if self.config.git.floating_tag && !is_prerelease {
Some(format!(
"{}{}",
self.config.git.tag_prefix, next_version.major
))
} else {
None
};
Ok(ReleasePlan {
current_version,
next_version,
bump,
commits: conventional_commits,
tag_name,
floating_tag_name,
prerelease: is_prerelease,
})
}
fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
let version_str = plan.next_version.to_string();
let changelog_body = self.format_changelog(plan)?;
let release_name = self.release_name(plan);
let env = release_env(&version_str, &plan.tag_name);
let env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let default_pkg = PackageConfig::default();
let active_package = self.active_package().unwrap_or(&default_pkg);
let mut ctx = StageContext {
plan,
config: &self.config,
git: &self.git,
vcs: &self.vcs,
active_package,
changelog_body: &changelog_body,
release_name: &release_name,
version_str: &version_str,
hooks_env: &env_refs,
dry_run,
sign_tags: self.config.git.sign_tags,
draft: self.draft,
bumped_files: Vec::new(),
};
for stage in default_pipeline() {
if !stage.is_complete(&ctx)? {
stage.run(&mut ctx)?;
}
}
if dry_run {
eprintln!("[dry-run] Changelog:\n{changelog_body}");
} else {
eprintln!("Released {}", plan.tag_name);
}
Ok(())
}
}
fn release_env(version: &str, tag: &str) -> Vec<(String, String)> {
vec![
("SR_VERSION".into(), version.into()),
("SR_TAG".into(), tag.into()),
]
}
pub(crate) fn resolve_globs(patterns: &[String]) -> Result<Vec<String>, String> {
let mut files = std::collections::BTreeSet::new();
for pattern in patterns {
let paths =
glob::glob(pattern).map_err(|e| format!("invalid glob pattern '{pattern}': {e}"))?;
for entry in paths {
match entry {
Ok(path) if path.is_file() => {
files.insert(path.to_string_lossy().into_owned());
}
Ok(_) => {}
Err(e) => {
return Err(format!("glob error for pattern '{pattern}': {e}"));
}
}
}
}
Ok(files.into_iter().collect())
}
pub fn today_string() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let z = secs / 86400 + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}")
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::changelog::DefaultChangelogFormatter;
use crate::commit::{Commit, TypedCommitParser};
use crate::config::{
ChangelogConfig, Config, GitConfig, HooksConfig, PackageConfig, default_changelog_groups,
};
use crate::git::{GitRepository, TagInfo};
struct FakeGit {
tags: Vec<TagInfo>,
commits: Vec<Commit>,
path_commits: Option<Vec<Commit>>,
head: String,
created_tags: Mutex<Vec<String>>,
pushed_tags: Mutex<Vec<String>>,
committed: Mutex<Vec<(Vec<String>, String)>>,
push_count: Mutex<u32>,
force_created_tags: Mutex<Vec<String>>,
force_pushed_tags: Mutex<Vec<String>>,
}
impl FakeGit {
fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
let head = tags
.last()
.map(|t| t.sha.clone())
.unwrap_or_else(|| "0".repeat(40));
Self {
tags,
commits,
path_commits: None,
head,
created_tags: Mutex::new(Vec::new()),
pushed_tags: Mutex::new(Vec::new()),
committed: Mutex::new(Vec::new()),
push_count: Mutex::new(0),
force_created_tags: Mutex::new(Vec::new()),
force_pushed_tags: Mutex::new(Vec::new()),
}
}
}
impl GitRepository for FakeGit {
fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
Ok(self.tags.last().cloned())
}
fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
Ok(self.commits.clone())
}
fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
self.created_tags.lock().unwrap().push(name.to_string());
Ok(())
}
fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
self.pushed_tags.lock().unwrap().push(name.to_string());
Ok(())
}
fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
self.committed.lock().unwrap().push((
paths.iter().map(|s| s.to_string()).collect(),
message.to_string(),
));
Ok(true)
}
fn push(&self) -> Result<(), ReleaseError> {
*self.push_count.lock().unwrap() += 1;
Ok(())
}
fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
Ok(self
.created_tags
.lock()
.unwrap()
.contains(&name.to_string()))
}
fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
}
fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
Ok(self.tags.clone())
}
fn commits_between(
&self,
_from: Option<&str>,
_to: &str,
) -> Result<Vec<Commit>, ReleaseError> {
Ok(self.commits.clone())
}
fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
Ok("2026-01-01".into())
}
fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
self.force_created_tags
.lock()
.unwrap()
.push(name.to_string());
Ok(())
}
fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
self.force_pushed_tags
.lock()
.unwrap()
.push(name.to_string());
Ok(())
}
fn head_sha(&self) -> Result<String, ReleaseError> {
Ok(self.head.clone())
}
fn commits_since_in_path(
&self,
_from: Option<&str>,
_path: &str,
) -> Result<Vec<Commit>, ReleaseError> {
Ok(self
.path_commits
.clone()
.unwrap_or_else(|| self.commits.clone()))
}
}
struct FakeVcs {
releases: Mutex<Vec<(String, String)>>,
deleted_releases: Mutex<Vec<String>>,
uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
stored_assets: Mutex<Vec<(String, String, Vec<u8>)>>,
}
impl FakeVcs {
fn new() -> Self {
Self {
releases: Mutex::new(Vec::new()),
deleted_releases: Mutex::new(Vec::new()),
uploaded_assets: Mutex::new(Vec::new()),
stored_assets: Mutex::new(Vec::new()),
}
}
fn seed_asset(&self, tag: &str, name: &str, content: Vec<u8>) {
self.stored_assets
.lock()
.unwrap()
.push((tag.to_string(), name.to_string(), content));
}
}
impl VcsProvider for FakeVcs {
fn create_release(
&self,
tag: &str,
_name: &str,
body: &str,
_prerelease: bool,
_draft: bool,
) -> Result<String, ReleaseError> {
self.releases
.lock()
.unwrap()
.push((tag.to_string(), body.to_string()));
Ok(format!("https://github.com/test/release/{tag}"))
}
fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
Ok(format!("https://github.com/test/compare/{base}...{head}"))
}
fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
}
fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
self.deleted_releases.lock().unwrap().push(tag.to_string());
self.releases.lock().unwrap().retain(|(t, _)| t != tag);
Ok(())
}
fn update_release(
&self,
tag: &str,
_name: &str,
body: &str,
_prerelease: bool,
_draft: bool,
) -> Result<String, ReleaseError> {
let mut releases = self.releases.lock().unwrap();
if let Some(entry) = releases.iter_mut().find(|(t, _)| t == tag) {
entry.1 = body.to_string();
}
Ok(format!("https://github.com/test/release/{tag}"))
}
fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
self.uploaded_assets.lock().unwrap().push((
tag.to_string(),
files.iter().map(|s| s.to_string()).collect(),
));
for path in files {
let basename = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
.to_string();
let content = std::fs::read(path).unwrap_or_default();
self.stored_assets
.lock()
.unwrap()
.push((tag.to_string(), basename, content));
}
Ok(())
}
fn list_assets(&self, tag: &str) -> Result<Vec<String>, ReleaseError> {
Ok(self
.stored_assets
.lock()
.unwrap()
.iter()
.filter(|(t, _, _)| t == tag)
.map(|(_, n, _)| n.clone())
.collect())
}
fn fetch_asset(&self, tag: &str, name: &str) -> Result<Option<Vec<u8>>, ReleaseError> {
Ok(self
.stored_assets
.lock()
.unwrap()
.iter()
.find(|(t, n, _)| t == tag && n == name)
.map(|(_, _, b)| b.clone()))
}
fn repo_url(&self) -> Option<String> {
Some("https://github.com/test/repo".into())
}
}
type TestStrategy =
TrunkReleaseStrategy<FakeGit, FakeVcs, TypedCommitParser, DefaultChangelogFormatter>;
fn test_config() -> Config {
Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
..Default::default()
}],
..Default::default()
}
}
fn config_with_git(git: GitConfig) -> Config {
Config {
git,
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
..Default::default()
}],
..Default::default()
}
}
fn make_strategy(tags: Vec<TagInfo>, commits: Vec<Commit>, config: Config) -> TestStrategy {
TrunkReleaseStrategy {
git: FakeGit::new(tags, commits),
vcs: FakeVcs::new(),
parser: TypedCommitParser::default(),
formatter: DefaultChangelogFormatter::new(None, default_changelog_groups()),
config,
prerelease_id: None,
draft: false,
}
}
fn raw_commit(msg: &str) -> Commit {
Commit {
sha: "a".repeat(40),
message: msg.into(),
}
}
#[test]
fn plan_no_commits_returns_error() {
let s = make_strategy(vec![], vec![], Config::default());
let err = s.plan().unwrap_err();
assert!(matches!(err, ReleaseError::NoCommits { .. }));
}
#[test]
fn plan_no_releasable_returns_error() {
let s = make_strategy(
vec![],
vec![raw_commit("chore: tidy up")],
Config::default(),
);
let err = s.plan().unwrap_err();
assert!(matches!(err, ReleaseError::NoBump { .. }));
}
#[test]
fn plan_first_release() {
let s = make_strategy(
vec![],
vec![raw_commit("feat: initial feature")],
Config::default(),
);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(0, 1, 0));
assert_eq!(plan.tag_name, "v0.1.0");
assert!(plan.current_version.is_none());
}
#[test]
fn plan_skips_commits_matching_skip_patterns() {
let s = make_strategy(
vec![],
vec![
raw_commit("feat: real feature"),
raw_commit("feat: noisy experiment [skip release]"),
raw_commit("fix: swallowed fix [skip sr]"),
],
test_config(),
);
let plan = s.plan().unwrap();
assert_eq!(plan.commits.len(), 1);
assert_eq!(plan.commits[0].description, "real feature");
}
#[test]
fn plan_custom_skip_patterns_override_defaults() {
let git = GitConfig {
skip_patterns: vec!["DO-NOT-RELEASE".into()],
..Default::default()
};
let s = make_strategy(
vec![],
vec![
raw_commit("feat: shipped"),
raw_commit("feat: DO-NOT-RELEASE internal"),
raw_commit("feat: still here [skip release]"),
],
config_with_git(git),
);
let plan = s.plan().unwrap();
assert_eq!(plan.commits.len(), 2);
}
#[test]
fn plan_increments_existing() {
let tag = TagInfo {
name: "v1.2.3".into(),
version: Version::new(1, 2, 3),
sha: "b".repeat(40),
};
let s = make_strategy(
vec![tag],
vec![raw_commit("fix: patch bug")],
Config::default(),
);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(1, 2, 4));
}
#[test]
fn plan_breaking_bump() {
let tag = TagInfo {
name: "v1.2.3".into(),
version: Version::new(1, 2, 3),
sha: "c".repeat(40),
};
let s = make_strategy(
vec![tag],
vec![raw_commit("feat!: breaking change")],
Config::default(),
);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(2, 0, 0));
}
#[test]
fn plan_v0_breaking_downshifts_to_minor() {
let tag = TagInfo {
name: "v0.5.0".into(),
version: Version::new(0, 5, 0),
sha: "c".repeat(40),
};
let s = make_strategy(
vec![tag],
vec![raw_commit("feat!: breaking change")],
Config::default(),
);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(0, 6, 0));
assert_eq!(plan.bump, BumpLevel::Minor);
}
#[test]
fn plan_v0_breaking_with_protection_disabled_bumps_major() {
let tag = TagInfo {
name: "v0.5.0".into(),
version: Version::new(0, 5, 0),
sha: "c".repeat(40),
};
let mut config = Config::default();
config.git.v0_protection = false;
let s = make_strategy(
vec![tag],
vec![raw_commit("feat!: breaking change")],
config,
);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(1, 0, 0));
assert_eq!(plan.bump, BumpLevel::Major);
}
#[test]
fn plan_v0_feat_stays_minor() {
let tag = TagInfo {
name: "v0.5.0".into(),
version: Version::new(0, 5, 0),
sha: "c".repeat(40),
};
let s = make_strategy(
vec![tag],
vec![raw_commit("feat: new feature")],
Config::default(),
);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(0, 6, 0));
assert_eq!(plan.bump, BumpLevel::Minor);
}
#[test]
fn plan_v0_fix_stays_patch() {
let tag = TagInfo {
name: "v0.5.0".into(),
version: Version::new(0, 5, 0),
sha: "c".repeat(40),
};
let s = make_strategy(
vec![tag],
vec![raw_commit("fix: bug fix")],
Config::default(),
);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(0, 5, 1));
assert_eq!(plan.bump, BumpLevel::Patch);
}
#[test]
fn execute_dry_run_no_side_effects() {
let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
let plan = s.plan().unwrap();
s.execute(&plan, true).unwrap();
assert!(s.git.created_tags.lock().unwrap().is_empty());
assert!(s.git.pushed_tags.lock().unwrap().is_empty());
}
#[test]
fn execute_creates_and_pushes_tag() {
let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
}
#[test]
fn execute_calls_vcs_create_release() {
let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let releases = s.vcs.releases.lock().unwrap();
assert_eq!(releases.len(), 1);
assert_eq!(releases[0].0, "v0.1.0");
assert!(!releases[0].1.is_empty());
}
#[test]
fn execute_commits_changelog_before_tag() {
let dir = tempfile::tempdir().unwrap();
let changelog_path = dir.path().join("CHANGELOG.md");
let config = Config {
changelog: ChangelogConfig {
file: Some(changelog_path.to_str().unwrap().to_string()),
..Default::default()
},
packages: vec![PackageConfig {
path: dir.path().to_str().unwrap().to_string(),
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let committed = s.git.committed.lock().unwrap();
assert_eq!(committed.len(), 1);
assert_eq!(
committed[0].0,
vec![changelog_path.to_str().unwrap().to_string()]
);
assert!(committed[0].1.contains("chore(release): v0.1.0"));
assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
}
#[test]
fn execute_skips_existing_tag() {
let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
let plan = s.plan().unwrap();
s.git
.created_tags
.lock()
.unwrap()
.push("v0.1.0".to_string());
s.execute(&plan, false).unwrap();
assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
}
#[test]
fn execute_skips_existing_release() {
let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
let plan = s.plan().unwrap();
s.vcs
.releases
.lock()
.unwrap()
.push(("v0.1.0".to_string(), "old notes".to_string()));
s.execute(&plan, false).unwrap();
let deleted = s.vcs.deleted_releases.lock().unwrap();
assert!(deleted.is_empty(), "update should not delete");
let releases = s.vcs.releases.lock().unwrap();
assert_eq!(releases.len(), 1);
assert_eq!(releases[0].0, "v0.1.0");
assert_ne!(releases[0].1, "old notes");
}
#[test]
fn execute_idempotent_rerun() {
let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
s.execute(&plan, false).unwrap();
assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
assert_eq!(*s.git.push_count.lock().unwrap(), 2);
let deleted = s.vcs.deleted_releases.lock().unwrap();
assert!(deleted.is_empty(), "update should not delete");
let releases = s.vcs.releases.lock().unwrap();
assert_eq!(releases.len(), 1);
assert_eq!(releases[0].0, "v0.1.0");
}
#[test]
fn execute_bumps_version_files() {
let dir = tempfile::tempdir().unwrap();
let cargo_path = dir.path().join("Cargo.toml");
std::fs::write(
&cargo_path,
"[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
)
.unwrap();
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec![cargo_path.to_str().unwrap().to_string()],
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let contents = std::fs::read_to_string(&cargo_path).unwrap();
assert!(contents.contains("version = \"0.1.0\""));
let committed = s.git.committed.lock().unwrap();
assert_eq!(committed.len(), 1);
assert!(
committed[0]
.0
.contains(&cargo_path.to_str().unwrap().to_string())
);
}
#[test]
fn execute_stages_changelog_and_version_files_together() {
let dir = tempfile::tempdir().unwrap();
let cargo_path = dir.path().join("Cargo.toml");
std::fs::write(
&cargo_path,
"[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
)
.unwrap();
let changelog_path = dir.path().join("CHANGELOG.md");
let config = Config {
changelog: ChangelogConfig {
file: Some(changelog_path.to_str().unwrap().to_string()),
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec![cargo_path.to_str().unwrap().to_string()],
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let committed = s.git.committed.lock().unwrap();
assert_eq!(committed.len(), 1);
assert!(
committed[0]
.0
.contains(&changelog_path.to_str().unwrap().to_string())
);
assert!(
committed[0]
.0
.contains(&cargo_path.to_str().unwrap().to_string())
);
}
#[test]
fn execute_uploads_artifacts() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
artifacts: vec![
dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
dir.path().join("*.zip").to_str().unwrap().to_string(),
],
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let uploaded = s.vcs.uploaded_assets.lock().unwrap();
assert_eq!(uploaded.len(), 2);
let artifact_call = uploaded
.iter()
.find(|(_tag, files)| files.iter().any(|f| f.ends_with("app.tar.gz")))
.expect("expected an upload call containing user artifacts");
assert_eq!(artifact_call.0, "v0.1.0");
assert_eq!(artifact_call.1.len(), 2);
assert!(artifact_call.1.iter().any(|f| f.ends_with("app.tar.gz")));
assert!(artifact_call.1.iter().any(|f| f.ends_with("app.zip")));
}
#[test]
fn execute_dry_run_shows_artifacts() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, true).unwrap();
let uploaded = s.vcs.uploaded_assets.lock().unwrap();
assert!(uploaded.is_empty());
}
#[test]
fn execute_no_artifacts_skips_upload() {
let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let uploaded = s.vcs.uploaded_assets.lock().unwrap();
let user_uploads: Vec<_> = uploaded
.iter()
.filter(|(_tag, files)| {
!files
.iter()
.all(|f| f.ends_with(crate::manifest::MANIFEST_ASSET_NAME))
})
.collect();
assert!(
user_uploads.is_empty(),
"unexpected non-manifest uploads: {user_uploads:?}"
);
}
#[test]
fn resolve_globs_basic() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "a").unwrap();
std::fs::write(dir.path().join("b.txt"), "b").unwrap();
std::fs::create_dir(dir.path().join("subdir")).unwrap();
let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
let result = resolve_globs(&[pattern]).unwrap();
assert_eq!(result.len(), 2);
assert!(result.iter().any(|f: &String| f.ends_with("a.txt")));
assert!(result.iter().any(|f: &String| f.ends_with("b.txt")));
}
#[test]
fn resolve_globs_deduplicates() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("file.txt"), "data").unwrap();
let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
let result = resolve_globs(&[pattern.clone(), pattern]).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn plan_floating_tag_when_enabled() {
let tag = TagInfo {
name: "v3.2.0".into(),
version: Version::new(3, 2, 0),
sha: "d".repeat(40),
};
let config = config_with_git(GitConfig {
floating_tag: true,
..Default::default()
});
let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(3, 2, 1));
assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
}
#[test]
fn plan_no_floating_tag_when_disabled() {
let s = make_strategy(
vec![],
vec![raw_commit("feat: something")],
config_with_git(GitConfig {
floating_tag: false,
..Default::default()
}),
);
let plan = s.plan().unwrap();
assert!(plan.floating_tag_name.is_none());
}
#[test]
fn plan_floating_tag_custom_prefix() {
let tag = TagInfo {
name: "release-2.5.0".into(),
version: Version::new(2, 5, 0),
sha: "e".repeat(40),
};
let config = config_with_git(GitConfig {
floating_tag: true,
tag_prefix: "release-".into(),
..Default::default()
});
let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
let plan = s.plan().unwrap();
assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
}
#[test]
fn execute_floating_tags_force_create_and_push() {
let config = config_with_git(GitConfig {
floating_tag: true,
..Default::default()
});
let tag = TagInfo {
name: "v1.2.3".into(),
version: Version::new(1, 2, 3),
sha: "f".repeat(40),
};
let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
let plan = s.plan().unwrap();
assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
s.execute(&plan, false).unwrap();
assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
}
#[test]
fn execute_no_floating_tags_when_disabled() {
let s = make_strategy(
vec![],
vec![raw_commit("feat: something")],
config_with_git(GitConfig {
floating_tag: false,
..Default::default()
}),
);
let plan = s.plan().unwrap();
assert!(plan.floating_tag_name.is_none());
s.execute(&plan, false).unwrap();
assert!(s.git.force_created_tags.lock().unwrap().is_empty());
assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
}
#[test]
fn execute_floating_tags_dry_run_no_side_effects() {
let config = config_with_git(GitConfig {
floating_tag: true,
..Default::default()
});
let tag = TagInfo {
name: "v2.0.0".into(),
version: Version::new(2, 0, 0),
sha: "a".repeat(40),
};
let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
let plan = s.plan().unwrap();
assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
s.execute(&plan, true).unwrap();
assert!(s.git.force_created_tags.lock().unwrap().is_empty());
assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
}
#[test]
fn execute_floating_tags_idempotent() {
let config = config_with_git(GitConfig {
floating_tag: true,
..Default::default()
});
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
s.execute(&plan, false).unwrap();
s.execute(&plan, false).unwrap();
assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
}
#[test]
fn execute_runs_build_hook_with_version_env() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("saw_version.txt");
let cmd = format!("echo \"$SR_VERSION\" > {}", marker.display());
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
hooks: Some(HooksConfig {
build: vec![cmd],
..Default::default()
}),
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let content = std::fs::read_to_string(&marker).unwrap();
assert_eq!(content.trim(), "0.1.0");
}
#[test]
fn execute_build_sees_bumped_version_on_disk() {
let dir = tempfile::tempdir().unwrap();
let cargo_path = dir.path().join("Cargo.toml");
std::fs::write(
&cargo_path,
"[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
)
.unwrap();
let marker = dir.path().join("observed_version.txt");
let cmd = format!(
"grep '^version' {} > {}",
cargo_path.display(),
marker.display()
);
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec![cargo_path.to_str().unwrap().to_string()],
hooks: Some(HooksConfig {
build: vec![cmd],
..Default::default()
}),
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let content = std::fs::read_to_string(&marker).unwrap();
assert!(
content.contains("0.1.0"),
"build should see bumped version on disk, got: {content}"
);
}
#[test]
fn execute_build_failure_leaves_no_tag_or_commit() {
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
hooks: Some(HooksConfig {
build: vec!["false".into()],
..Default::default()
}),
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
let err = s.execute(&plan, false).unwrap_err();
assert!(matches!(err, ReleaseError::Hook(_)), "got {err:?}");
assert!(s.git.created_tags.lock().unwrap().is_empty());
assert!(s.git.pushed_tags.lock().unwrap().is_empty());
assert!(s.git.committed.lock().unwrap().is_empty());
assert!(s.vcs.releases.lock().unwrap().is_empty());
}
#[test]
fn execute_validation_fails_when_declared_artifact_missing() {
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
artifacts: vec!["/definitely/not/here/*.tar.gz".into()],
hooks: Some(HooksConfig {
build: vec!["true".into()],
..Default::default()
}),
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
let err = s.execute(&plan, false).unwrap_err();
match err {
ReleaseError::Vcs(ref msg) => {
assert!(
msg.contains("matched no files"),
"expected validation error, got: {msg}"
);
}
other => panic!("expected Vcs error, got {other:?}"),
}
assert!(s.git.created_tags.lock().unwrap().is_empty());
assert!(s.git.pushed_tags.lock().unwrap().is_empty());
assert!(s.vcs.releases.lock().unwrap().is_empty());
}
#[test]
fn execute_validation_passes_when_all_artifacts_present() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
hooks: Some(HooksConfig {
build: vec!["true".into()],
..Default::default()
}),
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
}
#[test]
fn execute_validation_skipped_without_build_hooks() {
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
artifacts: vec!["/still/not/here/*.tar.gz".into()],
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
}
#[test]
fn execute_uploads_manifest_as_final_asset() {
let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let assets = s.vcs.list_assets("v0.1.0").unwrap();
assert!(
assets.contains(&crate::manifest::MANIFEST_ASSET_NAME.to_string()),
"manifest should be uploaded; got {assets:?}"
);
}
#[test]
fn execute_manifest_contains_tag_and_artifacts() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let manifest_bytes = s
.vcs
.fetch_asset("v0.1.0", crate::manifest::MANIFEST_ASSET_NAME)
.unwrap()
.expect("manifest should be present");
let manifest: crate::manifest::Manifest = serde_json::from_slice(&manifest_bytes).unwrap();
assert_eq!(manifest.tag, "v0.1.0");
assert!(manifest.artifacts.iter().any(|a| a == "app.tar.gz"));
assert!(!manifest.commit_sha.is_empty());
assert!(!manifest.sr_version.is_empty());
}
#[test]
fn execute_skips_already_uploaded_artifacts() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
let config = Config {
changelog: ChangelogConfig {
file: None,
..Default::default()
},
packages: vec![PackageConfig {
path: ".".into(),
version_files: vec!["__sr_test_dummy_no_bump__".into()],
artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
..Default::default()
}],
..Default::default()
};
let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
let plan = s.plan().unwrap();
s.execute(&plan, false).unwrap();
let uploads_after_first = s.vcs.uploaded_assets.lock().unwrap().len();
s.execute(&plan, false).unwrap();
let uploads_after_second = s.vcs.uploaded_assets.lock().unwrap().len();
assert_eq!(
uploads_after_first, uploads_after_second,
"idempotent re-run should not re-upload existing assets"
);
}
#[test]
fn plan_warns_but_proceeds_when_previous_release_incomplete() {
let prev_tag = TagInfo {
name: "v1.0.0".into(),
version: Version::new(1, 0, 0),
sha: "a".repeat(40),
};
let s = make_strategy(
vec![prev_tag],
vec![raw_commit("feat: new thing")],
test_config(),
);
let incomplete = crate::manifest::Manifest {
sr_version: "7.1.0".into(),
tag: "v1.0.0".into(),
commit_sha: "a".repeat(40),
artifacts: vec!["missing-binary.tar.gz".into()],
completed_at: "2026-04-18T00:00:00Z".into(),
};
s.vcs.seed_asset(
"v1.0.0",
crate::manifest::MANIFEST_ASSET_NAME,
serde_json::to_vec(&incomplete).unwrap(),
);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(1, 1, 0));
}
#[test]
fn plan_passes_when_previous_release_complete() {
let prev_tag = TagInfo {
name: "v1.0.0".into(),
version: Version::new(1, 0, 0),
sha: "a".repeat(40),
};
let s = make_strategy(
vec![prev_tag],
vec![raw_commit("feat: next thing")],
test_config(),
);
let complete = crate::manifest::Manifest {
sr_version: "7.1.0".into(),
tag: "v1.0.0".into(),
commit_sha: "a".repeat(40),
artifacts: vec!["ok.tar.gz".into()],
completed_at: "2026-04-18T00:00:00Z".into(),
};
s.vcs.seed_asset(
"v1.0.0",
crate::manifest::MANIFEST_ASSET_NAME,
serde_json::to_vec(&complete).unwrap(),
);
s.vcs.seed_asset("v1.0.0", "ok.tar.gz", b"bin".to_vec());
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(1, 1, 0));
}
#[test]
fn plan_passes_when_previous_release_has_no_manifest() {
let prev_tag = TagInfo {
name: "v1.0.0".into(),
version: Version::new(1, 0, 0),
sha: "a".repeat(40),
};
let s = make_strategy(
vec![prev_tag],
vec![raw_commit("feat: legacy compat")],
test_config(),
);
let plan = s.plan().unwrap();
assert_eq!(plan.next_version, Version::new(1, 1, 0));
}
#[test]
fn plan_propagates_transport_error_on_manifest_fetch() {
struct ErroringVcs;
impl VcsProvider for ErroringVcs {
fn create_release(
&self,
_: &str,
_: &str,
_: &str,
_: bool,
_: bool,
) -> Result<String, ReleaseError> {
Ok(String::new())
}
fn compare_url(&self, _: &str, _: &str) -> Result<String, ReleaseError> {
Ok(String::new())
}
fn release_exists(&self, _: &str) -> Result<bool, ReleaseError> {
Ok(false)
}
fn delete_release(&self, _: &str) -> Result<(), ReleaseError> {
Ok(())
}
fn fetch_asset(&self, _: &str, _: &str) -> Result<Option<Vec<u8>>, ReleaseError> {
Err(ReleaseError::Vcs("network down".into()))
}
}
let prev_tag = TagInfo {
name: "v1.0.0".into(),
version: Version::new(1, 0, 0),
sha: "a".repeat(40),
};
let s = TrunkReleaseStrategy {
git: FakeGit::new(vec![prev_tag], vec![raw_commit("feat: x")]),
vcs: ErroringVcs,
parser: TypedCommitParser::default(),
formatter: DefaultChangelogFormatter::new(None, default_changelog_groups()),
config: test_config(),
prerelease_id: None,
draft: false,
};
let err = s.plan().unwrap_err();
match err {
ReleaseError::Vcs(ref msg) => assert!(msg.contains("network down"), "{msg}"),
other => panic!("expected Vcs error, got {other:?}"),
}
}
#[test]
fn no_new_commits_at_tag_head_errors() {
let tag = TagInfo {
name: "v1.2.3".into(),
version: Version::new(1, 2, 3),
sha: "a".repeat(40),
};
let mut s = make_strategy(vec![tag], vec![], Config::default());
s.git.head = "a".repeat(40);
let err = s.plan().unwrap_err();
assert!(matches!(err, ReleaseError::NoCommits { .. }));
}
}