use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fmt;
use std::fs;
use std::path::Path;
use std::process::Command as ProcessCommand;
use inquire::Confirm;
use inquire::Select;
use inquire::Text;
use inquire::validator::Validation;
use monochange_core::CliInputDefinition;
use monochange_core::CliInputKind;
use monochange_core::CliStepDefinition;
use monochange_core::CliStepInputValue;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use serde::Deserialize;
use toml_edit::Array;
use toml_edit::DocumentMut;
use toml_edit::InlineTable;
use toml_edit::Item;
use toml_edit::Table;
use toml_edit::Value;
use toml_edit::value;
const CONFIG_FILE: &str = "monochange.toml";
const STEP_KIND_SHELL_COMMAND: &str = "Command";
const SAVE_STEPS_LABEL: &str = "Save command steps";
const MUTED_TEXT_START: &str = "\x1b[2m";
const MUTED_TEXT_END: &str = "\x1b[0m";
const STEP_SCORE_BASE: i64 = 10_000;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CommandSummary {
pub(crate) name: String,
help_text: Option<String>,
step_count: usize,
}
impl fmt::Display for CommandSummary {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.help_text {
Some(help_text) => write!(formatter, "{} — {}", self.name, help_text),
None => write!(formatter, "{} — {} steps", self.name, self.step_count),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CommandStepDraft {
pub(crate) kind: String,
pub(crate) name: Option<String>,
pub(crate) command: Option<String>,
pub(crate) inputs: BTreeMap<String, CliStepInputValue>,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
pub(crate) struct CommandInputDraft {
pub(crate) name: String,
#[serde(rename = "type")]
pub(crate) kind: String,
#[serde(default)]
pub(crate) help_text: Option<String>,
#[serde(default)]
pub(crate) required: bool,
#[serde(default, deserialize_with = "deserialize_command_input_default")]
pub(crate) default: Option<String>,
#[serde(default)]
pub(crate) choices: Vec<String>,
#[serde(default)]
pub(crate) short: Option<char>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum CommandInputDefault {
String(String),
Boolean(bool),
Integer(i64),
Number(f64),
}
fn deserialize_command_input_default<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<CommandInputDefault>::deserialize(deserializer)?;
Ok(value.map(|value| {
match value {
CommandInputDefault::String(value) => value,
CommandInputDefault::Boolean(value) => value.to_string(),
CommandInputDefault::Integer(value) => value.to_string(),
CommandInputDefault::Number(value) => value.to_string(),
}
}))
}
impl CommandStepDraft {
pub(crate) fn built_in(kind: impl Into<String>) -> Self {
Self {
kind: kind.into(),
name: None,
command: None,
inputs: BTreeMap::new(),
}
}
fn shell_command(command: String, name: Option<String>) -> Self {
Self {
kind: STEP_KIND_SHELL_COMMAND.to_string(),
name,
command: Some(command),
inputs: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CommandStepUpdate {
KeepExisting,
Replace(Vec<CommandStepDraft>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CommandUpdate {
pub(crate) original_name: Option<String>,
pub(crate) name: String,
pub(crate) help_text: Option<String>,
pub(crate) dry_run: bool,
pub(crate) inputs: Vec<CommandInputDraft>,
pub(crate) steps: CommandStepUpdate,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CommandDetails {
name: String,
help_text: Option<String>,
dry_run: bool,
inputs: Vec<CommandInputDraft>,
steps: Vec<CommandStepDraft>,
}
#[derive(Clone, Debug, Deserialize, Default)]
struct RawCliRoot {
#[serde(default)]
cli: BTreeMap<String, RawCliCommand>,
}
#[derive(Clone, Debug, Deserialize, Default)]
struct RawCliCommand {
#[serde(default)]
help_text: Option<String>,
#[serde(default)]
inputs: Vec<CommandInputDraft>,
#[serde(default)]
steps: Vec<RawCliStep>,
#[serde(default)]
dry_run: bool,
}
#[derive(Clone, Debug, Deserialize)]
struct RawCliStep {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
command: Option<String>,
#[serde(default, deserialize_with = "deserialize_command_step_inputs")]
inputs: BTreeMap<String, CliStepInputValue>,
}
fn deserialize_command_step_inputs<'de, D>(
deserializer: D,
) -> Result<BTreeMap<String, CliStepInputValue>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum RawInputs {
Overrides(BTreeMap<String, CliStepInputValue>),
Inherited(Vec<String>),
}
match RawInputs::deserialize(deserializer)? {
RawInputs::Overrides(overrides) => Ok(overrides),
RawInputs::Inherited(names) => {
Ok(names
.into_iter()
.map(|name| (name, CliStepInputValue::Inherited))
.collect())
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum DashboardAction {
AddCommand,
EditCommand,
OpenEditor,
Quit,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct StepChoice {
kind: String,
description: &'static str,
is_save: bool,
}
impl StepChoice {
fn new(kind: impl Into<String>) -> Self {
let kind = kind.into();
Self {
description: step_choice_description(&kind),
kind,
is_save: false,
}
}
fn save() -> Self {
Self {
kind: SAVE_STEPS_LABEL.to_string(),
description: "Finish the command after adding at least one step",
is_save: true,
}
}
}
impl fmt::Display for StepChoice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{} {MUTED_TEXT_START}— {}{MUTED_TEXT_END}",
self.kind, self.description
)
}
}
impl fmt::Display for DashboardAction {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AddCommand => formatter.write_str("Add a new [cli.<name>] command"),
Self::EditCommand => formatter.write_str("Edit an existing command"),
Self::OpenEditor => formatter.write_str("Open monochange.toml in $VISUAL/$EDITOR"),
Self::Quit => formatter.write_str("Quit without changes"),
}
}
}
#[coverage(off)]
pub(crate) fn run_command_wizard(root: &Path) -> MonochangeResult<String> {
let config_path = root.join(CONFIG_FILE);
let config_text = read_config_text(&config_path)?;
let summaries = cli_command_summaries(&config_text)?;
let action = Select::new(
"Command dashboard — what do you want to do?",
dashboard_actions(!summaries.is_empty()),
)
.prompt()
.map_err(map_inquire_error)?;
match action {
DashboardAction::AddCommand => create_command(root, &config_path, &config_text, &summaries),
DashboardAction::EditCommand => edit_command(root, &config_path, &config_text, &summaries),
DashboardAction::OpenEditor => {
open_config_in_editor(&config_path)?;
Ok(format!("opened {} in your editor", config_path.display()))
}
DashboardAction::Quit => Ok("command wizard exited without changes".to_string()),
}
}
pub(crate) fn cli_command_summaries(config_text: &str) -> MonochangeResult<Vec<CommandSummary>> {
let root = parse_cli_root(config_text)?;
Ok(root
.cli
.into_iter()
.map(|(name, command)| {
CommandSummary {
name,
help_text: command.help_text,
step_count: command.steps.len(),
}
})
.collect())
}
pub(crate) fn upsert_cli_command_document(
config_text: &str,
update: &CommandUpdate,
) -> MonochangeResult<String> {
validate_command_update(update)?;
let mut document = parse_document(config_text)?;
let cli = ensure_cli_table(&mut document)?;
let original_name = update.original_name.as_deref();
let is_rename = original_name.is_some_and(|name| name != update.name);
let has_existing_target = cli.contains_key(&update.name);
if original_name.is_none() && has_existing_target {
return Err(config_error(format!(
"CLI command `{}` already exists; choose edit from the dashboard instead",
update.name
)));
}
if is_rename && has_existing_target {
return Err(config_error(format!(
"CLI command `{}` already exists; choose a different command name",
update.name
)));
}
if let Some(original_name) = original_name
&& original_name != update.name
{
let existing = cli
.remove(original_name)
.ok_or_else(|| config_error(format!("CLI command `{original_name}` does not exist")))?;
cli.insert(&update.name, existing);
}
if !cli.contains_key(&update.name) {
cli.insert(&update.name, Item::Table(Table::new()));
}
let command = cli
.get_mut(&update.name)
.and_then(Item::as_table_mut)
.ok_or_else(|| config_error(format!("[cli.{}] must be a TOML table", update.name)))?;
write_command_fields(command, update)?;
Ok(render_document(&document))
}
#[coverage(off)]
fn create_command(
root: &Path,
config_path: &Path,
config_text: &str,
summaries: &[CommandSummary],
) -> MonochangeResult<String> {
let update = prompt_command_update(None, summaries)?;
let rendered = upsert_cli_command_document(config_text, &update)?;
write_config_text(config_path, &rendered)?;
let relative_path = config_path.strip_prefix(root).unwrap_or(config_path);
Ok(format!(
"updated {} and added CLI command `{}`",
relative_path.display(),
update.name
))
}
#[coverage(off)]
fn edit_command(
root: &Path,
config_path: &Path,
config_text: &str,
summaries: &[CommandSummary],
) -> MonochangeResult<String> {
if summaries.is_empty() {
return Err(config_error(
"monochange.toml does not define any [cli.<name>] commands yet",
));
}
let selected = Select::new("Choose a command to edit:", summaries.to_owned())
.prompt()
.map_err(map_inquire_error)?;
let details = read_cli_command(config_text, &selected.name)?
.ok_or_else(|| config_error(format!("CLI command `{}` does not exist", selected.name)))?;
let update = prompt_command_update(Some(&details), summaries)?;
let rendered = upsert_cli_command_document(config_text, &update)?;
write_config_text(config_path, &rendered)?;
let relative_path = config_path.strip_prefix(root).unwrap_or(config_path);
if update.original_name.as_deref() == Some(update.name.as_str()) {
Ok(format!(
"updated {} and edited CLI command `{}`",
relative_path.display(),
update.name
))
} else {
Ok(format!(
"updated {} and renamed CLI command `{}` to `{}`",
relative_path.display(),
update.original_name.as_deref().unwrap_or_default(),
update.name
))
}
}
#[coverage(off)]
fn prompt_command_update(
existing: Option<&CommandDetails>,
summaries: &[CommandSummary],
) -> MonochangeResult<CommandUpdate> {
let existing_names = summaries
.iter()
.map(|summary| summary.name.as_str())
.collect::<Vec<_>>();
let original_name = existing.map(|command| command.name.as_str());
let name = prompt_command_name(original_name, &existing_names)?;
let help_text = prompt_help_text(existing.and_then(|command| command.help_text.as_deref()))?;
let dry_run = Confirm::new("Run this command in dry-run mode by default?")
.with_default(existing.is_some_and(|command| command.dry_run))
.prompt()
.map_err(map_inquire_error)?;
let mut inputs = prompt_command_inputs(existing)?;
let steps = prompt_step_update(existing, &mut inputs)?;
Ok(CommandUpdate {
original_name: original_name.map(str::to_string),
name,
help_text,
dry_run,
inputs,
steps,
})
}
#[coverage(off)]
fn prompt_command_name(
original_name: Option<&str>,
existing_names: &[&str],
) -> MonochangeResult<String> {
let original_name_for_validator = original_name.map(str::to_string);
let existing_names_for_validator = existing_names
.iter()
.map(|name| (*name).to_string())
.collect::<Vec<_>>();
let mut prompt =
Text::new("Command name (for [cli.<name>]):").with_validator(move |input: &str| {
match validate_command_name_for_prompt(
input,
original_name_for_validator.as_deref(),
&existing_names_for_validator,
) {
Ok(()) => Ok(Validation::Valid),
Err(message) => Ok(Validation::Invalid(message.into())),
}
});
if let Some(original_name) = original_name {
prompt = prompt.with_initial_value(original_name);
}
Ok(prompt
.prompt()
.map_err(map_inquire_error)?
.trim()
.to_string())
}
#[coverage(off)]
fn prompt_help_text(initial: Option<&str>) -> MonochangeResult<Option<String>> {
let mut prompt = Text::new("Help text (blank to omit):");
if let Some(initial) = initial {
prompt = prompt.with_initial_value(initial);
}
let help_text = prompt.prompt().map_err(map_inquire_error)?;
Ok(normalize_optional_text(&help_text))
}
#[coverage(off)]
fn prompt_step_update(
existing: Option<&CommandDetails>,
command_inputs: &mut Vec<CommandInputDraft>,
) -> MonochangeResult<CommandStepUpdate> {
if let Some(existing) = existing
&& !existing.steps.is_empty()
{
let step_summary = existing
.steps
.iter()
.map(step_label)
.collect::<Vec<_>>()
.join(", ");
let keep_existing = Confirm::new(&format!("Keep existing steps ({step_summary})?"))
.with_default(true)
.prompt()
.map_err(map_inquire_error)?;
if keep_existing {
return Ok(CommandStepUpdate::KeepExisting);
}
}
Ok(CommandStepUpdate::Replace(prompt_replacement_steps(
command_inputs,
)?))
}
#[coverage(off)]
fn prompt_replacement_steps(
command_inputs: &mut Vec<CommandInputDraft>,
) -> MonochangeResult<Vec<CommandStepDraft>> {
let mut steps = Vec::new();
loop {
let selected = Select::new(
"Step dashboard — add the next step or save:",
step_choices(),
)
.with_scorer(&step_choice_scorer)
.with_sorter(&step_choice_sorter)
.with_formatter(&|answer| answer.value.kind.clone())
.prompt()
.map_err(map_inquire_error)?;
if selected.is_save {
if steps.is_empty() {
return Err(config_error("a CLI command needs at least one step"));
}
return Ok(steps);
}
if selected.kind == STEP_KIND_SHELL_COMMAND {
steps.push(prompt_shell_command_step()?);
} else {
steps.push(prompt_built_in_step(&selected.kind, command_inputs)?);
}
}
}
#[coverage(off)]
fn prompt_shell_command_step() -> MonochangeResult<CommandStepDraft> {
let command = Text::new("Shell command to run:")
.with_validator(|input: &str| {
if input.trim().is_empty() {
Ok(Validation::Invalid("command cannot be empty".into()))
} else {
Ok(Validation::Valid)
}
})
.prompt()
.map_err(map_inquire_error)?;
let step_name = Text::new("Step display name (blank to omit):")
.prompt()
.map_err(map_inquire_error)?;
let name = normalize_optional_text(&step_name);
Ok(CommandStepDraft::shell_command(
command.trim().to_string(),
name,
))
}
#[coverage(off)]
fn prompt_built_in_step(
kind: &str,
command_inputs: &mut Vec<CommandInputDraft>,
) -> MonochangeResult<CommandStepDraft> {
let mut step = CommandStepDraft::built_in(kind);
let schemas = step_input_schemas_for_kind(kind);
if schemas.is_empty() {
return Ok(step);
}
let input_names = schemas
.iter()
.map(|schema| schema.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let add_default_inputs = Confirm::new(&format!(
"Add default inputs for `{kind}` to this command ({input_names})?"
))
.with_default(true)
.prompt()
.map_err(map_inquire_error)?;
if add_default_inputs {
step = command_step_with_default_inputs(kind, command_inputs)?;
return Ok(step);
}
let set_values = Confirm::new("Set fixed values for this step's inputs?")
.with_default(false)
.prompt()
.map_err(map_inquire_error)?;
if set_values {
step.inputs = prompt_step_input_values(&schemas)?;
}
Ok(step)
}
#[coverage(off)]
fn prompt_step_input_values(
schemas: &[CliInputDefinition],
) -> MonochangeResult<BTreeMap<String, CliStepInputValue>> {
let mut inputs = BTreeMap::new();
for schema in schemas {
let value = Text::new(&format!(
"Value for `{}` ({}; blank to omit):",
schema.name,
command_input_kind_name(schema.kind)
))
.with_validator(|input: &str| {
if input.trim().is_empty() {
return Ok(Validation::Valid);
}
match step_input_value_from_text(schema, input) {
Ok(_) => Ok(Validation::Valid),
Err(error) => Ok(Validation::Invalid(error.to_string().into())),
}
})
.prompt()
.map_err(map_inquire_error)?;
if let Some(value) = step_input_value_from_text(schema, &value)? {
inputs.insert(schema.name.clone(), value);
}
}
Ok(inputs)
}
#[coverage(off)]
fn prompt_command_inputs(
existing: Option<&CommandDetails>,
) -> MonochangeResult<Vec<CommandInputDraft>> {
if let Some(existing) = existing
&& !existing.inputs.is_empty()
{
let input_summary = existing
.inputs
.iter()
.map(command_input_label)
.collect::<Vec<_>>()
.join(", ");
let keep_existing =
Confirm::new(&format!("Keep existing command inputs ({input_summary})?"))
.with_default(true)
.prompt()
.map_err(map_inquire_error)?;
if keep_existing {
return Ok(existing.inputs.clone());
}
}
let add_inputs = Confirm::new("Add or replace top-level command inputs?")
.with_default(false)
.prompt()
.map_err(map_inquire_error)?;
if !add_inputs {
return Ok(Vec::new());
}
prompt_replacement_inputs()
}
#[coverage(off)]
fn prompt_replacement_inputs() -> MonochangeResult<Vec<CommandInputDraft>> {
let mut inputs = Vec::new();
loop {
let input = prompt_command_input(&inputs)?;
inputs.push(input);
let add_another = Confirm::new("Add another command input?")
.with_default(false)
.prompt()
.map_err(map_inquire_error)?;
if !add_another {
return Ok(inputs);
}
}
}
#[coverage(off)]
fn prompt_command_input(existing: &[CommandInputDraft]) -> MonochangeResult<CommandInputDraft> {
let existing_names = existing
.iter()
.map(|input| input.name.clone())
.collect::<Vec<_>>();
let name = Text::new("Input name (for --<name>):")
.with_validator(move |input: &str| {
match validate_command_input_name_for_prompt(input, &existing_names) {
Ok(()) => Ok(Validation::Valid),
Err(message) => Ok(Validation::Invalid(message.into())),
}
})
.prompt()
.map_err(map_inquire_error)?
.trim()
.to_string();
let kind = Select::new("Input type:", command_input_kind_choices())
.prompt()
.map_err(map_inquire_error)?;
let help_text = prompt_help_text(None)?;
let required = Confirm::new("Require this input?")
.with_default(false)
.prompt()
.map_err(map_inquire_error)?;
let default = prompt_command_input_default(kind.as_str())?;
let choices = prompt_command_input_choices(kind.as_str())?;
let short = prompt_command_input_short()?;
Ok(CommandInputDraft {
name,
kind,
help_text,
required,
default,
choices,
short,
})
}
#[coverage(off)]
fn prompt_command_input_default(kind: &str) -> MonochangeResult<Option<String>> {
if kind == "string_list" {
return Ok(None);
}
let default = Text::new("Default value (blank to omit):")
.prompt()
.map_err(map_inquire_error)?;
Ok(normalize_optional_text(&default))
}
#[coverage(off)]
fn prompt_command_input_choices(kind: &str) -> MonochangeResult<Vec<String>> {
if kind != "choice" {
return Ok(Vec::new());
}
let choices = Text::new("Choices (comma-separated):")
.with_validator(|input: &str| {
if comma_separated_values(input).is_empty() {
Ok(Validation::Invalid(
"choice inputs need at least one choice".into(),
))
} else {
Ok(Validation::Valid)
}
})
.prompt()
.map_err(map_inquire_error)?;
Ok(comma_separated_values(&choices))
}
#[coverage(off)]
fn prompt_command_input_short() -> MonochangeResult<Option<char>> {
let short = Text::new("Short flag character (blank to omit):")
.with_validator(|input: &str| {
match normalize_short_flag(input) {
Ok(_) => Ok(Validation::Valid),
Err(message) => Ok(Validation::Invalid(message.into())),
}
})
.prompt()
.map_err(map_inquire_error)?;
normalize_short_flag(&short).map_err(config_error)
}
fn dashboard_actions(has_commands: bool) -> Vec<DashboardAction> {
let mut actions = vec![DashboardAction::AddCommand];
if has_commands {
actions.push(DashboardAction::EditCommand);
}
actions.push(DashboardAction::OpenEditor);
actions.push(DashboardAction::Quit);
actions
}
fn read_cli_command(config_text: &str, name: &str) -> MonochangeResult<Option<CommandDetails>> {
let root = parse_cli_root(config_text)?;
Ok(root.cli.get(name).map(|command| {
CommandDetails {
name: name.to_string(),
help_text: command.help_text.clone(),
dry_run: command.dry_run,
inputs: command.inputs.clone(),
steps: command
.steps
.iter()
.map(|step| {
CommandStepDraft {
kind: step.kind.clone(),
name: step.name.clone(),
command: step.command.clone(),
inputs: step.inputs.clone(),
}
})
.collect(),
}
}))
}
fn write_command_fields(command: &mut Table, update: &CommandUpdate) -> MonochangeResult<()> {
match &update.help_text {
Some(help_text) => {
command["help_text"] = value(help_text.as_str());
}
None => {
command.remove("help_text");
}
}
if update.dry_run {
command["dry_run"] = value(true);
} else {
command.remove("dry_run");
}
if update.inputs.is_empty() {
command.remove("inputs");
} else {
command["inputs"] = Item::Value(Value::Array(command_input_array(&update.inputs)?));
}
if let CommandStepUpdate::Replace(steps) = &update.steps {
command["steps"] = Item::Value(Value::Array(step_array(steps)?));
}
Ok(())
}
fn command_input_array(inputs: &[CommandInputDraft]) -> MonochangeResult<Array> {
let mut array = Array::new();
for input in inputs {
array.push(Value::InlineTable(command_input_inline_table(input)?));
}
Ok(array)
}
fn command_input_inline_table(input: &CommandInputDraft) -> MonochangeResult<InlineTable> {
validate_command_input_draft(input)?;
let mut table = InlineTable::new();
table.insert("name", Value::from(input.name.as_str()));
table.insert("type", Value::from(input.kind.as_str()));
if let Some(help_text) = &input.help_text {
table.insert("help_text", Value::from(help_text.as_str()));
}
if input.required {
table.insert("required", Value::from(true));
}
if let Some(default) = &input.default {
table.insert("default", Value::from(default.as_str()));
}
if !input.choices.is_empty() {
table.insert("choices", Value::Array(string_array(&input.choices)));
}
if let Some(short) = input.short {
let short = short.to_string();
table.insert("short", Value::from(short.as_str()));
}
Ok(table)
}
fn step_array(steps: &[CommandStepDraft]) -> MonochangeResult<Array> {
let mut array = Array::new();
for step in steps {
array.push(Value::InlineTable(step_inline_table(step)?));
}
Ok(array)
}
fn step_inline_table(step: &CommandStepDraft) -> MonochangeResult<InlineTable> {
validate_step_draft(step)?;
let mut table = InlineTable::new();
table.insert("type", Value::from(step.kind.as_str()));
if let Some(name) = &step.name {
table.insert("name", Value::from(name.as_str()));
}
if let Some(command) = &step.command {
table.insert("command", Value::from(command.as_str()));
}
if !step.inputs.is_empty() {
table.insert("inputs", step_inputs_value(&step.inputs));
}
Ok(table)
}
fn step_inputs_value(inputs: &BTreeMap<String, CliStepInputValue>) -> Value {
if inputs
.values()
.all(|value| matches!(value, CliStepInputValue::Inherited))
{
return Value::Array(string_array(&inputs.keys().cloned().collect::<Vec<_>>()));
}
let mut table = InlineTable::new();
for (name, value) in inputs {
table.insert(name, step_input_value(name, value));
}
Value::InlineTable(table)
}
fn step_input_value(name: &str, input: &CliStepInputValue) -> Value {
match input {
CliStepInputValue::Inherited => Value::from(format!("{{{{ inputs.{name} }}}}").as_str()),
CliStepInputValue::String(value) => Value::from(value.as_str()),
CliStepInputValue::Boolean(value) => Value::from(*value),
CliStepInputValue::List(values) => Value::Array(string_array(values)),
}
}
fn step_choices() -> Vec<StepChoice> {
let mut choices = monochange_core::all_step_variants()
.into_iter()
.map(|step| StepChoice::new(step.kind_name()))
.collect::<Vec<_>>();
if !choices
.iter()
.any(|choice| choice.kind == STEP_KIND_SHELL_COMMAND)
{
choices.push(StepChoice::new(STEP_KIND_SHELL_COMMAND));
}
choices.sort_by_key(|choice| unfiltered_step_choice_rank(&choice.kind));
choices.push(StepChoice::save());
choices
}
fn step_choice_scorer(
input: &str,
option: &StepChoice,
_display: &str,
_index: usize,
) -> Option<i64> {
let input = input.trim().to_lowercase();
if input.is_empty() {
return Some(STEP_SCORE_BASE - unfiltered_step_choice_rank(&option.kind) as i64);
}
let searchable = format!("{} {}", option.kind, option.description).to_lowercase();
if !searchable.contains(&input) {
return None;
}
Some(STEP_SCORE_BASE - filtered_step_choice_rank(&option.kind) as i64)
}
fn step_choice_sorter(options: &mut [(usize, i64)]) {
options.sort_unstable_by_key(|(_index, score)| std::cmp::Reverse(*score));
}
fn unfiltered_step_choice_rank(kind: &str) -> usize {
match kind {
"PrepareRelease" => 0,
STEP_KIND_SHELL_COMMAND => 1,
"CreateChangeFile" => 2,
"Validate" => 3,
"Discover" => 4,
"DisplayVersions" => 5,
"CommitRelease" => 6,
"PublishRelease" => 7,
"PublishPackages" => 8,
"OpenReleaseRequest" => 9,
"AffectedPackages" => 10,
"Config" => 11,
"VerifyReleaseBranch" => 12,
"PlanPublishRateLimits" => 13,
"PlaceholderPublish" => 14,
"CommentReleasedIssues" => 15,
"DiagnoseChangesets" => 16,
"RetargetRelease" => 17,
SAVE_STEPS_LABEL => usize::MAX,
_ => 100 + filtered_step_choice_rank(kind),
}
}
fn filtered_step_choice_rank(kind: &str) -> usize {
let mut labels = monochange_core::all_step_variants()
.into_iter()
.map(|step| step.kind_name().to_string())
.collect::<Vec<_>>();
if !labels.iter().any(|label| label == STEP_KIND_SHELL_COMMAND) {
labels.push(STEP_KIND_SHELL_COMMAND.to_string());
}
labels.push(SAVE_STEPS_LABEL.to_string());
labels.sort();
labels
.iter()
.position(|label| label == kind)
.unwrap_or(labels.len())
}
fn step_choice_description(kind: &str) -> &'static str {
match kind {
"Config" => "Load workspace configuration for later steps",
"Validate" => "Check config, changesets, and package manifests",
"Discover" => "List packages across supported ecosystems",
"DisplayVersions" => "Show planned package and group versions",
"CreateChangeFile" => "Create an interactive or prefilled changeset",
"PrepareRelease" => "Plan a release and expose release context",
"CommitRelease" => "Commit prepared release files locally",
"VerifyReleaseBranch" => "Ensure a release commit is on an allowed branch",
"PublishRelease" => "Create or update hosted releases",
"PlaceholderPublish" => "Publish placeholder versions for missing packages",
"PublishPackages" => "Publish prepared package artifacts",
"PlanPublishRateLimits" => "Group publish work around registry rate limits",
"OpenReleaseRequest" => "Open or update a release pull request",
"CommentReleasedIssues" => "Comment on issues included in a release",
"AffectedPackages" => "Report packages affected by changed files",
"DiagnoseChangesets" => "Explain changeset and release-plan decisions",
"RetargetRelease" => "Retarget an existing release to another commit",
STEP_KIND_SHELL_COMMAND => "Run a custom shell command",
SAVE_STEPS_LABEL => "Finish the command after adding at least one step",
_ => "Add this CLI step",
}
}
fn step_label(step: &CommandStepDraft) -> String {
match (&step.name, &step.command) {
(Some(name), _) => format!("{} ({name})", step.kind),
(None, Some(command)) => format!("{} ({command})", step.kind),
(None, None) => step.kind.clone(),
}
}
fn validate_command_update(update: &CommandUpdate) -> MonochangeResult<()> {
validate_command_name(&update.name)?;
if let Some(original_name) = &update.original_name {
validate_command_name(original_name)?;
}
validate_command_inputs(&update.inputs)?;
match &update.steps {
CommandStepUpdate::KeepExisting => {}
CommandStepUpdate::Replace(steps) => {
if steps.is_empty() {
return Err(config_error("a CLI command needs at least one step"));
}
for step in steps {
validate_step_draft(step)?;
}
}
}
Ok(())
}
pub(crate) fn validate_command_name(name: &str) -> MonochangeResult<()> {
validate_command_name_message(name).map_err(config_error)
}
fn validate_command_name_for_prompt(
name: &str,
original_name: Option<&str>,
existing_names: &[String],
) -> Result<(), String> {
validate_command_name_message(name)?;
let name = name.trim();
if original_name != Some(name) && existing_names.iter().any(|existing| existing == name) {
return Err(format!("CLI command `{name}` already exists"));
}
Ok(())
}
fn validate_command_name_message(name: &str) -> Result<(), String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("command name cannot be empty".to_string());
}
if name != trimmed {
return Err("command name cannot include leading or trailing whitespace".to_string());
}
let name = trimmed;
if monochange_config::RESERVED_CLI_COMMAND_NAMES.contains(&name) {
return Err(format!(
"CLI command `{name}` collides with a reserved built-in command"
));
}
if name.starts_with("step:") {
return Err("command names cannot start with `step:`".to_string());
}
if name.starts_with("step ") {
return Err("command names cannot start with `step `".to_string());
}
if !trimmed
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err("use lowercase letters, digits, and hyphens only, e.g. release-pr".to_string());
}
if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
return Err("hyphens must separate words, e.g. release-pr".to_string());
}
Ok(())
}
fn validate_command_inputs(inputs: &[CommandInputDraft]) -> MonochangeResult<()> {
let mut names = BTreeSet::new();
let mut shorts = BTreeSet::new();
for input in inputs {
validate_command_input_draft(input)?;
if !names.insert(input.name.as_str()) {
return Err(config_error(format!(
"duplicate CLI input `{}`; input names must be unique",
input.name
)));
}
if let Some(short) = input.short
&& !shorts.insert(short)
{
return Err(config_error(format!(
"duplicate CLI input short flag `{short}`; short flags must be unique"
)));
}
}
Ok(())
}
fn validate_command_input_draft(input: &CommandInputDraft) -> MonochangeResult<()> {
validate_command_input_name(&input.name)?;
if !command_input_kind_is_known(&input.kind) {
return Err(config_error(format!(
"unknown CLI input type `{}`",
input.kind
)));
}
if input.kind != "choice" && !input.choices.is_empty() {
return Err(config_error(format!(
"only `choice` inputs can define choices (input `{}`)",
input.name
)));
}
if input.kind == "choice" && input.choices.is_empty() {
return Err(config_error(format!(
"choice input `{}` needs at least one choice",
input.name
)));
}
if input.kind == "string_list" && input.default.is_some() {
return Err(config_error(format!(
"string_list input `{}` cannot define a scalar default",
input.name
)));
}
if let Some(short) = input.short
&& !short.is_ascii_alphanumeric()
{
return Err(config_error(format!(
"input `{}` short flag must be an ASCII letter or digit",
input.name
)));
}
Ok(())
}
fn validate_command_input_name(name: &str) -> MonochangeResult<()> {
validate_command_input_name_message(name).map_err(config_error)
}
fn validate_command_input_name_for_prompt(
name: &str,
existing_names: &[String],
) -> Result<(), String> {
validate_command_input_name_message(name)?;
let name = name.trim();
if existing_names.iter().any(|existing| existing == name) {
return Err(format!("CLI input `{name}` already exists"));
}
Ok(())
}
fn validate_command_input_name_message(name: &str) -> Result<(), String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("input name cannot be empty".to_string());
}
if name != trimmed {
return Err("input name cannot include leading or trailing whitespace".to_string());
}
if !trimmed
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err(
"use lowercase letters, digits, and hyphens only, e.g. release-type".to_string(),
);
}
if trimmed.starts_with('-') || trimmed.ends_with('-') || trimmed.contains("--") {
return Err("hyphens must separate words, e.g. release-type".to_string());
}
Ok(())
}
fn command_input_kind_is_known(kind: &str) -> bool {
command_input_kind_choices()
.iter()
.any(|choice| choice == kind)
}
fn command_input_kind_choices() -> Vec<String> {
["string", "string_list", "path", "choice", "boolean"]
.into_iter()
.map(str::to_string)
.collect()
}
fn command_input_kind_name(kind: CliInputKind) -> &'static str {
match kind {
CliInputKind::String => "string",
CliInputKind::StringList => "string_list",
CliInputKind::Path => "path",
CliInputKind::Choice => "choice",
CliInputKind::Boolean => "boolean",
}
}
fn step_variant_for_kind(kind: &str) -> Option<CliStepDefinition> {
monochange_core::all_step_variants()
.into_iter()
.find(|step| step.kind_name() == kind)
}
fn step_input_schemas_for_kind(kind: &str) -> Vec<CliInputDefinition> {
step_variant_for_kind(kind).map_or_else(Vec::new, |step| step.step_inputs_schema())
}
fn command_input_from_step_schema(schema: &CliInputDefinition) -> CommandInputDraft {
CommandInputDraft {
name: schema.name.clone(),
kind: command_input_kind_name(schema.kind).to_string(),
help_text: schema.help_text.clone(),
required: schema.required,
default: schema.default.clone(),
choices: schema.choices.clone(),
short: None,
}
}
fn add_missing_command_inputs_for_step(
command_inputs: &mut Vec<CommandInputDraft>,
schemas: &[CliInputDefinition],
) {
let existing_names = command_inputs
.iter()
.map(|input| input.name.as_str())
.collect::<BTreeSet<_>>();
let additions = schemas
.iter()
.filter(|schema| !existing_names.contains(schema.name.as_str()))
.map(command_input_from_step_schema)
.collect::<Vec<_>>();
command_inputs.extend(additions);
}
fn inherited_step_inputs(schemas: &[CliInputDefinition]) -> BTreeMap<String, CliStepInputValue> {
schemas
.iter()
.map(|schema| (schema.name.clone(), CliStepInputValue::Inherited))
.collect()
}
fn command_step_with_default_inputs(
kind: &str,
command_inputs: &mut Vec<CommandInputDraft>,
) -> MonochangeResult<CommandStepDraft> {
let schemas = step_input_schemas_for_kind(kind);
if schemas.is_empty() && step_variant_for_kind(kind).is_none() {
return Err(config_error(format!("unknown CLI step type `{kind}`")));
}
add_missing_command_inputs_for_step(command_inputs, &schemas);
let mut step = CommandStepDraft::built_in(kind);
step.inputs = inherited_step_inputs(&schemas);
Ok(step)
}
fn step_input_value_from_text(
schema: &CliInputDefinition,
value: &str,
) -> MonochangeResult<Option<CliStepInputValue>> {
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
match schema.kind {
CliInputKind::Boolean => {
match value {
"true" => Ok(Some(CliStepInputValue::Boolean(true))),
"false" => Ok(Some(CliStepInputValue::Boolean(false))),
_ => {
Err(config_error(format!(
"input `{}` expects `true` or `false`",
schema.name
)))
}
}
}
CliInputKind::StringList => {
Ok(Some(CliStepInputValue::List(comma_separated_values(value))))
}
CliInputKind::Choice => {
if schema.choices.iter().any(|choice| choice == value) {
Ok(Some(CliStepInputValue::String(value.to_string())))
} else {
Err(config_error(format!(
"input `{}` expects one of: {}",
schema.name,
schema.choices.join(", ")
)))
}
}
CliInputKind::String | CliInputKind::Path => {
Ok(Some(CliStepInputValue::String(value.to_string())))
}
}
}
fn comma_separated_values(input: &str) -> Vec<String> {
input
.split(',')
.filter_map(normalize_optional_text)
.collect()
}
fn normalize_short_flag(input: &str) -> Result<Option<char>, String> {
let Some(value) = normalize_optional_text(input) else {
return Ok(None);
};
let mut chars = value.chars();
let short = chars
.next()
.expect("normalize_optional_text returns non-empty strings");
if chars.next().is_some() {
return Err("short flag must be exactly one character".to_string());
}
if !short.is_ascii_alphanumeric() {
return Err("short flag must be an ASCII letter or digit".to_string());
}
Ok(Some(short))
}
fn command_input_label(input: &CommandInputDraft) -> String {
let required = if input.required { ", required" } else { "" };
format!("{} ({}{required})", input.name, input.kind)
}
fn string_array(values: &[String]) -> Array {
let mut array = Array::new();
for value in values {
array.push(value.as_str());
}
array
}
fn validate_step_draft(step: &CommandStepDraft) -> MonochangeResult<()> {
if !step_kind_is_known(&step.kind) {
return Err(config_error(format!(
"unknown CLI step type `{}`",
step.kind
)));
}
if step.kind == STEP_KIND_SHELL_COMMAND {
let command = step.command.as_deref().unwrap_or_default().trim();
if command.is_empty() {
return Err(config_error(
"Command steps need a non-empty `command` value",
));
}
} else if step.command.is_some() {
return Err(config_error(format!(
"only `{STEP_KIND_SHELL_COMMAND}` steps can define `command`"
)));
}
validate_step_inputs(step)?;
Ok(())
}
fn validate_step_inputs(step: &CommandStepDraft) -> MonochangeResult<()> {
let Some(step_variant) = step_variant_for_kind(&step.kind) else {
return Ok(());
};
for (name, value) in &step.inputs {
let Some(expected_kind) = step_variant.expected_input_kind(name) else {
return Err(config_error(format!(
"step `{}` does not support input `{name}`",
step.kind
)));
};
if !step_input_value_matches_kind(value, expected_kind) {
return Err(config_error(format!(
"step `{}` input `{name}` expects `{}` values",
step.kind,
command_input_kind_name(expected_kind)
)));
}
if let CliStepInputValue::String(value) = value
&& let Some(choices) = step_variant.valid_input_choices(name)
&& !choices.contains(&value.as_str())
{
return Err(config_error(format!(
"step `{}` input `{name}` expects one of: {}",
step.kind,
choices.join(", ")
)));
}
}
Ok(())
}
fn step_input_value_matches_kind(value: &CliStepInputValue, kind: CliInputKind) -> bool {
match value {
CliStepInputValue::Inherited => true,
CliStepInputValue::String(_) => {
matches!(
kind,
CliInputKind::String | CliInputKind::Path | CliInputKind::Choice
)
}
CliStepInputValue::Boolean(_) => kind == CliInputKind::Boolean,
CliStepInputValue::List(_) => kind == CliInputKind::StringList,
}
}
fn step_kind_is_known(kind: &str) -> bool {
kind == STEP_KIND_SHELL_COMMAND
|| monochange_core::all_step_variants()
.into_iter()
.any(|step| step.kind_name() == kind)
}
fn ensure_cli_table(document: &mut DocumentMut) -> MonochangeResult<&mut Table> {
if !document.as_table().contains_key("cli") {
document["cli"] = Item::Table(Table::new());
}
document["cli"]
.as_table_mut()
.ok_or_else(|| config_error("[cli] must be a TOML table"))
}
fn parse_cli_root(config_text: &str) -> MonochangeResult<RawCliRoot> {
toml::from_str(config_text).map_err(|error| config_error(error.to_string()))
}
fn parse_document(config_text: &str) -> MonochangeResult<DocumentMut> {
config_text
.parse::<DocumentMut>()
.map_err(|error| config_error(error.to_string()))
}
fn render_document(document: &DocumentMut) -> String {
let mut rendered = document.to_string();
if !rendered.ends_with('\n') {
rendered.push('\n');
}
rendered
}
fn read_config_text(config_path: &Path) -> MonochangeResult<String> {
match fs::read_to_string(config_path) {
Ok(contents) => Ok(contents),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => {
Err(config_error(format!(
"failed to read {}: {error}",
config_path.display()
)))
}
}
}
fn write_config_text(config_path: &Path, contents: &str) -> MonochangeResult<()> {
fs::write(config_path, contents).map_err(|error| {
config_error(format!(
"failed to write {}: {error}",
config_path.display()
))
})
}
#[coverage(off)]
fn open_config_in_editor(config_path: &Path) -> MonochangeResult<()> {
if !config_path.exists() {
write_config_text(config_path, "")?;
}
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.map_err(|_| config_error("set $VISUAL or $EDITOR before choosing the editor action"))?;
let mut command_parts = shlex::split(&editor)
.filter(|parts| !parts.is_empty())
.ok_or_else(|| config_error("$VISUAL/$EDITOR could not be parsed as a shell command"))?;
let program = command_parts.remove(0);
let status = ProcessCommand::new(program)
.args(command_parts)
.arg(config_path)
.status()
.map_err(|error| config_error(format!("failed to start editor: {error}")))?;
if status.success() {
Ok(())
} else {
Err(config_error(format!("editor exited with status {status}")))
}
}
fn normalize_optional_text(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[coverage(off)]
fn map_inquire_error(error: inquire::error::InquireError) -> MonochangeError {
match error {
inquire::error::InquireError::OperationInterrupted
| inquire::error::InquireError::OperationCanceled => MonochangeError::Cancelled,
other => {
MonochangeError::Interactive {
message: other.to_string(),
}
}
}
}
fn config_error(message: impl Into<String>) -> MonochangeError {
MonochangeError::Config(message.into())
}
#[cfg(test)]
#[path = "__tests__/command_wizard_tests.rs"]
mod tests;