Repository: casey/just (rust)

Issue:
Command line flag to exit without error if recipe not found
In some cases it's helpful to run `just` so that it silently exists with code 0 if requested recipe is not found.  Possible usecase: running same recipe in multiple directories (e.g. with GNU parallel) when in some of them the recipe is not defined, in which case we just want to skip the dir.

It is currently possible to do with NPM scripts, for example `npm run --ws --if-present <script>` will run *script* in every workspace where script is defined. 

Pull request description:
Suppress recipe not found error code
Closes #2275. It adds a new verbosity version which is used to convert the error code of an `UnknownRecipe` to 0, due to it making an early return it will also suppress any error messages. This behaviour is the same as the one `npm --if-present` has as it was referenced by the issue author.

I choose to add another verbosity variant due to not wanting to add further fields in `Config`.  I thought it fits the verbosity config since it affects the output of the program.

Patch:
diff --git a/src/config.rs b/src/config.rs
index d0b91d67..6eb8d23b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -9,6 +9,7 @@ use {
 
 #[derive(Debug, PartialEq)]
 pub(crate) struct Config {
+  pub(crate) allow_missing: bool,
   pub(crate) check: bool,
   pub(crate) color: Color,
   pub(crate) command_color: Option<ansi_term::Color>,
@@ -80,6 +81,7 @@ mod cmd {
 }
 
 mod arg {
+  pub(crate) const ALLOW_MISSING: &str = "ALLOW-MISSING";
   pub(crate) const ARGUMENTS: &str = "ARGUMENTS";
   pub(crate) const CHECK: &str = "CHECK";
   pub(crate) const CHOOSER: &str = "CHOOSER";
@@ -315,6 +317,13 @@ impl Config {
           .help("Suppress all output")
           .conflicts_with(arg::DRY_RUN),
       )
+      .arg(
+        Arg::new(arg::ALLOW_MISSING)
+          .long("allow-missing")
+          .env("JUST_ALLOW_MISSING")
+          .action(ArgAction::SetTrue)
+          .help("Ignore missing recipe and module errors"),
+      )
       .arg(
         Arg::new(arg::SET)
           .long("set")
@@ -706,6 +715,7 @@ impl Config {
     let explain = matches.get_flag(arg::EXPLAIN);
 
     Ok(Self {
+      allow_missing: matches.get_flag(arg::ALLOW_MISSING),
       check: matches.get_flag(arg::CHECK),
       color: (*matches.get_one::<UseColor>(arg::COLOR).unwrap()).into(),
       command_color: matches
diff --git a/src/error.rs b/src/error.rs
index dd6955fe..72438048 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -468,7 +468,7 @@ impl<'src> ColorDisplay for Error<'src> {
         write!(f, "{count} {overrides} overridden on the command line but not present in justfile")?;
       }
       UnknownRecipe { recipe, suggestion } => {
-        write!(f, "Justfile does not contain recipe `{recipe}`.")?;
+        write!(f, "Justfile does not contain recipe `{recipe}`")?;
         if let Some(suggestion) = suggestion {
           write!(f, "\n{suggestion}")?;
         }
diff --git a/src/subcommand.rs b/src/subcommand.rs
index 848bd59f..cc47d879 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -145,6 +145,15 @@ impl Subcommand {
         }
       }
 
+      if config.allow_missing
+        && matches!(
+          result,
+          Err(Error::UnknownRecipe { .. } | Error::UnknownSubmodule { .. })
+        )
+      {
+        return Ok(());
+      }
+
       return result;
     }
   }


Test patch:
diff --git a/tests/allow_missing.rs b/tests/allow_missing.rs
new file mode 100644
index 00000000..b2221120
--- /dev/null
+++ b/tests/allow_missing.rs
@@ -0,0 +1,50 @@
+use super::*;
+
+#[test]
+fn allow_missing_recipes_in_run_invocation() {
+  Test::new()
+    .arg("foo")
+    .stderr("error: Justfile does not contain recipe `foo`\n")
+    .status(EXIT_FAILURE)
+    .run();
+
+  Test::new().args(["--allow-missing", "foo"]).run();
+}
+
+#[test]
+fn allow_missing_modules_in_run_invocation() {
+  Test::new()
+    .arg("foo::bar")
+    .stderr("error: Justfile does not contain submodule `foo`\n")
+    .status(EXIT_FAILURE)
+    .run();
+
+  Test::new().args(["--allow-missing", "foo::bar"]).run();
+}
+
+#[test]
+fn allow_missing_does_not_apply_to_compilation_errors() {
+  Test::new()
+    .justfile("bar: foo")
+    .args(["--allow-missing", "foo"])
+    .stderr(
+      "
+        error: Recipe `bar` has unknown dependency `foo`
+         ——▶ justfile:1:6
+          │
+        1 │ bar: foo
+          │      ^^^
+      ",
+    )
+    .status(EXIT_FAILURE)
+    .run();
+}
+
+#[test]
+fn allow_missing_does_not_apply_to_other_subcommands() {
+  Test::new()
+    .args(["--allow-missing", "--show", "foo"])
+    .stderr("error: Justfile does not contain recipe `foo`\n")
+    .status(EXIT_FAILURE)
+    .run();
+}
diff --git a/tests/fallback.rs b/tests/fallback.rs
index 238a12a7..62e96b59 100644
--- a/tests/fallback.rs
+++ b/tests/fallback.rs
@@ -176,7 +176,7 @@ fn requires_setting() {
     .args(["foo"])
     .current_dir("bar")
     .status(EXIT_FAILURE)
-    .stderr("error: Justfile does not contain recipe `foo`.\n")
+    .stderr("error: Justfile does not contain recipe `foo`\n")
     .run();
 }
 
@@ -230,7 +230,7 @@ fn doesnt_work_with_justfile() {
     .args(["--justfile", "justfile", "foo"])
     .current_dir("bar")
     .status(EXIT_FAILURE)
-    .stderr("error: Justfile does not contain recipe `foo`.\n")
+    .stderr("error: Justfile does not contain recipe `foo`\n")
     .run();
 }
 
@@ -254,7 +254,7 @@ fn doesnt_work_with_justfile_and_working_directory() {
     .args(["--justfile", "justfile", "--working-directory", ".", "foo"])
     .current_dir("bar")
     .status(EXIT_FAILURE)
-    .stderr("error: Justfile does not contain recipe `foo`.\n")
+    .stderr("error: Justfile does not contain recipe `foo`\n")
     .run();
 }
 
@@ -282,7 +282,7 @@ fn prints_correct_error_message_when_recipe_not_found() {
     .status(EXIT_FAILURE)
     .stderr(
       "
-      error: Justfile does not contain recipe `foo`.
+      error: Justfile does not contain recipe `foo`
     ",
     )
     .run();
@@ -355,7 +355,7 @@ fn stop_fallback_when_fallback_is_false() {
     .current_dir("a/b")
     .stderr(
       "
-      error: Justfile does not contain recipe `baz`.
+      error: Justfile does not contain recipe `baz`
       Did you mean `bar`?
     ",
     )
diff --git a/tests/lib.rs b/tests/lib.rs
index ce6ab139..3e8a4c12 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -33,6 +33,7 @@ mod test;
 
 mod allow_duplicate_recipes;
 mod allow_duplicate_variables;
+mod allow_missing;
 mod assert_stdout;
 mod assert_success;
 mod assertions;
diff --git a/tests/misc.rs b/tests/misc.rs
index 590c13cd..ab16d5e2 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -594,7 +594,7 @@ foo A B:
     ",
   args:     ("foo", "ONE", "TWO", "THREE"),
   stdout:   "",
-  stderr:   "error: Justfile does not contain recipe `THREE`.\n",
+  stderr:   "error: Justfile does not contain recipe `THREE`\n",
   status:   EXIT_FAILURE,
 }
 
@@ -618,7 +618,7 @@ foo A B='B':
     ",
   args:     ("foo", "ONE", "TWO", "THREE"),
   stdout:   "",
-  stderr:   "error: Justfile does not contain recipe `THREE`.\n",
+  stderr:   "error: Justfile does not contain recipe `THREE`\n",
   status:   EXIT_FAILURE,
 }
 
@@ -643,7 +643,7 @@ test! {
   justfile: "hello:",
   args:     ("foo"),
   stdout:   "",
-  stderr:   "error: Justfile does not contain recipe `foo`.\n",
+  stderr:   "error: Justfile does not contain recipe `foo`\n",
   status:   EXIT_FAILURE,
 }
 
@@ -652,7 +652,7 @@ test! {
   justfile: "hello:",
   args:     ("foo", "bar"),
   stdout:   "",
-  stderr:   "error: Justfile does not contain recipe `foo`.\n",
+  stderr:   "error: Justfile does not contain recipe `foo`\n",
   status:   EXIT_FAILURE,
 }
 
@@ -999,7 +999,7 @@ a Z="\t z":
 "#,
   args:     ("hell"),
   stdout:   "",
-  stderr:   "error: Justfile does not contain recipe `hell`.\nDid you mean `hello`?\n",
+  stderr:   "error: Justfile does not contain recipe `hell`\nDid you mean `hello`?\n",
   status:   EXIT_FAILURE,
 }
 
diff --git a/tests/modules.rs b/tests/modules.rs
index 1d8911af..904c13da 100644
--- a/tests/modules.rs
+++ b/tests/modules.rs
@@ -77,19 +77,19 @@ fn nested_module_recipes_can_be_run_with_path_syntax() {
 fn invalid_path_syntax() {
   Test::new()
     .arg(":foo::foo")
-    .stderr("error: Justfile does not contain recipe `:foo::foo`.\n")
+    .stderr("error: Justfile does not contain recipe `:foo::foo`\n")
     .status(EXIT_FAILURE)
     .run();
 
   Test::new()
     .arg("foo::foo:")
-    .stderr("error: Justfile does not contain recipe `foo::foo:`.\n")
+    .stderr("error: Justfile does not contain recipe `foo::foo:`\n")
     .status(EXIT_FAILURE)
     .run();
 
   Test::new()
     .arg("foo:::foo")
-    .stderr("error: Justfile does not contain recipe `foo:::foo`.\n")
+    .stderr("error: Justfile does not contain recipe `foo:::foo`\n")
     .status(EXIT_FAILURE)
     .run();
 }
@@ -99,7 +99,7 @@ fn missing_recipe_after_invalid_path() {
   Test::new()
     .arg(":foo::foo")
     .arg("bar")
-    .stderr("error: Justfile does not contain recipe `:foo::foo`.\n")
+    .stderr("error: Justfile does not contain recipe `:foo::foo`\n")
     .status(EXIT_FAILURE)
     .run();
 }
@@ -655,7 +655,7 @@ fn submodule_recipe_not_found_spaced_error_message() {
       ",
     )
     .args(["foo", "baz"])
-    .stderr("error: Justfile does not contain recipe `foo baz`.\nDid you mean `bar`?\n")
+    .stderr("error: Justfile does not contain recipe `foo baz`\nDid you mean `bar`?\n")
     .status(1)
     .run();
 }
@@ -670,7 +670,7 @@ fn submodule_recipe_not_found_colon_separated_error_message() {
       ",
     )
     .args(["foo::baz"])
-    .stderr("error: Justfile does not contain recipe `foo::baz`.\nDid you mean `bar`?\n")
+    .stderr("error: Justfile does not contain recipe `foo::baz`\nDid you mean `bar`?\n")
     .status(1)
     .run();
 }
diff --git a/tests/show.rs b/tests/show.rs
index a66f9089..f2b3d268 100644
--- a/tests/show.rs
+++ b/tests/show.rs
@@ -48,7 +48,7 @@ a Z="\t z":
 "#,
   args:     ("--show", "hell"),
   stdout:   "",
-  stderr:   "error: Justfile does not contain recipe `hell`.\nDid you mean `hello`?\n",
+  stderr:   "error: Justfile does not contain recipe `hell`\nDid you mean `hello`?\n",
   status:   EXIT_FAILURE,
 }
 
@@ -65,7 +65,7 @@ a Z="\t z":
   args:     ("--show", "fo"),
   stdout:   "",
   stderr:   "
-    error: Justfile does not contain recipe `fo`.
+    error: Justfile does not contain recipe `fo`
     Did you mean `foo`, an alias for `hello`?
   ",
   status:   EXIT_FAILURE,
@@ -81,7 +81,7 @@ a Z="\t z":
 "#,
   args:     ("--show", "hell"),
   stdout:   "",
-  stderr:   "error: Justfile does not contain recipe `hell`.\n",
+  stderr:   "error: Justfile does not contain recipe `hell`\n",
   status:   EXIT_FAILURE,
 }
 
@@ -97,7 +97,7 @@ a Z="\t z":
 "#,
   args:     ("--show", "fooooooo"),
   stdout:   "",
-  stderr:   "error: Justfile does not contain recipe `fooooooo`.\n",
+  stderr:   "error: Justfile does not contain recipe `fooooooo`\n",
   status:   EXIT_FAILURE,
 }