Repository: casey/just
Pull request #1723 (2023-11-12 17:16:38)

Add a confirm attribute for recipes
This would add a "Confirm" attribute that requests confirmation before running a recipe. Runs prior to evaluation, and defaults to not running. Potential solution to issue [#1711](https://github.com/casey/just/issues/1711).

Hwatwasthat: > I just added some comments, and I thought of something else: If a recipe doesn't run, any recipe in the current invocation which depends on that recipe should also not run.

I've added in the confirmation during the running of dependencies. Now, if a recipe that requires confirmation is run as a dependency and is not confirmed then it will return an error of type `DependencyNotConfirmed`, which I could only find a way to insert the name of the dependency being run, I can't find an easy way to inject the parents name without adding in a lot more convolution.

The confirmation is carried out at the start of the `Justfile::run_recipe` function, as I couldn't see another place to put it that would prevent anything running and would allow the returning of an error rather than the ok of a single recipe that is being run (as that would stop any later recipes that were from running as I understand it, which is not the intended use of confirmation (again, as I understand it).
Hwatwasthat: Apologies, on testing a bit I realised that I've added in a double confirm. I'll figure out the best way to resolve this and then update the pull.
casey: Aside from the double confirm, that all sounds good. I think returning an error if a recipe isn't confirmed is sensible. I think that if we get to a recipe that needs confirmation, and it isn't confirmed, we can abort the whole run, and not run any other recipes. Although it is true that we could run any recipes which aren't dependencies of the unconfirmed recipe, not running them doesn't seem like a big deal.

Also, this will require a couple of tests: One that a recipe that requires and receives confirmation runs, and one that a recipe which requires confirmation but doesn't receive it does not run.
casey: Also, if you feel like adding a `--yes` flag that confirms all recipes (per this comment https://github.com/casey/just/issues/1711#issuecomment-1807732753) which seems useful, go for it, otherwise it can be left to another PR.
Hwatwasthat: > Aside from the double confirm, that all sounds good. I think returning an error if a recipe isn't confirmed is sensible. I think that if we get to a recipe that needs confirmation, and it isn't confirmed, we can abort the whole run, and not run any other recipes. Although it is true that we could run any recipes which aren't dependencies of the unconfirmed recipe, not running them doesn't seem like a big deal.
> 
> Also, this will require a couple of tests: One that a recipe that requires and receives confirmation runs, and one that a recipe which requires confirmation but doesn't receive it does not run.

Ok, that makes things easier then, I've got a version working that does that so I'll just plow on with it. Tests to come with that in the next commit.
Hwatwasthat: > Also, if you feel like adding a `--yes` flag that confirms all recipes (per this comment [#1711 (comment)](https://github.com/casey/just/issues/1711#issuecomment-1807732753)) which seems useful, go for it, otherwise it can be left to another PR.

I was just wondering on the name. yes is a bit ambiguous, maybe `--force-confirm` (just to make it clear), I feel like `--force` or `--no-confirm` doesn't work, as force is ambiguous and no confirm reads to me as the inverse (automatically denying confirm stuff). I have a flag working, right now it's `--yes`.

Patch:
diff --git a/.gitignore b/.gitignore
index e6c81f9f..ec381e40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 .DS_Store
 .idea
 /.vagrant
+/.vscode
 /README.html
 /book/en/build
 /book/en/src
diff --git a/README.md b/README.md
index 09368f02..23cee3e4 100644
--- a/README.md
+++ b/README.md
@@ -1251,13 +1251,14 @@ Recipes may be annotated with attributes that change their behavior.
 
 | Name                                | Description                                     |
 | ----------------------------------- | ----------------------------------------------- |
-| `[no-cd]`<sup>1.9.0</sup>           | Don't change directory before executing recipe. |
-| `[no-exit-message]`<sup>1.7.0</sup> | Don't print an error message if recipe fails.   |
+| `[confirm]`<sup>master</sup>        | Require confirmation prior to executing recipe. |
 | `[linux]`<sup>1.8.0</sup>           | Enable recipe on Linux.                         |
 | `[macos]`<sup>1.8.0</sup>           | Enable recipe on MacOS.                         |
+| `[no-cd]`<sup>1.9.0</sup>           | Don't change directory before executing recipe. |
+| `[no-exit-message]`<sup>1.7.0</sup> | Don't print an error message if recipe fails.   |
+| `[private]`<sup>1.10.0</sup>        | See [Private Recipes](#private-recipes).        |
 | `[unix]`<sup>1.8.0</sup>            | Enable recipe on Unixes. (Includes MacOS).      |
 | `[windows]`<sup>1.8.0</sup>         | Enable recipe on Windows.                       |
-| `[private]`<sup>1.10.0</sup>         | See [Private Recipes](#private-recipes).        |
 
 A recipe can have multiple attributes, either on multiple lines:
 
@@ -1321,6 +1322,23 @@ Can be used with paths that are relative to the current directory, because
 `[no-cd]` prevents `just` from changing the current directory when executing
 `commit`.
 
+### Requiring Confirmation for Recipes<sup>master</sup>
+
+`just` normally executes all recipes unless there is an error. The `[confirm]`
+attribute allows recipes require confirmation in the terminal prior to running.
+This can be overridden by passing `--yes` to `just`, which will automatically
+confirm any recipes marked by this attribute.
+
+Recipes dependent on a recipe that requires confirmation will not be run if the
+relied upon recipe is not confirmed, as well as recipes passed after any recipe
+that requires confirmation.
+
+```just
+[confirm]
+delete all:
+  rm -rf *
+```
+
 ### Command Evaluation Using Backticks
 
 Backticks can be used to store the result of commands:
diff --git a/completions/just.bash b/completions/just.bash
index 99d7fdb7..4647ceb0 100644
--- a/completions/just.bash
+++ b/completions/just.bash
@@ -20,7 +20,7 @@ _just() {
 
     case "${cmd}" in
         just)
-            opts=" -n -q -u -v -e -l -h -V -f -d -c -s  --check --dry-run --highlight --no-dotenv --no-highlight --quiet --shell-command --clear-shell-args --unsorted --unstable --verbose --changelog --choose --dump --edit --evaluate --fmt --init --list --summary --variables --help --version --chooser --color --command-color --dump-format --list-heading --list-prefix --justfile --set --shell --shell-arg --working-directory --command --completions --show --dotenv-filename --dotenv-path  <ARGUMENTS>... "
+            opts=" -n -q -u -v -e -l -h -V -f -d -c -s  --check --yes --dry-run --highlight --no-dotenv --no-highlight --quiet --shell-command --clear-shell-args --unsorted --unstable --verbose --changelog --choose --dump --edit --evaluate --fmt --init --list --summary --variables --help --version --chooser --color --command-color --dump-format --list-heading --list-prefix --justfile --set --shell --shell-arg --working-directory --command --completions --show --dotenv-filename --dotenv-path  <ARGUMENTS>... "
                 if [[ ${cur} == -* ]] ; then
                     COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
                     return 0
diff --git a/completions/just.elvish b/completions/just.elvish
index 1b3be811..51176476 100644
--- a/completions/just.elvish
+++ b/completions/just.elvish
@@ -35,6 +35,7 @@ edit:completion:arg-completer[just] = [@words]{
             cand --dotenv-filename 'Search for environment file named <DOTENV-FILENAME> instead of `.env`'
             cand --dotenv-path 'Load environment file at <DOTENV-PATH> instead of searching for one'
             cand --check 'Run `--fmt` in ''check'' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required.'
+            cand --yes 'Automatically confirm all recipes.'
             cand -n 'Print what just would do without doing it'
             cand --dry-run 'Print what just would do without doing it'
             cand --highlight 'Highlight echoed recipe lines in bold'
diff --git a/completions/just.fish b/completions/just.fish
index 7aedd4a8..aa126bdd 100644
--- a/completions/just.fish
+++ b/completions/just.fish
@@ -52,6 +52,7 @@ complete -c just -n "__fish_use_subcommand" -s s -l show -d 'Show information ab
 complete -c just -n "__fish_use_subcommand" -l dotenv-filename -d 'Search for environment file named <DOTENV-FILENAME> instead of `.env`'
 complete -c just -n "__fish_use_subcommand" -l dotenv-path -d 'Load environment file at <DOTENV-PATH> instead of searching for one'
 complete -c just -n "__fish_use_subcommand" -l check -d 'Run `--fmt` in \'check\' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required.'
+complete -c just -n "__fish_use_subcommand" -l yes -d 'Automatically confirm all recipes.'
 complete -c just -n "__fish_use_subcommand" -s n -l dry-run -d 'Print what just would do without doing it'
 complete -c just -n "__fish_use_subcommand" -l highlight -d 'Highlight echoed recipe lines in bold'
 complete -c just -n "__fish_use_subcommand" -l no-dotenv -d 'Don\'t load `.env` file'
diff --git a/completions/just.powershell b/completions/just.powershell
index 0842dc8c..d2907e3c 100644
--- a/completions/just.powershell
+++ b/completions/just.powershell
@@ -40,6 +40,7 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock {
             [CompletionResult]::new('--dotenv-filename', 'dotenv-filename', [CompletionResultType]::ParameterName, 'Search for environment file named <DOTENV-FILENAME> instead of `.env`')
             [CompletionResult]::new('--dotenv-path', 'dotenv-path', [CompletionResultType]::ParameterName, 'Load environment file at <DOTENV-PATH> instead of searching for one')
             [CompletionResult]::new('--check', 'check', [CompletionResultType]::ParameterName, 'Run `--fmt` in ''check'' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required.')
+            [CompletionResult]::new('--yes', 'yes', [CompletionResultType]::ParameterName, 'Automatically confirm all recipes.')
             [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Print what just would do without doing it')
             [CompletionResult]::new('--dry-run', 'dry-run', [CompletionResultType]::ParameterName, 'Print what just would do without doing it')
             [CompletionResult]::new('--highlight', 'highlight', [CompletionResultType]::ParameterName, 'Highlight echoed recipe lines in bold')
diff --git a/completions/just.zsh b/completions/just.zsh
index 4ddfa867..b2ee3d65 100644
--- a/completions/just.zsh
+++ b/completions/just.zsh
@@ -36,6 +36,7 @@ _just() {
 '(--dotenv-path)--dotenv-filename=[Search for environment file named <DOTENV-FILENAME> instead of `.env`]' \
 '--dotenv-path=[Load environment file at <DOTENV-PATH> instead of searching for one]' \
 '--check[Run `--fmt` in '\''check'\'' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required.]' \
+'--yes[Automatically confirm all recipes.]' \
 '(-q --quiet)-n[Print what just would do without doing it]' \
 '(-q --quiet)--dry-run[Print what just would do without doing it]' \
 '--highlight[Highlight echoed recipe lines in bold]' \
diff --git a/src/attribute.rs b/src/attribute.rs
index ffd532f4..b4c71665 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -6,6 +6,7 @@ use super::*;
 #[strum(serialize_all = "kebab-case")]
 #[serde(rename_all = "kebab-case")]
 pub(crate) enum Attribute {
+  Confirm,
   Linux,
   Macos,
   NoCd,
diff --git a/src/config.rs b/src/config.rs
index 682229bc..9e8b7b92 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -33,6 +33,7 @@ pub(crate) struct Config {
   pub(crate) unsorted: bool,
   pub(crate) unstable: bool,
   pub(crate) verbosity: Verbosity,
+  pub(crate) yes: bool,
 }
 
 mod cmd {
@@ -106,6 +107,7 @@ mod arg {
   pub(crate) const UNSTABLE: &str = "UNSTABLE";
   pub(crate) const VERBOSE: &str = "VERBOSE";
   pub(crate) const WORKING_DIRECTORY: &str = "WORKING-DIRECTORY";
+  pub(crate) const YES: &str = "YES";
 
   pub(crate) const COLOR_ALWAYS: &str = "always";
   pub(crate) const COLOR_AUTO: &str = "auto";
@@ -168,6 +170,7 @@ impl Config {
           .possible_values(arg::COMMAND_COLOR_VALUES)
           .help("Echo recipe lines in <COMMAND-COLOR>"),
       )
+      .arg(Arg::with_name(arg::YES).long("yes").help("Automatically confirm all recipes."))
       .arg(
         Arg::with_name(arg::DRY_RUN)
           .short("n")
@@ -622,14 +625,14 @@ impl Config {
 
     Ok(Self {
       check: matches.is_present(arg::CHECK),
+      color,
+      command_color,
+      dotenv_filename: matches.value_of(arg::DOTENV_FILENAME).map(str::to_owned),
+      dotenv_path: matches.value_of(arg::DOTENV_PATH).map(PathBuf::from),
       dry_run: matches.is_present(arg::DRY_RUN),
       dump_format: Self::dump_format_from_matches(matches)?,
       highlight: !matches.is_present(arg::NO_HIGHLIGHT),
-      shell: matches.value_of(arg::SHELL).map(str::to_owned),
-      load_dotenv: !matches.is_present(arg::NO_DOTENV),
-      shell_command: matches.is_present(arg::SHELL_COMMAND),
-      unsorted: matches.is_present(arg::UNSORTED),
-      unstable,
+      invocation_directory,
       list_heading: matches
         .value_of(arg::LIST_HEADING)
         .unwrap_or("Available recipes:\n")
@@ -638,15 +641,16 @@ impl Config {
         .value_of(arg::LIST_PREFIX)
         .unwrap_or("    ")
         .to_owned(),
-      color,
-      command_color,
-      invocation_directory,
+      load_dotenv: !matches.is_present(arg::NO_DOTENV),
       search_config,
+      shell: matches.value_of(arg::SHELL).map(str::to_owned),
       shell_args,
+      shell_command: matches.is_present(arg::SHELL_COMMAND),
       subcommand,
-      dotenv_filename: matches.value_of(arg::DOTENV_FILENAME).map(str::to_owned),
-      dotenv_path: matches.value_of(arg::DOTENV_PATH).map(PathBuf::from),
+      unsorted: matches.is_present(arg::UNSORTED),
+      unstable,
       verbosity,
+      yes: matches.is_present(arg::YES),
     })
   }
 
diff --git a/src/error.rs b/src/error.rs
index b14cc5a2..60548697 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -88,6 +88,9 @@ pub(crate) enum Error<'src> {
     function: Name<'src>,
     message: String,
   },
+  GetConfirmation {
+    io_error: io::Error,
+  },
   IncludeMissingPath {
     file: PathBuf,
     line: usize,
@@ -111,6 +114,9 @@ pub(crate) enum Error<'src> {
   },
   NoChoosableRecipes,
   NoRecipes,
+  NotConfirmed {
+    recipe: &'src str,
+  },
   RegexCompile {
     source: regex::Error,
   },
@@ -329,6 +335,9 @@ impl<'src> ColorDisplay for Error<'src> {
         let function = function.lexeme();
         write!(f, "Call to function `{function}` failed: {message}")?;
       }
+      GetConfirmation { io_error } => {
+        write!(f, "Failed to read confirmation from stdin: {io_error}")?;
+      }
       IncludeMissingPath { file: justfile, line } => {
         let line = line.ordinal();
         let justfile = justfile.display();
@@ -357,6 +366,9 @@ impl<'src> ColorDisplay for Error<'src> {
       }
       NoChoosableRecipes => write!(f, "Justfile contains no choosable recipes.")?,
       NoRecipes => write!(f, "Justfile contains no recipes.")?,
+      NotConfirmed { recipe } => {
+        write!(f, "Recipe `{recipe}` was not confirmed")?;
+      }
       RegexCompile { source } => write!(f, "{source}")?,
       Search { search_error } => Display::fmt(search_error, f)?,
       Shebang { recipe, command, argument, io_error} => {
diff --git a/src/justfile.rs b/src/justfile.rs
index 4d59c525..3648c769 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -290,6 +290,12 @@ impl<'src> Justfile<'src> {
       return Ok(());
     }
 
+    if !context.config.yes && !recipe.confirm()? {
+      return Err(Error::NotConfirmed {
+        recipe: recipe.name(),
+      });
+    }
+
     let (outer, positional) = Evaluator::evaluate_parameters(
       context.config,
       dotenv,
diff --git a/src/recipe.rs b/src/recipe.rs
index 407115d7..4ea8bc2b 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -63,6 +63,20 @@ impl<'src, D> Recipe<'src, D> {
     self.name.line
   }
 
+  pub(crate) fn confirm(&self) -> RunResult<'src, bool> {
+    if self.attributes.contains(&Attribute::Confirm) {
+      eprint!("Run recipe `{}`? ", self.name);
+      let mut line = String::new();
+      std::io::stdin()
+        .read_line(&mut line)
+        .map_err(|io_error| Error::GetConfirmation { io_error })?;
+      let line = line.trim().to_lowercase();
+      Ok(line == "y" || line == "yes")
+    } else {
+      Ok(true)
+    }
+  }
+
   pub(crate) fn public(&self) -> bool {
     !self.private && !self.attributes.contains(&Attribute::Private)
   }


Test patch:
diff --git a/tests/confirm.rs b/tests/confirm.rs
new file mode 100644
index 00000000..8b37489c
--- /dev/null
+++ b/tests/confirm.rs
@@ -0,0 +1,105 @@
+use super::*;
+
+#[test]
+fn confirm_recipe_arg() {
+  Test::new()
+    .arg("--yes")
+    .justfile(
+      "
+        [confirm]
+        requires_confirmation:
+            echo confirmed
+        ",
+    )
+    .stderr("echo confirmed\n")
+    .stdout("confirmed\n")
+    .run();
+}
+
+#[test]
+fn recipe_with_confirm_recipe_dependency_arg() {
+  Test::new()
+    .arg("--yes")
+    .justfile(
+      "
+        dep_confirmation: requires_confirmation
+            echo confirmed2
+
+        [confirm]
+        requires_confirmation:
+            echo confirmed
+        ",
+    )
+    .stderr("echo confirmed\necho confirmed2\n")
+    .stdout("confirmed\nconfirmed2\n")
+    .run();
+}
+
+#[test]
+fn confirm_recipe() {
+  Test::new()
+    .justfile(
+      "
+        [confirm]
+        requires_confirmation:
+            echo confirmed
+        ",
+    )
+    .stderr("Run recipe `requires_confirmation`? echo confirmed\n")
+    .stdout("confirmed\n")
+    .stdin("y")
+    .run();
+}
+
+#[test]
+fn recipe_with_confirm_recipe_dependency() {
+  Test::new()
+    .justfile(
+      "
+        dep_confirmation: requires_confirmation
+            echo confirmed2
+
+        [confirm]
+        requires_confirmation:
+            echo confirmed
+        ",
+    )
+    .stderr("Run recipe `requires_confirmation`? echo confirmed\necho confirmed2\n")
+    .stdout("confirmed\nconfirmed2\n")
+    .stdin("y")
+    .run();
+}
+
+#[test]
+fn do_not_confirm_recipe() {
+  Test::new()
+    .justfile(
+      "
+        [confirm]
+        requires_confirmation:
+            echo confirmed
+        ",
+    )
+    .stderr("Run recipe `requires_confirmation`? error: Recipe `requires_confirmation` was not confirmed\n")
+    .stdout("")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn do_not_confirm_recipe_with_confirm_recipe_dependency() {
+  Test::new()
+    .justfile(
+      "
+        dep_confirmation: requires_confirmation
+            echo mistake
+
+        [confirm]
+        requires_confirmation:
+            echo confirmed
+        ",
+    )
+    .stderr("Run recipe `requires_confirmation`? error: Recipe `requires_confirmation` was not confirmed\n")
+    .status(1)
+    .run();
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index 37b1aff7..ea7d63c1 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -42,6 +42,7 @@ mod choose;
 mod command;
 mod completions;
 mod conditional;
+mod confirm;
 mod delimiters;
 mod dotenv;
 mod edit;