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}
33
34#[derive(Debug, Serialize)]
36pub struct ReleasePlan {
37 pub current_version: Option<Version>,
38 pub next_version: Version,
39 pub bump: BumpLevel,
40 pub commits: Vec<ConventionalCommit>,
41 pub tag_name: String,
42}
43
44pub trait ReleaseStrategy: Send + Sync {
46 fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
48
49 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
51}
52
53pub trait VcsProvider: Send + Sync {
55 fn create_release(
57 &self,
58 tag: &str,
59 name: &str,
60 body: &str,
61 prerelease: bool,
62 ) -> Result<String, ReleaseError>;
63
64 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
66
67 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
69
70 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
72
73 fn repo_url(&self) -> Option<String> {
75 None
76 }
77
78 fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
80 Ok(())
81 }
82
83 fn resolve_contributors(
87 &self,
88 _author_shas: &[(&str, &str)],
89 ) -> std::collections::HashMap<String, String> {
90 std::collections::HashMap::new()
91 }
92}
93
94pub struct TrunkReleaseStrategy<G, V, C, F, H> {
96 pub git: G,
97 pub vcs: Option<V>,
98 pub parser: C,
99 pub formatter: F,
100 pub hooks: H,
101 pub config: ReleaseConfig,
102}
103
104fn to_hook_commands(commands: &[String]) -> Vec<HookCommand> {
105 commands
106 .iter()
107 .map(|c| HookCommand { command: c.clone() })
108 .collect()
109}
110
111impl<G, V, C, F, H> TrunkReleaseStrategy<G, V, C, F, H>
112where
113 G: GitRepository,
114 V: VcsProvider,
115 C: CommitParser,
116 F: ChangelogFormatter,
117 H: HookRunner,
118{
119 fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
120 let today = today_string();
121 let mut entry = ChangelogEntry {
122 version: plan.next_version.to_string(),
123 date: today,
124 commits: plan.commits.clone(),
125 compare_url: None,
126 repo_url: self.vcs.as_ref().and_then(|v| v.repo_url()),
127 contributor_map: std::collections::HashMap::new(),
128 };
129 if let Some(ref vcs) = self.vcs {
130 let author_shas = entry.unique_author_shas();
131 entry.contributor_map = vcs.resolve_contributors(&author_shas);
132 }
133 self.formatter.format(&[entry])
134 }
135}
136
137impl<G, V, C, F, H> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F, H>
138where
139 G: GitRepository,
140 V: VcsProvider,
141 C: CommitParser,
142 F: ChangelogFormatter,
143 H: HookRunner,
144{
145 fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
146 let tag_info = self.git.latest_tag(&self.config.tag_prefix)?;
147
148 let (current_version, from_sha) = match &tag_info {
149 Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
150 None => (None, None),
151 };
152
153 let raw_commits = self.git.commits_since(from_sha)?;
154 if raw_commits.is_empty() {
155 return Err(ReleaseError::NoCommits);
156 }
157
158 let conventional_commits: Vec<ConventionalCommit> = raw_commits
159 .iter()
160 .filter_map(|c| self.parser.parse(c).ok())
161 .collect();
162
163 let classifier = DefaultCommitClassifier::new(
164 self.config.types.clone(),
165 self.config.commit_pattern.clone(),
166 );
167 let bump =
168 determine_bump(&conventional_commits, &classifier).ok_or(ReleaseError::NoBump)?;
169
170 let base_version = current_version.clone().unwrap_or(Version::new(0, 0, 0));
171 let next_version = apply_bump(&base_version, bump);
172 let tag_name = format!("{}{next_version}", self.config.tag_prefix);
173
174 Ok(ReleasePlan {
175 current_version,
176 next_version,
177 bump,
178 commits: conventional_commits,
179 tag_name,
180 })
181 }
182
183 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
184 if dry_run {
185 let changelog_body = self.format_changelog(plan)?;
186 let ctx = build_hook_context(plan, "pre_release", true);
187 let mut env_keys: Vec<&String> = ctx.env.keys().collect();
188 env_keys.sort();
189 eprintln!("[dry-run] Hook environment variables:");
190 for key in env_keys {
191 eprintln!("[dry-run] {key}={}", ctx.env[key]);
192 }
193 eprintln!("[dry-run] Would create tag: {}", plan.tag_name);
194 eprintln!("[dry-run] Would push tag: {}", plan.tag_name);
195 if self.vcs.is_some() {
196 eprintln!(
197 "[dry-run] Would create GitHub release for {}",
198 plan.tag_name
199 );
200 }
201 for file in &self.config.version_files {
202 let filename = Path::new(file)
203 .file_name()
204 .and_then(|n| n.to_str())
205 .unwrap_or_default();
206 let supported = matches!(
207 filename,
208 "Cargo.toml"
209 | "package.json"
210 | "pyproject.toml"
211 | "pom.xml"
212 | "build.gradle"
213 | "build.gradle.kts"
214 ) || filename.ends_with(".go");
215 if supported {
216 eprintln!("[dry-run] Would bump version in: {file}");
217 } else if self.config.version_files_strict {
218 return Err(ReleaseError::VersionBump(format!(
219 "unsupported version file: {filename}"
220 )));
221 } else {
222 eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
223 }
224 }
225 if !self.config.artifacts.is_empty() {
226 let resolved = resolve_artifact_globs(&self.config.artifacts)?;
227 if resolved.is_empty() {
228 eprintln!("[dry-run] Artifact patterns matched no files");
229 } else {
230 eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
231 for f in &resolved {
232 eprintln!("[dry-run] {f}");
233 }
234 }
235 }
236 for hook in &self.config.hooks.pre_release {
237 eprintln!("[dry-run] Would run pre-release hook: {hook}");
238 }
239 for hook in &self.config.hooks.post_tag {
240 eprintln!("[dry-run] Would run post-tag hook: {hook}");
241 }
242 for hook in &self.config.hooks.post_release {
243 eprintln!("[dry-run] Would run post-release hook: {hook}");
244 }
245 eprintln!("[dry-run] Changelog:\n{changelog_body}");
246 return Ok(());
247 }
248
249 let failure_ctx = build_hook_context(plan, "on_failure", false);
250 let run_failure_hooks = |err: ReleaseError| -> ReleaseError {
251 let _ = self.hooks.run(
252 &to_hook_commands(&self.config.hooks.on_failure),
253 &failure_ctx,
254 );
255 err
256 };
257
258 let pre_ctx = build_hook_context(plan, "pre_release", false);
260 self.hooks
261 .run(&to_hook_commands(&self.config.hooks.pre_release), &pre_ctx)
262 .map_err(&run_failure_hooks)?;
263
264 let changelog_body = self.format_changelog(plan).map_err(&run_failure_hooks)?;
266
267 let version_str = plan.next_version.to_string();
269 let mut bumped_files: Vec<&str> = Vec::new();
270 for file in &self.config.version_files {
271 match bump_version_file(Path::new(file), &version_str) {
272 Ok(()) => bumped_files.push(file.as_str()),
273 Err(e) if !self.config.version_files_strict => {
274 eprintln!("warning: {e} — skipping {file}");
275 }
276 Err(e) => return Err(run_failure_hooks(e)),
277 }
278 }
279
280 if let Some(ref changelog_file) = self.config.changelog.file {
282 let path = Path::new(changelog_file);
283 let existing = if path.exists() {
284 fs::read_to_string(path)
285 .map_err(|e| ReleaseError::Changelog(e.to_string()))
286 .map_err(&run_failure_hooks)?
287 } else {
288 String::new()
289 };
290 let new_content = if existing.is_empty() {
291 format!("# Changelog\n\n{changelog_body}\n")
292 } else {
293 match existing.find("\n\n") {
295 Some(pos) => {
296 let (header, rest) = existing.split_at(pos);
297 format!("{header}\n\n{changelog_body}\n{rest}")
298 }
299 None => format!("{existing}\n\n{changelog_body}\n"),
300 }
301 };
302 fs::write(path, new_content)
303 .map_err(|e| ReleaseError::Changelog(e.to_string()))
304 .map_err(&run_failure_hooks)?;
305 }
306
307 {
309 let mut paths_to_stage: Vec<&str> = Vec::new();
310 if let Some(ref changelog_file) = self.config.changelog.file {
311 paths_to_stage.push(changelog_file.as_str());
312 }
313 for file in &bumped_files {
314 paths_to_stage.push(*file);
315 }
316 if !paths_to_stage.is_empty() {
317 let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
318 self.git
319 .stage_and_commit(&paths_to_stage, &commit_msg)
320 .map_err(&run_failure_hooks)?;
321 }
322 }
323
324 if !self
326 .git
327 .tag_exists(&plan.tag_name)
328 .map_err(&run_failure_hooks)?
329 {
330 self.git
331 .create_tag(&plan.tag_name, &changelog_body)
332 .map_err(&run_failure_hooks)?;
333 }
334
335 self.git.push().map_err(&run_failure_hooks)?;
337
338 if !self
340 .git
341 .remote_tag_exists(&plan.tag_name)
342 .map_err(&run_failure_hooks)?
343 {
344 self.git
345 .push_tag(&plan.tag_name)
346 .map_err(&run_failure_hooks)?;
347 }
348
349 let post_tag_ctx = build_hook_context(plan, "post_tag", false);
351 self.hooks
352 .run(
353 &to_hook_commands(&self.config.hooks.post_tag),
354 &post_tag_ctx,
355 )
356 .map_err(&run_failure_hooks)?;
357
358 if let Some(ref vcs) = self.vcs {
360 let release_name = format!("{} {}", self.config.tag_prefix, plan.next_version);
361 if vcs
362 .release_exists(&plan.tag_name)
363 .map_err(&run_failure_hooks)?
364 {
365 vcs.delete_release(&plan.tag_name)
367 .map_err(&run_failure_hooks)?;
368 }
369 vcs.create_release(&plan.tag_name, &release_name, &changelog_body, false)
370 .map_err(&run_failure_hooks)?;
371 }
372
373 if let Some(ref vcs) = self.vcs
375 && !self.config.artifacts.is_empty()
376 {
377 let resolved =
378 resolve_artifact_globs(&self.config.artifacts).map_err(&run_failure_hooks)?;
379 if !resolved.is_empty() {
380 let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
381 vcs.upload_assets(&plan.tag_name, &file_refs)
382 .map_err(&run_failure_hooks)?;
383 eprintln!(
384 "Uploaded {} artifact(s) to {}",
385 resolved.len(),
386 plan.tag_name
387 );
388 }
389 }
390
391 let post_release_ctx = build_hook_context(plan, "post_release", false);
393 self.hooks
394 .run(
395 &to_hook_commands(&self.config.hooks.post_release),
396 &post_release_ctx,
397 )
398 .map_err(&run_failure_hooks)?;
399
400 eprintln!("Released {}", plan.tag_name);
401 Ok(())
402 }
403}
404
405fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
406 let mut files = std::collections::BTreeSet::new();
407 for pattern in patterns {
408 let paths = glob::glob(pattern)
409 .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
410 for entry in paths {
411 match entry {
412 Ok(path) if path.is_file() => {
413 files.insert(path.to_string_lossy().into_owned());
414 }
415 Ok(_) => {} Err(e) => {
417 eprintln!("warning: glob error: {e}");
418 }
419 }
420 }
421 }
422 Ok(files.into_iter().collect())
423}
424
425pub fn today_string() -> String {
426 std::process::Command::new("date")
428 .arg("+%Y-%m-%d")
429 .output()
430 .ok()
431 .and_then(|o| {
432 if o.status.success() {
433 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
434 } else {
435 None
436 }
437 })
438 .unwrap_or_else(|| "unknown".to_string())
439}
440
441#[cfg(test)]
442mod tests {
443 use std::sync::Mutex;
444
445 use super::*;
446 use crate::changelog::DefaultChangelogFormatter;
447 use crate::commit::{Commit, DefaultCommitParser};
448 use crate::config::ReleaseConfig;
449 use crate::git::{GitRepository, TagInfo};
450 use crate::hooks::{HookCommand, HookContext, HookRunner};
451
452 struct FakeGit {
455 tags: Vec<TagInfo>,
456 commits: Vec<Commit>,
457 created_tags: Mutex<Vec<String>>,
458 pushed_tags: Mutex<Vec<String>>,
459 committed: Mutex<Vec<(Vec<String>, String)>>,
460 push_count: Mutex<u32>,
461 }
462
463 impl FakeGit {
464 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
465 Self {
466 tags,
467 commits,
468 created_tags: Mutex::new(Vec::new()),
469 pushed_tags: Mutex::new(Vec::new()),
470 committed: Mutex::new(Vec::new()),
471 push_count: Mutex::new(0),
472 }
473 }
474 }
475
476 impl GitRepository for FakeGit {
477 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
478 Ok(self.tags.last().cloned())
479 }
480
481 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
482 Ok(self.commits.clone())
483 }
484
485 fn create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
486 self.created_tags.lock().unwrap().push(name.to_string());
487 Ok(())
488 }
489
490 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
491 self.pushed_tags.lock().unwrap().push(name.to_string());
492 Ok(())
493 }
494
495 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
496 self.committed.lock().unwrap().push((
497 paths.iter().map(|s| s.to_string()).collect(),
498 message.to_string(),
499 ));
500 Ok(true)
501 }
502
503 fn push(&self) -> Result<(), ReleaseError> {
504 *self.push_count.lock().unwrap() += 1;
505 Ok(())
506 }
507
508 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
509 Ok(self
510 .created_tags
511 .lock()
512 .unwrap()
513 .contains(&name.to_string()))
514 }
515
516 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
517 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
518 }
519
520 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
521 Ok(self.tags.clone())
522 }
523
524 fn commits_between(
525 &self,
526 _from: Option<&str>,
527 _to: &str,
528 ) -> Result<Vec<Commit>, ReleaseError> {
529 Ok(self.commits.clone())
530 }
531
532 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
533 Ok("2026-01-01".into())
534 }
535 }
536
537 struct FakeVcs {
538 releases: Mutex<Vec<(String, String)>>,
539 deleted_releases: Mutex<Vec<String>>,
540 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
541 }
542
543 impl FakeVcs {
544 fn new() -> Self {
545 Self {
546 releases: Mutex::new(Vec::new()),
547 deleted_releases: Mutex::new(Vec::new()),
548 uploaded_assets: Mutex::new(Vec::new()),
549 }
550 }
551 }
552
553 impl VcsProvider for FakeVcs {
554 fn create_release(
555 &self,
556 tag: &str,
557 _name: &str,
558 body: &str,
559 _prerelease: bool,
560 ) -> Result<String, ReleaseError> {
561 self.releases
562 .lock()
563 .unwrap()
564 .push((tag.to_string(), body.to_string()));
565 Ok(format!("https://github.com/test/release/{tag}"))
566 }
567
568 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
569 Ok(format!("https://github.com/test/compare/{base}...{head}"))
570 }
571
572 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
573 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
574 }
575
576 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
577 self.deleted_releases.lock().unwrap().push(tag.to_string());
578 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
579 Ok(())
580 }
581
582 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
583 self.uploaded_assets.lock().unwrap().push((
584 tag.to_string(),
585 files.iter().map(|s| s.to_string()).collect(),
586 ));
587 Ok(())
588 }
589
590 fn repo_url(&self) -> Option<String> {
591 Some("https://github.com/test/repo".into())
592 }
593 }
594
595 struct FakeHooks {
596 run_log: Mutex<Vec<String>>,
597 ctx_log: Mutex<Vec<HookContext>>,
598 }
599
600 impl FakeHooks {
601 fn new() -> Self {
602 Self {
603 run_log: Mutex::new(Vec::new()),
604 ctx_log: Mutex::new(Vec::new()),
605 }
606 }
607 }
608
609 impl HookRunner for FakeHooks {
610 fn run(&self, hooks: &[HookCommand], ctx: &HookContext) -> Result<(), ReleaseError> {
611 for h in hooks {
612 self.run_log.lock().unwrap().push(h.command.clone());
613 }
614 if !hooks.is_empty() {
615 self.ctx_log.lock().unwrap().push(ctx.clone());
616 }
617 Ok(())
618 }
619 }
620
621 fn raw_commit(msg: &str) -> Commit {
624 Commit {
625 sha: "a".repeat(40),
626 message: msg.into(),
627 author: None,
628 }
629 }
630
631 fn make_strategy(
632 tags: Vec<TagInfo>,
633 commits: Vec<Commit>,
634 config: ReleaseConfig,
635 ) -> TrunkReleaseStrategy<
636 FakeGit,
637 FakeVcs,
638 DefaultCommitParser,
639 DefaultChangelogFormatter,
640 FakeHooks,
641 > {
642 let types = config.types.clone();
643 let breaking_section = config.breaking_section.clone();
644 let misc_section = config.misc_section.clone();
645 TrunkReleaseStrategy {
646 git: FakeGit::new(tags, commits),
647 vcs: Some(FakeVcs::new()),
648 parser: DefaultCommitParser,
649 formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
650 hooks: FakeHooks::new(),
651 config,
652 }
653 }
654
655 #[test]
658 fn plan_no_commits_returns_error() {
659 let s = make_strategy(vec![], vec![], ReleaseConfig::default());
660 let err = s.plan().unwrap_err();
661 assert!(matches!(err, ReleaseError::NoCommits));
662 }
663
664 #[test]
665 fn plan_no_releasable_returns_error() {
666 let s = make_strategy(
667 vec![],
668 vec![raw_commit("chore: tidy up")],
669 ReleaseConfig::default(),
670 );
671 let err = s.plan().unwrap_err();
672 assert!(matches!(err, ReleaseError::NoBump));
673 }
674
675 #[test]
676 fn plan_first_release() {
677 let s = make_strategy(
678 vec![],
679 vec![raw_commit("feat: initial feature")],
680 ReleaseConfig::default(),
681 );
682 let plan = s.plan().unwrap();
683 assert_eq!(plan.next_version, Version::new(0, 1, 0));
684 assert_eq!(plan.tag_name, "v0.1.0");
685 assert!(plan.current_version.is_none());
686 }
687
688 #[test]
689 fn plan_increments_existing() {
690 let tag = TagInfo {
691 name: "v1.2.3".into(),
692 version: Version::new(1, 2, 3),
693 sha: "b".repeat(40),
694 };
695 let s = make_strategy(
696 vec![tag],
697 vec![raw_commit("fix: patch bug")],
698 ReleaseConfig::default(),
699 );
700 let plan = s.plan().unwrap();
701 assert_eq!(plan.next_version, Version::new(1, 2, 4));
702 }
703
704 #[test]
705 fn plan_breaking_bump() {
706 let tag = TagInfo {
707 name: "v1.2.3".into(),
708 version: Version::new(1, 2, 3),
709 sha: "c".repeat(40),
710 };
711 let s = make_strategy(
712 vec![tag],
713 vec![raw_commit("feat!: breaking change")],
714 ReleaseConfig::default(),
715 );
716 let plan = s.plan().unwrap();
717 assert_eq!(plan.next_version, Version::new(2, 0, 0));
718 }
719
720 #[test]
723 fn execute_dry_run_no_side_effects() {
724 let mut config = ReleaseConfig::default();
725 config.hooks.pre_release = vec!["echo pre".into()];
726 config.hooks.post_tag = vec!["echo post-tag".into()];
727 config.hooks.post_release = vec!["echo post-release".into()];
728
729 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
730 let plan = s.plan().unwrap();
731 s.execute(&plan, true).unwrap();
732
733 assert!(s.git.created_tags.lock().unwrap().is_empty());
734 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
735 assert!(s.hooks.run_log.lock().unwrap().is_empty());
736 }
737
738 #[test]
739 fn execute_creates_and_pushes_tag() {
740 let s = make_strategy(
741 vec![],
742 vec![raw_commit("feat: something")],
743 ReleaseConfig::default(),
744 );
745 let plan = s.plan().unwrap();
746 s.execute(&plan, false).unwrap();
747
748 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
749 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
750 }
751
752 #[test]
753 fn execute_runs_hooks_in_order() {
754 let mut config = ReleaseConfig::default();
755 config.hooks.pre_release = vec!["echo pre".into()];
756 config.hooks.post_tag = vec!["echo post-tag".into()];
757 config.hooks.post_release = vec!["echo post-release".into()];
758
759 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
760 let plan = s.plan().unwrap();
761 s.execute(&plan, false).unwrap();
762
763 let log = s.hooks.run_log.lock().unwrap();
764 assert_eq!(*log, vec!["echo pre", "echo post-tag", "echo post-release"]);
765 }
766
767 #[test]
768 fn execute_calls_vcs_create_release() {
769 let s = make_strategy(
770 vec![],
771 vec![raw_commit("feat: something")],
772 ReleaseConfig::default(),
773 );
774 let plan = s.plan().unwrap();
775 s.execute(&plan, false).unwrap();
776
777 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
778 assert_eq!(releases.len(), 1);
779 assert_eq!(releases[0].0, "v0.1.0");
780 assert!(!releases[0].1.is_empty());
781 }
782
783 #[test]
784 fn execute_commits_changelog_before_tag() {
785 let dir = tempfile::tempdir().unwrap();
786 let changelog_path = dir.path().join("CHANGELOG.md");
787
788 let mut config = ReleaseConfig::default();
789 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
790
791 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
792 let plan = s.plan().unwrap();
793 s.execute(&plan, false).unwrap();
794
795 let committed = s.git.committed.lock().unwrap();
797 assert_eq!(committed.len(), 1);
798 assert_eq!(
799 committed[0].0,
800 vec![changelog_path.to_str().unwrap().to_string()]
801 );
802 assert!(committed[0].1.contains("chore(release): v0.1.0"));
803
804 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
806 }
807
808 #[test]
809 fn execute_skips_existing_tag() {
810 let s = make_strategy(
811 vec![],
812 vec![raw_commit("feat: something")],
813 ReleaseConfig::default(),
814 );
815 let plan = s.plan().unwrap();
816
817 s.git
819 .created_tags
820 .lock()
821 .unwrap()
822 .push("v0.1.0".to_string());
823
824 s.execute(&plan, false).unwrap();
825
826 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
828 }
829
830 #[test]
831 fn execute_skips_existing_release() {
832 let s = make_strategy(
833 vec![],
834 vec![raw_commit("feat: something")],
835 ReleaseConfig::default(),
836 );
837 let plan = s.plan().unwrap();
838
839 s.vcs
841 .as_ref()
842 .unwrap()
843 .releases
844 .lock()
845 .unwrap()
846 .push(("v0.1.0".to_string(), "old notes".to_string()));
847
848 s.execute(&plan, false).unwrap();
849
850 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
852 assert_eq!(*deleted, vec!["v0.1.0"]);
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_ne!(releases[0].1, "old notes");
858 }
859
860 #[test]
861 fn execute_idempotent_rerun() {
862 let s = make_strategy(
863 vec![],
864 vec![raw_commit("feat: something")],
865 ReleaseConfig::default(),
866 );
867 let plan = s.plan().unwrap();
868
869 s.execute(&plan, false).unwrap();
871
872 s.execute(&plan, false).unwrap();
874
875 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
877
878 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
880
881 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
883
884 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
886 assert_eq!(*deleted, vec!["v0.1.0"]);
887
888 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
889 assert_eq!(releases.len(), 1);
891 assert_eq!(releases[0].0, "v0.1.0");
892 }
893
894 #[test]
895 fn execute_passes_hook_context_with_version_info() {
896 let tag = TagInfo {
897 name: "v1.2.3".into(),
898 version: Version::new(1, 2, 3),
899 sha: "b".repeat(40),
900 };
901 let mut config = ReleaseConfig::default();
902 config.hooks.pre_release = vec!["echo pre".into()];
903 config.hooks.post_tag = vec!["echo post-tag".into()];
904 config.hooks.post_release = vec!["echo post-release".into()];
905
906 let s = make_strategy(
907 vec![tag],
908 vec![raw_commit("feat: first"), raw_commit("fix: second")],
909 config,
910 );
911 let plan = s.plan().unwrap();
912 s.execute(&plan, false).unwrap();
913
914 let ctx_log = s.hooks.ctx_log.lock().unwrap();
915 assert_eq!(ctx_log.len(), 3);
916
917 let pre = &ctx_log[0];
919 assert_eq!(pre.env.get("SR_VERSION").unwrap(), "1.3.0");
920 assert_eq!(pre.env.get("SR_PREVIOUS_VERSION").unwrap(), "1.2.3");
921 assert_eq!(pre.env.get("SR_TAG").unwrap(), "v1.3.0");
922 assert_eq!(pre.env.get("SR_BUMP_LEVEL").unwrap(), "minor");
923 assert_eq!(pre.env.get("SR_DRY_RUN").unwrap(), "false");
924 assert_eq!(pre.env.get("SR_COMMIT_COUNT").unwrap(), "2");
925 assert_eq!(pre.env.get("SR_HOOK_PHASE").unwrap(), "pre_release");
926
927 let post_tag = &ctx_log[1];
929 assert_eq!(post_tag.env.get("SR_HOOK_PHASE").unwrap(), "post_tag");
930 assert_eq!(post_tag.env.get("SR_VERSION").unwrap(), "1.3.0");
931
932 let post_release = &ctx_log[2];
934 assert_eq!(
935 post_release.env.get("SR_HOOK_PHASE").unwrap(),
936 "post_release"
937 );
938 assert_eq!(post_release.env.get("SR_VERSION").unwrap(), "1.3.0");
939 }
940
941 #[test]
942 fn hook_context_first_release_has_empty_previous_version() {
943 let mut config = ReleaseConfig::default();
944 config.hooks.pre_release = vec!["echo pre".into()];
945
946 let s = make_strategy(vec![], vec![raw_commit("feat: initial")], config);
947 let plan = s.plan().unwrap();
948 s.execute(&plan, false).unwrap();
949
950 let ctx_log = s.hooks.ctx_log.lock().unwrap();
951 assert!(!ctx_log.is_empty());
952 let pre = &ctx_log[0];
953 assert_eq!(pre.env.get("SR_PREVIOUS_VERSION").unwrap(), "");
954 assert_eq!(pre.env.get("SR_VERSION").unwrap(), "0.1.0");
955 }
956
957 #[test]
958 fn execute_bumps_version_files() {
959 let dir = tempfile::tempdir().unwrap();
960 let cargo_path = dir.path().join("Cargo.toml");
961 std::fs::write(
962 &cargo_path,
963 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
964 )
965 .unwrap();
966
967 let mut config = ReleaseConfig::default();
968 config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
969
970 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
971 let plan = s.plan().unwrap();
972 s.execute(&plan, false).unwrap();
973
974 let contents = std::fs::read_to_string(&cargo_path).unwrap();
976 assert!(contents.contains("version = \"0.1.0\""));
977
978 let committed = s.git.committed.lock().unwrap();
980 assert_eq!(committed.len(), 1);
981 assert!(
982 committed[0]
983 .0
984 .contains(&cargo_path.to_str().unwrap().to_string())
985 );
986 }
987
988 #[test]
989 fn execute_stages_changelog_and_version_files_together() {
990 let dir = tempfile::tempdir().unwrap();
991 let cargo_path = dir.path().join("Cargo.toml");
992 std::fs::write(
993 &cargo_path,
994 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
995 )
996 .unwrap();
997
998 let changelog_path = dir.path().join("CHANGELOG.md");
999
1000 let mut config = ReleaseConfig::default();
1001 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1002 config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1003
1004 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1005 let plan = s.plan().unwrap();
1006 s.execute(&plan, false).unwrap();
1007
1008 let committed = s.git.committed.lock().unwrap();
1010 assert_eq!(committed.len(), 1);
1011 assert!(
1012 committed[0]
1013 .0
1014 .contains(&changelog_path.to_str().unwrap().to_string())
1015 );
1016 assert!(
1017 committed[0]
1018 .0
1019 .contains(&cargo_path.to_str().unwrap().to_string())
1020 );
1021 }
1022
1023 #[test]
1026 fn execute_uploads_artifacts() {
1027 let dir = tempfile::tempdir().unwrap();
1028 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1029 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1030
1031 let mut config = ReleaseConfig::default();
1032 config.artifacts = vec![
1033 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1034 dir.path().join("*.zip").to_str().unwrap().to_string(),
1035 ];
1036
1037 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1038 let plan = s.plan().unwrap();
1039 s.execute(&plan, false).unwrap();
1040
1041 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1042 assert_eq!(uploaded.len(), 1);
1043 assert_eq!(uploaded[0].0, "v0.1.0");
1044 assert_eq!(uploaded[0].1.len(), 2);
1045 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1046 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1047 }
1048
1049 #[test]
1050 fn execute_dry_run_shows_artifacts() {
1051 let dir = tempfile::tempdir().unwrap();
1052 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1053
1054 let mut config = ReleaseConfig::default();
1055 config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
1056
1057 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1058 let plan = s.plan().unwrap();
1059 s.execute(&plan, true).unwrap();
1060
1061 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1063 assert!(uploaded.is_empty());
1064 }
1065
1066 #[test]
1067 fn execute_no_artifacts_skips_upload() {
1068 let s = make_strategy(
1069 vec![],
1070 vec![raw_commit("feat: something")],
1071 ReleaseConfig::default(),
1072 );
1073 let plan = s.plan().unwrap();
1074 s.execute(&plan, false).unwrap();
1075
1076 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1077 assert!(uploaded.is_empty());
1078 }
1079
1080 #[test]
1081 fn resolve_artifact_globs_basic() {
1082 let dir = tempfile::tempdir().unwrap();
1083 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1084 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1085 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1086
1087 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1088 let result = resolve_artifact_globs(&[pattern]).unwrap();
1089 assert_eq!(result.len(), 2);
1090 assert!(result.iter().any(|f| f.ends_with("a.txt")));
1091 assert!(result.iter().any(|f| f.ends_with("b.txt")));
1092 }
1093
1094 #[test]
1095 fn resolve_artifact_globs_deduplicates() {
1096 let dir = tempfile::tempdir().unwrap();
1097 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1098
1099 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1100 let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
1102 assert_eq!(result.len(), 1);
1103 }
1104}