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