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_lifecycle_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_lifecycle_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_lifecycle_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_lifecycle_hook(
604 cmd: &str,
605 version: &str,
606 tag: &str,
607 label: &str,
608) -> Result<(), ReleaseError> {
609 crate::hooks::run_shell(cmd, None, &[("SR_VERSION", version), ("SR_TAG", tag)])
610 .map_err(|e| ReleaseError::BuildCommand(format!("{label}: {e}")))
611}
612
613fn resolve_glob_patterns(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
615 let mut files = Vec::new();
616 for pattern in patterns {
617 let paths = glob::glob(pattern)
618 .map_err(|e| ReleaseError::Config(format!("invalid glob pattern '{pattern}': {e}")))?;
619 for entry in paths {
620 match entry {
621 Ok(path) if path.is_file() => {
622 files.push(path.to_string_lossy().into_owned());
623 }
624 Ok(_) => {}
625 Err(e) => {
626 eprintln!("warning: glob error: {e}");
627 }
628 }
629 }
630 }
631 Ok(files)
632}
633
634fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
635 let mut files = std::collections::BTreeSet::new();
636 for pattern in patterns {
637 let paths = glob::glob(pattern)
638 .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
639 for entry in paths {
640 match entry {
641 Ok(path) if path.is_file() => {
642 files.insert(path.to_string_lossy().into_owned());
643 }
644 Ok(_) => {} Err(e) => {
646 eprintln!("warning: glob error: {e}");
647 }
648 }
649 }
650 }
651 Ok(files.into_iter().collect())
652}
653
654fn generate_checksums(files: &[String]) -> Result<Vec<String>, ReleaseError> {
657 use sha2::{Digest, Sha256};
658
659 let mut checksum_paths = Vec::new();
660 for file_path in files {
661 let data = fs::read(file_path).map_err(|e| {
662 ReleaseError::Vcs(format!("failed to read {file_path} for checksum: {e}"))
663 })?;
664 let hash = Sha256::digest(&data);
665 let hex = format!("{hash:x}");
666 let file_name = Path::new(file_path)
667 .file_name()
668 .and_then(|n| n.to_str())
669 .unwrap_or("unknown");
670 let checksum_content = format!("{hex} {file_name}\n");
671 let checksum_path = format!("{file_path}.sha256");
672 fs::write(&checksum_path, checksum_content)
673 .map_err(|e| ReleaseError::Vcs(format!("failed to write checksum file: {e}")))?;
674 checksum_paths.push(checksum_path);
675 }
676 Ok(checksum_paths)
677}
678
679pub fn today_string() -> String {
680 let secs = std::time::SystemTime::now()
683 .duration_since(std::time::UNIX_EPOCH)
684 .unwrap_or_default()
685 .as_secs() as i64;
686
687 let z = secs / 86400 + 719468;
688 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
689 let doe = (z - era * 146097) as u32;
690 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
691 let y = yoe as i64 + era * 400;
692 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
693 let mp = (5 * doy + 2) / 153;
694 let d = doy - (153 * mp + 2) / 5 + 1;
695 let m = if mp < 10 { mp + 3 } else { mp - 9 };
696 let y = if m <= 2 { y + 1 } else { y };
697
698 format!("{y:04}-{m:02}-{d:02}")
699}
700
701#[cfg(test)]
702mod tests {
703 use std::sync::Mutex;
704
705 use super::*;
706 use crate::changelog::DefaultChangelogFormatter;
707 use crate::commit::{Commit, DefaultCommitParser};
708 use crate::config::ReleaseConfig;
709 use crate::git::{GitRepository, TagInfo};
710
711 struct FakeGit {
714 tags: Vec<TagInfo>,
715 commits: Vec<Commit>,
716 path_commits: Option<Vec<Commit>>,
718 head: String,
719 created_tags: Mutex<Vec<String>>,
720 pushed_tags: Mutex<Vec<String>>,
721 committed: Mutex<Vec<(Vec<String>, String)>>,
722 push_count: Mutex<u32>,
723 force_created_tags: Mutex<Vec<String>>,
724 force_pushed_tags: Mutex<Vec<String>>,
725 }
726
727 impl FakeGit {
728 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
729 let head = tags
730 .last()
731 .map(|t| t.sha.clone())
732 .unwrap_or_else(|| "0".repeat(40));
733 Self {
734 tags,
735 commits,
736 path_commits: None,
737 head,
738 created_tags: Mutex::new(Vec::new()),
739 pushed_tags: Mutex::new(Vec::new()),
740 committed: Mutex::new(Vec::new()),
741 push_count: Mutex::new(0),
742 force_created_tags: Mutex::new(Vec::new()),
743 force_pushed_tags: Mutex::new(Vec::new()),
744 }
745 }
746 }
747
748 impl GitRepository for FakeGit {
749 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
750 Ok(self.tags.last().cloned())
751 }
752
753 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
754 Ok(self.commits.clone())
755 }
756
757 fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
758 self.created_tags.lock().unwrap().push(name.to_string());
759 Ok(())
760 }
761
762 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
763 self.pushed_tags.lock().unwrap().push(name.to_string());
764 Ok(())
765 }
766
767 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
768 self.committed.lock().unwrap().push((
769 paths.iter().map(|s| s.to_string()).collect(),
770 message.to_string(),
771 ));
772 Ok(true)
773 }
774
775 fn push(&self) -> Result<(), ReleaseError> {
776 *self.push_count.lock().unwrap() += 1;
777 Ok(())
778 }
779
780 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
781 Ok(self
782 .created_tags
783 .lock()
784 .unwrap()
785 .contains(&name.to_string()))
786 }
787
788 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
789 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
790 }
791
792 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
793 Ok(self.tags.clone())
794 }
795
796 fn commits_between(
797 &self,
798 _from: Option<&str>,
799 _to: &str,
800 ) -> Result<Vec<Commit>, ReleaseError> {
801 Ok(self.commits.clone())
802 }
803
804 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
805 Ok("2026-01-01".into())
806 }
807
808 fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
809 self.force_created_tags
810 .lock()
811 .unwrap()
812 .push(name.to_string());
813 Ok(())
814 }
815
816 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
817 self.force_pushed_tags
818 .lock()
819 .unwrap()
820 .push(name.to_string());
821 Ok(())
822 }
823
824 fn head_sha(&self) -> Result<String, ReleaseError> {
825 Ok(self.head.clone())
826 }
827
828 fn commits_since_in_path(
829 &self,
830 _from: Option<&str>,
831 _path: &str,
832 ) -> Result<Vec<Commit>, ReleaseError> {
833 Ok(self
834 .path_commits
835 .clone()
836 .unwrap_or_else(|| self.commits.clone()))
837 }
838 }
839
840 struct FakeVcs {
841 releases: Mutex<Vec<(String, String)>>,
842 deleted_releases: Mutex<Vec<String>>,
843 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
844 }
845
846 impl FakeVcs {
847 fn new() -> Self {
848 Self {
849 releases: Mutex::new(Vec::new()),
850 deleted_releases: Mutex::new(Vec::new()),
851 uploaded_assets: Mutex::new(Vec::new()),
852 }
853 }
854 }
855
856 impl VcsProvider for FakeVcs {
857 fn create_release(
858 &self,
859 tag: &str,
860 _name: &str,
861 body: &str,
862 _prerelease: bool,
863 _draft: bool,
864 ) -> Result<String, ReleaseError> {
865 self.releases
866 .lock()
867 .unwrap()
868 .push((tag.to_string(), body.to_string()));
869 Ok(format!("https://github.com/test/release/{tag}"))
870 }
871
872 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
873 Ok(format!("https://github.com/test/compare/{base}...{head}"))
874 }
875
876 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
877 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
878 }
879
880 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
881 self.deleted_releases.lock().unwrap().push(tag.to_string());
882 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
883 Ok(())
884 }
885
886 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
887 self.uploaded_assets.lock().unwrap().push((
888 tag.to_string(),
889 files.iter().map(|s| s.to_string()).collect(),
890 ));
891 Ok(())
892 }
893
894 fn repo_url(&self) -> Option<String> {
895 Some("https://github.com/test/repo".into())
896 }
897 }
898
899 fn raw_commit(msg: &str) -> Commit {
902 Commit {
903 sha: "a".repeat(40),
904 message: msg.into(),
905 }
906 }
907
908 fn make_strategy(
909 tags: Vec<TagInfo>,
910 commits: Vec<Commit>,
911 config: ReleaseConfig,
912 ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
913 {
914 let types = config.types.clone();
915 let breaking_section = config.breaking_section.clone();
916 let misc_section = config.misc_section.clone();
917 TrunkReleaseStrategy {
918 git: FakeGit::new(tags, commits),
919 vcs: Some(FakeVcs::new()),
920 parser: DefaultCommitParser,
921 formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
922 config,
923 force: false,
924 }
925 }
926
927 #[test]
930 fn plan_no_commits_returns_error() {
931 let s = make_strategy(vec![], vec![], ReleaseConfig::default());
932 let err = s.plan().unwrap_err();
933 assert!(matches!(err, ReleaseError::NoCommits { .. }));
934 }
935
936 #[test]
937 fn plan_no_releasable_returns_error() {
938 let s = make_strategy(
939 vec![],
940 vec![raw_commit("chore: tidy up")],
941 ReleaseConfig::default(),
942 );
943 let err = s.plan().unwrap_err();
944 assert!(matches!(err, ReleaseError::NoBump { .. }));
945 }
946
947 #[test]
948 fn force_releases_patch_when_no_releasable_commits() {
949 let tag = TagInfo {
950 name: "v1.2.3".into(),
951 version: Version::new(1, 2, 3),
952 sha: "d".repeat(40),
953 };
954 let mut s = make_strategy(
955 vec![tag],
956 vec![raw_commit("chore: rename package")],
957 ReleaseConfig::default(),
958 );
959 s.force = true;
960 let plan = s.plan().unwrap();
961 assert_eq!(plan.next_version, Version::new(1, 2, 4));
962 assert_eq!(plan.bump, BumpLevel::Patch);
963 }
964
965 #[test]
966 fn plan_first_release() {
967 let s = make_strategy(
968 vec![],
969 vec![raw_commit("feat: initial feature")],
970 ReleaseConfig::default(),
971 );
972 let plan = s.plan().unwrap();
973 assert_eq!(plan.next_version, Version::new(0, 1, 0));
974 assert_eq!(plan.tag_name, "v0.1.0");
975 assert!(plan.current_version.is_none());
976 }
977
978 #[test]
979 fn plan_increments_existing() {
980 let tag = TagInfo {
981 name: "v1.2.3".into(),
982 version: Version::new(1, 2, 3),
983 sha: "b".repeat(40),
984 };
985 let s = make_strategy(
986 vec![tag],
987 vec![raw_commit("fix: patch bug")],
988 ReleaseConfig::default(),
989 );
990 let plan = s.plan().unwrap();
991 assert_eq!(plan.next_version, Version::new(1, 2, 4));
992 }
993
994 #[test]
995 fn plan_breaking_bump() {
996 let tag = TagInfo {
997 name: "v1.2.3".into(),
998 version: Version::new(1, 2, 3),
999 sha: "c".repeat(40),
1000 };
1001 let s = make_strategy(
1002 vec![tag],
1003 vec![raw_commit("feat!: breaking change")],
1004 ReleaseConfig::default(),
1005 );
1006 let plan = s.plan().unwrap();
1007 assert_eq!(plan.next_version, Version::new(2, 0, 0));
1008 }
1009
1010 #[test]
1011 fn plan_v0_breaking_downshifts_to_minor() {
1012 let tag = TagInfo {
1013 name: "v0.5.0".into(),
1014 version: Version::new(0, 5, 0),
1015 sha: "c".repeat(40),
1016 };
1017 let s = make_strategy(
1018 vec![tag],
1019 vec![raw_commit("feat!: breaking change")],
1020 ReleaseConfig::default(),
1021 );
1022 let plan = s.plan().unwrap();
1023 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1025 assert_eq!(plan.bump, BumpLevel::Minor);
1026 }
1027
1028 #[test]
1029 fn plan_v0_breaking_with_force_bumps_major() {
1030 let tag = TagInfo {
1031 name: "v0.5.0".into(),
1032 version: Version::new(0, 5, 0),
1033 sha: "c".repeat(40),
1034 };
1035 let mut s = make_strategy(
1036 vec![tag],
1037 vec![raw_commit("feat!: breaking change")],
1038 ReleaseConfig::default(),
1039 );
1040 s.force = true;
1041 let plan = s.plan().unwrap();
1042 assert_eq!(plan.next_version, Version::new(1, 0, 0));
1044 assert_eq!(plan.bump, BumpLevel::Major);
1045 }
1046
1047 #[test]
1048 fn plan_v0_feat_stays_minor() {
1049 let tag = TagInfo {
1050 name: "v0.5.0".into(),
1051 version: Version::new(0, 5, 0),
1052 sha: "c".repeat(40),
1053 };
1054 let s = make_strategy(
1055 vec![tag],
1056 vec![raw_commit("feat: new feature")],
1057 ReleaseConfig::default(),
1058 );
1059 let plan = s.plan().unwrap();
1060 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1062 assert_eq!(plan.bump, BumpLevel::Minor);
1063 }
1064
1065 #[test]
1066 fn plan_v0_fix_stays_patch() {
1067 let tag = TagInfo {
1068 name: "v0.5.0".into(),
1069 version: Version::new(0, 5, 0),
1070 sha: "c".repeat(40),
1071 };
1072 let s = make_strategy(
1073 vec![tag],
1074 vec![raw_commit("fix: bug fix")],
1075 ReleaseConfig::default(),
1076 );
1077 let plan = s.plan().unwrap();
1078 assert_eq!(plan.next_version, Version::new(0, 5, 1));
1080 assert_eq!(plan.bump, BumpLevel::Patch);
1081 }
1082
1083 #[test]
1086 fn execute_dry_run_no_side_effects() {
1087 let s = make_strategy(
1088 vec![],
1089 vec![raw_commit("feat: something")],
1090 ReleaseConfig::default(),
1091 );
1092 let plan = s.plan().unwrap();
1093 s.execute(&plan, true).unwrap();
1094
1095 assert!(s.git.created_tags.lock().unwrap().is_empty());
1096 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1097 }
1098
1099 #[test]
1100 fn execute_creates_and_pushes_tag() {
1101 let s = make_strategy(
1102 vec![],
1103 vec![raw_commit("feat: something")],
1104 ReleaseConfig::default(),
1105 );
1106 let plan = s.plan().unwrap();
1107 s.execute(&plan, false).unwrap();
1108
1109 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1110 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1111 }
1112
1113 #[test]
1114 fn execute_calls_vcs_create_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 s.execute(&plan, false).unwrap();
1122
1123 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1124 assert_eq!(releases.len(), 1);
1125 assert_eq!(releases[0].0, "v0.1.0");
1126 assert!(!releases[0].1.is_empty());
1127 }
1128
1129 #[test]
1130 fn execute_commits_changelog_before_tag() {
1131 let dir = tempfile::tempdir().unwrap();
1132 let changelog_path = dir.path().join("CHANGELOG.md");
1133
1134 let config = ReleaseConfig {
1135 changelog: crate::config::ChangelogConfig {
1136 file: Some(changelog_path.to_str().unwrap().to_string()),
1137 ..Default::default()
1138 },
1139 ..Default::default()
1140 };
1141
1142 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1143 let plan = s.plan().unwrap();
1144 s.execute(&plan, false).unwrap();
1145
1146 let committed = s.git.committed.lock().unwrap();
1148 assert_eq!(committed.len(), 1);
1149 assert_eq!(
1150 committed[0].0,
1151 vec![changelog_path.to_str().unwrap().to_string()]
1152 );
1153 assert!(committed[0].1.contains("chore(release): v0.1.0"));
1154
1155 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1157 }
1158
1159 #[test]
1160 fn execute_skips_existing_tag() {
1161 let s = make_strategy(
1162 vec![],
1163 vec![raw_commit("feat: something")],
1164 ReleaseConfig::default(),
1165 );
1166 let plan = s.plan().unwrap();
1167
1168 s.git
1170 .created_tags
1171 .lock()
1172 .unwrap()
1173 .push("v0.1.0".to_string());
1174
1175 s.execute(&plan, false).unwrap();
1176
1177 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1179 }
1180
1181 #[test]
1182 fn execute_skips_existing_release() {
1183 let s = make_strategy(
1184 vec![],
1185 vec![raw_commit("feat: something")],
1186 ReleaseConfig::default(),
1187 );
1188 let plan = s.plan().unwrap();
1189
1190 s.vcs
1192 .as_ref()
1193 .unwrap()
1194 .releases
1195 .lock()
1196 .unwrap()
1197 .push(("v0.1.0".to_string(), "old notes".to_string()));
1198
1199 s.execute(&plan, false).unwrap();
1200
1201 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
1203 assert_eq!(*deleted, vec!["v0.1.0"]);
1204
1205 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1206 assert_eq!(releases.len(), 1);
1207 assert_eq!(releases[0].0, "v0.1.0");
1208 assert_ne!(releases[0].1, "old notes");
1209 }
1210
1211 #[test]
1212 fn execute_idempotent_rerun() {
1213 let s = make_strategy(
1214 vec![],
1215 vec![raw_commit("feat: something")],
1216 ReleaseConfig::default(),
1217 );
1218 let plan = s.plan().unwrap();
1219
1220 s.execute(&plan, false).unwrap();
1222
1223 s.execute(&plan, false).unwrap();
1225
1226 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1228
1229 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1231
1232 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1234
1235 let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
1237 assert_eq!(*deleted, vec!["v0.1.0"]);
1238
1239 let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1240 assert_eq!(releases.len(), 1);
1242 assert_eq!(releases[0].0, "v0.1.0");
1243 }
1244
1245 #[test]
1246 fn execute_bumps_version_files() {
1247 let dir = tempfile::tempdir().unwrap();
1248 let cargo_path = dir.path().join("Cargo.toml");
1249 std::fs::write(
1250 &cargo_path,
1251 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1252 )
1253 .unwrap();
1254
1255 let config = ReleaseConfig {
1256 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1257 ..Default::default()
1258 };
1259
1260 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1261 let plan = s.plan().unwrap();
1262 s.execute(&plan, false).unwrap();
1263
1264 let contents = std::fs::read_to_string(&cargo_path).unwrap();
1266 assert!(contents.contains("version = \"0.1.0\""));
1267
1268 let committed = s.git.committed.lock().unwrap();
1270 assert_eq!(committed.len(), 1);
1271 assert!(
1272 committed[0]
1273 .0
1274 .contains(&cargo_path.to_str().unwrap().to_string())
1275 );
1276 }
1277
1278 #[test]
1279 fn execute_stages_changelog_and_version_files_together() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let cargo_path = dir.path().join("Cargo.toml");
1282 std::fs::write(
1283 &cargo_path,
1284 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1285 )
1286 .unwrap();
1287
1288 let changelog_path = dir.path().join("CHANGELOG.md");
1289
1290 let config = ReleaseConfig {
1291 changelog: crate::config::ChangelogConfig {
1292 file: Some(changelog_path.to_str().unwrap().to_string()),
1293 ..Default::default()
1294 },
1295 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1296 ..Default::default()
1297 };
1298
1299 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1300 let plan = s.plan().unwrap();
1301 s.execute(&plan, false).unwrap();
1302
1303 let committed = s.git.committed.lock().unwrap();
1305 assert_eq!(committed.len(), 1);
1306 assert!(
1307 committed[0]
1308 .0
1309 .contains(&changelog_path.to_str().unwrap().to_string())
1310 );
1311 assert!(
1312 committed[0]
1313 .0
1314 .contains(&cargo_path.to_str().unwrap().to_string())
1315 );
1316 }
1317
1318 #[test]
1321 fn execute_uploads_artifacts() {
1322 let dir = tempfile::tempdir().unwrap();
1323 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1324 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1325
1326 let config = ReleaseConfig {
1327 artifacts: vec![
1328 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1329 dir.path().join("*.zip").to_str().unwrap().to_string(),
1330 ],
1331 ..Default::default()
1332 };
1333
1334 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1335 let plan = s.plan().unwrap();
1336 s.execute(&plan, false).unwrap();
1337
1338 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1339 assert_eq!(uploaded.len(), 1);
1340 assert_eq!(uploaded[0].0, "v0.1.0");
1341 assert_eq!(uploaded[0].1.len(), 4);
1343 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1344 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1345 assert!(
1346 uploaded[0]
1347 .1
1348 .iter()
1349 .any(|f| f.ends_with("app.tar.gz.sha256"))
1350 );
1351 assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip.sha256")));
1352 }
1353
1354 #[test]
1355 fn execute_dry_run_shows_artifacts() {
1356 let dir = tempfile::tempdir().unwrap();
1357 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1358
1359 let config = ReleaseConfig {
1360 artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1361 ..Default::default()
1362 };
1363
1364 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1365 let plan = s.plan().unwrap();
1366 s.execute(&plan, true).unwrap();
1367
1368 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1370 assert!(uploaded.is_empty());
1371 }
1372
1373 #[test]
1374 fn execute_no_artifacts_skips_upload() {
1375 let s = make_strategy(
1376 vec![],
1377 vec![raw_commit("feat: something")],
1378 ReleaseConfig::default(),
1379 );
1380 let plan = s.plan().unwrap();
1381 s.execute(&plan, false).unwrap();
1382
1383 let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1384 assert!(uploaded.is_empty());
1385 }
1386
1387 #[test]
1388 fn resolve_artifact_globs_basic() {
1389 let dir = tempfile::tempdir().unwrap();
1390 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1391 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1392 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1393
1394 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1395 let result = resolve_artifact_globs(&[pattern]).unwrap();
1396 assert_eq!(result.len(), 2);
1397 assert!(result.iter().any(|f| f.ends_with("a.txt")));
1398 assert!(result.iter().any(|f| f.ends_with("b.txt")));
1399 }
1400
1401 #[test]
1402 fn resolve_artifact_globs_deduplicates() {
1403 let dir = tempfile::tempdir().unwrap();
1404 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1405
1406 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1407 let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
1409 assert_eq!(result.len(), 1);
1410 }
1411
1412 #[test]
1415 fn plan_floating_tag_when_enabled() {
1416 let tag = TagInfo {
1417 name: "v3.2.0".into(),
1418 version: Version::new(3, 2, 0),
1419 sha: "d".repeat(40),
1420 };
1421 let config = ReleaseConfig {
1422 floating_tags: true,
1423 ..Default::default()
1424 };
1425
1426 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1427 let plan = s.plan().unwrap();
1428 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1429 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1430 }
1431
1432 #[test]
1433 fn plan_no_floating_tag_when_disabled() {
1434 let s = make_strategy(
1435 vec![],
1436 vec![raw_commit("feat: something")],
1437 ReleaseConfig::default(),
1438 );
1439 let plan = s.plan().unwrap();
1440 assert!(plan.floating_tag_name.is_none());
1441 }
1442
1443 #[test]
1444 fn plan_floating_tag_custom_prefix() {
1445 let tag = TagInfo {
1446 name: "release-2.5.0".into(),
1447 version: Version::new(2, 5, 0),
1448 sha: "e".repeat(40),
1449 };
1450 let config = ReleaseConfig {
1451 floating_tags: true,
1452 tag_prefix: "release-".into(),
1453 ..Default::default()
1454 };
1455
1456 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1457 let plan = s.plan().unwrap();
1458 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1459 }
1460
1461 #[test]
1462 fn execute_floating_tags_force_create_and_push() {
1463 let config = ReleaseConfig {
1464 floating_tags: true,
1465 ..Default::default()
1466 };
1467
1468 let tag = TagInfo {
1469 name: "v1.2.3".into(),
1470 version: Version::new(1, 2, 3),
1471 sha: "f".repeat(40),
1472 };
1473 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1474 let plan = s.plan().unwrap();
1475 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1476
1477 s.execute(&plan, false).unwrap();
1478
1479 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1480 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1481 }
1482
1483 #[test]
1484 fn execute_no_floating_tags_when_disabled() {
1485 let s = make_strategy(
1486 vec![],
1487 vec![raw_commit("feat: something")],
1488 ReleaseConfig::default(),
1489 );
1490 let plan = s.plan().unwrap();
1491 assert!(plan.floating_tag_name.is_none());
1492
1493 s.execute(&plan, false).unwrap();
1494
1495 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1496 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1497 }
1498
1499 #[test]
1500 fn execute_floating_tags_dry_run_no_side_effects() {
1501 let config = ReleaseConfig {
1502 floating_tags: true,
1503 ..Default::default()
1504 };
1505
1506 let tag = TagInfo {
1507 name: "v2.0.0".into(),
1508 version: Version::new(2, 0, 0),
1509 sha: "a".repeat(40),
1510 };
1511 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1512 let plan = s.plan().unwrap();
1513 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1514
1515 s.execute(&plan, true).unwrap();
1516
1517 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1518 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1519 }
1520
1521 #[test]
1522 fn execute_floating_tags_idempotent() {
1523 let config = ReleaseConfig {
1524 floating_tags: true,
1525 ..Default::default()
1526 };
1527
1528 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1529 let plan = s.plan().unwrap();
1530 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1531
1532 s.execute(&plan, false).unwrap();
1534 s.execute(&plan, false).unwrap();
1535
1536 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1538 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1539 }
1540
1541 #[test]
1544 fn force_rerelease_when_tag_at_head() {
1545 let tag = TagInfo {
1546 name: "v1.2.3".into(),
1547 version: Version::new(1, 2, 3),
1548 sha: "a".repeat(40),
1549 };
1550 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1551 s.git.head = "a".repeat(40);
1553 s.force = true;
1554
1555 let plan = s.plan().unwrap();
1556 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1557 assert_eq!(plan.tag_name, "v1.2.3");
1558 assert!(plan.commits.is_empty());
1559 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1560 }
1561
1562 #[test]
1563 fn force_fails_when_tag_not_at_head() {
1564 let tag = TagInfo {
1565 name: "v1.2.3".into(),
1566 version: Version::new(1, 2, 3),
1567 sha: "a".repeat(40),
1568 };
1569 let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1570 s.git.head = "b".repeat(40);
1572 s.force = true;
1573
1574 let err = s.plan().unwrap_err();
1575 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1576 }
1577
1578 #[test]
1581 fn execute_runs_build_command_after_version_bump() {
1582 let dir = tempfile::tempdir().unwrap();
1583 let output_file = dir.path().join("sr_test_version");
1584
1585 let config = ReleaseConfig {
1586 build_command: Some(format!(
1587 "echo $SR_VERSION > {}",
1588 output_file.to_str().unwrap()
1589 )),
1590 ..Default::default()
1591 };
1592
1593 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1594 let plan = s.plan().unwrap();
1595 s.execute(&plan, false).unwrap();
1596
1597 let contents = std::fs::read_to_string(&output_file).unwrap();
1598 assert_eq!(contents.trim(), "0.1.0");
1599 }
1600
1601 #[test]
1602 fn execute_build_command_failure_aborts_release() {
1603 let config = ReleaseConfig {
1604 build_command: Some("exit 1".into()),
1605 ..Default::default()
1606 };
1607
1608 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1609 let plan = s.plan().unwrap();
1610 let result = s.execute(&plan, false);
1611
1612 assert!(result.is_err());
1613 assert!(s.git.created_tags.lock().unwrap().is_empty());
1614 }
1615
1616 #[test]
1617 fn execute_dry_run_skips_build_command() {
1618 let dir = tempfile::tempdir().unwrap();
1619 let output_file = dir.path().join("sr_test_should_not_exist");
1620
1621 let config = ReleaseConfig {
1622 build_command: Some(format!("echo test > {}", output_file.to_str().unwrap())),
1623 ..Default::default()
1624 };
1625
1626 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1627 let plan = s.plan().unwrap();
1628 s.execute(&plan, true).unwrap();
1629
1630 assert!(!output_file.exists());
1631 }
1632
1633 #[test]
1634 fn force_fails_with_no_tags() {
1635 let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1636 s.force = true;
1637
1638 let err = s.plan().unwrap_err();
1639 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1640 }
1641
1642 #[test]
1645 fn execute_stages_extra_files() {
1646 let dir = tempfile::tempdir().unwrap();
1647 let lock_file = dir.path().join("Cargo.lock");
1648 std::fs::write(&lock_file, "old lock").unwrap();
1649
1650 let config = ReleaseConfig {
1651 build_command: Some(format!("echo 'new lock' > {}", lock_file.to_str().unwrap())),
1652 stage_files: vec![lock_file.to_str().unwrap().to_string()],
1653 ..Default::default()
1654 };
1655
1656 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1657 let plan = s.plan().unwrap();
1658 s.execute(&plan, false).unwrap();
1659
1660 let committed = s.git.committed.lock().unwrap();
1661 assert!(!committed.is_empty());
1662 let (staged, _) = &committed[0];
1663 assert!(
1664 staged.iter().any(|f| f.contains("Cargo.lock")),
1665 "Cargo.lock should be staged, got: {staged:?}"
1666 );
1667 }
1668
1669 #[test]
1670 fn execute_dry_run_shows_stage_files() {
1671 let config = ReleaseConfig {
1672 stage_files: vec!["Cargo.lock".into()],
1673 ..Default::default()
1674 };
1675
1676 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1677 let plan = s.plan().unwrap();
1678 s.execute(&plan, true).unwrap();
1680 }
1681
1682 #[test]
1685 fn execute_build_failure_restores_version_files() {
1686 let dir = tempfile::tempdir().unwrap();
1687 let cargo_toml = dir.path().join("Cargo.toml");
1688 std::fs::write(
1689 &cargo_toml,
1690 "[package]\nname = \"test\"\nversion = \"1.0.0\"\n",
1691 )
1692 .unwrap();
1693
1694 let config = ReleaseConfig {
1695 version_files: vec![cargo_toml.to_str().unwrap().to_string()],
1696 build_command: Some("exit 1".into()),
1697 ..Default::default()
1698 };
1699
1700 let tag = TagInfo {
1701 name: "v1.0.0".into(),
1702 version: Version::new(1, 0, 0),
1703 sha: "d".repeat(40),
1704 };
1705 let s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
1706 let plan = s.plan().unwrap();
1707 let result = s.execute(&plan, false);
1708
1709 assert!(result.is_err());
1710 let contents = std::fs::read_to_string(&cargo_toml).unwrap();
1712 assert!(
1713 contents.contains("version = \"1.0.0\""),
1714 "version should be restored, got: {contents}"
1715 );
1716 }
1717
1718 #[test]
1721 fn execute_pre_release_command_runs() {
1722 let dir = tempfile::tempdir().unwrap();
1723 let marker = dir.path().join("pre_release_ran");
1724
1725 let config = ReleaseConfig {
1726 pre_release_command: Some(format!("touch {}", marker.to_str().unwrap())),
1727 ..Default::default()
1728 };
1729
1730 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1731 let plan = s.plan().unwrap();
1732 s.execute(&plan, false).unwrap();
1733
1734 assert!(marker.exists(), "pre-release command should have run");
1735 }
1736
1737 #[test]
1738 fn execute_post_release_command_runs() {
1739 let dir = tempfile::tempdir().unwrap();
1740 let marker = dir.path().join("post_release_ran");
1741
1742 let config = ReleaseConfig {
1743 post_release_command: Some(format!("touch {}", marker.to_str().unwrap())),
1744 ..Default::default()
1745 };
1746
1747 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1748 let plan = s.plan().unwrap();
1749 s.execute(&plan, false).unwrap();
1750
1751 assert!(marker.exists(), "post-release command should have run");
1752 }
1753
1754 #[test]
1755 fn execute_pre_release_failure_aborts_release() {
1756 let config = ReleaseConfig {
1757 pre_release_command: Some("exit 1".into()),
1758 ..Default::default()
1759 };
1760
1761 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1762 let plan = s.plan().unwrap();
1763 let result = s.execute(&plan, false);
1764
1765 assert!(result.is_err());
1766 assert!(s.git.created_tags.lock().unwrap().is_empty());
1768 assert!(s.git.committed.lock().unwrap().is_empty());
1769 }
1770
1771 #[test]
1772 fn execute_hooks_receive_version_env_vars() {
1773 let dir = tempfile::tempdir().unwrap();
1774 let output_file = dir.path().join("hook_output");
1775
1776 let config = ReleaseConfig {
1777 post_release_command: Some(format!(
1778 "echo $SR_VERSION $SR_TAG > {}",
1779 output_file.to_str().unwrap()
1780 )),
1781 ..Default::default()
1782 };
1783
1784 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1785 let plan = s.plan().unwrap();
1786 s.execute(&plan, false).unwrap();
1787
1788 let contents = std::fs::read_to_string(&output_file).unwrap();
1789 assert!(contents.contains("0.1.0"), "SR_VERSION should be set");
1790 assert!(contents.contains("v0.1.0"), "SR_TAG should be set");
1791 }
1792
1793 #[test]
1794 fn execute_dry_run_skips_hooks() {
1795 let dir = tempfile::tempdir().unwrap();
1796 let pre_marker = dir.path().join("pre_hook");
1797 let post_marker = dir.path().join("post_hook");
1798
1799 let config = ReleaseConfig {
1800 pre_release_command: Some(format!("touch {}", pre_marker.to_str().unwrap())),
1801 post_release_command: Some(format!("touch {}", post_marker.to_str().unwrap())),
1802 ..Default::default()
1803 };
1804
1805 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1806 let plan = s.plan().unwrap();
1807 s.execute(&plan, true).unwrap();
1808
1809 assert!(
1810 !pre_marker.exists(),
1811 "pre-release hook should not run in dry-run"
1812 );
1813 assert!(
1814 !post_marker.exists(),
1815 "post-release hook should not run in dry-run"
1816 );
1817 }
1818
1819 #[test]
1822 fn plan_prerelease_first_release() {
1823 let config = ReleaseConfig {
1824 prerelease: Some("alpha".into()),
1825 ..Default::default()
1826 };
1827
1828 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1829 let plan = s.plan().unwrap();
1830 assert_eq!(plan.next_version.to_string(), "0.1.0-alpha.1");
1831 assert_eq!(plan.tag_name, "v0.1.0-alpha.1");
1832 assert!(plan.prerelease);
1833 }
1834
1835 #[test]
1836 fn plan_prerelease_increments_from_stable() {
1837 let tag = TagInfo {
1838 name: "v1.0.0".into(),
1839 version: Version::new(1, 0, 0),
1840 sha: "d".repeat(40),
1841 };
1842 let config = ReleaseConfig {
1843 prerelease: Some("beta".into()),
1844 ..Default::default()
1845 };
1846
1847 let s = make_strategy(vec![tag], vec![raw_commit("feat: new feature")], config);
1848 let plan = s.plan().unwrap();
1849 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1850 assert!(plan.prerelease);
1851 }
1852
1853 #[test]
1854 fn plan_prerelease_increments_counter() {
1855 let tags = vec![
1856 TagInfo {
1857 name: "v1.0.0".into(),
1858 version: Version::new(1, 0, 0),
1859 sha: "a".repeat(40),
1860 },
1861 TagInfo {
1862 name: "v1.1.0-alpha.1".into(),
1863 version: Version::parse("1.1.0-alpha.1").unwrap(),
1864 sha: "b".repeat(40),
1865 },
1866 TagInfo {
1867 name: "v1.1.0-alpha.2".into(),
1868 version: Version::parse("1.1.0-alpha.2").unwrap(),
1869 sha: "c".repeat(40),
1870 },
1871 ];
1872 let config = ReleaseConfig {
1873 prerelease: Some("alpha".into()),
1874 ..Default::default()
1875 };
1876
1877 let s = make_strategy(tags, vec![raw_commit("feat: another")], config);
1878 let plan = s.plan().unwrap();
1879 assert_eq!(plan.next_version.to_string(), "1.1.0-alpha.3");
1880 }
1881
1882 #[test]
1883 fn plan_prerelease_different_id_starts_at_1() {
1884 let tags = vec![
1885 TagInfo {
1886 name: "v1.0.0".into(),
1887 version: Version::new(1, 0, 0),
1888 sha: "a".repeat(40),
1889 },
1890 TagInfo {
1891 name: "v1.1.0-alpha.3".into(),
1892 version: Version::parse("1.1.0-alpha.3").unwrap(),
1893 sha: "b".repeat(40),
1894 },
1895 ];
1896 let config = ReleaseConfig {
1897 prerelease: Some("beta".into()),
1898 ..Default::default()
1899 };
1900
1901 let s = make_strategy(tags, vec![raw_commit("feat: something")], config);
1902 let plan = s.plan().unwrap();
1903 assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1904 }
1905
1906 #[test]
1907 fn plan_prerelease_no_floating_tags() {
1908 let config = ReleaseConfig {
1909 prerelease: Some("rc".into()),
1910 floating_tags: true,
1911 ..Default::default()
1912 };
1913
1914 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1915 let plan = s.plan().unwrap();
1916 assert!(
1917 plan.floating_tag_name.is_none(),
1918 "pre-releases should not create floating tags"
1919 );
1920 }
1921
1922 #[test]
1923 fn plan_stable_skips_prerelease_tags() {
1924 let tags = vec![
1925 TagInfo {
1926 name: "v1.0.0".into(),
1927 version: Version::new(1, 0, 0),
1928 sha: "a".repeat(40),
1929 },
1930 TagInfo {
1931 name: "v1.1.0-alpha.1".into(),
1932 version: Version::parse("1.1.0-alpha.1").unwrap(),
1933 sha: "b".repeat(40),
1934 },
1935 ];
1936 let s = make_strategy(
1938 tags,
1939 vec![raw_commit("feat: something")],
1940 ReleaseConfig::default(),
1941 );
1942 let plan = s.plan().unwrap();
1943 assert_eq!(plan.next_version, Version::new(1, 1, 0));
1945 assert!(!plan.prerelease);
1946 }
1947
1948 #[test]
1949 fn plan_prerelease_marks_plan_as_prerelease() {
1950 let config = ReleaseConfig {
1951 prerelease: Some("alpha".into()),
1952 ..Default::default()
1953 };
1954
1955 let s = make_strategy(vec![], vec![raw_commit("fix: bug")], config);
1956 let plan = s.plan().unwrap();
1957 assert!(plan.prerelease);
1958 assert!(plan.next_version.to_string().contains("alpha"));
1959 }
1960
1961 #[test]
1964 fn plan_with_path_filter_uses_filtered_commits() {
1965 let config = ReleaseConfig {
1966 path_filter: Some("crates/core".into()),
1967 ..Default::default()
1968 };
1969
1970 let mut s = make_strategy(
1972 vec![],
1973 vec![raw_commit("feat: big feature"), raw_commit("fix: patch")],
1974 config,
1975 );
1976 s.git.path_commits = Some(vec![raw_commit("fix: patch only in core")]);
1977
1978 let plan = s.plan().unwrap();
1979 assert_eq!(plan.bump, BumpLevel::Patch);
1981 assert_eq!(plan.commits.len(), 1);
1982 assert_eq!(plan.commits[0].description, "patch only in core");
1983 }
1984
1985 #[test]
1986 fn plan_without_path_filter_uses_all_commits() {
1987 let config = ReleaseConfig::default();
1988
1989 let mut s = make_strategy(vec![], vec![raw_commit("feat: big feature")], config);
1990 s.git.path_commits = Some(vec![raw_commit("fix: filtered")]);
1991
1992 let plan = s.plan().unwrap();
1993 assert_eq!(plan.bump, BumpLevel::Minor);
1995 }
1996
1997 #[test]
1998 fn plan_with_path_filter_no_commits_returns_error() {
1999 let config = ReleaseConfig {
2000 path_filter: Some("crates/core".into()),
2001 ..Default::default()
2002 };
2003
2004 let mut s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
2005 s.git.path_commits = Some(vec![]);
2006
2007 let err = s.plan().unwrap_err();
2008 assert!(matches!(err, ReleaseError::NoCommits { .. }));
2009 }
2010
2011 #[test]
2012 fn plan_with_path_filter_custom_tag_prefix() {
2013 let config = ReleaseConfig {
2014 path_filter: Some("crates/core".into()),
2015 tag_prefix: "core/v".into(),
2016 ..Default::default()
2017 };
2018
2019 let tag = TagInfo {
2020 name: "core/v1.0.0".into(),
2021 version: Version::new(1, 0, 0),
2022 sha: "a".repeat(40),
2023 };
2024 let mut s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
2025 s.git.path_commits = Some(vec![raw_commit("fix: core bug")]);
2026
2027 let plan = s.plan().unwrap();
2028 assert_eq!(plan.tag_name, "core/v1.0.1");
2029 assert_eq!(plan.current_version, Some(Version::new(1, 0, 0)));
2030 }
2031}