1use semver::Version;
2use serde::Serialize;
3
4use crate::changelog::{ChangelogEntry, ChangelogFormatter};
5use crate::commit::{CommitParser, ConventionalCommit, DefaultCommitClassifier};
6use crate::config::Config;
7use crate::error::ReleaseError;
8use crate::git::GitRepository;
9use crate::stages::{StageContext, default_pipeline};
10use crate::version::{BumpLevel, apply_bump, apply_prerelease_bump, determine_bump};
11
12#[derive(Debug, Serialize)]
19pub struct ReleasePlan {
20 pub current_version: Option<Version>,
21 pub next_version: Version,
22 pub bump: BumpLevel,
23 pub commits: Vec<ConventionalCommit>,
24 pub tag_name: String,
25 pub floating_tag_name: Option<String>,
26 pub prerelease: bool,
27 pub packages: Vec<PackagePlan>,
31}
32
33#[derive(Debug, Clone, Serialize)]
36pub struct PackagePlan {
37 pub path: String,
38 pub version_files: Vec<String>,
40 pub artifacts: Vec<String>,
42 pub commits: Vec<ConventionalCommit>,
46}
47
48pub trait ReleaseStrategy: Send + Sync {
50 fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
53
54 fn prepare(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
62
63 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
66}
67
68pub trait VcsProvider: Send + Sync {
70 fn create_release(
72 &self,
73 tag: &str,
74 name: &str,
75 body: &str,
76 prerelease: bool,
77 draft: bool,
78 ) -> Result<String, ReleaseError>;
79
80 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
82
83 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
85
86 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
88
89 fn repo_url(&self) -> Option<String> {
91 None
92 }
93
94 fn update_release(
97 &self,
98 _tag: &str,
99 _name: &str,
100 _body: &str,
101 _prerelease: bool,
102 _draft: bool,
103 ) -> Result<String, ReleaseError> {
104 Err(ReleaseError::Vcs(
105 "update_release not implemented for this provider".into(),
106 ))
107 }
108
109 fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
111 Ok(())
112 }
113
114 fn list_assets(&self, _tag: &str) -> Result<Vec<String>, ReleaseError> {
118 Ok(Vec::new())
119 }
120
121 fn fetch_asset(&self, _tag: &str, _name: &str) -> Result<Option<Vec<u8>>, ReleaseError> {
125 Ok(None)
126 }
127
128 fn verify_release(&self, _tag: &str) -> Result<(), ReleaseError> {
130 Ok(())
131 }
132}
133
134pub struct NoopVcsProvider;
137
138impl VcsProvider for NoopVcsProvider {
139 fn create_release(
140 &self,
141 _tag: &str,
142 _name: &str,
143 _body: &str,
144 _prerelease: bool,
145 _draft: bool,
146 ) -> Result<String, ReleaseError> {
147 Ok(String::new())
148 }
149
150 fn compare_url(&self, _base: &str, _head: &str) -> Result<String, ReleaseError> {
151 Ok(String::new())
152 }
153
154 fn release_exists(&self, _tag: &str) -> Result<bool, ReleaseError> {
155 Ok(false)
156 }
157
158 fn delete_release(&self, _tag: &str) -> Result<(), ReleaseError> {
159 Ok(())
160 }
161}
162
163pub struct TrunkReleaseStrategy<G, V, C, F> {
165 pub git: G,
166 pub vcs: V,
167 pub parser: C,
168 pub formatter: F,
169 pub config: Config,
170 pub prerelease_id: Option<String>,
172 pub draft: bool,
174}
175
176impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
177where
178 G: GitRepository,
179 V: VcsProvider,
180 C: CommitParser,
181 F: ChangelogFormatter,
182{
183 fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
184 let today = today_string();
185 let compare_url = match &plan.current_version {
186 Some(v) => {
187 let base = format!("{}{v}", self.config.git.tag_prefix);
188 self.vcs
189 .compare_url(&base, &plan.tag_name)
190 .ok()
191 .filter(|s| !s.is_empty())
192 }
193 None => None,
194 };
195 let package_sections: Vec<crate::changelog::PackageSection> = plan
196 .packages
197 .iter()
198 .map(|pp| crate::changelog::PackageSection {
199 path: pp.path.clone(),
200 commits: pp.commits.clone(),
201 })
202 .collect();
203 let entry = ChangelogEntry {
204 version: plan.next_version.to_string(),
205 date: today,
206 commits: plan.commits.clone(),
207 compare_url,
208 repo_url: self.vcs.repo_url(),
209 package_sections,
210 };
211 self.formatter.format(&[entry])
212 }
213
214 fn release_name(&self, plan: &ReleasePlan) -> String {
216 if let Some(ref template_str) = self.config.vcs.github.release_name_template {
217 let mut env = minijinja::Environment::new();
218 if env.add_template("release_name", template_str).is_ok()
219 && let Ok(tmpl) = env.get_template("release_name")
220 && let Ok(rendered) = tmpl.render(minijinja::context! {
221 version => plan.next_version.to_string(),
222 tag_name => &plan.tag_name,
223 tag_prefix => &self.config.git.tag_prefix,
224 })
225 {
226 return rendered;
227 }
228 eprintln!("warning: invalid release_name_template, falling back to tag name");
229 }
230 plan.tag_name.clone()
231 }
232
233 fn build_package_plans(
239 &self,
240 from_sha: Option<&str>,
241 all_conventional: &[ConventionalCommit],
242 ) -> Result<Vec<PackagePlan>, ReleaseError> {
243 let packages = &self.config.packages;
244
245 if packages.len() == 1 {
247 let pkg = &packages[0];
248 return Ok(vec![PackagePlan {
249 path: pkg.path.clone(),
250 version_files: self.config.version_files_for(pkg),
251 artifacts: pkg.artifacts.clone(),
252 commits: all_conventional.to_vec(),
253 }]);
254 }
255
256 let skip_patterns = &self.config.git.skip_patterns;
260 let mut plans: Vec<PackagePlan> = Vec::with_capacity(packages.len());
261 let mut attributed_shas: std::collections::HashSet<String> =
262 std::collections::HashSet::new();
263
264 let root_index = packages.iter().position(|p| p.path == ".");
266
267 for (idx, pkg) in packages.iter().enumerate() {
268 if Some(idx) == root_index {
269 plans.push(PackagePlan {
270 path: pkg.path.clone(),
271 version_files: self.config.version_files_for(pkg),
272 artifacts: pkg.artifacts.clone(),
273 commits: Vec::new(), });
275 continue;
276 }
277
278 let raw = self.git.commits_since_in_path(from_sha, &pkg.path)?;
279 let pkg_shas: std::collections::HashSet<&str> =
280 raw.iter().map(|c| c.sha.as_str()).collect();
281 let pkg_commits: Vec<ConventionalCommit> = all_conventional
282 .iter()
283 .filter(|c| pkg_shas.contains(c.sha.as_str()))
284 .filter(|c| !c.description.is_empty())
285 .filter(|c| {
286 !skip_patterns
287 .iter()
288 .any(|p| c.description.contains(p.as_str()))
289 })
290 .cloned()
291 .collect();
292
293 for c in &pkg_commits {
294 attributed_shas.insert(c.sha.clone());
295 }
296
297 plans.push(PackagePlan {
298 path: pkg.path.clone(),
299 version_files: self.config.version_files_for(pkg),
300 artifacts: pkg.artifacts.clone(),
301 commits: pkg_commits,
302 });
303 }
304
305 if let Some(idx) = root_index {
310 let unattributed: Vec<ConventionalCommit> = all_conventional
311 .iter()
312 .filter(|c| !attributed_shas.contains(c.sha.as_str()))
313 .cloned()
314 .collect();
315 plans[idx].commits = unattributed;
316 }
317
318 Ok(plans)
319 }
320}
321
322impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
323where
324 G: GitRepository,
325 V: VcsProvider,
326 C: CommitParser,
327 F: ChangelogFormatter,
328{
329 fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
330 let is_prerelease = self.prerelease_id.is_some();
331
332 let all_tags = self.git.all_tags(&self.config.git.tag_prefix)?;
339 let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
340 let latest_any = all_tags.last();
341
342 let tag_info = if is_prerelease {
343 latest_any
344 } else {
345 latest_stable.or(latest_any)
346 };
347
348 let (current_version, from_sha) = match tag_info {
349 Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
350 None => (None, None),
351 };
352
353 let raw_commits = self.git.commits_since(from_sha)?;
358
359 if raw_commits.is_empty() {
360 let (tag, sha) = match tag_info {
361 Some(info) => (info.name.clone(), info.sha.clone()),
362 None => ("(none)".into(), "(none)".into()),
363 };
364 return Err(ReleaseError::NoCommits { tag, sha });
365 }
366
367 let skip_patterns = &self.config.git.skip_patterns;
368 let conventional_commits: Vec<ConventionalCommit> = raw_commits
369 .iter()
370 .filter(|c| !c.message.starts_with("chore(release):"))
371 .filter(|c| !skip_patterns.iter().any(|p| c.message.contains(p.as_str())))
372 .filter_map(|c| self.parser.parse(c).ok())
373 .collect();
374
375 let classifier = DefaultCommitClassifier::new(self.config.commit.types.into_commit_types());
376 let tag_for_err = tag_info
377 .map(|i| i.name.clone())
378 .unwrap_or_else(|| "(none)".into());
379 let commit_count = conventional_commits.len();
380 let bump = match determine_bump(&conventional_commits, &classifier) {
381 Some(b) => b,
382 None => {
383 return Err(ReleaseError::NoBump {
384 tag: tag_for_err,
385 commit_count,
386 });
387 }
388 };
389
390 let package_plans = self.build_package_plans(from_sha, &conventional_commits)?;
391
392 let base_version = if is_prerelease {
394 latest_stable
395 .map(|t| t.version.clone())
396 .or(current_version.clone())
397 .unwrap_or(Version::new(0, 0, 0))
398 } else {
399 current_version.clone().unwrap_or(Version::new(0, 0, 0))
400 };
401
402 let bump =
405 if base_version.major == 0 && bump == BumpLevel::Major && self.config.git.v0_protection
406 {
407 eprintln!(
408 "v0 protection: breaking change detected at v{base_version}, \
409 downshifting major → minor (set git.v0_protection: false to bump to v1)"
410 );
411 BumpLevel::Minor
412 } else {
413 bump
414 };
415
416 let next_version = if let Some(ref prerelease_id) = self.prerelease_id {
417 let existing_versions: Vec<Version> =
418 all_tags.iter().map(|t| t.version.clone()).collect();
419 apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
420 } else {
421 apply_bump(&base_version, bump)
422 };
423
424 let tag_name = format!("{}{next_version}", self.config.git.tag_prefix);
425
426 let floating_tag_name = if self.config.git.floating_tag && !is_prerelease {
428 Some(format!(
429 "{}{}",
430 self.config.git.tag_prefix, next_version.major
431 ))
432 } else {
433 None
434 };
435
436 Ok(ReleasePlan {
437 current_version,
438 next_version,
439 bump,
440 commits: conventional_commits,
441 tag_name,
442 floating_tag_name,
443 prerelease: is_prerelease,
444 packages: package_plans,
445 })
446 }
447
448 fn prepare(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
449 let version_str = plan.next_version.to_string();
450 let changelog_body = self.format_changelog(plan)?;
451 let release_name = self.release_name(plan);
452
453 let env = release_env(&version_str, &plan.tag_name);
454 let env_refs: Vec<(&str, &str)> =
455 env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
456
457 let mut ctx = StageContext {
458 plan,
459 config: &self.config,
460 git: &self.git,
461 vcs: &self.vcs,
462 changelog_body: &changelog_body,
463 release_name: &release_name,
464 version_str: &version_str,
465 hooks_env: &env_refs,
466 dry_run,
467 sign_tags: self.config.git.sign_tags,
468 draft: self.draft,
469 bumped_files: Vec::new(),
470 };
471
472 let bump = crate::stages::bump::Bump;
474 if !crate::stages::Stage::is_complete(&bump, &ctx)? {
475 crate::stages::Stage::run(&bump, &mut ctx)?;
476 }
477
478 if dry_run {
479 eprintln!("[dry-run] Changelog:\n{changelog_body}");
480 } else {
481 eprintln!(
482 "Prepared {} (bumped {} file(s))",
483 plan.tag_name,
484 ctx.bumped_files.len()
485 );
486 }
487 Ok(())
488 }
489
490 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
491 let version_str = plan.next_version.to_string();
492 let changelog_body = self.format_changelog(plan)?;
493 let release_name = self.release_name(plan);
494
495 let env = release_env(&version_str, &plan.tag_name);
496 let env_refs: Vec<(&str, &str)> =
497 env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
498
499 let mut ctx = StageContext {
500 plan,
501 config: &self.config,
502 git: &self.git,
503 vcs: &self.vcs,
504 changelog_body: &changelog_body,
505 release_name: &release_name,
506 version_str: &version_str,
507 hooks_env: &env_refs,
508 dry_run,
509 sign_tags: self.config.git.sign_tags,
510 draft: self.draft,
511 bumped_files: Vec::new(),
512 };
513
514 for stage in default_pipeline() {
515 if !stage.is_complete(&ctx)? {
516 stage.run(&mut ctx)?;
517 }
518 }
519
520 if dry_run {
521 eprintln!("[dry-run] Changelog:\n{changelog_body}");
522 } else {
523 eprintln!("Released {}", plan.tag_name);
524 }
525 Ok(())
526 }
527}
528
529fn release_env(version: &str, tag: &str) -> Vec<(String, String)> {
531 vec![
532 ("SR_VERSION".into(), version.into()),
533 ("SR_TAG".into(), tag.into()),
534 ]
535}
536
537pub(crate) fn resolve_paths(paths: &[String]) -> Result<Vec<String>, String> {
542 let mut files = std::collections::BTreeSet::new();
543 for p in paths {
544 let pb = std::path::Path::new(p);
545 if !pb.exists() {
546 return Err(format!("path does not exist: {p}"));
547 }
548 if !pb.is_file() {
549 return Err(format!("not a file: {p}"));
550 }
551 files.insert(p.clone());
552 }
553 Ok(files.into_iter().collect())
554}
555
556pub(crate) fn partition_paths(paths: &[String]) -> (Vec<String>, Vec<String>) {
560 let mut existing = Vec::new();
561 let mut missing = Vec::new();
562 for p in paths {
563 let pb = std::path::Path::new(p);
564 if pb.is_file() {
565 existing.push(p.clone());
566 } else {
567 missing.push(p.clone());
568 }
569 }
570 existing.sort();
571 existing.dedup();
572 (existing, missing)
573}
574
575pub fn today_string() -> String {
576 let secs = std::time::SystemTime::now()
579 .duration_since(std::time::UNIX_EPOCH)
580 .unwrap_or_default()
581 .as_secs() as i64;
582
583 let z = secs / 86400 + 719468;
584 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
585 let doe = (z - era * 146097) as u32;
586 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
587 let y = yoe as i64 + era * 400;
588 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
589 let mp = (5 * doy + 2) / 153;
590 let d = doy - (153 * mp + 2) / 5 + 1;
591 let m = if mp < 10 { mp + 3 } else { mp - 9 };
592 let y = if m <= 2 { y + 1 } else { y };
593
594 format!("{y:04}-{m:02}-{d:02}")
595}
596
597#[cfg(test)]
598mod tests {
599 use std::sync::Mutex;
600
601 use super::*;
602 use crate::changelog::DefaultChangelogFormatter;
603 use crate::commit::{Commit, TypedCommitParser};
604 use crate::config::{
605 ChangelogConfig, Config, GitConfig, PackageConfig, default_changelog_groups,
606 };
607 use crate::git::{GitRepository, TagInfo};
608
609 struct FakeGit {
612 tags: Vec<TagInfo>,
613 commits: Vec<Commit>,
614 path_commits: Option<Vec<Commit>>,
616 head: String,
617 created_tags: Mutex<Vec<String>>,
618 pushed_tags: Mutex<Vec<String>>,
619 committed: Mutex<Vec<(Vec<String>, String)>>,
620 push_count: Mutex<u32>,
621 force_created_tags: Mutex<Vec<String>>,
622 force_pushed_tags: Mutex<Vec<String>>,
623 }
624
625 impl FakeGit {
626 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
627 let head = tags
628 .last()
629 .map(|t| t.sha.clone())
630 .unwrap_or_else(|| "0".repeat(40));
631 Self {
632 tags,
633 commits,
634 path_commits: None,
635 head,
636 created_tags: Mutex::new(Vec::new()),
637 pushed_tags: Mutex::new(Vec::new()),
638 committed: Mutex::new(Vec::new()),
639 push_count: Mutex::new(0),
640 force_created_tags: Mutex::new(Vec::new()),
641 force_pushed_tags: Mutex::new(Vec::new()),
642 }
643 }
644 }
645
646 impl GitRepository for FakeGit {
647 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
648 Ok(self.tags.last().cloned())
649 }
650
651 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
652 Ok(self.commits.clone())
653 }
654
655 fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
656 self.created_tags.lock().unwrap().push(name.to_string());
657 Ok(())
658 }
659
660 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
661 self.pushed_tags.lock().unwrap().push(name.to_string());
662 Ok(())
663 }
664
665 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
666 self.committed.lock().unwrap().push((
667 paths.iter().map(|s| s.to_string()).collect(),
668 message.to_string(),
669 ));
670 Ok(true)
671 }
672
673 fn push(&self) -> Result<(), ReleaseError> {
674 *self.push_count.lock().unwrap() += 1;
675 Ok(())
676 }
677
678 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
679 Ok(self
680 .created_tags
681 .lock()
682 .unwrap()
683 .contains(&name.to_string()))
684 }
685
686 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
687 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
688 }
689
690 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
691 Ok(self.tags.clone())
692 }
693
694 fn commits_between(
695 &self,
696 _from: Option<&str>,
697 _to: &str,
698 ) -> Result<Vec<Commit>, ReleaseError> {
699 Ok(self.commits.clone())
700 }
701
702 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
703 Ok("2026-01-01".into())
704 }
705
706 fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
707 self.force_created_tags
708 .lock()
709 .unwrap()
710 .push(name.to_string());
711 Ok(())
712 }
713
714 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
715 self.force_pushed_tags
716 .lock()
717 .unwrap()
718 .push(name.to_string());
719 Ok(())
720 }
721
722 fn head_sha(&self) -> Result<String, ReleaseError> {
723 Ok(self.head.clone())
724 }
725
726 fn commits_since_in_path(
727 &self,
728 _from: Option<&str>,
729 _path: &str,
730 ) -> Result<Vec<Commit>, ReleaseError> {
731 Ok(self
732 .path_commits
733 .clone()
734 .unwrap_or_else(|| self.commits.clone()))
735 }
736 }
737
738 struct FakeVcs {
739 releases: Mutex<Vec<(String, String)>>,
740 deleted_releases: Mutex<Vec<String>>,
741 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
742 stored_assets: Mutex<Vec<(String, String, Vec<u8>)>>,
746 }
747
748 impl FakeVcs {
749 fn new() -> Self {
750 Self {
751 releases: Mutex::new(Vec::new()),
752 deleted_releases: Mutex::new(Vec::new()),
753 uploaded_assets: Mutex::new(Vec::new()),
754 stored_assets: Mutex::new(Vec::new()),
755 }
756 }
757 }
758
759 impl VcsProvider for FakeVcs {
760 fn create_release(
761 &self,
762 tag: &str,
763 _name: &str,
764 body: &str,
765 _prerelease: bool,
766 _draft: bool,
767 ) -> Result<String, ReleaseError> {
768 self.releases
769 .lock()
770 .unwrap()
771 .push((tag.to_string(), body.to_string()));
772 Ok(format!("https://github.com/test/release/{tag}"))
773 }
774
775 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
776 Ok(format!("https://github.com/test/compare/{base}...{head}"))
777 }
778
779 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
780 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
781 }
782
783 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
784 self.deleted_releases.lock().unwrap().push(tag.to_string());
785 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
786 Ok(())
787 }
788
789 fn update_release(
790 &self,
791 tag: &str,
792 _name: &str,
793 body: &str,
794 _prerelease: bool,
795 _draft: bool,
796 ) -> Result<String, ReleaseError> {
797 let mut releases = self.releases.lock().unwrap();
798 if let Some(entry) = releases.iter_mut().find(|(t, _)| t == tag) {
799 entry.1 = body.to_string();
800 }
801 Ok(format!("https://github.com/test/release/{tag}"))
802 }
803
804 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
805 self.uploaded_assets.lock().unwrap().push((
806 tag.to_string(),
807 files.iter().map(|s| s.to_string()).collect(),
808 ));
809 for path in files {
811 let basename = std::path::Path::new(path)
812 .file_name()
813 .and_then(|n| n.to_str())
814 .unwrap_or(path)
815 .to_string();
816 let content = std::fs::read(path).unwrap_or_default();
817 self.stored_assets
818 .lock()
819 .unwrap()
820 .push((tag.to_string(), basename, content));
821 }
822 Ok(())
823 }
824
825 fn list_assets(&self, tag: &str) -> Result<Vec<String>, ReleaseError> {
826 Ok(self
827 .stored_assets
828 .lock()
829 .unwrap()
830 .iter()
831 .filter(|(t, _, _)| t == tag)
832 .map(|(_, n, _)| n.clone())
833 .collect())
834 }
835
836 fn fetch_asset(&self, tag: &str, name: &str) -> Result<Option<Vec<u8>>, ReleaseError> {
837 Ok(self
838 .stored_assets
839 .lock()
840 .unwrap()
841 .iter()
842 .find(|(t, n, _)| t == tag && n == name)
843 .map(|(_, _, b)| b.clone()))
844 }
845
846 fn repo_url(&self) -> Option<String> {
847 Some("https://github.com/test/repo".into())
848 }
849 }
850
851 type TestStrategy =
854 TrunkReleaseStrategy<FakeGit, FakeVcs, TypedCommitParser, DefaultChangelogFormatter>;
855
856 fn test_config() -> Config {
860 Config {
861 changelog: ChangelogConfig {
862 file: None,
863 ..Default::default()
864 },
865 packages: vec![PackageConfig {
866 path: ".".into(),
867 version_files: vec!["__sr_test_dummy_no_bump__".into()],
868 ..Default::default()
869 }],
870 ..Default::default()
871 }
872 }
873
874 fn config_with_git(git: GitConfig) -> Config {
876 Config {
877 git,
878 changelog: ChangelogConfig {
879 file: None,
880 ..Default::default()
881 },
882 packages: vec![PackageConfig {
883 path: ".".into(),
884 version_files: vec!["__sr_test_dummy_no_bump__".into()],
885 ..Default::default()
886 }],
887 ..Default::default()
888 }
889 }
890
891 fn make_strategy(tags: Vec<TagInfo>, commits: Vec<Commit>, config: Config) -> TestStrategy {
892 TrunkReleaseStrategy {
893 git: FakeGit::new(tags, commits),
894 vcs: FakeVcs::new(),
895 parser: TypedCommitParser::default(),
896 formatter: DefaultChangelogFormatter::new(None, default_changelog_groups()),
897 config,
898 prerelease_id: None,
899 draft: false,
900 }
901 }
902
903 fn raw_commit(msg: &str) -> Commit {
904 Commit {
905 sha: "a".repeat(40),
906 message: msg.into(),
907 }
908 }
909
910 #[test]
913 fn plan_no_commits_returns_error() {
914 let s = make_strategy(vec![], vec![], Config::default());
915 let err = s.plan().unwrap_err();
916 assert!(matches!(err, ReleaseError::NoCommits { .. }));
917 }
918
919 #[test]
920 fn plan_no_releasable_returns_error() {
921 let s = make_strategy(
922 vec![],
923 vec![raw_commit("chore: tidy up")],
924 Config::default(),
925 );
926 let err = s.plan().unwrap_err();
927 assert!(matches!(err, ReleaseError::NoBump { .. }));
928 }
929
930 #[test]
931 fn plan_first_release() {
932 let s = make_strategy(
933 vec![],
934 vec![raw_commit("feat: initial feature")],
935 Config::default(),
936 );
937 let plan = s.plan().unwrap();
938 assert_eq!(plan.next_version, Version::new(0, 1, 0));
939 assert_eq!(plan.tag_name, "v0.1.0");
940 assert!(plan.current_version.is_none());
941 }
942
943 #[test]
944 fn plan_skips_commits_matching_skip_patterns() {
945 let s = make_strategy(
946 vec![],
947 vec![
948 raw_commit("feat: real feature"),
949 raw_commit("feat: noisy experiment [skip release]"),
950 raw_commit("fix: swallowed fix [skip sr]"),
951 ],
952 test_config(),
953 );
954 let plan = s.plan().unwrap();
955 assert_eq!(plan.commits.len(), 1);
956 assert_eq!(plan.commits[0].description, "real feature");
957 }
958
959 #[test]
960 fn plan_custom_skip_patterns_override_defaults() {
961 let git = GitConfig {
962 skip_patterns: vec!["DO-NOT-RELEASE".into()],
963 ..Default::default()
964 };
965 let s = make_strategy(
966 vec![],
967 vec![
968 raw_commit("feat: shipped"),
969 raw_commit("feat: DO-NOT-RELEASE internal"),
970 raw_commit("feat: still here [skip release]"),
972 ],
973 config_with_git(git),
974 );
975 let plan = s.plan().unwrap();
976 assert_eq!(plan.commits.len(), 2);
977 }
978
979 #[test]
980 fn plan_increments_existing() {
981 let tag = TagInfo {
982 name: "v1.2.3".into(),
983 version: Version::new(1, 2, 3),
984 sha: "b".repeat(40),
985 };
986 let s = make_strategy(
987 vec![tag],
988 vec![raw_commit("fix: patch bug")],
989 Config::default(),
990 );
991 let plan = s.plan().unwrap();
992 assert_eq!(plan.next_version, Version::new(1, 2, 4));
993 }
994
995 #[test]
996 fn plan_breaking_bump() {
997 let tag = TagInfo {
998 name: "v1.2.3".into(),
999 version: Version::new(1, 2, 3),
1000 sha: "c".repeat(40),
1001 };
1002 let s = make_strategy(
1003 vec![tag],
1004 vec![raw_commit("feat!: breaking change")],
1005 Config::default(),
1006 );
1007 let plan = s.plan().unwrap();
1008 assert_eq!(plan.next_version, Version::new(2, 0, 0));
1009 }
1010
1011 #[test]
1012 fn plan_v0_breaking_downshifts_to_minor() {
1013 let tag = TagInfo {
1014 name: "v0.5.0".into(),
1015 version: Version::new(0, 5, 0),
1016 sha: "c".repeat(40),
1017 };
1018 let s = make_strategy(
1019 vec![tag],
1020 vec![raw_commit("feat!: breaking change")],
1021 Config::default(),
1022 );
1023 let plan = s.plan().unwrap();
1024 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1026 assert_eq!(plan.bump, BumpLevel::Minor);
1027 }
1028
1029 #[test]
1030 fn plan_v0_breaking_with_protection_disabled_bumps_major() {
1031 let tag = TagInfo {
1032 name: "v0.5.0".into(),
1033 version: Version::new(0, 5, 0),
1034 sha: "c".repeat(40),
1035 };
1036 let mut config = Config::default();
1037 config.git.v0_protection = false;
1038 let s = make_strategy(
1039 vec![tag],
1040 vec![raw_commit("feat!: breaking change")],
1041 config,
1042 );
1043 let plan = s.plan().unwrap();
1044 assert_eq!(plan.next_version, Version::new(1, 0, 0));
1046 assert_eq!(plan.bump, BumpLevel::Major);
1047 }
1048
1049 #[test]
1050 fn plan_v0_feat_stays_minor() {
1051 let tag = TagInfo {
1052 name: "v0.5.0".into(),
1053 version: Version::new(0, 5, 0),
1054 sha: "c".repeat(40),
1055 };
1056 let s = make_strategy(
1057 vec![tag],
1058 vec![raw_commit("feat: new feature")],
1059 Config::default(),
1060 );
1061 let plan = s.plan().unwrap();
1062 assert_eq!(plan.next_version, Version::new(0, 6, 0));
1064 assert_eq!(plan.bump, BumpLevel::Minor);
1065 }
1066
1067 #[test]
1068 fn plan_v0_fix_stays_patch() {
1069 let tag = TagInfo {
1070 name: "v0.5.0".into(),
1071 version: Version::new(0, 5, 0),
1072 sha: "c".repeat(40),
1073 };
1074 let s = make_strategy(
1075 vec![tag],
1076 vec![raw_commit("fix: bug fix")],
1077 Config::default(),
1078 );
1079 let plan = s.plan().unwrap();
1080 assert_eq!(plan.next_version, Version::new(0, 5, 1));
1082 assert_eq!(plan.bump, BumpLevel::Patch);
1083 }
1084
1085 #[test]
1088 fn execute_dry_run_no_side_effects() {
1089 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1090 let plan = s.plan().unwrap();
1091 s.execute(&plan, true).unwrap();
1092
1093 assert!(s.git.created_tags.lock().unwrap().is_empty());
1094 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1095 }
1096
1097 #[test]
1098 fn execute_creates_and_pushes_tag() {
1099 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1100 let plan = s.plan().unwrap();
1101 s.execute(&plan, false).unwrap();
1102
1103 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1104 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1105 }
1106
1107 #[test]
1108 fn execute_calls_vcs_create_release() {
1109 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1110 let plan = s.plan().unwrap();
1111 s.execute(&plan, false).unwrap();
1112
1113 let releases = s.vcs.releases.lock().unwrap();
1114 assert_eq!(releases.len(), 1);
1115 assert_eq!(releases[0].0, "v0.1.0");
1116 assert!(!releases[0].1.is_empty());
1117 }
1118
1119 #[test]
1120 fn execute_commits_changelog_before_tag() {
1121 let dir = tempfile::tempdir().unwrap();
1122 let changelog_path = dir.path().join("CHANGELOG.md");
1123
1124 let config = Config {
1126 changelog: ChangelogConfig {
1127 file: Some(changelog_path.to_str().unwrap().to_string()),
1128 ..Default::default()
1129 },
1130 packages: vec![PackageConfig {
1131 path: dir.path().to_str().unwrap().to_string(),
1132 ..Default::default()
1133 }],
1134 ..Default::default()
1135 };
1136
1137 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1138 let plan = s.plan().unwrap();
1139 s.execute(&plan, false).unwrap();
1140
1141 let committed = s.git.committed.lock().unwrap();
1143 assert_eq!(committed.len(), 1);
1144 assert_eq!(
1145 committed[0].0,
1146 vec![changelog_path.to_str().unwrap().to_string()]
1147 );
1148 assert!(committed[0].1.contains("chore(release): v0.1.0"));
1149
1150 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1152 }
1153
1154 #[test]
1155 fn execute_skips_existing_tag() {
1156 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1157 let plan = s.plan().unwrap();
1158
1159 s.git
1161 .created_tags
1162 .lock()
1163 .unwrap()
1164 .push("v0.1.0".to_string());
1165
1166 s.execute(&plan, false).unwrap();
1167
1168 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1170 }
1171
1172 #[test]
1173 fn execute_skips_existing_release() {
1174 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1175 let plan = s.plan().unwrap();
1176
1177 s.vcs
1179 .releases
1180 .lock()
1181 .unwrap()
1182 .push(("v0.1.0".to_string(), "old notes".to_string()));
1183
1184 s.execute(&plan, false).unwrap();
1185
1186 let deleted = s.vcs.deleted_releases.lock().unwrap();
1188 assert!(deleted.is_empty(), "update should not delete");
1189
1190 let releases = s.vcs.releases.lock().unwrap();
1191 assert_eq!(releases.len(), 1);
1192 assert_eq!(releases[0].0, "v0.1.0");
1193 assert_ne!(releases[0].1, "old notes");
1194 }
1195
1196 #[test]
1197 fn execute_idempotent_rerun() {
1198 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1199 let plan = s.plan().unwrap();
1200
1201 s.execute(&plan, false).unwrap();
1203
1204 s.execute(&plan, false).unwrap();
1206
1207 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1209
1210 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1212
1213 assert_eq!(*s.git.push_count.lock().unwrap(), 1);
1216
1217 assert_eq!(s.git.committed.lock().unwrap().len(), 0);
1220
1221 let deleted = s.vcs.deleted_releases.lock().unwrap();
1223 assert!(deleted.is_empty(), "update should not delete");
1224
1225 let releases = s.vcs.releases.lock().unwrap();
1226 assert_eq!(releases.len(), 1);
1227 assert_eq!(releases[0].0, "v0.1.0");
1228
1229 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1232 assert!(uploaded.is_empty());
1233 }
1234
1235 #[test]
1236 fn execute_bumps_version_files() {
1237 let dir = tempfile::tempdir().unwrap();
1238 let cargo_path = dir.path().join("Cargo.toml");
1239 std::fs::write(
1240 &cargo_path,
1241 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1242 )
1243 .unwrap();
1244
1245 let config = Config {
1246 changelog: ChangelogConfig {
1247 file: None,
1248 ..Default::default()
1249 },
1250 packages: vec![PackageConfig {
1251 path: ".".into(),
1252 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1253 ..Default::default()
1254 }],
1255 ..Default::default()
1256 };
1257
1258 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1259 let plan = s.plan().unwrap();
1260 s.execute(&plan, false).unwrap();
1261
1262 let contents = std::fs::read_to_string(&cargo_path).unwrap();
1264 assert!(contents.contains("version = \"0.1.0\""));
1265
1266 let committed = s.git.committed.lock().unwrap();
1268 assert_eq!(committed.len(), 1);
1269 assert!(
1270 committed[0]
1271 .0
1272 .contains(&cargo_path.to_str().unwrap().to_string())
1273 );
1274 }
1275
1276 #[test]
1277 fn execute_stages_changelog_and_version_files_together() {
1278 let dir = tempfile::tempdir().unwrap();
1279 let cargo_path = dir.path().join("Cargo.toml");
1280 std::fs::write(
1281 &cargo_path,
1282 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1283 )
1284 .unwrap();
1285
1286 let changelog_path = dir.path().join("CHANGELOG.md");
1287
1288 let config = Config {
1289 changelog: ChangelogConfig {
1290 file: Some(changelog_path.to_str().unwrap().to_string()),
1291 ..Default::default()
1292 },
1293 packages: vec![PackageConfig {
1294 path: ".".into(),
1295 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1296 ..Default::default()
1297 }],
1298 ..Default::default()
1299 };
1300
1301 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1302 let plan = s.plan().unwrap();
1303 s.execute(&plan, false).unwrap();
1304
1305 let committed = s.git.committed.lock().unwrap();
1307 assert_eq!(committed.len(), 1);
1308 assert!(
1309 committed[0]
1310 .0
1311 .contains(&changelog_path.to_str().unwrap().to_string())
1312 );
1313 assert!(
1314 committed[0]
1315 .0
1316 .contains(&cargo_path.to_str().unwrap().to_string())
1317 );
1318 }
1319
1320 #[test]
1323 fn execute_uploads_artifacts() {
1324 let dir = tempfile::tempdir().unwrap();
1325 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1326 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1327
1328 let config = Config {
1329 changelog: ChangelogConfig {
1330 file: None,
1331 ..Default::default()
1332 },
1333 packages: vec![PackageConfig {
1334 path: ".".into(),
1335 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1336 artifacts: vec![
1337 dir.path().join("app.tar.gz").to_str().unwrap().to_string(),
1338 dir.path().join("app.zip").to_str().unwrap().to_string(),
1339 ],
1340 ..Default::default()
1341 }],
1342 ..Default::default()
1343 };
1344
1345 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1346 let plan = s.plan().unwrap();
1347 s.execute(&plan, false).unwrap();
1348
1349 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1350 assert_eq!(uploaded.len(), 1);
1352 let artifact_call = uploaded
1353 .iter()
1354 .find(|(_tag, files)| files.iter().any(|f| f.ends_with("app.tar.gz")))
1355 .expect("expected an upload call containing user artifacts");
1356 assert_eq!(artifact_call.0, "v0.1.0");
1357 assert_eq!(artifact_call.1.len(), 2);
1358 assert!(artifact_call.1.iter().any(|f| f.ends_with("app.tar.gz")));
1359 assert!(artifact_call.1.iter().any(|f| f.ends_with("app.zip")));
1360 }
1361
1362 #[test]
1363 fn execute_dry_run_shows_artifacts() {
1364 let dir = tempfile::tempdir().unwrap();
1365 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1366
1367 let config = Config {
1368 changelog: ChangelogConfig {
1369 file: None,
1370 ..Default::default()
1371 },
1372 packages: vec![PackageConfig {
1373 path: ".".into(),
1374 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1375 artifacts: vec![dir.path().join("app.tar.gz").to_str().unwrap().to_string()],
1376 ..Default::default()
1377 }],
1378 ..Default::default()
1379 };
1380
1381 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1382 let plan = s.plan().unwrap();
1383 s.execute(&plan, true).unwrap();
1384
1385 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1387 assert!(uploaded.is_empty());
1388 }
1389
1390 #[test]
1391 fn execute_no_artifacts_skips_upload() {
1392 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1393 let plan = s.plan().unwrap();
1394 s.execute(&plan, false).unwrap();
1395
1396 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1398 assert!(
1399 uploaded.is_empty(),
1400 "unexpected uploads with no declared artifacts: {uploaded:?}"
1401 );
1402 }
1403
1404 #[test]
1405 fn resolve_paths_literal_existing() {
1406 let dir = tempfile::tempdir().unwrap();
1407 let a = dir.path().join("a.txt");
1408 let b = dir.path().join("b.txt");
1409 std::fs::write(&a, "a").unwrap();
1410 std::fs::write(&b, "b").unwrap();
1411
1412 let input = vec![
1413 a.to_string_lossy().into_owned(),
1414 b.to_string_lossy().into_owned(),
1415 ];
1416 let result = resolve_paths(&input).unwrap();
1417 assert_eq!(result.len(), 2);
1418 }
1419
1420 #[test]
1421 fn resolve_paths_missing_errors() {
1422 let dir = tempfile::tempdir().unwrap();
1423 let a = dir.path().join("a.txt");
1424 std::fs::write(&a, "a").unwrap();
1425
1426 let result = resolve_paths(&[
1427 a.to_string_lossy().into_owned(),
1428 dir.path().join("nope.txt").to_string_lossy().into_owned(),
1429 ]);
1430 assert!(result.is_err());
1431 }
1432
1433 #[test]
1434 fn resolve_paths_deduplicates() {
1435 let dir = tempfile::tempdir().unwrap();
1436 let p = dir.path().join("file.txt");
1437 std::fs::write(&p, "data").unwrap();
1438 let ps = p.to_string_lossy().into_owned();
1439 let result = resolve_paths(&[ps.clone(), ps]).unwrap();
1440 assert_eq!(result.len(), 1);
1441 }
1442
1443 #[test]
1444 fn partition_paths_splits_existing_and_missing() {
1445 let dir = tempfile::tempdir().unwrap();
1446 let a = dir.path().join("a.txt");
1447 std::fs::write(&a, "a").unwrap();
1448
1449 let (on_disk, missing) = partition_paths(&[
1450 a.to_string_lossy().into_owned(),
1451 dir.path().join("nope.txt").to_string_lossy().into_owned(),
1452 ]);
1453 assert_eq!(on_disk.len(), 1);
1454 assert_eq!(missing.len(), 1);
1455 }
1456
1457 #[test]
1460 fn plan_floating_tag_when_enabled() {
1461 let tag = TagInfo {
1462 name: "v3.2.0".into(),
1463 version: Version::new(3, 2, 0),
1464 sha: "d".repeat(40),
1465 };
1466 let config = config_with_git(GitConfig {
1467 floating_tag: true,
1468 ..Default::default()
1469 });
1470
1471 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1472 let plan = s.plan().unwrap();
1473 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1474 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1475 }
1476
1477 #[test]
1478 fn plan_no_floating_tag_when_disabled() {
1479 let s = make_strategy(
1480 vec![],
1481 vec![raw_commit("feat: something")],
1482 config_with_git(GitConfig {
1483 floating_tag: false,
1484 ..Default::default()
1485 }),
1486 );
1487 let plan = s.plan().unwrap();
1488 assert!(plan.floating_tag_name.is_none());
1489 }
1490
1491 #[test]
1492 fn plan_floating_tag_custom_prefix() {
1493 let tag = TagInfo {
1494 name: "release-2.5.0".into(),
1495 version: Version::new(2, 5, 0),
1496 sha: "e".repeat(40),
1497 };
1498 let config = config_with_git(GitConfig {
1499 floating_tag: true,
1500 tag_prefix: "release-".into(),
1501 ..Default::default()
1502 });
1503
1504 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1505 let plan = s.plan().unwrap();
1506 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1507 }
1508
1509 #[test]
1510 fn execute_floating_tags_force_create_and_push() {
1511 let config = config_with_git(GitConfig {
1512 floating_tag: true,
1513 ..Default::default()
1514 });
1515
1516 let tag = TagInfo {
1517 name: "v1.2.3".into(),
1518 version: Version::new(1, 2, 3),
1519 sha: "f".repeat(40),
1520 };
1521 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1522 let plan = s.plan().unwrap();
1523 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1524
1525 s.execute(&plan, false).unwrap();
1526
1527 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1528 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1529 }
1530
1531 #[test]
1532 fn execute_no_floating_tags_when_disabled() {
1533 let s = make_strategy(
1534 vec![],
1535 vec![raw_commit("feat: something")],
1536 config_with_git(GitConfig {
1537 floating_tag: false,
1538 ..Default::default()
1539 }),
1540 );
1541 let plan = s.plan().unwrap();
1542 assert!(plan.floating_tag_name.is_none());
1543
1544 s.execute(&plan, false).unwrap();
1545
1546 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1547 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1548 }
1549
1550 #[test]
1551 fn execute_floating_tags_dry_run_no_side_effects() {
1552 let config = config_with_git(GitConfig {
1553 floating_tag: true,
1554 ..Default::default()
1555 });
1556
1557 let tag = TagInfo {
1558 name: "v2.0.0".into(),
1559 version: Version::new(2, 0, 0),
1560 sha: "a".repeat(40),
1561 };
1562 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1563 let plan = s.plan().unwrap();
1564 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1565
1566 s.execute(&plan, true).unwrap();
1567
1568 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1569 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1570 }
1571
1572 #[test]
1573 fn execute_floating_tags_idempotent() {
1574 let config = config_with_git(GitConfig {
1575 floating_tag: true,
1576 ..Default::default()
1577 });
1578
1579 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1580 let plan = s.plan().unwrap();
1581 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1582
1583 s.execute(&plan, false).unwrap();
1585 s.execute(&plan, false).unwrap();
1586
1587 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1589 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1590 }
1591
1592 #[test]
1598 fn execute_aborts_when_declared_artifact_missing() {
1599 let config = Config {
1600 changelog: ChangelogConfig {
1601 file: None,
1602 ..Default::default()
1603 },
1604 packages: vec![PackageConfig {
1605 path: ".".into(),
1606 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1607 artifacts: vec!["/definitely/not/here/app.tar.gz".into()],
1608 ..Default::default()
1609 }],
1610 ..Default::default()
1611 };
1612
1613 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1614 let plan = s.plan().unwrap();
1615 let err = s.execute(&plan, false).unwrap_err();
1616
1617 match err {
1618 ReleaseError::Vcs(ref msg) => {
1619 assert!(
1620 msg.contains("missing on disk"),
1621 "expected validation error, got: {msg}"
1622 );
1623 }
1624 other => panic!("expected Vcs error, got {other:?}"),
1625 }
1626
1627 assert!(s.git.created_tags.lock().unwrap().is_empty());
1628 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1629 assert!(s.vcs.releases.lock().unwrap().is_empty());
1630 }
1631
1632 #[test]
1635 fn execute_succeeds_when_all_artifacts_present() {
1636 let dir = tempfile::tempdir().unwrap();
1637 std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
1638
1639 let config = Config {
1640 changelog: ChangelogConfig {
1641 file: None,
1642 ..Default::default()
1643 },
1644 packages: vec![PackageConfig {
1645 path: ".".into(),
1646 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1647 artifacts: vec![dir.path().join("app.tar.gz").to_str().unwrap().to_string()],
1648 ..Default::default()
1649 }],
1650 ..Default::default()
1651 };
1652
1653 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1654 let plan = s.plan().unwrap();
1655 s.execute(&plan, false).unwrap();
1656
1657 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1658 }
1659
1660 #[test]
1666 fn execute_skips_already_uploaded_artifacts() {
1667 let dir = tempfile::tempdir().unwrap();
1668 std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
1669
1670 let config = Config {
1671 changelog: ChangelogConfig {
1672 file: None,
1673 ..Default::default()
1674 },
1675 packages: vec![PackageConfig {
1676 path: ".".into(),
1677 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1678 artifacts: vec![dir.path().join("app.tar.gz").to_str().unwrap().to_string()],
1679 ..Default::default()
1680 }],
1681 ..Default::default()
1682 };
1683
1684 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1685 let plan = s.plan().unwrap();
1686
1687 s.execute(&plan, false).unwrap();
1689 let uploads_after_first = s.vcs.uploaded_assets.lock().unwrap().len();
1690
1691 s.execute(&plan, false).unwrap();
1693 let uploads_after_second = s.vcs.uploaded_assets.lock().unwrap().len();
1694
1695 assert_eq!(
1696 uploads_after_first, uploads_after_second,
1697 "idempotent re-run should not re-upload existing assets"
1698 );
1699 }
1700
1701 #[test]
1707 fn build_diff_sees_existing_release_and_assets_as_no_change() {
1708 use crate::diff::{Action, ResourceKind, build_diff};
1709
1710 let dir = tempfile::tempdir().unwrap();
1711 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1712
1713 let config = Config {
1714 changelog: ChangelogConfig {
1715 file: None,
1716 ..Default::default()
1717 },
1718 packages: vec![PackageConfig {
1719 path: ".".into(),
1720 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1721 artifacts: vec![dir.path().join("app.tar.gz").to_str().unwrap().to_string()],
1722 ..Default::default()
1723 }],
1724 ..Default::default()
1725 };
1726
1727 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config.clone());
1728 let plan = s.plan().unwrap();
1729 s.execute(&plan, false).unwrap();
1730
1731 let diff = build_diff(&plan, &s.git, &s.vcs, &config, &[]).unwrap();
1732
1733 let release_row = diff
1734 .resources
1735 .iter()
1736 .find(|r| r.kind == ResourceKind::Release)
1737 .expect("release row present");
1738 assert_eq!(
1739 release_row.action,
1740 Action::Update,
1741 "existing release should diff as Update (body may be rewritten)"
1742 );
1743
1744 let asset_row = diff
1745 .resources
1746 .iter()
1747 .find(|r| r.kind == ResourceKind::Asset)
1748 .expect("asset row present");
1749 assert_eq!(
1750 asset_row.action,
1751 Action::NoChange,
1752 "already-uploaded asset must render as NoChange, not Create"
1753 );
1754 }
1755
1756 #[test]
1759 fn no_new_commits_at_tag_head_errors() {
1760 let tag = TagInfo {
1761 name: "v1.2.3".into(),
1762 version: Version::new(1, 2, 3),
1763 sha: "a".repeat(40),
1764 };
1765 let mut s = make_strategy(vec![tag], vec![], Config::default());
1766 s.git.head = "a".repeat(40);
1767 let err = s.plan().unwrap_err();
1768 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1769 }
1770}