diff --git a/docs/tasks/task-configuration.md b/docs/tasks/task-configuration.md index 640f6840b67..b81b9807fa7 100644 --- a/docs/tasks/task-configuration.md +++ b/docs/tasks/task-configuration.md @@ -415,7 +415,7 @@ run = "echo my internal task" ### `confirm` -- **Type**: `string` | `{ message: string, default: string }` +- **Type**: `string` | `{ message: string, default?: string, yes?: string, no?: string }` A message to show before running the task. This is useful for tasks that are destructive or take a long time to run. The user is prompted to confirm before the task's own `run` command executes. @@ -431,7 +431,16 @@ description = 'Cut a new release' file = 'scripts/release.sh' ``` -The confirm message supports Tera templates and can reference usage arguments: +Use `yes` and `no` to customize the labels of the two answers when plain "Yes" / "No" doesn't fit. +`default` is optional and defaults to `yes`. Piped answers (`echo y | mise run release`) still accept `y`/`n`. + +```mise-toml +[tasks.deploy] +confirm = { message = "Deploy to production?", yes = "Deploy", no = "Cancel", default = "no" } +run = "deploy.sh" +``` + +The confirm message and the `yes`/`no` labels support Tera templates and can reference usage arguments: ```mise-toml [tasks.deploy] diff --git a/schema/mise-task.json b/schema/mise-task.json index a1fce8ed31c..350e539e4b9 100644 --- a/schema/mise-task.json +++ b/schema/mise-task.json @@ -67,7 +67,7 @@ "type": "string" }, { - "description": "confirmation message before running this task with default value", + "description": "confirmation message before running this task with optional default value and custom answer labels", "properties": { "message": { "description": "confirmation message before running this task", @@ -77,9 +77,17 @@ "description": "default value for confirmation (yes/no/true/false)", "enum": ["yes", "no", "y", "n", "true", "false"], "type": "string" + }, + "yes": { + "description": "custom label for the affirmative answer", + "type": "string" + }, + "no": { + "description": "custom label for the negative answer", + "type": "string" } }, - "required": ["message", "default"], + "required": ["message"], "unevaluatedProperties": false, "type": "object" } diff --git a/schema/mise.json b/schema/mise.json index 89ae6fcde7f..401aa068cf6 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -5158,7 +5158,7 @@ "type": "string" }, { - "description": "confirmation message before running this task with default value", + "description": "confirmation message before running this task with optional default value and custom answer labels", "properties": { "message": { "description": "confirmation message before running this task", @@ -5168,9 +5168,17 @@ "description": "default value for confirmation (yes/no/true/false)", "enum": ["yes", "no", "y", "n", "true", "false"], "type": "string" + }, + "yes": { + "description": "custom label for the affirmative answer", + "type": "string" + }, + "no": { + "description": "custom label for the negative answer", + "type": "string" } }, - "required": ["message", "default"], + "required": ["message"], "unevaluatedProperties": false, "type": "object" } diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index eacda2e2a14..6499b5892e5 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -4395,6 +4395,29 @@ run = 'echo " $usage_environment"' )); } + #[test] + fn test_task_confirm_custom_labels_parse() { + let body = r#" +[tasks.deploy] +confirm = { message = "Deploy to prod?", yes = "Deploy", no = "Cancel" } +run = 'echo deploy' +"#; + + let path = std::path::Path::new("/tmp/mise.toml"); + let rf = MiseToml::from_str(body, path).unwrap(); + let task = rf.tasks.0.get("deploy").expect("deploy task should exist"); + + assert_eq!( + task.confirm, + Some(crate::task::TaskConfirm::Options { + message: "Deploy to prod?".to_string(), + default: None, + yes: Some("Deploy".to_string()), + no: Some("Cancel".to_string()), + }) + ); + } + #[test] fn test_task_templates_confirm_parses() { let body = r#" diff --git a/src/task/task_confirm.rs b/src/task/task_confirm.rs index e0912aaf38f..9551977e5b7 100644 --- a/src/task/task_confirm.rs +++ b/src/task/task_confirm.rs @@ -4,7 +4,17 @@ use serde::{Deserialize, Serialize}; #[serde(untagged)] pub enum TaskConfirm { Message(String), - Options { message: String, default: String }, + Options { + message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + default: Option, + /// Custom label for the affirmative answer (e.g. "Deploy") + #[serde(default, skip_serializing_if = "Option::is_none")] + yes: Option, + /// Custom label for the negative answer (e.g. "Cancel") + #[serde(default, skip_serializing_if = "Option::is_none")] + no: Option, + }, } impl TaskConfirm { @@ -18,7 +28,21 @@ impl TaskConfirm { pub(crate) fn default_value(&self) -> Option<&str> { match self { TaskConfirm::Message(_) => None, - TaskConfirm::Options { default, .. } => Some(default.as_str()), + TaskConfirm::Options { default, .. } => default.as_deref(), + } + } + + pub(crate) fn yes_label(&self) -> Option<&str> { + match self { + TaskConfirm::Message(_) => None, + TaskConfirm::Options { yes, .. } => yes.as_deref(), + } + } + + pub(crate) fn no_label(&self) -> Option<&str> { + match self { + TaskConfirm::Message(_) => None, + TaskConfirm::Options { no, .. } => no.as_deref(), } } } @@ -60,8 +84,40 @@ echo \"hello world\" task.confirm, Some(TaskConfirm::Options { message: "Proceed?".to_string(), - default: "yes".to_string() + default: Some("yes".to_string()), + yes: None, + no: None, }) ); } + + #[tokio::test] + #[cfg(unix)] + async fn test_from_path_confirm_custom_labels() { + use std::fs; + use tempfile::tempdir; + + let config = Config::get().await.unwrap(); + let temp_dir = tempdir().unwrap(); + let task_path = temp_dir.path().join("test_task"); + + fs::write( + &task_path, + r#"#!/bin/bash +#MISE confirm={message="Deploy to prod?", yes="Deploy", no="Cancel"} +echo \"hello world\" +"#, + ) + .unwrap(); + fs::set_permissions(&task_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let task = Task::from_path(&config, &task_path, temp_dir.path(), temp_dir.path()) + .await + .unwrap(); + let confirm = task.confirm.unwrap(); + assert_eq!(confirm.message(), "Deploy to prod?"); + assert_eq!(confirm.default_value(), None); + assert_eq!(confirm.yes_label(), Some("Deploy")); + assert_eq!(confirm.no_label(), Some("Cancel")); + } } diff --git a/src/task/task_executor.rs b/src/task/task_executor.rs index 3a56ef308f3..f3484797920 100644 --- a/src/task/task_executor.rs +++ b/src/task/task_executor.rs @@ -1981,7 +1981,14 @@ impl TaskExecutor { if let Some(confirm) = &task.confirm && !Settings::get().yes { - let message = if contains_template_syntax(confirm.message()) { + let texts = [ + Some(confirm.message()), + confirm.yes_label(), + confirm.no_label(), + ]; + let mut rendered: Vec> = + texts.iter().map(|t| t.map(str::to_string)).collect(); + if texts.iter().flatten().any(|t| contains_template_syntax(t)) { let config_root = task.config_root.clone().unwrap_or_default(); let mut tera = crate::tera::get_tera(Some(&config_root)); let mut tera_ctx = task.tera_ctx_for_usage(config).await?; @@ -1994,15 +2001,27 @@ impl TaskExecutor { } } tera_ctx.insert("usage", &usage_ctx); - render_str(&mut tera, confirm.message(), &tera_ctx)? - } else { - confirm.message().to_string() - }; + for (slot, text) in rendered.iter_mut().zip(texts) { + if let Some(text) = text + && contains_template_syntax(text) + { + *slot = Some(render_str(&mut tera, text, &tera_ctx)?); + } + } + } + let [message, yes_label, no_label]: [Option; 3] = + rendered.try_into().expect("three confirm texts"); + let message = message.unwrap_or_default(); let default_yes = match confirm.default_value() { Some(default) => Self::parse_confirm_default(default)?, None => true, // keep backwards compatible default of yes if not specified }; - match crate::ui::prompt::confirm_with_default(&message, default_yes) { + match crate::ui::prompt::confirm_with_labels( + &message, + default_yes, + yes_label.as_deref(), + no_label.as_deref(), + ) { Ok(Confirmation::Yes) => {} Ok(Confirmation::No) => return Err(eyre!("aborted by user")), Ok(Confirmation::Unanswered) => { diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index b92e66cca63..20d25793e3b 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -73,6 +73,18 @@ pub fn confirm>(message: S) -> eyre::Result { pub fn confirm_with_default>( message: S, default_yes: bool, +) -> eyre::Result { + confirm_with_labels(message, default_yes, None, None) +} + +/// Like [`confirm_with_default`], with optional custom labels for the two +/// answers. Typed answers (`y`/`n`) are accepted regardless of the labels, so +/// relabeling a prompt never breaks scripts that pipe an answer in. +pub fn confirm_with_labels>( + message: S, + default_yes: bool, + yes_label: Option<&str>, + no_label: Option<&str>, ) -> eyre::Result { let _lock = MUTEX.lock().unwrap(); // Prevent multiple prompts at once ctrlc::show_cursor_after_ctrl_c(); @@ -92,11 +104,17 @@ pub fn confirm_with_default>( // answer "yes" to every deletion. Read the line here instead, so that no // answer means no. if !std::io::stdin().is_terminal() { - return read_confirm_from_stdin(&message, default_yes); + return read_confirm_from_stdin(&message, default_yes, yes_label, no_label); } let theme = get_theme(); - let result = Confirm::new(message) - .selected(default_yes) + let mut confirm = Confirm::new(message).selected(default_yes); + if let Some(label) = yes_label { + confirm = confirm.affirmative(label); + } + if let Some(label) = no_label { + confirm = confirm.negative(label); + } + let result = confirm .theme(&theme) .run() .inspect_err(|_| restore_cursor())?; @@ -110,9 +128,21 @@ pub fn confirm_with_default>( /// Prints `message` to stderr and reads a single answer from a non-terminal /// stdin, which is what makes `echo y | mise ...` work without letting an /// unanswered prompt (EOF) count as consent. -fn read_confirm_from_stdin(message: &str, default_yes: bool) -> eyre::Result { +fn read_confirm_from_stdin( + message: &str, + default_yes: bool, + yes_label: Option<&str>, + no_label: Option<&str>, +) -> eyre::Result { let hint = if default_yes { "[Y/n]" } else { "[y/N]" }; - safe_eprintln!("{message} {hint}"); + match (yes_label, no_label) { + (None, None) => safe_eprintln!("{message} {hint}"), + _ => safe_eprintln!( + "{message} {hint} (y = {}, n = {})", + yes_label.unwrap_or("Yes"), + no_label.unwrap_or("No") + ), + } let mut line = String::new(); let read = std::io::stdin().lock().read_line(&mut line)?; // A 0-byte read is EOF, which is the case `demand` cannot distinguish.