From 7c1000cc7f78856022e98d324f201dd51ac07d69 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:51:12 +0000 Subject: [PATCH 1/4] feat(config): support environment-specific conf.d files --- docs/configuration.md | 4 +- docs/configuration/environments.md | 13 +++ e2e/config/test_config_auto_env | 12 +++ e2e/config/test_config_env | 33 +++++++ src/config/mod.rs | 149 +++++++++++++++++++++++++---- 5 files changed, 189 insertions(+), 22 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 724b67ba85d..270fa94ac6c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,10 +10,10 @@ Learn how to configure mise for your project with `mise.toml` files, environment - `mise.toml` - `mise/config.toml` - `.mise/config.toml` -- `.mise/conf.d/*.toml` - all non-hidden TOML files in this directory will be loaded in alphabetical order +- `.mise/conf.d/*.toml` - non-hidden TOML fragments, loaded in alphabetical order; environment suffixes such as `tools.development.toml` load only when that environment is active - `.config/mise.toml` - use this in order to group config files into a common directory - `.config/mise/config.toml` -- `.config/mise/conf.d/*.toml` - all non-hidden TOML files in this directory will be loaded in alphabetical order +- `.config/mise/conf.d/*.toml` - the same fragment and environment-suffix behavior under the grouped config directory ::: tip Run [`mise cfg`](/cli/config.html) to figure out what order mise is loading files on your particular setup. This is often diff --git a/docs/configuration/environments.md b/docs/configuration/environments.md index 252584796d5..c090e4f5de8 100644 --- a/docs/configuration/environments.md +++ b/docs/configuration/environments.md @@ -70,6 +70,19 @@ If `MISE_OVERRIDE_CONFIG_FILENAMES` is set, that will be used instead of all of You can also use paths like `mise/config.{MISE_ENV}.toml` or `.config/mise.{MISE_ENV}.toml` Those rules follow the order in [Configuration](/configuration). +Files in `.mise/conf.d` and `.config/mise/conf.d` use the same environment suffixes. This allows a +configuration to stay split into named fragments: + +```text +.mise/conf.d/tools.toml # always loaded +.mise/conf.d/tools.local.toml # always loaded, usually gitignored +.mise/conf.d/tools.development.toml # MISE_ENV=development +.mise/conf.d/tools.development.local.toml # MISE_ENV=development, usually gitignored +``` + +An extra filename component before `.toml` is reserved for the environment, so use hyphens rather +than dots inside an unconditional fragment name: `node-tools.toml`, not `node.tools.toml`. + Use `mise config` to see which files are being used. The rules around which file is written are different because we ultimately need to choose one. See diff --git a/e2e/config/test_config_auto_env b/e2e/config/test_config_auto_env index ff763f9a7e6..dd2a76b1399 100644 --- a/e2e/config/test_config_auto_env +++ b/e2e/config/test_config_auto_env @@ -21,9 +21,15 @@ env.UNIX_LOADED = "1"' >mise.unix.toml echo "env.AAA = \"$os\"" >"mise.$os.toml" echo "env.AAA = \"$os-$arch\"" >"mise.$os-$arch.toml" echo 'env.AAA = "prod"' >mise.prod.toml +mkdir -p .mise/conf.d +echo 'env.CONF_D_AUTO = "base"' >.mise/conf.d/00-base.toml +echo 'env.CONF_D_AUTO = "unix"' >.mise/conf.d/10-platform.unix.toml +echo "env.CONF_D_AUTO = \"$os\"" >".mise/conf.d/20-platform.$os.toml" +echo "env.CONF_D_AUTO = \"$os-$arch\"" >".mise/conf.d/30-platform.$os-$arch.toml" # default: off, platform configs are not loaded assert "mise env --json | jq -r .AAA" "base" +assert "mise env --json | jq -r .CONF_D_AUTO" "base" # the rollout warning never fires once the user has set auto_env explicitly, # regardless of the phase the current version is in (the version-gated unset @@ -35,6 +41,7 @@ assert_not_contains "MISE_AUTO_ENV=true mise env 2>&1" "load automatically" MISE_AUTO_ENV=true assert "mise env --json | jq -r .AAA" "$os-$arch" MISE_AUTO_ENV=true assert "mise env --json | jq -r .UNIX_LOADED" "1" MISE_AUTO_ENV=true assert "mise env --json | jq -r .BBB" "base" +MISE_AUTO_ENV=true assert "mise env --json | jq -r .CONF_D_AUTO" "$os-$arch" # explicit MISE_ENV entries take precedence over auto platform envs MISE_AUTO_ENV=true MISE_ENV=prod assert "mise env --json | jq -r .AAA" "prod" @@ -66,6 +73,11 @@ rm -f .miserc.toml # global config dir platform configs load too mkdir -p "$MISE_CONFIG_DIR" echo "env.GLOBAL_AUTO = \"$os\"" >"$MISE_CONFIG_DIR/config.$os.toml" +mkdir -p "$MISE_CONFIG_DIR/conf.d" +echo "env.GLOBAL_CONF_D_AUTO = \"$os\"" >"$MISE_CONFIG_DIR/conf.d/10-platform.$os.toml" MISE_AUTO_ENV=true assert "mise env --json | jq -r .GLOBAL_AUTO" "$os" +MISE_AUTO_ENV=true assert "mise env --json | jq -r .GLOBAL_CONF_D_AUTO" "$os" assert "mise env --json | jq -r .GLOBAL_AUTO" "null" +assert "mise env --json | jq -r .GLOBAL_CONF_D_AUTO" "null" rm -f "$MISE_CONFIG_DIR/config.$os.toml" +rm -f "$MISE_CONFIG_DIR/conf.d/10-platform.$os.toml" diff --git a/e2e/config/test_config_env b/e2e/config/test_config_env index 57b2b11d9ea..5f33b58a9cf 100644 --- a/e2e/config/test_config_env +++ b/e2e/config/test_config_env @@ -10,3 +10,36 @@ MISE_ENV=test assert "mise ls dummy" "dummy 2.0.0 (missing) ~/workdir/mise.tes MISE_ENV=ci assert "mise ls dummy" "dummy 3 (missing) ~/workdir/mise.ci.toml 3" MISE_ENV=ci,test assert "mise ls dummy" "dummy 2.0.0 (missing) ~/workdir/mise.test.toml 2" MISE_ENV=test,ci assert "mise ls dummy" "dummy 3 (missing) ~/workdir/mise.ci.toml 3" + +# conf.d fragments follow the same environment and local filename conventions +mkdir -p .mise/conf.d +cat >.mise/conf.d/00-base.toml <<'EOF' +[env] +CONF_D_COMMON = "base" +CONF_D_ENV = "base" +EOF +cat >.mise/conf.d/01-local.local.toml <<'EOF' +[env] +CONF_D_ALWAYS_LOCAL = "local" +EOF +cat >.mise/conf.d/10-test.test.toml <<'EOF' +[env] +CONF_D_ENV = "test" +CONF_D_ENV_LOCAL = "test" +EOF +cat >.mise/conf.d/11-test-local.test.local.toml <<'EOF' +[env] +CONF_D_ENV_LOCAL = "test-local" +EOF +cat >.mise/conf.d/20-ci.ci.toml <<'EOF' +[env] +CONF_D_ENV = "ci" +EOF +cat >.mise/conf.d/99-inactive.inactive.toml <<'EOF' +[env] +CONF_D_INACTIVE = "loaded" +EOF + +assert "mise env --json | jq -r '[.CONF_D_COMMON, .CONF_D_ENV, .CONF_D_ALWAYS_LOCAL, .CONF_D_INACTIVE] | @tsv'" $'base\tbase\tlocal\t' +MISE_ENV=test assert "mise env --json | jq -r '[.CONF_D_ENV, .CONF_D_ENV_LOCAL, .CONF_D_INACTIVE] | @tsv'" $'test\ttest-local\t' +MISE_ENV=test,ci assert "mise env --json | jq -r '[.CONF_D_ENV, .CONF_D_ENV_LOCAL] | @tsv'" $'ci\ttest-local' diff --git a/src/config/mod.rs b/src/config/mod.rs index 42154437991..53743ff39a1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1698,16 +1698,20 @@ static LOCAL_CONFIG_FILENAMES: Lazy> = Lazy::new(|| { /// (later wins, matching LOCAL_CONFIG_FILENAMES ordering) fn env_config_patterns(env: &str) -> Vec { vec![ + format!(".config/mise/conf.d/*.{env}.toml"), format!(".config/mise/config.{env}.toml"), format!(".config/mise.{env}.toml"), format!("mise/config.{env}.toml"), format!("mise.{env}.toml"), + format!(".mise/conf.d/*.{env}.toml"), format!(".mise/config.{env}.toml"), format!(".mise.{env}.toml"), + format!(".config/mise/conf.d/*.{env}.local.toml"), format!(".config/mise/config.{env}.local.toml"), format!(".config/mise.{env}.local.toml"), format!("mise/config.{env}.local.toml"), format!("mise.{env}.local.toml"), + format!(".mise/conf.d/*.{env}.local.toml"), format!(".mise/config.{env}.local.toml"), format!(".mise.{env}.local.toml"), ] @@ -1803,6 +1807,39 @@ fn config_glob(dir: &Path, pattern: &str) -> Vec { .collect() } +fn is_unconditional_conf_d_pattern(pattern: &str) -> bool { + pattern.ends_with("conf.d/*.toml") +} + +/// Whether a conf.d fragment reserves a filename component for an environment. +/// +/// `name.toml` and `name.local.toml` are unconditional. `name.dev.toml` and +/// `name.dev.local.toml` are environment-specific. Environment names themselves +/// may contain dots; active files are selected by exact glob patterns. +fn is_environment_conf_d_file(path: &Path) -> bool { + if !is_conf_d_file(path) { + return false; + } + let Some(filename) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let Some(stem) = filename.strip_suffix(".toml") else { + return false; + }; + let stem = stem.strip_suffix(".local").unwrap_or(stem); + stem.split_once('.') + .is_some_and(|(name, env)| !name.is_empty() && !env.is_empty()) +} + +fn load_config_glob(dir: &Path, pattern: &str) -> Vec { + config_glob(dir, pattern) + .into_iter() + .filter(|path| { + !is_unconditional_conf_d_pattern(pattern) || !is_environment_conf_d_file(path) + }) + .collect() +} + pub fn config_files_in_dir(dir: &Path) -> IndexSet { DEFAULT_CONFIG_FILENAMES .iter() @@ -1827,7 +1864,9 @@ pub(crate) fn environments_for_config_path(path: &Path) -> Vec { ["config", "mise", ".mise"].into_iter().any(|prefix| { filename == format!("{prefix}.{environment}.toml") || filename == format!("{prefix}.{environment}.local.toml") - }) + }) || (is_conf_d_file(path) + && (filename.ends_with(&format!(".{environment}.toml")) + || filename.ends_with(&format!(".{environment}.local.toml")))) }) .cloned() .collect() @@ -1839,7 +1878,7 @@ fn config_paths_in_dir_with_filenames(dir: &Path, filenames: &[String]) -> Vec

