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