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