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