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 eprintln!("[dry-run] Changelog:\n{changelog_body}");
233 return Ok(());
234 }
235
236 let changelog_body = self.format_changelog(plan)?;
238
239 let version_str = plan.next_version.to_string();
241 let mut bumped_files: Vec<&str> = Vec::new();
242 for file in &self.config.version_files {
243 match bump_version_file(Path::new(file), &version_str) {
244 Ok(()) => bumped_files.push(file.as_str()),
245 Err(e) if !self.config.version_files_strict => {
246 eprintln!("warning: {e} — skipping {file}");
247 }
248 Err(e) => return Err(e),
249 }
250 }
251
252 if let Some(ref changelog_file) = self.config.changelog.file {
254 let path = Path::new(changelog_file);
255 let existing = if path.exists() {
256 fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
257 } else {
258 String::new()
259 };
260 let new_content = if existing.is_empty() {
261 format!("# Changelog\n\n{changelog_body}\n")
262 } else {
263 match existing.find("\n\n") {
265 Some(pos) => {
266 let (header, rest) = existing.split_at(pos);
267 format!("{header}\n\n{changelog_body}\n{rest}")
268 }
269 None => format!("{existing}\n\n{changelog_body}\n"),
270 }
271 };
272 fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
273 }
274
275 {
277 let mut paths_to_stage: Vec<&str> = Vec::new();
278 if let Some(ref changelog_file) = self.config.changelog.file {
279 paths_to_stage.push(changelog_file.as_str());
280 }
281 for file in &bumped_files {
282 paths_to_stage.push(*file);
283 }
284 if !paths_to_stage.is_empty() {
285 let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
286 self.git.stage_and_commit(&paths_to_stage, &commit_msg)?;
287 }
288 }
289
290 if !self.git.tag_exists(&plan.tag_name)? {
292 self.git.create_tag(&plan.tag_name, &changelog_body)?;
293 }
294
295 self.git.push()?;
297
298 if !self.git.remote_tag_exists(&plan.tag_name)? {
300 self.git.push_tag(&plan.tag_name)?;
301 }
302
303 if let Some(ref floating) = plan.floating_tag_name {
305 let floating_msg = format!("Floating tag for {}", plan.tag_name);
306 self.git.force_create_tag(floating, &floating_msg)?;
307 self.git.force_push_tag(floating)?;
308 }
309
310 if let Some(ref vcs) = self.vcs {
312 let release_name = format!("{} {}", self.config.tag_prefix, plan.next_version);
313 if vcs.release_exists(&plan.tag_name)? {
314 vcs.delete_release(&plan.tag_name)?;
316 }
317 vcs.create_release(&plan.tag_name, &release_name, &changelog_body, false)?;
318 }
319
320 if let Some(ref vcs) = self.vcs
322 && !self.config.artifacts.is_empty()
323 {
324 let resolved = resolve_artifact_globs(&self.config.artifacts)?;
325 if !resolved.is_empty() {
326 let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
327 vcs.upload_assets(&plan.tag_name, &file_refs)?;
328 eprintln!(
329 "Uploaded {} artifact(s) to {}",
330 resolved.len(),
331 plan.tag_name
332 );
333 }
334 }
335
336 eprintln!("Released {}", plan.tag_name);
337 Ok(())
338 }
339}
340
341fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
342 let mut files = std::collections::BTreeSet::new();
343 for pattern in patterns {
344 let paths = glob::glob(pattern)
345 .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
346 for entry in paths {
347 match entry {
348 Ok(path) if path.is_file() => {
349 files.insert(path.to_string_lossy().into_owned());
350 }
351 Ok(_) => {} Err(e) => {
353 eprintln!("warning: glob error: {e}");
354 }
355 }
356 }
357 }
358 Ok(files.into_iter().collect())
359}
360
361pub fn today_string() -> String {
362 std::process::Command::new("date")
364 .arg("+%Y-%m-%d")
365 .output()
366 .ok()
367 .and_then(|o| {
368 if o.status.success() {
369 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
370 } else {
371 None
372 }
373 })
374 .unwrap_or_else(|| "unknown".to_string())
375}
376
377#[cfg(test)]
378mod tests {
379 use std::sync::Mutex;
380
381 use super::*;
382 use crate::changelog::DefaultChangelogFormatter;
383 use crate::commit::{Commit, DefaultCommitParser};
384 use crate::config::ReleaseConfig;
385 use crate::git::{GitRepository, TagInfo};
386
387 struct FakeGit {
390 tags: Vec<TagInfo>,
391 commits: Vec<Commit>,
392 head: String,
393 created_tags: Mutex<Vec<String>>,
394 pushed_tags: Mutex<Vec<String>>,
395 committed: Mutex<Vec<(Vec<String>, String)>>,
396 push_count: Mutex<u32>,
397 force_created_tags: Mutex<Vec<String>>,
398 force_pushed_tags: Mutex<Vec<String>>,
399 }
400
401 impl FakeGit {
402 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
403 let head = tags
404 .last()
405 .map(|t| t.sha.clone())
406 .unwrap_or_else(|| "0".repeat(40));
407 Self {
408 tags,
409 commits,
410 head,
411 created_tags: Mutex::new(Vec::new()),
412 pushed_tags: Mutex::new(Vec::new()),
413 committed: Mutex::new(Vec::new()),
414 push_count: Mutex::new(0),
415 force_created_tags: Mutex::new(Vec::new()),
416 force_pushed_tags: Mutex::new(Vec::new()),
417 }
418 }
419 }
420
421 impl GitRepository for FakeGit {
422 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
423 Ok(self.tags.last().cloned())
424 }
425
426 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
427 Ok(self.commits.clone())
428 }
429
430 fn create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
431 self.created_tags.lock().unwrap().push(name.to_string());
432 Ok(())
433 }
434
435 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
436 self.pushed_tags.lock().unwrap().push(name.to_string());
437 Ok(())
438 }
439
440 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
441 self.committed.lock().unwrap().push((
442 paths.iter().map(|s| s.to_string()).collect(),
443 message.to_string(),
444 ));
445 Ok(true)
446 }
447
448 fn push(&self) -> Result<(), ReleaseError> {
449 *self.push_count.lock().unwrap() += 1;
450 Ok(())
451 }
452
453 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
454 Ok(self
455 .created_tags
456 .lock()
457 .unwrap()
458 .contains(&name.to_string()))
459 }
460
461 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
462 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
463 }
464
465 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
466 Ok(self.tags.clone())
467 }
468
469 fn commits_between(
470 &self,
471 _from: Option<&str>,
472 _to: &str,
473 ) -> Result<Vec<Commit>, ReleaseError> {
474 Ok(self.commits.clone())
475 }
476
477 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
478 Ok("2026-01-01".into())
479 }
480
481 fn force_create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
482 self.force_created_tags
483 .lock()
484 .unwrap()
485 .push(name.to_string());
486 Ok(())
487 }
488
489 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
490 self.force_pushed_tags
491 .lock()
492 .unwrap()
493 .push(name.to_string());
494 Ok(())
495 }
496
497 fn head_sha(&self) -> Result<String, ReleaseError> {
498 Ok(self.head.clone())
499 }
500 }
501
502 struct FakeVcs {
503 releases: Mutex<Vec<(String, String)>>,
504 deleted_releases: Mutex<Vec<String>>,
505 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
506 }
507
508 impl FakeVcs {
509 fn new() -> Self {
510 Self {
511 releases: Mutex::new(Vec::new()),
512 deleted_releases: Mutex::new(Vec::new()),
513 uploaded_assets: Mutex::new(Vec::new()),
514 }
515 }
516 }
517
518 impl VcsProvider for FakeVcs {
519 fn create_release(
520 &self,
521 tag: &str,
522 _name: &str,
523 body: &str,
524 _prerelease: bool,
525 ) -> Result<String, ReleaseError> {
526 self.releases
527 .lock()
528 .unwrap()
529 .push((tag.to_string(), body.to_string()));
530 Ok(format!("https://github.com/test/release/{tag}"))
531 }
532
533 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
534 Ok(format!("https://github.com/test/compare/{base}...{head}"))
535 }
536
537 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
538 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
539 }
540
541 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
542 self.deleted_releases.lock().unwrap().push(tag.to_string());
543 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
544 Ok(())
545 }
546
547 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
548 self.uploaded_assets.lock().unwrap().push((
549 tag.to_string(),
550 files.iter().map(|s| s.to_string()).collect(),
551 ));
552 Ok(())
553 }
554
555 fn repo_url(&self) -> Option<String> {
556 Some("https://github.com/test/repo".into())
557 }
558 }
559
560 fn raw_commit(msg: &str) -> Commit {
563 Commit {
564 sha: "a".repeat(40),
565 message: msg.into(),
566 }
567 }
568
569 fn make_strategy(
570 tags: Vec<TagInfo>,
571 commits: Vec<Commit>,
572 config: ReleaseConfig,
573 ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
574 {
575 let types = config.types.clone();
576 let breaking_section = config.breaking_section.clone();
577 let misc_section = config.misc_section.clone();
578 TrunkReleaseStrategy {
579 git: FakeGit::new(tags, commits),
580 vcs: Some(FakeVcs::new()),
581 parser: DefaultCommitParser,
582 formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
583 config,
584 force: false,
585 }
586 }
587
588 #[test]
591 fn plan_no_commits_returns_error() {
592 let s = make_strategy(vec![], vec![], ReleaseConfig::default());
593 let err = s.plan().unwrap_err();
594 assert!(matches!(err, ReleaseError::NoCommits { .. }));
595 }
596
597 #[test]
598 fn plan_no_releasable_returns_error() {
599 let s = make_strategy(
600 vec![],
601 vec![raw_commit("chore: tidy up")],
602 ReleaseConfig::default(),
603 );
604 let err = s.plan().unwrap_err();
605 assert!(matches!(err, ReleaseError::NoBump { .. }));
606 }
607
608 #[test]
609 fn plan_first_release() {
610 let s = make_strategy(
611 vec![],
612 vec![raw_commit("feat: initial feature")],
613 ReleaseConfig::default(),
614 );
615 let plan = s.plan().unwrap();
616 assert_eq!(plan.next_version, Version::new(0, 1, 0));
617 assert_eq!(plan.tag_name, "v0.1.0");
618 assert!(plan.current_version.is_none());
619 }
620
621 #[test]
622 fn plan_increments_existing() {
623 let tag = TagInfo {
624 name: "v1.2.3".into(),
625 version: Version::new(1, 2, 3),
626 sha: "b".repeat(40),
627 };
628 let s = make_strategy(
629 vec![tag],
630 vec![raw_commit("fix: patch bug")],
631 ReleaseConfig::default(),
632 );
633 let plan = s.plan().unwrap();
634 assert_eq!(plan.next_version, Version::new(1, 2, 4));
635 }
636
637 #[test]
638 fn plan_breaking_bump() {
639 let tag = TagInfo {
640 name: "v1.2.3".into(),
641 version: Version::new(1, 2, 3),
642 sha: "c".repeat(40),
643 };
644 let s = make_strategy(
645 vec![tag],
646 vec![raw_commit("feat!: breaking change")],
647 ReleaseConfig::default(),
648 );
649 let plan = s.plan().unwrap();
650 assert_eq!(plan.next_version, Version::new(2, 0, 0));
651 }
652
653 #[test]
656 fn execute_dry_run_no_side_effects() {
657 let s = make_strategy(
658 vec![],
659 vec![raw_commit("feat: something")],
660 ReleaseConfig::default(),
661 );
662 let plan = s.plan().unwrap();
663 s.execute(&plan, true).unwrap();
664
665 assert!(s.git.created_tags.lock().unwrap().is_empty());
666 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
667 }
668
669 #[test]
670 fn execute_creates_and_pushes_tag() {
671 let s = make_strategy(
672 vec![],
673 vec![raw_commit("feat: something")],
674 ReleaseConfig::default(),
675 );
676 let plan = s.plan().unwrap();
677 s.execute(&plan, false).unwrap();
678
679 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
680 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
681 }
682
683 #[test]
684 fn execute_calls_vcs_create_release() {
685 let s = make_strategy(
686 vec![],
687 vec![raw_commit("feat: something")],
688 ReleaseConfig::default(),
689 );
690 let plan = s.plan().unwrap();
691 s.execute(&plan, false).unwrap();
692
693 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
694 assert_eq!(releases.len(), 1);
695 assert_eq!(releases[0].0, "v0.1.0");
696 assert!(!releases[0].1.is_empty());
697 }
698
699 #[test]
700 fn execute_commits_changelog_before_tag() {
701 let dir = tempfile::tempdir().unwrap();
702 let changelog_path = dir.path().join("CHANGELOG.md");
703
704 let mut config = ReleaseConfig::default();
705 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
706
707 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
708 let plan = s.plan().unwrap();
709 s.execute(&plan, false).unwrap();
710
711 let committed = s.git.committed.lock().unwrap();
713 assert_eq!(committed.len(), 1);
714 assert_eq!(
715 committed[0].0,
716 vec![changelog_path.to_str().unwrap().to_string()]
717 );
718 assert!(committed[0].1.contains("chore(release): v0.1.0"));
719
720 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
722 }
723
724 #[test]
725 fn execute_skips_existing_tag() {
726 let s = make_strategy(
727 vec![],
728 vec![raw_commit("feat: something")],
729 ReleaseConfig::default(),
730 );
731 let plan = s.plan().unwrap();
732
733 s.git
735 .created_tags
736 .lock()
737 .unwrap()
738 .push("v0.1.0".to_string());
739
740 s.execute(&plan, false).unwrap();
741
742 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
744 }
745
746 #[test]
747 fn execute_skips_existing_release() {
748 let s = make_strategy(
749 vec![],
750 vec![raw_commit("feat: something")],
751 ReleaseConfig::default(),
752 );
753 let plan = s.plan().unwrap();
754
755 s.vcs
757 .as_ref()
758 .unwrap()
759 .releases
760 .lock()
761 .unwrap()
762 .push(("v0.1.0".to_string(), "old notes".to_string()));
763
764 s.execute(&plan, false).unwrap();
765
766 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
768 assert_eq!(*deleted, vec!["v0.1.0"]);
769
770 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
771 assert_eq!(releases.len(), 1);
772 assert_eq!(releases[0].0, "v0.1.0");
773 assert_ne!(releases[0].1, "old notes");
774 }
775
776 #[test]
777 fn execute_idempotent_rerun() {
778 let s = make_strategy(
779 vec![],
780 vec![raw_commit("feat: something")],
781 ReleaseConfig::default(),
782 );
783 let plan = s.plan().unwrap();
784
785 s.execute(&plan, false).unwrap();
787
788 s.execute(&plan, false).unwrap();
790
791 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
793
794 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
796
797 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
799
800 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
802 assert_eq!(*deleted, vec!["v0.1.0"]);
803
804 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
805 assert_eq!(releases.len(), 1);
807 assert_eq!(releases[0].0, "v0.1.0");
808 }
809
810 #[test]
811 fn execute_bumps_version_files() {
812 let dir = tempfile::tempdir().unwrap();
813 let cargo_path = dir.path().join("Cargo.toml");
814 std::fs::write(
815 &cargo_path,
816 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
817 )
818 .unwrap();
819
820 let mut config = ReleaseConfig::default();
821 config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
822
823 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
824 let plan = s.plan().unwrap();
825 s.execute(&plan, false).unwrap();
826
827 let contents = std::fs::read_to_string(&cargo_path).unwrap();
829 assert!(contents.contains("version = \"0.1.0\""));
830
831 let committed = s.git.committed.lock().unwrap();
833 assert_eq!(committed.len(), 1);
834 assert!(
835 committed[0]
836 .0
837 .contains(&cargo_path.to_str().unwrap().to_string())
838 );
839 }
840
841 #[test]
842 fn execute_stages_changelog_and_version_files_together() {
843 let dir = tempfile::tempdir().unwrap();
844 let cargo_path = dir.path().join("Cargo.toml");
845 std::fs::write(
846 &cargo_path,
847 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
848 )
849 .unwrap();
850
851 let changelog_path = dir.path().join("CHANGELOG.md");
852
853 let mut config = ReleaseConfig::default();
854 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
855 config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
856
857 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
858 let plan = s.plan().unwrap();
859 s.execute(&plan, false).unwrap();
860
861 let committed = s.git.committed.lock().unwrap();
863 assert_eq!(committed.len(), 1);
864 assert!(
865 committed[0]
866 .0
867 .contains(&changelog_path.to_str().unwrap().to_string())
868 );
869 assert!(
870 committed[0]
871 .0
872 .contains(&cargo_path.to_str().unwrap().to_string())
873 );
874 }
875
876 #[test]
879 fn execute_uploads_artifacts() {
880 let dir = tempfile::tempdir().unwrap();
881 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
882 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
883
884 let mut config = ReleaseConfig::default();
885 config.artifacts = vec![
886 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
887 dir.path().join("*.zip").to_str().unwrap().to_string(),
888 ];
889
890 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
891 let plan = s.plan().unwrap();
892 s.execute(&plan, false).unwrap();
893
894 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
895 assert_eq!(uploaded.len(), 1);
896 assert_eq!(uploaded[0].0, "v0.1.0");
897 assert_eq!(uploaded[0].1.len(), 2);
898 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
899 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
900 }
901
902 #[test]
903 fn execute_dry_run_shows_artifacts() {
904 let dir = tempfile::tempdir().unwrap();
905 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
906
907 let mut config = ReleaseConfig::default();
908 config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
909
910 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
911 let plan = s.plan().unwrap();
912 s.execute(&plan, true).unwrap();
913
914 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
916 assert!(uploaded.is_empty());
917 }
918
919 #[test]
920 fn execute_no_artifacts_skips_upload() {
921 let s = make_strategy(
922 vec![],
923 vec![raw_commit("feat: something")],
924 ReleaseConfig::default(),
925 );
926 let plan = s.plan().unwrap();
927 s.execute(&plan, false).unwrap();
928
929 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
930 assert!(uploaded.is_empty());
931 }
932
933 #[test]
934 fn resolve_artifact_globs_basic() {
935 let dir = tempfile::tempdir().unwrap();
936 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
937 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
938 std::fs::create_dir(dir.path().join("subdir")).unwrap();
939
940 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
941 let result = resolve_artifact_globs(&[pattern]).unwrap();
942 assert_eq!(result.len(), 2);
943 assert!(result.iter().any(|f| f.ends_with("a.txt")));
944 assert!(result.iter().any(|f| f.ends_with("b.txt")));
945 }
946
947 #[test]
948 fn resolve_artifact_globs_deduplicates() {
949 let dir = tempfile::tempdir().unwrap();
950 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
951
952 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
953 let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
955 assert_eq!(result.len(), 1);
956 }
957
958 #[test]
961 fn plan_floating_tag_when_enabled() {
962 let tag = TagInfo {
963 name: "v3.2.0".into(),
964 version: Version::new(3, 2, 0),
965 sha: "d".repeat(40),
966 };
967 let mut config = ReleaseConfig::default();
968 config.floating_tags = true;
969
970 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
971 let plan = s.plan().unwrap();
972 assert_eq!(plan.next_version, Version::new(3, 2, 1));
973 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
974 }
975
976 #[test]
977 fn plan_no_floating_tag_when_disabled() {
978 let s = make_strategy(
979 vec![],
980 vec![raw_commit("feat: something")],
981 ReleaseConfig::default(),
982 );
983 let plan = s.plan().unwrap();
984 assert!(plan.floating_tag_name.is_none());
985 }
986
987 #[test]
988 fn plan_floating_tag_custom_prefix() {
989 let tag = TagInfo {
990 name: "release-2.5.0".into(),
991 version: Version::new(2, 5, 0),
992 sha: "e".repeat(40),
993 };
994 let mut config = ReleaseConfig::default();
995 config.floating_tags = true;
996 config.tag_prefix = "release-".into();
997
998 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
999 let plan = s.plan().unwrap();
1000 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1001 }
1002
1003 #[test]
1004 fn execute_floating_tags_force_create_and_push() {
1005 let mut config = ReleaseConfig::default();
1006 config.floating_tags = true;
1007
1008 let tag = TagInfo {
1009 name: "v1.2.3".into(),
1010 version: Version::new(1, 2, 3),
1011 sha: "f".repeat(40),
1012 };
1013 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1014 let plan = s.plan().unwrap();
1015 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1016
1017 s.execute(&plan, false).unwrap();
1018
1019 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1020 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1021 }
1022
1023 #[test]
1024 fn execute_no_floating_tags_when_disabled() {
1025 let s = make_strategy(
1026 vec![],
1027 vec![raw_commit("feat: something")],
1028 ReleaseConfig::default(),
1029 );
1030 let plan = s.plan().unwrap();
1031 assert!(plan.floating_tag_name.is_none());
1032
1033 s.execute(&plan, false).unwrap();
1034
1035 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1036 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1037 }
1038
1039 #[test]
1040 fn execute_floating_tags_dry_run_no_side_effects() {
1041 let mut config = ReleaseConfig::default();
1042 config.floating_tags = true;
1043
1044 let tag = TagInfo {
1045 name: "v2.0.0".into(),
1046 version: Version::new(2, 0, 0),
1047 sha: "a".repeat(40),
1048 };
1049 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1050 let plan = s.plan().unwrap();
1051 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1052
1053 s.execute(&plan, true).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_idempotent() {
1061 let mut config = ReleaseConfig::default();
1062 config.floating_tags = true;
1063
1064 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1065 let plan = s.plan().unwrap();
1066 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1067
1068 s.execute(&plan, false).unwrap();
1070 s.execute(&plan, false).unwrap();
1071
1072 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1074 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1075 }
1076
1077 #[test]
1080 fn force_rerelease_when_tag_at_head() {
1081 let tag = TagInfo {
1082 name: "v1.2.3".into(),
1083 version: Version::new(1, 2, 3),
1084 sha: "a".repeat(40),
1085 };
1086 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1087 s.git.head = "a".repeat(40);
1089 s.force = true;
1090
1091 let plan = s.plan().unwrap();
1092 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1093 assert_eq!(plan.tag_name, "v1.2.3");
1094 assert!(plan.commits.is_empty());
1095 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1096 }
1097
1098 #[test]
1099 fn force_fails_when_tag_not_at_head() {
1100 let tag = TagInfo {
1101 name: "v1.2.3".into(),
1102 version: Version::new(1, 2, 3),
1103 sha: "a".repeat(40),
1104 };
1105 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1106 s.git.head = "b".repeat(40);
1108 s.force = true;
1109
1110 let err = s.plan().unwrap_err();
1111 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1112 }
1113
1114 #[test]
1115 fn force_fails_with_no_tags() {
1116 let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1117 s.force = true;
1118
1119 let err = s.plan().unwrap_err();
1120 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1121 }
1122}