Repository: casey/just
Pull request #941 (2021-08-05 03:40:59)

Add support for specifying name/path of .env
#802

This allows users to overwrite just looking for a file called .env in the working directory or its ancestors.

## Open questions

1. I have these two new args, `--dotenv-filename` and `--dotenv-path` mutually exclusive, with the latter taking precedence in the code over the former. Is that correct?
2. Currently, if the path from `--dotenv-path` does not resolve to a file, then the existing functionality of searching for a file called ".env" in the working directory or its ancestors is taken. If the type of `Config::dotenv_path` was an `Option<PathBuf>`, I could differentiate between "user set" and "default value", but I didn't see any of the other args as options so I'm hesitant to introduce that without checking with you first. What's the desired behavior here?
3. Do you want tests of the overridden dotenv file lookup?

Celeo: My most recent battle with the CI job seems to be a success ;) 
casey: Let me know when this is ready for another look, I saw some pushes, but wasn't sure if it's ready for review.
Celeo: Yup, ready for another review; I think I got all your feedback incorporated and working.
casey: And def ping the PR with a comment when you're ready for me to take a look at it, I turned off push notifications because they get spammy for some of the repos I watch.
Celeo: Fixed, and tests added.

> Okay, last thing I swear

No worries!

> push notifications because they get spammy 

Yup, makes total sense!
casey: I just noticed the use of `/bin/bash`, can the recipes just be normal recipes, i.e. no shebang? I think on at least some systems, `bash` isn't installed as `/bin/bash`, so if it can't be removed, they should be `#!/usr/bin/env bash`.
Celeo: Ah right, thanks for catching that. I was confused by the presence of output on stderr, but that's the printing of the recipe content. Swapped with the `@` prefix for quieting that, and works. :+1:

Patch:
diff --git a/completions/just.bash b/completions/just.bash
index 56917091..7a1a2d27 100644
--- a/completions/just.bash
+++ b/completions/just.bash
@@ -20,7 +20,7 @@ _just() {
 
     case "${cmd}" in
         just)
-            opts=" -q -u -v -e -l -h -V -f -d -c -s  --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 --list-heading --list-prefix --justfile --set --shell --shell-arg --working-directory --command --completions --show  <ARGUMENTS>... "
+            opts=" -q -u -v -e -l -h -V -f -d -c -s  --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 --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
@@ -97,6 +97,14 @@ _just() {
                     COMPREPLY=($(compgen -f "${cur}"))
                     return 0
                     ;;
+                --dotenv-filename)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                --dotenv-path)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
                 *)
                     COMPREPLY=()
                     ;;
