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