Skip to main content

changeset_operations/operations/release/
operation.rs

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