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::ReleaseConfig;
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: ReleaseConfig,
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.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_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.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.prerelease.is_some();
186
187 let all_tags = self.git.all_tags(&self.config.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.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.floating_tags {
218 Some(format!("{}{}", self.config.tag_prefix, info.version.major))
219 } else {
220 None
221 };
222 return Ok(ReleasePlan {
223 current_version: Some(info.version.clone()),
224 next_version: info.version.clone(),
225 bump: BumpLevel::Patch,
226 commits: vec![],
227 tag_name: info.name.clone(),
228 floating_tag_name,
229 prerelease: is_prerelease,
230 });
231 }
232 }
233 let (tag, sha) = match tag_info {
234 Some(info) => (info.name.clone(), info.sha.clone()),
235 None => ("(none)".into(), "(none)".into()),
236 };
237 return Err(ReleaseError::NoCommits { tag, sha });
238 }
239
240 let conventional_commits: Vec<ConventionalCommit> = raw_commits
241 .iter()
242 .filter(|c| !c.message.starts_with("chore(release):"))
243 .filter_map(|c| self.parser.parse(c).ok())
244 .collect();
245
246 let classifier = DefaultCommitClassifier::new(
247 self.config.types.clone(),
248 self.config.commit_pattern.clone(),
249 );
250 let tag_for_err = tag_info
251 .map(|i| i.name.clone())
252 .unwrap_or_else(|| "(none)".into());
253 let commit_count = conventional_commits.len();
254 let bump = match determine_bump(&conventional_commits, &classifier) {
255 Some(b) => b,
256 None if self.force => BumpLevel::Patch,
257 None => {
258 return Err(ReleaseError::NoBump {
259 tag: tag_for_err,
260 commit_count,
261 });
262 }
263 };
264
265 let base_version = if is_prerelease {
267 latest_stable
268 .map(|t| t.version.clone())
269 .or(current_version.clone())
270 .unwrap_or(Version::new(0, 0, 0))
271 } else {
272 current_version.clone().unwrap_or(Version::new(0, 0, 0))
273 };
274
275 let bump = if base_version.major == 0 && bump == BumpLevel::Major && !self.force {
278 eprintln!(
279 "v0 protection: breaking change detected at v{base_version}, \
280 downshifting major → minor (use --force to bump to v1)"
281 );
282 BumpLevel::Minor
283 } else {
284 bump
285 };
286
287 let next_version = if let Some(ref prerelease_id) = self.config.prerelease {
288 let existing_versions: Vec<Version> =
289 all_tags.iter().map(|t| t.version.clone()).collect();
290 apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
291 } else {
292 apply_bump(&base_version, bump)
293 };
294
295 let tag_name = format!("{}{next_version}", self.config.tag_prefix);
296
297 let floating_tag_name = if self.config.floating_tags && !is_prerelease {
299 Some(format!("{}{}", self.config.tag_prefix, next_version.major))
300 } else {
301 None
302 };
303
304 Ok(ReleasePlan {
305 current_version,
306 next_version,
307 bump,
308 commits: conventional_commits,
309 tag_name,
310 floating_tag_name,
311 prerelease: is_prerelease,
312 })
313 }
314
315 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
316 let version_str = plan.next_version.to_string();
317
318 self.run_lifecycle_command(
319 &self.config.pre_release_command,
320 "pre_release_command",
321 &version_str,
322 &plan.tag_name,
323 dry_run,
324 )?;
325
326 let changelog_body = self.format_changelog(plan)?;
327
328 self.bump_and_build(plan, &version_str, &changelog_body, dry_run)?;
329 self.create_and_push_tags(plan, &changelog_body, dry_run)?;
330 self.create_or_update_release(plan, &changelog_body, dry_run)?;
331 self.upload_artifacts(plan, dry_run)?;
332 self.verify_release_exists(plan, dry_run)?;
333
334 self.run_lifecycle_command(
335 &self.config.post_release_command,
336 "post_release_command",
337 &version_str,
338 &plan.tag_name,
339 dry_run,
340 )?;
341
342 if dry_run {
343 eprintln!("[dry-run] Changelog:\n{changelog_body}");
344 } else {
345 eprintln!("Released {}", plan.tag_name);
346 }
347 Ok(())
348 }
349}
350
351impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
352where
353 G: GitRepository,
354 V: VcsProvider,
355 C: CommitParser,
356 F: ChangelogFormatter,
357{
358 fn run_lifecycle_command(
359 &self,
360 command: &Option<String>,
361 label: &str,
362 version: &str,
363 tag: &str,
364 dry_run: bool,
365 ) -> Result<(), ReleaseError> {
366 if let Some(cmd) = command {
367 if dry_run {
368 eprintln!("[dry-run] Would run {label}: {cmd}");
369 } else {
370 eprintln!("Running {label}: {cmd}");
371 run_lifecycle_hook(cmd, version, tag, label)?;
372 }
373 }
374 Ok(())
375 }
376
377 fn bump_and_build(
378 &self,
379 plan: &ReleasePlan,
380 version_str: &str,
381 changelog_body: &str,
382 dry_run: bool,
383 ) -> Result<(), ReleaseError> {
384 if dry_run {
385 for file in &self.config.version_files {
386 let filename = Path::new(file)
387 .file_name()
388 .and_then(|n| n.to_str())
389 .unwrap_or_default();
390 if is_supported_version_file(filename) {
391 eprintln!("[dry-run] Would bump version in: {file}");
392 } else if self.config.version_files_strict {
393 return Err(ReleaseError::VersionBump(format!(
394 "unsupported version file: {filename}"
395 )));
396 } else {
397 eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
398 }
399 }
400 if let Some(ref cmd) = self.config.build_command {
401 eprintln!("[dry-run] Would run build command: {cmd}");
402 }
403 if !self.config.stage_files.is_empty() {
404 eprintln!(
405 "[dry-run] Would stage additional files: {}",
406 self.config.stage_files.join(", ")
407 );
408 }
409 return Ok(());
410 }
411
412 let mut file_snapshots: Vec<(String, Option<String>)> = Vec::new();
414 for file in &self.config.version_files {
415 let path = Path::new(file);
416 let contents = if path.exists() {
417 Some(
418 fs::read_to_string(path)
419 .map_err(|e| ReleaseError::VersionBump(e.to_string()))?,
420 )
421 } else {
422 None
423 };
424 file_snapshots.push((file.clone(), contents));
425 }
426 if let Some(ref changelog_file) = self.config.changelog.file {
427 let path = Path::new(changelog_file);
428 let contents = if path.exists() {
429 Some(fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?)
430 } else {
431 None
432 };
433 file_snapshots.push((changelog_file.clone(), contents));
434 }
435
436 let files_to_stage = match self.execute_pre_commit(plan, version_str, changelog_body) {
438 Ok(files) => files,
439 Err(e) => {
440 eprintln!("error during pre-commit steps, restoring files...");
441 restore_snapshots(&file_snapshots);
442 return Err(e);
443 }
444 };
445
446 let mut paths_to_stage: Vec<String> = Vec::new();
448 if let Some(ref changelog_file) = self.config.changelog.file {
449 paths_to_stage.push(changelog_file.clone());
450 }
451 for file in &files_to_stage {
452 paths_to_stage.push(file.clone());
453 }
454 if !self.config.stage_files.is_empty() {
455 let extra = resolve_globs(&self.config.stage_files).map_err(ReleaseError::Config)?;
456 paths_to_stage.extend(extra);
457 }
458 if !paths_to_stage.is_empty() {
459 let refs: Vec<&str> = paths_to_stage.iter().map(|s| s.as_str()).collect();
460 let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
461 self.git.stage_and_commit(&refs, &commit_msg)?;
462 }
463 Ok(())
464 }
465
466 fn create_and_push_tags(
467 &self,
468 plan: &ReleasePlan,
469 changelog_body: &str,
470 dry_run: bool,
471 ) -> Result<(), ReleaseError> {
472 if dry_run {
473 let sign_label = if self.config.sign_tags {
474 " (signed)"
475 } else {
476 ""
477 };
478 eprintln!("[dry-run] Would create tag: {}{sign_label}", plan.tag_name);
479 eprintln!("[dry-run] Would push commit and tag: {}", plan.tag_name);
480 if let Some(ref floating) = plan.floating_tag_name {
481 eprintln!("[dry-run] Would create/update floating tag: {floating}");
482 eprintln!("[dry-run] Would force-push floating tag: {floating}");
483 }
484 return Ok(());
485 }
486
487 if !self.git.tag_exists(&plan.tag_name)? {
489 let tag_message = format!("{}\n\n{}", plan.tag_name, changelog_body);
490 self.git
491 .create_tag(&plan.tag_name, &tag_message, self.config.sign_tags)?;
492 }
493
494 self.git.push()?;
496
497 if !self.git.remote_tag_exists(&plan.tag_name)? {
499 self.git.push_tag(&plan.tag_name)?;
500 }
501
502 if let Some(ref floating) = plan.floating_tag_name {
504 self.git.force_create_tag(floating)?;
505 self.git.force_push_tag(floating)?;
506 }
507 Ok(())
508 }
509
510 fn create_or_update_release(
511 &self,
512 plan: &ReleasePlan,
513 changelog_body: &str,
514 dry_run: bool,
515 ) -> Result<(), ReleaseError> {
516 if dry_run {
517 let draft_label = if self.config.draft { " (draft)" } else { "" };
518 let release_name = self.release_name(plan);
519 eprintln!(
520 "[dry-run] Would create GitHub release \"{release_name}\" for {}{draft_label}",
521 plan.tag_name
522 );
523 return Ok(());
524 }
525
526 let release_name = self.release_name(plan);
527 if self.vcs.release_exists(&plan.tag_name)? {
528 self.vcs.update_release(
529 &plan.tag_name,
530 &release_name,
531 changelog_body,
532 plan.prerelease,
533 self.config.draft,
534 )?;
535 } else {
536 self.vcs.create_release(
537 &plan.tag_name,
538 &release_name,
539 changelog_body,
540 plan.prerelease,
541 self.config.draft,
542 )?;
543 }
544 Ok(())
545 }
546
547 fn upload_artifacts(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
548 if self.config.artifacts.is_empty() {
549 return Ok(());
550 }
551
552 let resolved = resolve_globs(&self.config.artifacts).map_err(ReleaseError::Vcs)?;
553
554 if dry_run {
555 if resolved.is_empty() {
556 eprintln!("[dry-run] Artifact patterns matched no files");
557 } else {
558 eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
559 for f in &resolved {
560 eprintln!("[dry-run] {f}");
561 }
562 }
563 return Ok(());
564 }
565
566 if !resolved.is_empty() {
567 let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
568 self.vcs.upload_assets(&plan.tag_name, &file_refs)?;
569 eprintln!(
570 "Uploaded {} artifact(s) to {}",
571 resolved.len(),
572 plan.tag_name
573 );
574 }
575 Ok(())
576 }
577
578 fn verify_release_exists(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
579 if dry_run {
580 eprintln!("[dry-run] Would verify release: {}", plan.tag_name);
581 return Ok(());
582 }
583
584 if let Err(e) = self.vcs.verify_release(&plan.tag_name) {
585 eprintln!("warning: post-release verification failed: {e}");
586 eprintln!(
587 " The tag {} was pushed but the GitHub release may be incomplete.",
588 plan.tag_name
589 );
590 eprintln!(" Re-run with --force to retry.");
591 }
592 Ok(())
593 }
594
595 fn execute_pre_commit(
598 &self,
599 plan: &ReleasePlan,
600 version_str: &str,
601 changelog_body: &str,
602 ) -> Result<Vec<String>, ReleaseError> {
603 let mut files_to_stage: Vec<String> = Vec::new();
605 for file in &self.config.version_files {
606 match bump_version_file(Path::new(file), version_str) {
607 Ok(extra) => {
608 files_to_stage.push(file.clone());
609 for extra_path in extra {
610 files_to_stage.push(extra_path.to_string_lossy().into_owned());
611 }
612 }
613 Err(e) if !self.config.version_files_strict => {
614 eprintln!("warning: {e} — skipping {file}");
615 }
616 Err(e) => return Err(e),
617 }
618 }
619
620 for lock_file in discover_lock_files(&files_to_stage) {
622 let lock_str = lock_file.to_string_lossy().into_owned();
623 if !files_to_stage.contains(&lock_str) {
624 files_to_stage.push(lock_str);
625 }
626 }
627
628 if let Some(ref changelog_file) = self.config.changelog.file {
630 let path = Path::new(changelog_file);
631 let existing = if path.exists() {
632 fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
633 } else {
634 String::new()
635 };
636 let new_content = if existing.is_empty() {
637 format!("# Changelog\n\n{changelog_body}\n")
638 } else {
639 match existing.find("\n\n") {
640 Some(pos) => {
641 let (header, rest) = existing.split_at(pos);
642 format!("{header}\n\n{changelog_body}\n{rest}")
643 }
644 None => format!("{existing}\n\n{changelog_body}\n"),
645 }
646 };
647 fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
648 }
649
650 if let Some(ref cmd) = self.config.build_command {
652 eprintln!("Running build command: {cmd}");
653 run_lifecycle_hook(cmd, version_str, &plan.tag_name, "build_command")?;
654 }
655
656 Ok(files_to_stage)
657 }
658}
659
660fn restore_snapshots(snapshots: &[(String, Option<String>)]) {
662 for (file, contents) in snapshots {
663 let path = Path::new(file);
664 match contents {
665 Some(data) => {
666 if let Err(e) = fs::write(path, data) {
667 eprintln!("warning: failed to restore {file}: {e}");
668 }
669 }
670 None => {
671 if path.exists()
673 && let Err(e) = fs::remove_file(path)
674 {
675 eprintln!("warning: failed to remove {file}: {e}");
676 }
677 }
678 }
679 }
680}
681
682fn run_lifecycle_hook(
684 cmd: &str,
685 version: &str,
686 tag: &str,
687 label: &str,
688) -> Result<(), ReleaseError> {
689 crate::hooks::run_shell(cmd, None, &[("SR_VERSION", version), ("SR_TAG", tag)])
690 .map_err(|e| ReleaseError::BuildCommand(format!("{label}: {e}")))
691}
692
693fn resolve_globs(patterns: &[String]) -> Result<Vec<String>, String> {
695 let mut files = std::collections::BTreeSet::new();
696 for pattern in patterns {
697 let paths =
698 glob::glob(pattern).map_err(|e| format!("invalid glob pattern '{pattern}': {e}"))?;
699 for entry in paths {
700 match entry {
701 Ok(path) if path.is_file() => {
702 files.insert(path.to_string_lossy().into_owned());
703 }
704 Ok(_) => {}
705 Err(e) => {
706 return Err(format!("glob error for pattern '{pattern}': {e}"));
707 }
708 }
709 }
710 }
711 Ok(files.into_iter().collect())
712}
713
714pub fn today_string() -> String {
715 let secs = std::time::SystemTime::now()
718 .duration_since(std::time::UNIX_EPOCH)
719 .unwrap_or_default()
720 .as_secs() as i64;
721
722 let z = secs / 86400 + 719468;
723 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
724 let doe = (z - era * 146097) as u32;
725 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
726 let y = yoe as i64 + era * 400;
727 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
728 let mp = (5 * doy + 2) / 153;
729 let d = doy - (153 * mp + 2) / 5 + 1;
730 let m = if mp < 10 { mp + 3 } else { mp - 9 };
731 let y = if m <= 2 { y + 1 } else { y };
732
733 format!("{y:04}-{m:02}-{d:02}")
734}
735
736#[cfg(test)]
737mod tests {
738 use std::sync::Mutex;
739
740 use super::*;
741 use crate::changelog::DefaultChangelogFormatter;
742 use crate::commit::{Commit, DefaultCommitParser};
743 use crate::config::ReleaseConfig;
744 use crate::git::{GitRepository, TagInfo};
745
746 struct FakeGit {
749 tags: Vec<TagInfo>,
750 commits: Vec<Commit>,
751 path_commits: Option<Vec<Commit>>,
753 head: String,
754 created_tags: Mutex<Vec<String>>,
755 pushed_tags: Mutex<Vec<String>>,
756 committed: Mutex<Vec<(Vec<String>, String)>>,
757 push_count: Mutex<u32>,
758 force_created_tags: Mutex<Vec<String>>,
759 force_pushed_tags: Mutex<Vec<String>>,
760 }
761
762 impl FakeGit {
763 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
764 let head = tags
765 .last()
766 .map(|t| t.sha.clone())
767 .unwrap_or_else(|| "0".repeat(40));
768 Self {
769 tags,
770 commits,
771 path_commits: None,
772 head,
773 created_tags: Mutex::new(Vec::new()),
774 pushed_tags: Mutex::new(Vec::new()),
775 committed: Mutex::new(Vec::new()),
776 push_count: Mutex::new(0),
777 force_created_tags: Mutex::new(Vec::new()),
778 force_pushed_tags: Mutex::new(Vec::new()),
779 }
780 }
781 }
782
783 impl GitRepository for FakeGit {
784 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
785 Ok(self.tags.last().cloned())
786 }
787
788 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
789 Ok(self.commits.clone())
790 }
791
792 fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
793 self.created_tags.lock().unwrap().push(name.to_string());
794 Ok(())
795 }
796
797 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
798 self.pushed_tags.lock().unwrap().push(name.to_string());
799 Ok(())
800 }
801
802 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
803 self.committed.lock().unwrap().push((
804 paths.iter().map(|s| s.to_string()).collect(),
805 message.to_string(),
806 ));
807 Ok(true)
808 }
809
810 fn push(&self) -> Result<(), ReleaseError> {
811 *self.push_count.lock().unwrap() += 1;
812 Ok(())
813 }
814
815 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
816 Ok(self
817 .created_tags
818 .lock()
819 .unwrap()
820 .contains(&name.to_string()))
821 }
822
823 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
824 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
825 }
826
827 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
828 Ok(self.tags.clone())
829 }
830
831 fn commits_between(
832 &self,
833 _from: Option<&str>,
834 _to: &str,
835 ) -> Result<Vec<Commit>, ReleaseError> {
836 Ok(self.commits.clone())
837 }
838
839 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
840 Ok("2026-01-01".into())
841 }
842
843 fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
844 self.force_created_tags
845 .lock()
846 .unwrap()
847 .push(name.to_string());
848 Ok(())
849 }
850
851 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
852 self.force_pushed_tags
853 .lock()
854 .unwrap()
855 .push(name.to_string());
856 Ok(())
857 }
858
859 fn head_sha(&self) -> Result<String, ReleaseError> {
860 Ok(self.head.clone())
861 }
862
863 fn commits_since_in_path(
864 &self,
865 _from: Option<&str>,
866 _path: &str,
867 ) -> Result<Vec<Commit>, ReleaseError> {
868 Ok(self
869 .path_commits
870 .clone()
871 .unwrap_or_else(|| self.commits.clone()))
872 }
873 }
874
875 struct FakeVcs {
876 releases: Mutex<Vec<(String, String)>>,
877 deleted_releases: Mutex<Vec<String>>,
878 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
879 }
880
881 impl FakeVcs {
882 fn new() -> Self {
883 Self {
884 releases: Mutex::new(Vec::new()),
885 deleted_releases: Mutex::new(Vec::new()),
886 uploaded_assets: Mutex::new(Vec::new()),
887 }
888 }
889 }
890
891 impl VcsProvider for FakeVcs {
892 fn create_release(
893 &self,
894 tag: &str,
895 _name: &str,
896 body: &str,
897 _prerelease: bool,
898 _draft: bool,
899 ) -> Result<String, ReleaseError> {
900 self.releases
901 .lock()
902 .unwrap()
903 .push((tag.to_string(), body.to_string()));
904 Ok(format!("https://github.com/test/release/{tag}"))
905 }
906
907 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
908 Ok(format!("https://github.com/test/compare/{base}...{head}"))
909 }
910
911 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
912 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
913 }
914
915 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
916 self.deleted_releases.lock().unwrap().push(tag.to_string());
917 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
918 Ok(())
919 }
920
921 fn update_release(
922 &self,
923 tag: &str,
924 _name: &str,
925 body: &str,
926 _prerelease: bool,
927 _draft: bool,
928 ) -> Result<String, ReleaseError> {
929 let mut releases = self.releases.lock().unwrap();
930 if let Some(entry) = releases.iter_mut().find(|(t, _)| t == tag) {
931 entry.1 = body.to_string();
932 }
933 Ok(format!("https://github.com/test/release/{tag}"))
934 }
935
936 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
937 self.uploaded_assets.lock().unwrap().push((
938 tag.to_string(),
939 files.iter().map(|s| s.to_string()).collect(),
940 ));
941 Ok(())
942 }
943
944 fn repo_url(&self) -> Option<String> {
945 Some("https://github.com/test/repo".into())
946 }
947 }
948
949 fn test_config() -> ReleaseConfig {
954 ReleaseConfig {
955 changelog: crate::config::ChangelogConfig {
956 file: None,
957 ..Default::default()
958 },
959 ..Default::default()
960 }
961 }
962
963 fn raw_commit(msg: &str) -> Commit {
964 Commit {
965 sha: "a".repeat(40),
966 message: msg.into(),
967 }
968 }
969
970 fn make_strategy(
971 tags: Vec<TagInfo>,
972 commits: Vec<Commit>,
973 config: ReleaseConfig,
974 ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
975 {
976 let types = config.types.clone();
977 let breaking_section = config.breaking_section.clone();
978 let misc_section = config.misc_section.clone();
979 TrunkReleaseStrategy {
980 git: FakeGit::new(tags, commits),
981 vcs: FakeVcs::new(),
982 parser: DefaultCommitParser,
983 formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
984 config,
985 force: false,
986 }
987 }
988
989 #[test]
992 fn plan_no_commits_returns_error() {
993 let s = make_strategy(vec![], vec![], ReleaseConfig::default());
994 let err = s.plan().unwrap_err();
995 assert!(matches!(err, ReleaseError::NoCommits { .. }));
996 }
997
998 #[test]
999 fn plan_no_releasable_returns_error() {
1000 let s = make_strategy(
1001 vec![],
1002 vec![raw_commit("chore: tidy up")],
1003 ReleaseConfig::default(),
1004 );
1005 let err = s.plan().unwrap_err();
1006 assert!(matches!(err, ReleaseError::NoBump { .. }));
1007 }
1008
1009 #[test]
1010 fn force_releases_patch_when_no_releasable_commits() {
1011 let tag = TagInfo {
1012 name: "v1.2.3".into(),
1013 version: Version::new(1, 2, 3),
1014 sha: "d".repeat(40),
1015 };
1016 let mut s = make_strategy(
1017 vec![tag],
1018 vec![raw_commit("chore: rename package")],
1019 ReleaseConfig::default(),
1020 );
1021 s.force = true;
1022 let plan = s.plan().unwrap();
1023 assert_eq!(plan.next_version, Version::new(1, 2, 4));
1024 assert_eq!(plan.bump, BumpLevel::Patch);
1025 }
1026
1027 #[test]
1028 fn plan_first_release() {
1029 let s = make_strategy(
1030 vec![],
1031 vec![raw_commit("feat: initial feature")],
1032 ReleaseConfig::default(),
1033 );
1034 let plan = s.plan().unwrap();
1035 assert_eq!(plan.next_version, Version::new(0, 1, 0));
1036 assert_eq!(plan.tag_name, "v0.1.0");
1037 assert!(plan.current_version.is_none());
1038 }
1039
1040 #[test]
1041 fn plan_increments_existing() {
1042 let tag = TagInfo {
1043 name: "v1.2.3".into(),
1044 version: Version::new(1, 2, 3),
1045 sha: "b".repeat(40),
1046 };
1047 let s = make_strategy(
1048 vec![tag],
1049 vec![raw_commit("fix: patch bug")],
1050 ReleaseConfig::default(),
1051 );
1052 let plan = s.plan().unwrap();
1053 assert_eq!(plan.next_version, Version::new(1, 2, 4));
1054 }
1055
1056 #[test]
1057 fn plan_breaking_bump() {
1058 let tag = TagInfo {
1059 name: "v1.2.3".into(),
1060 version: Version::new(1, 2, 3),
1061 sha: "c".repeat(40),
1062 };
1063 let s = make_strategy(
1064 vec![tag],
1065 vec![raw_commit("feat!: breaking change")],
1066 ReleaseConfig::default(),
1067 );
1068 let plan = s.plan().unwrap();
1069 assert_eq!(plan.next_version, Version::new(2, 0, 0));
1070 }
1071
1072 #[test]
1073 fn plan_v0_breaking_downshifts_to_minor() {
1074 let tag = TagInfo {
1075 name: "v0.5.0".into(),
1076 version: Version::new(0, 5, 0),
1077 sha: "c".repeat(40),
1078 };
1079 let s = make_strategy(
1080 vec![tag],
1081 vec![raw_commit("feat!: breaking change")],
1082 ReleaseConfig::default(),
1083 );
1084 let plan = s.plan().unwrap();
1085 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1087 assert_eq!(plan.bump, BumpLevel::Minor);
1088 }
1089
1090 #[test]
1091 fn plan_v0_breaking_with_force_bumps_major() {
1092 let tag = TagInfo {
1093 name: "v0.5.0".into(),
1094 version: Version::new(0, 5, 0),
1095 sha: "c".repeat(40),
1096 };
1097 let mut s = make_strategy(
1098 vec![tag],
1099 vec![raw_commit("feat!: breaking change")],
1100 ReleaseConfig::default(),
1101 );
1102 s.force = true;
1103 let plan = s.plan().unwrap();
1104 assert_eq!(plan.next_version, Version::new(1, 0, 0));
1106 assert_eq!(plan.bump, BumpLevel::Major);
1107 }
1108
1109 #[test]
1110 fn plan_v0_feat_stays_minor() {
1111 let tag = TagInfo {
1112 name: "v0.5.0".into(),
1113 version: Version::new(0, 5, 0),
1114 sha: "c".repeat(40),
1115 };
1116 let s = make_strategy(
1117 vec![tag],
1118 vec![raw_commit("feat: new feature")],
1119 ReleaseConfig::default(),
1120 );
1121 let plan = s.plan().unwrap();
1122 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1124 assert_eq!(plan.bump, BumpLevel::Minor);
1125 }
1126
1127 #[test]
1128 fn plan_v0_fix_stays_patch() {
1129 let tag = TagInfo {
1130 name: "v0.5.0".into(),
1131 version: Version::new(0, 5, 0),
1132 sha: "c".repeat(40),
1133 };
1134 let s = make_strategy(
1135 vec![tag],
1136 vec![raw_commit("fix: bug fix")],
1137 ReleaseConfig::default(),
1138 );
1139 let plan = s.plan().unwrap();
1140 assert_eq!(plan.next_version, Version::new(0, 5, 1));
1142 assert_eq!(plan.bump, BumpLevel::Patch);
1143 }
1144
1145 #[test]
1148 fn execute_dry_run_no_side_effects() {
1149 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1150 let plan = s.plan().unwrap();
1151 s.execute(&plan, true).unwrap();
1152
1153 assert!(s.git.created_tags.lock().unwrap().is_empty());
1154 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1155 }
1156
1157 #[test]
1158 fn execute_creates_and_pushes_tag() {
1159 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1160 let plan = s.plan().unwrap();
1161 s.execute(&plan, false).unwrap();
1162
1163 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1164 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1165 }
1166
1167 #[test]
1168 fn execute_calls_vcs_create_release() {
1169 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1170 let plan = s.plan().unwrap();
1171 s.execute(&plan, false).unwrap();
1172
1173 let releases = s.vcs.releases.lock().unwrap();
1174 assert_eq!(releases.len(), 1);
1175 assert_eq!(releases[0].0, "v0.1.0");
1176 assert!(!releases[0].1.is_empty());
1177 }
1178
1179 #[test]
1180 fn execute_commits_changelog_before_tag() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let changelog_path = dir.path().join("CHANGELOG.md");
1183
1184 let config = ReleaseConfig {
1185 changelog: crate::config::ChangelogConfig {
1186 file: Some(changelog_path.to_str().unwrap().to_string()),
1187 ..Default::default()
1188 },
1189 ..Default::default()
1190 };
1191
1192 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1193 let plan = s.plan().unwrap();
1194 s.execute(&plan, false).unwrap();
1195
1196 let committed = s.git.committed.lock().unwrap();
1198 assert_eq!(committed.len(), 1);
1199 assert_eq!(
1200 committed[0].0,
1201 vec![changelog_path.to_str().unwrap().to_string()]
1202 );
1203 assert!(committed[0].1.contains("chore(release): v0.1.0"));
1204
1205 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1207 }
1208
1209 #[test]
1210 fn execute_skips_existing_tag() {
1211 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1212 let plan = s.plan().unwrap();
1213
1214 s.git
1216 .created_tags
1217 .lock()
1218 .unwrap()
1219 .push("v0.1.0".to_string());
1220
1221 s.execute(&plan, false).unwrap();
1222
1223 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1225 }
1226
1227 #[test]
1228 fn execute_skips_existing_release() {
1229 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1230 let plan = s.plan().unwrap();
1231
1232 s.vcs
1234 .releases
1235 .lock()
1236 .unwrap()
1237 .push(("v0.1.0".to_string(), "old notes".to_string()));
1238
1239 s.execute(&plan, false).unwrap();
1240
1241 let deleted = s.vcs.deleted_releases.lock().unwrap();
1243 assert!(deleted.is_empty(), "update should not delete");
1244
1245 let releases = s.vcs.releases.lock().unwrap();
1246 assert_eq!(releases.len(), 1);
1247 assert_eq!(releases[0].0, "v0.1.0");
1248 assert_ne!(releases[0].1, "old notes");
1249 }
1250
1251 #[test]
1252 fn execute_idempotent_rerun() {
1253 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1254 let plan = s.plan().unwrap();
1255
1256 s.execute(&plan, false).unwrap();
1258
1259 s.execute(&plan, false).unwrap();
1261
1262 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1264
1265 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1267
1268 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1270
1271 let deleted = s.vcs.deleted_releases.lock().unwrap();
1273 assert!(deleted.is_empty(), "update should not delete");
1274
1275 let releases = s.vcs.releases.lock().unwrap();
1276 assert_eq!(releases.len(), 1);
1277 assert_eq!(releases[0].0, "v0.1.0");
1278 }
1279
1280 #[test]
1281 fn execute_bumps_version_files() {
1282 let dir = tempfile::tempdir().unwrap();
1283 let cargo_path = dir.path().join("Cargo.toml");
1284 std::fs::write(
1285 &cargo_path,
1286 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1287 )
1288 .unwrap();
1289
1290 let config = ReleaseConfig {
1291 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1292 ..test_config()
1293 };
1294
1295 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1296 let plan = s.plan().unwrap();
1297 s.execute(&plan, false).unwrap();
1298
1299 let contents = std::fs::read_to_string(&cargo_path).unwrap();
1301 assert!(contents.contains("version = \"0.1.0\""));
1302
1303 let committed = s.git.committed.lock().unwrap();
1305 assert_eq!(committed.len(), 1);
1306 assert!(
1307 committed[0]
1308 .0
1309 .contains(&cargo_path.to_str().unwrap().to_string())
1310 );
1311 }
1312
1313 #[test]
1314 fn execute_stages_changelog_and_version_files_together() {
1315 let dir = tempfile::tempdir().unwrap();
1316 let cargo_path = dir.path().join("Cargo.toml");
1317 std::fs::write(
1318 &cargo_path,
1319 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1320 )
1321 .unwrap();
1322
1323 let changelog_path = dir.path().join("CHANGELOG.md");
1324
1325 let config = ReleaseConfig {
1326 changelog: crate::config::ChangelogConfig {
1327 file: Some(changelog_path.to_str().unwrap().to_string()),
1328 ..Default::default()
1329 },
1330 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1331 ..Default::default()
1332 };
1333
1334 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1335 let plan = s.plan().unwrap();
1336 s.execute(&plan, false).unwrap();
1337
1338 let committed = s.git.committed.lock().unwrap();
1340 assert_eq!(committed.len(), 1);
1341 assert!(
1342 committed[0]
1343 .0
1344 .contains(&changelog_path.to_str().unwrap().to_string())
1345 );
1346 assert!(
1347 committed[0]
1348 .0
1349 .contains(&cargo_path.to_str().unwrap().to_string())
1350 );
1351 }
1352
1353 #[test]
1356 fn execute_uploads_artifacts() {
1357 let dir = tempfile::tempdir().unwrap();
1358 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1359 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1360
1361 let config = ReleaseConfig {
1362 artifacts: vec![
1363 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1364 dir.path().join("*.zip").to_str().unwrap().to_string(),
1365 ],
1366 ..test_config()
1367 };
1368
1369 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1370 let plan = s.plan().unwrap();
1371 s.execute(&plan, false).unwrap();
1372
1373 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1374 assert_eq!(uploaded.len(), 1);
1375 assert_eq!(uploaded[0].0, "v0.1.0");
1376 assert_eq!(uploaded[0].1.len(), 2);
1377 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1378 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1379 }
1380
1381 #[test]
1382 fn execute_dry_run_shows_artifacts() {
1383 let dir = tempfile::tempdir().unwrap();
1384 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1385
1386 let config = ReleaseConfig {
1387 artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1388 ..test_config()
1389 };
1390
1391 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1392 let plan = s.plan().unwrap();
1393 s.execute(&plan, true).unwrap();
1394
1395 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1397 assert!(uploaded.is_empty());
1398 }
1399
1400 #[test]
1401 fn execute_no_artifacts_skips_upload() {
1402 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1403 let plan = s.plan().unwrap();
1404 s.execute(&plan, false).unwrap();
1405
1406 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1407 assert!(uploaded.is_empty());
1408 }
1409
1410 #[test]
1411 fn resolve_globs_basic() {
1412 let dir = tempfile::tempdir().unwrap();
1413 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1414 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1415 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1416
1417 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1418 let result = resolve_globs(&[pattern]).unwrap();
1419 assert_eq!(result.len(), 2);
1420 assert!(result.iter().any(|f: &String| f.ends_with("a.txt")));
1421 assert!(result.iter().any(|f: &String| f.ends_with("b.txt")));
1422 }
1423
1424 #[test]
1425 fn resolve_globs_deduplicates() {
1426 let dir = tempfile::tempdir().unwrap();
1427 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1428
1429 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1430 let result = resolve_globs(&[pattern.clone(), pattern]).unwrap();
1432 assert_eq!(result.len(), 1);
1433 }
1434
1435 #[test]
1438 fn plan_floating_tag_when_enabled() {
1439 let tag = TagInfo {
1440 name: "v3.2.0".into(),
1441 version: Version::new(3, 2, 0),
1442 sha: "d".repeat(40),
1443 };
1444 let config = ReleaseConfig {
1445 floating_tags: true,
1446 ..Default::default()
1447 };
1448
1449 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1450 let plan = s.plan().unwrap();
1451 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1452 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1453 }
1454
1455 #[test]
1456 fn plan_no_floating_tag_when_disabled() {
1457 let s = make_strategy(
1458 vec![],
1459 vec![raw_commit("feat: something")],
1460 ReleaseConfig {
1461 floating_tags: false,
1462 ..Default::default()
1463 },
1464 );
1465 let plan = s.plan().unwrap();
1466 assert!(plan.floating_tag_name.is_none());
1467 }
1468
1469 #[test]
1470 fn plan_floating_tag_custom_prefix() {
1471 let tag = TagInfo {
1472 name: "release-2.5.0".into(),
1473 version: Version::new(2, 5, 0),
1474 sha: "e".repeat(40),
1475 };
1476 let config = ReleaseConfig {
1477 floating_tags: true,
1478 tag_prefix: "release-".into(),
1479 ..Default::default()
1480 };
1481
1482 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1483 let plan = s.plan().unwrap();
1484 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1485 }
1486
1487 #[test]
1488 fn execute_floating_tags_force_create_and_push() {
1489 let config = ReleaseConfig {
1490 floating_tags: true,
1491 ..test_config()
1492 };
1493
1494 let tag = TagInfo {
1495 name: "v1.2.3".into(),
1496 version: Version::new(1, 2, 3),
1497 sha: "f".repeat(40),
1498 };
1499 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1500 let plan = s.plan().unwrap();
1501 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1502
1503 s.execute(&plan, false).unwrap();
1504
1505 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1506 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1507 }
1508
1509 #[test]
1510 fn execute_no_floating_tags_when_disabled() {
1511 let s = make_strategy(
1512 vec![],
1513 vec![raw_commit("feat: something")],
1514 ReleaseConfig {
1515 floating_tags: false,
1516 ..test_config()
1517 },
1518 );
1519 let plan = s.plan().unwrap();
1520 assert!(plan.floating_tag_name.is_none());
1521
1522 s.execute(&plan, false).unwrap();
1523
1524 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1525 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1526 }
1527
1528 #[test]
1529 fn execute_floating_tags_dry_run_no_side_effects() {
1530 let config = ReleaseConfig {
1531 floating_tags: true,
1532 ..test_config()
1533 };
1534
1535 let tag = TagInfo {
1536 name: "v2.0.0".into(),
1537 version: Version::new(2, 0, 0),
1538 sha: "a".repeat(40),
1539 };
1540 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1541 let plan = s.plan().unwrap();
1542 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1543
1544 s.execute(&plan, true).unwrap();
1545
1546 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1547 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1548 }
1549
1550 #[test]
1551 fn execute_floating_tags_idempotent() {
1552 let config = ReleaseConfig {
1553 floating_tags: true,
1554 ..test_config()
1555 };
1556
1557 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1558 let plan = s.plan().unwrap();
1559 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1560
1561 s.execute(&plan, false).unwrap();
1563 s.execute(&plan, false).unwrap();
1564
1565 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1567 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1568 }
1569
1570 #[test]
1573 fn force_rerelease_when_tag_at_head() {
1574 let tag = TagInfo {
1575 name: "v1.2.3".into(),
1576 version: Version::new(1, 2, 3),
1577 sha: "a".repeat(40),
1578 };
1579 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1580 s.git.head = "a".repeat(40);
1582 s.force = true;
1583
1584 let plan = s.plan().unwrap();
1585 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1586 assert_eq!(plan.tag_name, "v1.2.3");
1587 assert!(plan.commits.is_empty());
1588 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1589 }
1590
1591 #[test]
1592 fn force_fails_when_tag_not_at_head() {
1593 let tag = TagInfo {
1594 name: "v1.2.3".into(),
1595 version: Version::new(1, 2, 3),
1596 sha: "a".repeat(40),
1597 };
1598 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1599 s.git.head = "b".repeat(40);
1601 s.force = true;
1602
1603 let err = s.plan().unwrap_err();
1604 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1605 }
1606
1607 #[test]
1610 fn execute_runs_build_command_after_version_bump() {
1611 let dir = tempfile::tempdir().unwrap();
1612 let output_file = dir.path().join("sr_test_version");
1613
1614 let config = ReleaseConfig {
1615 build_command: Some(format!(
1616 "echo $SR_VERSION > {}",
1617 output_file.to_str().unwrap()
1618 )),
1619 ..test_config()
1620 };
1621
1622 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1623 let plan = s.plan().unwrap();
1624 s.execute(&plan, false).unwrap();
1625
1626 let contents = std::fs::read_to_string(&output_file).unwrap();
1627 assert_eq!(contents.trim(), "0.1.0");
1628 }
1629
1630 #[test]
1631 fn execute_build_command_failure_aborts_release() {
1632 let config = ReleaseConfig {
1633 build_command: Some("exit 1".into()),
1634 ..test_config()
1635 };
1636
1637 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1638 let plan = s.plan().unwrap();
1639 let result = s.execute(&plan, false);
1640
1641 assert!(result.is_err());
1642 assert!(s.git.created_tags.lock().unwrap().is_empty());
1643 }
1644
1645 #[test]
1646 fn execute_dry_run_skips_build_command() {
1647 let dir = tempfile::tempdir().unwrap();
1648 let output_file = dir.path().join("sr_test_should_not_exist");
1649
1650 let config = ReleaseConfig {
1651 build_command: Some(format!("echo test > {}", output_file.to_str().unwrap())),
1652 ..test_config()
1653 };
1654
1655 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1656 let plan = s.plan().unwrap();
1657 s.execute(&plan, true).unwrap();
1658
1659 assert!(!output_file.exists());
1660 }
1661
1662 #[test]
1663 fn force_fails_with_no_tags() {
1664 let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1665 s.force = true;
1666
1667 let err = s.plan().unwrap_err();
1668 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1669 }
1670
1671 #[test]
1674 fn execute_stages_extra_files() {
1675 let dir = tempfile::tempdir().unwrap();
1676 let lock_file = dir.path().join("Cargo.lock");
1677 std::fs::write(&lock_file, "old lock").unwrap();
1678
1679 let config = ReleaseConfig {
1680 build_command: Some(format!("echo 'new lock' > {}", lock_file.to_str().unwrap())),
1681 stage_files: vec![lock_file.to_str().unwrap().to_string()],
1682 ..test_config()
1683 };
1684
1685 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1686 let plan = s.plan().unwrap();
1687 s.execute(&plan, false).unwrap();
1688
1689 let committed = s.git.committed.lock().unwrap();
1690 assert!(!committed.is_empty());
1691 let (staged, _) = &committed[0];
1692 assert!(
1693 staged.iter().any(|f| f.contains("Cargo.lock")),
1694 "Cargo.lock should be staged, got: {staged:?}"
1695 );
1696 }
1697
1698 #[test]
1699 fn execute_dry_run_shows_stage_files() {
1700 let config = ReleaseConfig {
1701 stage_files: vec!["Cargo.lock".into()],
1702 ..test_config()
1703 };
1704
1705 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1706 let plan = s.plan().unwrap();
1707 s.execute(&plan, true).unwrap();
1709 }
1710
1711 #[test]
1714 fn execute_build_failure_restores_version_files() {
1715 let dir = tempfile::tempdir().unwrap();
1716 let cargo_toml = dir.path().join("Cargo.toml");
1717 std::fs::write(
1718 &cargo_toml,
1719 "[package]\nname = \"test\"\nversion = \"1.0.0\"\n",
1720 )
1721 .unwrap();
1722
1723 let config = ReleaseConfig {
1724 version_files: vec![cargo_toml.to_str().unwrap().to_string()],
1725 build_command: Some("exit 1".into()),
1726 ..test_config()
1727 };
1728
1729 let tag = TagInfo {
1730 name: "v1.0.0".into(),
1731 version: Version::new(1, 0, 0),
1732 sha: "d".repeat(40),
1733 };
1734 let s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
1735 let plan = s.plan().unwrap();
1736 let result = s.execute(&plan, false);
1737
1738 assert!(result.is_err());
1739 let contents = std::fs::read_to_string(&cargo_toml).unwrap();
1741 assert!(
1742 contents.contains("version = \"1.0.0\""),
1743 "version should be restored, got: {contents}"
1744 );
1745 }
1746
1747 #[test]
1750 fn execute_pre_release_command_runs() {
1751 let dir = tempfile::tempdir().unwrap();
1752 let marker = dir.path().join("pre_release_ran");
1753
1754 let config = ReleaseConfig {
1755 pre_release_command: Some(format!("touch {}", marker.to_str().unwrap())),
1756 ..test_config()
1757 };
1758
1759 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1760 let plan = s.plan().unwrap();
1761 s.execute(&plan, false).unwrap();
1762
1763 assert!(marker.exists(), "pre-release command should have run");
1764 }
1765
1766 #[test]
1767 fn execute_post_release_command_runs() {
1768 let dir = tempfile::tempdir().unwrap();
1769 let marker = dir.path().join("post_release_ran");
1770
1771 let config = ReleaseConfig {
1772 post_release_command: Some(format!("touch {}", marker.to_str().unwrap())),
1773 ..test_config()
1774 };
1775
1776 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1777 let plan = s.plan().unwrap();
1778 s.execute(&plan, false).unwrap();
1779
1780 assert!(marker.exists(), "post-release command should have run");
1781 }
1782
1783 #[test]
1784 fn execute_pre_release_failure_aborts_release() {
1785 let config = ReleaseConfig {
1786 pre_release_command: Some("exit 1".into()),
1787 ..test_config()
1788 };
1789
1790 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1791 let plan = s.plan().unwrap();
1792 let result = s.execute(&plan, false);
1793
1794 assert!(result.is_err());
1795 assert!(s.git.created_tags.lock().unwrap().is_empty());
1797 assert!(s.git.committed.lock().unwrap().is_empty());
1798 }
1799
1800 #[test]
1801 fn execute_hooks_receive_version_env_vars() {
1802 let dir = tempfile::tempdir().unwrap();
1803 let output_file = dir.path().join("hook_output");
1804
1805 let config = ReleaseConfig {
1806 post_release_command: Some(format!(
1807 "echo $SR_VERSION $SR_TAG > {}",
1808 output_file.to_str().unwrap()
1809 )),
1810 ..test_config()
1811 };
1812
1813 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1814 let plan = s.plan().unwrap();
1815 s.execute(&plan, false).unwrap();
1816
1817 let contents = std::fs::read_to_string(&output_file).unwrap();
1818 assert!(contents.contains("0.1.0"), "SR_VERSION should be set");
1819 assert!(contents.contains("v0.1.0"), "SR_TAG should be set");
1820 }
1821
1822 #[test]
1823 fn execute_dry_run_skips_hooks() {
1824 let dir = tempfile::tempdir().unwrap();
1825 let pre_marker = dir.path().join("pre_hook");
1826 let post_marker = dir.path().join("post_hook");
1827
1828 let config = ReleaseConfig {
1829 pre_release_command: Some(format!("touch {}", pre_marker.to_str().unwrap())),
1830 post_release_command: Some(format!("touch {}", post_marker.to_str().unwrap())),
1831 ..test_config()
1832 };
1833
1834 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1835 let plan = s.plan().unwrap();
1836 s.execute(&plan, true).unwrap();
1837
1838 assert!(
1839 !pre_marker.exists(),
1840 "pre-release hook should not run in dry-run"
1841 );
1842 assert!(
1843 !post_marker.exists(),
1844 "post-release hook should not run in dry-run"
1845 );
1846 }
1847
1848 #[test]
1851 fn plan_prerelease_first_release() {
1852 let config = ReleaseConfig {
1853 prerelease: Some("alpha".into()),
1854 ..Default::default()
1855 };
1856
1857 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1858 let plan = s.plan().unwrap();
1859 assert_eq!(plan.next_version.to_string(), "0.1.0-alpha.1");
1860 assert_eq!(plan.tag_name, "v0.1.0-alpha.1");
1861 assert!(plan.prerelease);
1862 }
1863
1864 #[test]
1865 fn plan_prerelease_increments_from_stable() {
1866 let tag = TagInfo {
1867 name: "v1.0.0".into(),
1868 version: Version::new(1, 0, 0),
1869 sha: "d".repeat(40),
1870 };
1871 let config = ReleaseConfig {
1872 prerelease: Some("beta".into()),
1873 ..Default::default()
1874 };
1875
1876 let s = make_strategy(vec![tag], vec![raw_commit("feat: new feature")], config);
1877 let plan = s.plan().unwrap();
1878 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1879 assert!(plan.prerelease);
1880 }
1881
1882 #[test]
1883 fn plan_prerelease_increments_counter() {
1884 let tags = vec![
1885 TagInfo {
1886 name: "v1.0.0".into(),
1887 version: Version::new(1, 0, 0),
1888 sha: "a".repeat(40),
1889 },
1890 TagInfo {
1891 name: "v1.1.0-alpha.1".into(),
1892 version: Version::parse("1.1.0-alpha.1").unwrap(),
1893 sha: "b".repeat(40),
1894 },
1895 TagInfo {
1896 name: "v1.1.0-alpha.2".into(),
1897 version: Version::parse("1.1.0-alpha.2").unwrap(),
1898 sha: "c".repeat(40),
1899 },
1900 ];
1901 let config = ReleaseConfig {
1902 prerelease: Some("alpha".into()),
1903 ..Default::default()
1904 };
1905
1906 let s = make_strategy(tags, vec![raw_commit("feat: another")], config);
1907 let plan = s.plan().unwrap();
1908 assert_eq!(plan.next_version.to_string(), "1.1.0-alpha.3");
1909 }
1910
1911 #[test]
1912 fn plan_prerelease_different_id_starts_at_1() {
1913 let tags = vec![
1914 TagInfo {
1915 name: "v1.0.0".into(),
1916 version: Version::new(1, 0, 0),
1917 sha: "a".repeat(40),
1918 },
1919 TagInfo {
1920 name: "v1.1.0-alpha.3".into(),
1921 version: Version::parse("1.1.0-alpha.3").unwrap(),
1922 sha: "b".repeat(40),
1923 },
1924 ];
1925 let config = ReleaseConfig {
1926 prerelease: Some("beta".into()),
1927 ..Default::default()
1928 };
1929
1930 let s = make_strategy(tags, vec![raw_commit("feat: something")], config);
1931 let plan = s.plan().unwrap();
1932 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1933 }
1934
1935 #[test]
1936 fn plan_prerelease_no_floating_tags() {
1937 let config = ReleaseConfig {
1938 prerelease: Some("rc".into()),
1939 floating_tags: true,
1940 ..Default::default()
1941 };
1942
1943 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1944 let plan = s.plan().unwrap();
1945 assert!(
1946 plan.floating_tag_name.is_none(),
1947 "pre-releases should not create floating tags"
1948 );
1949 }
1950
1951 #[test]
1952 fn plan_stable_skips_prerelease_tags() {
1953 let tags = vec![
1954 TagInfo {
1955 name: "v1.0.0".into(),
1956 version: Version::new(1, 0, 0),
1957 sha: "a".repeat(40),
1958 },
1959 TagInfo {
1960 name: "v1.1.0-alpha.1".into(),
1961 version: Version::parse("1.1.0-alpha.1").unwrap(),
1962 sha: "b".repeat(40),
1963 },
1964 ];
1965 let s = make_strategy(
1967 tags,
1968 vec![raw_commit("feat: something")],
1969 ReleaseConfig::default(),
1970 );
1971 let plan = s.plan().unwrap();
1972 assert_eq!(plan.next_version, Version::new(1, 1, 0));
1974 assert!(!plan.prerelease);
1975 }
1976
1977 #[test]
1978 fn plan_prerelease_marks_plan_as_prerelease() {
1979 let config = ReleaseConfig {
1980 prerelease: Some("alpha".into()),
1981 ..Default::default()
1982 };
1983
1984 let s = make_strategy(vec![], vec![raw_commit("fix: bug")], config);
1985 let plan = s.plan().unwrap();
1986 assert!(plan.prerelease);
1987 assert!(plan.next_version.to_string().contains("alpha"));
1988 }
1989
1990 #[test]
1993 fn plan_with_path_filter_uses_filtered_commits() {
1994 let config = ReleaseConfig {
1995 path_filter: Some("crates/core".into()),
1996 ..Default::default()
1997 };
1998
1999 let mut s = make_strategy(
2001 vec![],
2002 vec![raw_commit("feat: big feature"), raw_commit("fix: patch")],
2003 config,
2004 );
2005 s.git.path_commits = Some(vec![raw_commit("fix: patch only in core")]);
2006
2007 let plan = s.plan().unwrap();
2008 assert_eq!(plan.bump, BumpLevel::Patch);
2010 assert_eq!(plan.commits.len(), 1);
2011 assert_eq!(plan.commits[0].description, "patch only in core");
2012 }
2013
2014 #[test]
2015 fn plan_without_path_filter_uses_all_commits() {
2016 let config = ReleaseConfig::default();
2017
2018 let mut s = make_strategy(vec![], vec![raw_commit("feat: big feature")], config);
2019 s.git.path_commits = Some(vec![raw_commit("fix: filtered")]);
2020
2021 let plan = s.plan().unwrap();
2022 assert_eq!(plan.bump, BumpLevel::Minor);
2024 }
2025
2026 #[test]
2027 fn plan_with_path_filter_no_commits_returns_error() {
2028 let config = ReleaseConfig {
2029 path_filter: Some("crates/core".into()),
2030 ..Default::default()
2031 };
2032
2033 let mut s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
2034 s.git.path_commits = Some(vec![]);
2035
2036 let err = s.plan().unwrap_err();
2037 assert!(matches!(err, ReleaseError::NoCommits { .. }));
2038 }
2039
2040 #[test]
2041 fn plan_with_path_filter_custom_tag_prefix() {
2042 let config = ReleaseConfig {
2043 path_filter: Some("crates/core".into()),
2044 tag_prefix: "core/v".into(),
2045 ..Default::default()
2046 };
2047
2048 let tag = TagInfo {
2049 name: "core/v1.0.0".into(),
2050 version: Version::new(1, 0, 0),
2051 sha: "a".repeat(40),
2052 };
2053 let mut s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
2054 s.git.path_commits = Some(vec![raw_commit("fix: core bug")]);
2055
2056 let plan = s.plan().unwrap();
2057 assert_eq!(plan.tag_name, "core/v1.0.1");
2058 assert_eq!(plan.current_version, Some(Version::new(1, 0, 0)));
2059 }
2060}