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;
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 ) -> Result<String, ReleaseError>;
46
47 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
49
50 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
52
53 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
55
56 fn repo_url(&self) -> Option<String> {
58 None
59 }
60
61 fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
63 Ok(())
64 }
65}
66
67pub struct TrunkReleaseStrategy<G, V, C, F> {
69 pub git: G,
70 pub vcs: Option<V>,
71 pub parser: C,
72 pub formatter: F,
73 pub config: ReleaseConfig,
74 pub force: bool,
76}
77
78impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
79where
80 G: GitRepository,
81 V: VcsProvider,
82 C: CommitParser,
83 F: ChangelogFormatter,
84{
85 fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
86 let today = today_string();
87 let entry = ChangelogEntry {
88 version: plan.next_version.to_string(),
89 date: today,
90 commits: plan.commits.clone(),
91 compare_url: None,
92 repo_url: self.vcs.as_ref().and_then(|v| v.repo_url()),
93 };
94 self.formatter.format(&[entry])
95 }
96}
97
98impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
99where
100 G: GitRepository,
101 V: VcsProvider,
102 C: CommitParser,
103 F: ChangelogFormatter,
104{
105 fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
106 let is_prerelease = self.config.prerelease.is_some();
107
108 let all_tags = self.git.all_tags(&self.config.tag_prefix)?;
111 let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
112 let latest_any = all_tags.last();
113
114 let tag_info = if is_prerelease {
116 latest_any
117 } else {
118 latest_stable.or(latest_any)
119 };
120
121 let (current_version, from_sha) = match tag_info {
122 Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
123 None => (None, None),
124 };
125
126 let raw_commits = self.git.commits_since(from_sha)?;
127 if raw_commits.is_empty() {
128 if self.force
130 && let Some(info) = tag_info
131 {
132 let head = self.git.head_sha()?;
133 if head == info.sha {
134 let floating_tag_name = if self.config.floating_tags {
135 Some(format!("{}{}", self.config.tag_prefix, info.version.major))
136 } else {
137 None
138 };
139 return Ok(ReleasePlan {
140 current_version: Some(info.version.clone()),
141 next_version: info.version.clone(),
142 bump: BumpLevel::Patch,
143 commits: vec![],
144 tag_name: info.name.clone(),
145 floating_tag_name,
146 prerelease: is_prerelease,
147 });
148 }
149 }
150 let (tag, sha) = match tag_info {
151 Some(info) => (info.name.clone(), info.sha.clone()),
152 None => ("(none)".into(), "(none)".into()),
153 };
154 return Err(ReleaseError::NoCommits { tag, sha });
155 }
156
157 let conventional_commits: Vec<ConventionalCommit> = raw_commits
158 .iter()
159 .filter_map(|c| self.parser.parse(c).ok())
160 .collect();
161
162 let classifier = DefaultCommitClassifier::new(
163 self.config.types.clone(),
164 self.config.commit_pattern.clone(),
165 );
166 let tag_for_err = tag_info
167 .map(|i| i.name.clone())
168 .unwrap_or_else(|| "(none)".into());
169 let commit_count = conventional_commits.len();
170 let bump =
171 determine_bump(&conventional_commits, &classifier).ok_or(ReleaseError::NoBump {
172 tag: tag_for_err,
173 commit_count,
174 })?;
175
176 let base_version = if is_prerelease {
178 latest_stable
179 .map(|t| t.version.clone())
180 .or(current_version.clone())
181 .unwrap_or(Version::new(0, 0, 0))
182 } else {
183 current_version.clone().unwrap_or(Version::new(0, 0, 0))
184 };
185
186 let next_version = if let Some(ref prerelease_id) = self.config.prerelease {
187 let existing_versions: Vec<Version> =
188 all_tags.iter().map(|t| t.version.clone()).collect();
189 apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
190 } else {
191 apply_bump(&base_version, bump)
192 };
193
194 let tag_name = format!("{}{next_version}", self.config.tag_prefix);
195
196 let floating_tag_name = if self.config.floating_tags && !is_prerelease {
198 Some(format!("{}{}", self.config.tag_prefix, next_version.major))
199 } else {
200 None
201 };
202
203 Ok(ReleasePlan {
204 current_version,
205 next_version,
206 bump,
207 commits: conventional_commits,
208 tag_name,
209 floating_tag_name,
210 prerelease: is_prerelease,
211 })
212 }
213
214 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
215 let version_str = plan.next_version.to_string();
216
217 if dry_run {
218 let changelog_body = self.format_changelog(plan)?;
219 if let Some(ref cmd) = self.config.pre_release_command {
220 eprintln!("[dry-run] Would run pre-release command: {cmd}");
221 }
222 eprintln!("[dry-run] Would create tag: {}", plan.tag_name);
223 eprintln!("[dry-run] Would push tag: {}", plan.tag_name);
224 if let Some(ref floating) = plan.floating_tag_name {
225 eprintln!("[dry-run] Would create/update floating tag: {floating}");
226 eprintln!("[dry-run] Would force-push floating tag: {floating}");
227 }
228 if self.vcs.is_some() {
229 eprintln!(
230 "[dry-run] Would create GitHub release for {}",
231 plan.tag_name
232 );
233 }
234 for file in &self.config.version_files {
235 let filename = Path::new(file)
236 .file_name()
237 .and_then(|n| n.to_str())
238 .unwrap_or_default();
239 let supported = matches!(
240 filename,
241 "Cargo.toml"
242 | "package.json"
243 | "pyproject.toml"
244 | "pom.xml"
245 | "build.gradle"
246 | "build.gradle.kts"
247 ) || filename.ends_with(".go");
248 if supported {
249 eprintln!("[dry-run] Would bump version in: {file}");
250 } else if self.config.version_files_strict {
251 return Err(ReleaseError::VersionBump(format!(
252 "unsupported version file: {filename}"
253 )));
254 } else {
255 eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
256 }
257 }
258 if !self.config.artifacts.is_empty() {
259 let resolved = resolve_artifact_globs(&self.config.artifacts)?;
260 if resolved.is_empty() {
261 eprintln!("[dry-run] Artifact patterns matched no files");
262 } else {
263 eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
264 for f in &resolved {
265 eprintln!("[dry-run] {f}");
266 }
267 }
268 }
269 if let Some(ref cmd) = self.config.build_command {
270 eprintln!("[dry-run] Would run build command: {cmd}");
271 }
272 if !self.config.stage_files.is_empty() {
273 eprintln!(
274 "[dry-run] Would stage additional files: {}",
275 self.config.stage_files.join(", ")
276 );
277 }
278 if let Some(ref cmd) = self.config.post_release_command {
279 eprintln!("[dry-run] Would run post-release command: {cmd}");
280 }
281 eprintln!("[dry-run] Changelog:\n{changelog_body}");
282 return Ok(());
283 }
284
285 if let Some(ref cmd) = self.config.pre_release_command {
287 eprintln!("Running pre-release command: {cmd}");
288 run_hook(cmd, &version_str, &plan.tag_name, "pre_release_command")?;
289 }
290
291 let changelog_body = self.format_changelog(plan)?;
293
294 let mut file_snapshots: Vec<(String, Option<String>)> = Vec::new();
296 for file in &self.config.version_files {
297 let path = Path::new(file);
298 let contents = if path.exists() {
299 Some(
300 fs::read_to_string(path)
301 .map_err(|e| ReleaseError::VersionBump(e.to_string()))?,
302 )
303 } else {
304 None
305 };
306 file_snapshots.push((file.clone(), contents));
307 }
308 if let Some(ref changelog_file) = self.config.changelog.file {
309 let path = Path::new(changelog_file);
310 let contents = if path.exists() {
311 Some(fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?)
312 } else {
313 None
314 };
315 file_snapshots.push((changelog_file.clone(), contents));
316 }
317
318 let bumped_files =
320 match self.execute_pre_commit(plan, &version_str, &changelog_body, &file_snapshots) {
321 Ok(files) => files,
322 Err(e) => {
323 eprintln!("error during pre-commit steps, restoring files...");
324 restore_snapshots(&file_snapshots);
325 return Err(e);
326 }
327 };
328
329 {
331 let mut paths_to_stage: Vec<String> = Vec::new();
332 if let Some(ref changelog_file) = self.config.changelog.file {
333 paths_to_stage.push(changelog_file.clone());
334 }
335 for file in &bumped_files {
336 paths_to_stage.push(file.clone());
337 }
338 if !self.config.stage_files.is_empty() {
339 let extra = resolve_glob_patterns(&self.config.stage_files)?;
340 paths_to_stage.extend(extra);
341 }
342 if !paths_to_stage.is_empty() {
343 let refs: Vec<&str> = paths_to_stage.iter().map(|s| s.as_str()).collect();
344 let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
345 self.git.stage_and_commit(&refs, &commit_msg)?;
346 }
347 }
348
349 if !self.git.tag_exists(&plan.tag_name)? {
351 self.git.create_tag(&plan.tag_name, &changelog_body)?;
352 }
353
354 self.git.push()?;
356
357 if !self.git.remote_tag_exists(&plan.tag_name)? {
359 self.git.push_tag(&plan.tag_name)?;
360 }
361
362 if let Some(ref floating) = plan.floating_tag_name {
364 let floating_msg = format!("Floating tag for {}", plan.tag_name);
365 self.git.force_create_tag(floating, &floating_msg)?;
366 self.git.force_push_tag(floating)?;
367 }
368
369 if let Some(ref vcs) = self.vcs {
371 let release_name = format!("{} {}", self.config.tag_prefix, plan.next_version);
372 if vcs.release_exists(&plan.tag_name)? {
373 vcs.delete_release(&plan.tag_name)?;
375 }
376 vcs.create_release(
377 &plan.tag_name,
378 &release_name,
379 &changelog_body,
380 plan.prerelease,
381 )?;
382 }
383
384 if let Some(ref vcs) = self.vcs
386 && !self.config.artifacts.is_empty()
387 {
388 let resolved = resolve_artifact_globs(&self.config.artifacts)?;
389 if !resolved.is_empty() {
390 let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
391 vcs.upload_assets(&plan.tag_name, &file_refs)?;
392 eprintln!(
393 "Uploaded {} artifact(s) to {}",
394 resolved.len(),
395 plan.tag_name
396 );
397 }
398 }
399
400 if let Some(ref cmd) = self.config.post_release_command {
402 eprintln!("Running post-release command: {cmd}");
403 run_hook(cmd, &version_str, &plan.tag_name, "post_release_command")?;
404 }
405
406 eprintln!("Released {}", plan.tag_name);
407 Ok(())
408 }
409}
410
411impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
412where
413 G: GitRepository,
414 V: VcsProvider,
415 C: CommitParser,
416 F: ChangelogFormatter,
417{
418 fn execute_pre_commit(
421 &self,
422 plan: &ReleasePlan,
423 version_str: &str,
424 changelog_body: &str,
425 _snapshots: &[(String, Option<String>)],
426 ) -> Result<Vec<String>, ReleaseError> {
427 let mut bumped_files: Vec<String> = Vec::new();
429 for file in &self.config.version_files {
430 match bump_version_file(Path::new(file), version_str) {
431 Ok(()) => bumped_files.push(file.clone()),
432 Err(e) if !self.config.version_files_strict => {
433 eprintln!("warning: {e} — skipping {file}");
434 }
435 Err(e) => return Err(e),
436 }
437 }
438
439 if let Some(ref changelog_file) = self.config.changelog.file {
441 let path = Path::new(changelog_file);
442 let existing = if path.exists() {
443 fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
444 } else {
445 String::new()
446 };
447 let new_content = if existing.is_empty() {
448 format!("# Changelog\n\n{changelog_body}\n")
449 } else {
450 match existing.find("\n\n") {
451 Some(pos) => {
452 let (header, rest) = existing.split_at(pos);
453 format!("{header}\n\n{changelog_body}\n{rest}")
454 }
455 None => format!("{existing}\n\n{changelog_body}\n"),
456 }
457 };
458 fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
459 }
460
461 if let Some(ref cmd) = self.config.build_command {
463 eprintln!("Running build command: {cmd}");
464 run_hook(cmd, version_str, &plan.tag_name, "build_command")?;
465 }
466
467 Ok(bumped_files)
468 }
469}
470
471fn restore_snapshots(snapshots: &[(String, Option<String>)]) {
473 for (file, contents) in snapshots {
474 let path = Path::new(file);
475 match contents {
476 Some(data) => {
477 if let Err(e) = fs::write(path, data) {
478 eprintln!("warning: failed to restore {file}: {e}");
479 }
480 }
481 None => {
482 if path.exists()
484 && let Err(e) = fs::remove_file(path)
485 {
486 eprintln!("warning: failed to remove {file}: {e}");
487 }
488 }
489 }
490 }
491}
492
493fn run_hook(cmd: &str, version: &str, tag: &str, label: &str) -> Result<(), ReleaseError> {
495 let status = std::process::Command::new("sh")
496 .args(["-c", cmd])
497 .env("SR_VERSION", version)
498 .env("SR_TAG", tag)
499 .status()
500 .map_err(|e| ReleaseError::BuildCommand(format!("{label}: {e}")))?;
501 if !status.success() {
502 return Err(ReleaseError::BuildCommand(format!(
503 "{label} exited with {}",
504 status.code().unwrap_or(-1)
505 )));
506 }
507 Ok(())
508}
509
510fn resolve_glob_patterns(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
512 let mut files = Vec::new();
513 for pattern in patterns {
514 let paths = glob::glob(pattern)
515 .map_err(|e| ReleaseError::Config(format!("invalid glob pattern '{pattern}': {e}")))?;
516 for entry in paths {
517 match entry {
518 Ok(path) if path.is_file() => {
519 files.push(path.to_string_lossy().into_owned());
520 }
521 Ok(_) => {}
522 Err(e) => {
523 eprintln!("warning: glob error: {e}");
524 }
525 }
526 }
527 }
528 Ok(files)
529}
530
531fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
532 let mut files = std::collections::BTreeSet::new();
533 for pattern in patterns {
534 let paths = glob::glob(pattern)
535 .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
536 for entry in paths {
537 match entry {
538 Ok(path) if path.is_file() => {
539 files.insert(path.to_string_lossy().into_owned());
540 }
541 Ok(_) => {} Err(e) => {
543 eprintln!("warning: glob error: {e}");
544 }
545 }
546 }
547 }
548 Ok(files.into_iter().collect())
549}
550
551pub fn today_string() -> String {
552 std::process::Command::new("date")
554 .arg("+%Y-%m-%d")
555 .output()
556 .ok()
557 .and_then(|o| {
558 if o.status.success() {
559 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
560 } else {
561 None
562 }
563 })
564 .unwrap_or_else(|| "unknown".to_string())
565}
566
567#[cfg(test)]
568mod tests {
569 use std::sync::Mutex;
570
571 use super::*;
572 use crate::changelog::DefaultChangelogFormatter;
573 use crate::commit::{Commit, DefaultCommitParser};
574 use crate::config::ReleaseConfig;
575 use crate::git::{GitRepository, TagInfo};
576
577 struct FakeGit {
580 tags: Vec<TagInfo>,
581 commits: Vec<Commit>,
582 head: String,
583 created_tags: Mutex<Vec<String>>,
584 pushed_tags: Mutex<Vec<String>>,
585 committed: Mutex<Vec<(Vec<String>, String)>>,
586 push_count: Mutex<u32>,
587 force_created_tags: Mutex<Vec<String>>,
588 force_pushed_tags: Mutex<Vec<String>>,
589 }
590
591 impl FakeGit {
592 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
593 let head = tags
594 .last()
595 .map(|t| t.sha.clone())
596 .unwrap_or_else(|| "0".repeat(40));
597 Self {
598 tags,
599 commits,
600 head,
601 created_tags: Mutex::new(Vec::new()),
602 pushed_tags: Mutex::new(Vec::new()),
603 committed: Mutex::new(Vec::new()),
604 push_count: Mutex::new(0),
605 force_created_tags: Mutex::new(Vec::new()),
606 force_pushed_tags: Mutex::new(Vec::new()),
607 }
608 }
609 }
610
611 impl GitRepository for FakeGit {
612 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
613 Ok(self.tags.last().cloned())
614 }
615
616 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
617 Ok(self.commits.clone())
618 }
619
620 fn create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
621 self.created_tags.lock().unwrap().push(name.to_string());
622 Ok(())
623 }
624
625 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
626 self.pushed_tags.lock().unwrap().push(name.to_string());
627 Ok(())
628 }
629
630 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
631 self.committed.lock().unwrap().push((
632 paths.iter().map(|s| s.to_string()).collect(),
633 message.to_string(),
634 ));
635 Ok(true)
636 }
637
638 fn push(&self) -> Result<(), ReleaseError> {
639 *self.push_count.lock().unwrap() += 1;
640 Ok(())
641 }
642
643 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
644 Ok(self
645 .created_tags
646 .lock()
647 .unwrap()
648 .contains(&name.to_string()))
649 }
650
651 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
652 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
653 }
654
655 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
656 Ok(self.tags.clone())
657 }
658
659 fn commits_between(
660 &self,
661 _from: Option<&str>,
662 _to: &str,
663 ) -> Result<Vec<Commit>, ReleaseError> {
664 Ok(self.commits.clone())
665 }
666
667 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
668 Ok("2026-01-01".into())
669 }
670
671 fn force_create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
672 self.force_created_tags
673 .lock()
674 .unwrap()
675 .push(name.to_string());
676 Ok(())
677 }
678
679 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
680 self.force_pushed_tags
681 .lock()
682 .unwrap()
683 .push(name.to_string());
684 Ok(())
685 }
686
687 fn head_sha(&self) -> Result<String, ReleaseError> {
688 Ok(self.head.clone())
689 }
690 }
691
692 struct FakeVcs {
693 releases: Mutex<Vec<(String, String)>>,
694 deleted_releases: Mutex<Vec<String>>,
695 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
696 }
697
698 impl FakeVcs {
699 fn new() -> Self {
700 Self {
701 releases: Mutex::new(Vec::new()),
702 deleted_releases: Mutex::new(Vec::new()),
703 uploaded_assets: Mutex::new(Vec::new()),
704 }
705 }
706 }
707
708 impl VcsProvider for FakeVcs {
709 fn create_release(
710 &self,
711 tag: &str,
712 _name: &str,
713 body: &str,
714 _prerelease: bool,
715 ) -> Result<String, ReleaseError> {
716 self.releases
717 .lock()
718 .unwrap()
719 .push((tag.to_string(), body.to_string()));
720 Ok(format!("https://github.com/test/release/{tag}"))
721 }
722
723 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
724 Ok(format!("https://github.com/test/compare/{base}...{head}"))
725 }
726
727 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
728 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
729 }
730
731 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
732 self.deleted_releases.lock().unwrap().push(tag.to_string());
733 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
734 Ok(())
735 }
736
737 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
738 self.uploaded_assets.lock().unwrap().push((
739 tag.to_string(),
740 files.iter().map(|s| s.to_string()).collect(),
741 ));
742 Ok(())
743 }
744
745 fn repo_url(&self) -> Option<String> {
746 Some("https://github.com/test/repo".into())
747 }
748 }
749
750 fn raw_commit(msg: &str) -> Commit {
753 Commit {
754 sha: "a".repeat(40),
755 message: msg.into(),
756 }
757 }
758
759 fn make_strategy(
760 tags: Vec<TagInfo>,
761 commits: Vec<Commit>,
762 config: ReleaseConfig,
763 ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
764 {
765 let types = config.types.clone();
766 let breaking_section = config.breaking_section.clone();
767 let misc_section = config.misc_section.clone();
768 TrunkReleaseStrategy {
769 git: FakeGit::new(tags, commits),
770 vcs: Some(FakeVcs::new()),
771 parser: DefaultCommitParser,
772 formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
773 config,
774 force: false,
775 }
776 }
777
778 #[test]
781 fn plan_no_commits_returns_error() {
782 let s = make_strategy(vec![], vec![], ReleaseConfig::default());
783 let err = s.plan().unwrap_err();
784 assert!(matches!(err, ReleaseError::NoCommits { .. }));
785 }
786
787 #[test]
788 fn plan_no_releasable_returns_error() {
789 let s = make_strategy(
790 vec![],
791 vec![raw_commit("chore: tidy up")],
792 ReleaseConfig::default(),
793 );
794 let err = s.plan().unwrap_err();
795 assert!(matches!(err, ReleaseError::NoBump { .. }));
796 }
797
798 #[test]
799 fn plan_first_release() {
800 let s = make_strategy(
801 vec![],
802 vec![raw_commit("feat: initial feature")],
803 ReleaseConfig::default(),
804 );
805 let plan = s.plan().unwrap();
806 assert_eq!(plan.next_version, Version::new(0, 1, 0));
807 assert_eq!(plan.tag_name, "v0.1.0");
808 assert!(plan.current_version.is_none());
809 }
810
811 #[test]
812 fn plan_increments_existing() {
813 let tag = TagInfo {
814 name: "v1.2.3".into(),
815 version: Version::new(1, 2, 3),
816 sha: "b".repeat(40),
817 };
818 let s = make_strategy(
819 vec![tag],
820 vec![raw_commit("fix: patch bug")],
821 ReleaseConfig::default(),
822 );
823 let plan = s.plan().unwrap();
824 assert_eq!(plan.next_version, Version::new(1, 2, 4));
825 }
826
827 #[test]
828 fn plan_breaking_bump() {
829 let tag = TagInfo {
830 name: "v1.2.3".into(),
831 version: Version::new(1, 2, 3),
832 sha: "c".repeat(40),
833 };
834 let s = make_strategy(
835 vec![tag],
836 vec![raw_commit("feat!: breaking change")],
837 ReleaseConfig::default(),
838 );
839 let plan = s.plan().unwrap();
840 assert_eq!(plan.next_version, Version::new(2, 0, 0));
841 }
842
843 #[test]
846 fn execute_dry_run_no_side_effects() {
847 let s = make_strategy(
848 vec![],
849 vec![raw_commit("feat: something")],
850 ReleaseConfig::default(),
851 );
852 let plan = s.plan().unwrap();
853 s.execute(&plan, true).unwrap();
854
855 assert!(s.git.created_tags.lock().unwrap().is_empty());
856 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
857 }
858
859 #[test]
860 fn execute_creates_and_pushes_tag() {
861 let s = make_strategy(
862 vec![],
863 vec![raw_commit("feat: something")],
864 ReleaseConfig::default(),
865 );
866 let plan = s.plan().unwrap();
867 s.execute(&plan, false).unwrap();
868
869 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
870 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
871 }
872
873 #[test]
874 fn execute_calls_vcs_create_release() {
875 let s = make_strategy(
876 vec![],
877 vec![raw_commit("feat: something")],
878 ReleaseConfig::default(),
879 );
880 let plan = s.plan().unwrap();
881 s.execute(&plan, false).unwrap();
882
883 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
884 assert_eq!(releases.len(), 1);
885 assert_eq!(releases[0].0, "v0.1.0");
886 assert!(!releases[0].1.is_empty());
887 }
888
889 #[test]
890 fn execute_commits_changelog_before_tag() {
891 let dir = tempfile::tempdir().unwrap();
892 let changelog_path = dir.path().join("CHANGELOG.md");
893
894 let mut config = ReleaseConfig::default();
895 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
896
897 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
898 let plan = s.plan().unwrap();
899 s.execute(&plan, false).unwrap();
900
901 let committed = s.git.committed.lock().unwrap();
903 assert_eq!(committed.len(), 1);
904 assert_eq!(
905 committed[0].0,
906 vec![changelog_path.to_str().unwrap().to_string()]
907 );
908 assert!(committed[0].1.contains("chore(release): v0.1.0"));
909
910 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
912 }
913
914 #[test]
915 fn execute_skips_existing_tag() {
916 let s = make_strategy(
917 vec![],
918 vec![raw_commit("feat: something")],
919 ReleaseConfig::default(),
920 );
921 let plan = s.plan().unwrap();
922
923 s.git
925 .created_tags
926 .lock()
927 .unwrap()
928 .push("v0.1.0".to_string());
929
930 s.execute(&plan, false).unwrap();
931
932 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
934 }
935
936 #[test]
937 fn execute_skips_existing_release() {
938 let s = make_strategy(
939 vec![],
940 vec![raw_commit("feat: something")],
941 ReleaseConfig::default(),
942 );
943 let plan = s.plan().unwrap();
944
945 s.vcs
947 .as_ref()
948 .unwrap()
949 .releases
950 .lock()
951 .unwrap()
952 .push(("v0.1.0".to_string(), "old notes".to_string()));
953
954 s.execute(&plan, false).unwrap();
955
956 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
958 assert_eq!(*deleted, vec!["v0.1.0"]);
959
960 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
961 assert_eq!(releases.len(), 1);
962 assert_eq!(releases[0].0, "v0.1.0");
963 assert_ne!(releases[0].1, "old notes");
964 }
965
966 #[test]
967 fn execute_idempotent_rerun() {
968 let s = make_strategy(
969 vec![],
970 vec![raw_commit("feat: something")],
971 ReleaseConfig::default(),
972 );
973 let plan = s.plan().unwrap();
974
975 s.execute(&plan, false).unwrap();
977
978 s.execute(&plan, false).unwrap();
980
981 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
983
984 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
986
987 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
989
990 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
992 assert_eq!(*deleted, vec!["v0.1.0"]);
993
994 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
995 assert_eq!(releases.len(), 1);
997 assert_eq!(releases[0].0, "v0.1.0");
998 }
999
1000 #[test]
1001 fn execute_bumps_version_files() {
1002 let dir = tempfile::tempdir().unwrap();
1003 let cargo_path = dir.path().join("Cargo.toml");
1004 std::fs::write(
1005 &cargo_path,
1006 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1007 )
1008 .unwrap();
1009
1010 let mut config = ReleaseConfig::default();
1011 config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1012
1013 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1014 let plan = s.plan().unwrap();
1015 s.execute(&plan, false).unwrap();
1016
1017 let contents = std::fs::read_to_string(&cargo_path).unwrap();
1019 assert!(contents.contains("version = \"0.1.0\""));
1020
1021 let committed = s.git.committed.lock().unwrap();
1023 assert_eq!(committed.len(), 1);
1024 assert!(
1025 committed[0]
1026 .0
1027 .contains(&cargo_path.to_str().unwrap().to_string())
1028 );
1029 }
1030
1031 #[test]
1032 fn execute_stages_changelog_and_version_files_together() {
1033 let dir = tempfile::tempdir().unwrap();
1034 let cargo_path = dir.path().join("Cargo.toml");
1035 std::fs::write(
1036 &cargo_path,
1037 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1038 )
1039 .unwrap();
1040
1041 let changelog_path = dir.path().join("CHANGELOG.md");
1042
1043 let mut config = ReleaseConfig::default();
1044 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1045 config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1046
1047 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1048 let plan = s.plan().unwrap();
1049 s.execute(&plan, false).unwrap();
1050
1051 let committed = s.git.committed.lock().unwrap();
1053 assert_eq!(committed.len(), 1);
1054 assert!(
1055 committed[0]
1056 .0
1057 .contains(&changelog_path.to_str().unwrap().to_string())
1058 );
1059 assert!(
1060 committed[0]
1061 .0
1062 .contains(&cargo_path.to_str().unwrap().to_string())
1063 );
1064 }
1065
1066 #[test]
1069 fn execute_uploads_artifacts() {
1070 let dir = tempfile::tempdir().unwrap();
1071 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1072 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1073
1074 let mut config = ReleaseConfig::default();
1075 config.artifacts = vec![
1076 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1077 dir.path().join("*.zip").to_str().unwrap().to_string(),
1078 ];
1079
1080 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1081 let plan = s.plan().unwrap();
1082 s.execute(&plan, false).unwrap();
1083
1084 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1085 assert_eq!(uploaded.len(), 1);
1086 assert_eq!(uploaded[0].0, "v0.1.0");
1087 assert_eq!(uploaded[0].1.len(), 2);
1088 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1089 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1090 }
1091
1092 #[test]
1093 fn execute_dry_run_shows_artifacts() {
1094 let dir = tempfile::tempdir().unwrap();
1095 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1096
1097 let mut config = ReleaseConfig::default();
1098 config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
1099
1100 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1101 let plan = s.plan().unwrap();
1102 s.execute(&plan, true).unwrap();
1103
1104 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1106 assert!(uploaded.is_empty());
1107 }
1108
1109 #[test]
1110 fn execute_no_artifacts_skips_upload() {
1111 let s = make_strategy(
1112 vec![],
1113 vec![raw_commit("feat: something")],
1114 ReleaseConfig::default(),
1115 );
1116 let plan = s.plan().unwrap();
1117 s.execute(&plan, false).unwrap();
1118
1119 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1120 assert!(uploaded.is_empty());
1121 }
1122
1123 #[test]
1124 fn resolve_artifact_globs_basic() {
1125 let dir = tempfile::tempdir().unwrap();
1126 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1127 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1128 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1129
1130 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1131 let result = resolve_artifact_globs(&[pattern]).unwrap();
1132 assert_eq!(result.len(), 2);
1133 assert!(result.iter().any(|f| f.ends_with("a.txt")));
1134 assert!(result.iter().any(|f| f.ends_with("b.txt")));
1135 }
1136
1137 #[test]
1138 fn resolve_artifact_globs_deduplicates() {
1139 let dir = tempfile::tempdir().unwrap();
1140 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1141
1142 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1143 let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
1145 assert_eq!(result.len(), 1);
1146 }
1147
1148 #[test]
1151 fn plan_floating_tag_when_enabled() {
1152 let tag = TagInfo {
1153 name: "v3.2.0".into(),
1154 version: Version::new(3, 2, 0),
1155 sha: "d".repeat(40),
1156 };
1157 let mut config = ReleaseConfig::default();
1158 config.floating_tags = true;
1159
1160 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1161 let plan = s.plan().unwrap();
1162 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1163 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1164 }
1165
1166 #[test]
1167 fn plan_no_floating_tag_when_disabled() {
1168 let s = make_strategy(
1169 vec![],
1170 vec![raw_commit("feat: something")],
1171 ReleaseConfig::default(),
1172 );
1173 let plan = s.plan().unwrap();
1174 assert!(plan.floating_tag_name.is_none());
1175 }
1176
1177 #[test]
1178 fn plan_floating_tag_custom_prefix() {
1179 let tag = TagInfo {
1180 name: "release-2.5.0".into(),
1181 version: Version::new(2, 5, 0),
1182 sha: "e".repeat(40),
1183 };
1184 let mut config = ReleaseConfig::default();
1185 config.floating_tags = true;
1186 config.tag_prefix = "release-".into();
1187
1188 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1189 let plan = s.plan().unwrap();
1190 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1191 }
1192
1193 #[test]
1194 fn execute_floating_tags_force_create_and_push() {
1195 let mut config = ReleaseConfig::default();
1196 config.floating_tags = true;
1197
1198 let tag = TagInfo {
1199 name: "v1.2.3".into(),
1200 version: Version::new(1, 2, 3),
1201 sha: "f".repeat(40),
1202 };
1203 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1204 let plan = s.plan().unwrap();
1205 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1206
1207 s.execute(&plan, false).unwrap();
1208
1209 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1210 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1211 }
1212
1213 #[test]
1214 fn execute_no_floating_tags_when_disabled() {
1215 let s = make_strategy(
1216 vec![],
1217 vec![raw_commit("feat: something")],
1218 ReleaseConfig::default(),
1219 );
1220 let plan = s.plan().unwrap();
1221 assert!(plan.floating_tag_name.is_none());
1222
1223 s.execute(&plan, false).unwrap();
1224
1225 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1226 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1227 }
1228
1229 #[test]
1230 fn execute_floating_tags_dry_run_no_side_effects() {
1231 let mut config = ReleaseConfig::default();
1232 config.floating_tags = true;
1233
1234 let tag = TagInfo {
1235 name: "v2.0.0".into(),
1236 version: Version::new(2, 0, 0),
1237 sha: "a".repeat(40),
1238 };
1239 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1240 let plan = s.plan().unwrap();
1241 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1242
1243 s.execute(&plan, true).unwrap();
1244
1245 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1246 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1247 }
1248
1249 #[test]
1250 fn execute_floating_tags_idempotent() {
1251 let mut config = ReleaseConfig::default();
1252 config.floating_tags = true;
1253
1254 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1255 let plan = s.plan().unwrap();
1256 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1257
1258 s.execute(&plan, false).unwrap();
1260 s.execute(&plan, false).unwrap();
1261
1262 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1264 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1265 }
1266
1267 #[test]
1270 fn force_rerelease_when_tag_at_head() {
1271 let tag = TagInfo {
1272 name: "v1.2.3".into(),
1273 version: Version::new(1, 2, 3),
1274 sha: "a".repeat(40),
1275 };
1276 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1277 s.git.head = "a".repeat(40);
1279 s.force = true;
1280
1281 let plan = s.plan().unwrap();
1282 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1283 assert_eq!(plan.tag_name, "v1.2.3");
1284 assert!(plan.commits.is_empty());
1285 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1286 }
1287
1288 #[test]
1289 fn force_fails_when_tag_not_at_head() {
1290 let tag = TagInfo {
1291 name: "v1.2.3".into(),
1292 version: Version::new(1, 2, 3),
1293 sha: "a".repeat(40),
1294 };
1295 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1296 s.git.head = "b".repeat(40);
1298 s.force = true;
1299
1300 let err = s.plan().unwrap_err();
1301 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1302 }
1303
1304 #[test]
1307 fn execute_runs_build_command_after_version_bump() {
1308 let dir = tempfile::tempdir().unwrap();
1309 let output_file = dir.path().join("sr_test_version");
1310
1311 let mut config = ReleaseConfig::default();
1312 config.build_command = Some(format!(
1313 "echo $SR_VERSION > {}",
1314 output_file.to_str().unwrap()
1315 ));
1316
1317 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1318 let plan = s.plan().unwrap();
1319 s.execute(&plan, false).unwrap();
1320
1321 let contents = std::fs::read_to_string(&output_file).unwrap();
1322 assert_eq!(contents.trim(), "0.1.0");
1323 }
1324
1325 #[test]
1326 fn execute_build_command_failure_aborts_release() {
1327 let mut config = ReleaseConfig::default();
1328 config.build_command = Some("exit 1".into());
1329
1330 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1331 let plan = s.plan().unwrap();
1332 let result = s.execute(&plan, false);
1333
1334 assert!(result.is_err());
1335 assert!(s.git.created_tags.lock().unwrap().is_empty());
1336 }
1337
1338 #[test]
1339 fn execute_dry_run_skips_build_command() {
1340 let dir = tempfile::tempdir().unwrap();
1341 let output_file = dir.path().join("sr_test_should_not_exist");
1342
1343 let mut config = ReleaseConfig::default();
1344 config.build_command = Some(format!("echo test > {}", output_file.to_str().unwrap()));
1345
1346 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1347 let plan = s.plan().unwrap();
1348 s.execute(&plan, true).unwrap();
1349
1350 assert!(!output_file.exists());
1351 }
1352
1353 #[test]
1354 fn force_fails_with_no_tags() {
1355 let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1356 s.force = true;
1357
1358 let err = s.plan().unwrap_err();
1359 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1360 }
1361
1362 #[test]
1365 fn execute_stages_extra_files() {
1366 let dir = tempfile::tempdir().unwrap();
1367 let lock_file = dir.path().join("Cargo.lock");
1368 std::fs::write(&lock_file, "old lock").unwrap();
1369
1370 let mut config = ReleaseConfig::default();
1371 config.build_command = Some(format!("echo 'new lock' > {}", lock_file.to_str().unwrap()));
1372 config.stage_files = vec![lock_file.to_str().unwrap().to_string()];
1373
1374 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1375 let plan = s.plan().unwrap();
1376 s.execute(&plan, false).unwrap();
1377
1378 let committed = s.git.committed.lock().unwrap();
1379 assert!(!committed.is_empty());
1380 let (staged, _) = &committed[0];
1381 assert!(
1382 staged.iter().any(|f| f.contains("Cargo.lock")),
1383 "Cargo.lock should be staged, got: {staged:?}"
1384 );
1385 }
1386
1387 #[test]
1388 fn execute_dry_run_shows_stage_files() {
1389 let mut config = ReleaseConfig::default();
1390 config.stage_files = vec!["Cargo.lock".into()];
1391
1392 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1393 let plan = s.plan().unwrap();
1394 s.execute(&plan, true).unwrap();
1396 }
1397
1398 #[test]
1401 fn execute_build_failure_restores_version_files() {
1402 let dir = tempfile::tempdir().unwrap();
1403 let cargo_toml = dir.path().join("Cargo.toml");
1404 std::fs::write(
1405 &cargo_toml,
1406 "[package]\nname = \"test\"\nversion = \"1.0.0\"\n",
1407 )
1408 .unwrap();
1409
1410 let mut config = ReleaseConfig::default();
1411 config.version_files = vec![cargo_toml.to_str().unwrap().to_string()];
1412 config.build_command = Some("exit 1".into());
1413
1414 let tag = TagInfo {
1415 name: "v1.0.0".into(),
1416 version: Version::new(1, 0, 0),
1417 sha: "d".repeat(40),
1418 };
1419 let s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
1420 let plan = s.plan().unwrap();
1421 let result = s.execute(&plan, false);
1422
1423 assert!(result.is_err());
1424 let contents = std::fs::read_to_string(&cargo_toml).unwrap();
1426 assert!(
1427 contents.contains("version = \"1.0.0\""),
1428 "version should be restored, got: {contents}"
1429 );
1430 }
1431
1432 #[test]
1435 fn execute_pre_release_command_runs() {
1436 let dir = tempfile::tempdir().unwrap();
1437 let marker = dir.path().join("pre_release_ran");
1438
1439 let mut config = ReleaseConfig::default();
1440 config.pre_release_command = Some(format!("touch {}", marker.to_str().unwrap()));
1441
1442 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1443 let plan = s.plan().unwrap();
1444 s.execute(&plan, false).unwrap();
1445
1446 assert!(marker.exists(), "pre-release command should have run");
1447 }
1448
1449 #[test]
1450 fn execute_post_release_command_runs() {
1451 let dir = tempfile::tempdir().unwrap();
1452 let marker = dir.path().join("post_release_ran");
1453
1454 let mut config = ReleaseConfig::default();
1455 config.post_release_command = Some(format!("touch {}", marker.to_str().unwrap()));
1456
1457 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1458 let plan = s.plan().unwrap();
1459 s.execute(&plan, false).unwrap();
1460
1461 assert!(marker.exists(), "post-release command should have run");
1462 }
1463
1464 #[test]
1465 fn execute_pre_release_failure_aborts_release() {
1466 let mut config = ReleaseConfig::default();
1467 config.pre_release_command = Some("exit 1".into());
1468
1469 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1470 let plan = s.plan().unwrap();
1471 let result = s.execute(&plan, false);
1472
1473 assert!(result.is_err());
1474 assert!(s.git.created_tags.lock().unwrap().is_empty());
1476 assert!(s.git.committed.lock().unwrap().is_empty());
1477 }
1478
1479 #[test]
1480 fn execute_hooks_receive_version_env_vars() {
1481 let dir = tempfile::tempdir().unwrap();
1482 let output_file = dir.path().join("hook_output");
1483
1484 let mut config = ReleaseConfig::default();
1485 config.post_release_command = Some(format!(
1486 "echo $SR_VERSION $SR_TAG > {}",
1487 output_file.to_str().unwrap()
1488 ));
1489
1490 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1491 let plan = s.plan().unwrap();
1492 s.execute(&plan, false).unwrap();
1493
1494 let contents = std::fs::read_to_string(&output_file).unwrap();
1495 assert!(contents.contains("0.1.0"), "SR_VERSION should be set");
1496 assert!(contents.contains("v0.1.0"), "SR_TAG should be set");
1497 }
1498
1499 #[test]
1500 fn execute_dry_run_skips_hooks() {
1501 let dir = tempfile::tempdir().unwrap();
1502 let pre_marker = dir.path().join("pre_hook");
1503 let post_marker = dir.path().join("post_hook");
1504
1505 let mut config = ReleaseConfig::default();
1506 config.pre_release_command = Some(format!("touch {}", pre_marker.to_str().unwrap()));
1507 config.post_release_command = Some(format!("touch {}", post_marker.to_str().unwrap()));
1508
1509 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1510 let plan = s.plan().unwrap();
1511 s.execute(&plan, true).unwrap();
1512
1513 assert!(
1514 !pre_marker.exists(),
1515 "pre-release hook should not run in dry-run"
1516 );
1517 assert!(
1518 !post_marker.exists(),
1519 "post-release hook should not run in dry-run"
1520 );
1521 }
1522
1523 #[test]
1526 fn plan_prerelease_first_release() {
1527 let mut config = ReleaseConfig::default();
1528 config.prerelease = Some("alpha".into());
1529
1530 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1531 let plan = s.plan().unwrap();
1532 assert_eq!(plan.next_version.to_string(), "0.1.0-alpha.1");
1533 assert_eq!(plan.tag_name, "v0.1.0-alpha.1");
1534 assert!(plan.prerelease);
1535 }
1536
1537 #[test]
1538 fn plan_prerelease_increments_from_stable() {
1539 let tag = TagInfo {
1540 name: "v1.0.0".into(),
1541 version: Version::new(1, 0, 0),
1542 sha: "d".repeat(40),
1543 };
1544 let mut config = ReleaseConfig::default();
1545 config.prerelease = Some("beta".into());
1546
1547 let s = make_strategy(vec![tag], vec![raw_commit("feat: new feature")], config);
1548 let plan = s.plan().unwrap();
1549 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1550 assert!(plan.prerelease);
1551 }
1552
1553 #[test]
1554 fn plan_prerelease_increments_counter() {
1555 let tags = vec![
1556 TagInfo {
1557 name: "v1.0.0".into(),
1558 version: Version::new(1, 0, 0),
1559 sha: "a".repeat(40),
1560 },
1561 TagInfo {
1562 name: "v1.1.0-alpha.1".into(),
1563 version: Version::parse("1.1.0-alpha.1").unwrap(),
1564 sha: "b".repeat(40),
1565 },
1566 TagInfo {
1567 name: "v1.1.0-alpha.2".into(),
1568 version: Version::parse("1.1.0-alpha.2").unwrap(),
1569 sha: "c".repeat(40),
1570 },
1571 ];
1572 let mut config = ReleaseConfig::default();
1573 config.prerelease = Some("alpha".into());
1574
1575 let s = make_strategy(tags, vec![raw_commit("feat: another")], config);
1576 let plan = s.plan().unwrap();
1577 assert_eq!(plan.next_version.to_string(), "1.1.0-alpha.3");
1578 }
1579
1580 #[test]
1581 fn plan_prerelease_different_id_starts_at_1() {
1582 let tags = vec![
1583 TagInfo {
1584 name: "v1.0.0".into(),
1585 version: Version::new(1, 0, 0),
1586 sha: "a".repeat(40),
1587 },
1588 TagInfo {
1589 name: "v1.1.0-alpha.3".into(),
1590 version: Version::parse("1.1.0-alpha.3").unwrap(),
1591 sha: "b".repeat(40),
1592 },
1593 ];
1594 let mut config = ReleaseConfig::default();
1595 config.prerelease = Some("beta".into());
1596
1597 let s = make_strategy(tags, vec![raw_commit("feat: something")], config);
1598 let plan = s.plan().unwrap();
1599 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1600 }
1601
1602 #[test]
1603 fn plan_prerelease_no_floating_tags() {
1604 let mut config = ReleaseConfig::default();
1605 config.prerelease = Some("rc".into());
1606 config.floating_tags = true;
1607
1608 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1609 let plan = s.plan().unwrap();
1610 assert!(
1611 plan.floating_tag_name.is_none(),
1612 "pre-releases should not create floating tags"
1613 );
1614 }
1615
1616 #[test]
1617 fn plan_stable_skips_prerelease_tags() {
1618 let tags = vec![
1619 TagInfo {
1620 name: "v1.0.0".into(),
1621 version: Version::new(1, 0, 0),
1622 sha: "a".repeat(40),
1623 },
1624 TagInfo {
1625 name: "v1.1.0-alpha.1".into(),
1626 version: Version::parse("1.1.0-alpha.1").unwrap(),
1627 sha: "b".repeat(40),
1628 },
1629 ];
1630 let s = make_strategy(
1632 tags,
1633 vec![raw_commit("feat: something")],
1634 ReleaseConfig::default(),
1635 );
1636 let plan = s.plan().unwrap();
1637 assert_eq!(plan.next_version, Version::new(1, 1, 0));
1639 assert!(!plan.prerelease);
1640 }
1641
1642 #[test]
1643 fn plan_prerelease_marks_plan_as_prerelease() {
1644 let mut config = ReleaseConfig::default();
1645 config.prerelease = Some("alpha".into());
1646
1647 let s = make_strategy(vec![], vec![raw_commit("fix: bug")], config);
1648 let plan = s.plan().unwrap();
1649 assert!(plan.prerelease);
1650 assert!(plan.next_version.to_string().contains("alpha"));
1651 }
1652}