1use std::fs;
2use std::path::Path;
3
4use semver::Version;
5use serde::Serialize;
6
7use crate::changelog::{ChangelogEntry, ChangelogFormatter};
8use crate::commit::{CommitParser, ConventionalCommit, DefaultCommitClassifier};
9use crate::config::ReleaseConfig;
10use crate::error::ReleaseError;
11use crate::git::GitRepository;
12use crate::version::{BumpLevel, apply_bump, apply_prerelease_bump, determine_bump};
13use crate::version_files::{bump_version_file, discover_lock_files, is_supported_version_file};
14
15#[derive(Debug, Serialize)]
17pub struct ReleasePlan {
18 pub current_version: Option<Version>,
19 pub next_version: Version,
20 pub bump: BumpLevel,
21 pub commits: Vec<ConventionalCommit>,
22 pub tag_name: String,
23 pub floating_tag_name: Option<String>,
24 pub prerelease: bool,
25}
26
27pub trait ReleaseStrategy: Send + Sync {
29 fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
31
32 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
34}
35
36pub trait VcsProvider: Send + Sync {
38 fn create_release(
40 &self,
41 tag: &str,
42 name: &str,
43 body: &str,
44 prerelease: bool,
45 draft: bool,
46 ) -> Result<String, ReleaseError>;
47
48 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
50
51 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
53
54 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
56
57 fn repo_url(&self) -> Option<String> {
59 None
60 }
61
62 fn update_release(
65 &self,
66 tag: &str,
67 name: &str,
68 body: &str,
69 prerelease: bool,
70 draft: bool,
71 ) -> Result<String, ReleaseError> {
72 self.delete_release(tag)?;
73 self.create_release(tag, name, body, prerelease, draft)
74 }
75
76 fn 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 = if let Some(ref path) = self.config.path_filter {
173 self.git.commits_since_in_path(from_sha, path)?
174 } else {
175 self.git.commits_since(from_sha)?
176 };
177 if raw_commits.is_empty() {
178 if self.force
180 && let Some(info) = tag_info
181 {
182 let head = self.git.head_sha()?;
183 if head == info.sha {
184 let floating_tag_name = if self.config.floating_tags {
185 Some(format!("{}{}", self.config.tag_prefix, info.version.major))
186 } else {
187 None
188 };
189 return Ok(ReleasePlan {
190 current_version: Some(info.version.clone()),
191 next_version: info.version.clone(),
192 bump: BumpLevel::Patch,
193 commits: vec![],
194 tag_name: info.name.clone(),
195 floating_tag_name,
196 prerelease: is_prerelease,
197 });
198 }
199 }
200 let (tag, sha) = match tag_info {
201 Some(info) => (info.name.clone(), info.sha.clone()),
202 None => ("(none)".into(), "(none)".into()),
203 };
204 return Err(ReleaseError::NoCommits { tag, sha });
205 }
206
207 let conventional_commits: Vec<ConventionalCommit> = raw_commits
208 .iter()
209 .filter(|c| !c.message.starts_with("chore(release):"))
210 .filter_map(|c| self.parser.parse(c).ok())
211 .collect();
212
213 let classifier = DefaultCommitClassifier::new(
214 self.config.types.clone(),
215 self.config.commit_pattern.clone(),
216 );
217 let tag_for_err = tag_info
218 .map(|i| i.name.clone())
219 .unwrap_or_else(|| "(none)".into());
220 let commit_count = conventional_commits.len();
221 let bump = match determine_bump(&conventional_commits, &classifier) {
222 Some(b) => b,
223 None if self.force => BumpLevel::Patch,
224 None => {
225 return Err(ReleaseError::NoBump {
226 tag: tag_for_err,
227 commit_count,
228 });
229 }
230 };
231
232 let base_version = if is_prerelease {
234 latest_stable
235 .map(|t| t.version.clone())
236 .or(current_version.clone())
237 .unwrap_or(Version::new(0, 0, 0))
238 } else {
239 current_version.clone().unwrap_or(Version::new(0, 0, 0))
240 };
241
242 let next_version = if let Some(ref prerelease_id) = self.config.prerelease {
243 let existing_versions: Vec<Version> =
244 all_tags.iter().map(|t| t.version.clone()).collect();
245 apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
246 } else {
247 apply_bump(&base_version, bump)
248 };
249
250 let tag_name = format!("{}{next_version}", self.config.tag_prefix);
251
252 let floating_tag_name = if self.config.floating_tags && !is_prerelease {
254 Some(format!("{}{}", self.config.tag_prefix, next_version.major))
255 } else {
256 None
257 };
258
259 Ok(ReleasePlan {
260 current_version,
261 next_version,
262 bump,
263 commits: conventional_commits,
264 tag_name,
265 floating_tag_name,
266 prerelease: is_prerelease,
267 })
268 }
269
270 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
271 let version_str = plan.next_version.to_string();
272
273 if dry_run {
274 let changelog_body = self.format_changelog(plan)?;
275 if let Some(ref cmd) = self.config.pre_release_command {
276 eprintln!("[dry-run] Would run pre-release command: {cmd}");
277 }
278 let sign_label = if self.config.sign_tags {
279 " (signed)"
280 } else {
281 ""
282 };
283 eprintln!("[dry-run] Would create tag: {}{sign_label}", plan.tag_name);
284 eprintln!("[dry-run] Would push tag: {}", plan.tag_name);
285 if let Some(ref floating) = plan.floating_tag_name {
286 eprintln!("[dry-run] Would create/update floating tag: {floating}");
287 eprintln!("[dry-run] Would force-push floating tag: {floating}");
288 }
289 if self.vcs.is_some() {
290 let draft_label = if self.config.draft { " (draft)" } else { "" };
291 let release_name = self.release_name(plan);
292 eprintln!(
293 "[dry-run] Would create GitHub release \"{release_name}\" for {}{draft_label}",
294 plan.tag_name
295 );
296 }
297 for file in &self.config.version_files {
298 let filename = Path::new(file)
299 .file_name()
300 .and_then(|n| n.to_str())
301 .unwrap_or_default();
302 if is_supported_version_file(filename) {
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(extra) => {
518 bumped_files.push(file.clone());
519 for extra_path in extra {
520 bumped_files.push(extra_path.to_string_lossy().into_owned());
521 }
522 }
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 for lock_file in discover_lock_files(&bumped_files) {
532 let lock_str = lock_file.to_string_lossy().into_owned();
533 if !bumped_files.contains(&lock_str) {
534 bumped_files.push(lock_str);
535 }
536 }
537
538 if let Some(ref changelog_file) = self.config.changelog.file {
540 let path = Path::new(changelog_file);
541 let existing = if path.exists() {
542 fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
543 } else {
544 String::new()
545 };
546 let new_content = if existing.is_empty() {
547 format!("# Changelog\n\n{changelog_body}\n")
548 } else {
549 match existing.find("\n\n") {
550 Some(pos) => {
551 let (header, rest) = existing.split_at(pos);
552 format!("{header}\n\n{changelog_body}\n{rest}")
553 }
554 None => format!("{existing}\n\n{changelog_body}\n"),
555 }
556 };
557 fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
558 }
559
560 if let Some(ref cmd) = self.config.build_command {
562 eprintln!("Running build command: {cmd}");
563 run_hook(cmd, version_str, &plan.tag_name, "build_command")?;
564 }
565
566 Ok(bumped_files)
567 }
568}
569
570fn restore_snapshots(snapshots: &[(String, Option<String>)]) {
572 for (file, contents) in snapshots {
573 let path = Path::new(file);
574 match contents {
575 Some(data) => {
576 if let Err(e) = fs::write(path, data) {
577 eprintln!("warning: failed to restore {file}: {e}");
578 }
579 }
580 None => {
581 if path.exists()
583 && let Err(e) = fs::remove_file(path)
584 {
585 eprintln!("warning: failed to remove {file}: {e}");
586 }
587 }
588 }
589 }
590}
591
592fn run_hook(cmd: &str, version: &str, tag: &str, label: &str) -> Result<(), ReleaseError> {
594 let status = std::process::Command::new("sh")
595 .args(["-c", cmd])
596 .env("SR_VERSION", version)
597 .env("SR_TAG", tag)
598 .status()
599 .map_err(|e| ReleaseError::BuildCommand(format!("{label}: {e}")))?;
600 if !status.success() {
601 return Err(ReleaseError::BuildCommand(format!(
602 "{label} exited with {}",
603 status.code().unwrap_or(-1)
604 )));
605 }
606 Ok(())
607}
608
609fn resolve_glob_patterns(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
611 let mut files = Vec::new();
612 for pattern in patterns {
613 let paths = glob::glob(pattern)
614 .map_err(|e| ReleaseError::Config(format!("invalid glob pattern '{pattern}': {e}")))?;
615 for entry in paths {
616 match entry {
617 Ok(path) if path.is_file() => {
618 files.push(path.to_string_lossy().into_owned());
619 }
620 Ok(_) => {}
621 Err(e) => {
622 eprintln!("warning: glob error: {e}");
623 }
624 }
625 }
626 }
627 Ok(files)
628}
629
630fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
631 let mut files = std::collections::BTreeSet::new();
632 for pattern in patterns {
633 let paths = glob::glob(pattern)
634 .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
635 for entry in paths {
636 match entry {
637 Ok(path) if path.is_file() => {
638 files.insert(path.to_string_lossy().into_owned());
639 }
640 Ok(_) => {} Err(e) => {
642 eprintln!("warning: glob error: {e}");
643 }
644 }
645 }
646 }
647 Ok(files.into_iter().collect())
648}
649
650fn generate_checksums(files: &[String]) -> Result<Vec<String>, ReleaseError> {
653 use sha2::{Digest, Sha256};
654
655 let mut checksum_paths = Vec::new();
656 for file_path in files {
657 let data = fs::read(file_path).map_err(|e| {
658 ReleaseError::Vcs(format!("failed to read {file_path} for checksum: {e}"))
659 })?;
660 let hash = Sha256::digest(&data);
661 let hex = format!("{hash:x}");
662 let file_name = Path::new(file_path)
663 .file_name()
664 .and_then(|n| n.to_str())
665 .unwrap_or("unknown");
666 let checksum_content = format!("{hex} {file_name}\n");
667 let checksum_path = format!("{file_path}.sha256");
668 fs::write(&checksum_path, checksum_content)
669 .map_err(|e| ReleaseError::Vcs(format!("failed to write checksum file: {e}")))?;
670 checksum_paths.push(checksum_path);
671 }
672 Ok(checksum_paths)
673}
674
675pub fn today_string() -> String {
676 let secs = std::time::SystemTime::now()
679 .duration_since(std::time::UNIX_EPOCH)
680 .unwrap_or_default()
681 .as_secs() as i64;
682
683 let z = secs / 86400 + 719468;
684 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
685 let doe = (z - era * 146097) as u32;
686 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
687 let y = yoe as i64 + era * 400;
688 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
689 let mp = (5 * doy + 2) / 153;
690 let d = doy - (153 * mp + 2) / 5 + 1;
691 let m = if mp < 10 { mp + 3 } else { mp - 9 };
692 let y = if m <= 2 { y + 1 } else { y };
693
694 format!("{y:04}-{m:02}-{d:02}")
695}
696
697#[cfg(test)]
698mod tests {
699 use std::sync::Mutex;
700
701 use super::*;
702 use crate::changelog::DefaultChangelogFormatter;
703 use crate::commit::{Commit, DefaultCommitParser};
704 use crate::config::ReleaseConfig;
705 use crate::git::{GitRepository, TagInfo};
706
707 struct FakeGit {
710 tags: Vec<TagInfo>,
711 commits: Vec<Commit>,
712 path_commits: Option<Vec<Commit>>,
714 head: String,
715 created_tags: Mutex<Vec<String>>,
716 pushed_tags: Mutex<Vec<String>>,
717 committed: Mutex<Vec<(Vec<String>, String)>>,
718 push_count: Mutex<u32>,
719 force_created_tags: Mutex<Vec<String>>,
720 force_pushed_tags: Mutex<Vec<String>>,
721 }
722
723 impl FakeGit {
724 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
725 let head = tags
726 .last()
727 .map(|t| t.sha.clone())
728 .unwrap_or_else(|| "0".repeat(40));
729 Self {
730 tags,
731 commits,
732 path_commits: None,
733 head,
734 created_tags: Mutex::new(Vec::new()),
735 pushed_tags: Mutex::new(Vec::new()),
736 committed: Mutex::new(Vec::new()),
737 push_count: Mutex::new(0),
738 force_created_tags: Mutex::new(Vec::new()),
739 force_pushed_tags: Mutex::new(Vec::new()),
740 }
741 }
742 }
743
744 impl GitRepository for FakeGit {
745 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
746 Ok(self.tags.last().cloned())
747 }
748
749 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
750 Ok(self.commits.clone())
751 }
752
753 fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
754 self.created_tags.lock().unwrap().push(name.to_string());
755 Ok(())
756 }
757
758 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
759 self.pushed_tags.lock().unwrap().push(name.to_string());
760 Ok(())
761 }
762
763 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
764 self.committed.lock().unwrap().push((
765 paths.iter().map(|s| s.to_string()).collect(),
766 message.to_string(),
767 ));
768 Ok(true)
769 }
770
771 fn push(&self) -> Result<(), ReleaseError> {
772 *self.push_count.lock().unwrap() += 1;
773 Ok(())
774 }
775
776 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
777 Ok(self
778 .created_tags
779 .lock()
780 .unwrap()
781 .contains(&name.to_string()))
782 }
783
784 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
785 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
786 }
787
788 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
789 Ok(self.tags.clone())
790 }
791
792 fn commits_between(
793 &self,
794 _from: Option<&str>,
795 _to: &str,
796 ) -> Result<Vec<Commit>, ReleaseError> {
797 Ok(self.commits.clone())
798 }
799
800 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
801 Ok("2026-01-01".into())
802 }
803
804 fn force_create_tag(
805 &self,
806 name: &str,
807 _message: &str,
808 _sign: bool,
809 ) -> Result<(), ReleaseError> {
810 self.force_created_tags
811 .lock()
812 .unwrap()
813 .push(name.to_string());
814 Ok(())
815 }
816
817 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
818 self.force_pushed_tags
819 .lock()
820 .unwrap()
821 .push(name.to_string());
822 Ok(())
823 }
824
825 fn head_sha(&self) -> Result<String, ReleaseError> {
826 Ok(self.head.clone())
827 }
828
829 fn commits_since_in_path(
830 &self,
831 _from: Option<&str>,
832 _path: &str,
833 ) -> Result<Vec<Commit>, ReleaseError> {
834 Ok(self
835 .path_commits
836 .clone()
837 .unwrap_or_else(|| self.commits.clone()))
838 }
839 }
840
841 struct FakeVcs {
842 releases: Mutex<Vec<(String, String)>>,
843 deleted_releases: Mutex<Vec<String>>,
844 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
845 }
846
847 impl FakeVcs {
848 fn new() -> Self {
849 Self {
850 releases: Mutex::new(Vec::new()),
851 deleted_releases: Mutex::new(Vec::new()),
852 uploaded_assets: Mutex::new(Vec::new()),
853 }
854 }
855 }
856
857 impl VcsProvider for FakeVcs {
858 fn create_release(
859 &self,
860 tag: &str,
861 _name: &str,
862 body: &str,
863 _prerelease: bool,
864 _draft: bool,
865 ) -> Result<String, ReleaseError> {
866 self.releases
867 .lock()
868 .unwrap()
869 .push((tag.to_string(), body.to_string()));
870 Ok(format!("https://github.com/test/release/{tag}"))
871 }
872
873 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
874 Ok(format!("https://github.com/test/compare/{base}...{head}"))
875 }
876
877 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
878 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
879 }
880
881 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
882 self.deleted_releases.lock().unwrap().push(tag.to_string());
883 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
884 Ok(())
885 }
886
887 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
888 self.uploaded_assets.lock().unwrap().push((
889 tag.to_string(),
890 files.iter().map(|s| s.to_string()).collect(),
891 ));
892 Ok(())
893 }
894
895 fn repo_url(&self) -> Option<String> {
896 Some("https://github.com/test/repo".into())
897 }
898 }
899
900 fn raw_commit(msg: &str) -> Commit {
903 Commit {
904 sha: "a".repeat(40),
905 message: msg.into(),
906 }
907 }
908
909 fn make_strategy(
910 tags: Vec<TagInfo>,
911 commits: Vec<Commit>,
912 config: ReleaseConfig,
913 ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
914 {
915 let types = config.types.clone();
916 let breaking_section = config.breaking_section.clone();
917 let misc_section = config.misc_section.clone();
918 TrunkReleaseStrategy {
919 git: FakeGit::new(tags, commits),
920 vcs: Some(FakeVcs::new()),
921 parser: DefaultCommitParser,
922 formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
923 config,
924 force: false,
925 }
926 }
927
928 #[test]
931 fn plan_no_commits_returns_error() {
932 let s = make_strategy(vec![], vec![], ReleaseConfig::default());
933 let err = s.plan().unwrap_err();
934 assert!(matches!(err, ReleaseError::NoCommits { .. }));
935 }
936
937 #[test]
938 fn plan_no_releasable_returns_error() {
939 let s = make_strategy(
940 vec![],
941 vec![raw_commit("chore: tidy up")],
942 ReleaseConfig::default(),
943 );
944 let err = s.plan().unwrap_err();
945 assert!(matches!(err, ReleaseError::NoBump { .. }));
946 }
947
948 #[test]
949 fn force_releases_patch_when_no_releasable_commits() {
950 let tag = TagInfo {
951 name: "v1.2.3".into(),
952 version: Version::new(1, 2, 3),
953 sha: "d".repeat(40),
954 };
955 let mut s = make_strategy(
956 vec![tag],
957 vec![raw_commit("chore: rename package")],
958 ReleaseConfig::default(),
959 );
960 s.force = true;
961 let plan = s.plan().unwrap();
962 assert_eq!(plan.next_version, Version::new(1, 2, 4));
963 assert_eq!(plan.bump, BumpLevel::Patch);
964 }
965
966 #[test]
967 fn plan_first_release() {
968 let s = make_strategy(
969 vec![],
970 vec![raw_commit("feat: initial feature")],
971 ReleaseConfig::default(),
972 );
973 let plan = s.plan().unwrap();
974 assert_eq!(plan.next_version, Version::new(0, 1, 0));
975 assert_eq!(plan.tag_name, "v0.1.0");
976 assert!(plan.current_version.is_none());
977 }
978
979 #[test]
980 fn plan_increments_existing() {
981 let tag = TagInfo {
982 name: "v1.2.3".into(),
983 version: Version::new(1, 2, 3),
984 sha: "b".repeat(40),
985 };
986 let s = make_strategy(
987 vec![tag],
988 vec![raw_commit("fix: patch bug")],
989 ReleaseConfig::default(),
990 );
991 let plan = s.plan().unwrap();
992 assert_eq!(plan.next_version, Version::new(1, 2, 4));
993 }
994
995 #[test]
996 fn plan_breaking_bump() {
997 let tag = TagInfo {
998 name: "v1.2.3".into(),
999 version: Version::new(1, 2, 3),
1000 sha: "c".repeat(40),
1001 };
1002 let s = make_strategy(
1003 vec![tag],
1004 vec![raw_commit("feat!: breaking change")],
1005 ReleaseConfig::default(),
1006 );
1007 let plan = s.plan().unwrap();
1008 assert_eq!(plan.next_version, Version::new(2, 0, 0));
1009 }
1010
1011 #[test]
1014 fn execute_dry_run_no_side_effects() {
1015 let s = make_strategy(
1016 vec![],
1017 vec![raw_commit("feat: something")],
1018 ReleaseConfig::default(),
1019 );
1020 let plan = s.plan().unwrap();
1021 s.execute(&plan, true).unwrap();
1022
1023 assert!(s.git.created_tags.lock().unwrap().is_empty());
1024 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1025 }
1026
1027 #[test]
1028 fn execute_creates_and_pushes_tag() {
1029 let s = make_strategy(
1030 vec![],
1031 vec![raw_commit("feat: something")],
1032 ReleaseConfig::default(),
1033 );
1034 let plan = s.plan().unwrap();
1035 s.execute(&plan, false).unwrap();
1036
1037 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1038 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1039 }
1040
1041 #[test]
1042 fn execute_calls_vcs_create_release() {
1043 let s = make_strategy(
1044 vec![],
1045 vec![raw_commit("feat: something")],
1046 ReleaseConfig::default(),
1047 );
1048 let plan = s.plan().unwrap();
1049 s.execute(&plan, false).unwrap();
1050
1051 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1052 assert_eq!(releases.len(), 1);
1053 assert_eq!(releases[0].0, "v0.1.0");
1054 assert!(!releases[0].1.is_empty());
1055 }
1056
1057 #[test]
1058 fn execute_commits_changelog_before_tag() {
1059 let dir = tempfile::tempdir().unwrap();
1060 let changelog_path = dir.path().join("CHANGELOG.md");
1061
1062 let mut config = ReleaseConfig::default();
1063 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1064
1065 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1066 let plan = s.plan().unwrap();
1067 s.execute(&plan, false).unwrap();
1068
1069 let committed = s.git.committed.lock().unwrap();
1071 assert_eq!(committed.len(), 1);
1072 assert_eq!(
1073 committed[0].0,
1074 vec![changelog_path.to_str().unwrap().to_string()]
1075 );
1076 assert!(committed[0].1.contains("chore(release): v0.1.0"));
1077
1078 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1080 }
1081
1082 #[test]
1083 fn execute_skips_existing_tag() {
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.git
1093 .created_tags
1094 .lock()
1095 .unwrap()
1096 .push("v0.1.0".to_string());
1097
1098 s.execute(&plan, false).unwrap();
1099
1100 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1102 }
1103
1104 #[test]
1105 fn execute_skips_existing_release() {
1106 let s = make_strategy(
1107 vec![],
1108 vec![raw_commit("feat: something")],
1109 ReleaseConfig::default(),
1110 );
1111 let plan = s.plan().unwrap();
1112
1113 s.vcs
1115 .as_ref()
1116 .unwrap()
1117 .releases
1118 .lock()
1119 .unwrap()
1120 .push(("v0.1.0".to_string(), "old notes".to_string()));
1121
1122 s.execute(&plan, false).unwrap();
1123
1124 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
1126 assert_eq!(*deleted, vec!["v0.1.0"]);
1127
1128 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1129 assert_eq!(releases.len(), 1);
1130 assert_eq!(releases[0].0, "v0.1.0");
1131 assert_ne!(releases[0].1, "old notes");
1132 }
1133
1134 #[test]
1135 fn execute_idempotent_rerun() {
1136 let s = make_strategy(
1137 vec![],
1138 vec![raw_commit("feat: something")],
1139 ReleaseConfig::default(),
1140 );
1141 let plan = s.plan().unwrap();
1142
1143 s.execute(&plan, false).unwrap();
1145
1146 s.execute(&plan, false).unwrap();
1148
1149 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1151
1152 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1154
1155 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1157
1158 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
1160 assert_eq!(*deleted, vec!["v0.1.0"]);
1161
1162 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1163 assert_eq!(releases.len(), 1);
1165 assert_eq!(releases[0].0, "v0.1.0");
1166 }
1167
1168 #[test]
1169 fn execute_bumps_version_files() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let cargo_path = dir.path().join("Cargo.toml");
1172 std::fs::write(
1173 &cargo_path,
1174 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1175 )
1176 .unwrap();
1177
1178 let mut config = ReleaseConfig::default();
1179 config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1180
1181 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1182 let plan = s.plan().unwrap();
1183 s.execute(&plan, false).unwrap();
1184
1185 let contents = std::fs::read_to_string(&cargo_path).unwrap();
1187 assert!(contents.contains("version = \"0.1.0\""));
1188
1189 let committed = s.git.committed.lock().unwrap();
1191 assert_eq!(committed.len(), 1);
1192 assert!(
1193 committed[0]
1194 .0
1195 .contains(&cargo_path.to_str().unwrap().to_string())
1196 );
1197 }
1198
1199 #[test]
1200 fn execute_stages_changelog_and_version_files_together() {
1201 let dir = tempfile::tempdir().unwrap();
1202 let cargo_path = dir.path().join("Cargo.toml");
1203 std::fs::write(
1204 &cargo_path,
1205 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1206 )
1207 .unwrap();
1208
1209 let changelog_path = dir.path().join("CHANGELOG.md");
1210
1211 let mut config = ReleaseConfig::default();
1212 config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1213 config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1214
1215 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1216 let plan = s.plan().unwrap();
1217 s.execute(&plan, false).unwrap();
1218
1219 let committed = s.git.committed.lock().unwrap();
1221 assert_eq!(committed.len(), 1);
1222 assert!(
1223 committed[0]
1224 .0
1225 .contains(&changelog_path.to_str().unwrap().to_string())
1226 );
1227 assert!(
1228 committed[0]
1229 .0
1230 .contains(&cargo_path.to_str().unwrap().to_string())
1231 );
1232 }
1233
1234 #[test]
1237 fn execute_uploads_artifacts() {
1238 let dir = tempfile::tempdir().unwrap();
1239 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1240 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1241
1242 let mut config = ReleaseConfig::default();
1243 config.artifacts = vec![
1244 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1245 dir.path().join("*.zip").to_str().unwrap().to_string(),
1246 ];
1247
1248 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1249 let plan = s.plan().unwrap();
1250 s.execute(&plan, false).unwrap();
1251
1252 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1253 assert_eq!(uploaded.len(), 1);
1254 assert_eq!(uploaded[0].0, "v0.1.0");
1255 assert_eq!(uploaded[0].1.len(), 4);
1257 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1258 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1259 assert!(
1260 uploaded[0]
1261 .1
1262 .iter()
1263 .any(|f| f.ends_with("app.tar.gz.sha256"))
1264 );
1265 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip.sha256")));
1266 }
1267
1268 #[test]
1269 fn execute_dry_run_shows_artifacts() {
1270 let dir = tempfile::tempdir().unwrap();
1271 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1272
1273 let mut config = ReleaseConfig::default();
1274 config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
1275
1276 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1277 let plan = s.plan().unwrap();
1278 s.execute(&plan, true).unwrap();
1279
1280 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1282 assert!(uploaded.is_empty());
1283 }
1284
1285 #[test]
1286 fn execute_no_artifacts_skips_upload() {
1287 let s = make_strategy(
1288 vec![],
1289 vec![raw_commit("feat: something")],
1290 ReleaseConfig::default(),
1291 );
1292 let plan = s.plan().unwrap();
1293 s.execute(&plan, false).unwrap();
1294
1295 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1296 assert!(uploaded.is_empty());
1297 }
1298
1299 #[test]
1300 fn resolve_artifact_globs_basic() {
1301 let dir = tempfile::tempdir().unwrap();
1302 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1303 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1304 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1305
1306 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1307 let result = resolve_artifact_globs(&[pattern]).unwrap();
1308 assert_eq!(result.len(), 2);
1309 assert!(result.iter().any(|f| f.ends_with("a.txt")));
1310 assert!(result.iter().any(|f| f.ends_with("b.txt")));
1311 }
1312
1313 #[test]
1314 fn resolve_artifact_globs_deduplicates() {
1315 let dir = tempfile::tempdir().unwrap();
1316 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1317
1318 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1319 let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
1321 assert_eq!(result.len(), 1);
1322 }
1323
1324 #[test]
1327 fn plan_floating_tag_when_enabled() {
1328 let tag = TagInfo {
1329 name: "v3.2.0".into(),
1330 version: Version::new(3, 2, 0),
1331 sha: "d".repeat(40),
1332 };
1333 let mut config = ReleaseConfig::default();
1334 config.floating_tags = true;
1335
1336 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1337 let plan = s.plan().unwrap();
1338 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1339 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1340 }
1341
1342 #[test]
1343 fn plan_no_floating_tag_when_disabled() {
1344 let s = make_strategy(
1345 vec![],
1346 vec![raw_commit("feat: something")],
1347 ReleaseConfig::default(),
1348 );
1349 let plan = s.plan().unwrap();
1350 assert!(plan.floating_tag_name.is_none());
1351 }
1352
1353 #[test]
1354 fn plan_floating_tag_custom_prefix() {
1355 let tag = TagInfo {
1356 name: "release-2.5.0".into(),
1357 version: Version::new(2, 5, 0),
1358 sha: "e".repeat(40),
1359 };
1360 let mut config = ReleaseConfig::default();
1361 config.floating_tags = true;
1362 config.tag_prefix = "release-".into();
1363
1364 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1365 let plan = s.plan().unwrap();
1366 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1367 }
1368
1369 #[test]
1370 fn execute_floating_tags_force_create_and_push() {
1371 let mut config = ReleaseConfig::default();
1372 config.floating_tags = true;
1373
1374 let tag = TagInfo {
1375 name: "v1.2.3".into(),
1376 version: Version::new(1, 2, 3),
1377 sha: "f".repeat(40),
1378 };
1379 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1380 let plan = s.plan().unwrap();
1381 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1382
1383 s.execute(&plan, false).unwrap();
1384
1385 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1386 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1387 }
1388
1389 #[test]
1390 fn execute_no_floating_tags_when_disabled() {
1391 let s = make_strategy(
1392 vec![],
1393 vec![raw_commit("feat: something")],
1394 ReleaseConfig::default(),
1395 );
1396 let plan = s.plan().unwrap();
1397 assert!(plan.floating_tag_name.is_none());
1398
1399 s.execute(&plan, false).unwrap();
1400
1401 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1402 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1403 }
1404
1405 #[test]
1406 fn execute_floating_tags_dry_run_no_side_effects() {
1407 let mut config = ReleaseConfig::default();
1408 config.floating_tags = true;
1409
1410 let tag = TagInfo {
1411 name: "v2.0.0".into(),
1412 version: Version::new(2, 0, 0),
1413 sha: "a".repeat(40),
1414 };
1415 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1416 let plan = s.plan().unwrap();
1417 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1418
1419 s.execute(&plan, true).unwrap();
1420
1421 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1422 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1423 }
1424
1425 #[test]
1426 fn execute_floating_tags_idempotent() {
1427 let mut config = ReleaseConfig::default();
1428 config.floating_tags = true;
1429
1430 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1431 let plan = s.plan().unwrap();
1432 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1433
1434 s.execute(&plan, false).unwrap();
1436 s.execute(&plan, false).unwrap();
1437
1438 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1440 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1441 }
1442
1443 #[test]
1446 fn force_rerelease_when_tag_at_head() {
1447 let tag = TagInfo {
1448 name: "v1.2.3".into(),
1449 version: Version::new(1, 2, 3),
1450 sha: "a".repeat(40),
1451 };
1452 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1453 s.git.head = "a".repeat(40);
1455 s.force = true;
1456
1457 let plan = s.plan().unwrap();
1458 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1459 assert_eq!(plan.tag_name, "v1.2.3");
1460 assert!(plan.commits.is_empty());
1461 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1462 }
1463
1464 #[test]
1465 fn force_fails_when_tag_not_at_head() {
1466 let tag = TagInfo {
1467 name: "v1.2.3".into(),
1468 version: Version::new(1, 2, 3),
1469 sha: "a".repeat(40),
1470 };
1471 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1472 s.git.head = "b".repeat(40);
1474 s.force = true;
1475
1476 let err = s.plan().unwrap_err();
1477 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1478 }
1479
1480 #[test]
1483 fn execute_runs_build_command_after_version_bump() {
1484 let dir = tempfile::tempdir().unwrap();
1485 let output_file = dir.path().join("sr_test_version");
1486
1487 let mut config = ReleaseConfig::default();
1488 config.build_command = Some(format!(
1489 "echo $SR_VERSION > {}",
1490 output_file.to_str().unwrap()
1491 ));
1492
1493 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1494 let plan = s.plan().unwrap();
1495 s.execute(&plan, false).unwrap();
1496
1497 let contents = std::fs::read_to_string(&output_file).unwrap();
1498 assert_eq!(contents.trim(), "0.1.0");
1499 }
1500
1501 #[test]
1502 fn execute_build_command_failure_aborts_release() {
1503 let mut config = ReleaseConfig::default();
1504 config.build_command = Some("exit 1".into());
1505
1506 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1507 let plan = s.plan().unwrap();
1508 let result = s.execute(&plan, false);
1509
1510 assert!(result.is_err());
1511 assert!(s.git.created_tags.lock().unwrap().is_empty());
1512 }
1513
1514 #[test]
1515 fn execute_dry_run_skips_build_command() {
1516 let dir = tempfile::tempdir().unwrap();
1517 let output_file = dir.path().join("sr_test_should_not_exist");
1518
1519 let mut config = ReleaseConfig::default();
1520 config.build_command = Some(format!("echo test > {}", output_file.to_str().unwrap()));
1521
1522 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1523 let plan = s.plan().unwrap();
1524 s.execute(&plan, true).unwrap();
1525
1526 assert!(!output_file.exists());
1527 }
1528
1529 #[test]
1530 fn force_fails_with_no_tags() {
1531 let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1532 s.force = true;
1533
1534 let err = s.plan().unwrap_err();
1535 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1536 }
1537
1538 #[test]
1541 fn execute_stages_extra_files() {
1542 let dir = tempfile::tempdir().unwrap();
1543 let lock_file = dir.path().join("Cargo.lock");
1544 std::fs::write(&lock_file, "old lock").unwrap();
1545
1546 let mut config = ReleaseConfig::default();
1547 config.build_command = Some(format!("echo 'new lock' > {}", lock_file.to_str().unwrap()));
1548 config.stage_files = vec![lock_file.to_str().unwrap().to_string()];
1549
1550 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1551 let plan = s.plan().unwrap();
1552 s.execute(&plan, false).unwrap();
1553
1554 let committed = s.git.committed.lock().unwrap();
1555 assert!(!committed.is_empty());
1556 let (staged, _) = &committed[0];
1557 assert!(
1558 staged.iter().any(|f| f.contains("Cargo.lock")),
1559 "Cargo.lock should be staged, got: {staged:?}"
1560 );
1561 }
1562
1563 #[test]
1564 fn execute_dry_run_shows_stage_files() {
1565 let mut config = ReleaseConfig::default();
1566 config.stage_files = vec!["Cargo.lock".into()];
1567
1568 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1569 let plan = s.plan().unwrap();
1570 s.execute(&plan, true).unwrap();
1572 }
1573
1574 #[test]
1577 fn execute_build_failure_restores_version_files() {
1578 let dir = tempfile::tempdir().unwrap();
1579 let cargo_toml = dir.path().join("Cargo.toml");
1580 std::fs::write(
1581 &cargo_toml,
1582 "[package]\nname = \"test\"\nversion = \"1.0.0\"\n",
1583 )
1584 .unwrap();
1585
1586 let mut config = ReleaseConfig::default();
1587 config.version_files = vec![cargo_toml.to_str().unwrap().to_string()];
1588 config.build_command = Some("exit 1".into());
1589
1590 let tag = TagInfo {
1591 name: "v1.0.0".into(),
1592 version: Version::new(1, 0, 0),
1593 sha: "d".repeat(40),
1594 };
1595 let s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
1596 let plan = s.plan().unwrap();
1597 let result = s.execute(&plan, false);
1598
1599 assert!(result.is_err());
1600 let contents = std::fs::read_to_string(&cargo_toml).unwrap();
1602 assert!(
1603 contents.contains("version = \"1.0.0\""),
1604 "version should be restored, got: {contents}"
1605 );
1606 }
1607
1608 #[test]
1611 fn execute_pre_release_command_runs() {
1612 let dir = tempfile::tempdir().unwrap();
1613 let marker = dir.path().join("pre_release_ran");
1614
1615 let mut config = ReleaseConfig::default();
1616 config.pre_release_command = Some(format!("touch {}", marker.to_str().unwrap()));
1617
1618 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1619 let plan = s.plan().unwrap();
1620 s.execute(&plan, false).unwrap();
1621
1622 assert!(marker.exists(), "pre-release command should have run");
1623 }
1624
1625 #[test]
1626 fn execute_post_release_command_runs() {
1627 let dir = tempfile::tempdir().unwrap();
1628 let marker = dir.path().join("post_release_ran");
1629
1630 let mut config = ReleaseConfig::default();
1631 config.post_release_command = Some(format!("touch {}", marker.to_str().unwrap()));
1632
1633 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1634 let plan = s.plan().unwrap();
1635 s.execute(&plan, false).unwrap();
1636
1637 assert!(marker.exists(), "post-release command should have run");
1638 }
1639
1640 #[test]
1641 fn execute_pre_release_failure_aborts_release() {
1642 let mut config = ReleaseConfig::default();
1643 config.pre_release_command = Some("exit 1".into());
1644
1645 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1646 let plan = s.plan().unwrap();
1647 let result = s.execute(&plan, false);
1648
1649 assert!(result.is_err());
1650 assert!(s.git.created_tags.lock().unwrap().is_empty());
1652 assert!(s.git.committed.lock().unwrap().is_empty());
1653 }
1654
1655 #[test]
1656 fn execute_hooks_receive_version_env_vars() {
1657 let dir = tempfile::tempdir().unwrap();
1658 let output_file = dir.path().join("hook_output");
1659
1660 let mut config = ReleaseConfig::default();
1661 config.post_release_command = Some(format!(
1662 "echo $SR_VERSION $SR_TAG > {}",
1663 output_file.to_str().unwrap()
1664 ));
1665
1666 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1667 let plan = s.plan().unwrap();
1668 s.execute(&plan, false).unwrap();
1669
1670 let contents = std::fs::read_to_string(&output_file).unwrap();
1671 assert!(contents.contains("0.1.0"), "SR_VERSION should be set");
1672 assert!(contents.contains("v0.1.0"), "SR_TAG should be set");
1673 }
1674
1675 #[test]
1676 fn execute_dry_run_skips_hooks() {
1677 let dir = tempfile::tempdir().unwrap();
1678 let pre_marker = dir.path().join("pre_hook");
1679 let post_marker = dir.path().join("post_hook");
1680
1681 let mut config = ReleaseConfig::default();
1682 config.pre_release_command = Some(format!("touch {}", pre_marker.to_str().unwrap()));
1683 config.post_release_command = Some(format!("touch {}", post_marker.to_str().unwrap()));
1684
1685 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1686 let plan = s.plan().unwrap();
1687 s.execute(&plan, true).unwrap();
1688
1689 assert!(
1690 !pre_marker.exists(),
1691 "pre-release hook should not run in dry-run"
1692 );
1693 assert!(
1694 !post_marker.exists(),
1695 "post-release hook should not run in dry-run"
1696 );
1697 }
1698
1699 #[test]
1702 fn plan_prerelease_first_release() {
1703 let mut config = ReleaseConfig::default();
1704 config.prerelease = Some("alpha".into());
1705
1706 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1707 let plan = s.plan().unwrap();
1708 assert_eq!(plan.next_version.to_string(), "0.1.0-alpha.1");
1709 assert_eq!(plan.tag_name, "v0.1.0-alpha.1");
1710 assert!(plan.prerelease);
1711 }
1712
1713 #[test]
1714 fn plan_prerelease_increments_from_stable() {
1715 let tag = TagInfo {
1716 name: "v1.0.0".into(),
1717 version: Version::new(1, 0, 0),
1718 sha: "d".repeat(40),
1719 };
1720 let mut config = ReleaseConfig::default();
1721 config.prerelease = Some("beta".into());
1722
1723 let s = make_strategy(vec![tag], vec![raw_commit("feat: new feature")], config);
1724 let plan = s.plan().unwrap();
1725 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1726 assert!(plan.prerelease);
1727 }
1728
1729 #[test]
1730 fn plan_prerelease_increments_counter() {
1731 let tags = vec![
1732 TagInfo {
1733 name: "v1.0.0".into(),
1734 version: Version::new(1, 0, 0),
1735 sha: "a".repeat(40),
1736 },
1737 TagInfo {
1738 name: "v1.1.0-alpha.1".into(),
1739 version: Version::parse("1.1.0-alpha.1").unwrap(),
1740 sha: "b".repeat(40),
1741 },
1742 TagInfo {
1743 name: "v1.1.0-alpha.2".into(),
1744 version: Version::parse("1.1.0-alpha.2").unwrap(),
1745 sha: "c".repeat(40),
1746 },
1747 ];
1748 let mut config = ReleaseConfig::default();
1749 config.prerelease = Some("alpha".into());
1750
1751 let s = make_strategy(tags, vec![raw_commit("feat: another")], config);
1752 let plan = s.plan().unwrap();
1753 assert_eq!(plan.next_version.to_string(), "1.1.0-alpha.3");
1754 }
1755
1756 #[test]
1757 fn plan_prerelease_different_id_starts_at_1() {
1758 let tags = vec![
1759 TagInfo {
1760 name: "v1.0.0".into(),
1761 version: Version::new(1, 0, 0),
1762 sha: "a".repeat(40),
1763 },
1764 TagInfo {
1765 name: "v1.1.0-alpha.3".into(),
1766 version: Version::parse("1.1.0-alpha.3").unwrap(),
1767 sha: "b".repeat(40),
1768 },
1769 ];
1770 let mut config = ReleaseConfig::default();
1771 config.prerelease = Some("beta".into());
1772
1773 let s = make_strategy(tags, vec![raw_commit("feat: something")], config);
1774 let plan = s.plan().unwrap();
1775 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1776 }
1777
1778 #[test]
1779 fn plan_prerelease_no_floating_tags() {
1780 let mut config = ReleaseConfig::default();
1781 config.prerelease = Some("rc".into());
1782 config.floating_tags = true;
1783
1784 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1785 let plan = s.plan().unwrap();
1786 assert!(
1787 plan.floating_tag_name.is_none(),
1788 "pre-releases should not create floating tags"
1789 );
1790 }
1791
1792 #[test]
1793 fn plan_stable_skips_prerelease_tags() {
1794 let tags = vec![
1795 TagInfo {
1796 name: "v1.0.0".into(),
1797 version: Version::new(1, 0, 0),
1798 sha: "a".repeat(40),
1799 },
1800 TagInfo {
1801 name: "v1.1.0-alpha.1".into(),
1802 version: Version::parse("1.1.0-alpha.1").unwrap(),
1803 sha: "b".repeat(40),
1804 },
1805 ];
1806 let s = make_strategy(
1808 tags,
1809 vec![raw_commit("feat: something")],
1810 ReleaseConfig::default(),
1811 );
1812 let plan = s.plan().unwrap();
1813 assert_eq!(plan.next_version, Version::new(1, 1, 0));
1815 assert!(!plan.prerelease);
1816 }
1817
1818 #[test]
1819 fn plan_prerelease_marks_plan_as_prerelease() {
1820 let mut config = ReleaseConfig::default();
1821 config.prerelease = Some("alpha".into());
1822
1823 let s = make_strategy(vec![], vec![raw_commit("fix: bug")], config);
1824 let plan = s.plan().unwrap();
1825 assert!(plan.prerelease);
1826 assert!(plan.next_version.to_string().contains("alpha"));
1827 }
1828
1829 #[test]
1832 fn plan_with_path_filter_uses_filtered_commits() {
1833 let mut config = ReleaseConfig::default();
1834 config.path_filter = Some("crates/core".into());
1835
1836 let mut s = make_strategy(
1838 vec![],
1839 vec![raw_commit("feat: big feature"), raw_commit("fix: patch")],
1840 config,
1841 );
1842 s.git.path_commits = Some(vec![raw_commit("fix: patch only in core")]);
1843
1844 let plan = s.plan().unwrap();
1845 assert_eq!(plan.bump, BumpLevel::Patch);
1847 assert_eq!(plan.commits.len(), 1);
1848 assert_eq!(plan.commits[0].description, "patch only in core");
1849 }
1850
1851 #[test]
1852 fn plan_without_path_filter_uses_all_commits() {
1853 let config = ReleaseConfig::default();
1854
1855 let mut s = make_strategy(vec![], vec![raw_commit("feat: big feature")], config);
1856 s.git.path_commits = Some(vec![raw_commit("fix: filtered")]);
1857
1858 let plan = s.plan().unwrap();
1859 assert_eq!(plan.bump, BumpLevel::Minor);
1861 }
1862
1863 #[test]
1864 fn plan_with_path_filter_no_commits_returns_error() {
1865 let mut config = ReleaseConfig::default();
1866 config.path_filter = Some("crates/core".into());
1867
1868 let mut s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1869 s.git.path_commits = Some(vec![]);
1870
1871 let err = s.plan().unwrap_err();
1872 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1873 }
1874
1875 #[test]
1876 fn plan_with_path_filter_custom_tag_prefix() {
1877 let mut config = ReleaseConfig::default();
1878 config.path_filter = Some("crates/core".into());
1879 config.tag_prefix = "core/v".into();
1880
1881 let tag = TagInfo {
1882 name: "core/v1.0.0".into(),
1883 version: Version::new(1, 0, 0),
1884 sha: "a".repeat(40),
1885 };
1886 let mut s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
1887 s.git.path_commits = Some(vec![raw_commit("fix: core bug")]);
1888
1889 let plan = s.plan().unwrap();
1890 assert_eq!(plan.tag_name, "core/v1.0.1");
1891 assert_eq!(plan.current_version, Some(Version::new(1, 0, 0)));
1892 }
1893}