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::hooks::{HookCommand, HookContext, HookRunner};
13use crate::version::{BumpLevel, apply_bump, determine_bump};
14use crate::version_files::bump_version_file;
15
16fn build_hook_context(plan: &ReleasePlan, phase: &str, dry_run: bool) -> HookContext {
18 HookContext::default()
19 .set("SR_VERSION", plan.next_version.to_string())
20 .set(
21 "SR_PREVIOUS_VERSION",
22 plan.current_version
23 .as_ref()
24 .map(|v| v.to_string())
25 .unwrap_or_default(),
26 )
27 .set("SR_TAG", &plan.tag_name)
28 .set("SR_BUMP_LEVEL", plan.bump.to_string())
29 .set("SR_DRY_RUN", dry_run.to_string())
30 .set("SR_COMMIT_COUNT", plan.commits.len().to_string())
31 .set("SR_HOOK_PHASE", phase)
32 .set(
33 "SR_FLOATING_TAG",
34 plan.floating_tag_name.as_deref().unwrap_or(""),
35 )
36}
37
38#[derive(Debug, Serialize)]
40pub struct ReleasePlan {
41 pub current_version: Option<Version>,
42 pub next_version: Version,
43 pub bump: BumpLevel,
44 pub commits: Vec<ConventionalCommit>,
45 pub tag_name: String,
46 pub floating_tag_name: Option<String>,
47}
48
49pub trait ReleaseStrategy: Send + Sync {
51 fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
53
54 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
56}
57
58pub trait VcsProvider: Send + Sync {
60 fn create_release(
62 &self,
63 tag: &str,
64 name: &str,
65 body: &str,
66 prerelease: bool,
67 ) -> Result<String, ReleaseError>;
68
69 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
71
72 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
74
75 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
77
78 fn repo_url(&self) -> Option<String> {
80 None
81 }
82
83 fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
85 Ok(())
86 }
87}
88
89pub struct TrunkReleaseStrategy<G, V, C, F, H> {
91 pub git: G,
92 pub vcs: Option<V>,
93 pub parser: C,
94 pub formatter: F,
95 pub hooks: H,
96 pub config: ReleaseConfig,
97 pub force: bool,
99}
100
101fn to_hook_commands(commands: &[String]) -> Vec<HookCommand> {
102 commands
103 .iter()
104 .map(|c| HookCommand { command: c.clone() })
105 .collect()
106}
107
108impl<G, V, C, F, H> TrunkReleaseStrategy<G, V, C, F, H>
109where
110 G: GitRepository,
111 V: VcsProvider,
112 C: CommitParser,
113 F: ChangelogFormatter,
114 H: HookRunner,
115{
116 fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
117 let today = today_string();
118 let entry = ChangelogEntry {
119 version: plan.next_version.to_string(),
120 date: today,
121 commits: plan.commits.clone(),
122 compare_url: None,
123 repo_url: self.vcs.as_ref().and_then(|v| v.repo_url()),
124 };
125 self.formatter.format(&[entry])
126 }
127}
128
129impl<G, V, C, F, H> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F, H>
130where
131 G: GitRepository,
132 V: VcsProvider,
133 C: CommitParser,
134 F: ChangelogFormatter,
135 H: HookRunner,
136{
137 fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
138 let tag_info = self.git.latest_tag(&self.config.tag_prefix)?;
139
140 let (current_version, from_sha) = match &tag_info {
141 Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
142 None => (None, None),
143 };
144
145 let raw_commits = self.git.commits_since(from_sha)?;
146 if raw_commits.is_empty() {
147 if self.force
149 && let Some(ref info) = tag_info
150 {
151 let head = self.git.head_sha()?;
152 if head == info.sha {
153 let floating_tag_name = if self.config.floating_tags {
154 Some(format!("{}{}", self.config.tag_prefix, info.version.major))
155 } else {
156 None
157 };
158 return Ok(ReleasePlan {
159 current_version: Some(info.version.clone()),
160 next_version: info.version.clone(),
161 bump: BumpLevel::Patch, commits: vec![],
163 tag_name: info.name.clone(),
164 floating_tag_name,
165 });
166 }
167 }
168 let (tag, sha) = match &tag_info {
169 Some(info) => (info.name.clone(), info.sha.clone()),
170 None => ("(none)".into(), "(none)".into()),
171 };
172 return Err(ReleaseError::NoCommits { tag, sha });
173 }
174
175 let conventional_commits: Vec<ConventionalCommit> = raw_commits
176 .iter()
177 .filter_map(|c| self.parser.parse(c).ok())
178 .collect();
179
180 let classifier = DefaultCommitClassifier::new(
181 self.config.types.clone(),
182 self.config.commit_pattern.clone(),
183 );
184 let tag_for_err = tag_info
185 .as_ref()
186 .map(|i| i.name.clone())
187 .unwrap_or_else(|| "(none)".into());
188 let commit_count = conventional_commits.len();
189 let bump =
190 determine_bump(&conventional_commits, &classifier).ok_or(ReleaseError::NoBump {
191 tag: tag_for_err,
192 commit_count,
193 })?;
194
195 let base_version = current_version.clone().unwrap_or(Version::new(0, 0, 0));
196 let next_version = apply_bump(&base_version, bump);
197 let tag_name = format!("{}{next_version}", self.config.tag_prefix);
198
199 let floating_tag_name = if self.config.floating_tags {
200 Some(format!("{}{}", self.config.tag_prefix, next_version.major))
201 } else {
202 None
203 };
204
205 Ok(ReleasePlan {
206 current_version,
207 next_version,
208 bump,
209 commits: conventional_commits,
210 tag_name,
211 floating_tag_name,
212 })
213 }
214
215 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
216 if dry_run {
217 let changelog_body = self.format_changelog(plan)?;
218 let ctx = build_hook_context(plan, "pre_release", true);
219 let mut env_keys: Vec<&String> = ctx.env.keys().collect();
220 env_keys.sort();
221 eprintln!("[dry-run] Hook environment variables:");
222 for key in env_keys {
223 eprintln!("[dry-run] {key}={}", ctx.env[key]);
224 }
225 eprintln!("[dry-run] Would create tag: {}", plan.tag_name);
226 eprintln!("[dry-run] Would push tag: {}", plan.tag_name);
227 if let Some(ref floating) = plan.floating_tag_name {
228 eprintln!("[dry-run] Would create/update floating tag: {floating}");
229 eprintln!("[dry-run] Would force-push floating tag: {floating}");
230 }
231 if self.vcs.is_some() {
232 eprintln!(
233 "[dry-run] Would create GitHub release for {}",
234 plan.tag_name
235 );
236 }
237 for file in &self.config.version_files {
238 let filename = Path::new(file)
239 .file_name()
240 .and_then(|n| n.to_str())
241 .unwrap_or_default();
242 let supported = matches!(
243 filename,
244 "Cargo.toml"
245 | "package.json"
246 | "pyproject.toml"
247 | "pom.xml"
248 | "build.gradle"
249 | "build.gradle.kts"
250 ) || filename.ends_with(".go");
251 if supported {
252 eprintln!("[dry-run] Would bump version in: {file}");
253 } else if self.config.version_files_strict {
254 return Err(ReleaseError::VersionBump(format!(
255 "unsupported version file: {filename}"
256 )));
257 } else {
258 eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
259 }
260 }
261 if !self.config.artifacts.is_empty() {
262 let resolved = resolve_artifact_globs(&self.config.artifacts)?;
263 if resolved.is_empty() {
264 eprintln!("[dry-run] Artifact patterns matched no files");
265 } else {
266 eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
267 for f in &resolved {
268 eprintln!("[dry-run] {f}");
269 }
270 }
271 }
272 for hook in &self.config.hooks.pre_release {
273 eprintln!("[dry-run] Would run pre-release hook: {hook}");
274 }
275 for hook in &self.config.hooks.post_tag {
276 eprintln!("[dry-run] Would run post-tag hook: {hook}");
277 }
278 for hook in &self.config.hooks.post_release {
279 eprintln!("[dry-run] Would run post-release hook: {hook}");
280 }
281 eprintln!("[dry-run] Changelog:\n{changelog_body}");
282 return Ok(());
283 }
284
285 let failure_ctx = build_hook_context(plan, "on_failure", false);
286 let run_failure_hooks = |err: ReleaseError| -> ReleaseError {
287 let _ = self.hooks.run(
288 &to_hook_commands(&self.config.hooks.on_failure),
289 &failure_ctx,
290 );
291 err
292 };
293
294 let pre_ctx = build_hook_context(plan, "pre_release", false);
296 self.hooks
297 .run(&to_hook_commands(&self.config.hooks.pre_release), &pre_ctx)
298 .map_err(&run_failure_hooks)?;
299
300 let changelog_body = self.format_changelog(plan).map_err(&run_failure_hooks)?;
302
303 let version_str = plan.next_version.to_string();
305 let mut bumped_files: Vec<&str> = Vec::new();
306 for file in &self.config.version_files {
307 match bump_version_file(Path::new(file), &version_str) {
308 Ok(()) => bumped_files.push(file.as_str()),
309 Err(e) if !self.config.version_files_strict => {
310 eprintln!("warning: {e} — skipping {file}");
311 }
312 Err(e) => return Err(run_failure_hooks(e)),
313 }
314 }
315
316 if let Some(ref changelog_file) = self.config.changelog.file {
318 let path = Path::new(changelog_file);
319 let existing = if path.exists() {
320 fs::read_to_string(path)
321 .map_err(|e| ReleaseError::Changelog(e.to_string()))
322 .map_err(&run_failure_hooks)?
323 } else {
324 String::new()
325 };
326 let new_content = if existing.is_empty() {
327 format!("# Changelog\n\n{changelog_body}\n")
328 } else {
329 match existing.find("\n\n") {
331 Some(pos) => {
332 let (header, rest) = existing.split_at(pos);
333 format!("{header}\n\n{changelog_body}\n{rest}")
334 }
335 None => format!("{existing}\n\n{changelog_body}\n"),
336 }
337 };
338 fs::write(path, new_content)
339 .map_err(|e| ReleaseError::Changelog(e.to_string()))
340 .map_err(&run_failure_hooks)?;
341 }
342
343 {
345 let mut paths_to_stage: Vec<&str> = Vec::new();
346 if let Some(ref changelog_file) = self.config.changelog.file {
347 paths_to_stage.push(changelog_file.as_str());
348 }
349 for file in &bumped_files {
350 paths_to_stage.push(*file);
351 }
352 if !paths_to_stage.is_empty() {
353 let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
354 self.git
355 .stage_and_commit(&paths_to_stage, &commit_msg)
356 .map_err(&run_failure_hooks)?;
357 }
358 }
359
360 if !self
362 .git
363 .tag_exists(&plan.tag_name)
364 .map_err(&run_failure_hooks)?
365 {
366 self.git
367 .create_tag(&plan.tag_name, &changelog_body)
368 .map_err(&run_failure_hooks)?;
369 }
370
371 self.git.push().map_err(&run_failure_hooks)?;
373
374 if !self
376 .git
377 .remote_tag_exists(&plan.tag_name)
378 .map_err(&run_failure_hooks)?
379 {
380 self.git
381 .push_tag(&plan.tag_name)
382 .map_err(&run_failure_hooks)?;
383 }
384
385 if let Some(ref floating) = plan.floating_tag_name {
387 let floating_msg = format!("Floating tag for {}", plan.tag_name);
388 self.git
389 .force_create_tag(floating, &floating_msg)
390 .map_err(&run_failure_hooks)?;
391 self.git
392 .force_push_tag(floating)
393 .map_err(&run_failure_hooks)?;
394 }
395
396 let post_tag_ctx = build_hook_context(plan, "post_tag", false);
398 self.hooks
399 .run(
400 &to_hook_commands(&self.config.hooks.post_tag),
401 &post_tag_ctx,
402 )
403 .map_err(&run_failure_hooks)?;
404
405 if let Some(ref vcs) = self.vcs {
407 let release_name = format!("{} {}", self.config.tag_prefix, plan.next_version);
408 if vcs
409 .release_exists(&plan.tag_name)
410 .map_err(&run_failure_hooks)?
411 {
412 vcs.delete_release(&plan.tag_name)
414 .map_err(&run_failure_hooks)?;
415 }
416 vcs.create_release(&plan.tag_name, &release_name, &changelog_body, false)
417 .map_err(&run_failure_hooks)?;
418 }
419
420 if let Some(ref vcs) = self.vcs
422 && !self.config.artifacts.is_empty()
423 {
424 let resolved =
425 resolve_artifact_globs(&self.config.artifacts).map_err(&run_failure_hooks)?;
426 if !resolved.is_empty() {
427 let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
428 vcs.upload_assets(&plan.tag_name, &file_refs)
429 .map_err(&run_failure_hooks)?;
430 eprintln!(
431 "Uploaded {} artifact(s) to {}",
432 resolved.len(),
433 plan.tag_name
434 );
435 }
436 }
437
438 let post_release_ctx = build_hook_context(plan, "post_release", false);
440 self.hooks
441 .run(
442 &to_hook_commands(&self.config.hooks.post_release),
443 &post_release_ctx,
444 )
445 .map_err(&run_failure_hooks)?;
446
447 eprintln!("Released {}", plan.tag_name);
448 Ok(())
449 }
450}
451
452fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
453 let mut files = std::collections::BTreeSet::new();
454 for pattern in patterns {
455 let paths = glob::glob(pattern)
456 .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
457 for entry in paths {
458 match entry {
459 Ok(path) if path.is_file() => {
460 files.insert(path.to_string_lossy().into_owned());
461 }
462 Ok(_) => {} Err(e) => {
464 eprintln!("warning: glob error: {e}");
465 }
466 }
467 }
468 }
469 Ok(files.into_iter().collect())
470}
471
472pub fn today_string() -> String {
473 std::process::Command::new("date")
475 .arg("+%Y-%m-%d")
476 .output()
477 .ok()
478 .and_then(|o| {
479 if o.status.success() {
480 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
481 } else {
482 None
483 }
484 })
485 .unwrap_or_else(|| "unknown".to_string())
486}
487
488#[cfg(test)]
489mod tests {
490 use std::sync::Mutex;
491
492 use super::*;
493 use crate::changelog::DefaultChangelogFormatter;
494 use crate::commit::{Commit, DefaultCommitParser};
495 use crate::config::ReleaseConfig;
496 use crate::git::{GitRepository, TagInfo};
497 use crate::hooks::{HookCommand, HookContext, HookRunner};
498
499 struct FakeGit {
502 tags: Vec<TagInfo>,
503 commits: Vec<Commit>,
504 head: String,
505 created_tags: Mutex<Vec<String>>,
506 pushed_tags: Mutex<Vec<String>>,
507 committed: Mutex<Vec<(Vec<String>, String)>>,
508 push_count: Mutex<u32>,
509 force_created_tags: Mutex<Vec<String>>,
510 force_pushed_tags: Mutex<Vec<String>>,
511 }
512
513 impl FakeGit {
514 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
515 let head = tags
516 .last()
517 .map(|t| t.sha.clone())
518 .unwrap_or_else(|| "0".repeat(40));
519 Self {
520 tags,
521 commits,
522 head,
523 created_tags: Mutex::new(Vec::new()),
524 pushed_tags: Mutex::new(Vec::new()),
525 committed: Mutex::new(Vec::new()),
526 push_count: Mutex::new(0),
527 force_created_tags: Mutex::new(Vec::new()),
528 force_pushed_tags: Mutex::new(Vec::new()),
529 }
530 }
531 }
532
533 impl GitRepository for FakeGit {
534 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
535 Ok(self.tags.last().cloned())
536 }
537
538 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
539 Ok(self.commits.clone())
540 }
541
542 fn create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
543 self.created_tags.lock().unwrap().push(name.to_string());
544 Ok(())
545 }
546
547 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
548 self.pushed_tags.lock().unwrap().push(name.to_string());
549 Ok(())
550 }
551
552 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
553 self.committed.lock().unwrap().push((
554 paths.iter().map(|s| s.to_string()).collect(),
555 message.to_string(),
556 ));
557 Ok(true)
558 }
559
560 fn push(&self) -> Result<(), ReleaseError> {
561 *self.push_count.lock().unwrap() += 1;
562 Ok(())
563 }
564
565 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
566 Ok(self
567 .created_tags
568 .lock()
569 .unwrap()
570 .contains(&name.to_string()))
571 }
572
573 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
574 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
575 }
576
577 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
578 Ok(self.tags.clone())
579 }
580
581 fn commits_between(
582 &self,
583 _from: Option<&str>,
584 _to: &str,
585 ) -> Result<Vec<Commit>, ReleaseError> {
586 Ok(self.commits.clone())
587 }
588
589 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
590 Ok("2026-01-01".into())
591 }
592
593 fn force_create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
594 self.force_created_tags
595 .lock()
596 .unwrap()
597 .push(name.to_string());
598 Ok(())
599 }
600
601 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
602 self.force_pushed_tags
603 .lock()
604 .unwrap()
605 .push(name.to_string());
606 Ok(())
607 }
608
609 fn head_sha(&self) -> Result<String, ReleaseError> {
610 Ok(self.head.clone())
611 }
612 }
613
614 struct FakeVcs {
615 releases: Mutex<Vec<(String, String)>>,
616 deleted_releases: Mutex<Vec<String>>,
617 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
618 }
619
620 impl FakeVcs {
621 fn new() -> Self {
622 Self {
623 releases: Mutex::new(Vec::new()),
624 deleted_releases: Mutex::new(Vec::new()),
625 uploaded_assets: Mutex::new(Vec::new()),
626 }
627 }
628 }
629
630 impl VcsProvider for FakeVcs {
631 fn create_release(
632 &self,
633 tag: &str,
634 _name: &str,
635 body: &str,
636 _prerelease: bool,
637 ) -> Result<String, ReleaseError> {
638 self.releases
639 .lock()
640 .unwrap()
641 .push((tag.to_string(), body.to_string()));
642 Ok(format!("https://github.com/test/release/{tag}"))
643 }
644
645 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
646 Ok(format!("https://github.com/test/compare/{base}...{head}"))
647 }
648
649 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
650 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
651 }
652
653 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
654 self.deleted_releases.lock().unwrap().push(tag.to_string());
655 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
656 Ok(())
657 }
658
659 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
660 self.uploaded_assets.lock().unwrap().push((
661 tag.to_string(),
662 files.iter().map(|s| s.to_string()).collect(),
663 ));
664 Ok(())
665 }
666
667 fn repo_url(&self) -> Option<String> {
668 Some("https://github.com/test/repo".into())
669 }
670 }
671
672 struct FakeHooks {
673 run_log: Mutex<Vec<String>>,
674 ctx_log: Mutex<Vec<HookContext>>,
675 }
676
677 impl FakeHooks {
678 fn new() -> Self {
679 Self {
680 run_log: Mutex::new(Vec::new()),
681 ctx_log: Mutex::new(Vec::new()),
682 }
683 }
684 }
685
686 impl HookRunner for FakeHooks {
687 fn run(&self, hooks: &[HookCommand], ctx: &HookContext) -> Result<(), ReleaseError> {
688 for h in hooks {
689 self.run_log.lock().unwrap().push(h.command.clone());
690 }
691 if !hooks.is_empty() {
692 self.ctx_log.lock().unwrap().push(ctx.clone());
693 }
694 Ok(())
695 }
696 }
697
698 fn raw_commit(msg: &str) -> Commit {
701 Commit {
702 sha: "a".repeat(40),
703 message: msg.into(),
704 }
705 }
706
707 fn make_strategy(
708 tags: Vec<TagInfo>,
709 commits: Vec<Commit>,
710 config: ReleaseConfig,
711 ) -> TrunkReleaseStrategy<
712 FakeGit,
713 FakeVcs,
714 DefaultCommitParser,
715 DefaultChangelogFormatter,
716 FakeHooks,
717 > {
718 let types = config.types.clone();
719 let breaking_section = config.breaking_section.clone();
720 let misc_section = config.misc_section.clone();
721 TrunkReleaseStrategy {
722 git: FakeGit::new(tags, commits),
723 vcs: Some(FakeVcs::new()),
724 parser: DefaultCommitParser,
725 formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
726 hooks: FakeHooks::new(),
727 config,
728 force: false,
729 }
730 }
731
732 #[test]
735 fn plan_no_commits_returns_error() {
736 let s = make_strategy(vec![], vec![], ReleaseConfig::default());
737 let err = s.plan().unwrap_err();
738 assert!(matches!(err, ReleaseError::NoCommits { .. }));
739 }
740
741 #[test]
742 fn plan_no_releasable_returns_error() {
743 let s = make_strategy(
744 vec![],
745 vec![raw_commit("chore: tidy up")],
746 ReleaseConfig::default(),
747 );
748 let err = s.plan().unwrap_err();
749 assert!(matches!(err, ReleaseError::NoBump { .. }));
750 }
751
752 #[test]
753 fn plan_first_release() {
754 let s = make_strategy(
755 vec![],
756 vec![raw_commit("feat: initial feature")],
757 ReleaseConfig::default(),
758 );
759 let plan = s.plan().unwrap();
760 assert_eq!(plan.next_version, Version::new(0, 1, 0));
761 assert_eq!(plan.tag_name, "v0.1.0");
762 assert!(plan.current_version.is_none());
763 }
764
765 #[test]
766 fn plan_increments_existing() {
767 let tag = TagInfo {
768 name: "v1.2.3".into(),
769 version: Version::new(1, 2, 3),
770 sha: "b".repeat(40),
771 };
772 let s = make_strategy(
773 vec![tag],
774 vec![raw_commit("fix: patch bug")],
775 ReleaseConfig::default(),
776 );
777 let plan = s.plan().unwrap();
778 assert_eq!(plan.next_version, Version::new(1, 2, 4));
779 }
780
781 #[test]
782 fn plan_breaking_bump() {
783 let tag = TagInfo {
784 name: "v1.2.3".into(),
785 version: Version::new(1, 2, 3),
786 sha: "c".repeat(40),
787 };
788 let s = make_strategy(
789 vec![tag],
790 vec![raw_commit("feat!: breaking change")],
791 ReleaseConfig::default(),
792 );
793 let plan = s.plan().unwrap();
794 assert_eq!(plan.next_version, Version::new(2, 0, 0));
795 }
796
797 #[test]
800 fn execute_dry_run_no_side_effects() {
801 let mut config = ReleaseConfig::default();
802 config.hooks.pre_release = vec!["echo pre".into()];
803 config.hooks.post_tag = vec!["echo post-tag".into()];
804 config.hooks.post_release = vec!["echo post-release".into()];
805
806 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
807 let plan = s.plan().unwrap();
808 s.execute(&plan, true).unwrap();
809
810 assert!(s.git.created_tags.lock().unwrap().is_empty());
811 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
812 assert!(s.hooks.run_log.lock().unwrap().is_empty());
813 }
814
815 #[test]
816 fn execute_creates_and_pushes_tag() {
817 let s = make_strategy(
818 vec![],
819 vec![raw_commit("feat: something")],
820 ReleaseConfig::default(),
821 );
822 let plan = s.plan().unwrap();
823 s.execute(&plan, false).unwrap();
824
825 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
826 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
827 }
828
829 #[test]
830 fn execute_runs_hooks_in_order() {
831 let mut config = ReleaseConfig::default();
832 config.hooks.pre_release = vec!["echo pre".into()];
833 config.hooks.post_tag = vec!["echo post-tag".into()];
834 config.hooks.post_release = vec!["echo post-release".into()];
835
836 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
837 let plan = s.plan().unwrap();
838 s.execute(&plan, false).unwrap();
839
840 let log = s.hooks.run_log.lock().unwrap();
841 assert_eq!(*log, vec!["echo pre", "echo post-tag", "echo post-release"]);
842 }
843
844 #[test]
845 fn execute_calls_vcs_create_release() {
846 let s = make_strategy(
847 vec![],
848 vec![raw_commit("feat: something")],
849 ReleaseConfig::default(),
850 );
851 let plan = s.plan().unwrap();
852 s.execute(&plan, false).unwrap();
853
854 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
855 assert_eq!(releases.len(), 1);
856 assert_eq!(releases[0].0, "v0.1.0");
857 assert!(!releases[0].1.is_empty());
858 }
859
860 #[test]
861 fn execute_commits_changelog_before_tag() {
862 let dir = tempfile::tempdir().unwrap();
863 let changelog_path = dir.path().join("CHANGELOG.md");
864
865 let mut config = ReleaseConfig::default();
866 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
867
868 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
869 let plan = s.plan().unwrap();
870 s.execute(&plan, false).unwrap();
871
872 let committed = s.git.committed.lock().unwrap();
874 assert_eq!(committed.len(), 1);
875 assert_eq!(
876 committed[0].0,
877 vec![changelog_path.to_str().unwrap().to_string()]
878 );
879 assert!(committed[0].1.contains("chore(release): v0.1.0"));
880
881 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
883 }
884
885 #[test]
886 fn execute_skips_existing_tag() {
887 let s = make_strategy(
888 vec![],
889 vec![raw_commit("feat: something")],
890 ReleaseConfig::default(),
891 );
892 let plan = s.plan().unwrap();
893
894 s.git
896 .created_tags
897 .lock()
898 .unwrap()
899 .push("v0.1.0".to_string());
900
901 s.execute(&plan, false).unwrap();
902
903 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
905 }
906
907 #[test]
908 fn execute_skips_existing_release() {
909 let s = make_strategy(
910 vec![],
911 vec![raw_commit("feat: something")],
912 ReleaseConfig::default(),
913 );
914 let plan = s.plan().unwrap();
915
916 s.vcs
918 .as_ref()
919 .unwrap()
920 .releases
921 .lock()
922 .unwrap()
923 .push(("v0.1.0".to_string(), "old notes".to_string()));
924
925 s.execute(&plan, false).unwrap();
926
927 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
929 assert_eq!(*deleted, vec!["v0.1.0"]);
930
931 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
932 assert_eq!(releases.len(), 1);
933 assert_eq!(releases[0].0, "v0.1.0");
934 assert_ne!(releases[0].1, "old notes");
935 }
936
937 #[test]
938 fn execute_idempotent_rerun() {
939 let s = make_strategy(
940 vec![],
941 vec![raw_commit("feat: something")],
942 ReleaseConfig::default(),
943 );
944 let plan = s.plan().unwrap();
945
946 s.execute(&plan, false).unwrap();
948
949 s.execute(&plan, false).unwrap();
951
952 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
954
955 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
957
958 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
960
961 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
963 assert_eq!(*deleted, vec!["v0.1.0"]);
964
965 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
966 assert_eq!(releases.len(), 1);
968 assert_eq!(releases[0].0, "v0.1.0");
969 }
970
971 #[test]
972 fn execute_passes_hook_context_with_version_info() {
973 let tag = TagInfo {
974 name: "v1.2.3".into(),
975 version: Version::new(1, 2, 3),
976 sha: "b".repeat(40),
977 };
978 let mut config = ReleaseConfig::default();
979 config.hooks.pre_release = vec!["echo pre".into()];
980 config.hooks.post_tag = vec!["echo post-tag".into()];
981 config.hooks.post_release = vec!["echo post-release".into()];
982
983 let s = make_strategy(
984 vec![tag],
985 vec![raw_commit("feat: first"), raw_commit("fix: second")],
986 config,
987 );
988 let plan = s.plan().unwrap();
989 s.execute(&plan, false).unwrap();
990
991 let ctx_log = s.hooks.ctx_log.lock().unwrap();
992 assert_eq!(ctx_log.len(), 3);
993
994 let pre = &ctx_log[0];
996 assert_eq!(pre.env.get("SR_VERSION").unwrap(), "1.3.0");
997 assert_eq!(pre.env.get("SR_PREVIOUS_VERSION").unwrap(), "1.2.3");
998 assert_eq!(pre.env.get("SR_TAG").unwrap(), "v1.3.0");
999 assert_eq!(pre.env.get("SR_BUMP_LEVEL").unwrap(), "minor");
1000 assert_eq!(pre.env.get("SR_DRY_RUN").unwrap(), "false");
1001 assert_eq!(pre.env.get("SR_COMMIT_COUNT").unwrap(), "2");
1002 assert_eq!(pre.env.get("SR_HOOK_PHASE").unwrap(), "pre_release");
1003
1004 let post_tag = &ctx_log[1];
1006 assert_eq!(post_tag.env.get("SR_HOOK_PHASE").unwrap(), "post_tag");
1007 assert_eq!(post_tag.env.get("SR_VERSION").unwrap(), "1.3.0");
1008
1009 let post_release = &ctx_log[2];
1011 assert_eq!(
1012 post_release.env.get("SR_HOOK_PHASE").unwrap(),
1013 "post_release"
1014 );
1015 assert_eq!(post_release.env.get("SR_VERSION").unwrap(), "1.3.0");
1016 }
1017
1018 #[test]
1019 fn hook_context_first_release_has_empty_previous_version() {
1020 let mut config = ReleaseConfig::default();
1021 config.hooks.pre_release = vec!["echo pre".into()];
1022
1023 let s = make_strategy(vec![], vec![raw_commit("feat: initial")], config);
1024 let plan = s.plan().unwrap();
1025 s.execute(&plan, false).unwrap();
1026
1027 let ctx_log = s.hooks.ctx_log.lock().unwrap();
1028 assert!(!ctx_log.is_empty());
1029 let pre = &ctx_log[0];
1030 assert_eq!(pre.env.get("SR_PREVIOUS_VERSION").unwrap(), "");
1031 assert_eq!(pre.env.get("SR_VERSION").unwrap(), "0.1.0");
1032 }
1033
1034 #[test]
1035 fn execute_bumps_version_files() {
1036 let dir = tempfile::tempdir().unwrap();
1037 let cargo_path = dir.path().join("Cargo.toml");
1038 std::fs::write(
1039 &cargo_path,
1040 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1041 )
1042 .unwrap();
1043
1044 let mut config = ReleaseConfig::default();
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 contents = std::fs::read_to_string(&cargo_path).unwrap();
1053 assert!(contents.contains("version = \"0.1.0\""));
1054
1055 let committed = s.git.committed.lock().unwrap();
1057 assert_eq!(committed.len(), 1);
1058 assert!(
1059 committed[0]
1060 .0
1061 .contains(&cargo_path.to_str().unwrap().to_string())
1062 );
1063 }
1064
1065 #[test]
1066 fn execute_stages_changelog_and_version_files_together() {
1067 let dir = tempfile::tempdir().unwrap();
1068 let cargo_path = dir.path().join("Cargo.toml");
1069 std::fs::write(
1070 &cargo_path,
1071 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1072 )
1073 .unwrap();
1074
1075 let changelog_path = dir.path().join("CHANGELOG.md");
1076
1077 let mut config = ReleaseConfig::default();
1078 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1079 config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1080
1081 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1082 let plan = s.plan().unwrap();
1083 s.execute(&plan, false).unwrap();
1084
1085 let committed = s.git.committed.lock().unwrap();
1087 assert_eq!(committed.len(), 1);
1088 assert!(
1089 committed[0]
1090 .0
1091 .contains(&changelog_path.to_str().unwrap().to_string())
1092 );
1093 assert!(
1094 committed[0]
1095 .0
1096 .contains(&cargo_path.to_str().unwrap().to_string())
1097 );
1098 }
1099
1100 #[test]
1103 fn execute_uploads_artifacts() {
1104 let dir = tempfile::tempdir().unwrap();
1105 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1106 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1107
1108 let mut config = ReleaseConfig::default();
1109 config.artifacts = vec![
1110 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1111 dir.path().join("*.zip").to_str().unwrap().to_string(),
1112 ];
1113
1114 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1115 let plan = s.plan().unwrap();
1116 s.execute(&plan, false).unwrap();
1117
1118 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1119 assert_eq!(uploaded.len(), 1);
1120 assert_eq!(uploaded[0].0, "v0.1.0");
1121 assert_eq!(uploaded[0].1.len(), 2);
1122 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1123 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1124 }
1125
1126 #[test]
1127 fn execute_dry_run_shows_artifacts() {
1128 let dir = tempfile::tempdir().unwrap();
1129 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1130
1131 let mut config = ReleaseConfig::default();
1132 config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
1133
1134 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1135 let plan = s.plan().unwrap();
1136 s.execute(&plan, true).unwrap();
1137
1138 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1140 assert!(uploaded.is_empty());
1141 }
1142
1143 #[test]
1144 fn execute_no_artifacts_skips_upload() {
1145 let s = make_strategy(
1146 vec![],
1147 vec![raw_commit("feat: something")],
1148 ReleaseConfig::default(),
1149 );
1150 let plan = s.plan().unwrap();
1151 s.execute(&plan, false).unwrap();
1152
1153 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1154 assert!(uploaded.is_empty());
1155 }
1156
1157 #[test]
1158 fn resolve_artifact_globs_basic() {
1159 let dir = tempfile::tempdir().unwrap();
1160 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1161 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1162 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1163
1164 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1165 let result = resolve_artifact_globs(&[pattern]).unwrap();
1166 assert_eq!(result.len(), 2);
1167 assert!(result.iter().any(|f| f.ends_with("a.txt")));
1168 assert!(result.iter().any(|f| f.ends_with("b.txt")));
1169 }
1170
1171 #[test]
1172 fn resolve_artifact_globs_deduplicates() {
1173 let dir = tempfile::tempdir().unwrap();
1174 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1175
1176 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1177 let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
1179 assert_eq!(result.len(), 1);
1180 }
1181
1182 #[test]
1185 fn plan_floating_tag_when_enabled() {
1186 let tag = TagInfo {
1187 name: "v3.2.0".into(),
1188 version: Version::new(3, 2, 0),
1189 sha: "d".repeat(40),
1190 };
1191 let mut config = ReleaseConfig::default();
1192 config.floating_tags = true;
1193
1194 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1195 let plan = s.plan().unwrap();
1196 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1197 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1198 }
1199
1200 #[test]
1201 fn plan_no_floating_tag_when_disabled() {
1202 let s = make_strategy(
1203 vec![],
1204 vec![raw_commit("feat: something")],
1205 ReleaseConfig::default(),
1206 );
1207 let plan = s.plan().unwrap();
1208 assert!(plan.floating_tag_name.is_none());
1209 }
1210
1211 #[test]
1212 fn plan_floating_tag_custom_prefix() {
1213 let tag = TagInfo {
1214 name: "release-2.5.0".into(),
1215 version: Version::new(2, 5, 0),
1216 sha: "e".repeat(40),
1217 };
1218 let mut config = ReleaseConfig::default();
1219 config.floating_tags = true;
1220 config.tag_prefix = "release-".into();
1221
1222 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1223 let plan = s.plan().unwrap();
1224 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1225 }
1226
1227 #[test]
1228 fn execute_floating_tags_force_create_and_push() {
1229 let mut config = ReleaseConfig::default();
1230 config.floating_tags = true;
1231
1232 let tag = TagInfo {
1233 name: "v1.2.3".into(),
1234 version: Version::new(1, 2, 3),
1235 sha: "f".repeat(40),
1236 };
1237 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1238 let plan = s.plan().unwrap();
1239 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1240
1241 s.execute(&plan, false).unwrap();
1242
1243 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1244 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1245 }
1246
1247 #[test]
1248 fn execute_no_floating_tags_when_disabled() {
1249 let s = make_strategy(
1250 vec![],
1251 vec![raw_commit("feat: something")],
1252 ReleaseConfig::default(),
1253 );
1254 let plan = s.plan().unwrap();
1255 assert!(plan.floating_tag_name.is_none());
1256
1257 s.execute(&plan, false).unwrap();
1258
1259 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1260 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1261 }
1262
1263 #[test]
1264 fn execute_floating_tags_dry_run_no_side_effects() {
1265 let mut config = ReleaseConfig::default();
1266 config.floating_tags = true;
1267
1268 let tag = TagInfo {
1269 name: "v2.0.0".into(),
1270 version: Version::new(2, 0, 0),
1271 sha: "a".repeat(40),
1272 };
1273 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1274 let plan = s.plan().unwrap();
1275 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1276
1277 s.execute(&plan, true).unwrap();
1278
1279 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1280 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1281 }
1282
1283 #[test]
1284 fn execute_floating_tags_idempotent() {
1285 let mut config = ReleaseConfig::default();
1286 config.floating_tags = true;
1287
1288 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1289 let plan = s.plan().unwrap();
1290 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1291
1292 s.execute(&plan, false).unwrap();
1294 s.execute(&plan, false).unwrap();
1295
1296 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1298 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1299 }
1300
1301 #[test]
1302 fn hook_context_includes_floating_tag() {
1303 let mut config = ReleaseConfig::default();
1304 config.floating_tags = true;
1305 config.hooks.pre_release = vec!["echo pre".into()];
1306
1307 let tag = TagInfo {
1308 name: "v3.0.0".into(),
1309 version: Version::new(3, 0, 0),
1310 sha: "b".repeat(40),
1311 };
1312 let s = make_strategy(vec![tag], vec![raw_commit("feat: new")], config);
1313 let plan = s.plan().unwrap();
1314 s.execute(&plan, false).unwrap();
1315
1316 let ctx_log = s.hooks.ctx_log.lock().unwrap();
1317 assert!(!ctx_log.is_empty());
1318 assert_eq!(ctx_log[0].env.get("SR_FLOATING_TAG").unwrap(), "v3");
1319 }
1320
1321 #[test]
1322 fn hook_context_floating_tag_empty_when_disabled() {
1323 let mut config = ReleaseConfig::default();
1324 config.hooks.pre_release = vec!["echo pre".into()];
1325
1326 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1327 let plan = s.plan().unwrap();
1328 s.execute(&plan, false).unwrap();
1329
1330 let ctx_log = s.hooks.ctx_log.lock().unwrap();
1331 assert!(!ctx_log.is_empty());
1332 assert_eq!(ctx_log[0].env.get("SR_FLOATING_TAG").unwrap(), "");
1333 }
1334
1335 #[test]
1338 fn force_rerelease_when_tag_at_head() {
1339 let tag = TagInfo {
1340 name: "v1.2.3".into(),
1341 version: Version::new(1, 2, 3),
1342 sha: "a".repeat(40),
1343 };
1344 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1345 s.git.head = "a".repeat(40);
1347 s.force = true;
1348
1349 let plan = s.plan().unwrap();
1350 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1351 assert_eq!(plan.tag_name, "v1.2.3");
1352 assert!(plan.commits.is_empty());
1353 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1354 }
1355
1356 #[test]
1357 fn force_fails_when_tag_not_at_head() {
1358 let tag = TagInfo {
1359 name: "v1.2.3".into(),
1360 version: Version::new(1, 2, 3),
1361 sha: "a".repeat(40),
1362 };
1363 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1364 s.git.head = "b".repeat(40);
1366 s.force = true;
1367
1368 let err = s.plan().unwrap_err();
1369 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1370 }
1371
1372 #[test]
1373 fn force_fails_with_no_tags() {
1374 let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1375 s.force = true;
1376
1377 let err = s.plan().unwrap_err();
1378 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1379 }
1380}