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