From e00e965ad986af03c0ea717144c8726780d5fae6 Mon Sep 17 00:00:00 2001 From: Michael Hewson Date: Mon, 24 Mar 2025 23:28:22 -0500 Subject: [PATCH 1/8] wip: multiple dotenv files --- src/config.rs | 24 +++++++++------ src/load_dotenv.rs | 76 +++++++++++++++++++++++++++++++--------------- src/settings.rs | 4 +-- 3 files changed, 68 insertions(+), 36 deletions(-) diff --git a/src/config.rs b/src/config.rs index 3e17ed0c39..42a89391b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,8 +17,8 @@ pub(crate) struct Config { pub(crate) check: bool, pub(crate) color: Color, pub(crate) command_color: Option, - pub(crate) dotenv_filename: Option, - pub(crate) dotenv_path: Option, + pub(crate) dotenv_filename: Vec, + pub(crate) dotenv_path: Vec, pub(crate) dry_run: bool, pub(crate) dump_format: DumpFormat, pub(crate) explain: bool, @@ -201,17 +201,17 @@ impl Config { .arg( Arg::new(arg::DOTENV_FILENAME) .long("dotenv-filename") - .action(ArgAction::Set) - .help("Search for environment file named instead of `.env`") + .action(ArgAction::Append) + .help("Search for environment files with these names instead of `.env`. Can be used multiple times") .conflicts_with(arg::DOTENV_PATH), ) .arg( Arg::new(arg::DOTENV_PATH) .short('E') .long("dotenv-path") - .action(ArgAction::Set) + .action(ArgAction::Append) .value_parser(value_parser!(PathBuf)) - .help("Load as environment file instead of searching for one"), + .help("Load environment files at these paths instead of searching. Can be used multiple times"), ) .arg( Arg::new(arg::DRY_RUN) @@ -763,9 +763,15 @@ impl Config { .copied() .map(CommandColor::into), dotenv_filename: matches - .get_one::(arg::DOTENV_FILENAME) - .map(Into::into), - dotenv_path: matches.get_one::(arg::DOTENV_PATH).map(Into::into), + .get_many::(arg::DOTENV_FILENAME) + .unwrap_or_default() + .map(Into::into) + .collect(), + dotenv_path: matches + .get_many::(arg::DOTENV_PATH) + .unwrap_or_default() + .map(Into::into) + .collect(), dry_run: matches.get_flag(arg::DRY_RUN), dump_format: matches .get_one::(arg::DUMP_FORMAT) diff --git a/src/load_dotenv.rs b/src/load_dotenv.rs index 29b31b2433..4a65501e98 100644 --- a/src/load_dotenv.rs +++ b/src/load_dotenv.rs @@ -5,37 +5,59 @@ pub(crate) fn load_dotenv( settings: &Settings, working_directory: &Path, ) -> RunResult<'static, BTreeMap> { - let dotenv_filename = config - .dotenv_filename - .as_ref() - .or(settings.dotenv_filename.as_ref()); + let dotenv_filenames = if !config.dotenv_filename.is_empty() { + config.dotenv_filename.as_slice() + } else { + settings.dotenv_filename.as_slice() + }; - let dotenv_path = config - .dotenv_path - .as_ref() - .or(settings.dotenv_path.as_ref()); + let dotenv_paths = if !config.dotenv_path.is_empty() { + config.dotenv_path.as_slice() + } else { + settings.dotenv_path.as_slice() + }; if !settings.dotenv_load - && dotenv_filename.is_none() - && dotenv_path.is_none() + && dotenv_filenames.is_empty() + && dotenv_paths.is_empty() && !settings.dotenv_required { return Ok(BTreeMap::new()); } - if let Some(path) = dotenv_path { - let path = working_directory.join(path); - if path.is_file() { - return load_from_file(&path); - } + if !dotenv_paths.is_empty() { + let paths = dotenv_paths + .iter() + .map(|path| working_directory.join(path)) + .collect::>(); + + return load_from_files(&paths); } - let filename = dotenv_filename.map_or(".env", |s| s.as_str()); + let filenames = if dotenv_filenames.is_empty() { + vec![".env"] + } else { + dotenv_filenames + .iter() + .map(|s| s.as_str()) + .collect::>() + }; for directory in working_directory.ancestors() { - let path = directory.join(filename); - if path.is_file() { - return load_from_file(&path); + let present_filenames = filenames + .iter() + .filter_map(|filename| { + let filename = directory.join(filename); + if filename.is_file() { + Some(filename) + } else { + None + } + }) + .collect::>(); + + if !present_filenames.is_empty() { + return load_from_files(&present_filenames); } } @@ -46,14 +68,18 @@ pub(crate) fn load_dotenv( } } -fn load_from_file(path: &Path) -> RunResult<'static, BTreeMap> { - let iter = dotenvy::from_path_iter(path)?; +fn load_from_files(paths: &[PathBuf]) -> RunResult<'static, BTreeMap> { let mut dotenv = BTreeMap::new(); - for result in iter { - let (key, value) = result?; - if env::var_os(&key).is_none() { - dotenv.insert(key, value); + + for path in paths { + let iter = dotenvy::from_path_iter(path)?; + for result in iter { + let (key, value) = result?; + if env::var_os(&key).is_none() { + dotenv.insert(key, value); + } } } + Ok(dotenv) } diff --git a/src/settings.rs b/src/settings.rs index 6ab3335398..de889919e6 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -9,9 +9,9 @@ pub(crate) const WINDOWS_POWERSHELL_ARGS: &[&str] = &["-NoLogo", "-Command"]; pub(crate) struct Settings<'src> { pub(crate) allow_duplicate_recipes: bool, pub(crate) allow_duplicate_variables: bool, - pub(crate) dotenv_filename: Option, + pub(crate) dotenv_filename: Vec, pub(crate) dotenv_load: bool, - pub(crate) dotenv_path: Option, + pub(crate) dotenv_path: Vec, pub(crate) dotenv_required: bool, pub(crate) export: bool, pub(crate) fallback: bool, From c8aabdd631e821685d64581ef41a11e1e1955de1 Mon Sep 17 00:00:00 2001 From: Michael Hewson Date: Tue, 25 Mar 2025 13:18:25 -0500 Subject: [PATCH 2/8] got it to compile --- src/lib.rs | 2 ++ src/node.rs | 14 ++++++++++---- src/parser.rs | 25 +++++++++++++++++++++++-- src/setting.rs | 14 ++++++++------ src/settings.rs | 8 ++++---- src/string_literal_or_array.rs | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 src/string_literal_or_array.rs diff --git a/src/lib.rs b/src/lib.rs index 1bdf9ad171..d8100c3240 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,6 +83,7 @@ pub(crate) use { string_delimiter::StringDelimiter, string_kind::StringKind, string_literal::StringLiteral, + string_literal_or_array::StringLiteralOrArray, subcommand::Subcommand, suggestion::Suggestion, table::Table, @@ -264,6 +265,7 @@ mod source; mod string_delimiter; mod string_kind; mod string_literal; +mod string_literal_or_array; mod subcommand; mod suggestion; mod table; diff --git a/src/node.rs b/src/node.rs index 6460782497..f6640607a4 100644 --- a/src/node.rs +++ b/src/node.rs @@ -322,10 +322,16 @@ impl<'src> Node<'src> for Set<'src> { set.push_mut(Tree::string(&argument.cooked)); } } - Setting::DotenvFilename(value) - | Setting::DotenvPath(value) - | Setting::Tempdir(value) - | Setting::WorkingDirectory(value) => { + Setting::DotenvFilename(value) | Setting::DotenvPath(value) => match value { + StringLiteralOrArray::Single(string_literal) => { + set.push_mut(Tree::string(&string_literal.cooked)) + } + StringLiteralOrArray::Multiple(string_literals) => set.push_mut(Tree::list( + string_literals.iter().map(|s| Tree::string(&s.cooked)), + )), + }, + + Setting::Tempdir(value) | Setting::WorkingDirectory(value) => { set.push_mut(Tree::string(&value.cooked)); } } diff --git a/src/parser.rs b/src/parser.rs index a72003bf3e..d1d6000a28 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1138,8 +1138,10 @@ impl<'run, 'src> Parser<'run, 'src> { self.expect(ColonEquals)?; let set_value = match keyword { - Keyword::DotenvFilename => Some(Setting::DotenvFilename(self.parse_string_literal()?)), - Keyword::DotenvPath => Some(Setting::DotenvPath(self.parse_string_literal()?)), + Keyword::DotenvFilename => Some(Setting::DotenvFilename( + self.parse_string_literal_or_array()?, + )), + Keyword::DotenvPath => Some(Setting::DotenvPath(self.parse_string_literal_or_array()?)), Keyword::ScriptInterpreter => Some(Setting::ScriptInterpreter(self.parse_interpreter()?)), Keyword::Shell => Some(Setting::Shell(self.parse_interpreter()?)), Keyword::Tempdir => Some(Setting::Tempdir(self.parse_string_literal()?)), @@ -1243,6 +1245,25 @@ impl<'run, 'src> Parser<'run, 'src> { Ok(Some((token.unwrap(), attributes.into_keys().collect()))) } } + + fn parse_string_literal_or_array(&mut self) -> CompileResult<'src, StringLiteralOrArray<'src>> { + if self.accepted(BracketL)? { + let mut literals = Vec::new(); + + while !self.next_is(BracketR) { + literals.push(self.parse_string_literal()?); + + if !self.accepted(Comma)? { + break; + } + } + + self.expect(BracketR)?; + Ok(StringLiteralOrArray::Multiple(literals)) + } else { + Ok(StringLiteralOrArray::Single(self.parse_string_literal()?)) + } + } } #[cfg(test)] diff --git a/src/setting.rs b/src/setting.rs index f187f41897..4c506cd485 100644 --- a/src/setting.rs +++ b/src/setting.rs @@ -4,9 +4,9 @@ use super::*; pub(crate) enum Setting<'src> { AllowDuplicateRecipes(bool), AllowDuplicateVariables(bool), - DotenvFilename(StringLiteral<'src>), + DotenvFilename(StringLiteralOrArray<'src>), DotenvLoad(bool), - DotenvPath(StringLiteral<'src>), + DotenvPath(StringLiteralOrArray<'src>), DotenvRequired(bool), Export(bool), Fallback(bool), @@ -41,10 +41,12 @@ impl Display for Setting<'_> { Self::ScriptInterpreter(shell) | Self::Shell(shell) | Self::WindowsShell(shell) => { write!(f, "[{shell}]") } - Self::DotenvFilename(value) - | Self::DotenvPath(value) - | Self::Tempdir(value) - | Self::WorkingDirectory(value) => { + + Self::DotenvFilename(value) | Self::DotenvPath(value) => { + write!(f, "{}", value) + } + + Self::Tempdir(value) | Self::WorkingDirectory(value) => { write!(f, "{value}") } } diff --git a/src/settings.rs b/src/settings.rs index de889919e6..c2781ee777 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -41,14 +41,14 @@ impl<'src> Settings<'src> { Setting::AllowDuplicateVariables(allow_duplicate_variables) => { settings.allow_duplicate_variables = allow_duplicate_variables; } - Setting::DotenvFilename(filename) => { - settings.dotenv_filename = Some(filename.cooked); + Setting::DotenvFilename(filenames) => { + settings.dotenv_filename = filenames.cooked(); } Setting::DotenvLoad(dotenv_load) => { settings.dotenv_load = dotenv_load; } - Setting::DotenvPath(path) => { - settings.dotenv_path = Some(PathBuf::from(path.cooked)); + Setting::DotenvPath(paths) => { + settings.dotenv_path = paths.cooked().into_iter().map(Into::into).collect(); } Setting::DotenvRequired(dotenv_required) => { settings.dotenv_required = dotenv_required; diff --git a/src/string_literal_or_array.rs b/src/string_literal_or_array.rs new file mode 100644 index 0000000000..89d72ffe3d --- /dev/null +++ b/src/string_literal_or_array.rs @@ -0,0 +1,34 @@ +use super::*; + +#[derive(Debug, Clone)] +pub(crate) enum StringLiteralOrArray<'src> { + Single(StringLiteral<'src>), + Multiple(Vec>), +} + +impl<'src> StringLiteralOrArray<'src> { + pub(crate) fn cooked(&self) -> Vec { + match self { + Self::Single(lit) => vec![lit.cooked.clone()], + Self::Multiple(lits) => lits.iter().map(|lit| lit.cooked.clone()).collect(), + } + } +} + +impl Display for StringLiteralOrArray<'_> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Single(lit) => write!(f, "{lit}"), + Self::Multiple(lits) => { + write!(f, "[")?; + for (i, lit) in lits.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{lit}")?; + } + write!(f, "]") + } + } + } +} From baf87f16004d26ddec634be2fed24bc984041f99 Mon Sep 17 00:00:00 2001 From: Michael Hewson Date: Tue, 25 Mar 2025 13:19:04 -0500 Subject: [PATCH 3/8] fix failing test about missing dotenv path --- src/load_dotenv.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/load_dotenv.rs b/src/load_dotenv.rs index 4a65501e98..f0e185011f 100644 --- a/src/load_dotenv.rs +++ b/src/load_dotenv.rs @@ -26,12 +26,15 @@ pub(crate) fn load_dotenv( } if !dotenv_paths.is_empty() { - let paths = dotenv_paths + let present_paths = dotenv_paths .iter() .map(|path| working_directory.join(path)) + .filter(|path| path.is_file()) .collect::>(); - return load_from_files(&paths); + if !present_paths.is_empty() { + return load_from_files(&present_paths); + } } let filenames = if dotenv_filenames.is_empty() { From 029b0d42055ed8f22ace5604a98d1d2672f7dfd0 Mon Sep 17 00:00:00 2001 From: Michael Hewson Date: Tue, 25 Mar 2025 13:59:55 -0500 Subject: [PATCH 4/8] add support for array in json tests --- tests/json.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/json.rs b/tests/json.rs index 30f713bea8..0b36a1ca6d 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -77,9 +77,9 @@ struct Recipe<'a> { struct Settings<'a> { allow_duplicate_recipes: bool, allow_duplicate_variables: bool, - dotenv_filename: Option<&'a str>, + dotenv_filename: Vec<&'a str>, dotenv_load: bool, - dotenv_path: Option<&'a str>, + dotenv_path: Vec<&'a str>, dotenv_required: bool, export: bool, fallback: bool, @@ -694,8 +694,8 @@ fn settings() { .into(), settings: Settings { allow_duplicate_recipes: true, - dotenv_filename: Some("filename"), - dotenv_path: Some("path"), + dotenv_filename: vec!["filename"], + dotenv_path: vec!["path"], dotenv_load: true, export: true, fallback: true, @@ -713,6 +713,24 @@ fn settings() { ); } +#[test] +fn settings_multiple_dotenv() { + case( + " + set dotenv-filename := ['filename1', 'filename2'] + set dotenv-path := ['path1', 'path2'] + ", + Module { + settings: Settings { + dotenv_filename: vec!["filename1", "filename2"], + dotenv_path: vec!["path1", "path2"], + ..default() + }, + ..default() + }, + ); +} + #[test] fn shebang() { case( From 83c13282ad7ea6649ef5da36b15525aff1e092f1 Mon Sep 17 00:00:00 2001 From: Michael Hewson Date: Tue, 25 Mar 2025 17:05:21 -0500 Subject: [PATCH 5/8] add test cases --- tests/dotenv.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/dotenv.rs b/tests/dotenv.rs index 08c4ed7a9e..44196d5ef2 100644 --- a/tests/dotenv.rs +++ b/tests/dotenv.rs @@ -407,3 +407,92 @@ fn dotenv_path_does_not_override_dotenv_file() { .stdout("ROOT\n") .run(); } + +#[test] +fn multiple_dotenv_filename() { + Test::new() + .justfile( + r#" + set dotenv-filename := ['.env1', '.env2'] + + foo: + @echo $KEY1-$KEY2-$KEY3 + "#, + ) + .write(".env1", "KEY1=one\nKEY2=two") + .write(".env2", "KEY2=override\nKEY3=three") + .stdout("one-override-three\n") + .run(); +} + +#[test] +fn multiple_dotenv_filename_parent() { + Test::new() + .justfile( + r#" + set dotenv-filename := ['.env1', '.env2'] + + foo: + @echo $KEY1-$KEY2 + "#, + ) + .write(".env1", "KEY1=parent1") + .write(".env2", "KEY2=parent2") + .write( + "sub/justfile", + "set dotenv-filename := ['.env1', '.env2']\n@foo:\n echo $KEY1-$KEY2", + ) + .current_dir("sub") + .stdout("parent1-parent2\n") + .run(); +} + +#[test] +fn multiple_dotenv_path() { + Test::new() + .justfile( + r#" + set dotenv-path := ['config/.env1', 'config/.env2'] + + foo: + @echo $KEY1-$KEY2-$KEY3 + "#, + ) + .write("config/.env1", "KEY1=one\nKEY2=two") + .write("config/.env2", "KEY2=override\nKEY3=three") + .stdout("one-override-three\n") + .run(); +} + +#[test] +fn empty_dotenv_path_array_falls_back_to_dotenv_filename() { + Test::new() + .justfile( + r#" + set dotenv-path := [] + set dotenv-load + + foo: + @echo $KEY + "#, + ) + .write(".env", "KEY=value") + .stdout("value\n") + .run(); +} + +#[test] +fn empty_dotenv_filename_array() { + Test::new() + .justfile( + r#" + set dotenv-filename := [] + + foo: + @echo ${KEY:-not_set} + "#, + ) + .write(".env", "KEY=value") + .stdout("not_set\n") + .run(); +} From 0aa6de002d31789f38f00c36028d7dab7f8b7f6e Mon Sep 17 00:00:00 2001 From: Michael Hewson Date: Tue, 25 Mar 2025 18:43:11 -0500 Subject: [PATCH 6/8] update test names to start with dotenv --- tests/dotenv.rs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/dotenv.rs b/tests/dotenv.rs index 44196d5ef2..3008b483e3 100644 --- a/tests/dotenv.rs +++ b/tests/dotenv.rs @@ -409,7 +409,7 @@ fn dotenv_path_does_not_override_dotenv_file() { } #[test] -fn multiple_dotenv_filename() { +fn dotenv_multiple_filename() { Test::new() .justfile( r#" @@ -426,7 +426,7 @@ fn multiple_dotenv_filename() { } #[test] -fn multiple_dotenv_filename_parent() { +fn dotenv_multiple_filename_parent() { Test::new() .justfile( r#" @@ -448,7 +448,7 @@ fn multiple_dotenv_filename_parent() { } #[test] -fn multiple_dotenv_path() { +fn dotenv_multiple_path() { Test::new() .justfile( r#" @@ -465,7 +465,7 @@ fn multiple_dotenv_path() { } #[test] -fn empty_dotenv_path_array_falls_back_to_dotenv_filename() { +fn dotenv_empty_path_array_falls_back_to_dotenv_filename() { Test::new() .justfile( r#" @@ -482,7 +482,7 @@ fn empty_dotenv_path_array_falls_back_to_dotenv_filename() { } #[test] -fn empty_dotenv_filename_array() { +fn dotenv_empty_filename_array() { Test::new() .justfile( r#" @@ -496,3 +496,22 @@ fn empty_dotenv_filename_array() { .stdout("not_set\n") .run(); } + +#[test] +fn dotenv_multiple_filenames_with_variable_expansion() { + Test::new() + .justfile( + r#" + set dotenv-filename := [".env", x'.env.${ENV:-dev}'] + + foo: + @echo $KEY1$KEY2 + "#, + ) + .write(".env", "KEY1=default\nKEY2=default") + .write(".env.dev", "KEY2=dev") + .write(".env.prod", "KEY2=prod") + .env("ENV", "prod") + .stdout("defaultprod\n") + .run(); +} From 4b170b972192f344ed86396bf9d8815b07de881a Mon Sep 17 00:00:00 2001 From: Michael Hewson Date: Tue, 25 Mar 2025 19:10:02 -0500 Subject: [PATCH 7/8] update docs --- GRAMMAR.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/GRAMMAR.md b/GRAMMAR.md index 00721f15af..29ea98f6c1 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -65,9 +65,9 @@ set : 'set' setting eol setting : 'allow-duplicate-recipes' boolean? | 'allow-duplicate-variables' boolean? - | 'dotenv-filename' ':=' string + | 'dotenv-filename' ':=' string | string_list | 'dotenv-load' boolean? - | 'dotenv-path' ':=' string + | 'dotenv-path' ':=' string | string_list | 'dotenv-required' boolean? | 'export' boolean? | 'fallback' boolean? diff --git a/README.md b/README.md index 5433906221..617522883b 100644 --- a/README.md +++ b/README.md @@ -982,9 +982,9 @@ foo: |------|-------|---------|-------------| | `allow-duplicate-recipes` | boolean | `false` | Allow recipes appearing later in a `justfile` to override earlier recipes with the same name. | | `allow-duplicate-variables` | boolean | `false` | Allow variables appearing later in a `justfile` to override earlier variables with the same name. | -| `dotenv-filename` | string | - | Load a `.env` file with a custom name, if present. | +| `dotenv-filename` | string or [string] | - | Load a `.env` file with a custom name, if present. If an array is supplied, files are loaded in order. | | `dotenv-load` | boolean | `false` | Load a `.env` file, if present. | -| `dotenv-path` | string | - | Load a `.env` file from a custom path and error if not present. Overrides `dotenv-filename`. | +| `dotenv-path` | string or [string] | - | Load a `.env` file from a custom path and error if not present. If an array is supplied, files are loaded in order. Overrides `dotenv-filename`. | | `dotenv-required` | boolean | `false` | Error if a `.env` file isn't found. | | `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. | From f8583a4dce53a5590cfb2ab15b9e8b3f112b1dc8 Mon Sep 17 00:00:00 2001 From: Michael Hewson Date: Tue, 25 Mar 2025 19:23:39 -0500 Subject: [PATCH 8/8] tweak command-line argument usage info --- src/config.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 42a89391b9..fff4269131 100644 --- a/src/config.rs +++ b/src/config.rs @@ -202,7 +202,7 @@ impl Config { Arg::new(arg::DOTENV_FILENAME) .long("dotenv-filename") .action(ArgAction::Append) - .help("Search for environment files with these names instead of `.env`. Can be used multiple times") + .help("Search for environment files with this name instead of `.env`. Can be used multiple times to load from multiple files in order.") .conflicts_with(arg::DOTENV_PATH), ) .arg( @@ -211,7 +211,7 @@ impl Config { .long("dotenv-path") .action(ArgAction::Append) .value_parser(value_parser!(PathBuf)) - .help("Load environment files at these paths instead of searching. Can be used multiple times"), + .help("Load environment files at this path instead of searching. Can be used multiple times to load from multiple files in order."), ) .arg( Arg::new(arg::DRY_RUN)