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