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