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