Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/tasks/task-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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]
Expand Down
12 changes: 10 additions & 2 deletions schema/mise-task.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions schema/mise.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
Expand Down
23 changes: 23 additions & 0 deletions src/config/config_file/mise_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
62 changes: 59 additions & 3 deletions src/task/task_confirm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Custom label for the affirmative answer (e.g. "Deploy")
#[serde(default, skip_serializing_if = "Option::is_none")]
yes: Option<String>,
/// Custom label for the negative answer (e.g. "Cancel")
#[serde(default, skip_serializing_if = "Option::is_none")]
no: Option<String>,
},
}

impl TaskConfirm {
Expand All @@ -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(),
}
}
}
Expand Down Expand Up @@ -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"));
}
}
31 changes: 25 additions & 6 deletions src/task/task_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<String>> =
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?;
Expand All @@ -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)?);
Comment on lines +2004 to +2008

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Label templates lack tests

Both added tests use fixed labels and stop after parsing. They would pass even if check_confirmation stopped rendering labels, leaving users with raw templates. Add a test with a plain message and yes/no labels using usage.*, then assert the rendered labels reach the prompt. This covers the new case where only a label needs Tera.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

}
}
}
let [message, yes_label, no_label]: [Option<String>; 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) => {
Expand Down
40 changes: 35 additions & 5 deletions src/ui/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ pub fn confirm<S: Into<String>>(message: S) -> eyre::Result<Confirmation> {
pub fn confirm_with_default<S: Into<String>>(
message: S,
default_yes: bool,
) -> eyre::Result<Confirmation> {
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<S: Into<String>>(
message: S,
default_yes: bool,
yes_label: Option<&str>,
no_label: Option<&str>,
) -> eyre::Result<Confirmation> {
let _lock = MUTEX.lock().unwrap(); // Prevent multiple prompts at once
ctrlc::show_cursor_after_ctrl_c();
Expand All @@ -92,11 +104,17 @@ pub fn confirm_with_default<S: Into<String>>(
// 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())?;
Expand All @@ -110,9 +128,21 @@ pub fn confirm_with_default<S: Into<String>>(
/// 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<Confirmation> {
fn read_confirm_from_stdin(
message: &str,
default_yes: bool,
yes_label: Option<&str>,
no_label: Option<&str>,
) -> eyre::Result<Confirmation> {
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.
Expand Down
Loading