1use std::fs;
2use std::path::Path;
3
4use semver::Version;
5use serde::Serialize;
6
7use crate::changelog::{ChangelogEntry, ChangelogFormatter};
8use crate::commit::{CommitParser, ConventionalCommit, DefaultCommitClassifier};
9use crate::config::{Config, PackageConfig};
10use crate::error::ReleaseError;
11use crate::git::GitRepository;
12use crate::version::{BumpLevel, apply_bump, apply_prerelease_bump, determine_bump};
13use crate::version_files::{bump_version_file, discover_lock_files, is_supported_version_file};
14
15#[derive(Debug, Serialize)]
17pub struct ReleasePlan {
18 pub current_version: Option<Version>,
19 pub next_version: Version,
20 pub bump: BumpLevel,
21 pub commits: Vec<ConventionalCommit>,
22 pub tag_name: String,
23 pub floating_tag_name: Option<String>,
24 pub prerelease: bool,
25}
26
27pub trait ReleaseStrategy: Send + Sync {
29 fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
31
32 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
34}
35
36pub trait VcsProvider: Send + Sync {
38 fn create_release(
40 &self,
41 tag: &str,
42 name: &str,
43 body: &str,
44 prerelease: bool,
45 draft: bool,
46 ) -> Result<String, ReleaseError>;
47
48 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
50
51 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
53
54 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
56
57 fn repo_url(&self) -> Option<String> {
59 None
60 }
61
62 fn update_release(
65 &self,
66 _tag: &str,
67 _name: &str,
68 _body: &str,
69 _prerelease: bool,
70 _draft: bool,
71 ) -> Result<String, ReleaseError> {
72 Err(ReleaseError::Vcs(
73 "update_release not implemented for this provider".into(),
74 ))
75 }
76
77 fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
79 Ok(())
80 }
81
82 fn verify_release(&self, _tag: &str) -> Result<(), ReleaseError> {
84 Ok(())
85 }
86}
87
88pub struct NoopVcsProvider;
91
92impl VcsProvider for NoopVcsProvider {
93 fn create_release(
94 &self,
95 _tag: &str,
96 _name: &str,
97 _body: &str,
98 _prerelease: bool,
99 _draft: bool,
100 ) -> Result<String, ReleaseError> {
101 Ok(String::new())
102 }
103
104 fn compare_url(&self, _base: &str, _head: &str) -> Result<String, ReleaseError> {
105 Ok(String::new())
106 }
107
108 fn release_exists(&self, _tag: &str) -> Result<bool, ReleaseError> {
109 Ok(false)
110 }
111
112 fn delete_release(&self, _tag: &str) -> Result<(), ReleaseError> {
113 Ok(())
114 }
115}
116
117pub struct TrunkReleaseStrategy<G, V, C, F> {
119 pub git: G,
120 pub vcs: V,
121 pub parser: C,
122 pub formatter: F,
123 pub config: Config,
124 pub force: bool,
126 pub prerelease_id: Option<String>,
128 pub draft: bool,
130}
131
132impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
133where
134 G: GitRepository,
135 V: VcsProvider,
136 C: CommitParser,
137 F: ChangelogFormatter,
138{
139 fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
140 let today = today_string();
141 let compare_url = match &plan.current_version {
142 Some(v) => {
143 let base = format!("{}{v}", self.config.git.tag_prefix);
144 self.vcs
145 .compare_url(&base, &plan.tag_name)
146 .ok()
147 .filter(|s| !s.is_empty())
148 }
149 None => None,
150 };
151 let entry = ChangelogEntry {
152 version: plan.next_version.to_string(),
153 date: today,
154 commits: plan.commits.clone(),
155 compare_url,
156 repo_url: self.vcs.repo_url(),
157 };
158 self.formatter.format(&[entry])
159 }
160
161 fn release_name(&self, plan: &ReleasePlan) -> String {
163 if let Some(ref template_str) = self.config.vcs.github.release_name_template {
164 let mut env = minijinja::Environment::new();
165 if env.add_template("release_name", template_str).is_ok()
166 && let Ok(tmpl) = env.get_template("release_name")
167 && let Ok(rendered) = tmpl.render(minijinja::context! {
168 version => plan.next_version.to_string(),
169 tag_name => &plan.tag_name,
170 tag_prefix => &self.config.git.tag_prefix,
171 })
172 {
173 return rendered;
174 }
175 eprintln!("warning: invalid release_name_template, falling back to tag name");
176 }
177 plan.tag_name.clone()
178 }
179
180 fn active_package(&self) -> Option<&PackageConfig> {
183 self.config
184 .packages
185 .iter()
186 .find(|p| p.path == ".")
187 .or_else(|| self.config.packages.first())
188 }
189}
190
191impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
192where
193 G: GitRepository,
194 V: VcsProvider,
195 C: CommitParser,
196 F: ChangelogFormatter,
197{
198 fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
199 let is_prerelease = self.prerelease_id.is_some();
200
201 let all_tags = self.git.all_tags(&self.config.git.tag_prefix)?;
204 let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
205 let latest_any = all_tags.last();
206
207 let tag_info = if is_prerelease {
209 latest_any
210 } else {
211 latest_stable.or(latest_any)
212 };
213
214 let (current_version, from_sha) = match tag_info {
215 Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
216 None => (None, None),
217 };
218
219 let default_pkg = PackageConfig::default();
220 let pkg = self.active_package().unwrap_or(&default_pkg);
221 let path_filter = if pkg.path != "." {
222 Some(pkg.path.as_str())
223 } else {
224 None
225 };
226
227 let raw_commits = if let Some(path) = path_filter {
228 self.git.commits_since_in_path(from_sha, path)?
229 } else {
230 self.git.commits_since(from_sha)?
231 };
232
233 if raw_commits.is_empty() {
234 if self.force
236 && let Some(info) = tag_info
237 {
238 let head = self.git.head_sha()?;
239 if head == info.sha {
240 let floating_tag_name = if self.config.git.floating_tag {
241 Some(format!(
242 "{}{}",
243 self.config.git.tag_prefix, info.version.major
244 ))
245 } else {
246 None
247 };
248 return Ok(ReleasePlan {
249 current_version: Some(info.version.clone()),
250 next_version: info.version.clone(),
251 bump: BumpLevel::Patch,
252 commits: vec![],
253 tag_name: info.name.clone(),
254 floating_tag_name,
255 prerelease: is_prerelease,
256 });
257 }
258 }
259 let (tag, sha) = match tag_info {
260 Some(info) => (info.name.clone(), info.sha.clone()),
261 None => ("(none)".into(), "(none)".into()),
262 };
263 return Err(ReleaseError::NoCommits { tag, sha });
264 }
265
266 let conventional_commits: Vec<ConventionalCommit> = raw_commits
267 .iter()
268 .filter(|c| !c.message.starts_with("chore(release):"))
269 .filter_map(|c| self.parser.parse(c).ok())
270 .collect();
271
272 let classifier = DefaultCommitClassifier::new(self.config.commit.types.into_commit_types());
273 let tag_for_err = tag_info
274 .map(|i| i.name.clone())
275 .unwrap_or_else(|| "(none)".into());
276 let commit_count = conventional_commits.len();
277 let bump = match determine_bump(&conventional_commits, &classifier) {
278 Some(b) => b,
279 None if self.force => BumpLevel::Patch,
280 None => {
281 return Err(ReleaseError::NoBump {
282 tag: tag_for_err,
283 commit_count,
284 });
285 }
286 };
287
288 let base_version = if is_prerelease {
290 latest_stable
291 .map(|t| t.version.clone())
292 .or(current_version.clone())
293 .unwrap_or(Version::new(0, 0, 0))
294 } else {
295 current_version.clone().unwrap_or(Version::new(0, 0, 0))
296 };
297
298 let bump =
301 if base_version.major == 0 && bump == BumpLevel::Major && self.config.git.v0_protection
302 {
303 eprintln!(
304 "v0 protection: breaking change detected at v{base_version}, \
305 downshifting major → minor (set git.v0_protection: false to bump to v1)"
306 );
307 BumpLevel::Minor
308 } else {
309 bump
310 };
311
312 let next_version = if let Some(ref prerelease_id) = self.prerelease_id {
313 let existing_versions: Vec<Version> =
314 all_tags.iter().map(|t| t.version.clone()).collect();
315 apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
316 } else {
317 apply_bump(&base_version, bump)
318 };
319
320 let tag_name = format!("{}{next_version}", self.config.git.tag_prefix);
321
322 let floating_tag_name = if self.config.git.floating_tag && !is_prerelease {
324 Some(format!(
325 "{}{}",
326 self.config.git.tag_prefix, next_version.major
327 ))
328 } else {
329 None
330 };
331
332 Ok(ReleasePlan {
333 current_version,
334 next_version,
335 bump,
336 commits: conventional_commits,
337 tag_name,
338 floating_tag_name,
339 prerelease: is_prerelease,
340 })
341 }
342
343 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
344 let version_str = plan.next_version.to_string();
345
346 let env = release_env(&version_str, &plan.tag_name);
348 let env_refs: Vec<(&str, &str)> =
349 env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
350 if !dry_run
351 && let Some(pkg) = self.active_package()
352 && let Some(ref hooks) = pkg.hooks
353 {
354 crate::hooks::run_pre_release(hooks, &env_refs)?;
355 }
356
357 let changelog_body = self.format_changelog(plan)?;
359
360 self.bump_and_build(plan, &version_str, &changelog_body, dry_run)?;
362
363 self.create_and_push_tags(plan, &changelog_body, dry_run)?;
365
366 self.create_or_update_release(plan, &changelog_body, dry_run)?;
368 self.upload_artifacts(plan, dry_run)?;
369 self.verify_release_exists(plan, dry_run)?;
370
371 if !dry_run
373 && let Some(pkg) = self.active_package()
374 && let Some(ref hooks) = pkg.hooks
375 {
376 crate::hooks::run_post_release(hooks, &env_refs)?;
377 }
378
379 if dry_run {
380 eprintln!("[dry-run] Changelog:\n{changelog_body}");
381 } else {
382 eprintln!("Released {}", plan.tag_name);
383 }
384 Ok(())
385 }
386}
387
388fn release_env(version: &str, tag: &str) -> Vec<(String, String)> {
390 vec![
391 ("SR_VERSION".into(), version.into()),
392 ("SR_TAG".into(), tag.into()),
393 ]
394}
395
396impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
397where
398 G: GitRepository,
399 V: VcsProvider,
400 C: CommitParser,
401 F: ChangelogFormatter,
402{
403 fn bump_and_build(
404 &self,
405 plan: &ReleasePlan,
406 version_str: &str,
407 changelog_body: &str,
408 dry_run: bool,
409 ) -> Result<(), ReleaseError> {
410 let default_pkg = PackageConfig::default();
411 let pkg = self.active_package().unwrap_or(&default_pkg);
412 let version_files = self.config.version_files_for(pkg);
413 let version_files_strict = pkg.version_files_strict;
414 let stage_files = &pkg.stage_files;
415 let changelog_file = self.config.changelog_for(pkg).file.clone();
416
417 if dry_run {
418 for file in &version_files {
419 let filename = Path::new(file)
420 .file_name()
421 .and_then(|n| n.to_str())
422 .unwrap_or_default();
423 if is_supported_version_file(filename) {
424 eprintln!("[dry-run] Would bump version in: {file}");
425 } else if version_files_strict {
426 return Err(ReleaseError::VersionBump(format!(
427 "unsupported version file: {filename}"
428 )));
429 } else {
430 eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
431 }
432 }
433 if !stage_files.is_empty() {
434 eprintln!(
435 "[dry-run] Would stage additional files: {}",
436 stage_files.join(", ")
437 );
438 }
439 return Ok(());
440 }
441
442 let files_to_stage = self.execute_mutations(
443 version_str,
444 changelog_body,
445 &version_files,
446 &changelog_file,
447 version_files_strict,
448 )?;
449
450 let mut paths_to_stage: Vec<String> = Vec::new();
452 if let Some(ref cf) = changelog_file {
453 paths_to_stage.push(cf.clone());
454 }
455 for file in &files_to_stage {
456 paths_to_stage.push(file.clone());
457 }
458 if !stage_files.is_empty() {
459 let extra = resolve_globs(stage_files).map_err(ReleaseError::Config)?;
460 paths_to_stage.extend(extra);
461 }
462 if !paths_to_stage.is_empty() {
463 let refs: Vec<&str> = paths_to_stage.iter().map(|s| s.as_str()).collect();
464 let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
465 self.git.stage_and_commit(&refs, &commit_msg)?;
466 }
467 Ok(())
468 }
469
470 fn create_and_push_tags(
471 &self,
472 plan: &ReleasePlan,
473 changelog_body: &str,
474 dry_run: bool,
475 ) -> Result<(), ReleaseError> {
476 if dry_run {
477 let sign_label = if self.config.git.sign_tags {
478 " (signed)"
479 } else {
480 ""
481 };
482 eprintln!("[dry-run] Would create tag: {}{sign_label}", plan.tag_name);
483 eprintln!("[dry-run] Would push commit and tag: {}", plan.tag_name);
484 if let Some(ref floating) = plan.floating_tag_name {
485 eprintln!("[dry-run] Would create/update floating tag: {floating}");
486 eprintln!("[dry-run] Would force-push floating tag: {floating}");
487 }
488 return Ok(());
489 }
490
491 if !self.git.tag_exists(&plan.tag_name)? {
493 let tag_message = format!("{}\n\n{}", plan.tag_name, changelog_body);
494 self.git
495 .create_tag(&plan.tag_name, &tag_message, self.config.git.sign_tags)?;
496 }
497
498 self.git.push()?;
500
501 if !self.git.remote_tag_exists(&plan.tag_name)? {
503 self.git.push_tag(&plan.tag_name)?;
504 }
505
506 if let Some(ref floating) = plan.floating_tag_name {
508 self.git.force_create_tag(floating)?;
509 self.git.force_push_tag(floating)?;
510 }
511 Ok(())
512 }
513
514 fn create_or_update_release(
515 &self,
516 plan: &ReleasePlan,
517 changelog_body: &str,
518 dry_run: bool,
519 ) -> Result<(), ReleaseError> {
520 if dry_run {
521 let draft_label = if self.draft { " (draft)" } else { "" };
522 let release_name = self.release_name(plan);
523 eprintln!(
524 "[dry-run] Would create GitHub release \"{release_name}\" for {}{draft_label}",
525 plan.tag_name
526 );
527 return Ok(());
528 }
529
530 let release_name = self.release_name(plan);
531 if self.vcs.release_exists(&plan.tag_name)? {
532 self.vcs.update_release(
533 &plan.tag_name,
534 &release_name,
535 changelog_body,
536 plan.prerelease,
537 self.draft,
538 )?;
539 } else {
540 self.vcs.create_release(
541 &plan.tag_name,
542 &release_name,
543 changelog_body,
544 plan.prerelease,
545 self.draft,
546 )?;
547 }
548 Ok(())
549 }
550
551 fn upload_artifacts(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
552 let all_artifacts = self.config.all_artifacts();
553 if all_artifacts.is_empty() {
554 return Ok(());
555 }
556
557 let resolved = resolve_globs(&all_artifacts).map_err(ReleaseError::Vcs)?;
558
559 if dry_run {
560 if resolved.is_empty() {
561 eprintln!("[dry-run] Artifact patterns matched no files");
562 } else {
563 eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
564 for f in &resolved {
565 eprintln!("[dry-run] {f}");
566 }
567 }
568 return Ok(());
569 }
570
571 if !resolved.is_empty() {
572 let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
573 self.vcs.upload_assets(&plan.tag_name, &file_refs)?;
574 eprintln!(
575 "Uploaded {} artifact(s) to {}",
576 resolved.len(),
577 plan.tag_name
578 );
579 }
580 Ok(())
581 }
582
583 fn verify_release_exists(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
584 if dry_run {
585 eprintln!("[dry-run] Would verify release: {}", plan.tag_name);
586 return Ok(());
587 }
588
589 if let Err(e) = self.vcs.verify_release(&plan.tag_name) {
590 eprintln!("warning: post-release verification failed: {e}");
591 eprintln!(
592 " The tag {} was pushed but the GitHub release may be incomplete.",
593 plan.tag_name
594 );
595 eprintln!(" Re-run with --force to retry.");
596 }
597 Ok(())
598 }
599
600 fn execute_mutations(
603 &self,
604 version_str: &str,
605 changelog_body: &str,
606 version_files: &[String],
607 changelog_file: &Option<String>,
608 version_files_strict: bool,
609 ) -> Result<Vec<String>, ReleaseError> {
610 let mut files_to_stage: Vec<String> = Vec::new();
611 for file in version_files {
612 match bump_version_file(Path::new(file), version_str) {
613 Ok(extra) => {
614 files_to_stage.push(file.clone());
615 for extra_path in extra {
616 files_to_stage.push(extra_path.to_string_lossy().into_owned());
617 }
618 }
619 Err(e) if !version_files_strict => {
620 eprintln!("warning: {e} — skipping {file}");
621 }
622 Err(e) => return Err(e),
623 }
624 }
625
626 for lock_file in discover_lock_files(&files_to_stage) {
628 let lock_str = lock_file.to_string_lossy().into_owned();
629 if !files_to_stage.contains(&lock_str) {
630 files_to_stage.push(lock_str);
631 }
632 }
633
634 if let Some(cf) = changelog_file {
636 let path = Path::new(cf);
637 let existing = if path.exists() {
638 fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
639 } else {
640 String::new()
641 };
642 let new_content = if existing.is_empty() {
643 format!("# Changelog\n\n{changelog_body}\n")
644 } else {
645 match existing.find("\n\n") {
646 Some(pos) => {
647 let (header, rest) = existing.split_at(pos);
648 format!("{header}\n\n{changelog_body}\n{rest}")
649 }
650 None => format!("{existing}\n\n{changelog_body}\n"),
651 }
652 };
653 fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
654 }
655
656 Ok(files_to_stage)
657 }
658}
659
660fn resolve_globs(patterns: &[String]) -> Result<Vec<String>, String> {
662 let mut files = std::collections::BTreeSet::new();
663 for pattern in patterns {
664 let paths =
665 glob::glob(pattern).map_err(|e| format!("invalid glob pattern '{pattern}': {e}"))?;
666 for entry in paths {
667 match entry {
668 Ok(path) if path.is_file() => {
669 files.insert(path.to_string_lossy().into_owned());
670 }
671 Ok(_) => {}
672 Err(e) => {
673 return Err(format!("glob error for pattern '{pattern}': {e}"));
674 }
675 }
676 }
677 }
678 Ok(files.into_iter().collect())
679}
680
681pub fn today_string() -> String {
682 let secs = std::time::SystemTime::now()
685 .duration_since(std::time::UNIX_EPOCH)
686 .unwrap_or_default()
687 .as_secs() as i64;
688
689 let z = secs / 86400 + 719468;
690 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
691 let doe = (z - era * 146097) as u32;
692 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
693 let y = yoe as i64 + era * 400;
694 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
695 let mp = (5 * doy + 2) / 153;
696 let d = doy - (153 * mp + 2) / 5 + 1;
697 let m = if mp < 10 { mp + 3 } else { mp - 9 };
698 let y = if m <= 2 { y + 1 } else { y };
699
700 format!("{y:04}-{m:02}-{d:02}")
701}
702
703#[cfg(test)]
704mod tests {
705 use std::sync::Mutex;
706
707 use super::*;
708 use crate::changelog::DefaultChangelogFormatter;
709 use crate::commit::{Commit, TypedCommitParser};
710 use crate::config::{
711 ChangelogConfig, Config, GitConfig, PackageConfig, default_changelog_groups,
712 };
713 use crate::git::{GitRepository, TagInfo};
714
715 struct FakeGit {
718 tags: Vec<TagInfo>,
719 commits: Vec<Commit>,
720 path_commits: Option<Vec<Commit>>,
722 head: String,
723 created_tags: Mutex<Vec<String>>,
724 pushed_tags: Mutex<Vec<String>>,
725 committed: Mutex<Vec<(Vec<String>, String)>>,
726 push_count: Mutex<u32>,
727 force_created_tags: Mutex<Vec<String>>,
728 force_pushed_tags: Mutex<Vec<String>>,
729 }
730
731 impl FakeGit {
732 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
733 let head = tags
734 .last()
735 .map(|t| t.sha.clone())
736 .unwrap_or_else(|| "0".repeat(40));
737 Self {
738 tags,
739 commits,
740 path_commits: None,
741 head,
742 created_tags: Mutex::new(Vec::new()),
743 pushed_tags: Mutex::new(Vec::new()),
744 committed: Mutex::new(Vec::new()),
745 push_count: Mutex::new(0),
746 force_created_tags: Mutex::new(Vec::new()),
747 force_pushed_tags: Mutex::new(Vec::new()),
748 }
749 }
750 }
751
752 impl GitRepository for FakeGit {
753 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
754 Ok(self.tags.last().cloned())
755 }
756
757 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
758 Ok(self.commits.clone())
759 }
760
761 fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
762 self.created_tags.lock().unwrap().push(name.to_string());
763 Ok(())
764 }
765
766 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
767 self.pushed_tags.lock().unwrap().push(name.to_string());
768 Ok(())
769 }
770
771 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
772 self.committed.lock().unwrap().push((
773 paths.iter().map(|s| s.to_string()).collect(),
774 message.to_string(),
775 ));
776 Ok(true)
777 }
778
779 fn push(&self) -> Result<(), ReleaseError> {
780 *self.push_count.lock().unwrap() += 1;
781 Ok(())
782 }
783
784 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
785 Ok(self
786 .created_tags
787 .lock()
788 .unwrap()
789 .contains(&name.to_string()))
790 }
791
792 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
793 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
794 }
795
796 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
797 Ok(self.tags.clone())
798 }
799
800 fn commits_between(
801 &self,
802 _from: Option<&str>,
803 _to: &str,
804 ) -> Result<Vec<Commit>, ReleaseError> {
805 Ok(self.commits.clone())
806 }
807
808 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
809 Ok("2026-01-01".into())
810 }
811
812 fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
813 self.force_created_tags
814 .lock()
815 .unwrap()
816 .push(name.to_string());
817 Ok(())
818 }
819
820 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
821 self.force_pushed_tags
822 .lock()
823 .unwrap()
824 .push(name.to_string());
825 Ok(())
826 }
827
828 fn head_sha(&self) -> Result<String, ReleaseError> {
829 Ok(self.head.clone())
830 }
831
832 fn commits_since_in_path(
833 &self,
834 _from: Option<&str>,
835 _path: &str,
836 ) -> Result<Vec<Commit>, ReleaseError> {
837 Ok(self
838 .path_commits
839 .clone()
840 .unwrap_or_else(|| self.commits.clone()))
841 }
842 }
843
844 struct FakeVcs {
845 releases: Mutex<Vec<(String, String)>>,
846 deleted_releases: Mutex<Vec<String>>,
847 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
848 }
849
850 impl FakeVcs {
851 fn new() -> Self {
852 Self {
853 releases: Mutex::new(Vec::new()),
854 deleted_releases: Mutex::new(Vec::new()),
855 uploaded_assets: Mutex::new(Vec::new()),
856 }
857 }
858 }
859
860 impl VcsProvider for FakeVcs {
861 fn create_release(
862 &self,
863 tag: &str,
864 _name: &str,
865 body: &str,
866 _prerelease: bool,
867 _draft: bool,
868 ) -> Result<String, ReleaseError> {
869 self.releases
870 .lock()
871 .unwrap()
872 .push((tag.to_string(), body.to_string()));
873 Ok(format!("https://github.com/test/release/{tag}"))
874 }
875
876 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
877 Ok(format!("https://github.com/test/compare/{base}...{head}"))
878 }
879
880 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
881 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
882 }
883
884 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
885 self.deleted_releases.lock().unwrap().push(tag.to_string());
886 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
887 Ok(())
888 }
889
890 fn update_release(
891 &self,
892 tag: &str,
893 _name: &str,
894 body: &str,
895 _prerelease: bool,
896 _draft: bool,
897 ) -> Result<String, ReleaseError> {
898 let mut releases = self.releases.lock().unwrap();
899 if let Some(entry) = releases.iter_mut().find(|(t, _)| t == tag) {
900 entry.1 = body.to_string();
901 }
902 Ok(format!("https://github.com/test/release/{tag}"))
903 }
904
905 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
906 self.uploaded_assets.lock().unwrap().push((
907 tag.to_string(),
908 files.iter().map(|s| s.to_string()).collect(),
909 ));
910 Ok(())
911 }
912
913 fn repo_url(&self) -> Option<String> {
914 Some("https://github.com/test/repo".into())
915 }
916 }
917
918 type TestStrategy =
921 TrunkReleaseStrategy<FakeGit, FakeVcs, TypedCommitParser, DefaultChangelogFormatter>;
922
923 fn test_config() -> Config {
925 Config {
926 changelog: ChangelogConfig {
927 file: None,
928 ..Default::default()
929 },
930 ..Default::default()
931 }
932 }
933
934 fn config_with_git(git: GitConfig) -> Config {
936 Config {
937 git,
938 changelog: ChangelogConfig {
939 file: None,
940 ..Default::default()
941 },
942 ..Default::default()
943 }
944 }
945
946 fn make_strategy(tags: Vec<TagInfo>, commits: Vec<Commit>, config: Config) -> TestStrategy {
947 TrunkReleaseStrategy {
948 git: FakeGit::new(tags, commits),
949 vcs: FakeVcs::new(),
950 parser: TypedCommitParser::default(),
951 formatter: DefaultChangelogFormatter::new(None, default_changelog_groups()),
952 config,
953 force: false,
954 prerelease_id: None,
955 draft: false,
956 }
957 }
958
959 fn raw_commit(msg: &str) -> Commit {
960 Commit {
961 sha: "a".repeat(40),
962 message: msg.into(),
963 }
964 }
965
966 #[test]
969 fn plan_no_commits_returns_error() {
970 let s = make_strategy(vec![], vec![], Config::default());
971 let err = s.plan().unwrap_err();
972 assert!(matches!(err, ReleaseError::NoCommits { .. }));
973 }
974
975 #[test]
976 fn plan_no_releasable_returns_error() {
977 let s = make_strategy(
978 vec![],
979 vec![raw_commit("chore: tidy up")],
980 Config::default(),
981 );
982 let err = s.plan().unwrap_err();
983 assert!(matches!(err, ReleaseError::NoBump { .. }));
984 }
985
986 #[test]
987 fn force_releases_patch_when_no_releasable_commits() {
988 let tag = TagInfo {
989 name: "v1.2.3".into(),
990 version: Version::new(1, 2, 3),
991 sha: "d".repeat(40),
992 };
993 let mut s = make_strategy(
994 vec![tag],
995 vec![raw_commit("chore: rename package")],
996 Config::default(),
997 );
998 s.force = true;
999 let plan = s.plan().unwrap();
1000 assert_eq!(plan.next_version, Version::new(1, 2, 4));
1001 assert_eq!(plan.bump, BumpLevel::Patch);
1002 }
1003
1004 #[test]
1005 fn plan_first_release() {
1006 let s = make_strategy(
1007 vec![],
1008 vec![raw_commit("feat: initial feature")],
1009 Config::default(),
1010 );
1011 let plan = s.plan().unwrap();
1012 assert_eq!(plan.next_version, Version::new(0, 1, 0));
1013 assert_eq!(plan.tag_name, "v0.1.0");
1014 assert!(plan.current_version.is_none());
1015 }
1016
1017 #[test]
1018 fn plan_increments_existing() {
1019 let tag = TagInfo {
1020 name: "v1.2.3".into(),
1021 version: Version::new(1, 2, 3),
1022 sha: "b".repeat(40),
1023 };
1024 let s = make_strategy(
1025 vec![tag],
1026 vec![raw_commit("fix: patch bug")],
1027 Config::default(),
1028 );
1029 let plan = s.plan().unwrap();
1030 assert_eq!(plan.next_version, Version::new(1, 2, 4));
1031 }
1032
1033 #[test]
1034 fn plan_breaking_bump() {
1035 let tag = TagInfo {
1036 name: "v1.2.3".into(),
1037 version: Version::new(1, 2, 3),
1038 sha: "c".repeat(40),
1039 };
1040 let s = make_strategy(
1041 vec![tag],
1042 vec![raw_commit("feat!: breaking change")],
1043 Config::default(),
1044 );
1045 let plan = s.plan().unwrap();
1046 assert_eq!(plan.next_version, Version::new(2, 0, 0));
1047 }
1048
1049 #[test]
1050 fn plan_v0_breaking_downshifts_to_minor() {
1051 let tag = TagInfo {
1052 name: "v0.5.0".into(),
1053 version: Version::new(0, 5, 0),
1054 sha: "c".repeat(40),
1055 };
1056 let s = make_strategy(
1057 vec![tag],
1058 vec![raw_commit("feat!: breaking change")],
1059 Config::default(),
1060 );
1061 let plan = s.plan().unwrap();
1062 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1064 assert_eq!(plan.bump, BumpLevel::Minor);
1065 }
1066
1067 #[test]
1068 fn plan_v0_breaking_with_protection_disabled_bumps_major() {
1069 let tag = TagInfo {
1070 name: "v0.5.0".into(),
1071 version: Version::new(0, 5, 0),
1072 sha: "c".repeat(40),
1073 };
1074 let mut config = Config::default();
1075 config.git.v0_protection = false;
1076 let s = make_strategy(
1077 vec![tag],
1078 vec![raw_commit("feat!: breaking change")],
1079 config,
1080 );
1081 let plan = s.plan().unwrap();
1082 assert_eq!(plan.next_version, Version::new(1, 0, 0));
1084 assert_eq!(plan.bump, BumpLevel::Major);
1085 }
1086
1087 #[test]
1088 fn plan_v0_feat_stays_minor() {
1089 let tag = TagInfo {
1090 name: "v0.5.0".into(),
1091 version: Version::new(0, 5, 0),
1092 sha: "c".repeat(40),
1093 };
1094 let s = make_strategy(
1095 vec![tag],
1096 vec![raw_commit("feat: new feature")],
1097 Config::default(),
1098 );
1099 let plan = s.plan().unwrap();
1100 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1102 assert_eq!(plan.bump, BumpLevel::Minor);
1103 }
1104
1105 #[test]
1106 fn plan_v0_fix_stays_patch() {
1107 let tag = TagInfo {
1108 name: "v0.5.0".into(),
1109 version: Version::new(0, 5, 0),
1110 sha: "c".repeat(40),
1111 };
1112 let s = make_strategy(
1113 vec![tag],
1114 vec![raw_commit("fix: bug fix")],
1115 Config::default(),
1116 );
1117 let plan = s.plan().unwrap();
1118 assert_eq!(plan.next_version, Version::new(0, 5, 1));
1120 assert_eq!(plan.bump, BumpLevel::Patch);
1121 }
1122
1123 #[test]
1126 fn execute_dry_run_no_side_effects() {
1127 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1128 let plan = s.plan().unwrap();
1129 s.execute(&plan, true).unwrap();
1130
1131 assert!(s.git.created_tags.lock().unwrap().is_empty());
1132 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1133 }
1134
1135 #[test]
1136 fn execute_creates_and_pushes_tag() {
1137 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1138 let plan = s.plan().unwrap();
1139 s.execute(&plan, false).unwrap();
1140
1141 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1142 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1143 }
1144
1145 #[test]
1146 fn execute_calls_vcs_create_release() {
1147 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1148 let plan = s.plan().unwrap();
1149 s.execute(&plan, false).unwrap();
1150
1151 let releases = s.vcs.releases.lock().unwrap();
1152 assert_eq!(releases.len(), 1);
1153 assert_eq!(releases[0].0, "v0.1.0");
1154 assert!(!releases[0].1.is_empty());
1155 }
1156
1157 #[test]
1158 fn execute_commits_changelog_before_tag() {
1159 let dir = tempfile::tempdir().unwrap();
1160 let changelog_path = dir.path().join("CHANGELOG.md");
1161
1162 let config = Config {
1164 changelog: ChangelogConfig {
1165 file: Some(changelog_path.to_str().unwrap().to_string()),
1166 ..Default::default()
1167 },
1168 packages: vec![PackageConfig {
1169 path: dir.path().to_str().unwrap().to_string(),
1170 ..Default::default()
1171 }],
1172 ..Default::default()
1173 };
1174
1175 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1176 let plan = s.plan().unwrap();
1177 s.execute(&plan, false).unwrap();
1178
1179 let committed = s.git.committed.lock().unwrap();
1181 assert_eq!(committed.len(), 1);
1182 assert_eq!(
1183 committed[0].0,
1184 vec![changelog_path.to_str().unwrap().to_string()]
1185 );
1186 assert!(committed[0].1.contains("chore(release): v0.1.0"));
1187
1188 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1190 }
1191
1192 #[test]
1193 fn execute_skips_existing_tag() {
1194 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1195 let plan = s.plan().unwrap();
1196
1197 s.git
1199 .created_tags
1200 .lock()
1201 .unwrap()
1202 .push("v0.1.0".to_string());
1203
1204 s.execute(&plan, false).unwrap();
1205
1206 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1208 }
1209
1210 #[test]
1211 fn execute_skips_existing_release() {
1212 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1213 let plan = s.plan().unwrap();
1214
1215 s.vcs
1217 .releases
1218 .lock()
1219 .unwrap()
1220 .push(("v0.1.0".to_string(), "old notes".to_string()));
1221
1222 s.execute(&plan, false).unwrap();
1223
1224 let deleted = s.vcs.deleted_releases.lock().unwrap();
1226 assert!(deleted.is_empty(), "update should not delete");
1227
1228 let releases = s.vcs.releases.lock().unwrap();
1229 assert_eq!(releases.len(), 1);
1230 assert_eq!(releases[0].0, "v0.1.0");
1231 assert_ne!(releases[0].1, "old notes");
1232 }
1233
1234 #[test]
1235 fn execute_idempotent_rerun() {
1236 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1237 let plan = s.plan().unwrap();
1238
1239 s.execute(&plan, false).unwrap();
1241
1242 s.execute(&plan, false).unwrap();
1244
1245 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1247
1248 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1250
1251 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1253
1254 let deleted = s.vcs.deleted_releases.lock().unwrap();
1256 assert!(deleted.is_empty(), "update should not delete");
1257
1258 let releases = s.vcs.releases.lock().unwrap();
1259 assert_eq!(releases.len(), 1);
1260 assert_eq!(releases[0].0, "v0.1.0");
1261 }
1262
1263 #[test]
1264 fn execute_bumps_version_files() {
1265 let dir = tempfile::tempdir().unwrap();
1266 let cargo_path = dir.path().join("Cargo.toml");
1267 std::fs::write(
1268 &cargo_path,
1269 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1270 )
1271 .unwrap();
1272
1273 let config = Config {
1274 changelog: ChangelogConfig {
1275 file: None,
1276 ..Default::default()
1277 },
1278 packages: vec![PackageConfig {
1279 path: ".".into(),
1280 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1281 ..Default::default()
1282 }],
1283 ..Default::default()
1284 };
1285
1286 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1287 let plan = s.plan().unwrap();
1288 s.execute(&plan, false).unwrap();
1289
1290 let contents = std::fs::read_to_string(&cargo_path).unwrap();
1292 assert!(contents.contains("version = \"0.1.0\""));
1293
1294 let committed = s.git.committed.lock().unwrap();
1296 assert_eq!(committed.len(), 1);
1297 assert!(
1298 committed[0]
1299 .0
1300 .contains(&cargo_path.to_str().unwrap().to_string())
1301 );
1302 }
1303
1304 #[test]
1305 fn execute_stages_changelog_and_version_files_together() {
1306 let dir = tempfile::tempdir().unwrap();
1307 let cargo_path = dir.path().join("Cargo.toml");
1308 std::fs::write(
1309 &cargo_path,
1310 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1311 )
1312 .unwrap();
1313
1314 let changelog_path = dir.path().join("CHANGELOG.md");
1315
1316 let config = Config {
1317 changelog: ChangelogConfig {
1318 file: Some(changelog_path.to_str().unwrap().to_string()),
1319 ..Default::default()
1320 },
1321 packages: vec![PackageConfig {
1322 path: ".".into(),
1323 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1324 ..Default::default()
1325 }],
1326 ..Default::default()
1327 };
1328
1329 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1330 let plan = s.plan().unwrap();
1331 s.execute(&plan, false).unwrap();
1332
1333 let committed = s.git.committed.lock().unwrap();
1335 assert_eq!(committed.len(), 1);
1336 assert!(
1337 committed[0]
1338 .0
1339 .contains(&changelog_path.to_str().unwrap().to_string())
1340 );
1341 assert!(
1342 committed[0]
1343 .0
1344 .contains(&cargo_path.to_str().unwrap().to_string())
1345 );
1346 }
1347
1348 #[test]
1351 fn execute_uploads_artifacts() {
1352 let dir = tempfile::tempdir().unwrap();
1353 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1354 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1355
1356 let config = Config {
1357 changelog: ChangelogConfig {
1358 file: None,
1359 ..Default::default()
1360 },
1361 packages: vec![PackageConfig {
1362 path: ".".into(),
1363 artifacts: vec![
1364 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1365 dir.path().join("*.zip").to_str().unwrap().to_string(),
1366 ],
1367 ..Default::default()
1368 }],
1369 ..Default::default()
1370 };
1371
1372 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1373 let plan = s.plan().unwrap();
1374 s.execute(&plan, false).unwrap();
1375
1376 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1377 assert_eq!(uploaded.len(), 1);
1378 assert_eq!(uploaded[0].0, "v0.1.0");
1379 assert_eq!(uploaded[0].1.len(), 2);
1380 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1381 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1382 }
1383
1384 #[test]
1385 fn execute_dry_run_shows_artifacts() {
1386 let dir = tempfile::tempdir().unwrap();
1387 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1388
1389 let config = Config {
1390 changelog: ChangelogConfig {
1391 file: None,
1392 ..Default::default()
1393 },
1394 packages: vec![PackageConfig {
1395 path: ".".into(),
1396 artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1397 ..Default::default()
1398 }],
1399 ..Default::default()
1400 };
1401
1402 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1403 let plan = s.plan().unwrap();
1404 s.execute(&plan, true).unwrap();
1405
1406 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1408 assert!(uploaded.is_empty());
1409 }
1410
1411 #[test]
1412 fn execute_no_artifacts_skips_upload() {
1413 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1414 let plan = s.plan().unwrap();
1415 s.execute(&plan, false).unwrap();
1416
1417 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1418 assert!(uploaded.is_empty());
1419 }
1420
1421 #[test]
1422 fn resolve_globs_basic() {
1423 let dir = tempfile::tempdir().unwrap();
1424 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1425 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1426 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1427
1428 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1429 let result = resolve_globs(&[pattern]).unwrap();
1430 assert_eq!(result.len(), 2);
1431 assert!(result.iter().any(|f: &String| f.ends_with("a.txt")));
1432 assert!(result.iter().any(|f: &String| f.ends_with("b.txt")));
1433 }
1434
1435 #[test]
1436 fn resolve_globs_deduplicates() {
1437 let dir = tempfile::tempdir().unwrap();
1438 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1439
1440 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1441 let result = resolve_globs(&[pattern.clone(), pattern]).unwrap();
1443 assert_eq!(result.len(), 1);
1444 }
1445
1446 #[test]
1449 fn plan_floating_tag_when_enabled() {
1450 let tag = TagInfo {
1451 name: "v3.2.0".into(),
1452 version: Version::new(3, 2, 0),
1453 sha: "d".repeat(40),
1454 };
1455 let config = config_with_git(GitConfig {
1456 floating_tag: true,
1457 ..Default::default()
1458 });
1459
1460 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1461 let plan = s.plan().unwrap();
1462 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1463 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1464 }
1465
1466 #[test]
1467 fn plan_no_floating_tag_when_disabled() {
1468 let s = make_strategy(
1469 vec![],
1470 vec![raw_commit("feat: something")],
1471 config_with_git(GitConfig {
1472 floating_tag: false,
1473 ..Default::default()
1474 }),
1475 );
1476 let plan = s.plan().unwrap();
1477 assert!(plan.floating_tag_name.is_none());
1478 }
1479
1480 #[test]
1481 fn plan_floating_tag_custom_prefix() {
1482 let tag = TagInfo {
1483 name: "release-2.5.0".into(),
1484 version: Version::new(2, 5, 0),
1485 sha: "e".repeat(40),
1486 };
1487 let config = config_with_git(GitConfig {
1488 floating_tag: true,
1489 tag_prefix: "release-".into(),
1490 ..Default::default()
1491 });
1492
1493 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1494 let plan = s.plan().unwrap();
1495 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1496 }
1497
1498 #[test]
1499 fn execute_floating_tags_force_create_and_push() {
1500 let config = config_with_git(GitConfig {
1501 floating_tag: true,
1502 ..Default::default()
1503 });
1504
1505 let tag = TagInfo {
1506 name: "v1.2.3".into(),
1507 version: Version::new(1, 2, 3),
1508 sha: "f".repeat(40),
1509 };
1510 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1511 let plan = s.plan().unwrap();
1512 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1513
1514 s.execute(&plan, false).unwrap();
1515
1516 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1517 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1518 }
1519
1520 #[test]
1521 fn execute_no_floating_tags_when_disabled() {
1522 let s = make_strategy(
1523 vec![],
1524 vec![raw_commit("feat: something")],
1525 config_with_git(GitConfig {
1526 floating_tag: false,
1527 ..Default::default()
1528 }),
1529 );
1530 let plan = s.plan().unwrap();
1531 assert!(plan.floating_tag_name.is_none());
1532
1533 s.execute(&plan, false).unwrap();
1534
1535 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1536 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1537 }
1538
1539 #[test]
1540 fn execute_floating_tags_dry_run_no_side_effects() {
1541 let config = config_with_git(GitConfig {
1542 floating_tag: true,
1543 ..Default::default()
1544 });
1545
1546 let tag = TagInfo {
1547 name: "v2.0.0".into(),
1548 version: Version::new(2, 0, 0),
1549 sha: "a".repeat(40),
1550 };
1551 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1552 let plan = s.plan().unwrap();
1553 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1554
1555 s.execute(&plan, true).unwrap();
1556
1557 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1558 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1559 }
1560
1561 #[test]
1562 fn execute_floating_tags_idempotent() {
1563 let config = config_with_git(GitConfig {
1564 floating_tag: true,
1565 ..Default::default()
1566 });
1567
1568 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1569 let plan = s.plan().unwrap();
1570 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1571
1572 s.execute(&plan, false).unwrap();
1574 s.execute(&plan, false).unwrap();
1575
1576 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1578 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1579 }
1580
1581 #[test]
1584 fn force_rerelease_when_tag_at_head() {
1585 let tag = TagInfo {
1586 name: "v1.2.3".into(),
1587 version: Version::new(1, 2, 3),
1588 sha: "a".repeat(40),
1589 };
1590 let mut s = make_strategy(vec![tag], vec![], Config::default());
1591 s.git.head = "a".repeat(40);
1593 s.force = true;
1594
1595 let plan = s.plan().unwrap();
1596 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1597 assert_eq!(plan.tag_name, "v1.2.3");
1598 assert!(plan.commits.is_empty());
1599 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1600 }
1601
1602 #[test]
1603 fn force_fails_when_tag_not_at_head() {
1604 let tag = TagInfo {
1605 name: "v1.2.3".into(),
1606 version: Version::new(1, 2, 3),
1607 sha: "a".repeat(40),
1608 };
1609 let mut s = make_strategy(vec![tag], vec![], Config::default());
1610 s.git.head = "b".repeat(40);
1612 s.force = true;
1613
1614 let err = s.plan().unwrap_err();
1615 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1616 }
1617}