diff --git a/completions/just.elvish b/completions/just.elvish
index 70e29868..4972a56e 100644
--- a/completions/just.elvish
+++ b/completions/just.elvish
@@ -30,6 +30,8 @@ edit:completion:arg-completer[just] = [@words]{
             cand --completions 'Print shell completion script for <SHELL>'
             cand -s 'Show information about <RECIPE>'
             cand --show 'Show information about <RECIPE>'
+            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 --dry-run 'Print what just would do without doing it'
             cand --highlight 'Highlight echoed recipe lines in bold'
             cand --no-dotenv 'Don''t load `.env` file'
diff --git a/completions/just.fish b/completions/just.fish
index 48d1da5f..5379aeaf 100644
--- a/completions/just.fish
+++ b/completions/just.fish
@@ -21,6 +21,8 @@ complete -c just -n "__fish_use_subcommand" -s d -l working-directory -d 'Use <W
 complete -c just -n "__fish_use_subcommand" -s c -l command -d 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set'
 complete -c just -n "__fish_use_subcommand" -l completions -d 'Print shell completion script for <SHELL>' -r -f -a "zsh bash fish powershell elvish"
 complete -c just -n "__fish_use_subcommand" -s s -l show -d 'Show information about <RECIPE>'
+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 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 031d68b0..f2be3127 100644
--- a/completions/just.powershell
+++ b/completions/just.powershell
@@ -35,6 +35,8 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock {
             [CompletionResult]::new('--completions', 'completions', [CompletionResultType]::ParameterName, 'Print shell completion script for <SHELL>')
             [CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Show information about <RECIPE>')
             [CompletionResult]::new('--show', 'show', [CompletionResultType]::ParameterName, 'Show information about <RECIPE>')
+            [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('--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')
             [CompletionResult]::new('--no-dotenv', 'no-dotenv', [CompletionResultType]::ParameterName, 'Don''t load `.env` file')
diff --git a/completions/just.zsh b/completions/just.zsh
index 25746a82..b504b709 100644
--- a/completions/just.zsh
+++ b/completions/just.zsh
@@ -31,6 +31,8 @@ _just() {
 '--completions=[Print shell completion script for <SHELL>]: :(zsh bash fish powershell elvish)' \
 '-s+[Show information about <RECIPE>]: :_just_commands' \
 '--show=[Show information about <RECIPE>]: :_just_commands' \
+'(--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]' \
 '(-q --quiet)--dry-run[Print what just would do without doing it]' \
 '--highlight[Highlight echoed recipe lines in bold]' \
 '--no-dotenv[Don'\''t load `.env` file]' \
diff --git a/src/config.rs b/src/config.rs
index 85c8d9d4..cc7e441b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -29,6 +29,8 @@ pub(crate) struct Config {
   pub(crate) subcommand:           Subcommand,
   pub(crate) unsorted:             bool,
   pub(crate) unstable:             bool,
+  pub(crate) dotenv_filename:      Option<String>,
+  pub(crate) dotenv_path:          Option<PathBuf>,
   pub(crate) verbosity:            Verbosity,
 }
 
@@ -96,6 +98,8 @@ mod arg {
   pub(crate) const SHELL_COMMAND: &str = "SHELL-COMMAND";
   pub(crate) const UNSORTED: &str = "UNSORTED";
   pub(crate) const UNSTABLE: &str = "UNSTABLE";
+  pub(crate) const DOTENV_FILENAME: &str = "DOTENV_FILENAME";
+  pub(crate) const DOTENV_PATH: &str = "DOTENV_PATH";
   pub(crate) const VERBOSE: &str = "VERBOSE";
   pub(crate) const WORKING_DIRECTORY: &str = "WORKING-DIRECTORY";
 
@@ -317,6 +321,19 @@ impl Config {
           .long("variables")
           .help("List names of variables"),
       )
+      .arg(
+        Arg::with_name(arg::DOTENV_FILENAME)
+          .long("dotenv-filename")
+          .takes_value(true)
+          .help("Search for environment file named <DOTENV-FILENAME> instead of `.env`")
+          .conflicts_with(arg::DOTENV_PATH),
+      )
+      .arg(
+        Arg::with_name(arg::DOTENV_PATH)
+          .long("dotenv-path")
+          .help("Load environment file at <DOTENV-PATH> instead of searching for one")
+          .takes_value(true),
+      )
       .group(ArgGroup::with_name("SUBCOMMAND").args(cmd::ALL))
       .arg(
         Arg::with_name(arg::ARGUMENTS)
@@ -537,6 +554,8 @@ impl Config {
       shell_args,
       shell_present,
       subcommand,
+      dotenv_filename: matches.value_of(arg::DOTENV_FILENAME).map(str::to_owned),
+      dotenv_path: matches.value_of(arg::DOTENV_PATH).map(PathBuf::from),
       verbosity,
     })
   }
@@ -616,6 +635,12 @@ OPTIONS:
         --completions <SHELL>
             Print shell completion script for <SHELL> [possible values: zsh,
             bash, fish, powershell, elvish]
+        --dotenv-filename <DOTENV_FILENAME>
+            Search for environment file named <DOTENV-FILENAME> instead of
+            `.env`
+        --dotenv-path <DOTENV_PATH>
+            Load environment file at <DOTENV-PATH> instead of searching for one
+
     -f, --justfile <JUSTFILE>                      Use <JUSTFILE> as justfile
         --list-heading <TEXT>                      Print <TEXT> before list
         --list-prefix <TEXT>
@@ -883,6 +908,11 @@ ARGS:
     verbosity: Verbosity::Quiet,
   }
 
+  error! {
+    name: dotenv_both_filename_and_path,
+    args: ["--dotenv-filename", "foo", "--dotenv-path", "bar"],
+  }
+
   test! {
     name: set_default,
     args: [],
diff --git a/src/error.rs b/src/error.rs
index a60b8035..94186c27 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -426,7 +426,7 @@ impl<'src> ColorDisplay for Error<'src> {
         )?;
       },
       Dotenv { dotenv_error } => {
-        write!(f, "Failed to load .env: {}", dotenv_error)?;
+        write!(f, "Failed to load environment file: {}", dotenv_error)?;
       },
       EditorInvoke { editor, io_error } => {
         write!(
diff --git a/src/load_dotenv.rs b/src/load_dotenv.rs
index a91fbc26..c9449261 100644
--- a/src/load_dotenv.rs
+++ b/src/load_dotenv.rs
@@ -1,45 +1,69 @@
 use crate::common::*;
 
+const DEFAULT_DOTENV_FILENAME: &str = ".env";
+
 pub(crate) fn load_dotenv(
   config: &Config,
   settings: &Settings,
   working_directory: &Path,
 ) -> RunResult<'static, BTreeMap<String, String>> {
-  // `dotenv::from_path_iter` should eventually be un-deprecated, see:
-  // https://github.com/dotenv-rs/dotenv/issues/13
-  #![allow(deprecated)]
-
-  if !settings.dotenv_load.unwrap_or(true) {
+  if !settings.dotenv_load.unwrap_or(true)
+    && config.dotenv_filename.is_none()
+    && config.dotenv_path.is_none()
+  {
     return Ok(BTreeMap::new());
   }
 
-  for directory in working_directory.ancestors() {
-    let path = directory.join(".env");
+  if let Some(path) = &config.dotenv_path {
+    return load_from_file(config, settings, &path);
+  }
+
+  let filename = config
+    .dotenv_filename
+    .as_deref()
+    .unwrap_or(DEFAULT_DOTENV_FILENAME)
+    .to_owned();
 
+  for directory in working_directory.ancestors() {
+    let path = directory.join(&filename);
     if path.is_file() {
-      if settings.dotenv_load.is_none()
-        && config.verbosity.loud()
-        && !std::env::var_os("JUST_SUPPRESS_DOTENV_LOAD_WARNING")
-          .map(|val| val.as_os_str().to_str() == Some("1"))
-          .unwrap_or(false)
-      {
-        eprintln!(
-          "{}",
-          Warning::DotenvLoad.color_display(config.color.stderr())
-        );
-      }
-
-      let iter = dotenv::from_path_iter(&path)?;
-      let mut dotenv = BTreeMap::new();
-      for result in iter {
-        let (key, value) = result?;
-        if env::var_os(&key).is_none() {
-          dotenv.insert(key, value);
-        }
-      }
-      return Ok(dotenv);
+      return load_from_file(config, settings, &path);
     }
   }
 
   Ok(BTreeMap::new())
 }
+
+fn load_from_file(
+  config: &Config,
+  settings: &Settings,
+  path: &Path,
+) -> RunResult<'static, BTreeMap<String, String>> {
+  // `dotenv::from_path_iter` should eventually be un-deprecated, see:
+  // https://github.com/dotenv-rs/dotenv/issues/13
+  #![allow(deprecated)]
+
+  if config.verbosity.loud()
+    && settings.dotenv_load.is_none()
+    && config.dotenv_filename.is_none()
+    && config.dotenv_path.is_none()
+    && !std::env::var_os("JUST_SUPPRESS_DOTENV_LOAD_WARNING")
+      .map(|val| val.as_os_str().to_str() == Some("1"))
+      .unwrap_or(false)
+  {
+    eprintln!(
+      "{}",
+      Warning::DotenvLoad.color_display(config.color.stderr())
+    );
+  }
+
+  let iter = dotenv::from_path_iter(&path)?;
+  let mut dotenv = BTreeMap::new();
+  for result in iter {
+    let (key, value) = result?;
+    if env::var_os(&key).is_none() {
+      dotenv.insert(key, value);
+    }
+  }
+  Ok(dotenv)
+}


Test patch:
diff --git a/tests/dotenv.rs b/tests/dotenv.rs
index 666a04b1..60782680 100644
--- a/tests/dotenv.rs
+++ b/tests/dotenv.rs
@@ -101,3 +101,103 @@ echo $DOTENV_KEY
     .suppress_dotenv_load_warning(false)
     .run();
 }
+
+#[test]
+fn path_not_found() {
+  Test::new()
+    .justfile(
+      "
+      foo:
+        echo $NAME
+    ",
+    )
+    .args(&["--dotenv-path", ".env.prod"])
+    .stderr(if cfg!(windows) {
+      "error: Failed to load environment file: The system cannot find the file specified. (os \
+       error 2)\n"
+    } else {
+      "error: Failed to load environment file: No such file or directory (os error 2)\n"
+    })
+    .status(EXIT_FAILURE)
+    .run();
+}
+
+#[test]
+fn path_resolves() {
+  Test::new()
+    .justfile(
+      "
+      foo:
+        @echo $NAME
+    ",
+    )
+    .tree(tree! {
+      subdir: {
+        ".env": "NAME=bar"
+      }
+    })
+    .args(&["--dotenv-path", "subdir/.env"])
+    .stdout("bar\n")
+    .status(EXIT_SUCCESS)
+    .run();
+}
+
+#[test]
+fn filename_resolves() {
+  Test::new()
+    .justfile(
+      "
+      foo:
+        @echo $NAME
+    ",
+    )
+    .tree(tree! {
+      ".env.special": "NAME=bar"
+    })
+    .args(&["--dotenv-filename", ".env.special"])
+    .stdout("bar\n")
+    .status(EXIT_SUCCESS)
+    .run();
+}
+
+#[test]
+fn filename_flag_overwrites_no_load() {
+  Test::new()
+    .justfile(
+      "
+      set dotenv-load := false
+
+      foo:
+        @echo $NAME
+    ",
+    )
+    .tree(tree! {
+      ".env.special": "NAME=bar"
+    })
+    .args(&["--dotenv-filename", ".env.special"])
+    .stdout("bar\n")
+    .status(EXIT_SUCCESS)
+    .run();
+}
+
+#[test]
+fn path_flag_overwrites_no_load() {
+  Test::new()
+    .justfile(
+      "
+      set dotenv-load := false
+
+      foo:
+        @echo $NAME
+    ",
+    )
+    .tree(tree! {
+      subdir: {
+        ".env": "NAME=bar"
+      }
+    })
+    .args(&["--dotenv-path", "subdir/.env"])
+    .stdout("bar\n")
+    .status(EXIT_SUCCESS)
+    .run();
+}