IndexSet Vec { } for dir in [*dirs::CONFIG, *dirs::SYSTEM_CONFIG] { for env_name in &candidate_envs { + found.extend(conf_d_environment_files(&dir, env_name, false)); + found.extend(conf_d_environment_files(&dir, env_name, true)); for filename in [ format!("config.{env_name}.toml"), format!("mise.{env_name}.toml"), @@ -2434,21 +2475,6 @@ pub fn system_config_files() -> IndexSet { config_files } -static CONFIG_FILENAMES: Lazy> = Lazy::new(|| { - let mut filenames = vec!["config.toml".to_string(), "mise.toml".to_string()]; - for env in &*env::MISE_ENV_WITH_AUTO { - filenames.push(format!("config.{env}.toml")); - filenames.push(format!("mise.{env}.toml")); - } - filenames.push("config.local.toml".to_string()); - filenames.push("mise.local.toml".to_string()); - for env in &*env::MISE_ENV_WITH_AUTO { - filenames.push(format!("config.{env}.local.toml")); - filenames.push(format!("mise.{env}.local.toml")); - } - filenames -}); - /// Config files in a global/system config dir, lowest precedence first: `conf.d`, /// then `config.toml`/`mise.toml`, then the `.local` variants. fn config_files_from_dir(dir: &Path) -> IndexSet { @@ -2457,14 +2483,47 @@ fn config_files_from_dir(dir: &Path) -> IndexSet { if let Some(file_name) = p.file_name().map(|f| f.to_string_lossy().to_string()) && !file_name.starts_with(".") && file_name.ends_with(".toml") + && !is_environment_conf_d_file(&p) { files.insert(p); } } - files.extend(CONFIG_FILENAMES.iter().map(|f| dir.join(f))); + files.extend([dir.join("config.toml"), dir.join("mise.toml")]); + for environment in &*env::MISE_ENV_WITH_AUTO { + files.extend(conf_d_environment_files(dir, environment, false)); + files.extend([ + dir.join(format!("config.{environment}.toml")), + dir.join(format!("mise.{environment}.toml")), + ]); + } + files.extend([dir.join("config.local.toml"), dir.join("mise.local.toml")]); + for environment in &*env::MISE_ENV_WITH_AUTO { + files.extend(conf_d_environment_files(dir, environment, true)); + files.extend([ + dir.join(format!("config.{environment}.local.toml")), + dir.join(format!("mise.{environment}.local.toml")), + ]); + } files.into_iter().filter(|p| p.is_file()).collect() } +fn conf_d_environment_files(dir: &Path, environment: &str, local: bool) -> Vec { + let suffix = if local { + format!(".{environment}.local.toml") + } else { + format!(".{environment}.toml") + }; + file::ls(&dir.join("conf.d")) + .unwrap_or_default() + .into_iter() + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| !name.starts_with('.') && name.ends_with(&suffix)) + }) + .collect() +} + /// the preferred global config file to write to, or the path where it should be created. /// Uses first_config_file() to pick the lowest-precedence non-local TOML (i.e., config.toml /// rather than config.local.toml) so that `mise use -g` writes to config.toml. @@ -3764,7 +3823,7 @@ fn has_mise_config_with_filenames(dir: &Path, filenames: &[String]) -> bool { fn has_config_file_with_filenames(dir: &Path, filenames: &[String]) -> bool { filenames.iter().any(|f| { if f.contains('*') { - !config_glob(dir, f).is_empty() + !load_config_glob(dir, f).is_empty() } else { dir.join(f).exists() } @@ -5324,6 +5383,52 @@ mod tests { Ok(()) } + #[test] + fn test_project_conf_d_environment_precedence() -> Result<()> { + let tmp = TempDir::new()?; + let confd = tmp.path().join(".mise/conf.d"); + fs::create_dir_all(&confd)?; + for filename in [ + "01-base.toml", + "02-local.local.toml", + "03-dev.dev.toml", + "04-dev-local.dev.local.toml", + "05-ci.ci.toml", + ] { + fs::write(confd.join(filename), "[env]\n")?; + } + + let filenames = vec![ + ".mise/conf.d/*.toml".to_string(), + ".mise/conf.d/*.dev.toml".to_string(), + ".mise/conf.d/*.dev.local.toml".to_string(), + ]; + let paths = config_paths_in_dir_with_filenames(tmp.path(), &filenames); + let relative = paths + .iter() + .map(|path| path.strip_prefix(tmp.path()).unwrap()) + .collect_vec(); + assert_eq!( + relative, + vec![ + Path::new(".mise/conf.d/04-dev-local.dev.local.toml"), + Path::new(".mise/conf.d/03-dev.dev.toml"), + Path::new(".mise/conf.d/02-local.local.toml"), + Path::new(".mise/conf.d/01-base.toml"), + ] + ); + + assert!(is_environment_conf_d_file(&confd.join("03-dev.dev.toml"))); + assert!(is_environment_conf_d_file( + &confd.join("04-dev-local.dev.local.toml") + )); + assert!(!is_environment_conf_d_file( + &confd.join("02-local.local.toml") + )); + + Ok(()) + } + #[test] fn test_prefer_windows_file_task_siblings_keeps_windows_native_script() { let file_tasks = vec![ @@ -5652,16 +5757,20 @@ mod tests { assert_eq!( env_config_patterns("linux"), vec![ + ".config/mise/conf.d/*.linux.toml", ".config/mise/config.linux.toml", ".config/mise.linux.toml", "mise/config.linux.toml", "mise.linux.toml", + ".mise/conf.d/*.linux.toml", ".mise/config.linux.toml", ".mise.linux.toml", + ".config/mise/conf.d/*.linux.local.toml", ".config/mise/config.linux.local.toml", ".config/mise.linux.local.toml", "mise/config.linux.local.toml", "mise.linux.local.toml", + ".mise/conf.d/*.linux.local.toml", ".mise/config.linux.local.toml", ".mise.linux.local.toml", ] From a9aaa39c9c7993bd6750450c0c1a9dbac142828e Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:03:44 +0000 Subject: [PATCH 2/4] fix(config): match conf.d environments exactly --- e2e/config/test_config_auto_env | 1 - e2e/config/test_config_env | 22 +++++++ e2e/tasks/test_task_monorepo_mise_env | 12 ++++ src/config/mod.rs | 89 +++++++++++++++++++-------- 4 files changed, 97 insertions(+), 27 deletions(-) diff --git a/e2e/config/test_config_auto_env b/e2e/config/test_config_auto_env index dd2a76b1399..b51c691d103 100644 --- a/e2e/config/test_config_auto_env +++ b/e2e/config/test_config_auto_env @@ -80,4 +80,3 @@ MISE_AUTO_ENV=true assert "mise env --json | jq -r .GLOBAL_CONF_D_AUTO" "$os" assert "mise env --json | jq -r .GLOBAL_AUTO" "null" assert "mise env --json | jq -r .GLOBAL_CONF_D_AUTO" "null" rm -f "$MISE_CONFIG_DIR/config.$os.toml" -rm -f "$MISE_CONFIG_DIR/conf.d/10-platform.$os.toml" diff --git a/e2e/config/test_config_env b/e2e/config/test_config_env index 5f33b58a9cf..028fb697fe2 100644 --- a/e2e/config/test_config_env +++ b/e2e/config/test_config_env @@ -43,3 +43,25 @@ EOF assert "mise env --json | jq -r '[.CONF_D_COMMON, .CONF_D_ENV, .CONF_D_ALWAYS_LOCAL, .CONF_D_INACTIVE] | @tsv'" $'base\tbase\tlocal\t' MISE_ENV=test assert "mise env --json | jq -r '[.CONF_D_ENV, .CONF_D_ENV_LOCAL, .CONF_D_INACTIVE] | @tsv'" $'test\ttest-local\t' MISE_ENV=test,ci assert "mise env --json | jq -r '[.CONF_D_ENV, .CONF_D_ENV_LOCAL] | @tsv'" $'ci\ttest-local' + +# Environment names are matched literally and by the complete suffix after the first dot +cat >'.mise/conf.d/30-literal.qa*.toml' <<'EOF' +[env] +CONF_D_LITERAL = "literal" +EOF +cat >.mise/conf.d/31-unrelated.qa1.toml <<'EOF' +[env] +CONF_D_UNRELATED = "loaded" +EOF +cat >.mise/conf.d/40-dotted.qa.prod.toml <<'EOF' +[env] +CONF_D_DOTTED = "dotted" +EOF +cat >.mise/conf.d/41-prod.prod.toml <<'EOF' +[env] +CONF_D_PROD = "prod" +EOF + +MISE_ENV='qa*' assert "mise env --json | jq -r '[.CONF_D_LITERAL, .CONF_D_UNRELATED] | @tsv'" $'literal\t' +MISE_ENV=prod assert "mise env --json | jq -r '[.CONF_D_DOTTED, .CONF_D_PROD] | @tsv'" $'\tprod' +MISE_ENV=qa.prod assert "mise env --json | jq -r '[.CONF_D_DOTTED, .CONF_D_PROD] | @tsv'" $'dotted\t' diff --git a/e2e/tasks/test_task_monorepo_mise_env b/e2e/tasks/test_task_monorepo_mise_env index be6a4760914..4201724bc24 100644 --- a/e2e/tasks/test_task_monorepo_mise_env +++ b/e2e/tasks/test_task_monorepo_mise_env @@ -37,6 +37,8 @@ echo "FRONTEND_VAR=\$FRONTEND_VAR" echo "ENV_VAR=\$ENV_VAR" echo "DEV_VAR=\$DEV_VAR" echo "PROD_VAR=\$PROD_VAR" +echo "DEV_CONF_D=\$DEV_CONF_D" +echo "PROD_CONF_D=\$PROD_CONF_D" echo "ROOT_VAR=\$ROOT_VAR" ''' EOF @@ -54,6 +56,10 @@ ENV_VAR = "frontend_prod" PROD_VAR = "frontend_prod_only" EOF +mkdir -p projects/frontend/.mise/conf.d +echo 'env.DEV_CONF_D = "dev-conf-d"' >projects/frontend/.mise/conf.d/frontend.dev.toml +echo 'env.PROD_CONF_D = "prod-conf-d"' >projects/frontend/.mise/conf.d/frontend.prod.toml + # Create backend project mkdir -p projects/backend cat <projects/backend/mise.toml @@ -99,6 +105,8 @@ echo "$output" assert_contains "echo '$output'" "ENV_VAR=frontend_default" assert_contains "echo '$output'" "FRONTEND_VAR=frontend_value" assert_contains "echo '$output'" "ROOT_VAR=root_value" +assert_not_contains "echo '$output'" "dev-conf-d" +assert_not_contains "echo '$output'" "prod-conf-d" # Test 4: Frontend task with MISE_ENV=dev should load mise.dev.toml echo "=== Test 4: Frontend task with MISE_ENV=dev ===" @@ -112,6 +120,8 @@ assert_contains "echo '$output'" "DEV_VAR=frontend_dev_only" assert_contains "echo '$output'" "ROOT_VAR=root_value" # Should have FRONTEND_VAR from base frontend config assert_contains "echo '$output'" "FRONTEND_VAR=frontend_value" +assert_contains "echo '$output'" "DEV_CONF_D=dev-conf-d" +assert_not_contains "echo '$output'" "prod-conf-d" # Test 5: Frontend task with MISE_ENV=prod should load mise.prod.toml echo "=== Test 5: Frontend task with MISE_ENV=prod ===" @@ -121,6 +131,8 @@ echo "$output" assert_contains "echo '$output'" "ENV_VAR=frontend_prod" # Should load PROD_VAR from frontend/mise.prod.toml assert_contains "echo '$output'" "PROD_VAR=frontend_prod_only" +assert_contains "echo '$output'" "PROD_CONF_D=prod-conf-d" +assert_not_contains "echo '$output'" "dev-conf-d" # Test 6: Backend task with MISE_ENV=dev should load backend's dev config echo "=== Test 6: Backend task with MISE_ENV=dev ===" diff --git a/src/config/mod.rs b/src/config/mod.rs index 53743ff39a1..3fe6bbb8392 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1423,7 +1423,7 @@ fn configs_at_root<'a>(dir: &Path, config_files: &'a ConfigMap) -> Vec<&'a Arc> = Lazy::new(|| { /// Config filename patterns for a single MISE_ENV environment, in precedence order /// (later wins, matching LOCAL_CONFIG_FILENAMES ordering) fn env_config_patterns(env: &str) -> Vec { + let env = glob::Pattern::escape(env); vec![ format!(".config/mise/conf.d/*.{env}.toml"), format!(".config/mise/config.{env}.toml"), @@ -1816,26 +1817,34 @@ fn is_unconditional_conf_d_pattern(pattern: &str) -> bool { /// `name.toml` and `name.local.toml` are unconditional. `name.dev.toml` and /// `name.dev.local.toml` are environment-specific. Environment names themselves /// may contain dots; active files are selected by exact glob patterns. -fn is_environment_conf_d_file(path: &Path) -> bool { +fn conf_d_file_environment(path: &Path) -> Option<(&str, bool)> { if !is_conf_d_file(path) { - return false; + return None; } - let Some(filename) = path.file_name().and_then(|name| name.to_str()) else { - return false; - }; - let Some(stem) = filename.strip_suffix(".toml") else { - return false; - }; - let stem = stem.strip_suffix(".local").unwrap_or(stem); - stem.split_once('.') - .is_some_and(|(name, env)| !name.is_empty() && !env.is_empty()) + let filename = path.file_name()?.to_str()?; + let stem = filename.strip_suffix(".toml")?; + let (stem, local) = stem + .strip_suffix(".local") + .map_or((stem, false), |stem| (stem, true)); + let (name, environment) = stem.split_once('.')?; + (!name.is_empty() && !environment.is_empty()).then_some((environment, local)) +} + +fn is_environment_conf_d_file(path: &Path) -> bool { + conf_d_file_environment(path).is_some() } fn load_config_glob(dir: &Path, pattern: &str) -> Vec { config_glob(dir, pattern) .into_iter() .filter(|path| { - !is_unconditional_conf_d_pattern(pattern) || !is_environment_conf_d_file(path) + if is_unconditional_conf_d_pattern(pattern) { + !is_environment_conf_d_file(path) + } else if let Some((environment, _)) = conf_d_file_environment(path) { + env::MISE_ENV_WITH_AUTO.iter().any(|env| env == environment) + } else { + true + } }) .collect() } @@ -1864,9 +1873,8 @@ pub(crate) fn environments_for_config_path(path: &Path) -> Vec { ["config", "mise", ".mise"].into_iter().any(|prefix| { filename == format!("{prefix}.{environment}.toml") || filename == format!("{prefix}.{environment}.local.toml") - }) || (is_conf_d_file(path) - && (filename.ends_with(&format!(".{environment}.toml")) - || filename.ends_with(&format!(".{environment}.local.toml")))) + }) || conf_d_file_environment(path) + .is_some_and(|(file_environment, _)| file_environment == environment.as_str()) }) .cloned() .collect() @@ -2244,7 +2252,16 @@ fn detect_auto_env_candidate_files() -> Vec { } for env_name in &candidate_envs { for pattern in env_config_patterns(env_name) { - found.extend(glob(&dir, &pattern).unwrap_or_default()); + found.extend( + glob(&dir, &pattern) + .unwrap_or_default() + .into_iter() + .filter(|path| { + !is_conf_d_file(path) + || conf_d_file_environment(path) + .is_some_and(|(environment, _)| environment == env_name) + }), + ); } } } @@ -2304,7 +2321,7 @@ pub async fn load_config_hierarchy_from_dir( config_filenames .iter() .rev() - .flat_map(|f| config_glob(dir, f).into_iter().rev()) + .flat_map(|f| load_config_glob(dir, f).into_iter().rev()) .collect() } }) @@ -2508,18 +2525,14 @@ fn config_files_from_dir(dir: &Path) -> IndexSet { } fn conf_d_environment_files(dir: &Path, environment: &str, local: bool) -> Vec { - let suffix = if local { - format!(".{environment}.local.toml") - } else { - format!(".{environment}.toml") - }; file::ls(&dir.join("conf.d")) .unwrap_or_default() .into_iter() .filter(|path| { path.file_name() .and_then(|name| name.to_str()) - .is_some_and(|name| !name.starts_with('.') && name.ends_with(&suffix)) + .is_some_and(|name| !name.starts_with('.')) + && conf_d_file_environment(path) == Some((environment, local)) }) .collect() } @@ -5411,17 +5424,23 @@ mod tests { assert_eq!( relative, vec![ - Path::new(".mise/conf.d/04-dev-local.dev.local.toml"), - Path::new(".mise/conf.d/03-dev.dev.toml"), Path::new(".mise/conf.d/02-local.local.toml"), Path::new(".mise/conf.d/01-base.toml"), ] ); assert!(is_environment_conf_d_file(&confd.join("03-dev.dev.toml"))); + assert_eq!( + conf_d_file_environment(&confd.join("03-dev.dev.toml")), + Some(("dev", false)) + ); assert!(is_environment_conf_d_file( &confd.join("04-dev-local.dev.local.toml") )); + assert_eq!( + conf_d_file_environment(&confd.join("module.qa.prod.local.toml")), + Some(("qa.prod", true)) + ); assert!(!is_environment_conf_d_file( &confd.join("02-local.local.toml") )); @@ -5777,6 +5796,24 @@ mod tests { ); } + #[test] + fn test_env_config_patterns_escape_glob_characters() -> Result<()> { + let tmp = TempDir::new()?; + let confd = tmp.path().join(".mise/conf.d"); + fs::create_dir_all(&confd)?; + fs::write(confd.join("tools.qa*.toml"), "[env]\n")?; + fs::write(confd.join("tools.qa1.toml"), "[env]\n")?; + + let pattern = env_config_patterns("qa*") + .into_iter() + .find(|pattern| pattern.starts_with(".mise/conf.d/") && !pattern.contains(".local.")) + .unwrap(); + let matches = glob(tmp.path(), &pattern)?; + assert_eq!(matches, vec![confd.join("tools.qa*.toml")]); + + Ok(()) + } + #[test] fn test_should_warn_auto_env() { let v = |s: &str| versions::Versioning::new(s).unwrap(); From 9b449693f8462c192bd4330becf743cec14298e9 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:19:19 +0000 Subject: [PATCH 3/4] fix(config): satisfy clippy for conf.d discovery --- src/config/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 3fe6bbb8392..09a0c074ab2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2267,8 +2267,8 @@ fn detect_auto_env_candidate_files() -> Vec { } for dir in [*dirs::CONFIG, *dirs::SYSTEM_CONFIG] { for env_name in &candidate_envs { - found.extend(conf_d_environment_files(&dir, env_name, false)); - found.extend(conf_d_environment_files(&dir, env_name, true)); + found.extend(conf_d_environment_files(dir, env_name, false)); + found.extend(conf_d_environment_files(dir, env_name, true)); for filename in [ format!("config.{env_name}.toml"), format!("mise.{env_name}.toml"), From a2c39117c6e527a719c1d15510f9901e602ba439 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:27:51 +0000 Subject: [PATCH 4/4] fix(config): recognize escaped environment globs --- src/config/mod.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 09a0c074ab2..5fb0314efff 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1421,7 +1421,7 @@ fn configs_at_root<'a>(dir: &Path, config_files: &'a ConfigMap) -> Vec<&'a Arc Vec

bool { fn has_config_file_with_filenames(dir: &Path, filenames: &[String]) -> bool { filenames.iter().any(|f| { - if f.contains('*') { + if is_glob_pattern(f) { !load_config_glob(dir, f).is_empty() } else { dir.join(f).exists() @@ -5814,6 +5814,21 @@ mod tests { Ok(()) } + #[test] + fn test_env_config_patterns_with_non_star_glob_characters() -> Result<()> { + for env_name in ["qa?", "qa[1]", "qa]"] { + let tmp = TempDir::new()?; + let path = tmp.path().join(format!("mise.{env_name}.toml")); + fs::write(&path, "[env]\n")?; + + let patterns = env_config_patterns(env_name); + assert!(config_paths_in_dir_with_filenames(tmp.path(), &patterns).contains(&path)); + assert!(has_config_file_with_filenames(tmp.path(), &patterns)); + } + + Ok(()) + } + #[test] fn test_should_warn_auto_env() { let v = |s: &str| versions::Versioning::new(s).unwrap();