From 9415bee16bb44ac716aaf7ff09136645d61ad5e6 Mon Sep 17 00:00:00 2001 From: Hwatwasthat Date: Thu, 16 Nov 2023 23:51:34 +0000 Subject: [PATCH] Add `[confirm]` attribute (#1723) --- .gitignore | 1 + README.md | 24 +++++++-- completions/just.bash | 2 +- completions/just.elvish | 1 + completions/just.fish | 1 + completions/just.powershell | 1 + completions/just.zsh | 1 + src/attribute.rs | 1 + src/config.rs | 24 +++++---- src/error.rs | 12 +++++ src/justfile.rs | 6 +++ src/recipe.rs | 14 +++++ tests/confirm.rs | 105 ++++++++++++++++++++++++++++++++++++ tests/lib.rs | 1 + 14 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 tests/confirm.rs diff --git a/.gitignore b/.gitignore index e6c81f9..ec381e4 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 09368f0..23cee3e 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]`1.9.0 | Don't change directory before executing recipe. | -| `[no-exit-message]`1.7.0 | Don't print an error message if recipe fails. | +| `[confirm]`master | Require confirmation prior to executing recipe. | | `[linux]`1.8.0 | Enable recipe on Linux. | | `[macos]`1.8.0 | Enable recipe on MacOS. | +| `[no-cd]`1.9.0 | Don't change directory before executing recipe. | +| `[no-exit-message]`1.7.0 | Don't print an error message if recipe fails. | +| `[private]`1.10.0 | See [Private Recipes](#private-recipes). | | `[unix]`1.8.0 | Enable recipe on Unixes. (Includes MacOS). | | `[windows]`1.8.0 | Enable recipe on Windows. | -| `[private]`1.10.0 | 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 Recipesmaster + +`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 99d7fdb..4647ceb 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 ... " + 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 ... " if [[ ${cur} == -* ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/completions/just.elvish b/completions/just.elvish index 1b3be81..5117647 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 instead of `.env`' cand --dotenv-path 'Load environment file at 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 7aedd4a..aa126bd 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 instead of `.env`' complete -c just -n "__fish_use_subcommand" -l dotenv-path -d 'Load environment file at 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 0842dc8..d2907e3 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 instead of `.env`') [CompletionResult]::new('--dotenv-path', 'dotenv-path', [CompletionResultType]::ParameterName, 'Load environment file at 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 4ddfa86..b2ee3d6 100644 --- a/completions/just.zsh +++ b/completions/just.zsh @@ -36,6 +36,7 @@ _just() { '(--dotenv-path)--dotenv-filename=[Search for environment file named instead of `.env`]' \ '--dotenv-path=[Load environment file at 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 ffd532f..b4c7166 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 682229b..9e8b7b9 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 "), ) + .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 b14cc5a..6054869 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 4d59c52..3648c76 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 407115d..4ea8bc2 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) } diff --git a/tests/confirm.rs b/tests/confirm.rs new file mode 100644 index 0000000..8b37489 --- /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 37b1aff..ea7d63c 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;