use std::fs;
use std::io::Write as _;
use std::process::Command;
use dialoguer::{Confirm, Input, MultiSelect, Select};
use changeset_core::{BumpType, ChangeCategory, PackageInfo};
use changeset_git::DEFAULT_BASE_BRANCH;
use changeset_manifest::{
ChangelogLocation, ComparisonLinks, NoneBumpBehavior, TagFormat, ZeroVersionBehavior,
};
use changeset_operations::Result;
use changeset_operations::traits::{
BumpSelection, CategorySelection, ChangelogSettingsInput, DescriptionInput,
FilteringSettingsInput, GitSettingsInput, InitInteractionProvider, InteractionProvider,
PackageSelection, ProjectContext, VersionSettingsInput,
};
use crate::environment::is_interactive;
use crate::error::CliError;
pub(crate) struct TerminalInteractionProvider {
use_editor: bool,
}
impl TerminalInteractionProvider {
#[must_use]
pub(crate) fn new(use_editor: bool) -> Self {
Self { use_editor }
}
}
impl InteractionProvider for TerminalInteractionProvider {
fn select_packages(
&self,
available: &[PackageInfo],
display_labels: Option<&[String]>,
) -> Result<PackageSelection> {
if !is_interactive() {
return Err(cli_to_operation_error(CliError::NotATty));
}
let default_labels: Vec<String>;
let items: &[String] = match display_labels {
Some(labels) => labels,
None => {
default_labels = available
.iter()
.map(|p| format!("{} ({})", p.name(), p.version()))
.collect();
&default_labels
}
};
let selection = MultiSelect::new()
.with_prompt("Select packages to include in changeset")
.items(items)
.interact_opt()
.map_err(from_dialoguer)?;
match selection {
Some(indices) => {
let packages = indices.into_iter().map(|i| available[i].clone()).collect();
Ok(PackageSelection::Selected(packages))
}
None => Ok(PackageSelection::Cancelled),
}
}
fn select_bump_type(&self, package_name: &str) -> Result<BumpSelection> {
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),
}
}
fn select_category(&self) -> Result<CategorySelection> {
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),
}
}
fn get_description(&self) -> Result<DescriptionInput> {
if self.use_editor {
get_description_editor().map_err(cli_to_operation_error)
} else {
get_description_terminal().map_err(cli_to_operation_error)
}
}
}
pub(crate) struct NonInteractiveProvider;
impl InteractionProvider for NonInteractiveProvider {
fn select_packages(
&self,
_available: &[PackageInfo],
_display_labels: Option<&[String]>,
) -> Result<PackageSelection> {
Err(changeset_operations::OperationError::InteractionRequired)
}
fn select_bump_type(&self, package_name: &str) -> Result<BumpSelection> {
Err(changeset_operations::OperationError::MissingBumpType {
package_name: package_name.to_string(),
})
}
fn select_category(&self) -> Result<CategorySelection> {
Ok(CategorySelection::Selected(ChangeCategory::default()))
}
fn get_description(&self) -> Result<DescriptionInput> {
Err(changeset_operations::OperationError::MissingDescription)
}
}
#[derive(Default)]
pub(crate) struct TerminalInitInteractionProvider;
impl TerminalInitInteractionProvider {
#[must_use]
pub(crate) fn new() -> Self {
Self
}
}
impl InitInteractionProvider for TerminalInitInteractionProvider {
fn configure_git_settings(&self, context: ProjectContext) -> Result<Option<GitSettingsInput>> {
if !is_interactive() {
return Ok(None);
}
let configure = Confirm::new()
.with_prompt("Configure git settings?")
.default(true)
.interact_opt()
.map_err(from_dialoguer)?;
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_title_template, changes_in_body) = if commit {
let template = prompt_commit_title_template()?;
let body = select_bool("Include version details in commit body?", true)?;
(Some(template), Some(body))
} else {
(None, None)
};
Ok(Some(GitSettingsInput {
commit,
tags,
keep_changesets,
tag_format,
base_branch,
commit_title_template,
changes_in_body,
}))
}
fn configure_changelog_settings(
&self,
context: ProjectContext,
) -> Result<Option<ChangelogSettingsInput>> {
if !is_interactive() {
return Ok(None);
}
let configure = Confirm::new()
.with_prompt("Configure changelog settings?")
.default(true)
.interact_opt()
.map_err(from_dialoguer)?;
if configure != Some(true) {
return Ok(None);
}
let changelog = if context.is_single_package {
ChangelogLocation::Root
} else {
select_changelog_location()?
};
let comparison_links = select_comparison_links()?;
let comparison_links_template = if comparison_links != ComparisonLinks::Disabled {
let template = prompt_comparison_links_template()?;
if template.is_empty() {
None
} else {
Some(template)
}
} else {
None
};
let dep_template = prompt_dependency_bump_changelog_template()?;
let dependency_bump_changelog_template =
if dep_template == "Updated dependency `{dependency}` to v{version}" {
None
} else {
Some(dep_template)
};
Ok(Some(ChangelogSettingsInput {
changelog,
comparison_links,
comparison_links_template,
dependency_bump_changelog_template,
}))
}
fn configure_version_settings(&self) -> Result<Option<VersionSettingsInput>> {
if !is_interactive() {
return Ok(None);
}
let configure = Confirm::new()
.with_prompt("Configure version settings?")
.default(true)
.interact_opt()
.map_err(from_dialoguer)?;
if configure != Some(true) {
return Ok(None);
}
let zero_version_behavior = select_zero_version_behavior()?;
let none_bump_behavior = select_none_bump_behavior()?;
let none_bump_promote_message_template =
if none_bump_behavior == NoneBumpBehavior::PromoteToPatch {
Some(prompt_none_bump_promote_message_template()?)
} else {
None
};
Ok(Some(VersionSettingsInput {
zero_version_behavior: Some(zero_version_behavior),
none_bump_behavior: Some(none_bump_behavior),
none_bump_promote_message_template,
}))
}
fn configure_filtering_settings(&self) -> Result<Option<FilteringSettingsInput>> {
if !is_interactive() {
return Ok(None);
}
let configure = Confirm::new()
.with_prompt("Configure file filtering?")
.default(false)
.interact_opt()
.map_err(from_dialoguer)?;
if configure != Some(true) {
return Ok(None);
}
let ignored_files = prompt_ignored_files_loop()?;
if ignored_files.is_empty() {
return Ok(None);
}
Ok(Some(FilteringSettingsInput { ignored_files }))
}
}
pub(crate) fn confirm_proceed(prompt: &str) -> crate::error::Result<bool> {
if !is_interactive() {
return Err(CliError::NotATty);
}
let confirmed = Confirm::new()
.with_prompt(prompt)
.default(true)
.interact_opt()
.map_err(from_dialoguer)?;
Ok(confirmed == Some(true))
}
fn from_dialoguer(e: dialoguer::Error) -> std::io::Error {
match e {
dialoguer::Error::IO(io) => io,
}
}
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::InvalidPackageBumpFormat { .. }
| CliError::InvalidBumpType { .. }
| CliError::VerificationFailed { .. }
| CliError::ChangesetDeleted { .. } => OperationError::Cancelled,
}
}
fn get_description_terminal() -> std::result::Result<DescriptionInput, CliError> {
println!();
println!("Enter description (press Enter 3 times to finish):");
println!();
let mut lines = Vec::new();
let mut empty_line_count = 0;
loop {
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
empty_line_count += 1;
if empty_line_count >= 2 {
break;
}
lines.push(String::new());
} else {
empty_line_count = 0;
lines.push(trimmed.to_string());
}
}
while lines.last().is_some_and(String::is_empty) {
lines.pop();
}
Ok(DescriptionInput::Provided(lines.join("\n")))
}
fn get_description_editor() -> std::result::Result<DescriptionInput, CliError> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let mut temp_file = tempfile::NamedTempFile::new()?;
let template =
"# Enter your changeset description above.\n# Lines starting with # will be ignored.\n";
temp_file.write_all(template.as_bytes())?;
temp_file.flush()?;
let status = Command::new(&editor)
.arg(temp_file.path())
.status()
.map_err(|source| CliError::EditorFailed { source })?;
if !status.success() {
return Err(CliError::EditorFailed {
source: std::io::Error::other(format!("editor exited with status: {status}")),
});
}
let content = fs::read_to_string(temp_file.path())?;
let description: String = content
.lines()
.filter(|line| !line.starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
Ok(DescriptionInput::Provided(description))
}
fn prompt_base_branch() -> Result<String> {
Ok(Input::new()
.with_prompt("Default base branch for git comparisons")
.default(DEFAULT_BASE_BRANCH.to_string())
.interact_text()
.map_err(from_dialoguer)?)
}
fn select_bool(prompt: &str, default: bool) -> Result<bool> {
Ok(Confirm::new()
.with_prompt(prompt)
.default(default)
.interact()
.map_err(from_dialoguer)?)
}
fn select_tag_format(is_single_package: bool) -> Result<TagFormat> {
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,
)
} 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)
}
}
}
}
fn select_changelog_location() -> Result<ChangelogLocation> {
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_comparison_links() -> Result<ComparisonLinks> {
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_zero_version_behavior() -> Result<ZeroVersionBehavior> {
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_none_bump_behavior() -> Result<NoneBumpBehavior> {
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 prompt_commit_title_template() -> Result<String> {
Ok(Input::new()
.with_prompt("Commit title template (placeholder: {new-version})")
.default("{new-version}".to_string())
.interact_text()
.map_err(from_dialoguer)?)
}
fn prompt_comparison_links_template() -> Result<String> {
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)?)
}
fn prompt_dependency_bump_changelog_template() -> Result<String> {
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)?)
}
fn prompt_ignored_files_loop() -> Result<Vec<String>> {
let mut patterns = Vec::new();
loop {
let pattern: String = Input::new()
.with_prompt("Add ignore pattern (empty to finish)")
.default(String::new())
.allow_empty(true)
.interact_text()
.map_err(from_dialoguer)?;
let trimmed = pattern.trim().to_string();
if trimmed.is_empty() {
break;
}
patterns.push(trimmed);
}
Ok(patterns)
}
fn prompt_none_bump_promote_message_template() -> Result<String> {
Ok(Input::new()
.with_prompt("Changelog message template for promoted none bumps")
.default("Internal architectural changes".to_string())
.interact_text()
.map_err(from_dialoguer)?)
}