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