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