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, apply_prerelease_bump, determine_bump};
13use crate::version_files::{bump_version_file, discover_lock_files, is_supported_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 pub prerelease: bool,
25}
26
27pub trait ReleaseStrategy: Send + Sync {
29 fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
31
32 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
34}
35
36pub trait VcsProvider: Send + Sync {
38 fn create_release(
40 &self,
41 tag: &str,
42 name: &str,
43 body: &str,
44 prerelease: bool,
45 draft: bool,
46 ) -> Result<String, ReleaseError>;
47
48 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
50
51 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
53
54 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
56
57 fn repo_url(&self) -> Option<String> {
59 None
60 }
61
62 fn update_release(
65 &self,
66 _tag: &str,
67 _name: &str,
68 _body: &str,
69 _prerelease: bool,
70 _draft: bool,
71 ) -> Result<String, ReleaseError> {
72 Err(ReleaseError::Vcs(
73 "update_release not implemented for this provider".into(),
74 ))
75 }
76
77 fn sync_floating_release(
80 &self,
81 _floating_tag: &str,
82 _versioned_tag: &str,
83 ) -> Result<(), ReleaseError> {
84 Ok(())
85 }
86
87 fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
89 Ok(())
90 }
91
92 fn verify_release(&self, _tag: &str) -> Result<(), ReleaseError> {
94 Ok(())
95 }
96}
97
98pub struct NoopVcsProvider;
101
102impl VcsProvider for NoopVcsProvider {
103 fn create_release(
104 &self,
105 _tag: &str,
106 _name: &str,
107 _body: &str,
108 _prerelease: bool,
109 _draft: bool,
110 ) -> Result<String, ReleaseError> {
111 Ok(String::new())
112 }
113
114 fn compare_url(&self, _base: &str, _head: &str) -> Result<String, ReleaseError> {
115 Ok(String::new())
116 }
117
118 fn release_exists(&self, _tag: &str) -> Result<bool, ReleaseError> {
119 Ok(false)
120 }
121
122 fn delete_release(&self, _tag: &str) -> Result<(), ReleaseError> {
123 Ok(())
124 }
125}
126
127pub struct TrunkReleaseStrategy<G, V, C, F> {
129 pub git: G,
130 pub vcs: V,
131 pub parser: C,
132 pub formatter: F,
133 pub config: ReleaseConfig,
134 pub force: bool,
136}
137
138impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
139where
140 G: GitRepository,
141 V: VcsProvider,
142 C: CommitParser,
143 F: ChangelogFormatter,
144{
145 fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
146 let today = today_string();
147 let compare_url = match &plan.current_version {
148 Some(v) => {
149 let base = format!("{}{v}", self.config.tag_prefix);
150 self.vcs
151 .compare_url(&base, &plan.tag_name)
152 .ok()
153 .filter(|s| !s.is_empty())
154 }
155 None => None,
156 };
157 let entry = ChangelogEntry {
158 version: plan.next_version.to_string(),
159 date: today,
160 commits: plan.commits.clone(),
161 compare_url,
162 repo_url: self.vcs.repo_url(),
163 };
164 self.formatter.format(&[entry])
165 }
166
167 fn release_name(&self, plan: &ReleasePlan) -> String {
169 if let Some(ref template_str) = self.config.release_name_template {
170 let mut env = minijinja::Environment::new();
171 if env.add_template("release_name", template_str).is_ok()
172 && let Ok(tmpl) = env.get_template("release_name")
173 && let Ok(rendered) = tmpl.render(minijinja::context! {
174 version => plan.next_version.to_string(),
175 tag_name => &plan.tag_name,
176 tag_prefix => &self.config.tag_prefix,
177 })
178 {
179 return rendered;
180 }
181 eprintln!("warning: invalid release_name_template, falling back to tag name");
182 }
183 plan.tag_name.clone()
184 }
185}
186
187impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
188where
189 G: GitRepository,
190 V: VcsProvider,
191 C: CommitParser,
192 F: ChangelogFormatter,
193{
194 fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
195 let is_prerelease = self.config.prerelease.is_some();
196
197 let all_tags = self.git.all_tags(&self.config.tag_prefix)?;
200 let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
201 let latest_any = all_tags.last();
202
203 let tag_info = if is_prerelease {
205 latest_any
206 } else {
207 latest_stable.or(latest_any)
208 };
209
210 let (current_version, from_sha) = match tag_info {
211 Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
212 None => (None, None),
213 };
214
215 let raw_commits = if let Some(ref path) = self.config.path_filter {
216 self.git.commits_since_in_path(from_sha, path)?
217 } else {
218 self.git.commits_since(from_sha)?
219 };
220 if raw_commits.is_empty() {
221 if self.force
223 && let Some(info) = tag_info
224 {
225 let head = self.git.head_sha()?;
226 if head == info.sha {
227 let floating_tag_name = if self.config.floating_tags {
228 Some(format!("{}{}", self.config.tag_prefix, info.version.major))
229 } else {
230 None
231 };
232 return Ok(ReleasePlan {
233 current_version: Some(info.version.clone()),
234 next_version: info.version.clone(),
235 bump: BumpLevel::Patch,
236 commits: vec![],
237 tag_name: info.name.clone(),
238 floating_tag_name,
239 prerelease: is_prerelease,
240 });
241 }
242 }
243 let (tag, sha) = match tag_info {
244 Some(info) => (info.name.clone(), info.sha.clone()),
245 None => ("(none)".into(), "(none)".into()),
246 };
247 return Err(ReleaseError::NoCommits { tag, sha });
248 }
249
250 let conventional_commits: Vec<ConventionalCommit> = raw_commits
251 .iter()
252 .filter(|c| !c.message.starts_with("chore(release):"))
253 .filter_map(|c| self.parser.parse(c).ok())
254 .collect();
255
256 let classifier = DefaultCommitClassifier::new(
257 self.config.types.clone(),
258 self.config.commit_pattern.clone(),
259 );
260 let tag_for_err = tag_info
261 .map(|i| i.name.clone())
262 .unwrap_or_else(|| "(none)".into());
263 let commit_count = conventional_commits.len();
264 let bump = match determine_bump(&conventional_commits, &classifier) {
265 Some(b) => b,
266 None if self.force => BumpLevel::Patch,
267 None => {
268 return Err(ReleaseError::NoBump {
269 tag: tag_for_err,
270 commit_count,
271 });
272 }
273 };
274
275 let base_version = if is_prerelease {
277 latest_stable
278 .map(|t| t.version.clone())
279 .or(current_version.clone())
280 .unwrap_or(Version::new(0, 0, 0))
281 } else {
282 current_version.clone().unwrap_or(Version::new(0, 0, 0))
283 };
284
285 let bump = if base_version.major == 0 && bump == BumpLevel::Major && !self.force {
288 eprintln!(
289 "v0 protection: breaking change detected at v{base_version}, \
290 downshifting major → minor (use --force to bump to v1)"
291 );
292 BumpLevel::Minor
293 } else {
294 bump
295 };
296
297 let next_version = if let Some(ref prerelease_id) = self.config.prerelease {
298 let existing_versions: Vec<Version> =
299 all_tags.iter().map(|t| t.version.clone()).collect();
300 apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
301 } else {
302 apply_bump(&base_version, bump)
303 };
304
305 let tag_name = format!("{}{next_version}", self.config.tag_prefix);
306
307 let floating_tag_name = if self.config.floating_tags && !is_prerelease {
309 Some(format!("{}{}", self.config.tag_prefix, next_version.major))
310 } else {
311 None
312 };
313
314 Ok(ReleasePlan {
315 current_version,
316 next_version,
317 bump,
318 commits: conventional_commits,
319 tag_name,
320 floating_tag_name,
321 prerelease: is_prerelease,
322 })
323 }
324
325 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
326 let version_str = plan.next_version.to_string();
327
328 self.run_lifecycle_command(
329 &self.config.pre_release_command,
330 "pre_release_command",
331 &version_str,
332 &plan.tag_name,
333 dry_run,
334 )?;
335
336 let changelog_body = self.format_changelog(plan)?;
337
338 self.bump_and_build(plan, &version_str, &changelog_body, dry_run)?;
339 self.create_and_push_tags(plan, &changelog_body, dry_run)?;
340 self.create_or_update_release(plan, &changelog_body, dry_run)?;
341 self.upload_artifacts(plan, dry_run)?;
342 self.verify_and_sync_release(plan, dry_run)?;
343
344 self.run_lifecycle_command(
345 &self.config.post_release_command,
346 "post_release_command",
347 &version_str,
348 &plan.tag_name,
349 dry_run,
350 )?;
351
352 if dry_run {
353 eprintln!("[dry-run] Changelog:\n{changelog_body}");
354 } else {
355 eprintln!("Released {}", plan.tag_name);
356 }
357 Ok(())
358 }
359}
360
361impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
362where
363 G: GitRepository,
364 V: VcsProvider,
365 C: CommitParser,
366 F: ChangelogFormatter,
367{
368 fn run_lifecycle_command(
369 &self,
370 command: &Option<String>,
371 label: &str,
372 version: &str,
373 tag: &str,
374 dry_run: bool,
375 ) -> Result<(), ReleaseError> {
376 if let Some(cmd) = command {
377 if dry_run {
378 eprintln!("[dry-run] Would run {label}: {cmd}");
379 } else {
380 eprintln!("Running {label}: {cmd}");
381 run_lifecycle_hook(cmd, version, tag, label)?;
382 }
383 }
384 Ok(())
385 }
386
387 fn bump_and_build(
388 &self,
389 plan: &ReleasePlan,
390 version_str: &str,
391 changelog_body: &str,
392 dry_run: bool,
393 ) -> Result<(), ReleaseError> {
394 if dry_run {
395 for file in &self.config.version_files {
396 let filename = Path::new(file)
397 .file_name()
398 .and_then(|n| n.to_str())
399 .unwrap_or_default();
400 if is_supported_version_file(filename) {
401 eprintln!("[dry-run] Would bump version in: {file}");
402 } else if self.config.version_files_strict {
403 return Err(ReleaseError::VersionBump(format!(
404 "unsupported version file: {filename}"
405 )));
406 } else {
407 eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
408 }
409 }
410 if let Some(ref cmd) = self.config.build_command {
411 eprintln!("[dry-run] Would run build command: {cmd}");
412 }
413 if !self.config.stage_files.is_empty() {
414 eprintln!(
415 "[dry-run] Would stage additional files: {}",
416 self.config.stage_files.join(", ")
417 );
418 }
419 return Ok(());
420 }
421
422 let mut file_snapshots: Vec<(String, Option<String>)> = Vec::new();
424 for file in &self.config.version_files {
425 let path = Path::new(file);
426 let contents = if path.exists() {
427 Some(
428 fs::read_to_string(path)
429 .map_err(|e| ReleaseError::VersionBump(e.to_string()))?,
430 )
431 } else {
432 None
433 };
434 file_snapshots.push((file.clone(), contents));
435 }
436 if let Some(ref changelog_file) = self.config.changelog.file {
437 let path = Path::new(changelog_file);
438 let contents = if path.exists() {
439 Some(fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?)
440 } else {
441 None
442 };
443 file_snapshots.push((changelog_file.clone(), contents));
444 }
445
446 let files_to_stage = match self.execute_pre_commit(plan, version_str, changelog_body) {
448 Ok(files) => files,
449 Err(e) => {
450 eprintln!("error during pre-commit steps, restoring files...");
451 restore_snapshots(&file_snapshots);
452 return Err(e);
453 }
454 };
455
456 let mut paths_to_stage: Vec<String> = Vec::new();
458 if let Some(ref changelog_file) = self.config.changelog.file {
459 paths_to_stage.push(changelog_file.clone());
460 }
461 for file in &files_to_stage {
462 paths_to_stage.push(file.clone());
463 }
464 if !self.config.stage_files.is_empty() {
465 let extra = resolve_globs(&self.config.stage_files).map_err(ReleaseError::Config)?;
466 paths_to_stage.extend(extra);
467 }
468 if !paths_to_stage.is_empty() {
469 let refs: Vec<&str> = paths_to_stage.iter().map(|s| s.as_str()).collect();
470 let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
471 self.git.stage_and_commit(&refs, &commit_msg)?;
472 }
473 Ok(())
474 }
475
476 fn create_and_push_tags(
477 &self,
478 plan: &ReleasePlan,
479 changelog_body: &str,
480 dry_run: bool,
481 ) -> Result<(), ReleaseError> {
482 if dry_run {
483 let sign_label = if self.config.sign_tags {
484 " (signed)"
485 } else {
486 ""
487 };
488 eprintln!("[dry-run] Would create tag: {}{sign_label}", plan.tag_name);
489 eprintln!("[dry-run] Would push commit and tag: {}", plan.tag_name);
490 if let Some(ref floating) = plan.floating_tag_name {
491 eprintln!("[dry-run] Would create/update floating tag: {floating}");
492 eprintln!("[dry-run] Would force-push floating tag: {floating}");
493 }
494 return Ok(());
495 }
496
497 if !self.git.tag_exists(&plan.tag_name)? {
499 let tag_message = format!("{}\n\n{}", plan.tag_name, changelog_body);
500 self.git
501 .create_tag(&plan.tag_name, &tag_message, self.config.sign_tags)?;
502 }
503
504 self.git.push()?;
506
507 if !self.git.remote_tag_exists(&plan.tag_name)? {
509 self.git.push_tag(&plan.tag_name)?;
510 }
511
512 if let Some(ref floating) = plan.floating_tag_name {
514 self.git.force_create_tag(floating)?;
515 self.git.force_push_tag(floating)?;
516 }
517 Ok(())
518 }
519
520 fn create_or_update_release(
521 &self,
522 plan: &ReleasePlan,
523 changelog_body: &str,
524 dry_run: bool,
525 ) -> Result<(), ReleaseError> {
526 if dry_run {
527 let draft_label = if self.config.draft { " (draft)" } else { "" };
528 let release_name = self.release_name(plan);
529 eprintln!(
530 "[dry-run] Would create GitHub release \"{release_name}\" for {}{draft_label}",
531 plan.tag_name
532 );
533 return Ok(());
534 }
535
536 let release_name = self.release_name(plan);
537 if self.vcs.release_exists(&plan.tag_name)? {
538 self.vcs.update_release(
539 &plan.tag_name,
540 &release_name,
541 changelog_body,
542 plan.prerelease,
543 self.config.draft,
544 )?;
545 } else {
546 self.vcs.create_release(
547 &plan.tag_name,
548 &release_name,
549 changelog_body,
550 plan.prerelease,
551 self.config.draft,
552 )?;
553 }
554 Ok(())
555 }
556
557 fn upload_artifacts(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
558 if self.config.artifacts.is_empty() {
559 return Ok(());
560 }
561
562 let resolved = resolve_globs(&self.config.artifacts).map_err(ReleaseError::Vcs)?;
563
564 if dry_run {
565 if resolved.is_empty() {
566 eprintln!("[dry-run] Artifact patterns matched no files");
567 } else {
568 eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
569 for f in &resolved {
570 eprintln!("[dry-run] {f}");
571 }
572 }
573 return Ok(());
574 }
575
576 if !resolved.is_empty() {
577 let checksum_files = generate_checksums(&resolved)?;
578 let mut all_files = resolved.clone();
579 all_files.extend(checksum_files.iter().cloned());
580
581 let file_refs: Vec<&str> = all_files.iter().map(|s| s.as_str()).collect();
582 self.vcs.upload_assets(&plan.tag_name, &file_refs)?;
583 eprintln!(
584 "Uploaded {} artifact(s) + {} checksum(s) to {}",
585 resolved.len(),
586 checksum_files.len(),
587 plan.tag_name
588 );
589
590 for f in &checksum_files {
591 let _ = fs::remove_file(f);
592 }
593 }
594 Ok(())
595 }
596
597 fn verify_and_sync_release(
598 &self,
599 plan: &ReleasePlan,
600 dry_run: bool,
601 ) -> Result<(), ReleaseError> {
602 if dry_run {
603 eprintln!("[dry-run] Would verify release: {}", plan.tag_name);
604 if let Some(ref floating) = plan.floating_tag_name {
605 eprintln!(
606 "[dry-run] Would sync floating release {floating} with {}",
607 plan.tag_name
608 );
609 }
610 return Ok(());
611 }
612
613 if let Err(e) = self.vcs.verify_release(&plan.tag_name) {
614 eprintln!("warning: post-release verification failed: {e}");
615 eprintln!(
616 " The tag {} was pushed but the GitHub release may be incomplete.",
617 plan.tag_name
618 );
619 eprintln!(" Re-run with --force to retry.");
620 }
621
622 if let Some(ref floating) = plan.floating_tag_name
623 && let Err(e) = self.vcs.sync_floating_release(floating, &plan.tag_name)
624 {
625 eprintln!("warning: failed to sync floating release {floating}: {e}");
626 }
627 Ok(())
628 }
629
630 fn execute_pre_commit(
633 &self,
634 plan: &ReleasePlan,
635 version_str: &str,
636 changelog_body: &str,
637 ) -> Result<Vec<String>, ReleaseError> {
638 let mut files_to_stage: Vec<String> = Vec::new();
640 for file in &self.config.version_files {
641 match bump_version_file(Path::new(file), version_str) {
642 Ok(extra) => {
643 files_to_stage.push(file.clone());
644 for extra_path in extra {
645 files_to_stage.push(extra_path.to_string_lossy().into_owned());
646 }
647 }
648 Err(e) if !self.config.version_files_strict => {
649 eprintln!("warning: {e} — skipping {file}");
650 }
651 Err(e) => return Err(e),
652 }
653 }
654
655 for lock_file in discover_lock_files(&files_to_stage) {
657 let lock_str = lock_file.to_string_lossy().into_owned();
658 if !files_to_stage.contains(&lock_str) {
659 files_to_stage.push(lock_str);
660 }
661 }
662
663 if let Some(ref changelog_file) = self.config.changelog.file {
665 let path = Path::new(changelog_file);
666 let existing = if path.exists() {
667 fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
668 } else {
669 String::new()
670 };
671 let new_content = if existing.is_empty() {
672 format!("# Changelog\n\n{changelog_body}\n")
673 } else {
674 match existing.find("\n\n") {
675 Some(pos) => {
676 let (header, rest) = existing.split_at(pos);
677 format!("{header}\n\n{changelog_body}\n{rest}")
678 }
679 None => format!("{existing}\n\n{changelog_body}\n"),
680 }
681 };
682 fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
683 }
684
685 if let Some(ref cmd) = self.config.build_command {
687 eprintln!("Running build command: {cmd}");
688 run_lifecycle_hook(cmd, version_str, &plan.tag_name, "build_command")?;
689 }
690
691 Ok(files_to_stage)
692 }
693}
694
695fn restore_snapshots(snapshots: &[(String, Option<String>)]) {
697 for (file, contents) in snapshots {
698 let path = Path::new(file);
699 match contents {
700 Some(data) => {
701 if let Err(e) = fs::write(path, data) {
702 eprintln!("warning: failed to restore {file}: {e}");
703 }
704 }
705 None => {
706 if path.exists()
708 && let Err(e) = fs::remove_file(path)
709 {
710 eprintln!("warning: failed to remove {file}: {e}");
711 }
712 }
713 }
714 }
715}
716
717fn run_lifecycle_hook(
719 cmd: &str,
720 version: &str,
721 tag: &str,
722 label: &str,
723) -> Result<(), ReleaseError> {
724 crate::hooks::run_shell(cmd, None, &[("SR_VERSION", version), ("SR_TAG", tag)])
725 .map_err(|e| ReleaseError::BuildCommand(format!("{label}: {e}")))
726}
727
728fn resolve_globs(patterns: &[String]) -> Result<Vec<String>, String> {
730 let mut files = std::collections::BTreeSet::new();
731 for pattern in patterns {
732 let paths =
733 glob::glob(pattern).map_err(|e| format!("invalid glob pattern '{pattern}': {e}"))?;
734 for entry in paths {
735 match entry {
736 Ok(path) if path.is_file() => {
737 files.insert(path.to_string_lossy().into_owned());
738 }
739 Ok(_) => {}
740 Err(e) => {
741 return Err(format!("glob error for pattern '{pattern}': {e}"));
742 }
743 }
744 }
745 }
746 Ok(files.into_iter().collect())
747}
748
749fn generate_checksums(files: &[String]) -> Result<Vec<String>, ReleaseError> {
752 use sha2::{Digest, Sha256};
753
754 let mut checksum_paths = Vec::new();
755 for file_path in files {
756 let data = fs::read(file_path).map_err(|e| {
757 ReleaseError::Vcs(format!("failed to read {file_path} for checksum: {e}"))
758 })?;
759 let hash = Sha256::digest(&data);
760 let hex = format!("{hash:x}");
761 let file_name = Path::new(file_path)
762 .file_name()
763 .and_then(|n| n.to_str())
764 .unwrap_or("unknown");
765 let checksum_content = format!("{hex} {file_name}\n");
766 let checksum_path = format!("{file_path}.sha256");
767 fs::write(&checksum_path, checksum_content)
768 .map_err(|e| ReleaseError::Vcs(format!("failed to write checksum file: {e}")))?;
769 checksum_paths.push(checksum_path);
770 }
771 Ok(checksum_paths)
772}
773
774pub fn today_string() -> String {
775 let secs = std::time::SystemTime::now()
778 .duration_since(std::time::UNIX_EPOCH)
779 .unwrap_or_default()
780 .as_secs() as i64;
781
782 let z = secs / 86400 + 719468;
783 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
784 let doe = (z - era * 146097) as u32;
785 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
786 let y = yoe as i64 + era * 400;
787 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
788 let mp = (5 * doy + 2) / 153;
789 let d = doy - (153 * mp + 2) / 5 + 1;
790 let m = if mp < 10 { mp + 3 } else { mp - 9 };
791 let y = if m <= 2 { y + 1 } else { y };
792
793 format!("{y:04}-{m:02}-{d:02}")
794}
795
796#[cfg(test)]
797mod tests {
798 use std::sync::Mutex;
799
800 use super::*;
801 use crate::changelog::DefaultChangelogFormatter;
802 use crate::commit::{Commit, DefaultCommitParser};
803 use crate::config::ReleaseConfig;
804 use crate::git::{GitRepository, TagInfo};
805
806 struct FakeGit {
809 tags: Vec<TagInfo>,
810 commits: Vec<Commit>,
811 path_commits: Option<Vec<Commit>>,
813 head: String,
814 created_tags: Mutex<Vec<String>>,
815 pushed_tags: Mutex<Vec<String>>,
816 committed: Mutex<Vec<(Vec<String>, String)>>,
817 push_count: Mutex<u32>,
818 force_created_tags: Mutex<Vec<String>>,
819 force_pushed_tags: Mutex<Vec<String>>,
820 }
821
822 impl FakeGit {
823 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
824 let head = tags
825 .last()
826 .map(|t| t.sha.clone())
827 .unwrap_or_else(|| "0".repeat(40));
828 Self {
829 tags,
830 commits,
831 path_commits: None,
832 head,
833 created_tags: Mutex::new(Vec::new()),
834 pushed_tags: Mutex::new(Vec::new()),
835 committed: Mutex::new(Vec::new()),
836 push_count: Mutex::new(0),
837 force_created_tags: Mutex::new(Vec::new()),
838 force_pushed_tags: Mutex::new(Vec::new()),
839 }
840 }
841 }
842
843 impl GitRepository for FakeGit {
844 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
845 Ok(self.tags.last().cloned())
846 }
847
848 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
849 Ok(self.commits.clone())
850 }
851
852 fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
853 self.created_tags.lock().unwrap().push(name.to_string());
854 Ok(())
855 }
856
857 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
858 self.pushed_tags.lock().unwrap().push(name.to_string());
859 Ok(())
860 }
861
862 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
863 self.committed.lock().unwrap().push((
864 paths.iter().map(|s| s.to_string()).collect(),
865 message.to_string(),
866 ));
867 Ok(true)
868 }
869
870 fn push(&self) -> Result<(), ReleaseError> {
871 *self.push_count.lock().unwrap() += 1;
872 Ok(())
873 }
874
875 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
876 Ok(self
877 .created_tags
878 .lock()
879 .unwrap()
880 .contains(&name.to_string()))
881 }
882
883 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
884 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
885 }
886
887 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
888 Ok(self.tags.clone())
889 }
890
891 fn commits_between(
892 &self,
893 _from: Option<&str>,
894 _to: &str,
895 ) -> Result<Vec<Commit>, ReleaseError> {
896 Ok(self.commits.clone())
897 }
898
899 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
900 Ok("2026-01-01".into())
901 }
902
903 fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
904 self.force_created_tags
905 .lock()
906 .unwrap()
907 .push(name.to_string());
908 Ok(())
909 }
910
911 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
912 self.force_pushed_tags
913 .lock()
914 .unwrap()
915 .push(name.to_string());
916 Ok(())
917 }
918
919 fn head_sha(&self) -> Result<String, ReleaseError> {
920 Ok(self.head.clone())
921 }
922
923 fn commits_since_in_path(
924 &self,
925 _from: Option<&str>,
926 _path: &str,
927 ) -> Result<Vec<Commit>, ReleaseError> {
928 Ok(self
929 .path_commits
930 .clone()
931 .unwrap_or_else(|| self.commits.clone()))
932 }
933 }
934
935 struct FakeVcs {
936 releases: Mutex<Vec<(String, String)>>,
937 deleted_releases: Mutex<Vec<String>>,
938 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
939 }
940
941 impl FakeVcs {
942 fn new() -> Self {
943 Self {
944 releases: Mutex::new(Vec::new()),
945 deleted_releases: Mutex::new(Vec::new()),
946 uploaded_assets: Mutex::new(Vec::new()),
947 }
948 }
949 }
950
951 impl VcsProvider for FakeVcs {
952 fn create_release(
953 &self,
954 tag: &str,
955 _name: &str,
956 body: &str,
957 _prerelease: bool,
958 _draft: bool,
959 ) -> Result<String, ReleaseError> {
960 self.releases
961 .lock()
962 .unwrap()
963 .push((tag.to_string(), body.to_string()));
964 Ok(format!("https://github.com/test/release/{tag}"))
965 }
966
967 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
968 Ok(format!("https://github.com/test/compare/{base}...{head}"))
969 }
970
971 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
972 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
973 }
974
975 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
976 self.deleted_releases.lock().unwrap().push(tag.to_string());
977 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
978 Ok(())
979 }
980
981 fn update_release(
982 &self,
983 tag: &str,
984 _name: &str,
985 body: &str,
986 _prerelease: bool,
987 _draft: bool,
988 ) -> Result<String, ReleaseError> {
989 let mut releases = self.releases.lock().unwrap();
990 if let Some(entry) = releases.iter_mut().find(|(t, _)| t == tag) {
991 entry.1 = body.to_string();
992 }
993 Ok(format!("https://github.com/test/release/{tag}"))
994 }
995
996 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
997 self.uploaded_assets.lock().unwrap().push((
998 tag.to_string(),
999 files.iter().map(|s| s.to_string()).collect(),
1000 ));
1001 Ok(())
1002 }
1003
1004 fn repo_url(&self) -> Option<String> {
1005 Some("https://github.com/test/repo".into())
1006 }
1007 }
1008
1009 fn raw_commit(msg: &str) -> Commit {
1012 Commit {
1013 sha: "a".repeat(40),
1014 message: msg.into(),
1015 }
1016 }
1017
1018 fn make_strategy(
1019 tags: Vec<TagInfo>,
1020 commits: Vec<Commit>,
1021 config: ReleaseConfig,
1022 ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
1023 {
1024 let types = config.types.clone();
1025 let breaking_section = config.breaking_section.clone();
1026 let misc_section = config.misc_section.clone();
1027 TrunkReleaseStrategy {
1028 git: FakeGit::new(tags, commits),
1029 vcs: FakeVcs::new(),
1030 parser: DefaultCommitParser,
1031 formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
1032 config,
1033 force: false,
1034 }
1035 }
1036
1037 #[test]
1040 fn plan_no_commits_returns_error() {
1041 let s = make_strategy(vec![], vec![], ReleaseConfig::default());
1042 let err = s.plan().unwrap_err();
1043 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1044 }
1045
1046 #[test]
1047 fn plan_no_releasable_returns_error() {
1048 let s = make_strategy(
1049 vec![],
1050 vec![raw_commit("chore: tidy up")],
1051 ReleaseConfig::default(),
1052 );
1053 let err = s.plan().unwrap_err();
1054 assert!(matches!(err, ReleaseError::NoBump { .. }));
1055 }
1056
1057 #[test]
1058 fn force_releases_patch_when_no_releasable_commits() {
1059 let tag = TagInfo {
1060 name: "v1.2.3".into(),
1061 version: Version::new(1, 2, 3),
1062 sha: "d".repeat(40),
1063 };
1064 let mut s = make_strategy(
1065 vec![tag],
1066 vec![raw_commit("chore: rename package")],
1067 ReleaseConfig::default(),
1068 );
1069 s.force = true;
1070 let plan = s.plan().unwrap();
1071 assert_eq!(plan.next_version, Version::new(1, 2, 4));
1072 assert_eq!(plan.bump, BumpLevel::Patch);
1073 }
1074
1075 #[test]
1076 fn plan_first_release() {
1077 let s = make_strategy(
1078 vec![],
1079 vec![raw_commit("feat: initial feature")],
1080 ReleaseConfig::default(),
1081 );
1082 let plan = s.plan().unwrap();
1083 assert_eq!(plan.next_version, Version::new(0, 1, 0));
1084 assert_eq!(plan.tag_name, "v0.1.0");
1085 assert!(plan.current_version.is_none());
1086 }
1087
1088 #[test]
1089 fn plan_increments_existing() {
1090 let tag = TagInfo {
1091 name: "v1.2.3".into(),
1092 version: Version::new(1, 2, 3),
1093 sha: "b".repeat(40),
1094 };
1095 let s = make_strategy(
1096 vec![tag],
1097 vec![raw_commit("fix: patch bug")],
1098 ReleaseConfig::default(),
1099 );
1100 let plan = s.plan().unwrap();
1101 assert_eq!(plan.next_version, Version::new(1, 2, 4));
1102 }
1103
1104 #[test]
1105 fn plan_breaking_bump() {
1106 let tag = TagInfo {
1107 name: "v1.2.3".into(),
1108 version: Version::new(1, 2, 3),
1109 sha: "c".repeat(40),
1110 };
1111 let s = make_strategy(
1112 vec![tag],
1113 vec![raw_commit("feat!: breaking change")],
1114 ReleaseConfig::default(),
1115 );
1116 let plan = s.plan().unwrap();
1117 assert_eq!(plan.next_version, Version::new(2, 0, 0));
1118 }
1119
1120 #[test]
1121 fn plan_v0_breaking_downshifts_to_minor() {
1122 let tag = TagInfo {
1123 name: "v0.5.0".into(),
1124 version: Version::new(0, 5, 0),
1125 sha: "c".repeat(40),
1126 };
1127 let s = make_strategy(
1128 vec![tag],
1129 vec![raw_commit("feat!: breaking change")],
1130 ReleaseConfig::default(),
1131 );
1132 let plan = s.plan().unwrap();
1133 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1135 assert_eq!(plan.bump, BumpLevel::Minor);
1136 }
1137
1138 #[test]
1139 fn plan_v0_breaking_with_force_bumps_major() {
1140 let tag = TagInfo {
1141 name: "v0.5.0".into(),
1142 version: Version::new(0, 5, 0),
1143 sha: "c".repeat(40),
1144 };
1145 let mut s = make_strategy(
1146 vec![tag],
1147 vec![raw_commit("feat!: breaking change")],
1148 ReleaseConfig::default(),
1149 );
1150 s.force = true;
1151 let plan = s.plan().unwrap();
1152 assert_eq!(plan.next_version, Version::new(1, 0, 0));
1154 assert_eq!(plan.bump, BumpLevel::Major);
1155 }
1156
1157 #[test]
1158 fn plan_v0_feat_stays_minor() {
1159 let tag = TagInfo {
1160 name: "v0.5.0".into(),
1161 version: Version::new(0, 5, 0),
1162 sha: "c".repeat(40),
1163 };
1164 let s = make_strategy(
1165 vec![tag],
1166 vec![raw_commit("feat: new feature")],
1167 ReleaseConfig::default(),
1168 );
1169 let plan = s.plan().unwrap();
1170 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1172 assert_eq!(plan.bump, BumpLevel::Minor);
1173 }
1174
1175 #[test]
1176 fn plan_v0_fix_stays_patch() {
1177 let tag = TagInfo {
1178 name: "v0.5.0".into(),
1179 version: Version::new(0, 5, 0),
1180 sha: "c".repeat(40),
1181 };
1182 let s = make_strategy(
1183 vec![tag],
1184 vec![raw_commit("fix: bug fix")],
1185 ReleaseConfig::default(),
1186 );
1187 let plan = s.plan().unwrap();
1188 assert_eq!(plan.next_version, Version::new(0, 5, 1));
1190 assert_eq!(plan.bump, BumpLevel::Patch);
1191 }
1192
1193 #[test]
1196 fn execute_dry_run_no_side_effects() {
1197 let s = make_strategy(
1198 vec![],
1199 vec![raw_commit("feat: something")],
1200 ReleaseConfig::default(),
1201 );
1202 let plan = s.plan().unwrap();
1203 s.execute(&plan, true).unwrap();
1204
1205 assert!(s.git.created_tags.lock().unwrap().is_empty());
1206 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1207 }
1208
1209 #[test]
1210 fn execute_creates_and_pushes_tag() {
1211 let s = make_strategy(
1212 vec![],
1213 vec![raw_commit("feat: something")],
1214 ReleaseConfig::default(),
1215 );
1216 let plan = s.plan().unwrap();
1217 s.execute(&plan, false).unwrap();
1218
1219 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1220 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1221 }
1222
1223 #[test]
1224 fn execute_calls_vcs_create_release() {
1225 let s = make_strategy(
1226 vec![],
1227 vec![raw_commit("feat: something")],
1228 ReleaseConfig::default(),
1229 );
1230 let plan = s.plan().unwrap();
1231 s.execute(&plan, false).unwrap();
1232
1233 let releases = s.vcs.releases.lock().unwrap();
1234 assert_eq!(releases.len(), 1);
1235 assert_eq!(releases[0].0, "v0.1.0");
1236 assert!(!releases[0].1.is_empty());
1237 }
1238
1239 #[test]
1240 fn execute_commits_changelog_before_tag() {
1241 let dir = tempfile::tempdir().unwrap();
1242 let changelog_path = dir.path().join("CHANGELOG.md");
1243
1244 let config = ReleaseConfig {
1245 changelog: crate::config::ChangelogConfig {
1246 file: Some(changelog_path.to_str().unwrap().to_string()),
1247 ..Default::default()
1248 },
1249 ..Default::default()
1250 };
1251
1252 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1253 let plan = s.plan().unwrap();
1254 s.execute(&plan, false).unwrap();
1255
1256 let committed = s.git.committed.lock().unwrap();
1258 assert_eq!(committed.len(), 1);
1259 assert_eq!(
1260 committed[0].0,
1261 vec![changelog_path.to_str().unwrap().to_string()]
1262 );
1263 assert!(committed[0].1.contains("chore(release): v0.1.0"));
1264
1265 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1267 }
1268
1269 #[test]
1270 fn execute_skips_existing_tag() {
1271 let s = make_strategy(
1272 vec![],
1273 vec![raw_commit("feat: something")],
1274 ReleaseConfig::default(),
1275 );
1276 let plan = s.plan().unwrap();
1277
1278 s.git
1280 .created_tags
1281 .lock()
1282 .unwrap()
1283 .push("v0.1.0".to_string());
1284
1285 s.execute(&plan, false).unwrap();
1286
1287 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1289 }
1290
1291 #[test]
1292 fn execute_skips_existing_release() {
1293 let s = make_strategy(
1294 vec![],
1295 vec![raw_commit("feat: something")],
1296 ReleaseConfig::default(),
1297 );
1298 let plan = s.plan().unwrap();
1299
1300 s.vcs
1302 .releases
1303 .lock()
1304 .unwrap()
1305 .push(("v0.1.0".to_string(), "old notes".to_string()));
1306
1307 s.execute(&plan, false).unwrap();
1308
1309 let deleted = s.vcs.deleted_releases.lock().unwrap();
1311 assert!(deleted.is_empty(), "update should not delete");
1312
1313 let releases = s.vcs.releases.lock().unwrap();
1314 assert_eq!(releases.len(), 1);
1315 assert_eq!(releases[0].0, "v0.1.0");
1316 assert_ne!(releases[0].1, "old notes");
1317 }
1318
1319 #[test]
1320 fn execute_idempotent_rerun() {
1321 let s = make_strategy(
1322 vec![],
1323 vec![raw_commit("feat: something")],
1324 ReleaseConfig::default(),
1325 );
1326 let plan = s.plan().unwrap();
1327
1328 s.execute(&plan, false).unwrap();
1330
1331 s.execute(&plan, false).unwrap();
1333
1334 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1336
1337 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1339
1340 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1342
1343 let deleted = s.vcs.deleted_releases.lock().unwrap();
1345 assert!(deleted.is_empty(), "update should not delete");
1346
1347 let releases = s.vcs.releases.lock().unwrap();
1348 assert_eq!(releases.len(), 1);
1349 assert_eq!(releases[0].0, "v0.1.0");
1350 }
1351
1352 #[test]
1353 fn execute_bumps_version_files() {
1354 let dir = tempfile::tempdir().unwrap();
1355 let cargo_path = dir.path().join("Cargo.toml");
1356 std::fs::write(
1357 &cargo_path,
1358 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1359 )
1360 .unwrap();
1361
1362 let config = ReleaseConfig {
1363 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1364 ..Default::default()
1365 };
1366
1367 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1368 let plan = s.plan().unwrap();
1369 s.execute(&plan, false).unwrap();
1370
1371 let contents = std::fs::read_to_string(&cargo_path).unwrap();
1373 assert!(contents.contains("version = \"0.1.0\""));
1374
1375 let committed = s.git.committed.lock().unwrap();
1377 assert_eq!(committed.len(), 1);
1378 assert!(
1379 committed[0]
1380 .0
1381 .contains(&cargo_path.to_str().unwrap().to_string())
1382 );
1383 }
1384
1385 #[test]
1386 fn execute_stages_changelog_and_version_files_together() {
1387 let dir = tempfile::tempdir().unwrap();
1388 let cargo_path = dir.path().join("Cargo.toml");
1389 std::fs::write(
1390 &cargo_path,
1391 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1392 )
1393 .unwrap();
1394
1395 let changelog_path = dir.path().join("CHANGELOG.md");
1396
1397 let config = ReleaseConfig {
1398 changelog: crate::config::ChangelogConfig {
1399 file: Some(changelog_path.to_str().unwrap().to_string()),
1400 ..Default::default()
1401 },
1402 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1403 ..Default::default()
1404 };
1405
1406 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1407 let plan = s.plan().unwrap();
1408 s.execute(&plan, false).unwrap();
1409
1410 let committed = s.git.committed.lock().unwrap();
1412 assert_eq!(committed.len(), 1);
1413 assert!(
1414 committed[0]
1415 .0
1416 .contains(&changelog_path.to_str().unwrap().to_string())
1417 );
1418 assert!(
1419 committed[0]
1420 .0
1421 .contains(&cargo_path.to_str().unwrap().to_string())
1422 );
1423 }
1424
1425 #[test]
1428 fn execute_uploads_artifacts() {
1429 let dir = tempfile::tempdir().unwrap();
1430 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1431 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1432
1433 let config = ReleaseConfig {
1434 artifacts: vec![
1435 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1436 dir.path().join("*.zip").to_str().unwrap().to_string(),
1437 ],
1438 ..Default::default()
1439 };
1440
1441 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1442 let plan = s.plan().unwrap();
1443 s.execute(&plan, false).unwrap();
1444
1445 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1446 assert_eq!(uploaded.len(), 1);
1447 assert_eq!(uploaded[0].0, "v0.1.0");
1448 assert_eq!(uploaded[0].1.len(), 4);
1450 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1451 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1452 assert!(
1453 uploaded[0]
1454 .1
1455 .iter()
1456 .any(|f| f.ends_with("app.tar.gz.sha256"))
1457 );
1458 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip.sha256")));
1459 }
1460
1461 #[test]
1462 fn execute_dry_run_shows_artifacts() {
1463 let dir = tempfile::tempdir().unwrap();
1464 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1465
1466 let config = ReleaseConfig {
1467 artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1468 ..Default::default()
1469 };
1470
1471 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1472 let plan = s.plan().unwrap();
1473 s.execute(&plan, true).unwrap();
1474
1475 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1477 assert!(uploaded.is_empty());
1478 }
1479
1480 #[test]
1481 fn execute_no_artifacts_skips_upload() {
1482 let s = make_strategy(
1483 vec![],
1484 vec![raw_commit("feat: something")],
1485 ReleaseConfig::default(),
1486 );
1487 let plan = s.plan().unwrap();
1488 s.execute(&plan, false).unwrap();
1489
1490 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1491 assert!(uploaded.is_empty());
1492 }
1493
1494 #[test]
1495 fn resolve_globs_basic() {
1496 let dir = tempfile::tempdir().unwrap();
1497 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1498 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1499 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1500
1501 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1502 let result = resolve_globs(&[pattern]).unwrap();
1503 assert_eq!(result.len(), 2);
1504 assert!(result.iter().any(|f: &String| f.ends_with("a.txt")));
1505 assert!(result.iter().any(|f: &String| f.ends_with("b.txt")));
1506 }
1507
1508 #[test]
1509 fn resolve_globs_deduplicates() {
1510 let dir = tempfile::tempdir().unwrap();
1511 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1512
1513 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1514 let result = resolve_globs(&[pattern.clone(), pattern]).unwrap();
1516 assert_eq!(result.len(), 1);
1517 }
1518
1519 #[test]
1522 fn plan_floating_tag_when_enabled() {
1523 let tag = TagInfo {
1524 name: "v3.2.0".into(),
1525 version: Version::new(3, 2, 0),
1526 sha: "d".repeat(40),
1527 };
1528 let config = ReleaseConfig {
1529 floating_tags: true,
1530 ..Default::default()
1531 };
1532
1533 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1534 let plan = s.plan().unwrap();
1535 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1536 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1537 }
1538
1539 #[test]
1540 fn plan_no_floating_tag_when_disabled() {
1541 let s = make_strategy(
1542 vec![],
1543 vec![raw_commit("feat: something")],
1544 ReleaseConfig::default(),
1545 );
1546 let plan = s.plan().unwrap();
1547 assert!(plan.floating_tag_name.is_none());
1548 }
1549
1550 #[test]
1551 fn plan_floating_tag_custom_prefix() {
1552 let tag = TagInfo {
1553 name: "release-2.5.0".into(),
1554 version: Version::new(2, 5, 0),
1555 sha: "e".repeat(40),
1556 };
1557 let config = ReleaseConfig {
1558 floating_tags: true,
1559 tag_prefix: "release-".into(),
1560 ..Default::default()
1561 };
1562
1563 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1564 let plan = s.plan().unwrap();
1565 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1566 }
1567
1568 #[test]
1569 fn execute_floating_tags_force_create_and_push() {
1570 let config = ReleaseConfig {
1571 floating_tags: true,
1572 ..Default::default()
1573 };
1574
1575 let tag = TagInfo {
1576 name: "v1.2.3".into(),
1577 version: Version::new(1, 2, 3),
1578 sha: "f".repeat(40),
1579 };
1580 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1581 let plan = s.plan().unwrap();
1582 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1583
1584 s.execute(&plan, false).unwrap();
1585
1586 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1587 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1588 }
1589
1590 #[test]
1591 fn execute_no_floating_tags_when_disabled() {
1592 let s = make_strategy(
1593 vec![],
1594 vec![raw_commit("feat: something")],
1595 ReleaseConfig::default(),
1596 );
1597 let plan = s.plan().unwrap();
1598 assert!(plan.floating_tag_name.is_none());
1599
1600 s.execute(&plan, false).unwrap();
1601
1602 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1603 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1604 }
1605
1606 #[test]
1607 fn execute_floating_tags_dry_run_no_side_effects() {
1608 let config = ReleaseConfig {
1609 floating_tags: true,
1610 ..Default::default()
1611 };
1612
1613 let tag = TagInfo {
1614 name: "v2.0.0".into(),
1615 version: Version::new(2, 0, 0),
1616 sha: "a".repeat(40),
1617 };
1618 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1619 let plan = s.plan().unwrap();
1620 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1621
1622 s.execute(&plan, true).unwrap();
1623
1624 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1625 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1626 }
1627
1628 #[test]
1629 fn execute_floating_tags_idempotent() {
1630 let config = ReleaseConfig {
1631 floating_tags: true,
1632 ..Default::default()
1633 };
1634
1635 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1636 let plan = s.plan().unwrap();
1637 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1638
1639 s.execute(&plan, false).unwrap();
1641 s.execute(&plan, false).unwrap();
1642
1643 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1645 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1646 }
1647
1648 #[test]
1651 fn force_rerelease_when_tag_at_head() {
1652 let tag = TagInfo {
1653 name: "v1.2.3".into(),
1654 version: Version::new(1, 2, 3),
1655 sha: "a".repeat(40),
1656 };
1657 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1658 s.git.head = "a".repeat(40);
1660 s.force = true;
1661
1662 let plan = s.plan().unwrap();
1663 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1664 assert_eq!(plan.tag_name, "v1.2.3");
1665 assert!(plan.commits.is_empty());
1666 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1667 }
1668
1669 #[test]
1670 fn force_fails_when_tag_not_at_head() {
1671 let tag = TagInfo {
1672 name: "v1.2.3".into(),
1673 version: Version::new(1, 2, 3),
1674 sha: "a".repeat(40),
1675 };
1676 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1677 s.git.head = "b".repeat(40);
1679 s.force = true;
1680
1681 let err = s.plan().unwrap_err();
1682 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1683 }
1684
1685 #[test]
1688 fn execute_runs_build_command_after_version_bump() {
1689 let dir = tempfile::tempdir().unwrap();
1690 let output_file = dir.path().join("sr_test_version");
1691
1692 let config = ReleaseConfig {
1693 build_command: Some(format!(
1694 "echo $SR_VERSION > {}",
1695 output_file.to_str().unwrap()
1696 )),
1697 ..Default::default()
1698 };
1699
1700 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1701 let plan = s.plan().unwrap();
1702 s.execute(&plan, false).unwrap();
1703
1704 let contents = std::fs::read_to_string(&output_file).unwrap();
1705 assert_eq!(contents.trim(), "0.1.0");
1706 }
1707
1708 #[test]
1709 fn execute_build_command_failure_aborts_release() {
1710 let config = ReleaseConfig {
1711 build_command: Some("exit 1".into()),
1712 ..Default::default()
1713 };
1714
1715 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1716 let plan = s.plan().unwrap();
1717 let result = s.execute(&plan, false);
1718
1719 assert!(result.is_err());
1720 assert!(s.git.created_tags.lock().unwrap().is_empty());
1721 }
1722
1723 #[test]
1724 fn execute_dry_run_skips_build_command() {
1725 let dir = tempfile::tempdir().unwrap();
1726 let output_file = dir.path().join("sr_test_should_not_exist");
1727
1728 let config = ReleaseConfig {
1729 build_command: Some(format!("echo test > {}", output_file.to_str().unwrap())),
1730 ..Default::default()
1731 };
1732
1733 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1734 let plan = s.plan().unwrap();
1735 s.execute(&plan, true).unwrap();
1736
1737 assert!(!output_file.exists());
1738 }
1739
1740 #[test]
1741 fn force_fails_with_no_tags() {
1742 let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1743 s.force = true;
1744
1745 let err = s.plan().unwrap_err();
1746 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1747 }
1748
1749 #[test]
1752 fn execute_stages_extra_files() {
1753 let dir = tempfile::tempdir().unwrap();
1754 let lock_file = dir.path().join("Cargo.lock");
1755 std::fs::write(&lock_file, "old lock").unwrap();
1756
1757 let config = ReleaseConfig {
1758 build_command: Some(format!("echo 'new lock' > {}", lock_file.to_str().unwrap())),
1759 stage_files: vec![lock_file.to_str().unwrap().to_string()],
1760 ..Default::default()
1761 };
1762
1763 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1764 let plan = s.plan().unwrap();
1765 s.execute(&plan, false).unwrap();
1766
1767 let committed = s.git.committed.lock().unwrap();
1768 assert!(!committed.is_empty());
1769 let (staged, _) = &committed[0];
1770 assert!(
1771 staged.iter().any(|f| f.contains("Cargo.lock")),
1772 "Cargo.lock should be staged, got: {staged:?}"
1773 );
1774 }
1775
1776 #[test]
1777 fn execute_dry_run_shows_stage_files() {
1778 let config = ReleaseConfig {
1779 stage_files: vec!["Cargo.lock".into()],
1780 ..Default::default()
1781 };
1782
1783 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1784 let plan = s.plan().unwrap();
1785 s.execute(&plan, true).unwrap();
1787 }
1788
1789 #[test]
1792 fn execute_build_failure_restores_version_files() {
1793 let dir = tempfile::tempdir().unwrap();
1794 let cargo_toml = dir.path().join("Cargo.toml");
1795 std::fs::write(
1796 &cargo_toml,
1797 "[package]\nname = \"test\"\nversion = \"1.0.0\"\n",
1798 )
1799 .unwrap();
1800
1801 let config = ReleaseConfig {
1802 version_files: vec![cargo_toml.to_str().unwrap().to_string()],
1803 build_command: Some("exit 1".into()),
1804 ..Default::default()
1805 };
1806
1807 let tag = TagInfo {
1808 name: "v1.0.0".into(),
1809 version: Version::new(1, 0, 0),
1810 sha: "d".repeat(40),
1811 };
1812 let s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
1813 let plan = s.plan().unwrap();
1814 let result = s.execute(&plan, false);
1815
1816 assert!(result.is_err());
1817 let contents = std::fs::read_to_string(&cargo_toml).unwrap();
1819 assert!(
1820 contents.contains("version = \"1.0.0\""),
1821 "version should be restored, got: {contents}"
1822 );
1823 }
1824
1825 #[test]
1828 fn execute_pre_release_command_runs() {
1829 let dir = tempfile::tempdir().unwrap();
1830 let marker = dir.path().join("pre_release_ran");
1831
1832 let config = ReleaseConfig {
1833 pre_release_command: Some(format!("touch {}", marker.to_str().unwrap())),
1834 ..Default::default()
1835 };
1836
1837 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1838 let plan = s.plan().unwrap();
1839 s.execute(&plan, false).unwrap();
1840
1841 assert!(marker.exists(), "pre-release command should have run");
1842 }
1843
1844 #[test]
1845 fn execute_post_release_command_runs() {
1846 let dir = tempfile::tempdir().unwrap();
1847 let marker = dir.path().join("post_release_ran");
1848
1849 let config = ReleaseConfig {
1850 post_release_command: Some(format!("touch {}", marker.to_str().unwrap())),
1851 ..Default::default()
1852 };
1853
1854 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1855 let plan = s.plan().unwrap();
1856 s.execute(&plan, false).unwrap();
1857
1858 assert!(marker.exists(), "post-release command should have run");
1859 }
1860
1861 #[test]
1862 fn execute_pre_release_failure_aborts_release() {
1863 let config = ReleaseConfig {
1864 pre_release_command: Some("exit 1".into()),
1865 ..Default::default()
1866 };
1867
1868 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1869 let plan = s.plan().unwrap();
1870 let result = s.execute(&plan, false);
1871
1872 assert!(result.is_err());
1873 assert!(s.git.created_tags.lock().unwrap().is_empty());
1875 assert!(s.git.committed.lock().unwrap().is_empty());
1876 }
1877
1878 #[test]
1879 fn execute_hooks_receive_version_env_vars() {
1880 let dir = tempfile::tempdir().unwrap();
1881 let output_file = dir.path().join("hook_output");
1882
1883 let config = ReleaseConfig {
1884 post_release_command: Some(format!(
1885 "echo $SR_VERSION $SR_TAG > {}",
1886 output_file.to_str().unwrap()
1887 )),
1888 ..Default::default()
1889 };
1890
1891 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1892 let plan = s.plan().unwrap();
1893 s.execute(&plan, false).unwrap();
1894
1895 let contents = std::fs::read_to_string(&output_file).unwrap();
1896 assert!(contents.contains("0.1.0"), "SR_VERSION should be set");
1897 assert!(contents.contains("v0.1.0"), "SR_TAG should be set");
1898 }
1899
1900 #[test]
1901 fn execute_dry_run_skips_hooks() {
1902 let dir = tempfile::tempdir().unwrap();
1903 let pre_marker = dir.path().join("pre_hook");
1904 let post_marker = dir.path().join("post_hook");
1905
1906 let config = ReleaseConfig {
1907 pre_release_command: Some(format!("touch {}", pre_marker.to_str().unwrap())),
1908 post_release_command: Some(format!("touch {}", post_marker.to_str().unwrap())),
1909 ..Default::default()
1910 };
1911
1912 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1913 let plan = s.plan().unwrap();
1914 s.execute(&plan, true).unwrap();
1915
1916 assert!(
1917 !pre_marker.exists(),
1918 "pre-release hook should not run in dry-run"
1919 );
1920 assert!(
1921 !post_marker.exists(),
1922 "post-release hook should not run in dry-run"
1923 );
1924 }
1925
1926 #[test]
1929 fn plan_prerelease_first_release() {
1930 let config = ReleaseConfig {
1931 prerelease: Some("alpha".into()),
1932 ..Default::default()
1933 };
1934
1935 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1936 let plan = s.plan().unwrap();
1937 assert_eq!(plan.next_version.to_string(), "0.1.0-alpha.1");
1938 assert_eq!(plan.tag_name, "v0.1.0-alpha.1");
1939 assert!(plan.prerelease);
1940 }
1941
1942 #[test]
1943 fn plan_prerelease_increments_from_stable() {
1944 let tag = TagInfo {
1945 name: "v1.0.0".into(),
1946 version: Version::new(1, 0, 0),
1947 sha: "d".repeat(40),
1948 };
1949 let config = ReleaseConfig {
1950 prerelease: Some("beta".into()),
1951 ..Default::default()
1952 };
1953
1954 let s = make_strategy(vec![tag], vec![raw_commit("feat: new feature")], config);
1955 let plan = s.plan().unwrap();
1956 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1957 assert!(plan.prerelease);
1958 }
1959
1960 #[test]
1961 fn plan_prerelease_increments_counter() {
1962 let tags = vec![
1963 TagInfo {
1964 name: "v1.0.0".into(),
1965 version: Version::new(1, 0, 0),
1966 sha: "a".repeat(40),
1967 },
1968 TagInfo {
1969 name: "v1.1.0-alpha.1".into(),
1970 version: Version::parse("1.1.0-alpha.1").unwrap(),
1971 sha: "b".repeat(40),
1972 },
1973 TagInfo {
1974 name: "v1.1.0-alpha.2".into(),
1975 version: Version::parse("1.1.0-alpha.2").unwrap(),
1976 sha: "c".repeat(40),
1977 },
1978 ];
1979 let config = ReleaseConfig {
1980 prerelease: Some("alpha".into()),
1981 ..Default::default()
1982 };
1983
1984 let s = make_strategy(tags, vec![raw_commit("feat: another")], config);
1985 let plan = s.plan().unwrap();
1986 assert_eq!(plan.next_version.to_string(), "1.1.0-alpha.3");
1987 }
1988
1989 #[test]
1990 fn plan_prerelease_different_id_starts_at_1() {
1991 let tags = vec![
1992 TagInfo {
1993 name: "v1.0.0".into(),
1994 version: Version::new(1, 0, 0),
1995 sha: "a".repeat(40),
1996 },
1997 TagInfo {
1998 name: "v1.1.0-alpha.3".into(),
1999 version: Version::parse("1.1.0-alpha.3").unwrap(),
2000 sha: "b".repeat(40),
2001 },
2002 ];
2003 let config = ReleaseConfig {
2004 prerelease: Some("beta".into()),
2005 ..Default::default()
2006 };
2007
2008 let s = make_strategy(tags, vec![raw_commit("feat: something")], config);
2009 let plan = s.plan().unwrap();
2010 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
2011 }
2012
2013 #[test]
2014 fn plan_prerelease_no_floating_tags() {
2015 let config = ReleaseConfig {
2016 prerelease: Some("rc".into()),
2017 floating_tags: true,
2018 ..Default::default()
2019 };
2020
2021 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
2022 let plan = s.plan().unwrap();
2023 assert!(
2024 plan.floating_tag_name.is_none(),
2025 "pre-releases should not create floating tags"
2026 );
2027 }
2028
2029 #[test]
2030 fn plan_stable_skips_prerelease_tags() {
2031 let tags = vec![
2032 TagInfo {
2033 name: "v1.0.0".into(),
2034 version: Version::new(1, 0, 0),
2035 sha: "a".repeat(40),
2036 },
2037 TagInfo {
2038 name: "v1.1.0-alpha.1".into(),
2039 version: Version::parse("1.1.0-alpha.1").unwrap(),
2040 sha: "b".repeat(40),
2041 },
2042 ];
2043 let s = make_strategy(
2045 tags,
2046 vec![raw_commit("feat: something")],
2047 ReleaseConfig::default(),
2048 );
2049 let plan = s.plan().unwrap();
2050 assert_eq!(plan.next_version, Version::new(1, 1, 0));
2052 assert!(!plan.prerelease);
2053 }
2054
2055 #[test]
2056 fn plan_prerelease_marks_plan_as_prerelease() {
2057 let config = ReleaseConfig {
2058 prerelease: Some("alpha".into()),
2059 ..Default::default()
2060 };
2061
2062 let s = make_strategy(vec![], vec![raw_commit("fix: bug")], config);
2063 let plan = s.plan().unwrap();
2064 assert!(plan.prerelease);
2065 assert!(plan.next_version.to_string().contains("alpha"));
2066 }
2067
2068 #[test]
2071 fn plan_with_path_filter_uses_filtered_commits() {
2072 let config = ReleaseConfig {
2073 path_filter: Some("crates/core".into()),
2074 ..Default::default()
2075 };
2076
2077 let mut s = make_strategy(
2079 vec![],
2080 vec![raw_commit("feat: big feature"), raw_commit("fix: patch")],
2081 config,
2082 );
2083 s.git.path_commits = Some(vec![raw_commit("fix: patch only in core")]);
2084
2085 let plan = s.plan().unwrap();
2086 assert_eq!(plan.bump, BumpLevel::Patch);
2088 assert_eq!(plan.commits.len(), 1);
2089 assert_eq!(plan.commits[0].description, "patch only in core");
2090 }
2091
2092 #[test]
2093 fn plan_without_path_filter_uses_all_commits() {
2094 let config = ReleaseConfig::default();
2095
2096 let mut s = make_strategy(vec![], vec![raw_commit("feat: big feature")], config);
2097 s.git.path_commits = Some(vec![raw_commit("fix: filtered")]);
2098
2099 let plan = s.plan().unwrap();
2100 assert_eq!(plan.bump, BumpLevel::Minor);
2102 }
2103
2104 #[test]
2105 fn plan_with_path_filter_no_commits_returns_error() {
2106 let config = ReleaseConfig {
2107 path_filter: Some("crates/core".into()),
2108 ..Default::default()
2109 };
2110
2111 let mut s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
2112 s.git.path_commits = Some(vec![]);
2113
2114 let err = s.plan().unwrap_err();
2115 assert!(matches!(err, ReleaseError::NoCommits { .. }));
2116 }
2117
2118 #[test]
2119 fn plan_with_path_filter_custom_tag_prefix() {
2120 let config = ReleaseConfig {
2121 path_filter: Some("crates/core".into()),
2122 tag_prefix: "core/v".into(),
2123 ..Default::default()
2124 };
2125
2126 let tag = TagInfo {
2127 name: "core/v1.0.0".into(),
2128 version: Version::new(1, 0, 0),
2129 sha: "a".repeat(40),
2130 };
2131 let mut s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
2132 s.git.path_commits = Some(vec![raw_commit("fix: core bug")]);
2133
2134 let plan = s.plan().unwrap();
2135 assert_eq!(plan.tag_name, "core/v1.0.1");
2136 assert_eq!(plan.current_version, Some(Version::new(1, 0, 0)));
2137 }
2138}