Skip to main content

changeset_operations/operations/release/
operation.rs

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    /// # Errors
155    ///
156    /// Propagates errors from project discovery, changeset parsing, version
157    /// planning, changelog generation, and git operations.
158    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}