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