Repository: casey/just
Pull request #2498 (2024-12-02 00:28:05)

Add hidden `--request` subcommand for testing


Patch:
diff --git a/src/config.rs b/src/config.rs
index 6eb8d23b..86ec4bfe 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -53,6 +53,7 @@ mod cmd {
   pub(crate) const INIT: &str = "INIT";
   pub(crate) const LIST: &str = "LIST";
   pub(crate) const MAN: &str = "MAN";
+  pub(crate) const REQUEST: &str = "REQUEST";
   pub(crate) const SHOW: &str = "SHOW";
   pub(crate) const SUMMARY: &str = "SUMMARY";
   pub(crate) const VARIABLES: &str = "VARIABLES";
@@ -69,6 +70,7 @@ mod cmd {
     INIT,
     LIST,
     MAN,
+    REQUEST,
     SHOW,
     SUMMARY,
     VARIABLES,
@@ -517,6 +519,17 @@ impl Config {
           .help("Print man page")
           .help_heading(cmd::HEADING),
       )
+      .arg(
+        Arg::new(cmd::REQUEST)
+          .long("request")
+          .action(ArgAction::Set)
+          .hide(true)
+          .help(
+            "Execute <REQUEST>. For internal testing purposes only. May be changed or removed at \
+            any time.",
+          )
+          .help_heading(cmd::REQUEST),
+      )
       .arg(
         Arg::new(cmd::SHOW)
           .short('s')
@@ -696,6 +709,11 @@ impl Config {
       }
     } else if matches.get_flag(cmd::MAN) {
       Subcommand::Man
+    } else if let Some(request) = matches.get_one::<String>(cmd::REQUEST) {
+      Subcommand::Request {
+        request: serde_json::from_str(request)
+          .map_err(|source| ConfigError::RequestParse { source })?,
+      }
     } else if let Some(path) = matches.get_many::<String>(cmd::SHOW) {
       Subcommand::Show {
         path: Self::parse_module_path(path)?,
diff --git a/src/config_error.rs b/src/config_error.rs
index 6935b81c..30233463 100644
--- a/src/config_error.rs
+++ b/src/config_error.rs
@@ -12,6 +12,8 @@ pub(crate) enum ConfigError {
   Internal { message: String },
   #[snafu(display("Invalid module path `{}`", path.join(" ")))]
   ModulePath { path: Vec<String> },
+  #[snafu(display("Failed to parse request: {source}"))]
+  RequestParse { source: serde_json::Error },
   #[snafu(display(
     "Path-prefixed recipes may not be used with `--working-directory` or `--justfile`."
   ))]
diff --git a/src/error.rs b/src/error.rs
index b8774a25..a07c4efd 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -81,7 +81,7 @@ pub(crate) enum Error<'src> {
   },
   DotenvRequired,
   DumpJson {
-    serde_json_error: serde_json::Error,
+    source: serde_json::Error,
   },
   EditorInvoke {
     editor: OsString,
@@ -359,8 +359,8 @@ impl ColorDisplay for Error<'_> {
       DotenvRequired => {
         write!(f, "Dotenv file not found")?;
       }
-      DumpJson { serde_json_error } => {
-        write!(f, "Failed to dump JSON to stdout: {serde_json_error}")?;
+      DumpJson { source } => {
+        write!(f, "Failed to dump JSON to stdout: {source}")?;
       }
       EditorInvoke { editor, io_error } => {
         let editor = editor.to_string_lossy();
diff --git a/src/lib.rs b/src/lib.rs
index 799cc378..cda797c7 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -42,7 +42,7 @@ pub(crate) use {
   regex::Regex,
   serde::{
     ser::{SerializeMap, SerializeSeq},
-    Serialize, Serializer,
+    Deserialize, Serialize, Serializer,
   },
   snafu::{ResultExt, Snafu},
   std::{
@@ -76,9 +76,12 @@ pub(crate) use crate::{node::Node, tree::Tree};
 
 pub use crate::run::run;
 
+#[doc(hidden)]
+use request::Request;
+
 // Used in integration tests.
 #[doc(hidden)]
-pub use unindent::unindent;
+pub use {request::Response, unindent::unindent};
 
 type CompileResult<'a, T = ()> = Result<T, CompileError<'a>>;
 type ConfigResult<T> = Result<T, ConfigError>;
@@ -106,6 +109,10 @@ pub mod fuzzing;
 #[doc(hidden)]
 pub mod summary;
 
+// Used for testing with the `--request` subcommand.
+#[doc(hidden)]
+pub mod request;
+
 mod alias;
 mod analyzer;
 mod argument_parser;
diff --git a/src/request.rs b/src/request.rs
new file mode 100644
index 00000000..a84a84c2
--- /dev/null
+++ b/src/request.rs
@@ -0,0 +1,13 @@
+use super::*;
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "kebab-case")]
+pub enum Request {
+  EnvironmentVariable(String),
+}
+
+#[derive(Debug, Deserialize, PartialEq, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Response {
+  EnvironmentVariable(Option<OsString>),
+}
diff --git a/src/subcommand.rs b/src/subcommand.rs
index cc47d879..8d345788 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -35,6 +35,9 @@ pub(crate) enum Subcommand {
     path: ModulePath,
   },
   Man,
+  Request {
+    request: Request,
+  },
   Run {
     arguments: Vec<String>,
     overrides: BTreeMap<String, String>,
@@ -71,10 +74,6 @@ impl Subcommand {
     let justfile = &compilation.justfile;
 
     match self {
-      Run {
-        arguments,
-        overrides,
-      } => Self::run(config, loader, search, compilation, arguments, overrides)?,
       Choose { overrides, chooser } => {
         Self::choose(config, justfile, &search, overrides, chooser.as_deref())?;
       }
@@ -85,6 +84,11 @@ impl Subcommand {
       Format => Self::format(config, &search, compilation)?,
       Groups => Self::groups(config, justfile),
       List { path } => Self::list(config, justfile, path)?,
+      Request { request } => Self::request(request)?,
+      Run {
+        arguments,
+        overrides,
+      } => Self::run(config, loader, search, compilation, arguments, overrides)?,
       Show { path } => Self::show(config, justfile, path)?,
       Summary => Self::summary(config, justfile),
       Variables => Self::variables(justfile),
@@ -280,7 +284,7 @@ impl Subcommand {
     match config.dump_format {
       DumpFormat::Json => {
         serde_json::to_writer(io::stdout(), &compilation.justfile)
-          .map_err(|serde_json_error| Error::DumpJson { serde_json_error })?;
+          .map_err(|source| Error::DumpJson { source })?;
         println!();
       }
       DumpFormat::Just => print!("{}", compilation.root_ast()),
@@ -402,6 +406,16 @@ impl Subcommand {
     Ok(())
   }
 
+  fn request(request: &Request) -> RunResult<'static> {
+    let response = match request {
+      Request::EnvironmentVariable(key) => Response::EnvironmentVariable(env::var_os(key)),
+    };
+
+    serde_json::to_writer(io::stdout(), &response).map_err(|source| Error::DumpJson { source })?;
+
+    Ok(())
+  }
+
   fn list(config: &Config, mut module: &Justfile, path: &ModulePath) -> RunResult<'static> {
     for name in &path.path {
       module = module


Test patch:
diff --git a/tests/constants.rs b/tests/constants.rs
index c6a3a853..5c30109d 100644
--- a/tests/constants.rs
+++ b/tests/constants.rs
@@ -48,14 +48,13 @@ fn constants_can_be_redefined() {
 fn constants_are_not_exported() {
   Test::new()
     .justfile(
-      "
+      r#"
         set export
 
         foo:
-          echo $HEXUPPER
-      ",
+          @'{{just_executable()}}' --request '{"environment-variable": "HEXUPPER"}'
+      "#,
     )
-    .stderr_regex(".*HEXUPPER: unbound variable.*")
-    .status(127)
+    .response(Response::EnvironmentVariable(None))
     .run();
 }
diff --git a/tests/lib.rs b/tests/lib.rs
index 675e1fe0..3ee9c380 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -6,7 +6,7 @@ pub(crate) use {
     test::{assert_eval_eq, Output, Test},
   },
   executable_path::executable_path,
-  just::unindent,
+  just::{unindent, Response},
   libc::{EXIT_FAILURE, EXIT_SUCCESS},
   pretty_assertions::Comparison,
   regex::Regex,
@@ -99,6 +99,7 @@ mod quote;
 mod readme;
 mod recursion_limit;
 mod regexes;
+mod request;
 mod run;
 mod script;
 mod search;
diff --git a/tests/request.rs b/tests/request.rs
new file mode 100644
index 00000000..da8e144f
--- /dev/null
+++ b/tests/request.rs
@@ -0,0 +1,29 @@
+use super::*;
+
+#[test]
+fn environment_variable_set() {
+  Test::new()
+    .justfile(
+      r#"
+      export BAR := 'baz'
+
+      @foo:
+        '{{just_executable()}}' --request '{"environment-variable": "BAR"}'
+    "#,
+    )
+    .response(Response::EnvironmentVariable(Some("baz".into())))
+    .run();
+}
+
+#[test]
+fn environment_variable_missing() {
+  Test::new()
+    .justfile(
+      r#"
+      @foo:
+        '{{just_executable()}}' --request '{"environment-variable": "FOO_BAR_BAZ"}'
+    "#,
+    )
+    .response(Response::EnvironmentVariable(None))
+    .run();
+}
diff --git a/tests/test.rs b/tests/test.rs
index cd7c0cce..e02b5a85 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -50,6 +50,7 @@ pub(crate) struct Test {
   pub(crate) env: BTreeMap<String, String>,
   pub(crate) expected_files: BTreeMap<PathBuf, Vec<u8>>,
   pub(crate) justfile: Option<String>,
+  pub(crate) response: Option<Response>,
   pub(crate) shell: bool,
   pub(crate) status: i32,
   pub(crate) stderr: String,
@@ -74,6 +75,7 @@ impl Test {
       env: BTreeMap::new(),
       expected_files: BTreeMap::new(),
       justfile: Some(String::new()),
+      response: None,
       shell: true,
       status: EXIT_SUCCESS,
       stderr: String::new(),
@@ -139,6 +141,11 @@ impl Test {
     self
   }
 
+  pub(crate) fn response(mut self, response: Response) -> Self {
+    self.response = Some(response);
+    self.stdout_regex(".*")
+  }
+
   pub(crate) fn shell(mut self, shell: bool) -> Self {
     self.shell = shell;
     self
@@ -293,6 +300,15 @@ impl Test {
       panic!("Output mismatch.");
     }
 
+    if let Some(ref response) = self.response {
+      assert_eq!(
+        &serde_json::from_str::<Response>(output_stdout)
+          .expect("failed to deserialize stdout as response"),
+        response,
+        "response mismatch"
+      );
+    }
+
     for (path, expected) in &self.expected_files {
       let actual = fs::read(self.tempdir.path().join(path)).unwrap();
       assert_eq!(