use std::collections::BTreeMap;
use monochange_core::CliInputDefinition;
use monochange_core::CliInputKind;
use monochange_core::CliStepInputValue;
use tempfile::tempdir;
use toml::Value;
use toml_edit::DocumentMut;
use super::CommandInputDraft;
use super::CommandStepDraft;
use super::CommandStepUpdate;
use super::CommandUpdate;
use super::DashboardAction;
use super::SAVE_STEPS_LABEL;
use super::STEP_KIND_SHELL_COMMAND;
use super::cli_command_summaries;
use super::comma_separated_values;
use super::command_input_kind_choices;
use super::command_input_kind_is_known;
use super::command_input_kind_name;
use super::command_input_label;
use super::command_step_with_default_inputs;
use super::dashboard_actions;
use super::filtered_step_choice_rank;
use super::normalize_optional_text;
use super::normalize_short_flag;
use super::read_cli_command;
use super::read_config_text;
use super::render_document;
use super::step_choice_description;
use super::step_choice_scorer;
use super::step_choice_sorter;
use super::step_choices;
use super::step_input_schemas_for_kind;
use super::step_input_value_from_text;
use super::step_label;
use super::unfiltered_step_choice_rank;
use super::upsert_cli_command_document;
use super::validate_command_input_draft;
use super::validate_command_input_name_for_prompt;
use super::validate_command_inputs;
use super::validate_command_name;
use super::validate_command_name_for_prompt;
use super::validate_step_draft;
use super::write_config_text;
#[test]
fn upsert_cli_command_document_adds_command_to_empty_config() {
let update = CommandUpdate {
original_name: None,
name: "ship-it".to_string(),
help_text: Some("Ship the release".to_string()),
dry_run: false,
inputs: Vec::new(),
steps: CommandStepUpdate::Replace(vec![CommandStepDraft::built_in("Discover")]),
};
let rendered = render_update("", &update);
let value = parse_rendered_toml(&rendered);
let command = value
.get("cli")
.and_then(|cli| cli.get("ship-it"))
.and_then(Value::as_table)
.unwrap_or_else(|| panic!("command table should exist"));
assert_eq!(
command.get("help_text").and_then(Value::as_str),
Some("Ship the release")
);
assert!(!command.contains_key("dry_run"));
let steps = command
.get("steps")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("steps should be an array"));
assert_eq!(
steps[0].get("type").and_then(Value::as_str),
Some("Discover")
);
}
#[test]
fn upsert_cli_command_document_edits_existing_command_without_replacing_steps() {
let config = r#"
[cli.release]
help_text = "Old help"
dry_run = true
steps = [{ type = "PrepareRelease", allow_empty_changesets = true }]
"#;
let update = CommandUpdate {
original_name: Some("release".to_string()),
name: "release".to_string(),
help_text: Some("New help".to_string()),
dry_run: false,
inputs: Vec::new(),
steps: CommandStepUpdate::KeepExisting,
};
let rendered = render_update(config, &update);
let value = parse_rendered_toml(&rendered);
let command = value["cli"]["release"]
.as_table()
.unwrap_or_else(|| panic!("command table should exist"));
assert_eq!(command["help_text"].as_str(), Some("New help"));
assert!(!command.contains_key("dry_run"));
assert_eq!(
command["steps"][0]["allow_empty_changesets"].as_bool(),
Some(true)
);
}
#[test]
fn upsert_cli_command_document_renames_existing_command() {
let config = r#"
[cli.old-name]
steps = [{ type = "Validate" }]
[cli.keep]
steps = [{ type = "Discover" }]
"#;
let update = CommandUpdate {
original_name: Some("old-name".to_string()),
name: "new-name".to_string(),
help_text: None,
dry_run: false,
inputs: Vec::new(),
steps: CommandStepUpdate::KeepExisting,
};
let rendered = render_update(config, &update);
let value = parse_rendered_toml(&rendered);
let cli = value["cli"]
.as_table()
.unwrap_or_else(|| panic!("cli table should exist"));
assert!(!cli.contains_key("old-name"));
assert!(cli.contains_key("new-name"));
assert!(cli.contains_key("keep"));
assert_eq!(
cli["new-name"]["steps"][0]["type"].as_str(),
Some("Validate")
);
}
#[test]
fn upsert_cli_command_document_writes_shell_command_steps() {
let update = CommandUpdate {
original_name: None,
name: "lockfiles".to_string(),
help_text: None,
dry_run: true,
inputs: Vec::new(),
steps: CommandStepUpdate::Replace(vec![CommandStepDraft::shell_command(
"pnpm install --lockfile-only".to_string(),
Some("generate lockfiles".to_string()),
)]),
};
let rendered = render_update("", &update);
let value = parse_rendered_toml(&rendered);
let command = value["cli"]["lockfiles"]
.as_table()
.unwrap_or_else(|| panic!("command table should exist"));
let step = &command["steps"][0];
assert_eq!(command["dry_run"].as_bool(), Some(true));
assert_eq!(step["type"].as_str(), Some("Command"));
assert_eq!(step["name"].as_str(), Some("generate lockfiles"));
assert_eq!(
step["command"].as_str(),
Some("pnpm install --lockfile-only")
);
}
#[test]
fn upsert_cli_command_document_writes_command_inputs() {
let mut release_type = command_input_string("release-type");
release_type.kind = "choice".to_string();
release_type.help_text = Some("Release type to prepare".to_string());
release_type.required = true;
release_type.default = Some("patch".to_string());
release_type.choices = vec![
"patch".to_string(),
"minor".to_string(),
"major".to_string(),
];
release_type.short = Some('r');
let update = CommandUpdate {
original_name: None,
name: "release-pr".to_string(),
help_text: None,
dry_run: false,
inputs: vec![release_type],
steps: CommandStepUpdate::Replace(vec![CommandStepDraft::built_in("PrepareRelease")]),
};
let rendered = render_update("", &update);
let value = parse_rendered_toml(&rendered);
let input = &value["cli"]["release-pr"]["inputs"][0];
assert_eq!(input["name"].as_str(), Some("release-type"));
assert_eq!(input["type"].as_str(), Some("choice"));
assert_eq!(input["help_text"].as_str(), Some("Release type to prepare"));
assert_eq!(input["required"].as_bool(), Some(true));
assert_eq!(input["default"].as_str(), Some("patch"));
assert_eq!(input["short"].as_str(), Some("r"));
assert_eq!(input["choices"][0].as_str(), Some("patch"));
assert_eq!(input["choices"][2].as_str(), Some("major"));
}
#[test]
fn upsert_cli_command_document_writes_step_inputs() {
let mut inherited_step = CommandStepDraft::built_in("PrepareRelease");
inherited_step
.inputs
.insert("format".to_string(), CliStepInputValue::Inherited);
let mut fixed_step = CommandStepDraft::built_in("CommitRelease");
fixed_step
.inputs
.insert("no_verify".to_string(), CliStepInputValue::Boolean(true));
let mut mixed_step = CommandStepDraft::shell_command("echo publish".to_string(), None);
mixed_step
.inputs
.insert("channel".to_string(), CliStepInputValue::Inherited);
mixed_step.inputs.insert(
"message".to_string(),
CliStepInputValue::String("ship it".to_string()),
);
mixed_step.inputs.insert(
"packages".to_string(),
CliStepInputValue::List(vec!["core".to_string(), "cli".to_string()]),
);
let update = CommandUpdate {
original_name: None,
name: "release-pr".to_string(),
help_text: None,
dry_run: false,
inputs: Vec::new(),
steps: CommandStepUpdate::Replace(vec![inherited_step, fixed_step, mixed_step]),
};
let rendered = render_update("", &update);
let value = parse_rendered_toml(&rendered);
let steps = &value["cli"]["release-pr"]["steps"];
assert_eq!(steps[0]["inputs"][0].as_str(), Some("format"));
assert_eq!(steps[1]["inputs"]["no_verify"].as_bool(), Some(true));
assert_eq!(
steps[2]["inputs"]["channel"].as_str(),
Some("{{ inputs.channel }}")
);
assert_eq!(steps[2]["inputs"]["message"].as_str(), Some("ship it"));
assert_eq!(steps[2]["inputs"]["packages"][1].as_str(), Some("cli"));
}
#[test]
fn read_cli_command_reads_step_input_formats() {
let config = r#"
[cli.release-pr]
steps = [
{ type = "PrepareRelease", inputs = ["format"] },
{ type = "CommitRelease", inputs = { no_verify = true } },
]
"#;
let command = read_cli_command(config, "release-pr")
.unwrap_or_else(|error| panic!("command should parse: {error}"))
.unwrap_or_else(|| panic!("command should exist"));
assert_eq!(
command.steps[0].inputs.get("format"),
Some(&CliStepInputValue::Inherited)
);
assert_eq!(
command.steps[1].inputs.get("no_verify"),
Some(&CliStepInputValue::Boolean(true))
);
}
#[test]
fn command_step_with_default_inputs_adds_and_inherits_expected_inputs() {
let mut command_inputs = Vec::new();
let step = command_step_with_default_inputs("PrepareRelease", &mut command_inputs)
.unwrap_or_else(|error| panic!("step should be created: {error}"));
assert_eq!(command_inputs.len(), 2);
assert_eq!(command_inputs[0].name, "format");
assert_eq!(command_inputs[0].kind, "choice");
assert_eq!(command_inputs[0].choices[0], "text");
assert_eq!(command_inputs[1].name, "write_empty_release_record");
assert_eq!(command_inputs[1].kind, "boolean");
assert_eq!(
step.inputs.get("format"),
Some(&CliStepInputValue::Inherited)
);
assert_eq!(
step.inputs.get("write_empty_release_record"),
Some(&CliStepInputValue::Inherited)
);
assert_config_error(
command_step_with_default_inputs("NotARealStep", &mut command_inputs).map(|_| ()),
"unknown CLI step type `NotARealStep`",
);
}
#[test]
fn command_input_kind_name_returns_schema_names() {
assert_eq!(command_input_kind_name(CliInputKind::String), "string");
assert_eq!(
command_input_kind_name(CliInputKind::StringList),
"string_list"
);
assert_eq!(command_input_kind_name(CliInputKind::Path), "path");
assert_eq!(command_input_kind_name(CliInputKind::Choice), "choice");
assert_eq!(command_input_kind_name(CliInputKind::Boolean), "boolean");
}
#[test]
fn step_input_value_from_text_parses_typed_fixed_values() {
let schema = CliInputDefinition {
name: "items".to_string(),
kind: CliInputKind::StringList,
help_text: None,
required: false,
default: None,
choices: Vec::new(),
short: None,
};
assert_eq!(
step_input_value_from_text(&schema, "alpha, beta")
.unwrap_or_else(|error| panic!("list should parse: {error}")),
Some(CliStepInputValue::List(vec![
"alpha".to_string(),
"beta".to_string()
]))
);
assert_eq!(
step_input_value_from_text(&schema, " ")
.unwrap_or_else(|error| panic!("blank should parse: {error}")),
None
);
let string_schema = CliInputDefinition {
name: "reason".to_string(),
kind: CliInputKind::String,
help_text: None,
required: false,
default: None,
choices: Vec::new(),
short: None,
};
assert_eq!(
step_input_value_from_text(&string_schema, "release notes")
.unwrap_or_else(|error| panic!("string should parse: {error}")),
Some(CliStepInputValue::String("release notes".to_string()))
);
let path_schema = CliInputDefinition {
name: "output".to_string(),
kind: CliInputKind::Path,
help_text: None,
required: false,
default: None,
choices: Vec::new(),
short: None,
};
assert_eq!(
step_input_value_from_text(&path_schema, "dist/plan.json")
.unwrap_or_else(|error| panic!("path should parse: {error}")),
Some(CliStepInputValue::String("dist/plan.json".to_string()))
);
let boolean_schema = step_input_schemas_for_kind("CommitRelease")
.into_iter()
.find(|schema| schema.name == "no_verify")
.unwrap_or_else(|| panic!("no_verify schema should exist"));
assert_eq!(
step_input_value_from_text(&boolean_schema, "true")
.unwrap_or_else(|error| panic!("boolean should parse: {error}")),
Some(CliStepInputValue::Boolean(true))
);
assert_config_error(
step_input_value_from_text(&boolean_schema, "yes").map(|_| ()),
"expects `true` or `false`",
);
let format_schema = step_input_schemas_for_kind("PrepareRelease")
.into_iter()
.find(|schema| schema.name == "format")
.unwrap_or_else(|| panic!("format schema should exist"));
assert_eq!(
step_input_value_from_text(&format_schema, "json")
.unwrap_or_else(|error| panic!("choice should parse: {error}")),
Some(CliStepInputValue::String("json".to_string()))
);
assert_config_error(
step_input_value_from_text(&format_schema, "yaml").map(|_| ()),
"expects one of: text, json",
);
}
#[test]
fn cli_command_summaries_returns_configured_commands() {
let config = r#"
[cli.discover]
help_text = "Discover packages"
steps = [{ type = "Discover" }]
[cli.release-pr]
steps = [{ type = "PrepareRelease" }, { type = "OpenReleaseRequest" }]
"#;
let summaries = cli_command_summaries(config)
.unwrap_or_else(|error| panic!("commands should parse: {error}"));
let labels = summaries
.into_iter()
.map(|summary| summary.to_string())
.collect::<Vec<_>>();
assert_eq!(
labels,
vec![
"discover — Discover packages".to_string(),
"release-pr — 2 steps".to_string(),
]
);
}
#[test]
fn dashboard_actions_offer_edit_only_when_commands_exist() {
assert_eq!(
dashboard_actions(false),
vec![
DashboardAction::AddCommand,
DashboardAction::OpenEditor,
DashboardAction::Quit,
]
);
assert_eq!(
dashboard_actions(true),
vec![
DashboardAction::AddCommand,
DashboardAction::EditCommand,
DashboardAction::OpenEditor,
DashboardAction::Quit,
]
);
assert_eq!(
DashboardAction::AddCommand.to_string(),
"Add a new [cli.<name>] command"
);
assert_eq!(
DashboardAction::EditCommand.to_string(),
"Edit an existing command"
);
assert_eq!(
DashboardAction::OpenEditor.to_string(),
"Open monochange.toml in $VISUAL/$EDITOR"
);
assert_eq!(DashboardAction::Quit.to_string(), "Quit without changes");
}
#[test]
fn read_cli_command_returns_details_for_existing_command() {
let config = r#"
[cli.lockfiles]
help_text = "Generate lockfiles"
dry_run = true
inputs = [{ name = "workspace", type = "path", help_text = "Workspace path", required = true, short = "w" }]
steps = [{ type = "Command", name = "install", command = "pnpm install --lockfile-only" }]
"#;
let command = read_cli_command(config, "lockfiles")
.unwrap_or_else(|error| panic!("command should parse: {error}"))
.unwrap_or_else(|| panic!("command should exist"));
let missing = read_cli_command(config, "missing")
.unwrap_or_else(|error| panic!("command lookup should parse: {error}"));
assert!(missing.is_none());
assert_eq!(command.name, "lockfiles");
assert_eq!(command.help_text.as_deref(), Some("Generate lockfiles"));
assert!(command.dry_run);
assert_eq!(command.inputs.len(), 1);
assert_eq!(command.inputs[0].name, "workspace");
assert_eq!(command.inputs[0].kind, "path");
assert_eq!(
command.inputs[0].help_text.as_deref(),
Some("Workspace path")
);
assert!(command.inputs[0].required);
assert_eq!(command.inputs[0].short, Some('w'));
assert_eq!(command.steps.len(), 1);
assert_eq!(command.steps[0].kind, STEP_KIND_SHELL_COMMAND);
assert_eq!(command.steps[0].name.as_deref(), Some("install"));
assert_eq!(
command.steps[0].command.as_deref(),
Some("pnpm install --lockfile-only")
);
}
#[test]
fn read_cli_command_accepts_typed_input_defaults() {
let config = r#"
[cli.release]
inputs = [
{ name = "channel", type = "string", default = "stable" },
{ name = "interactive", type = "boolean", default = false },
{ name = "attempts", type = "string", default = 3 },
{ name = "threshold", type = "string", default = 2.5 },
]
steps = [{ type = "Command", command = "monochange version" }]
"#;
let command = read_cli_command(config, "release")
.unwrap_or_else(|error| panic!("command should parse typed defaults: {error}"))
.unwrap_or_else(|| panic!("command should exist"));
assert_eq!(command.inputs[0].default.as_deref(), Some("stable"));
assert_eq!(command.inputs[1].default.as_deref(), Some("false"));
assert_eq!(command.inputs[2].default.as_deref(), Some("3"));
assert_eq!(command.inputs[3].default.as_deref(), Some("2.5"));
}
#[test]
fn step_choices_and_labels_include_described_ranked_shell_command_and_save_action() {
let choices = step_choices();
let labels = choices
.iter()
.map(|choice| choice.kind.as_str())
.collect::<Vec<_>>();
assert_eq!(
labels.get(0..4),
Some(
[
"PrepareRelease",
STEP_KIND_SHELL_COMMAND,
"CreateChangeFile",
"Validate",
]
.as_slice()
)
);
assert!(labels.contains(&STEP_KIND_SHELL_COMMAND));
assert_eq!(labels.last().copied(), Some(SAVE_STEPS_LABEL));
assert!(choices[0].to_string().contains("\x1b[2m— Plan a release"));
assert!(
choices[1]
.to_string()
.contains("Run a custom shell command")
);
assert_eq!(step_choice_scorer("", &choices[0], "", 0), Some(10_000));
assert_eq!(
step_choice_scorer("command", &choices[1], "", 0),
Some(9_999)
);
assert_eq!(step_choice_scorer("zzzz", &choices[1], "", 0), None);
let save_choice = choices
.iter()
.find(|choice| choice.kind == SAVE_STEPS_LABEL)
.unwrap_or_else(|| panic!("save choice should exist"));
assert_eq!(
step_choice_scorer("", save_choice, "", 0),
Some(10_000 - usize::MAX as i64)
);
assert_eq!(step_choice_description("CustomStep"), "Add this CLI step");
assert_eq!(
unfiltered_step_choice_rank("CustomStep"),
100 + filtered_step_choice_rank("CustomStep")
);
let mut scored = vec![(1, 9_997), (0, 9_984), (2, 9_999)];
step_choice_sorter(&mut scored);
assert_eq!(scored, vec![(2, 9_999), (1, 9_997), (0, 9_984)]);
assert_eq!(
step_label(&CommandStepDraft {
kind: "Validate".to_string(),
name: Some("lint".to_string()),
command: None,
inputs: BTreeMap::new(),
}),
"Validate (lint)"
);
assert_eq!(
step_label(&CommandStepDraft {
kind: STEP_KIND_SHELL_COMMAND.to_string(),
name: None,
command: Some("cargo test".to_string()),
inputs: BTreeMap::new(),
}),
"Command (cargo test)"
);
assert_eq!(
step_label(&CommandStepDraft::built_in("Discover")),
"Discover"
);
}
#[test]
fn command_name_validation_reports_prompt_and_shape_errors() {
assert!(validate_command_name("release-pr").is_ok());
for (name, expected) in [
("", "command name cannot be empty"),
(
" release",
"command name cannot include leading or trailing whitespace",
),
("step release", "command names cannot start with `step `"),
("-release", "hyphens must separate words"),
] {
let error = validate_command_name(name)
.err()
.unwrap_or_else(|| panic!("{name:?} should be invalid"));
assert!(
error.to_string().contains(expected),
"expected {error} to contain {expected}"
);
}
let existing_names = vec!["release".to_string()];
let duplicate = validate_command_name_for_prompt("release", None, &existing_names)
.err()
.unwrap_or_else(|| panic!("duplicate command should be rejected"));
assert_eq!(duplicate, "CLI command `release` already exists");
assert!(validate_command_name_for_prompt("release", Some("release"), &existing_names).is_ok());
}
#[test]
fn validate_step_draft_rejects_unknown_or_misconfigured_steps() {
assert_config_error(
validate_step_draft(&CommandStepDraft::built_in("NotARealStep")),
"unknown CLI step type `NotARealStep`",
);
assert_config_error(
validate_step_draft(&CommandStepDraft {
kind: STEP_KIND_SHELL_COMMAND.to_string(),
name: None,
command: Some(" ".to_string()),
inputs: BTreeMap::new(),
}),
"Command steps need a non-empty `command` value",
);
assert_config_error(
validate_step_draft(&CommandStepDraft {
kind: "Discover".to_string(),
name: None,
command: Some("cargo test".to_string()),
inputs: BTreeMap::new(),
}),
"only `Command` steps can define `command`",
);
let mut unsupported_input = CommandStepDraft::built_in("Discover");
unsupported_input
.inputs
.insert("fix".to_string(), CliStepInputValue::Inherited);
assert_config_error(
validate_step_draft(&unsupported_input),
"step `Discover` does not support input `fix`",
);
let mut wrong_type = CommandStepDraft::built_in("CommitRelease");
wrong_type.inputs.insert(
"no_verify".to_string(),
CliStepInputValue::String("true".to_string()),
);
assert_config_error(
validate_step_draft(&wrong_type),
"step `CommitRelease` input `no_verify` expects `boolean` values",
);
let mut invalid_choice = CommandStepDraft::built_in("PrepareRelease");
invalid_choice.inputs.insert(
"format".to_string(),
CliStepInputValue::String("yaml".to_string()),
);
assert_config_error(
validate_step_draft(&invalid_choice),
"step `PrepareRelease` input `format` expects one of: text, json",
);
let mut list_input = CommandStepDraft::built_in("PublishPackages");
list_input.inputs.insert(
"package".to_string(),
CliStepInputValue::List(vec!["monochange".to_string()]),
);
assert!(validate_step_draft(&list_input).is_ok());
}
#[test]
fn validate_command_inputs_reject_invalid_or_duplicate_inputs() {
let mut unknown = command_input_string("release-type");
unknown.kind = "object".to_string();
assert_config_error(
validate_command_input_draft(&unknown),
"unknown CLI input type `object`",
);
let mut misplaced_choices = command_input_string("release-type");
misplaced_choices.choices = vec!["patch".to_string()];
assert_config_error(
validate_command_input_draft(&misplaced_choices),
"only `choice` inputs can define choices",
);
let mut choice_without_values = command_input_string("release-type");
choice_without_values.kind = "choice".to_string();
assert_config_error(
validate_command_input_draft(&choice_without_values),
"choice input `release-type` needs at least one choice",
);
assert_config_error(
validate_command_inputs(&[
command_input_string("workspace"),
command_input_string("workspace"),
]),
"duplicate CLI input `workspace`",
);
let mut short_a = command_input_string("workspace");
short_a.short = Some('w');
let mut short_b = command_input_string("worktree");
short_b.short = Some('w');
assert_config_error(
validate_command_inputs(&[short_a, short_b]),
"duplicate CLI input short flag `w`",
);
let mut list_with_default = command_input_string("packages");
list_with_default.kind = "string_list".to_string();
list_with_default.default = Some("monochange".to_string());
assert_config_error(
validate_command_input_draft(&list_with_default),
"string_list input `packages` cannot define a scalar default",
);
let mut invalid_short = command_input_string("workspace");
invalid_short.short = Some('-');
assert_config_error(
validate_command_input_draft(&invalid_short),
"input `workspace` short flag must be an ASCII letter or digit",
);
}
#[test]
fn command_input_helpers_normalize_and_validate_prompt_values() {
assert_eq!(
command_input_kind_choices(),
vec!["string", "string_list", "path", "choice", "boolean"]
);
assert!(command_input_kind_is_known("boolean"));
assert!(!command_input_kind_is_known("object"));
assert_eq!(
comma_separated_values(" patch, minor ,, major "),
vec!["patch", "minor", "major"]
);
assert_eq!(normalize_short_flag(" r "), Ok(Some('r')));
assert_eq!(normalize_short_flag(" "), Ok(None));
assert_eq!(
normalize_short_flag("rr"),
Err("short flag must be exactly one character".to_string())
);
assert_eq!(
normalize_short_flag("-"),
Err("short flag must be an ASCII letter or digit".to_string())
);
let existing_names = vec!["release-type".to_string()];
assert_eq!(
validate_command_input_name_for_prompt("release-type", &existing_names),
Err("CLI input `release-type` already exists".to_string())
);
assert!(validate_command_input_name_for_prompt("workspace", &existing_names).is_ok());
for (name, expected) in [
("", "input name cannot be empty"),
(
" release-type",
"input name cannot include leading or trailing whitespace",
),
(
"release_type",
"use lowercase letters, digits, and hyphens only",
),
("release--type", "hyphens must separate words"),
] {
let error = validate_command_input_name_for_prompt(name, &[])
.err()
.unwrap_or_else(|| panic!("{name:?} should be invalid"));
assert!(
error.contains(expected),
"expected {error} to contain {expected}"
);
}
let mut required_input = command_input_string("workspace");
required_input.required = true;
assert_eq!(
command_input_label(&required_input),
"workspace (string, required)"
);
}
#[test]
fn upsert_cli_command_document_rejects_create_and_rename_conflicts() {
let config = r#"
[cli.release]
steps = [{ type = "Validate" }]
[cli.deploy]
steps = [{ type = "Discover" }]
"#;
let duplicate_add = CommandUpdate {
original_name: None,
name: "release".to_string(),
help_text: None,
dry_run: false,
inputs: Vec::new(),
steps: CommandStepUpdate::Replace(vec![CommandStepDraft::built_in("Validate")]),
};
let duplicate_rename = CommandUpdate {
original_name: Some("release".to_string()),
name: "deploy".to_string(),
help_text: None,
dry_run: false,
inputs: Vec::new(),
steps: CommandStepUpdate::KeepExisting,
};
assert_config_error(
upsert_cli_command_document(config, &duplicate_add).map(|_| ()),
"CLI command `release` already exists; choose edit from the dashboard instead",
);
assert_config_error(
upsert_cli_command_document(config, &duplicate_rename).map(|_| ()),
"CLI command `deploy` already exists; choose a different command name",
);
}
#[test]
fn upsert_cli_command_document_rejects_empty_replacement_steps() {
let update = CommandUpdate {
original_name: None,
name: "release-pr".to_string(),
help_text: None,
dry_run: false,
inputs: Vec::new(),
steps: CommandStepUpdate::Replace(Vec::new()),
};
assert_config_error(
upsert_cli_command_document("", &update).map(|_| ()),
"a CLI command needs at least one step",
);
}
#[test]
fn render_document_and_optional_text_normalization_are_stable() {
let document = ""
.parse::<DocumentMut>()
.unwrap_or_else(|error| panic!("empty document should parse: {error}"));
assert_eq!(render_document(&document), "\n");
assert_eq!(normalize_optional_text(" \n\t "), None);
assert_eq!(
normalize_optional_text(" release "),
Some("release".to_string())
);
}
#[test]
fn config_text_helpers_handle_missing_files_and_io_errors() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let config_path = tempdir.path().join("monochange.toml");
assert_eq!(
read_config_text(&config_path).unwrap_or_else(|error| panic!("missing config: {error}")),
""
);
write_config_text(&config_path, "name = \"demo\"\n")
.unwrap_or_else(|error| panic!("write config: {error}"));
assert_eq!(
read_config_text(&config_path).unwrap_or_else(|error| panic!("read config: {error}")),
"name = \"demo\"\n"
);
let read_error = read_config_text(tempdir.path())
.err()
.unwrap_or_else(|| panic!("reading a directory should fail"));
assert!(read_error.to_string().contains("failed to read"));
let missing_parent_path = tempdir.path().join("missing").join("monochange.toml");
let write_error = write_config_text(&missing_parent_path, "")
.err()
.unwrap_or_else(|| panic!("writing through a missing parent should fail"));
assert!(write_error.to_string().contains("failed to write"));
assert!(!missing_parent_path.exists());
}
#[test]
fn validate_command_name_rejects_reserved_command_name() {
let error = validate_command_name("command")
.err()
.unwrap_or_else(|| panic!("command should be reserved"));
assert!(
error
.to_string()
.contains("collides with a reserved built-in command")
);
}
#[test]
fn validate_command_name_rejects_non_kebab_case_names() {
let error = validate_command_name("Release_PR")
.err()
.unwrap_or_else(|| panic!("uppercase names should be rejected"));
assert!(
error
.to_string()
.contains("use lowercase letters, digits, and hyphens only")
);
}
fn assert_config_error(result: monochange_core::MonochangeResult<()>, expected: &str) {
let error = result
.err()
.unwrap_or_else(|| panic!("expected config error containing {expected}"));
assert!(
error.to_string().contains(expected),
"expected {error} to contain {expected}"
);
}
fn render_update(config: &str, update: &CommandUpdate) -> String {
upsert_cli_command_document(config, update)
.unwrap_or_else(|error| panic!("command update should render: {error}"))
}
fn command_input_string(name: impl Into<String>) -> CommandInputDraft {
CommandInputDraft {
name: name.into(),
kind: "string".to_string(),
help_text: None,
required: false,
default: None,
choices: Vec::new(),
short: None,
}
}
fn parse_rendered_toml(rendered: &str) -> Value {
toml::from_str(rendered).unwrap_or_else(|error| panic!("rendered TOML should parse: {error}"))
}