diff --git a/.changeset/changesets/lucratively-safe-gurnard.md b/.changeset/changesets/lucratively-safe-gurnard.md new file mode 100644 index 0000000..56b95cb --- /dev/null +++ b/.changeset/changesets/lucratively-safe-gurnard.md @@ -0,0 +1,4 @@ +--- +changeset-operations: none +--- +Add Copy derive to PrereleaseAction, GraduationAction, and AdditionalPackageField diff --git a/.changeset/changesets/sensually-profitable-ladybird.md b/.changeset/changesets/sensually-profitable-ladybird.md new file mode 100644 index 0000000..db8ba5d --- /dev/null +++ b/.changeset/changesets/sensually-profitable-ladybird.md @@ -0,0 +1,4 @@ +--- +cargo-changeset: none +--- +Replace magic index-to-variant mapping in Select prompts with strum-annotated mirror enums diff --git a/Cargo.lock b/Cargo.lock index bb5edbf..f4564ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,6 +158,7 @@ dependencies = [ "predicates", "semver", "serde_json", + "strum", "tempfile", "thiserror", ] @@ -1577,6 +1578,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/crates/cargo-changeset/Cargo.toml b/crates/cargo-changeset/Cargo.toml index e9ae09a..1811df0 100644 --- a/crates/cargo-changeset/Cargo.toml +++ b/crates/cargo-changeset/Cargo.toml @@ -25,6 +25,7 @@ changeset-project = { workspace = true } changeset-version = { workspace = true } clap = { workspace = true } dialoguer = { workspace = true } +strum = { version = "0.28", features = ["derive"] } tempfile = "3.25" thiserror = { workspace = true } diff --git a/crates/cargo-changeset/src/commands/additional_packages.rs b/crates/cargo-changeset/src/commands/additional_packages.rs index 1c66686..7ccee3f 100644 --- a/crates/cargo-changeset/src/commands/additional_packages.rs +++ b/crates/cargo-changeset/src/commands/additional_packages.rs @@ -24,6 +24,7 @@ use changeset_operations::traits::{ use crate::environment::is_interactive; use crate::error::{CliError, Result}; +use crate::interaction::{AdditionalPackageFieldSelectionOption, select_variant}; #[derive(Args)] pub(crate) struct AdditionalPackagesArgs { @@ -215,15 +216,14 @@ impl AdditionalPackageInteractionProvider for TerminalAdditionalPackageInteracti &self, package_path: &Path, ) -> changeset_operations::Result> { - Ok(crate::interaction::prompt_multi_value( - &crate::interaction::MultiValuePromptConfig { - intro: "Enter glob patterns for files that influence this package \ + crate::interaction::prompt_multi_value(&crate::interaction::MultiValuePromptConfig { + intro: "Enter glob patterns for files that influence this package \ (one per line, empty line to finish):", - first_prompt: "Glob pattern", - additional_prompt: "Additional pattern", - first_default: Some(format!("{}/**", package_path.display())), - }, - )?) + first_prompt: "Glob pattern", + additional_prompt: "Additional pattern", + first_default: Some(format!("{}/**", package_path.display())), + }) + .map_err(super::cli_error_to_operation_error) } fn prompt_manifest_file_path(&self) -> changeset_operations::Result { @@ -328,29 +328,21 @@ impl AdditionalPackageInteractionProvider for TerminalAdditionalPackageInteracti fn select_field_to_edit( &self, ) -> changeset_operations::Result> { - let options = [ - "path", - "influence patterns", - "manifest file path", - "manifest format", - "manifest version path", - "Done", - ]; - - let selection = Select::new() - .with_prompt("Which field would you like to edit?") - .items(options) - .interact_opt() - .map_err(super::dialoguer_to_operation_error)?; - - Ok(match selection { - Some(0) => MenuSelection::Selected(AdditionalPackageField::Path), - Some(1) => MenuSelection::Selected(AdditionalPackageField::Influence), - Some(2) => MenuSelection::Selected(AdditionalPackageField::ManifestFilePath), - Some(3) => MenuSelection::Selected(AdditionalPackageField::ManifestFormat), - Some(4) => MenuSelection::Selected(AdditionalPackageField::ManifestVersionFieldPath), - _ => MenuSelection::Cancelled, - }) + let selection = select_variant::( + "Which field would you like to edit?", + 0, + ) + .map_err(super::cli_error_to_operation_error)?; + + Ok( + match selection + .unwrap_or(AdditionalPackageFieldSelectionOption::Done) + .into_field() + { + Some(field) => MenuSelection::Selected(field), + None => MenuSelection::Cancelled, + }, + ) } fn confirm_removal(&self, name: &str) -> changeset_operations::Result { diff --git a/crates/cargo-changeset/src/commands/manage.rs b/crates/cargo-changeset/src/commands/manage.rs index 32cf37a..bbf8242 100644 --- a/crates/cargo-changeset/src/commands/manage.rs +++ b/crates/cargo-changeset/src/commands/manage.rs @@ -16,6 +16,7 @@ use changeset_operations::traits::{ use super::{ManageArgs, ManageCommand, ManageGraduationArgs, ManagePrereleaseArgs}; use crate::environment::is_interactive; use crate::error::{CliError, Result}; +use crate::interaction::select_from_options; struct TerminalManageInteractionProvider; @@ -24,25 +25,21 @@ impl PrereleaseInteractionProvider for TerminalManageInteractionProvider { &self, ) -> changeset_operations::Result> { let options = [ - "Add crate to pre-release", - "Remove crate from pre-release", - "Graduate crate (move to graduation queue)", - "Done", + (PrereleaseAction::Add, "Add crate to pre-release"), + (PrereleaseAction::Remove, "Remove crate from pre-release"), + ( + PrereleaseAction::Graduate, + "Graduate crate (move to graduation queue)", + ), + (PrereleaseAction::Done, "Done"), ]; - let selection = Select::new() - .with_prompt("What would you like to do?") - .items(options) - .default(0) - .interact_opt() - .map_err(super::dialoguer_to_operation_error)?; + let selection = select_from_options("What would you like to do?", &options, 0) + .map_err(super::cli_error_to_operation_error)?; - Ok(match selection { - Some(0) => MenuSelection::Selected(PrereleaseAction::Add), - Some(1) => MenuSelection::Selected(PrereleaseAction::Remove), - Some(2) => MenuSelection::Selected(PrereleaseAction::Graduate), - _ => MenuSelection::Selected(PrereleaseAction::Done), - }) + Ok(MenuSelection::Selected( + selection.unwrap_or(PrereleaseAction::Done), + )) } fn select_package_for_prerelease( @@ -102,23 +99,20 @@ impl GraduationInteractionProvider for TerminalManageInteractionProvider { &self, ) -> changeset_operations::Result> { let options = [ - "Add crate to graduation queue", - "Remove crate from graduation queue", - "Done", + (GraduationAction::Add, "Add crate to graduation queue"), + ( + GraduationAction::Remove, + "Remove crate from graduation queue", + ), + (GraduationAction::Done, "Done"), ]; - let selection = Select::new() - .with_prompt("What would you like to do?") - .items(options) - .default(0) - .interact_opt() - .map_err(super::dialoguer_to_operation_error)?; + let selection = select_from_options("What would you like to do?", &options, 0) + .map_err(super::cli_error_to_operation_error)?; - Ok(match selection { - Some(0) => MenuSelection::Selected(GraduationAction::Add), - Some(1) => MenuSelection::Selected(GraduationAction::Remove), - _ => MenuSelection::Selected(GraduationAction::Done), - }) + Ok(MenuSelection::Selected( + selection.unwrap_or(GraduationAction::Done), + )) } fn select_package_for_graduation( diff --git a/crates/cargo-changeset/src/commands/mod.rs b/crates/cargo-changeset/src/commands/mod.rs index 4068b80..13d0099 100644 --- a/crates/cargo-changeset/src/commands/mod.rs +++ b/crates/cargo-changeset/src/commands/mod.rs @@ -391,6 +391,27 @@ pub(super) fn dialoguer_to_operation_error(e: dialoguer::Error) -> OperationErro } } +pub(super) fn cli_error_to_operation_error(e: crate::error::CliError) -> OperationError { + use crate::error::CliError; + match e { + CliError::Io(io) => OperationError::Io(io), + CliError::NotATty => OperationError::InteractionRequired, + CliError::EditorFailed { source } => OperationError::Io(source), + CliError::Core(e) => OperationError::Core(e), + CliError::Git(e) => OperationError::Git(e), + CliError::Project(e) => OperationError::Project(e), + CliError::Operation(e) => e, + CliError::CurrentDir(io) => OperationError::Io(io), + CliError::ManifestFormatRequired | CliError::IncompleteArgs => { + OperationError::InteractionRequired + } + CliError::InvalidPackageBumpFormat { .. } + | CliError::InvalidBumpType { .. } + | CliError::VerificationFailed { .. } + | CliError::ChangesetDeleted { .. } => OperationError::Cancelled, + } +} + #[cfg(test)] mod dialoguer_conversion_tests { use std::io; @@ -458,3 +479,85 @@ mod dialoguer_conversion_tests { } } } + +#[cfg(test)] +mod cli_error_conversion_tests { + use std::io; + + use changeset_operations::OperationError; + + use crate::error::CliError; + + use super::cli_error_to_operation_error; + + #[test] + fn io_error_maps_to_operation_io() { + let err = CliError::Io(io::Error::new(io::ErrorKind::NotFound, "not found")); + + let result = cli_error_to_operation_error(err); + + assert!(matches!(result, OperationError::Io(_))); + } + + #[test] + fn not_a_tty_maps_to_interaction_required() { + let err = CliError::NotATty; + + let result = cli_error_to_operation_error(err); + + assert!(matches!(result, OperationError::InteractionRequired)); + } + + #[test] + fn incomplete_args_maps_to_interaction_required() { + let err = CliError::IncompleteArgs; + + let result = cli_error_to_operation_error(err); + + assert!(matches!(result, OperationError::InteractionRequired)); + } + + #[test] + fn operation_error_passes_through() { + let inner = OperationError::Cancelled; + let err = CliError::Operation(inner); + + let result = cli_error_to_operation_error(err); + + assert!(matches!(result, OperationError::Cancelled)); + } + + #[test] + fn project_error_maps_to_operation_project() { + use std::path::PathBuf; + let err = CliError::Project(changeset_project::ProjectError::NotFound { + start_dir: PathBuf::from("/test"), + }); + + let result = cli_error_to_operation_error(err); + + assert!(matches!(result, OperationError::Project(_))); + } + + #[test] + fn editor_failed_maps_to_operation_io() { + let err = CliError::EditorFailed { + source: io::Error::new(io::ErrorKind::NotFound, "editor not found"), + }; + + let result = cli_error_to_operation_error(err); + + assert!(matches!(result, OperationError::Io(_))); + } + + #[test] + fn invalid_package_bump_format_maps_to_cancelled() { + let err = CliError::InvalidPackageBumpFormat { + input: "bad".to_string(), + }; + + let result = cli_error_to_operation_error(err); + + assert!(matches!(result, OperationError::Cancelled)); + } +} diff --git a/crates/cargo-changeset/src/error.rs b/crates/cargo-changeset/src/error.rs index 02ef419..311e73c 100644 --- a/crates/cargo-changeset/src/error.rs +++ b/crates/cargo-changeset/src/error.rs @@ -56,6 +56,14 @@ pub enum CliError { pub type Result = std::result::Result; +impl From for CliError { + fn from(e: dialoguer::Error) -> Self { + match e { + dialoguer::Error::IO(io) => Self::Io(io), + } + } +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -153,4 +161,14 @@ mod tests { assert!(matches!(cli_err, CliError::Operation(_))); } + + #[test] + fn dialoguer_error_converts_via_from() { + let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe closed"); + let dialoguer_err = dialoguer::Error::IO(io_err); + + let cli_err: CliError = dialoguer_err.into(); + + assert!(matches!(cli_err, CliError::Io(_))); + } } diff --git a/crates/cargo-changeset/src/interaction.rs b/crates/cargo-changeset/src/interaction/mod.rs similarity index 54% rename from crates/cargo-changeset/src/interaction.rs rename to crates/cargo-changeset/src/interaction/mod.rs index 9c035d6..de575a8 100644 --- a/crates/cargo-changeset/src/interaction.rs +++ b/crates/cargo-changeset/src/interaction/mod.rs @@ -1,13 +1,16 @@ +mod selection_options; + use std::fs; use std::io::Write as _; use std::process::Command; use dialoguer::{Confirm, Input, MultiSelect, Select}; +use strum::{EnumMessage, VariantArray}; -use changeset_core::{BumpType, ChangeCategory, PackageInfo}; +use changeset_core::{ChangeCategory, PackageInfo}; use changeset_git::DEFAULT_BASE_BRANCH; use changeset_manifest::{ - ChangelogLocation, ComparisonLinks, NoneBumpBehavior, TagFormat, ZeroVersionBehavior, + ChangelogLocation, ComparisonLinks, NoneBumpBehavior, ZeroVersionBehavior, }; use changeset_operations::Result; use changeset_operations::traits::{ @@ -16,8 +19,15 @@ use changeset_operations::traits::{ PackageSelection, ProjectContext, VersionSettingsInput, }; +use crate::commands::{cli_error_to_operation_error, dialoguer_to_operation_error}; use crate::environment::is_interactive; -use crate::error::CliError; +use crate::error::{CliError, Result as CliResult}; + +pub(crate) use selection_options::{ + AdditionalPackageFieldSelectionOption, BumpTypeSelectionOption, ChangeCategorySelectionOption, + ChangelogLocationSelectionOption, ComparisonLinksSelectionOption, + NoneBumpBehaviorSelectionOption, TagFormatSelectionOption, ZeroVersionBehaviorSelectionOption, +}; pub(crate) struct TerminalInteractionProvider { use_editor: bool, @@ -37,7 +47,7 @@ impl InteractionProvider for TerminalInteractionProvider { display_labels: Option<&[String]>, ) -> Result { if !is_interactive() { - return Err(cli_to_operation_error(CliError::NotATty)); + return Err(changeset_operations::OperationError::InteractionRequired); } let default_labels: Vec; @@ -56,7 +66,7 @@ impl InteractionProvider for TerminalInteractionProvider { .with_prompt("Select packages to include in changeset") .items(items) .interact_opt() - .map_err(from_dialoguer)?; + .map_err(dialoguer_to_operation_error)?; match selection { Some(indices) => { @@ -68,62 +78,32 @@ impl InteractionProvider for TerminalInteractionProvider { } fn select_bump_type(&self, package_name: &str) -> Result { - let items = [ - "patch - Bug fixes (backwards compatible)", - "minor - New features (backwards compatible)", - "major - Breaking changes", - "none - No version bump (internal changes only)", - ]; - - let selection = Select::new() - .with_prompt(format!("Select bump type for '{package_name}'")) - .items(items) - .default(0) - .interact_opt() - .map_err(from_dialoguer)?; - - match selection { - Some(0) => Ok(BumpSelection::Selected(BumpType::Patch)), - Some(1) => Ok(BumpSelection::Selected(BumpType::Minor)), - Some(2) => Ok(BumpSelection::Selected(BumpType::Major)), - Some(3) => Ok(BumpSelection::Selected(BumpType::None)), - _ => Ok(BumpSelection::Cancelled), - } + let selection = select_variant::( + &format!("Select bump type for '{package_name}'"), + 0, + ) + .map_err(cli_error_to_operation_error)?; + Ok(match selection { + Some(opt) => BumpSelection::Selected(opt.into()), + None => BumpSelection::Cancelled, + }) } fn select_category(&self) -> Result { - let items = [ - "changed - General changes (default)", - "added - New features", - "fixed - Bug fixes", - "deprecated - Deprecated features", - "removed - Removed features", - "security - Security fixes", - ]; - - let selection = Select::new() - .with_prompt("Select change category") - .items(items) - .default(0) - .interact_opt() - .map_err(from_dialoguer)?; - - match selection { - Some(0) => Ok(CategorySelection::Selected(ChangeCategory::Changed)), - Some(1) => Ok(CategorySelection::Selected(ChangeCategory::Added)), - Some(2) => Ok(CategorySelection::Selected(ChangeCategory::Fixed)), - Some(3) => Ok(CategorySelection::Selected(ChangeCategory::Deprecated)), - Some(4) => Ok(CategorySelection::Selected(ChangeCategory::Removed)), - Some(5) => Ok(CategorySelection::Selected(ChangeCategory::Security)), - _ => Ok(CategorySelection::Cancelled), - } + let selection = + select_variant::("Select change category", 0) + .map_err(cli_error_to_operation_error)?; + Ok(match selection { + Some(opt) => CategorySelection::Selected(opt.into()), + None => CategorySelection::Cancelled, + }) } fn get_description(&self) -> Result { if self.use_editor { - get_description_editor().map_err(cli_to_operation_error) + get_description_editor().map_err(cli_error_to_operation_error) } else { - get_description_terminal().map_err(cli_to_operation_error) + get_description_terminal().map_err(cli_error_to_operation_error) } } } @@ -181,21 +161,26 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { .with_prompt("Configure git settings?") .default(true) .interact_opt() - .map_err(from_dialoguer)?; + .map_err(dialoguer_to_operation_error)?; if configure != Some(true) { return Ok(None); } - let commit = select_bool("Create git commits on release?", true)?; - let tags = select_bool("Create git tags on release?", true)?; - let keep_changesets = select_bool("Keep changeset files after release?", false)?; - let tag_format = select_tag_format(context.is_single_package)?; - let base_branch = prompt_base_branch()?; + let commit = select_bool("Create git commits on release?", true) + .map_err(cli_error_to_operation_error)?; + let tags = select_bool("Create git tags on release?", true) + .map_err(cli_error_to_operation_error)?; + let keep_changesets = select_bool("Keep changeset files after release?", false) + .map_err(cli_error_to_operation_error)?; + let tag_format = + select_tag_format(context.is_single_package).map_err(cli_error_to_operation_error)?; + let base_branch = prompt_base_branch().map_err(cli_error_to_operation_error)?; let (commit_title_template, changes_in_body) = if commit { - let template = prompt_commit_title_template()?; - let body = select_bool("Include version details in commit body?", true)?; + let template = prompt_commit_title_template().map_err(cli_error_to_operation_error)?; + let body = select_bool("Include version details in commit body?", true) + .map_err(cli_error_to_operation_error)?; (Some(template), Some(body)) } else { (None, None) @@ -224,7 +209,7 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { .with_prompt("Configure changelog settings?") .default(true) .interact_opt() - .map_err(from_dialoguer)?; + .map_err(dialoguer_to_operation_error)?; if configure != Some(true) { return Ok(None); @@ -233,12 +218,13 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { let changelog = if context.is_single_package { ChangelogLocation::Root } else { - select_changelog_location()? + select_changelog_location().map_err(cli_error_to_operation_error)? }; - let comparison_links = select_comparison_links()?; + let comparison_links = select_comparison_links().map_err(cli_error_to_operation_error)?; let comparison_links_template = if comparison_links != ComparisonLinks::Disabled { - let template = prompt_comparison_links_template()?; + let template = + prompt_comparison_links_template().map_err(cli_error_to_operation_error)?; if template.is_empty() { None } else { @@ -248,7 +234,8 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { None }; - let dep_template = prompt_dependency_bump_changelog_template()?; + let dep_template = + prompt_dependency_bump_changelog_template().map_err(cli_error_to_operation_error)?; let dependency_bump_changelog_template = if dep_template == "Updated dependency `{dependency}` to v{version}" { None @@ -273,17 +260,22 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { .with_prompt("Configure version settings?") .default(true) .interact_opt() - .map_err(from_dialoguer)?; + .map_err(dialoguer_to_operation_error)?; if configure != Some(true) { return Ok(None); } - let zero_version_behavior = select_zero_version_behavior()?; - let none_bump_behavior = select_none_bump_behavior()?; + let zero_version_behavior = + select_zero_version_behavior().map_err(cli_error_to_operation_error)?; + let none_bump_behavior = + select_none_bump_behavior().map_err(cli_error_to_operation_error)?; let none_bump_promote_message_template = if none_bump_behavior == NoneBumpBehavior::PromoteToPatch { - Some(prompt_none_bump_promote_message_template()?) + Some( + prompt_none_bump_promote_message_template() + .map_err(cli_error_to_operation_error)?, + ) } else { None }; @@ -304,13 +296,13 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { .with_prompt("Configure file filtering?") .default(false) .interact_opt() - .map_err(from_dialoguer)?; + .map_err(dialoguer_to_operation_error)?; if configure != Some(true) { return Ok(None); } - let ignored_files = prompt_ignored_files_loop()?; + let ignored_files = prompt_ignored_files_loop().map_err(cli_error_to_operation_error)?; if ignored_files.is_empty() { return Ok(None); @@ -320,7 +312,7 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { } } -pub(crate) fn confirm_proceed(prompt: &str) -> crate::error::Result { +pub(crate) fn confirm_proceed(prompt: &str) -> CliResult { if !is_interactive() { return Err(CliError::NotATty); } @@ -328,15 +320,12 @@ pub(crate) fn confirm_proceed(prompt: &str) -> crate::error::Result { let confirmed = Confirm::new() .with_prompt(prompt) .default(true) - .interact_opt() - .map_err(from_dialoguer)?; + .interact_opt()?; Ok(confirmed == Some(true)) } -pub(crate) fn prompt_multi_value( - config: &MultiValuePromptConfig<'_>, -) -> std::io::Result> { +pub(crate) fn prompt_multi_value(config: &MultiValuePromptConfig<'_>) -> CliResult> { let mut values = Vec::new(); println!("{}", config.intro); @@ -346,7 +335,7 @@ pub(crate) fn prompt_multi_value( if let Some(ref default) = config.first_default { first_input = first_input.default(default.clone()); } - let first = first_input.interact_text().map_err(from_dialoguer)?; + let first = first_input.interact_text()?; let first = first.trim().to_string(); if first.is_empty() { return Ok(values); @@ -357,8 +346,7 @@ pub(crate) fn prompt_multi_value( let s: String = Input::new() .with_prompt(config.additional_prompt) .allow_empty(true) - .interact_text() - .map_err(from_dialoguer)?; + .interact_text()?; let s = s.trim().to_string(); if s.is_empty() { break; @@ -368,35 +356,46 @@ pub(crate) fn prompt_multi_value( Ok(values) } -fn from_dialoguer(e: dialoguer::Error) -> std::io::Error { - match e { - dialoguer::Error::IO(io) => io, - } +pub(crate) fn select_variant(prompt: &str, default: usize) -> CliResult> +where + T: Copy + VariantArray + EnumMessage, +{ + let variants = T::VARIANTS; + debug_assert!( + default < variants.len(), + "default index {default} is out of bounds for {} variants", + variants.len() + ); + let items: Vec<&str> = variants + .iter() + .map(|v| { + v.get_message() + .expect("all strum variants must have a #[strum(message)] annotation") + }) + .collect(); + let selection = Select::new() + .with_prompt(prompt) + .items(&items) + .default(default) + .interact_opt()?; + Ok(selection.map(|idx| variants[idx])) } -fn cli_to_operation_error(e: CliError) -> changeset_operations::OperationError { - use changeset_operations::OperationError; - - match e { - CliError::Io(io) => OperationError::Io(io), - CliError::NotATty => OperationError::InteractionRequired, - CliError::EditorFailed { source } => OperationError::Io(source), - CliError::Core(e) => OperationError::Core(e), - CliError::Git(e) => OperationError::Git(e), - CliError::Project(e) => OperationError::Project(e), - CliError::Operation(e) => e, - CliError::CurrentDir(io) => OperationError::Io(io), - CliError::ManifestFormatRequired | CliError::IncompleteArgs => { - OperationError::InteractionRequired - } - CliError::InvalidPackageBumpFormat { .. } - | CliError::InvalidBumpType { .. } - | CliError::VerificationFailed { .. } - | CliError::ChangesetDeleted { .. } => OperationError::Cancelled, - } +pub(crate) fn select_from_options( + prompt: &str, + options: &[(T, &str)], + default: usize, +) -> CliResult> { + let items: Vec<&str> = options.iter().map(|(_, label)| *label).collect(); + let selection = Select::new() + .with_prompt(prompt) + .items(&items) + .default(default) + .interact_opt()?; + Ok(selection.map(|idx| options[idx].0)) } -fn get_description_terminal() -> std::result::Result { +fn get_description_terminal() -> CliResult { println!(); println!("Enter description (press Enter 3 times to finish):"); println!(); @@ -429,7 +428,7 @@ fn get_description_terminal() -> std::result::Result Ok(DescriptionInput::Provided(lines.join("\n"))) } -fn get_description_editor() -> std::result::Result { +fn get_description_editor() -> CliResult { let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); let mut temp_file = tempfile::NamedTempFile::new()?; @@ -460,186 +459,93 @@ fn get_description_editor() -> std::result::Result { Ok(DescriptionInput::Provided(description)) } -fn prompt_base_branch() -> Result { +fn prompt_base_branch() -> CliResult { Ok(Input::new() .with_prompt("Default base branch for git comparisons") .default(DEFAULT_BASE_BRANCH.to_string()) - .interact_text() - .map_err(from_dialoguer)?) + .interact_text()?) } -fn select_bool(prompt: &str, default: bool) -> Result { +fn select_bool(prompt: &str, default: bool) -> CliResult { Ok(Confirm::new() .with_prompt(prompt) .default(default) - .interact() - .map_err(from_dialoguer)?) + .interact()?) } -fn select_tag_format(is_single_package: bool) -> Result { - let (items, default_idx) = if is_single_package { - ( - [ - "version-only - Tags like v1.0.0 (default)", - "crate-prefixed - Tags like crate-name@1.0.0", - ], - 0, - ) +fn select_tag_format(is_single_package: bool) -> CliResult { + let default_idx: usize = if is_single_package { 0 } else { 1 }; + let selection = select_variant::("Select tag format", default_idx)?; + Ok(selection.map(Into::into).unwrap_or(if is_single_package { + changeset_manifest::TagFormat::VersionOnly } else { - ( - [ - "version-only - Tags like v1.0.0", - "crate-prefixed - Tags like crate-name@1.0.0 (default)", - ], - 1, - ) - }; - - let selection = Select::new() - .with_prompt("Select tag format") - .items(items) - .default(default_idx) - .interact_opt() - .map_err(from_dialoguer)?; - - match selection { - Some(0) => Ok(TagFormat::VersionOnly), - Some(1) => Ok(TagFormat::CratePrefixed), - _ => { - if is_single_package { - Ok(TagFormat::VersionOnly) - } else { - Ok(TagFormat::CratePrefixed) - } - } - } + changeset_manifest::TagFormat::CratePrefixed + })) } -fn select_changelog_location() -> Result { - let items = [ - "root - Single CHANGELOG.md at project root (default)", - "per-package - CHANGELOG.md in each package directory", - ]; - - let selection = Select::new() - .with_prompt("Select changelog location") - .items(items) - .default(0) - .interact_opt() - .map_err(from_dialoguer)?; - - match selection { - Some(0) => Ok(ChangelogLocation::Root), - Some(1) => Ok(ChangelogLocation::PerPackage), - _ => Ok(ChangelogLocation::default()), - } +fn select_changelog_location() -> CliResult { + let selection = + select_variant::("Select changelog location", 0)?; + Ok(selection.map_or_else(ChangelogLocation::default, Into::into)) } -fn select_comparison_links() -> Result { - let items = [ - "auto - Generate links if git remote detected (default)", - "enabled - Always generate comparison links", - "disabled - Never generate comparison links", - ]; - - let selection = Select::new() - .with_prompt("Select comparison links mode") - .items(items) - .default(0) - .interact_opt() - .map_err(from_dialoguer)?; - - match selection { - Some(0) => Ok(ComparisonLinks::Auto), - Some(1) => Ok(ComparisonLinks::Enabled), - Some(2) => Ok(ComparisonLinks::Disabled), - _ => Ok(ComparisonLinks::default()), - } +fn select_comparison_links() -> CliResult { + let selection = + select_variant::("Select comparison links mode", 0)?; + Ok(selection.map_or_else(ComparisonLinks::default, Into::into)) } -fn select_zero_version_behavior() -> Result { - let items = [ - "effective-minor - Major bump on 0.x increments minor (default)", - "auto-promote-on-major - Major bump on 0.x promotes to 1.0.0", - ]; - - let selection = Select::new() - .with_prompt("Select zero version (0.x.y) behavior") - .items(items) - .default(0) - .interact_opt() - .map_err(from_dialoguer)?; - - match selection { - Some(0) => Ok(ZeroVersionBehavior::EffectiveMinor), - Some(1) => Ok(ZeroVersionBehavior::AutoPromoteOnMajor), - _ => Ok(ZeroVersionBehavior::default()), - } +fn select_zero_version_behavior() -> CliResult { + let selection = select_variant::( + "Select zero version (0.x.y) behavior", + 0, + )?; + Ok(selection.map_or_else(ZeroVersionBehavior::default, Into::into)) } -fn select_none_bump_behavior() -> Result { - let items = [ - "promote-to-patch - Treat none bumps as patch releases (default)", - "allow - Allow none bumps without version change", - "disallow - Reject changesets with none bump type", - ]; - - let selection = Select::new() - .with_prompt("Select none bump behavior") - .items(items) - .default(0) - .interact_opt() - .map_err(from_dialoguer)?; - - match selection { - Some(0) => Ok(NoneBumpBehavior::PromoteToPatch), - Some(1) => Ok(NoneBumpBehavior::Allow), - Some(2) => Ok(NoneBumpBehavior::Disallow), - _ => Ok(NoneBumpBehavior::default()), - } +fn select_none_bump_behavior() -> CliResult { + let selection = + select_variant::("Select none bump behavior", 0)?; + Ok(selection.map_or_else(NoneBumpBehavior::default, Into::into)) } -fn prompt_commit_title_template() -> Result { +fn prompt_commit_title_template() -> CliResult { Ok(Input::new() .with_prompt("Commit title template (placeholder: {new-version})") .default("{new-version}".to_string()) - .interact_text() - .map_err(from_dialoguer)?) + .interact_text()?) } -fn prompt_comparison_links_template() -> Result { +fn prompt_comparison_links_template() -> CliResult { Ok(Input::new() .with_prompt( "Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target})", ) .default(String::new()) .allow_empty(true) - .interact_text() - .map_err(from_dialoguer)?) + .interact_text()?) } -fn prompt_dependency_bump_changelog_template() -> Result { +fn prompt_dependency_bump_changelog_template() -> CliResult { Ok(Input::new() .with_prompt("Dependency bump changelog template (placeholders: {dependency}, {version})") .default("Updated dependency `{dependency}` to v{version}".to_string()) - .interact_text() - .map_err(from_dialoguer)?) + .interact_text()?) } -fn prompt_ignored_files_loop() -> Result> { - Ok(prompt_multi_value(&MultiValuePromptConfig { +fn prompt_ignored_files_loop() -> CliResult> { + prompt_multi_value(&MultiValuePromptConfig { intro: "Enter file patterns to exclude from change detection \ (one per line, empty line to finish):", first_prompt: "Ignore pattern", additional_prompt: "Additional pattern", first_default: None, - })?) + }) } -fn prompt_none_bump_promote_message_template() -> Result { +fn prompt_none_bump_promote_message_template() -> CliResult { Ok(Input::new() .with_prompt("Changelog message template for promoted none bumps") .default("Internal architectural changes".to_string()) - .interact_text() - .map_err(from_dialoguer)?) + .interact_text()?) } diff --git a/crates/cargo-changeset/src/interaction/selection_options.rs b/crates/cargo-changeset/src/interaction/selection_options.rs new file mode 100644 index 0000000..738414d --- /dev/null +++ b/crates/cargo-changeset/src/interaction/selection_options.rs @@ -0,0 +1,181 @@ +use strum::{EnumMessage, VariantArray}; + +use changeset_core::{BumpType, ChangeCategory}; +use changeset_manifest::{ + ChangelogLocation, ComparisonLinks, NoneBumpBehavior, TagFormat, ZeroVersionBehavior, +}; +use changeset_operations::traits::AdditionalPackageField; + +#[derive(Clone, Copy, VariantArray, EnumMessage)] +pub(crate) enum BumpTypeSelectionOption { + #[strum(message = "patch - Bug fixes (backwards compatible)")] + Patch, + #[strum(message = "minor - New features (backwards compatible)")] + Minor, + #[strum(message = "major - Breaking changes")] + Major, + #[strum(message = "none - No version bump (internal changes only)")] + None, +} + +impl From for BumpType { + fn from(opt: BumpTypeSelectionOption) -> Self { + match opt { + BumpTypeSelectionOption::Patch => Self::Patch, + BumpTypeSelectionOption::Minor => Self::Minor, + BumpTypeSelectionOption::Major => Self::Major, + BumpTypeSelectionOption::None => Self::None, + } + } +} + +#[derive(Clone, Copy, VariantArray, EnumMessage)] +pub(crate) enum ChangeCategorySelectionOption { + #[strum(message = "changed - General changes (default)")] + Changed, + #[strum(message = "added - New features")] + Added, + #[strum(message = "fixed - Bug fixes")] + Fixed, + #[strum(message = "deprecated - Deprecated features")] + Deprecated, + #[strum(message = "removed - Removed features")] + Removed, + #[strum(message = "security - Security fixes")] + Security, +} + +impl From for ChangeCategory { + fn from(opt: ChangeCategorySelectionOption) -> Self { + match opt { + ChangeCategorySelectionOption::Changed => Self::Changed, + ChangeCategorySelectionOption::Added => Self::Added, + ChangeCategorySelectionOption::Fixed => Self::Fixed, + ChangeCategorySelectionOption::Deprecated => Self::Deprecated, + ChangeCategorySelectionOption::Removed => Self::Removed, + ChangeCategorySelectionOption::Security => Self::Security, + } + } +} + +#[derive(Clone, Copy, VariantArray, EnumMessage)] +pub(crate) enum ChangelogLocationSelectionOption { + #[strum(message = "root - Single CHANGELOG.md at project root (default)")] + Root, + #[strum(message = "per-package - CHANGELOG.md in each package directory")] + PerPackage, +} + +impl From for ChangelogLocation { + fn from(opt: ChangelogLocationSelectionOption) -> Self { + match opt { + ChangelogLocationSelectionOption::Root => Self::Root, + ChangelogLocationSelectionOption::PerPackage => Self::PerPackage, + } + } +} + +#[derive(Clone, Copy, VariantArray, EnumMessage)] +pub(crate) enum ComparisonLinksSelectionOption { + #[strum(message = "auto - Generate links if git remote detected (default)")] + Auto, + #[strum(message = "enabled - Always generate comparison links")] + Enabled, + #[strum(message = "disabled - Never generate comparison links")] + Disabled, +} + +impl From for ComparisonLinks { + fn from(opt: ComparisonLinksSelectionOption) -> Self { + match opt { + ComparisonLinksSelectionOption::Auto => Self::Auto, + ComparisonLinksSelectionOption::Enabled => Self::Enabled, + ComparisonLinksSelectionOption::Disabled => Self::Disabled, + } + } +} + +#[derive(Clone, Copy, VariantArray, EnumMessage)] +pub(crate) enum ZeroVersionBehaviorSelectionOption { + #[strum(message = "effective-minor - Major bump on 0.x increments minor (default)")] + EffectiveMinor, + #[strum(message = "auto-promote-on-major - Major bump on 0.x promotes to 1.0.0")] + AutoPromoteOnMajor, +} + +impl From for ZeroVersionBehavior { + fn from(opt: ZeroVersionBehaviorSelectionOption) -> Self { + match opt { + ZeroVersionBehaviorSelectionOption::EffectiveMinor => Self::EffectiveMinor, + ZeroVersionBehaviorSelectionOption::AutoPromoteOnMajor => Self::AutoPromoteOnMajor, + } + } +} + +#[derive(Clone, Copy, VariantArray, EnumMessage)] +pub(crate) enum NoneBumpBehaviorSelectionOption { + #[strum(message = "promote-to-patch - Treat none bumps as patch releases (default)")] + PromoteToPatch, + #[strum(message = "allow - Allow none bumps without version change")] + Allow, + #[strum(message = "disallow - Reject changesets with none bump type")] + Disallow, +} + +impl From for NoneBumpBehavior { + fn from(opt: NoneBumpBehaviorSelectionOption) -> Self { + match opt { + NoneBumpBehaviorSelectionOption::PromoteToPatch => Self::PromoteToPatch, + NoneBumpBehaviorSelectionOption::Allow => Self::Allow, + NoneBumpBehaviorSelectionOption::Disallow => Self::Disallow, + } + } +} + +#[derive(Clone, Copy, VariantArray, EnumMessage)] +pub(crate) enum TagFormatSelectionOption { + #[strum(message = "version-only - Tags like v1.0.0")] + VersionOnly, + #[strum(message = "crate-prefixed - Tags like crate-name@1.0.0")] + CratePrefixed, +} + +impl From for TagFormat { + fn from(opt: TagFormatSelectionOption) -> Self { + match opt { + TagFormatSelectionOption::VersionOnly => Self::VersionOnly, + TagFormatSelectionOption::CratePrefixed => Self::CratePrefixed, + } + } +} + +#[derive(Clone, Copy, VariantArray, EnumMessage)] +pub(crate) enum AdditionalPackageFieldSelectionOption { + #[strum(message = "path")] + Path, + #[strum(message = "influence patterns")] + Influence, + #[strum(message = "manifest file path")] + ManifestFilePath, + #[strum(message = "manifest format")] + ManifestFormat, + #[strum(message = "manifest version path")] + ManifestVersionFieldPath, + #[strum(message = "Done")] + Done, +} + +impl AdditionalPackageFieldSelectionOption { + pub(crate) fn into_field(self) -> Option { + match self { + Self::Path => Some(AdditionalPackageField::Path), + Self::Influence => Some(AdditionalPackageField::Influence), + Self::ManifestFilePath => Some(AdditionalPackageField::ManifestFilePath), + Self::ManifestFormat => Some(AdditionalPackageField::ManifestFormat), + Self::ManifestVersionFieldPath => { + Some(AdditionalPackageField::ManifestVersionFieldPath) + } + Self::Done => None, + } + } +} diff --git a/crates/changeset-operations/src/traits/additional_package_interaction.rs b/crates/changeset-operations/src/traits/additional_package_interaction.rs index 61abda4..fe5d6ad 100644 --- a/crates/changeset-operations/src/traits/additional_package_interaction.rs +++ b/crates/changeset-operations/src/traits/additional_package_interaction.rs @@ -5,7 +5,7 @@ use changeset_core::{AdditionalPackageDeclaration, ManifestFormat}; use crate::Result; use crate::traits::MenuSelection; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum AdditionalPackageField { Path, Influence, diff --git a/crates/changeset-operations/src/traits/manage_interaction.rs b/crates/changeset-operations/src/traits/manage_interaction.rs index 25fbf2c..c10a6b6 100644 --- a/crates/changeset-operations/src/traits/manage_interaction.rs +++ b/crates/changeset-operations/src/traits/manage_interaction.rs @@ -2,7 +2,7 @@ use changeset_core::PackageInfo; use crate::Result; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PrereleaseAction { Add, Remove, @@ -10,7 +10,7 @@ pub enum PrereleaseAction { Done, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GraduationAction { Add, Remove,