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