1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use changeset_changelog::{ChangelogLocation, ComparisonLinksSetting, RepositoryInfo};
6use changeset_core::{PackageInfo, PrereleaseSpec};
7use changeset_project::{GraduationState, ProjectKind, TagFormat};
8use changeset_saga::SagaBuilder;
9use chrono::Local;
10use indexmap::IndexMap;
11use semver::Version;
12
13use super::context::ReleaseSagaContext;
14use super::saga_data::{ReleaseSagaData, SagaReleaseOptions};
15use super::saga_steps::{
16 ClearChangesetsConsumedStep, CreateCommitStep, CreateTagsStep, DeleteChangesetFilesStep,
17 MarkChangesetsConsumedStep, RemoveWorkspaceVersionStep, RestoreChangelogsStep, StageFilesStep,
18 UpdateReleaseStateStep, WriteManifestVersionsStep,
19};
20use super::validator::{ReleaseCliInput, ReleaseValidator};
21use crate::Result;
22use crate::error::OperationError;
23use crate::operations::changelog_aggregation::ChangesetAggregator;
24use crate::planner::VersionPlanner;
25use crate::traits::{
26 ChangelogWriter, ChangesetReader, ChangesetWriter, GitProvider, ManifestWriter,
27 ProjectProvider, ReleaseStateIO,
28};
29use crate::types::{PackageReleaseConfig, PackageVersion};
30
31pub struct ReleaseInput {
32 pub dry_run: bool,
33 pub convert_inherited: bool,
34 pub no_commit: bool,
35 pub no_tags: bool,
36 pub keep_changesets: bool,
37 pub force: bool,
38 pub per_package_config: HashMap<String, PackageReleaseConfig>,
40 pub global_prerelease: Option<PrereleaseSpec>,
42 pub graduate_all: bool,
44}
45
46#[derive(Debug, Clone)]
47pub struct ChangelogUpdate {
48 pub path: PathBuf,
49 pub package: Option<String>,
50 pub version: Version,
51 pub created: bool,
52}
53
54#[derive(Debug, Clone)]
55pub struct CommitResult {
56 pub sha: String,
57 pub message: String,
58}
59
60#[derive(Debug, Clone)]
61pub struct TagResult {
62 pub name: String,
63 pub target_sha: String,
64}
65
66#[derive(Debug, Clone, Default)]
67pub struct GitOperationResult {
68 pub commit: Option<CommitResult>,
69 pub tags_created: Vec<TagResult>,
70 pub changesets_deleted: Vec<PathBuf>,
71}
72
73#[derive(Debug, Clone)]
74pub struct ReleaseOutput {
75 pub planned_releases: Vec<PackageVersion>,
76 pub unchanged_packages: Vec<String>,
77 pub changesets_consumed: Vec<PathBuf>,
78 pub changelog_updates: Vec<ChangelogUpdate>,
79 pub git_result: Option<GitOperationResult>,
80}
81
82#[derive(Debug)]
83pub enum ReleaseOutcome {
84 DryRun(ReleaseOutput),
85 Executed(ReleaseOutput),
86 NoChangesets,
87}
88
89struct GitOptions {
90 should_commit: bool,
91 should_create_tags: bool,
92 should_delete_changesets: bool,
93}
94
95struct ReleaseContext {
96 project: changeset_project::CargoProject,
97 root_config: changeset_project::RootChangesetConfig,
98 changeset_dir: PathBuf,
99 changeset_files: Vec<PathBuf>,
100 prerelease_state: Option<changeset_project::PrereleaseState>,
101 graduation_state: Option<GraduationState>,
102 per_package_config: HashMap<String, PackageReleaseConfig>,
103 is_prerelease_graduation: bool,
104 is_graduating: bool,
105 is_prerelease_release: bool,
106 git_options: GitOptions,
107 inherited_packages: Vec<String>,
108 early_return: Option<Result<ReleaseOutcome>>,
109}
110
111struct ReleasePlan {
112 output: ReleaseOutput,
113 planned_releases: Vec<PackageVersion>,
114 package_lookup: IndexMap<String, PackageInfo>,
115 changelog_backups: Vec<super::steps::ChangelogFileState>,
116}
117
118fn find_previous_tag(planned_releases: &[PackageVersion]) -> Option<String> {
119 let first_release = planned_releases.first()?;
120 let previous_version = &first_release.current_version;
121 Some(previous_version.to_string())
122}
123
124fn is_any_prerelease_configured(
125 input: &ReleaseInput,
126 per_package_config: &HashMap<String, PackageReleaseConfig>,
127) -> bool {
128 input.global_prerelease.is_some() || per_package_config.values().any(|c| c.prerelease.is_some())
129}
130
131fn is_prerelease_graduation(
132 packages: &[PackageInfo],
133 per_package_config: &HashMap<String, PackageReleaseConfig>,
134) -> bool {
135 if per_package_config.values().any(|c| c.prerelease.is_some()) {
136 return false;
137 }
138 packages
139 .iter()
140 .any(|p| changeset_version::is_prerelease(&p.version))
141}
142
143fn is_zero_graduation(
144 packages: &[PackageInfo],
145 input: &ReleaseInput,
146 per_package_config: &HashMap<String, PackageReleaseConfig>,
147) -> bool {
148 let has_graduation = input.graduate_all || per_package_config.values().any(|c| c.graduate_zero);
149 if !has_graduation {
150 return false;
151 }
152 packages
153 .iter()
154 .any(|p| changeset_version::is_zero_version(&p.version))
155}
156
157pub struct ReleaseOperation<P, RW, M, C, G, S> {
158 project_provider: P,
159 changeset_io: Arc<RW>,
160 manifest_writer: Arc<M>,
161 changelog_writer: C,
162 git_provider: Arc<G>,
163 release_state_io: Arc<S>,
164}
165
166#[cfg(test)]
167impl<P, RW, M, C, G, S> ReleaseOperation<P, RW, M, C, G, S> {
168 pub(crate) fn manifest_writer(&self) -> &M {
169 &self.manifest_writer
170 }
171
172 pub(crate) fn git_provider(&self) -> &G {
173 &self.git_provider
174 }
175}
176
177impl<P, RW, M, C, G, S> ReleaseOperation<P, RW, M, C, G, S>
178where
179 P: ProjectProvider,
180 RW: ChangesetReader + ChangesetWriter + Send + Sync + 'static,
181 M: ManifestWriter + Send + Sync + 'static,
182 C: ChangelogWriter + Clone + Send + Sync + 'static,
183 G: GitProvider + Send + Sync + 'static,
184 S: ReleaseStateIO + Send + Sync + 'static,
185{
186 pub fn new(
187 project_provider: P,
188 changeset_io: RW,
189 manifest_writer: M,
190 changelog_writer: C,
191 git_provider: G,
192 release_state_io: S,
193 ) -> Self {
194 Self {
195 project_provider,
196 changeset_io: Arc::new(changeset_io),
197 manifest_writer: Arc::new(manifest_writer),
198 changelog_writer,
199 git_provider: Arc::new(git_provider),
200 release_state_io: Arc::new(release_state_io),
201 }
202 }
203
204 fn find_packages_with_inherited_versions(
205 &self,
206 packages: &[PackageInfo],
207 ) -> Result<Vec<String>> {
208 self.manifest_writer
209 .find_packages_with_inherited_versions(packages)
210 }
211
212 fn detect_repository_info(&self, project_root: &Path) -> Option<RepositoryInfo> {
213 let url = self.git_provider.remote_url(project_root).ok()??;
214 RepositoryInfo::from_url(&url).ok()
215 }
216
217 fn capture_changelog_state(
218 &self,
219 project_root: &Path,
220 changelog_config: &changeset_changelog::ChangelogConfig,
221 planned_releases: &[PackageVersion],
222 package_lookup: &IndexMap<String, PackageInfo>,
223 ) -> Result<Vec<super::steps::ChangelogFileState>> {
224 use super::steps::ChangelogFileState;
225 let mut backups = Vec::new();
226
227 match changelog_config.changelog {
228 ChangelogLocation::Root => {
229 let changelog_path = project_root.join("CHANGELOG.md");
230 let max_version = planned_releases
231 .iter()
232 .map(|r| &r.new_version)
233 .max()
234 .cloned();
235
236 if let Some(version) = max_version {
237 let file_existed = self.changelog_writer.changelog_exists(&changelog_path);
238 let original_content = if file_existed {
239 Some(std::fs::read_to_string(&changelog_path).map_err(|e| {
240 OperationError::ChangesetFileRead {
241 path: changelog_path.clone(),
242 source: e,
243 }
244 })?)
245 } else {
246 None
247 };
248
249 backups.push(ChangelogFileState {
250 path: changelog_path,
251 version,
252 package: None,
253 original_content,
254 file_existed,
255 });
256 }
257 }
258 ChangelogLocation::PerPackage => {
259 for release in planned_releases {
260 if let Some(pkg) = package_lookup.get(&release.name) {
261 let changelog_path = pkg.path.join("CHANGELOG.md");
262 let file_existed = self.changelog_writer.changelog_exists(&changelog_path);
263 let original_content = if file_existed {
264 Some(std::fs::read_to_string(&changelog_path).map_err(|e| {
265 OperationError::ChangesetFileRead {
266 path: changelog_path.clone(),
267 source: e,
268 }
269 })?)
270 } else {
271 None
272 };
273
274 backups.push(ChangelogFileState {
275 path: changelog_path,
276 version: release.new_version.clone(),
277 package: Some(release.name.clone()),
278 original_content,
279 file_existed,
280 });
281 }
282 }
283 }
284 }
285
286 Ok(backups)
287 }
288
289 fn generate_changelog_updates(
290 &self,
291 project_root: &Path,
292 changelog_config: &changeset_changelog::ChangelogConfig,
293 aggregator: &ChangesetAggregator,
294 planned_releases: &[PackageVersion],
295 package_lookup: &IndexMap<String, PackageInfo>,
296 ) -> Result<Vec<ChangelogUpdate>> {
297 let today = Local::now().date_naive();
298 let repo_info = self.resolve_repo_info(project_root, changelog_config)?;
299 let mut changelog_updates = Vec::new();
300
301 match changelog_config.changelog {
302 ChangelogLocation::Root => {
303 let changelog_path = project_root.join("CHANGELOG.md");
304 let max_version = planned_releases
305 .iter()
306 .map(|r| &r.new_version)
307 .max()
308 .cloned();
309
310 if let Some(version) = max_version {
311 let packages: Vec<_> = planned_releases
312 .iter()
313 .map(|r| (r.name.clone(), r.new_version.clone()))
314 .collect();
315
316 if let Some(release) = aggregator.build_root_release(&version, today, &packages)
317 {
318 let previous_tag = find_previous_tag(planned_releases);
319
320 let result = self.changelog_writer.write_release(
321 &changelog_path,
322 &release,
323 repo_info.as_ref(),
324 previous_tag.as_deref(),
325 )?;
326
327 changelog_updates.push(ChangelogUpdate {
328 path: result.path,
329 package: None,
330 version,
331 created: result.created,
332 });
333 }
334 }
335 }
336 ChangelogLocation::PerPackage => {
337 for release in planned_releases {
338 if let Some(pkg) = package_lookup.get(&release.name) {
339 let changelog_path = pkg.path.join("CHANGELOG.md");
340
341 if let Some(version_release) = aggregator.build_package_release(
342 &release.name,
343 &release.new_version,
344 today,
345 ) {
346 let previous_version = release.current_version.to_string();
347
348 let result = self.changelog_writer.write_release(
349 &changelog_path,
350 &version_release,
351 repo_info.as_ref(),
352 Some(&previous_version),
353 )?;
354
355 changelog_updates.push(ChangelogUpdate {
356 path: result.path,
357 package: Some(release.name.clone()),
358 version: release.new_version.clone(),
359 created: result.created,
360 });
361 }
362 }
363 }
364 }
365 }
366
367 Ok(changelog_updates)
368 }
369
370 fn resolve_repo_info(
371 &self,
372 project_root: &Path,
373 changelog_config: &changeset_changelog::ChangelogConfig,
374 ) -> Result<Option<RepositoryInfo>> {
375 match changelog_config.comparison_links {
376 ComparisonLinksSetting::Disabled => Ok(None),
377 ComparisonLinksSetting::Auto => Ok(self.detect_repository_info(project_root)),
378 ComparisonLinksSetting::Enabled => {
379 let repo_info = self.detect_repository_info(project_root);
380 if repo_info.is_none() {
381 return Err(OperationError::ComparisonLinksRequired);
382 }
383 Ok(repo_info)
384 }
385 }
386 }
387
388 fn validate_working_tree(
395 &self,
396 project_root: &Path,
397 should_commit: bool,
398 dry_run: bool,
399 ) -> Result<()> {
400 if should_commit && !dry_run {
401 let is_clean = self.git_provider.is_working_tree_clean(project_root)?;
402 if !is_clean {
403 return Err(OperationError::DirtyWorkingTree);
404 }
405 }
406 Ok(())
407 }
408
409 fn check_inherited_versions(
416 &self,
417 packages: &[PackageInfo],
418 convert_inherited: bool,
419 ) -> Result<Vec<String>> {
420 let inherited_packages = self.find_packages_with_inherited_versions(packages)?;
421 if !inherited_packages.is_empty() && !convert_inherited {
422 return Err(OperationError::InheritedVersionsRequireConvert {
423 packages: inherited_packages,
424 });
425 }
426 Ok(inherited_packages)
427 }
428
429 fn load_changesets(
435 &self,
436 changeset_dir: &Path,
437 changeset_files: &[PathBuf],
438 ) -> Result<(Vec<changeset_core::Changeset>, ChangesetAggregator)> {
439 let mut changesets = Vec::new();
440 let mut aggregator = ChangesetAggregator::new();
441
442 for path in changeset_files {
443 let changeset = self.changeset_io.read_changeset(path)?;
444 aggregator.add_changeset(&changeset);
445 changesets.push(changeset);
446 }
447
448 let consumed_paths = self.changeset_io.list_consumed_changesets(changeset_dir)?;
449 for path in &consumed_paths {
450 let changeset = self.changeset_io.read_changeset(path)?;
451 aggregator.add_changeset(&changeset);
452 }
453
454 Ok((changesets, aggregator))
455 }
456
457 fn collect_unchanged_packages(
458 packages: &[PackageInfo],
459 planned_releases: &[PackageVersion],
460 ) -> Vec<String> {
461 let packages_with_releases: std::collections::HashSet<_> =
462 planned_releases.iter().map(|r| r.name.clone()).collect();
463
464 packages
465 .iter()
466 .filter(|p| !packages_with_releases.contains(&p.name))
467 .map(|p| p.name.clone())
468 .collect()
469 }
470
471 pub fn execute(&self, start_path: &Path, input: &ReleaseInput) -> Result<ReleaseOutcome> {
476 let context = self.prepare_release_context(start_path, input)?;
477
478 if let Some(early_return) = context.early_return {
479 return early_return;
480 }
481
482 let plan = self.plan_release(&context, input.dry_run)?;
483
484 if input.dry_run {
485 return Ok(ReleaseOutcome::DryRun(plan.output));
486 }
487
488 self.execute_release(&context, plan)
489 }
490
491 fn prepare_release_context(
492 &self,
493 start_path: &Path,
494 input: &ReleaseInput,
495 ) -> Result<ReleaseContext> {
496 let project = self.project_provider.discover_project(start_path)?;
497 let (root_config, _) = self.project_provider.load_configs(&project)?;
498
499 let changeset_dir = project.root.join(root_config.changeset_dir());
500 let changeset_files = self.changeset_io.list_changesets(&changeset_dir)?;
501
502 let prerelease_state = self
503 .release_state_io
504 .load_prerelease_state(&changeset_dir)?;
505 let graduation_state = self
506 .release_state_io
507 .load_graduation_state(&changeset_dir)?;
508
509 let cli_input = Self::build_cli_input(input);
510 let validated_config = ReleaseValidator::validate(
511 &cli_input,
512 prerelease_state.as_ref(),
513 graduation_state.as_ref(),
514 &project.packages,
515 &project.kind,
516 )
517 .map_err(OperationError::ValidationFailed)?;
518
519 let per_package_config = validated_config.per_package;
520
521 let is_prerelease_graduation =
522 is_prerelease_graduation(&project.packages, &per_package_config);
523 let is_zero_graduation = is_zero_graduation(&project.packages, input, &per_package_config);
524 let is_graduating = is_prerelease_graduation || is_zero_graduation;
525
526 let early_return =
527 Self::check_early_return(&changeset_files, is_graduating, input, &per_package_config);
528
529 let git_config = root_config.git_config();
530 let git_options = GitOptions {
531 should_commit: !input.no_commit && git_config.commit(),
532 should_create_tags: !input.no_tags && git_config.tags(),
533 should_delete_changesets: !input.keep_changesets && !git_config.keep_changesets(),
534 };
535 let is_prerelease_release = is_any_prerelease_configured(input, &per_package_config);
536
537 self.validate_working_tree(&project.root, git_options.should_commit, input.dry_run)?;
538 let inherited_packages =
539 self.check_inherited_versions(&project.packages, input.convert_inherited)?;
540
541 Ok(ReleaseContext {
542 project,
543 root_config,
544 changeset_dir,
545 changeset_files,
546 prerelease_state,
547 graduation_state,
548 per_package_config,
549 is_prerelease_graduation,
550 is_graduating,
551 is_prerelease_release,
552 git_options,
553 inherited_packages,
554 early_return,
555 })
556 }
557
558 fn check_early_return(
559 changeset_files: &[PathBuf],
560 is_graduating: bool,
561 input: &ReleaseInput,
562 per_package_config: &HashMap<String, PackageReleaseConfig>,
563 ) -> Option<Result<ReleaseOutcome>> {
564 if changeset_files.is_empty() && !is_graduating {
565 if is_any_prerelease_configured(input, per_package_config) && !input.force {
566 return Some(Err(OperationError::NoChangesetsWithoutForce));
567 }
568 return Some(Ok(ReleaseOutcome::NoChangesets));
569 }
570 None
571 }
572
573 fn plan_release(&self, context: &ReleaseContext, dry_run: bool) -> Result<ReleasePlan> {
574 let (changesets, aggregator) =
575 self.load_changesets(&context.changeset_dir, &context.changeset_files)?;
576
577 let planned_releases = if context.is_prerelease_graduation {
578 VersionPlanner::plan_graduation(&context.project.packages)?.releases
579 } else {
580 VersionPlanner::plan_releases_per_package(
581 &changesets,
582 &context.project.packages,
583 &context.per_package_config,
584 context.root_config.zero_version_behavior(),
585 )?
586 .releases
587 };
588
589 let package_lookup: IndexMap<_, _> = context
590 .project
591 .packages
592 .iter()
593 .map(|p| (p.name.clone(), p.clone()))
594 .collect();
595
596 let unchanged_packages =
597 Self::collect_unchanged_packages(&context.project.packages, &planned_releases);
598
599 let (changelog_updates, changelog_backups) = if dry_run {
600 (Vec::new(), Vec::new())
601 } else {
602 let backups = self.capture_changelog_state(
603 &context.project.root,
604 context.root_config.changelog_config(),
605 &planned_releases,
606 &package_lookup,
607 )?;
608 let updates = self.generate_changelog_updates(
609 &context.project.root,
610 context.root_config.changelog_config(),
611 &aggregator,
612 &planned_releases,
613 &package_lookup,
614 )?;
615 (updates, backups)
616 };
617
618 let output = ReleaseOutput {
619 planned_releases: planned_releases.clone(),
620 unchanged_packages,
621 changesets_consumed: context.changeset_files.clone(),
622 changelog_updates,
623 git_result: None,
624 };
625
626 Ok(ReleasePlan {
627 output,
628 planned_releases,
629 package_lookup,
630 changelog_backups,
631 })
632 }
633
634 fn execute_release(
635 &self,
636 context: &ReleaseContext,
637 plan: ReleasePlan,
638 ) -> Result<ReleaseOutcome> {
639 let package_paths: IndexMap<String, PathBuf> = plan
640 .package_lookup
641 .iter()
642 .map(|(name, info)| (name.clone(), info.path.clone()))
643 .collect();
644
645 let saga_data = ReleaseSagaData::new(
646 context.changeset_dir.clone(),
647 context.project.root.join("Cargo.toml"),
648 plan.planned_releases.clone(),
649 package_paths,
650 plan.output.changelog_updates.clone(),
651 context.changeset_files.clone(),
652 )
653 .with_options(SagaReleaseOptions {
654 is_prerelease_release: context.is_prerelease_release,
655 is_graduating: context.is_graduating,
656 is_prerelease_graduation: context.is_prerelease_graduation,
657 should_commit: context.git_options.should_commit,
658 should_create_tags: context.git_options.should_create_tags,
659 should_delete_changesets: context.git_options.should_delete_changesets,
660 })
661 .with_inherited_packages(context.inherited_packages.clone())
662 .with_prerelease_state(context.prerelease_state.as_ref())
663 .with_graduation_state(context.graduation_state.as_ref())
664 .with_changelog_backups(plan.changelog_backups);
665
666 let result = self.execute_release_saga(context, saga_data)?;
667
668 Ok(ReleaseOutcome::Executed(ReleaseOutput {
669 git_result: Some(result.into_git_result()),
670 ..plan.output
671 }))
672 }
673
674 #[allow(clippy::items_after_statements)]
675 fn execute_release_saga(
676 &self,
677 context: &ReleaseContext,
678 saga_data: ReleaseSagaData,
679 ) -> Result<ReleaseSagaData> {
680 let git_config = context.root_config.git_config();
681 let use_crate_prefix = match &context.project.kind {
682 ProjectKind::SinglePackage => git_config.tag_format() == TagFormat::CratePrefixed,
683 ProjectKind::VirtualWorkspace | ProjectKind::WorkspaceWithRoot => true,
684 };
685
686 type RestoreChangelogs<G, M, RW, S, CW> = RestoreChangelogsStep<G, M, RW, S, CW>;
687 type WriteManifests<G, M, RW, S, CW> = WriteManifestVersionsStep<G, M, RW, S, CW>;
688 type RemoveWorkspace<G, M, RW, S, CW> = RemoveWorkspaceVersionStep<G, M, RW, S, CW>;
689 type MarkConsumed<G, M, RW, S, CW> = MarkChangesetsConsumedStep<G, M, RW, S, CW>;
690 type ClearConsumed<G, M, RW, S, CW> = ClearChangesetsConsumedStep<G, M, RW, S, CW>;
691 type DeleteChangesets<G, M, RW, S, CW> = DeleteChangesetFilesStep<G, M, RW, S, CW>;
692 type Stage<G, M, RW, S, CW> = StageFilesStep<G, M, RW, S, CW>;
693 type Commit<G, M, RW, S, CW> = CreateCommitStep<G, M, RW, S, CW>;
694 type Tags<G, M, RW, S, CW> = CreateTagsStep<G, M, RW, S, CW>;
695 type UpdateState<G, M, RW, S, CW> = UpdateReleaseStateStep<G, M, RW, S, CW>;
696
697 let saga = SagaBuilder::new()
698 .first_step(RestoreChangelogs::<G, M, RW, S, C>::new())
699 .then(WriteManifests::<G, M, RW, S, C>::new())
700 .then(RemoveWorkspace::<G, M, RW, S, C>::new())
701 .then(MarkConsumed::<G, M, RW, S, C>::new())
702 .then(ClearConsumed::<G, M, RW, S, C>::new())
703 .then(DeleteChangesets::<G, M, RW, S, C>::new())
704 .then(Stage::<G, M, RW, S, C>::new())
705 .then(Commit::<G, M, RW, S, C>::new(
706 git_config.commit_title_template().to_string(),
707 git_config.changes_in_body(),
708 ))
709 .then(Tags::<G, M, RW, S, C>::new(
710 git_config.tag_format(),
711 use_crate_prefix,
712 ))
713 .then(UpdateState::<G, M, RW, S, C>::new())
714 .build();
715
716 let saga_context = self.create_saga_context(&context.project.root);
717 saga.execute(&saga_context, saga_data).map_err(Into::into)
718 }
719
720 fn create_saga_context(&self, project_root: &Path) -> ReleaseSagaContext<G, M, RW, S, C> {
721 ReleaseSagaContext::new(
722 project_root.to_path_buf(),
723 Arc::clone(&self.git_provider),
724 Arc::clone(&self.manifest_writer),
725 Arc::clone(&self.changeset_io),
726 Arc::clone(&self.release_state_io),
727 Arc::new(self.changelog_writer.clone()),
728 )
729 }
730
731 fn build_cli_input(input: &ReleaseInput) -> ReleaseCliInput {
732 ReleaseCliInput {
733 cli_prerelease: input
734 .per_package_config
735 .iter()
736 .filter_map(|(name, config)| {
737 config
738 .prerelease
739 .as_ref()
740 .map(|spec| (name.clone(), spec.clone()))
741 })
742 .collect(),
743 global_prerelease: input.global_prerelease.clone(),
744 cli_graduate: input
745 .per_package_config
746 .iter()
747 .filter(|(_, config)| config.graduate_zero)
748 .map(|(name, _)| name.clone())
749 .collect(),
750 graduate_all: input.graduate_all,
751 }
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use crate::mocks::{
759 MockChangelogWriter, MockChangesetReader, MockGitProvider, MockManifestWriter,
760 MockProjectProvider, MockReleaseStateIO, make_changeset,
761 };
762 use changeset_core::BumpType;
763
764 fn default_input() -> ReleaseInput {
765 ReleaseInput {
766 dry_run: true,
767 convert_inherited: false,
768 no_commit: true,
769 no_tags: true,
770 keep_changesets: true,
771 force: false,
772 per_package_config: HashMap::new(),
773 global_prerelease: None,
774 graduate_all: false,
775 }
776 }
777
778 fn make_operation<P, RW, M>(
779 project_provider: P,
780 changeset_io: RW,
781 manifest_writer: M,
782 ) -> ReleaseOperation<P, RW, M, MockChangelogWriter, MockGitProvider, MockReleaseStateIO>
783 where
784 P: ProjectProvider,
785 RW: ChangesetReader + ChangesetWriter + Send + Sync + 'static,
786 M: ManifestWriter + Send + Sync + 'static,
787 {
788 ReleaseOperation::new(
789 project_provider,
790 changeset_io,
791 manifest_writer,
792 MockChangelogWriter::new(),
793 MockGitProvider::new(),
794 MockReleaseStateIO::new(),
795 )
796 }
797
798 #[test]
799 fn returns_no_changesets_when_empty() {
800 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
801 let changeset_reader = MockChangesetReader::new();
802 let manifest_writer = MockManifestWriter::new();
803
804 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
805
806 let result = operation
807 .execute(Path::new("/any"), &default_input())
808 .expect("execute failed");
809
810 assert!(matches!(result, ReleaseOutcome::NoChangesets));
811 }
812
813 #[test]
814 fn calculates_single_patch_bump() {
815 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
816 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix a bug");
817 let changeset_reader = MockChangesetReader::new()
818 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
819 let manifest_writer = MockManifestWriter::new();
820
821 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
822
823 let result = operation
824 .execute(Path::new("/any"), &default_input())
825 .expect("execute failed");
826
827 let ReleaseOutcome::DryRun(output) = result else {
828 panic!("expected DryRun outcome");
829 };
830
831 assert_eq!(output.planned_releases.len(), 1);
832 let release = &output.planned_releases[0];
833 assert_eq!(release.name, "my-crate");
834 assert_eq!(release.current_version.to_string(), "1.0.0");
835 assert_eq!(release.new_version.to_string(), "1.0.1");
836 assert_eq!(release.bump_type, BumpType::Patch);
837 }
838
839 #[test]
840 fn takes_maximum_bump_from_multiple_changesets() {
841 let project_provider = MockProjectProvider::single_package("my-crate", "1.2.3");
842 let changeset1 = make_changeset("my-crate", BumpType::Patch, "Fix bug");
843 let changeset2 = make_changeset("my-crate", BumpType::Minor, "Add feature");
844
845 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
846 (PathBuf::from(".changeset/changesets/fix.md"), changeset1),
847 (
848 PathBuf::from(".changeset/changesets/feature.md"),
849 changeset2,
850 ),
851 ]);
852 let manifest_writer = MockManifestWriter::new();
853
854 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
855
856 let result = operation
857 .execute(Path::new("/any"), &default_input())
858 .expect("execute failed");
859
860 let ReleaseOutcome::DryRun(output) = result else {
861 panic!("expected DryRun outcome");
862 };
863
864 assert_eq!(output.planned_releases.len(), 1);
865 let release = &output.planned_releases[0];
866 assert_eq!(release.new_version.to_string(), "1.3.0");
867 assert_eq!(release.bump_type, BumpType::Minor);
868 }
869
870 #[test]
871 fn handles_workspace_with_multiple_packages() {
872 let project_provider =
873 MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
874
875 let changeset1 = make_changeset("crate-a", BumpType::Minor, "Add feature to A");
876 let changeset2 = make_changeset("crate-b", BumpType::Major, "Breaking change in B");
877
878 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
879 (
880 PathBuf::from(".changeset/changesets/feature-a.md"),
881 changeset1,
882 ),
883 (
884 PathBuf::from(".changeset/changesets/breaking-b.md"),
885 changeset2,
886 ),
887 ]);
888 let manifest_writer = MockManifestWriter::new();
889
890 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
891
892 let result = operation
893 .execute(Path::new("/any"), &default_input())
894 .expect("execute failed");
895
896 let ReleaseOutcome::DryRun(output) = result else {
897 panic!("expected DryRun outcome");
898 };
899
900 assert_eq!(output.planned_releases.len(), 2);
901 assert!(output.unchanged_packages.is_empty());
902
903 let crate_a = output
904 .planned_releases
905 .iter()
906 .find(|r| r.name == "crate-a")
907 .expect("crate-a should be in releases");
908 assert_eq!(crate_a.new_version.to_string(), "1.1.0");
909
910 let crate_b = output
911 .planned_releases
912 .iter()
913 .find(|r| r.name == "crate-b")
914 .expect("crate-b should be in releases");
915 assert_eq!(crate_b.new_version.to_string(), "3.0.0");
916 }
917
918 #[test]
919 fn identifies_unchanged_packages() {
920 let project_provider = MockProjectProvider::workspace(vec![
921 ("crate-a", "1.0.0"),
922 ("crate-b", "2.0.0"),
923 ("crate-c", "3.0.0"),
924 ]);
925
926 let changeset = make_changeset("crate-a", BumpType::Patch, "Fix crate-a");
927 let changeset_reader = MockChangesetReader::new()
928 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
929 let manifest_writer = MockManifestWriter::new();
930
931 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
932
933 let result = operation
934 .execute(Path::new("/any"), &default_input())
935 .expect("execute failed");
936
937 let ReleaseOutcome::DryRun(output) = result else {
938 panic!("expected DryRun outcome");
939 };
940
941 assert_eq!(output.planned_releases.len(), 1);
942 assert_eq!(output.unchanged_packages.len(), 2);
943 assert!(output.unchanged_packages.contains(&"crate-b".to_string()));
944 assert!(output.unchanged_packages.contains(&"crate-c".to_string()));
945 }
946
947 #[test]
948 fn tracks_consumed_changeset_files() {
949 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
950 let changeset1 = make_changeset("my-crate", BumpType::Patch, "Fix 1");
951 let changeset2 = make_changeset("my-crate", BumpType::Patch, "Fix 2");
952
953 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
954 (PathBuf::from(".changeset/changesets/fix1.md"), changeset1),
955 (PathBuf::from(".changeset/changesets/fix2.md"), changeset2),
956 ]);
957 let manifest_writer = MockManifestWriter::new();
958
959 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
960
961 let result = operation
962 .execute(Path::new("/any"), &default_input())
963 .expect("execute failed");
964
965 let ReleaseOutcome::DryRun(output) = result else {
966 panic!("expected DryRun outcome");
967 };
968
969 assert_eq!(output.changesets_consumed.len(), 2);
970 }
971
972 #[test]
973 fn returns_executed_when_not_dry_run() {
974 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
975 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
976 let changeset_reader = MockChangesetReader::new()
977 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
978 let manifest_writer = MockManifestWriter::new();
979
980 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
981 let input = ReleaseInput {
982 dry_run: false,
983 convert_inherited: false,
984 no_commit: true,
985 no_tags: true,
986 keep_changesets: true,
987 force: false,
988 per_package_config: HashMap::new(),
989 global_prerelease: None,
990 graduate_all: false,
991 };
992
993 let result = operation
994 .execute(Path::new("/any"), &input)
995 .expect("execute failed");
996
997 assert!(matches!(result, ReleaseOutcome::Executed(_)));
998 }
999
1000 #[test]
1001 fn writes_versions_when_not_dry_run() {
1002 use std::sync::Arc;
1003
1004 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1005 let changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
1006 let changeset_reader = MockChangesetReader::new()
1007 .with_changeset(PathBuf::from(".changeset/changesets/feature.md"), changeset);
1008 let manifest_writer = Arc::new(MockManifestWriter::new());
1009
1010 let operation = ReleaseOperation::new(
1011 project_provider,
1012 changeset_reader,
1013 Arc::clone(&manifest_writer),
1014 MockChangelogWriter::new(),
1015 MockGitProvider::new(),
1016 MockReleaseStateIO::new(),
1017 );
1018 let input = ReleaseInput {
1019 dry_run: false,
1020 convert_inherited: false,
1021 no_commit: true,
1022 no_tags: true,
1023 keep_changesets: true,
1024 force: false,
1025 per_package_config: HashMap::new(),
1026 global_prerelease: None,
1027 graduate_all: false,
1028 };
1029
1030 let ReleaseOutcome::Executed(output) = operation
1031 .execute(Path::new("/any"), &input)
1032 .expect("execute failed")
1033 else {
1034 panic!("expected Executed outcome");
1035 };
1036
1037 assert_eq!(output.planned_releases.len(), 1);
1038 assert_eq!(output.planned_releases[0].new_version.to_string(), "1.1.0");
1039
1040 let written = manifest_writer.written_versions();
1041 assert_eq!(written.len(), 1);
1042 assert_eq!(written[0].0, PathBuf::from("/mock/project/Cargo.toml"));
1043 assert_eq!(written[0].1.to_string(), "1.1.0");
1044 }
1045
1046 #[test]
1047 fn returns_error_when_inherited_without_convert_flag() {
1048 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1049 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1050 let changeset_reader = MockChangesetReader::new()
1051 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1052 let manifest_writer = MockManifestWriter::new()
1053 .with_inherited(vec![PathBuf::from("/mock/project/Cargo.toml")]);
1054
1055 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
1056 let input = ReleaseInput {
1057 dry_run: false,
1058 convert_inherited: false,
1059 no_commit: true,
1060 no_tags: true,
1061 keep_changesets: true,
1062 force: false,
1063 per_package_config: HashMap::new(),
1064 global_prerelease: None,
1065 graduate_all: false,
1066 };
1067
1068 let result = operation.execute(Path::new("/any"), &input);
1069
1070 assert!(matches!(
1071 result,
1072 Err(OperationError::InheritedVersionsRequireConvert { .. })
1073 ));
1074 }
1075
1076 #[test]
1077 fn allows_inherited_with_convert_flag() {
1078 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1079 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1080 let changeset_reader = MockChangesetReader::new()
1081 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1082 let manifest_writer = MockManifestWriter::new()
1083 .with_inherited(vec![PathBuf::from("/mock/project/Cargo.toml")]);
1084
1085 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
1086 let input = ReleaseInput {
1087 dry_run: false,
1088 convert_inherited: true,
1089 no_commit: true,
1090 no_tags: true,
1091 keep_changesets: true,
1092 force: false,
1093 per_package_config: HashMap::new(),
1094 global_prerelease: None,
1095 graduate_all: false,
1096 };
1097
1098 let result = operation.execute(Path::new("/any"), &input);
1099
1100 assert!(result.is_ok());
1101 }
1102
1103 #[test]
1104 fn removes_workspace_version_when_converting_inherited() {
1105 use std::sync::Arc;
1106
1107 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1108 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1109 let changeset_reader = MockChangesetReader::new()
1110 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1111 let manifest_writer = Arc::new(
1112 MockManifestWriter::new()
1113 .with_inherited(vec![PathBuf::from("/mock/project/Cargo.toml")]),
1114 );
1115
1116 let operation = ReleaseOperation::new(
1117 project_provider,
1118 changeset_reader,
1119 Arc::clone(&manifest_writer),
1120 MockChangelogWriter::new(),
1121 MockGitProvider::new(),
1122 MockReleaseStateIO::new(),
1123 );
1124 let input = ReleaseInput {
1125 dry_run: false,
1126 convert_inherited: true,
1127 no_commit: true,
1128 no_tags: true,
1129 keep_changesets: true,
1130 force: false,
1131 per_package_config: HashMap::new(),
1132 global_prerelease: None,
1133 graduate_all: false,
1134 };
1135
1136 let ReleaseOutcome::Executed(_) = operation
1137 .execute(Path::new("/any"), &input)
1138 .expect("execute failed")
1139 else {
1140 panic!("expected Executed outcome");
1141 };
1142
1143 assert!(
1144 manifest_writer.workspace_version_removed(),
1145 "workspace version should be removed"
1146 );
1147 }
1148
1149 #[test]
1150 fn errors_on_dirty_working_tree_when_commit_enabled() {
1151 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1152 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1153 let changeset_reader = MockChangesetReader::new()
1154 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1155 let manifest_writer = MockManifestWriter::new();
1156 let git_provider = MockGitProvider::new().is_clean(false);
1157
1158 let operation = ReleaseOperation::new(
1159 project_provider,
1160 changeset_reader,
1161 manifest_writer,
1162 MockChangelogWriter::new(),
1163 git_provider,
1164 MockReleaseStateIO::new(),
1165 );
1166 let input = ReleaseInput {
1167 dry_run: false,
1168 convert_inherited: false,
1169 no_commit: false,
1170 no_tags: true,
1171 keep_changesets: true,
1172 force: false,
1173 per_package_config: HashMap::new(),
1174 global_prerelease: None,
1175 graduate_all: false,
1176 };
1177
1178 let result = operation.execute(Path::new("/any"), &input);
1179
1180 assert!(matches!(result, Err(OperationError::DirtyWorkingTree)));
1181 }
1182
1183 #[test]
1184 fn allows_dirty_tree_with_no_commit() {
1185 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1186 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1187 let changeset_reader = MockChangesetReader::new()
1188 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1189 let manifest_writer = MockManifestWriter::new();
1190 let git_provider = MockGitProvider::new().is_clean(false);
1191
1192 let operation = ReleaseOperation::new(
1193 project_provider,
1194 changeset_reader,
1195 manifest_writer,
1196 MockChangelogWriter::new(),
1197 git_provider,
1198 MockReleaseStateIO::new(),
1199 );
1200 let input = ReleaseInput {
1201 dry_run: false,
1202 convert_inherited: false,
1203 no_commit: true,
1204 no_tags: true,
1205 keep_changesets: true,
1206 force: false,
1207 per_package_config: HashMap::new(),
1208 global_prerelease: None,
1209 graduate_all: false,
1210 };
1211
1212 let result = operation.execute(Path::new("/any"), &input);
1213
1214 assert!(result.is_ok());
1215 }
1216
1217 #[test]
1218 fn allows_dirty_tree_in_dry_run() {
1219 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1220 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1221 let changeset_reader = MockChangesetReader::new()
1222 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1223 let manifest_writer = MockManifestWriter::new();
1224 let git_provider = MockGitProvider::new().is_clean(false);
1225
1226 let operation = ReleaseOperation::new(
1227 project_provider,
1228 changeset_reader,
1229 manifest_writer,
1230 MockChangelogWriter::new(),
1231 git_provider,
1232 MockReleaseStateIO::new(),
1233 );
1234 let input = ReleaseInput {
1235 dry_run: true,
1236 convert_inherited: false,
1237 no_commit: false,
1238 no_tags: false,
1239 keep_changesets: false,
1240 force: false,
1241 per_package_config: HashMap::new(),
1242 global_prerelease: None,
1243 graduate_all: false,
1244 };
1245
1246 let result = operation.execute(Path::new("/any"), &input);
1247
1248 assert!(result.is_ok());
1249 }
1250
1251 #[test]
1252 fn commit_message_uses_template() {
1253 use std::sync::Arc;
1254
1255 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1256 let changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
1257 let changeset_reader = MockChangesetReader::new()
1258 .with_changeset(PathBuf::from(".changeset/changesets/feature.md"), changeset);
1259 let manifest_writer = MockManifestWriter::new();
1260 let git_provider = Arc::new(MockGitProvider::new());
1261
1262 let operation = ReleaseOperation::new(
1263 project_provider,
1264 changeset_reader,
1265 manifest_writer,
1266 MockChangelogWriter::new(),
1267 Arc::clone(&git_provider),
1268 MockReleaseStateIO::new(),
1269 );
1270 let input = ReleaseInput {
1271 dry_run: false,
1272 convert_inherited: false,
1273 no_commit: false,
1274 no_tags: true,
1275 keep_changesets: true,
1276 force: false,
1277 per_package_config: HashMap::new(),
1278 global_prerelease: None,
1279 graduate_all: false,
1280 };
1281
1282 let ReleaseOutcome::Executed(output) = operation
1283 .execute(Path::new("/any"), &input)
1284 .expect("execute failed")
1285 else {
1286 panic!("expected Executed outcome");
1287 };
1288
1289 let git_result = output.git_result.expect("should have git result");
1290 let commit = git_result.commit.expect("should have commit");
1291 assert!(commit.message.contains("my-crate-v1.1.0"));
1292 assert!(commit.message.contains("my-crate 1.0.0 -> 1.1.0"));
1293 }
1294
1295 #[test]
1296 fn workspace_tags_use_crate_prefix() {
1297 use std::sync::Arc;
1298
1299 let project_provider =
1300 MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
1301 let changeset1 = make_changeset("crate-a", BumpType::Patch, "Fix A");
1302 let changeset2 = make_changeset("crate-b", BumpType::Patch, "Fix B");
1303 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
1304 (PathBuf::from(".changeset/changesets/fix-a.md"), changeset1),
1305 (PathBuf::from(".changeset/changesets/fix-b.md"), changeset2),
1306 ]);
1307 let manifest_writer = MockManifestWriter::new();
1308 let git_provider = Arc::new(MockGitProvider::new());
1309
1310 let operation = ReleaseOperation::new(
1311 project_provider,
1312 changeset_reader,
1313 manifest_writer,
1314 MockChangelogWriter::new(),
1315 Arc::clone(&git_provider),
1316 MockReleaseStateIO::new(),
1317 );
1318 let input = ReleaseInput {
1319 dry_run: false,
1320 convert_inherited: false,
1321 no_commit: false,
1322 no_tags: false,
1323 keep_changesets: true,
1324 force: false,
1325 per_package_config: HashMap::new(),
1326 global_prerelease: None,
1327 graduate_all: false,
1328 };
1329
1330 let ReleaseOutcome::Executed(output) = operation
1331 .execute(Path::new("/any"), &input)
1332 .expect("execute failed")
1333 else {
1334 panic!("expected Executed outcome");
1335 };
1336
1337 let git_result = output.git_result.expect("should have git result");
1338 assert_eq!(git_result.tags_created.len(), 2);
1339
1340 let tag_names: Vec<_> = git_result.tags_created.iter().map(|t| &t.name).collect();
1341 assert!(tag_names.contains(&&"crate-a-v1.0.1".to_string()));
1342 assert!(tag_names.contains(&&"crate-b-v2.0.1".to_string()));
1343 }
1344
1345 #[test]
1346 fn no_tags_skips_tag_creation() {
1347 use std::sync::Arc;
1348
1349 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1350 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1351 let changeset_reader = MockChangesetReader::new()
1352 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1353 let manifest_writer = MockManifestWriter::new();
1354 let git_provider = Arc::new(MockGitProvider::new());
1355
1356 let operation = ReleaseOperation::new(
1357 project_provider,
1358 changeset_reader,
1359 manifest_writer,
1360 MockChangelogWriter::new(),
1361 Arc::clone(&git_provider),
1362 MockReleaseStateIO::new(),
1363 );
1364 let input = ReleaseInput {
1365 dry_run: false,
1366 convert_inherited: false,
1367 no_commit: false,
1368 no_tags: true,
1369 keep_changesets: true,
1370 force: false,
1371 per_package_config: HashMap::new(),
1372 global_prerelease: None,
1373 graduate_all: false,
1374 };
1375
1376 let ReleaseOutcome::Executed(output) = operation
1377 .execute(Path::new("/any"), &input)
1378 .expect("execute failed")
1379 else {
1380 panic!("expected Executed outcome");
1381 };
1382
1383 let git_result = output.git_result.expect("should have git result");
1384 assert!(git_result.tags_created.is_empty());
1385 assert!(git_result.commit.is_some());
1386 }
1387
1388 #[test]
1389 fn single_package_uses_version_only_tag_format() {
1390 use std::sync::Arc;
1391
1392 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1393 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1394 let changeset_reader = MockChangesetReader::new()
1395 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1396 let manifest_writer = MockManifestWriter::new();
1397 let git_provider = Arc::new(MockGitProvider::new());
1398
1399 let operation = ReleaseOperation::new(
1400 project_provider,
1401 changeset_reader,
1402 manifest_writer,
1403 MockChangelogWriter::new(),
1404 Arc::clone(&git_provider),
1405 MockReleaseStateIO::new(),
1406 );
1407 let input = ReleaseInput {
1408 dry_run: false,
1409 convert_inherited: false,
1410 no_commit: false,
1411 no_tags: false,
1412 keep_changesets: true,
1413 force: false,
1414 per_package_config: HashMap::new(),
1415 global_prerelease: None,
1416 graduate_all: false,
1417 };
1418
1419 let ReleaseOutcome::Executed(output) = operation
1420 .execute(Path::new("/any"), &input)
1421 .expect("execute failed")
1422 else {
1423 panic!("expected Executed outcome");
1424 };
1425
1426 let git_result = output.git_result.expect("should have git result");
1427 assert_eq!(git_result.tags_created.len(), 1);
1428 assert_eq!(
1429 git_result.tags_created[0].name, "v1.0.1",
1430 "single package should use version-only tag format without crate prefix"
1431 );
1432 }
1433
1434 #[test]
1435 fn keep_changesets_false_populates_deleted_list() {
1436 use std::sync::Arc;
1437
1438 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1439 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1440 let changeset_reader = MockChangesetReader::new()
1441 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1442 let manifest_writer = MockManifestWriter::new();
1443 let git_provider = Arc::new(MockGitProvider::new());
1444
1445 let operation = ReleaseOperation::new(
1446 project_provider,
1447 changeset_reader,
1448 manifest_writer,
1449 MockChangelogWriter::new(),
1450 Arc::clone(&git_provider),
1451 MockReleaseStateIO::new(),
1452 );
1453 let input = ReleaseInput {
1454 dry_run: false,
1455 convert_inherited: false,
1456 no_commit: true,
1457 no_tags: true,
1458 keep_changesets: false,
1459 force: false,
1460 per_package_config: HashMap::new(),
1461 global_prerelease: None,
1462 graduate_all: false,
1463 };
1464
1465 let ReleaseOutcome::Executed(output) = operation
1466 .execute(Path::new("/any"), &input)
1467 .expect("execute failed")
1468 else {
1469 panic!("expected Executed outcome");
1470 };
1471
1472 let git_result = output.git_result.expect("should have git result");
1473 assert_eq!(git_result.changesets_deleted.len(), 1);
1474 assert_eq!(
1475 git_result.changesets_deleted[0],
1476 PathBuf::from(".changeset/changesets/fix.md")
1477 );
1478 }
1479
1480 #[test]
1481 fn keep_changesets_true_leaves_deleted_list_empty() {
1482 use std::sync::Arc;
1483
1484 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1485 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix");
1486 let changeset_reader = MockChangesetReader::new()
1487 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
1488 let manifest_writer = MockManifestWriter::new();
1489 let git_provider = Arc::new(MockGitProvider::new());
1490
1491 let operation = ReleaseOperation::new(
1492 project_provider,
1493 changeset_reader,
1494 manifest_writer,
1495 MockChangelogWriter::new(),
1496 Arc::clone(&git_provider),
1497 MockReleaseStateIO::new(),
1498 );
1499 let input = ReleaseInput {
1500 dry_run: false,
1501 convert_inherited: false,
1502 no_commit: true,
1503 no_tags: true,
1504 keep_changesets: true,
1505 force: false,
1506 per_package_config: HashMap::new(),
1507 global_prerelease: None,
1508 graduate_all: false,
1509 };
1510
1511 let ReleaseOutcome::Executed(output) = operation
1512 .execute(Path::new("/any"), &input)
1513 .expect("execute failed")
1514 else {
1515 panic!("expected Executed outcome");
1516 };
1517
1518 let git_result = output.git_result.expect("should have git result");
1519 assert!(
1520 git_result.changesets_deleted.is_empty(),
1521 "changesets_deleted should be empty when keep_changesets is true"
1522 );
1523 }
1524
1525 #[test]
1526 fn deleted_changesets_are_staged_for_commit() {
1527 use std::sync::Arc;
1528
1529 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1530 let changeset1 = make_changeset("my-crate", BumpType::Patch, "Fix 1");
1531 let changeset2 = make_changeset("my-crate", BumpType::Patch, "Fix 2");
1532 let changeset_reader = MockChangesetReader::new().with_changesets(vec![
1533 (PathBuf::from(".changeset/changesets/fix1.md"), changeset1),
1534 (PathBuf::from(".changeset/changesets/fix2.md"), changeset2),
1535 ]);
1536 let manifest_writer = MockManifestWriter::new();
1537 let git_provider = Arc::new(MockGitProvider::new());
1538
1539 let operation = ReleaseOperation::new(
1540 project_provider,
1541 changeset_reader,
1542 manifest_writer,
1543 MockChangelogWriter::new(),
1544 Arc::clone(&git_provider),
1545 MockReleaseStateIO::new(),
1546 );
1547 let input = ReleaseInput {
1548 dry_run: false,
1549 convert_inherited: false,
1550 no_commit: false,
1551 no_tags: true,
1552 keep_changesets: false,
1553 force: false,
1554 per_package_config: HashMap::new(),
1555 global_prerelease: None,
1556 graduate_all: false,
1557 };
1558
1559 let _ = operation
1560 .execute(Path::new("/any"), &input)
1561 .expect("execute failed");
1562
1563 let staged = git_provider.staged_files();
1564 assert!(
1565 staged.contains(&PathBuf::from(".changeset/changesets/fix1.md")),
1566 "fix1.md should be staged"
1567 );
1568 assert!(
1569 staged.contains(&PathBuf::from(".changeset/changesets/fix2.md")),
1570 "fix2.md should be staged"
1571 );
1572 }
1573
1574 #[test]
1575 fn changes_in_body_false_produces_title_only_commit() {
1576 use changeset_project::{GitConfig, RootChangesetConfig};
1577 use std::sync::Arc;
1578
1579 let custom_config = RootChangesetConfig::default()
1580 .with_git_config(GitConfig::default().with_changes_in_body(false));
1581 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
1582 .with_root_config(custom_config);
1583 let changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
1584 let changeset_reader = MockChangesetReader::new()
1585 .with_changeset(PathBuf::from(".changeset/changesets/feature.md"), changeset);
1586 let manifest_writer = MockManifestWriter::new();
1587 let git_provider = Arc::new(MockGitProvider::new());
1588
1589 let operation = ReleaseOperation::new(
1590 project_provider,
1591 changeset_reader,
1592 manifest_writer,
1593 MockChangelogWriter::new(),
1594 Arc::clone(&git_provider),
1595 MockReleaseStateIO::new(),
1596 );
1597 let input = ReleaseInput {
1598 dry_run: false,
1599 convert_inherited: false,
1600 no_commit: false,
1601 no_tags: true,
1602 keep_changesets: true,
1603 force: false,
1604 per_package_config: HashMap::new(),
1605 global_prerelease: None,
1606 graduate_all: false,
1607 };
1608
1609 let ReleaseOutcome::Executed(output) = operation
1610 .execute(Path::new("/any"), &input)
1611 .expect("execute failed")
1612 else {
1613 panic!("expected Executed outcome");
1614 };
1615
1616 let git_result = output.git_result.expect("should have git result");
1617 let commit = git_result.commit.expect("should have commit");
1618 assert!(
1619 !commit.message.contains('\n'),
1620 "commit message should be title-only without newlines, got: {}",
1621 commit.message
1622 );
1623 assert!(
1624 commit.message.contains("my-crate-v1.1.0"),
1625 "commit message should contain version info"
1626 );
1627 }
1628
1629 #[test]
1630 fn prerelease_marks_changesets_as_consumed() {
1631 use std::sync::Arc;
1632
1633 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1634 let changeset_path = PathBuf::from(".changeset/changesets/fix.md");
1635 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
1636 let changeset_reader =
1637 Arc::new(MockChangesetReader::new().with_changeset(changeset_path.clone(), changeset));
1638 let manifest_writer = MockManifestWriter::new();
1639
1640 let operation = ReleaseOperation::new(
1641 project_provider,
1642 Arc::clone(&changeset_reader),
1643 manifest_writer,
1644 MockChangelogWriter::new(),
1645 MockGitProvider::new(),
1646 MockReleaseStateIO::new(),
1647 );
1648 let input = ReleaseInput {
1649 dry_run: false,
1650 convert_inherited: false,
1651 no_commit: true,
1652 no_tags: true,
1653 keep_changesets: true,
1654 force: false,
1655 per_package_config: HashMap::new(),
1656 global_prerelease: Some(PrereleaseSpec::Alpha),
1657 graduate_all: false,
1658 };
1659
1660 let result = operation
1661 .execute(Path::new("/any"), &input)
1662 .expect("execute should succeed");
1663
1664 assert!(matches!(result, ReleaseOutcome::Executed(_)));
1665
1666 let consumed_status = changeset_reader.get_consumed_status(&changeset_path);
1667 assert!(
1668 consumed_status.is_some(),
1669 "changeset should be marked as consumed for prerelease"
1670 );
1671 assert!(
1672 consumed_status.expect("checked above").contains("alpha"),
1673 "consumed version should contain alpha prerelease tag"
1674 );
1675 }
1676
1677 #[test]
1678 fn prerelease_increment_requires_changesets_or_force() {
1679 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1680 let changeset_reader = MockChangesetReader::new();
1681 let manifest_writer = MockManifestWriter::new();
1682
1683 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
1684 let input = ReleaseInput {
1685 dry_run: false,
1686 convert_inherited: false,
1687 no_commit: true,
1688 no_tags: true,
1689 keep_changesets: true,
1690 force: false,
1691 per_package_config: HashMap::new(),
1692 global_prerelease: Some(PrereleaseSpec::Alpha),
1693 graduate_all: false,
1694 };
1695
1696 let result = operation.execute(Path::new("/any"), &input);
1697
1698 assert!(
1699 matches!(result, Err(OperationError::NoChangesetsWithoutForce)),
1700 "should error without changesets and without force flag"
1701 );
1702 }
1703
1704 #[test]
1705 fn prerelease_with_force_returns_no_changesets() {
1706 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1707 let changeset_reader = MockChangesetReader::new();
1708 let manifest_writer = MockManifestWriter::new();
1709
1710 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
1711 let input = ReleaseInput {
1712 dry_run: false,
1713 convert_inherited: false,
1714 no_commit: true,
1715 no_tags: true,
1716 keep_changesets: true,
1717 force: true,
1718 per_package_config: HashMap::new(),
1719 global_prerelease: Some(PrereleaseSpec::Alpha),
1720 graduate_all: false,
1721 };
1722
1723 let result = operation
1724 .execute(Path::new("/any"), &input)
1725 .expect("execute should succeed with force flag");
1726
1727 assert!(
1728 matches!(result, ReleaseOutcome::NoChangesets),
1729 "should return NoChangesets when force is set but no changesets exist"
1730 );
1731 }
1732
1733 #[test]
1734 fn graduation_clears_consumed_flag() {
1735 use std::sync::Arc;
1736
1737 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.1-alpha.1");
1738 let consumed_path = PathBuf::from(".changeset/changesets/consumed.md");
1739 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
1740 let changeset_reader = Arc::new(MockChangesetReader::new().with_consumed_changeset(
1741 consumed_path.clone(),
1742 changeset,
1743 "1.0.1-alpha.1".to_string(),
1744 ));
1745 let manifest_writer = MockManifestWriter::new();
1746
1747 let operation = ReleaseOperation::new(
1748 project_provider,
1749 Arc::clone(&changeset_reader),
1750 manifest_writer,
1751 MockChangelogWriter::new(),
1752 MockGitProvider::new(),
1753 MockReleaseStateIO::new(),
1754 );
1755 let input = ReleaseInput {
1756 dry_run: false,
1757 convert_inherited: false,
1758 no_commit: true,
1759 no_tags: true,
1760 keep_changesets: true,
1761 force: false,
1762 per_package_config: HashMap::new(),
1763 global_prerelease: None,
1764 graduate_all: false,
1765 };
1766
1767 let result = operation
1768 .execute(Path::new("/any"), &input)
1769 .expect("graduation should succeed");
1770
1771 assert!(matches!(result, ReleaseOutcome::Executed(_)));
1772
1773 let consumed_status = changeset_reader.get_consumed_status(&consumed_path);
1774 assert!(
1775 consumed_status.is_none(),
1776 "consumed flag should be cleared after graduation"
1777 );
1778 }
1779
1780 #[test]
1781 fn graduation_aggregates_consumed_changesets_in_changelog() {
1782 use std::sync::Arc;
1783
1784 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.1-alpha.1");
1785 let consumed_path1 = PathBuf::from(".changeset/changesets/fix1.md");
1786 let consumed_path2 = PathBuf::from(".changeset/changesets/fix2.md");
1787 let changeset1 = make_changeset("my-crate", BumpType::Patch, "Fix bug one");
1788 let changeset2 = make_changeset("my-crate", BumpType::Patch, "Fix bug two");
1789
1790 let changeset_reader = Arc::new(
1791 MockChangesetReader::new()
1792 .with_consumed_changeset(consumed_path1, changeset1, "1.0.1-alpha.1".to_string())
1793 .with_consumed_changeset(consumed_path2, changeset2, "1.0.1-alpha.1".to_string()),
1794 );
1795 let manifest_writer = MockManifestWriter::new();
1796 let changelog_writer = Arc::new(MockChangelogWriter::new());
1797
1798 let operation = ReleaseOperation::new(
1799 project_provider,
1800 Arc::clone(&changeset_reader),
1801 manifest_writer,
1802 Arc::clone(&changelog_writer),
1803 MockGitProvider::new(),
1804 MockReleaseStateIO::new(),
1805 );
1806 let input = ReleaseInput {
1807 dry_run: false,
1808 convert_inherited: false,
1809 no_commit: true,
1810 no_tags: true,
1811 keep_changesets: true,
1812 force: false,
1813 per_package_config: HashMap::new(),
1814 global_prerelease: None,
1815 graduate_all: false,
1816 };
1817
1818 let result = operation
1819 .execute(Path::new("/any"), &input)
1820 .expect("graduation should succeed");
1821
1822 assert!(matches!(result, ReleaseOutcome::Executed(_)));
1823
1824 let written = changelog_writer.written_releases();
1825 assert_eq!(written.len(), 1, "should write one changelog release");
1826
1827 let (_, release) = &written[0];
1828 assert_eq!(
1829 release.entries.len(),
1830 2,
1831 "changelog should contain entries from both consumed changesets"
1832 );
1833 }
1834
1835 #[test]
1836 fn consumed_changesets_excluded_from_normal_release() {
1837 use std::sync::Arc;
1838
1839 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
1840 let unconsumed_path = PathBuf::from(".changeset/changesets/unconsumed.md");
1841 let consumed_path = PathBuf::from(".changeset/changesets/consumed.md");
1842 let unconsumed_changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
1843 let consumed_changeset = make_changeset("my-crate", BumpType::Patch, "Fix from prerelease");
1844
1845 let changeset_reader = Arc::new(
1846 MockChangesetReader::new()
1847 .with_changeset(unconsumed_path.clone(), unconsumed_changeset)
1848 .with_consumed_changeset(
1849 consumed_path.clone(),
1850 consumed_changeset,
1851 "1.0.1-alpha.1".to_string(),
1852 ),
1853 );
1854 let manifest_writer = Arc::new(MockManifestWriter::new());
1855
1856 let operation = ReleaseOperation::new(
1857 project_provider,
1858 Arc::clone(&changeset_reader),
1859 Arc::clone(&manifest_writer),
1860 MockChangelogWriter::new(),
1861 MockGitProvider::new(),
1862 MockReleaseStateIO::new(),
1863 );
1864 let input = ReleaseInput {
1865 dry_run: false,
1866 convert_inherited: false,
1867 no_commit: true,
1868 no_tags: true,
1869 keep_changesets: true,
1870 force: false,
1871 per_package_config: HashMap::new(),
1872 global_prerelease: None,
1873 graduate_all: false,
1874 };
1875
1876 let result = operation
1877 .execute(Path::new("/any"), &input)
1878 .expect("release should succeed");
1879
1880 let ReleaseOutcome::Executed(output) = result else {
1881 panic!("expected Executed outcome");
1882 };
1883
1884 assert_eq!(output.planned_releases.len(), 1);
1885 assert_eq!(
1886 output.planned_releases[0].new_version.to_string(),
1887 "1.1.0",
1888 "should apply minor bump from unconsumed changeset only"
1889 );
1890
1891 assert_eq!(
1892 output.changesets_consumed.len(),
1893 1,
1894 "only unconsumed changeset should be in consumed list"
1895 );
1896 assert!(
1897 output.changesets_consumed.contains(&unconsumed_path),
1898 "unconsumed changeset should be processed"
1899 );
1900 }
1901
1902 #[test]
1903 fn prerelease_with_different_tag_resets_number() {
1904 use std::sync::Arc;
1905
1906 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.1-alpha.2");
1907 let changeset_path = PathBuf::from(".changeset/changesets/feature.md");
1908 let changeset = make_changeset("my-crate", BumpType::Patch, "Another fix");
1909 let changeset_reader =
1910 Arc::new(MockChangesetReader::new().with_changeset(changeset_path, changeset));
1911 let manifest_writer = Arc::new(MockManifestWriter::new());
1912
1913 let operation = ReleaseOperation::new(
1914 project_provider,
1915 Arc::clone(&changeset_reader),
1916 Arc::clone(&manifest_writer),
1917 MockChangelogWriter::new(),
1918 MockGitProvider::new(),
1919 MockReleaseStateIO::new(),
1920 );
1921 let input = ReleaseInput {
1922 dry_run: false,
1923 convert_inherited: false,
1924 no_commit: true,
1925 no_tags: true,
1926 keep_changesets: true,
1927 force: false,
1928 per_package_config: HashMap::new(),
1929 global_prerelease: Some(PrereleaseSpec::Beta),
1930 graduate_all: false,
1931 };
1932
1933 let result = operation
1934 .execute(Path::new("/any"), &input)
1935 .expect("prerelease with different tag should succeed");
1936
1937 let ReleaseOutcome::Executed(output) = result else {
1938 panic!("expected Executed outcome");
1939 };
1940
1941 assert_eq!(output.planned_releases.len(), 1);
1942 assert_eq!(
1943 output.planned_releases[0].new_version.to_string(),
1944 "1.0.1-beta.1",
1945 "switching prerelease tag should reset number to 1"
1946 );
1947 }
1948
1949 #[test]
1950 fn zero_graduation_deletes_changesets() {
1951 use std::sync::Arc;
1952
1953 let project_provider = MockProjectProvider::single_package("my-crate", "0.5.0");
1954 let changeset_path = PathBuf::from(".changeset/changesets/feature.md");
1955 let changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
1956 let changeset_reader =
1957 Arc::new(MockChangesetReader::new().with_changeset(changeset_path.clone(), changeset));
1958 let manifest_writer = MockManifestWriter::new();
1959 let git_provider = Arc::new(MockGitProvider::new());
1960
1961 let operation = ReleaseOperation::new(
1962 project_provider,
1963 Arc::clone(&changeset_reader),
1964 manifest_writer,
1965 MockChangelogWriter::new(),
1966 Arc::clone(&git_provider),
1967 MockReleaseStateIO::new(),
1968 );
1969 let input = ReleaseInput {
1970 dry_run: false,
1971 convert_inherited: false,
1972 no_commit: true,
1973 no_tags: true,
1974 keep_changesets: false,
1975 force: false,
1976 per_package_config: HashMap::new(),
1977 global_prerelease: None,
1978 graduate_all: true,
1979 };
1980
1981 let ReleaseOutcome::Executed(output) = operation
1982 .execute(Path::new("/any"), &input)
1983 .expect("zero graduation should succeed")
1984 else {
1985 panic!("expected Executed outcome");
1986 };
1987
1988 assert_eq!(
1989 output.planned_releases[0].new_version.to_string(),
1990 "1.0.0",
1991 "zero graduation should bump to 1.0.0"
1992 );
1993
1994 let git_result = output.git_result.expect("should have git result");
1995 assert_eq!(
1996 git_result.changesets_deleted.len(),
1997 1,
1998 "zero graduation should delete changesets"
1999 );
2000 assert!(
2001 git_result.changesets_deleted.contains(&changeset_path),
2002 "deleted list should contain the changeset file"
2003 );
2004
2005 let deleted_files = git_provider.deleted_files();
2006 assert!(
2007 deleted_files.contains(&changeset_path),
2008 "changeset file should be deleted via git provider"
2009 );
2010 }
2011
2012 #[test]
2013 fn prerelease_graduation_preserves_changesets() {
2014 use std::sync::Arc;
2015
2016 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.1-alpha.1");
2017 let consumed_path = PathBuf::from(".changeset/changesets/consumed.md");
2018 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
2019 let changeset_reader = Arc::new(MockChangesetReader::new().with_consumed_changeset(
2020 consumed_path.clone(),
2021 changeset,
2022 "1.0.1-alpha.1".to_string(),
2023 ));
2024 let manifest_writer = MockManifestWriter::new();
2025 let git_provider = Arc::new(MockGitProvider::new());
2026
2027 let operation = ReleaseOperation::new(
2028 project_provider,
2029 Arc::clone(&changeset_reader),
2030 manifest_writer,
2031 MockChangelogWriter::new(),
2032 Arc::clone(&git_provider),
2033 MockReleaseStateIO::new(),
2034 );
2035 let input = ReleaseInput {
2036 dry_run: false,
2037 convert_inherited: false,
2038 no_commit: true,
2039 no_tags: true,
2040 keep_changesets: false,
2041 force: false,
2042 per_package_config: HashMap::new(),
2043 global_prerelease: None,
2044 graduate_all: false,
2045 };
2046
2047 let ReleaseOutcome::Executed(output) = operation
2048 .execute(Path::new("/any"), &input)
2049 .expect("prerelease graduation should succeed")
2050 else {
2051 panic!("expected Executed outcome");
2052 };
2053
2054 assert_eq!(
2055 output.planned_releases[0].new_version.to_string(),
2056 "1.0.1",
2057 "prerelease graduation should remove prerelease suffix"
2058 );
2059
2060 let git_result = output.git_result.expect("should have git result");
2061 assert!(
2062 git_result.changesets_deleted.is_empty(),
2063 "prerelease graduation should NOT delete changesets (they were already consumed)"
2064 );
2065
2066 let deleted_files = git_provider.deleted_files();
2067 assert!(
2068 deleted_files.is_empty(),
2069 "no files should be deleted during prerelease graduation"
2070 );
2071 }
2072
2073 #[test]
2074 fn release_respects_prerelease_toml_state() {
2075 use changeset_project::PrereleaseState;
2076 use std::sync::Arc;
2077
2078 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
2079 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
2080 let changeset_reader = MockChangesetReader::new()
2081 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
2082 let manifest_writer = MockManifestWriter::new();
2083
2084 let mut prerelease_state = PrereleaseState::new();
2085 prerelease_state.insert("my-crate".to_string(), "alpha".to_string());
2086 let release_state_io =
2087 Arc::new(MockReleaseStateIO::new().with_prerelease_state(prerelease_state));
2088
2089 let operation = ReleaseOperation::new(
2090 project_provider,
2091 changeset_reader,
2092 manifest_writer,
2093 MockChangelogWriter::new(),
2094 MockGitProvider::new(),
2095 Arc::clone(&release_state_io),
2096 );
2097 let input = ReleaseInput {
2098 dry_run: false,
2099 convert_inherited: false,
2100 no_commit: true,
2101 no_tags: true,
2102 keep_changesets: true,
2103 force: false,
2104 per_package_config: HashMap::new(),
2105 global_prerelease: None,
2106 graduate_all: false,
2107 };
2108
2109 let ReleaseOutcome::Executed(output) = operation
2110 .execute(Path::new("/any"), &input)
2111 .expect("release should succeed")
2112 else {
2113 panic!("expected Executed outcome");
2114 };
2115
2116 assert_eq!(output.planned_releases.len(), 1);
2117 assert_eq!(
2118 output.planned_releases[0].new_version.to_string(),
2119 "1.0.1-alpha.1",
2120 "should apply prerelease from TOML state"
2121 );
2122 }
2123
2124 #[test]
2125 fn cli_prerelease_overrides_toml_state() {
2126 use changeset_project::PrereleaseState;
2127 use std::sync::Arc;
2128
2129 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
2130 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
2131 let changeset_reader = MockChangesetReader::new()
2132 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
2133 let manifest_writer = MockManifestWriter::new();
2134
2135 let mut prerelease_state = PrereleaseState::new();
2136 prerelease_state.insert("my-crate".to_string(), "alpha".to_string());
2137 let release_state_io =
2138 Arc::new(MockReleaseStateIO::new().with_prerelease_state(prerelease_state));
2139
2140 let operation = ReleaseOperation::new(
2141 project_provider,
2142 changeset_reader,
2143 manifest_writer,
2144 MockChangelogWriter::new(),
2145 MockGitProvider::new(),
2146 Arc::clone(&release_state_io),
2147 );
2148 let input = ReleaseInput {
2149 dry_run: false,
2150 convert_inherited: false,
2151 no_commit: true,
2152 no_tags: true,
2153 keep_changesets: true,
2154 force: false,
2155 per_package_config: HashMap::new(),
2156 global_prerelease: Some(PrereleaseSpec::Beta),
2157 graduate_all: false,
2158 };
2159
2160 let ReleaseOutcome::Executed(output) = operation
2161 .execute(Path::new("/any"), &input)
2162 .expect("release should succeed")
2163 else {
2164 panic!("expected Executed outcome");
2165 };
2166
2167 assert_eq!(output.planned_releases.len(), 1);
2168 assert_eq!(
2169 output.planned_releases[0].new_version.to_string(),
2170 "1.0.1-beta.1",
2171 "CLI prerelease should override TOML state"
2172 );
2173 }
2174
2175 #[test]
2176 fn graduation_state_updates_after_release() {
2177 use changeset_project::GraduationState;
2178 use std::sync::Arc;
2179
2180 let project_provider = MockProjectProvider::single_package("my-crate", "0.5.0");
2181 let changeset = make_changeset("my-crate", BumpType::Minor, "Add feature");
2182 let changeset_reader = MockChangesetReader::new()
2183 .with_changeset(PathBuf::from(".changeset/changesets/feature.md"), changeset);
2184 let manifest_writer = MockManifestWriter::new();
2185
2186 let mut graduation_state = GraduationState::new();
2187 graduation_state.add("my-crate".to_string());
2188 let release_state_io =
2189 Arc::new(MockReleaseStateIO::new().with_graduation_state(graduation_state));
2190
2191 let operation = ReleaseOperation::new(
2192 project_provider,
2193 changeset_reader,
2194 manifest_writer,
2195 MockChangelogWriter::new(),
2196 MockGitProvider::new(),
2197 Arc::clone(&release_state_io),
2198 );
2199 let input = ReleaseInput {
2200 dry_run: false,
2201 convert_inherited: false,
2202 no_commit: true,
2203 no_tags: true,
2204 keep_changesets: true,
2205 force: false,
2206 per_package_config: HashMap::new(),
2207 global_prerelease: None,
2208 graduate_all: false,
2209 };
2210
2211 let ReleaseOutcome::Executed(output) = operation
2212 .execute(Path::new("/any"), &input)
2213 .expect("release should succeed")
2214 else {
2215 panic!("expected Executed outcome");
2216 };
2217
2218 assert_eq!(output.planned_releases.len(), 1);
2219 assert_eq!(
2220 output.planned_releases[0].new_version.to_string(),
2221 "1.0.0",
2222 "should graduate from 0.x to 1.0.0"
2223 );
2224
2225 let updated_state = release_state_io.get_graduation_state();
2226 assert!(
2227 updated_state.is_none() || !updated_state.expect("state").contains("my-crate"),
2228 "graduated package should be removed from graduation state"
2229 );
2230 }
2231
2232 #[test]
2233 fn graduate_all_flag_graduates_zero_versions() {
2234 let project_provider = MockProjectProvider::single_package("my-crate", "0.5.0");
2235 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
2236 let changeset_reader = MockChangesetReader::new()
2237 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
2238 let manifest_writer = MockManifestWriter::new();
2239
2240 let operation = make_operation(project_provider, changeset_reader, manifest_writer);
2241 let input = ReleaseInput {
2242 dry_run: false,
2243 convert_inherited: false,
2244 no_commit: true,
2245 no_tags: true,
2246 keep_changesets: true,
2247 force: false,
2248 per_package_config: HashMap::new(),
2249 global_prerelease: None,
2250 graduate_all: true,
2251 };
2252
2253 let ReleaseOutcome::Executed(output) = operation
2254 .execute(Path::new("/any"), &input)
2255 .expect("release should succeed")
2256 else {
2257 panic!("expected Executed outcome");
2258 };
2259
2260 assert_eq!(output.planned_releases.len(), 1);
2261 assert_eq!(
2262 output.planned_releases[0].new_version.to_string(),
2263 "1.0.0",
2264 "graduate_all should promote 0.x to 1.0.0"
2265 );
2266 }
2267
2268 #[test]
2269 fn prerelease_state_saved_after_normal_release() {
2270 use changeset_project::PrereleaseState;
2271 use std::sync::Arc;
2272
2273 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
2274 let changeset_path = PathBuf::from(".changeset/changesets/fix.md");
2275 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
2276 let changeset_reader =
2277 Arc::new(MockChangesetReader::new().with_changeset(changeset_path, changeset));
2278 let manifest_writer = MockManifestWriter::new();
2279
2280 let mut prerelease_state = PrereleaseState::new();
2281 prerelease_state.insert("other-crate".to_string(), "beta".to_string());
2282 let release_state_io =
2283 Arc::new(MockReleaseStateIO::new().with_prerelease_state(prerelease_state));
2284
2285 let operation = ReleaseOperation::new(
2286 project_provider,
2287 Arc::clone(&changeset_reader),
2288 manifest_writer,
2289 MockChangelogWriter::new(),
2290 MockGitProvider::new(),
2291 Arc::clone(&release_state_io),
2292 );
2293 let input = ReleaseInput {
2294 dry_run: false,
2295 convert_inherited: false,
2296 no_commit: true,
2297 no_tags: true,
2298 keep_changesets: true,
2299 force: false,
2300 per_package_config: HashMap::new(),
2301 global_prerelease: None,
2302 graduate_all: false,
2303 };
2304
2305 let ReleaseOutcome::Executed(output) = operation
2306 .execute(Path::new("/any"), &input)
2307 .expect("release should succeed")
2308 else {
2309 panic!("expected Executed outcome");
2310 };
2311
2312 assert_eq!(output.planned_releases.len(), 1);
2313 assert_eq!(
2314 output.planned_releases[0].new_version.to_string(),
2315 "1.0.1",
2316 "should bump patch version"
2317 );
2318
2319 let updated_state = release_state_io.get_prerelease_state();
2320 assert!(
2321 updated_state
2322 .as_ref()
2323 .is_some_and(|s| s.contains("other-crate")),
2324 "unrelated packages should remain in prerelease state after release"
2325 );
2326 }
2327
2328 #[test]
2329 fn prerelease_graduation_removes_package_from_state_if_present() {
2330 use std::sync::Arc;
2331
2332 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0-alpha.1");
2333 let consumed_path = PathBuf::from(".changeset/changesets/fix.md");
2334 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix bug");
2335 let changeset_reader = Arc::new(MockChangesetReader::new().with_consumed_changeset(
2336 consumed_path,
2337 changeset,
2338 "1.0.0-alpha.1".to_string(),
2339 ));
2340 let manifest_writer = MockManifestWriter::new();
2341
2342 let release_state_io = Arc::new(MockReleaseStateIO::new());
2343
2344 let operation = ReleaseOperation::new(
2345 project_provider,
2346 Arc::clone(&changeset_reader),
2347 manifest_writer,
2348 MockChangelogWriter::new(),
2349 MockGitProvider::new(),
2350 Arc::clone(&release_state_io),
2351 );
2352 let input = ReleaseInput {
2353 dry_run: false,
2354 convert_inherited: false,
2355 no_commit: true,
2356 no_tags: true,
2357 keep_changesets: true,
2358 force: false,
2359 per_package_config: HashMap::new(),
2360 global_prerelease: None,
2361 graduate_all: false,
2362 };
2363
2364 let ReleaseOutcome::Executed(output) = operation
2365 .execute(Path::new("/any"), &input)
2366 .expect("graduation should succeed")
2367 else {
2368 panic!("expected Executed outcome");
2369 };
2370
2371 assert_eq!(output.planned_releases.len(), 1);
2372 assert_eq!(
2373 output.planned_releases[0].new_version.to_string(),
2374 "1.0.0",
2375 "should graduate from prerelease to stable"
2376 );
2377 assert!(
2378 changeset_version::is_prerelease(&output.planned_releases[0].current_version),
2379 "current version should have been a prerelease"
2380 );
2381 assert!(
2382 !changeset_version::is_prerelease(&output.planned_releases[0].new_version),
2383 "new version should be stable"
2384 );
2385
2386 let updated_state = release_state_io.get_prerelease_state();
2387 assert!(
2388 updated_state.is_none() || !updated_state.expect("state").contains("my-crate"),
2389 "graduated package should not be in prerelease state"
2390 );
2391 }
2392
2393 #[test]
2394 fn saga_rollback_restores_manifest_versions_on_commit_failure() {
2395 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
2396 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix a bug");
2397 let changeset_reader = MockChangesetReader::new()
2398 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
2399 let manifest_writer = MockManifestWriter::new();
2400 let git_provider = MockGitProvider::new();
2401 git_provider.set_fail_on_commit(true);
2402
2403 let operation = ReleaseOperation::new(
2404 project_provider,
2405 changeset_reader,
2406 manifest_writer,
2407 MockChangelogWriter::new(),
2408 git_provider,
2409 MockReleaseStateIO::new(),
2410 );
2411 let input = ReleaseInput {
2412 dry_run: false,
2413 convert_inherited: false,
2414 no_commit: false,
2415 no_tags: true,
2416 keep_changesets: true,
2417 force: false,
2418 per_package_config: HashMap::new(),
2419 global_prerelease: None,
2420 graduate_all: false,
2421 };
2422
2423 let result = operation.execute(Path::new("/any"), &input);
2424
2425 assert!(result.is_err(), "release should fail due to commit failure");
2426
2427 let versions = operation.manifest_writer().written_versions();
2428 assert!(
2429 versions.len() >= 2,
2430 "should have written version twice (update then rollback), got {} writes",
2431 versions.len()
2432 );
2433
2434 let last_write = &versions.last().expect("should have at least one write");
2435 assert_eq!(
2436 last_write.1.to_string(),
2437 "1.0.0",
2438 "last write should restore original version"
2439 );
2440 }
2441
2442 #[test]
2443 fn saga_rollback_deletes_tags_on_failure_after_tag_creation() {
2444 use std::sync::Arc;
2445
2446 let project_provider =
2447 MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
2448 let changeset_a = make_changeset("crate-a", BumpType::Patch, "Fix in crate-a");
2449 let changeset_b = make_changeset("crate-b", BumpType::Minor, "Feature in crate-b");
2450 let changeset_reader = Arc::new(
2451 MockChangesetReader::new()
2452 .with_changeset(PathBuf::from(".changeset/changesets/fix-a.md"), changeset_a)
2453 .with_changeset(
2454 PathBuf::from(".changeset/changesets/feat-b.md"),
2455 changeset_b,
2456 ),
2457 );
2458 let manifest_writer = Arc::new(MockManifestWriter::new());
2459 let git_provider = Arc::new(MockGitProvider::new());
2460
2461 let operation = ReleaseOperation::new(
2462 project_provider,
2463 Arc::clone(&changeset_reader),
2464 Arc::clone(&manifest_writer),
2465 MockChangelogWriter::new(),
2466 Arc::clone(&git_provider),
2467 Arc::new(MockReleaseStateIO::new()),
2468 );
2469 let input = ReleaseInput {
2470 dry_run: false,
2471 convert_inherited: false,
2472 no_commit: false,
2473 no_tags: false,
2474 keep_changesets: true,
2475 force: false,
2476 per_package_config: HashMap::new(),
2477 global_prerelease: None,
2478 graduate_all: false,
2479 };
2480
2481 let result = operation.execute(Path::new("/any"), &input);
2482 assert!(result.is_ok(), "release should succeed");
2483
2484 let tags = git_provider.tags_created();
2485 assert_eq!(tags.len(), 2, "should create tags for both packages");
2486 }
2487
2488 #[test]
2489 fn saga_rollback_resets_commit_when_tag_creation_fails() {
2490 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
2491 let changeset = make_changeset("my-crate", BumpType::Patch, "Fix a bug");
2492 let changeset_reader = MockChangesetReader::new()
2493 .with_changeset(PathBuf::from(".changeset/changesets/fix.md"), changeset);
2494 let manifest_writer = MockManifestWriter::new();
2495 let git_provider = MockGitProvider::new();
2496 git_provider.set_fail_on_create_tag(true);
2497
2498 let operation = ReleaseOperation::new(
2499 project_provider,
2500 changeset_reader,
2501 manifest_writer,
2502 MockChangelogWriter::new(),
2503 git_provider,
2504 MockReleaseStateIO::new(),
2505 );
2506 let input = ReleaseInput {
2507 dry_run: false,
2508 convert_inherited: false,
2509 no_commit: false,
2510 no_tags: false,
2511 keep_changesets: true,
2512 force: false,
2513 per_package_config: HashMap::new(),
2514 global_prerelease: None,
2515 graduate_all: false,
2516 };
2517
2518 let result = operation.execute(Path::new("/any"), &input);
2519
2520 assert!(
2521 result.is_err(),
2522 "release should fail due to tag creation failure"
2523 );
2524
2525 assert_eq!(
2526 operation.git_provider().commits().len(),
2527 1,
2528 "should have created one commit before failure"
2529 );
2530
2531 assert_eq!(
2532 operation.git_provider().reset_count(),
2533 1,
2534 "should have reset the commit during rollback"
2535 );
2536
2537 let versions = operation.manifest_writer().written_versions();
2538 let last_write = versions.last().expect("should have version writes");
2539 assert_eq!(
2540 last_write.1.to_string(),
2541 "1.0.0",
2542 "manifest version should be restored to original"
2543 );
2544 }
2545}