Repository: casey/just
Pull request #1692 (2023-10-11 08:25:41)

Add `dotenv-filename` and `dotenv-path` to in-`justfile` settings
## Context

I was trying to load a `.env` file located in another directory from multiple other `justfile`s for a project with a lot of repositories earlier and couldn't find any way to do that in the documentation.

After checking the code, I found the `--dotenv-filename` and `--dotenv-path` arguments that did exactly what I needed to do (introduced in #941), but it was impossible to set those options in the `justfile` directly.

## Changes

This pull request pretty much makes the `--dotenv-filename` and `--dotenv-path` available as settings that can be set using the `set` keyword in a `justfile`.

The implementation of the `load_dotenv` function was modified so that it considers these new settings, on top of the already existing program arguments. The program arguments take precedence over the settings in the `justfile`.

The documentation in `README.md` was updated to reflect those changes.

## Caveats

I found out right before creating this PR that there was already a pull request (#1640) and a few open issues about this topic (#945), and did not take them into account during implementation. There's some very good ideas floating in these, but my PR just exposes the existing program arguments as settings.

I did not update the documentation in `README.中文.md` since I cannot read or write Chinese.

casey: Looks great! I collapsed the various sections about the dotenv integration into one, since otherwise there would be a bunch of repetition and cross-linking.

Patch:
diff --git a/GRAMMAR.md b/GRAMMAR.md
index 51a431fb..6a0c40a1 100644
--- a/GRAMMAR.md
+++ b/GRAMMAR.md
@@ -60,7 +60,9 @@ assignment    : NAME ':=' expression eol
 export        : 'export' assignment
 
 setting       : 'set' 'allow-duplicate-recipes' boolean?
+              | 'set' 'dotenv-filename' ':=' string
               | 'set' 'dotenv-load' boolean?
+              | 'set' 'dotenv-path' ':=' string
               | 'set' 'export' boolean?
               | 'set' 'fallback' boolean?
               | 'set' 'ignore-comments' boolean?
diff --git a/README.md b/README.md
index f385d55b..65e7aa4a 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ Yay, all your tests passed!
 
 - Wherever possible, errors are resolved statically. Unknown recipes and circular dependencies are reported before anything runs.
 
-- `just` [loads `.env` files](#dotenv-integration), making it easy to populate environment variables.
+- `just` [loads `.env` files](#dotenv-settings), making it easy to populate environment variables.
 
 - Recipes can be [listed from the command line](#listing-available-recipes).
 
@@ -669,7 +669,9 @@ foo:
 | Name                      | Value              | Default | Description                                                                                   |
 | ------------------------- | ------------------ | ------- |---------------------------------------------------------------------------------------------- |
 | `allow-duplicate-recipes` | boolean            | `false` | Allow recipes appearing later in a `justfile` to override earlier recipes with the same name. |
+| `dotenv-filename`         | string             | -       | Load a `.env` file with a custom name, if present.                                            |
 | `dotenv-load`             | boolean            | `false` | Load a `.env` file, if present.                                                               |
+| `dotenv-path`             | string             | -       | Load a `.env` file from a custom path, if present. Overrides `dotenv-filename`.               |
 | `export`                  | boolean            | `false` | Export all variables as environment variables.                                                |
 | `fallback`                | boolean            | `false` | Search `justfile` in parent directory if the first recipe on the command line is not found.   |
 | `ignore-comments`         | boolean            | `false` | Ignore recipe lines beginning with `#`.                                                       |
@@ -710,9 +712,41 @@ $ just foo
 bar
 ```
 
-#### Dotenv Load
+#### Dotenv Settings
 
-If `dotenv-load` is `true`, a `.env` file will be loaded if present. Defaults to `false`.
+If `dotenv-load`, `dotenv-filename` or `dotenv-path` is set, `just` will load environment variables from a file.
+
+If `dotenv-path` is set, `just` will look for a file at the given path.
+
+Otherwise, `just` looks for a file named `.env` by default, unless `dotenv-filename` set, in which case the value of `dotenv-filename` is used. This file can be located in the same directory as your `justfile` or in a parent directory.
+
+The loaded variables are environment variables, not `just` variables, and so must be accessed using `$VARIABLE_NAME` in recipes and backticks.
+
+For example, if your `.env` file contains:
+
+```sh
+# a comment, will be ignored
+DATABASE_ADDRESS=localhost:6379
+SERVER_PORT=1337
+```
+
+And your `justfile` contains:
+
+```just
+set dotenv-load
+
+serve:
+  @echo "Starting server with database $DATABASE_ADDRESS on port $SERVER_PORT…"
+  ./server --database $DATABASE_ADDRESS --port $SERVER_PORT
+```
+
+`just serve` will output:
+
+```sh
+$ just serve
+Starting server with database localhost:6379 on port 1337…
+./server --database $DATABASE_ADDRESS --port $SERVER_PORT
+```
 
 #### Export
 
@@ -878,36 +912,6 @@ Available recipes:
     test # test stuff
 ```
 
-### Dotenv Integration
-
-If [`dotenv-load`](#dotenv-load) is set, `just` will load environment variables from a file named `.env`. This file can be located in the same directory as your `justfile` or in a parent directory. These variables are environment variables, not `just` variables, and so must be accessed using `$VARIABLE_NAME` in recipes and backticks.
-
-For example, if your `.env` file contains:
-
-```sh
-# a comment, will be ignored
-DATABASE_ADDRESS=localhost:6379
-SERVER_PORT=1337
-```
-
-And your `justfile` contains:
-
-```just
-set dotenv-load
-
-serve:
-  @echo "Starting server with database $DATABASE_ADDRESS on port $SERVER_PORT…"
-  ./server --database $DATABASE_ADDRESS --port $SERVER_PORT
-```
-
-`just serve` will output:
-
-```sh
-$ just serve
-Starting server with database localhost:6379 on port 1337…
-./server --database $DATABASE_ADDRESS --port $SERVER_PORT
-```
-
 ### Variables and Substitution
 
 Variables, strings, concatenation, path joining, and substitution using `{{…}}` are supported:
@@ -1528,9 +1532,6 @@ print_home_folder:
 $ just
 HOME is '/home/myuser'
 ```
-#### Loading Environment Variables from a `.env` File
-
-`just` will load environment variables from a `.env` file if [dotenv-load](#dotenv-load) is set. The variables in the file will be available as environment variables to the recipes. See [dotenv-integration](#dotenv-integration) for more information.
 
 #### Setting `just` Variables from Environment Variables
 
diff --git a/justfile b/justfile
index 52b4ee1f..f25e719c 100755
--- a/justfile
+++ b/justfile
@@ -6,10 +6,6 @@ alias t := test
 
 alias c := check
 
-bt := '0'
-
-export RUST_BACKTRACE := bt
-
 log := "warn"
 
 export JUST_LOG := log
diff --git a/src/keyword.rs b/src/keyword.rs
index bff1b6c2..db2ba951 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -5,7 +5,9 @@ use super::*;
 pub(crate) enum Keyword {
   Alias,
   AllowDuplicateRecipes,
+  DotenvFilename,
   DotenvLoad,
+  DotenvPath,
   Else,
   Export,
   Fallback,
diff --git a/src/load_dotenv.rs b/src/load_dotenv.rs
index b1fbe868..1ccb4b9b 100644
--- a/src/load_dotenv.rs
+++ b/src/load_dotenv.rs
@@ -7,25 +7,28 @@ pub(crate) fn load_dotenv(
   settings: &Settings,
   working_directory: &Path,
 ) -> RunResult<'static, BTreeMap<String, String>> {
-  if !settings.dotenv_load.unwrap_or(false)
-    && config.dotenv_filename.is_none()
-    && config.dotenv_path.is_none()
-  {
+  let dotenv_filename = config
+    .dotenv_filename
+    .as_ref()
+    .or(settings.dotenv_filename.as_ref());
+
+  let dotenv_path = config
+    .dotenv_path
+    .as_ref()
+    .or(settings.dotenv_path.as_ref());
+
+  if !settings.dotenv_load.unwrap_or(false) && dotenv_filename.is_none() && dotenv_path.is_none() {
     return Ok(BTreeMap::new());
   }
 
-  if let Some(path) = &config.dotenv_path {
+  if let Some(path) = dotenv_path {
     return load_from_file(path);
   }
 
-  let filename = config
-    .dotenv_filename
-    .as_deref()
-    .unwrap_or(DEFAULT_DOTENV_FILENAME)
-    .to_owned();
+  let filename = dotenv_filename.map_or(DEFAULT_DOTENV_FILENAME, |s| s.as_str());
 
   for directory in working_directory.ancestors() {
-    let path = directory.join(filename.as_str());
+    let path = directory.join(filename);
     if path.is_file() {
       return load_from_file(&path);
     }
diff --git a/src/node.rs b/src/node.rs
index e231c248..924bcf34 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -249,7 +249,7 @@ impl<'src> Node<'src> for Set<'src> {
           set.push_mut(Tree::string(&argument.cooked));
         }
       }
-      Setting::Tempdir(value) => {
+      Setting::DotenvFilename(value) | Setting::DotenvPath(value) | Setting::Tempdir(value) => {
         set.push_mut(Tree::string(value));
       }
     }
diff --git a/src/parser.rs b/src/parser.rs
index 04c4a9d1..81ae3c19 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -780,21 +780,23 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     self.presume_keyword(Keyword::Set)?;
     let name = Name::from_identifier(self.presume(Identifier)?);
     let lexeme = name.lexeme();
+    let Some(keyword) = Keyword::from_lexeme(lexeme) else {
+      return Err(name.error(CompileErrorKind::UnknownSetting {
+        setting: name.lexeme(),
+      }));
+    };
 
-    let set_bool: Option<Setting> = match Keyword::from_lexeme(lexeme) {
-      Some(kw) => match kw {
-        Keyword::AllowDuplicateRecipes => {
-          Some(Setting::AllowDuplicateRecipes(self.parse_set_bool()?))
-        }
-        Keyword::DotenvLoad => Some(Setting::DotenvLoad(self.parse_set_bool()?)),
-        Keyword::Export => Some(Setting::Export(self.parse_set_bool()?)),
-        Keyword::Fallback => Some(Setting::Fallback(self.parse_set_bool()?)),
-        Keyword::IgnoreComments => Some(Setting::IgnoreComments(self.parse_set_bool()?)),
-        Keyword::PositionalArguments => Some(Setting::PositionalArguments(self.parse_set_bool()?)),
-        Keyword::WindowsPowershell => Some(Setting::WindowsPowerShell(self.parse_set_bool()?)),
-        _ => None,
-      },
-      None => None,
+    let set_bool = match keyword {
+      Keyword::AllowDuplicateRecipes => {
+        Some(Setting::AllowDuplicateRecipes(self.parse_set_bool()?))
+      }
+      Keyword::DotenvLoad => Some(Setting::DotenvLoad(self.parse_set_bool()?)),
+      Keyword::Export => Some(Setting::Export(self.parse_set_bool()?)),
+      Keyword::Fallback => Some(Setting::Fallback(self.parse_set_bool()?)),
+      Keyword::IgnoreComments => Some(Setting::IgnoreComments(self.parse_set_bool()?)),
+      Keyword::PositionalArguments => Some(Setting::PositionalArguments(self.parse_set_bool()?)),
+      Keyword::WindowsPowershell => Some(Setting::WindowsPowerShell(self.parse_set_bool()?)),
+      _ => None,
     };
 
     if let Some(value) = set_bool {
@@ -803,26 +805,22 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
     self.expect(ColonEquals)?;
 
-    if name.lexeme() == Keyword::Shell.lexeme() {
-      Ok(Set {
-        value: Setting::Shell(self.parse_shell()?),
-        name,
-      })
-    } else if name.lexeme() == Keyword::WindowsShell.lexeme() {
-      Ok(Set {
-        value: Setting::WindowsShell(self.parse_shell()?),
-        name,
-      })
-    } else if name.lexeme() == Keyword::Tempdir.lexeme() {
-      Ok(Set {
-        value: Setting::Tempdir(self.parse_string_literal()?.cooked),
-        name,
-      })
-    } else {
-      Err(name.error(CompileErrorKind::UnknownSetting {
-        setting: name.lexeme(),
-      }))
+    let set_value = match keyword {
+      Keyword::DotenvFilename => Some(Setting::DotenvFilename(self.parse_string_literal()?.cooked)),
+      Keyword::DotenvPath => Some(Setting::DotenvPath(self.parse_string_literal()?.cooked)),
+      Keyword::Shell => Some(Setting::Shell(self.parse_shell()?)),
+      Keyword::Tempdir => Some(Setting::Tempdir(self.parse_string_literal()?.cooked)),
+      Keyword::WindowsShell => Some(Setting::WindowsShell(self.parse_shell()?)),
+      _ => None,
+    };
+
+    if let Some(value) = set_value {
+      return Ok(Set { name, value });
     }
+
+    Err(name.error(CompileErrorKind::UnknownSetting {
+      setting: name.lexeme(),
+    }))
   }
 
   /// Parse a shell setting value
diff --git a/src/setting.rs b/src/setting.rs
index 7278760a..9b2a9fe6 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -3,7 +3,9 @@ use super::*;
 #[derive(Debug, Clone)]
 pub(crate) enum Setting<'src> {
   AllowDuplicateRecipes(bool),
+  DotenvFilename(String),
   DotenvLoad(bool),
+  DotenvPath(String),
   Export(bool),
   Fallback(bool),
   IgnoreComments(bool),
@@ -25,8 +27,8 @@ impl<'src> Display for Setting<'src> {
       | Setting::PositionalArguments(value)
       | Setting::WindowsPowerShell(value) => write!(f, "{value}"),
       Setting::Shell(shell) | Setting::WindowsShell(shell) => write!(f, "{shell}"),
-      Setting::Tempdir(tempdir) => {
-        write!(f, "{tempdir:?}")
+      Setting::DotenvFilename(value) | Setting::DotenvPath(value) | Setting::Tempdir(value) => {
+        write!(f, "{value:?}")
       }
     }
   }
diff --git a/src/settings.rs b/src/settings.rs
index 508cfb02..26765e0f 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -9,7 +9,9 @@ pub(crate) const WINDOWS_POWERSHELL_ARGS: &[&str] = &["-NoLogo", "-Command"];
 #[allow(clippy::struct_excessive_bools)]
 pub(crate) struct Settings<'src> {
   pub(crate) allow_duplicate_recipes: bool,
+  pub(crate) dotenv_filename: Option<String>,
   pub(crate) dotenv_load: Option<bool>,
+  pub(crate) dotenv_path: Option<PathBuf>,
   pub(crate) export: bool,
   pub(crate) fallback: bool,
   pub(crate) ignore_comments: bool,
@@ -29,9 +31,15 @@ impl<'src> Settings<'src> {
         Setting::AllowDuplicateRecipes(allow_duplicate_recipes) => {
           settings.allow_duplicate_recipes = allow_duplicate_recipes;
         }
+        Setting::DotenvFilename(filename) => {
+          settings.dotenv_filename = Some(filename);
+        }
         Setting::DotenvLoad(dotenv_load) => {
           settings.dotenv_load = Some(dotenv_load);
         }
+        Setting::DotenvPath(path) => {
+          settings.dotenv_path = Some(PathBuf::from(path));
+        }
         Setting::Export(export) => {
           settings.export = export;
         }


Test patch:
diff --git a/tests/dotenv.rs b/tests/dotenv.rs
index a1182f60..3814e892 100644
--- a/tests/dotenv.rs
+++ b/tests/dotenv.rs
@@ -161,3 +161,87 @@ fn path_flag_overwrites_no_load() {
     .status(EXIT_SUCCESS)
     .run();
 }
+
+#[test]
+fn can_set_dotenv_filename_from_justfile() {
+  Test::new()
+    .justfile(
+      r#"
+        set dotenv-filename := ".env.special"
+
+        foo:
+          @echo $NAME
+      "#,
+    )
+    .tree(tree! {
+      ".env.special": "NAME=bar"
+    })
+    .stdout("bar\n")
+    .status(EXIT_SUCCESS)
+    .run();
+}
+
+#[test]
+fn can_set_dotenv_path_from_justfile() {
+  Test::new()
+    .justfile(
+      r#"
+        set dotenv-path:= "subdir/.env"
+
+        foo:
+          @echo $NAME
+      "#,
+    )
+    .tree(tree! {
+      subdir: {
+        ".env": "NAME=bar"
+      }
+    })
+    .stdout("bar\n")
+    .status(EXIT_SUCCESS)
+    .run();
+}
+
+#[test]
+fn program_argument_has_priority_for_dotenv_filename() {
+  Test::new()
+    .justfile(
+      r#"
+        set dotenv-filename := ".env.special"
+
+        foo:
+          @echo $NAME
+      "#,
+    )
+    .tree(tree! {
+      ".env.special": "NAME=bar",
+      ".env.superspecial": "NAME=baz"
+    })
+    .args(["--dotenv-filename", ".env.superspecial"])
+    .stdout("baz\n")
+    .status(EXIT_SUCCESS)
+    .run();
+}
+
+#[test]
+fn program_argument_has_priority_for_dotenv_path() {
+  Test::new()
+    .justfile(
+      r#"
+        set dotenv-path:= "subdir/.env"
+
+        foo:
+          @echo $NAME
+      "#,
+    )
+    .tree(tree! {
+      subdir: {
+        ".env": "NAME=bar",
+        ".env.special": "NAME=baz"
+      }
+    })
+    .args(["--dotenv-path", "subdir/.env.special"])
+    .stdout("baz\n")
+    .status(EXIT_SUCCESS)
+    .run();
+}
diff --git a/tests/json.rs b/tests/json.rs
index 4e4afb1b..39e04b68 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -42,7 +42,9 @@ fn alias() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "positional_arguments": false,
@@ -74,7 +76,9 @@ fn assignment() {
       "recipes": {},
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -120,7 +124,9 @@ fn body() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -177,7 +183,9 @@ fn dependencies() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -271,7 +279,9 @@ fn dependency_argument() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -329,7 +339,9 @@ fn duplicate_recipes() {
       },
       "settings": {
         "allow_duplicate_recipes": true,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -368,7 +380,9 @@ fn doc_comment() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -394,7 +408,9 @@ fn empty_justfile() {
       "recipes": {},
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -535,7 +551,9 @@ fn parameters() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -612,7 +630,9 @@ fn priors() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -651,7 +671,9 @@ fn private() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -690,7 +712,9 @@ fn quiet() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -710,6 +734,8 @@ fn settings() {
   test(
     "
       set dotenv-load
+      set dotenv-filename := \"filename\"
+      set dotenv-path := \"path\"
       set export
       set fallback
       set positional-arguments
@@ -738,7 +764,9 @@ fn settings() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": "filename",
         "dotenv_load": true,
+        "dotenv_path": "path",
         "export": true,
         "fallback": true,
         "ignore_comments": true,
@@ -783,7 +811,9 @@ fn shebang() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -822,7 +852,9 @@ fn simple() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "ignore_comments": false,
@@ -864,7 +896,9 @@ fn attribute() {
       },
       "settings": {
         "allow_duplicate_recipes": false,
+        "dotenv_filename": null,
         "dotenv_load": null,
+        "dotenv_path": null,
         "export": false,
         "fallback": false,
         "positional_arguments": false,
diff --git a/tests/misc.rs b/tests/misc.rs
index 2c64d5d2..7d8ff0f2 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -71,10 +71,24 @@ test! {
     set foo
   ",
   stderr: "
-  error: Expected ':=', but found end of line
+  error: Unknown setting `foo`
     |
   1 | set foo
-    |        ^
+    |     ^^^
+  ",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: bad_setting_with_keyword_name,
+  justfile: "
+    set if := 'foo'
+  ",
+  stderr: "
+  error: Unknown setting `if`
+    |
+  1 | set if := 'foo'
+    |     ^^
   ",
   status: EXIT_FAILURE,
 }