use dashmap::DashMap;
use eyre::{Context, Result, bail, eyre};
use indexmap::{IndexMap, IndexSet};
use itertools::{Either, Itertools};
use path_absolutize::Absolutize;
pub(crate) use settings::{CompilePurpose, Settings};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::env::join_paths;
use std::fmt::{Debug, Formatter};
use std::iter::once;
use std::path::{Component, Path, PathBuf};
use std::sync::LazyLock as Lazy;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, SystemTime};
use tokio::{sync::OnceCell, task::JoinSet};
use walkdir::WalkDir;
use crate::backend::ABackend;
use crate::cli::args::{BackendArg, split_bracketed_opts};
use crate::cli::version;
use crate::config::config_file::idiomatic_version::IdiomaticVersionFile;
use crate::config::config_file::min_version::MinVersionSpec;
use crate::config::config_file::mise_toml::{MiseToml, Tasks};
use crate::config::config_file::{
ConfigFile, TaskConfig, config_trust_root, is_path_trusted, trust_check,
};
use crate::config::env_directive::{EnvResolveOptions, EnvResults, ToolsFilter};
use crate::config::tracking::Tracker;
use crate::env::{MISE_DEFAULT_CONFIG_FILENAME, MISE_DEFAULT_TOOL_VERSIONS_FILENAME};
use crate::file::display_path;
use crate::remote_source::RemoteSource;
use crate::shorthands::{Shorthands, get_shorthands};
use crate::task::task_file_providers::{
TaskFileArtifact, TaskFileProvidersBuilder, validate_remote_git_path,
};
use crate::task::task_sources::TaskOutputs;
use crate::task::{
RunEntry, Task, TaskCacheConfig, TaskRustCacheConfig, TaskTemplate, monorepo_scope,
strip_extension,
};
use crate::tera::{contains_template_syntax, get_empty_tera, render_str, take_tera_accessed_files};
use crate::toolset::env_cache::{CachedNonToolEnv, compute_settings_hash, get_file_mtime};
use crate::toolset::{
ResolvedToolOptions, ToolOptions, ToolRequestSet, ToolRequestSetBuilder, ToolSource,
ToolVersion, ToolVersionOptions, Toolset, install_state,
};
use crate::ui::style;
use crate::{backend, dirs, env, file, lockfile, registry, runtime_symlinks, shims, timeout};
pub(crate) mod command_wrapper;
pub(crate) mod config_file;
pub(crate) mod env_directive;
pub(crate) mod miserc;
pub(crate) mod provenance;
pub(crate) mod settings;
pub(crate) mod tracking;
use crate::env_diff::EnvMap;
use crate::hook_env::WatchFilePattern;
use crate::hooks::Hook;
use crate::plugins::PluginType;
use crate::redactions::Redactor;
use crate::tera::BASE_CONTEXT;
use crate::watch_files::WatchFile;
use crate::wildcard::Wildcard;
pub(crate) use command_wrapper::CommandWrapper;
type AliasMap = IndexMap<String, Alias>;
pub(crate) type ConfigMap = IndexMap<PathBuf, Arc<dyn ConfigFile>>;
pub(crate) type EnvWithSources = IndexMap<String, (String, PathBuf)>;
type RemoteTaskIncludeKey = (String, Option<String>);
type RemoteTaskIncludeArtifacts = DashMap<RemoteTaskIncludeKey, Arc<OnceCell<TaskFileArtifact>>>;
static REMOTE_TASK_INCLUDE_ARTIFACTS: Lazy<RemoteTaskIncludeArtifacts> = Lazy::new(DashMap::new);
pub(crate) fn take_remote_task_include_artifacts() -> Vec<Arc<OnceCell<TaskFileArtifact>>> {
let keys = REMOTE_TASK_INCLUDE_ARTIFACTS
.iter()
.map(|entry| entry.key().clone())
.collect_vec();
keys.into_iter()
.filter_map(|key| {
REMOTE_TASK_INCLUDE_ARTIFACTS
.remove(&key)
.map(|(_, artifact)| artifact)
})
.collect()
}
#[cfg(test)]
mod remote_task_include_tests {
use super::*;
#[test]
fn take_remote_task_include_artifacts_drains_cache() {
drop(take_remote_task_include_artifacts());
REMOTE_TASK_INCLUDE_ARTIFACTS.insert(
("https://example.test/repo.git".into(), None),
Arc::new(OnceCell::new()),
);
let artifacts = take_remote_task_include_artifacts();
assert!(REMOTE_TASK_INCLUDE_ARTIFACTS.is_empty());
assert_eq!(artifacts.len(), 1);
}
}
pub(crate) struct MonorepoUnion {
pub config_files: ConfigMap,
pub tool_request_set: ToolRequestSet,
pub repo_urls: HashMap<String, String>,
}
fn extend_monorepo_tool_request_set(union: &mut ToolRequestSet, requests: &ToolRequestSet) {
union
.unknown_tools
.extend(requests.unknown_tools.iter().cloned());
for (_ba, tool_requests, source) in requests.iter() {
for request in tool_requests {
let already_present = union.tools.get(request.ba()).is_some_and(|existing| {
existing.iter().any(|existing| {
existing.version() == request.version()
&& existing.options() == request.options()
})
});
if !already_present {
union.add_version(request.clone(), source);
}
}
}
}
#[derive(Clone)]
struct BootstrapConfigMap {
config_files: ConfigMap,
tera_ctx: tera::Context,
}
pub(crate) struct Config {
pub config_files: ConfigMap,
bootstrap_config_maps: Vec<BootstrapConfigMap>,
pub project_root: Option<PathBuf>,
pub all_aliases: AliasMap,
pub repo_urls: HashMap<String, String>,
pub vars: IndexMap<String, String>,
pub tera_ctx: tera::Context,
pub shorthands: Shorthands,
pub shell_aliases: EnvWithSources,
pub tera_files: Vec<PathBuf>,
aliases: AliasMap,
env: OnceCell<EnvResults>,
env_with_sources: OnceCell<EnvWithSources>,
hooks: OnceCell<Vec<(PathBuf, Option<PathBuf>, Hook)>>,
tasks_cache: Arc<DashMap<crate::task::TaskLoadContext, Arc<BTreeMap<String, Task>>>>,
workspace_project_graph_cache:
Mutex<Option<Arc<crate::task::workspace::WorkspaceProjectGraph>>>,
tool_request_set: OnceCell<ToolRequestSet>,
toolset: OnceCell<Toolset>,
vars_results: OnceCell<EnvResults>,
lockfile_discovery: std::sync::OnceLock<Arc<crate::lockfile::LockfileDiscovery>>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Alias {
pub backend: Option<String>,
pub versions: IndexMap<String, String>,
}
static _CONFIG: RwLock<Option<Arc<Config>>> = RwLock::new(None);
static _REDACTOR: Lazy<Mutex<Redactor>> = Lazy::new(Default::default);
const MONOREPO_LOCKFILE_WARN_AT: &str = "2026.12.0";
const MONOREPO_LOCKFILE_DEFAULT_AT: &str = "2027.6.0";
pub(crate) fn is_loaded() -> bool {
_CONFIG.read().unwrap().is_some()
}
impl Config {
pub(crate) fn tool_config_locked(&self, source: &ToolSource) -> bool {
let ToolSource::MiseToml(path) = source else {
return false;
};
let Some(root) = self.config_files.get(path).map(|cf| cf.config_root()) else {
return false;
};
let is_global = is_global_config(path);
self.config_files.values().any(|cf| {
is_global_config(cf.get_path()) == is_global
&& cf.config_root() == root
&& cf.tool_config().locked
})
}
pub(crate) async fn get() -> Result<Arc<Self>> {
if let Some(config) = &*_CONFIG.read().unwrap() {
return Ok(config.clone());
}
measure!("load config", { Self::load().await })
}
pub(crate) fn maybe_get() -> Option<Arc<Self>> {
_CONFIG.read().unwrap().as_ref().cloned()
}
pub(crate) fn get_() -> Arc<Self> {
(*_CONFIG.read().unwrap()).clone().unwrap()
}
pub(crate) async fn reset() -> Result<Arc<Self>> {
backend::reset().await?;
timeout::run_with_timeout_async(
async || {
_CONFIG.write().unwrap().take();
*GLOBAL_CONFIG_FILES.lock().unwrap() = None;
*SYSTEM_CONFIG_FILES.lock().unwrap() = None;
GLOB_RESULTS.lock().unwrap().clear();
crate::lockfile::invalidate_caches();
crate::task::reset();
Ok(())
},
Duration::from_secs(5),
)
.await?;
Settings::reload();
Config::load().await
}
pub(crate) fn with_config_files(&self, config_files: ConfigMap) -> Arc<Self> {
let project_root = get_project_root(&config_files).or_else(|| self.project_root.clone());
let repo_urls = load_plugins(&config_files).unwrap_or_else(|_| self.repo_urls.clone());
Arc::new(Self {
tera_ctx: self.tera_ctx.clone(),
config_files,
bootstrap_config_maps: self.bootstrap_config_maps.clone(),
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: self.shorthands.clone(),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases: self.all_aliases.clone(),
aliases: self.aliases.clone(),
project_root,
repo_urls,
shell_aliases: self.shell_aliases.clone(),
tera_files: self.tera_files.clone(),
vars: self.vars.clone(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
})
}
pub(crate) fn lockfile_discovery(
&self,
derive: impl FnOnce() -> crate::lockfile::LockfileDiscovery,
) -> Arc<crate::lockfile::LockfileDiscovery> {
self.lockfile_discovery
.get_or_init(|| Arc::new(derive()))
.clone()
}
pub(crate) fn with_tool_request_set(&self, tool_request_set: ToolRequestSet) -> Arc<Self> {
let config = self.with_config_files(self.config_files.clone());
config.tool_request_set.set(tool_request_set).unwrap();
config
}
#[async_backtrace::framed]
pub async fn load() -> Result<Arc<Self>> {
backend::load_tools().await?;
let idiomatic_files = measure!("config::load idiomatic_files", {
load_idiomatic_filenames().await
});
let config_filenames = idiomatic_files
.keys()
.chain(DEFAULT_CONFIG_FILENAMES.iter())
.cloned()
.collect_vec();
let config_paths = measure!("config::load config_paths", {
load_config_paths(&config_filenames, false)
});
trace!("config_paths: {config_paths:?}");
let config_files = measure!("config::load config_files", {
load_all_config_files(&config_paths, &idiomatic_files).await?
});
let config = Self::load_from_config_files(config_files, false).await?;
*_CONFIG.write().unwrap() = Some(config.clone());
Ok(config)
}
async fn load_from_config_files(
config_files: ConfigMap,
global_only: bool,
) -> Result<Arc<Self>> {
let mut config = Self {
tera_ctx: BASE_CONTEXT.clone(),
config_files,
bootstrap_config_maps: vec![],
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: get_shorthands(&Settings::get()),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases: Default::default(),
aliases: Default::default(),
project_root: Default::default(),
repo_urls: Default::default(),
shell_aliases: Default::default(),
tera_files: Default::default(),
vars: Default::default(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
};
let vars_config = Arc::new(Self {
tera_ctx: config.tera_ctx.clone(),
config_files: config.config_files.clone(),
bootstrap_config_maps: vec![],
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: config.shorthands.clone(),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases: config.all_aliases.clone(),
aliases: config.aliases.clone(),
project_root: config.project_root.clone(),
repo_urls: config.repo_urls.clone(),
shell_aliases: config.shell_aliases.clone(),
tera_files: config.tera_files.clone(),
vars: config.vars.clone(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
});
let vars_results = measure!("config::load vars_results", {
let results = load_vars(&vars_config).await?;
config.vars_results.set(results.clone()).ok();
results
});
let vars: IndexMap<String, String> = vars_results
.vars
.iter()
.map(|(k, (v, _))| (k.clone(), v.clone()))
.collect();
config.tera_ctx.insert("vars", &vars);
config.vars = vars;
config.aliases = load_aliases(&config.config_files)?;
let _ = take_tera_accessed_files();
config.shell_aliases = load_shell_aliases(&config.config_files)?;
config.tera_files = take_tera_accessed_files();
config.project_root = get_project_root(&config.config_files);
config.repo_urls = load_plugins(&config.config_files)?;
measure!("config::load validate", {
config.validate()?;
});
config.bootstrap_config_maps = load_bootstrap_config_maps(&config).await?;
config.all_aliases = measure!("config::load all_aliases", { config.load_all_aliases() });
measure!("config::load redactions", {
config.add_redactions_excluding(
config
.redaction_keys()
.into_iter()
.chain(vars_results.redactions.iter().cloned()),
&config.vars.clone().into_iter().collect(),
&vars_results.redaction_exclusions,
);
});
if log::log_enabled!(log::Level::Trace) {
trace!("config: {config:#?}");
} else if log::log_enabled!(log::Level::Debug) {
for p in config.config_files.keys() {
debug!("config: {}", display_path(p));
}
}
if !global_only {
warn_if_auto_env_files_exist();
warn_if_monorepo_lockfile_default_changes(&config);
}
time!("load done");
measure!("config::load install_state", {
for plugin in config.repo_urls.keys() {
let (plugin_type, plugin) = PluginType::from_plugin_config(plugin);
install_state::add_plugin(plugin, plugin_type).await?;
}
});
measure!("config::load remove_aliased_tools", {
for short in config
.all_aliases
.iter()
.filter(|(_, a)| a.backend.is_some())
.map(|(s, _)| s)
.chain(config.repo_urls.keys())
{
backend::remove(short);
}
});
let config = Arc::new(config);
config.env_results().await?;
Ok(config)
}
pub(crate) fn env_maybe(&self) -> Option<IndexMap<String, String>> {
self.env_with_sources.get().map(|env| {
env.iter()
.map(|(k, (v, _))| (k.clone(), v.clone()))
.collect()
})
}
pub(crate) fn bootstrap_config_maps(&self) -> impl Iterator<Item = &ConfigMap> {
if self.bootstrap_config_maps.is_empty() {
Either::Left(std::iter::once(&self.config_files))
} else {
Either::Right(
self.bootstrap_config_maps
.iter()
.map(|config| &config.config_files),
)
}
}
pub(crate) fn bootstrap_tera_ctx(&self, config_path: &Path) -> &tera::Context {
self.bootstrap_config_maps
.iter()
.find(|config| config.config_files.contains_key(config_path))
.map(|config| &config.tera_ctx)
.unwrap_or(&self.tera_ctx)
}
pub(crate) async fn env(self: &Arc<Self>) -> eyre::Result<IndexMap<String, String>> {
Ok(self
.env_with_sources()
.await?
.iter()
.map(|(k, (v, _))| (k.clone(), v.clone()))
.collect())
}
pub(crate) async fn env_with_sources(self: &Arc<Self>) -> eyre::Result<&EnvWithSources> {
self.env_with_sources
.get_or_try_init(async || Ok(self.env_results().await?.env.clone()))
.await
}
pub(crate) async fn env_results(self: &Arc<Self>) -> Result<&EnvResults> {
self.env
.get_or_try_init(|| async { self.load_env().await })
.await
}
pub(crate) fn env_results_cached(&self) -> Option<&EnvResults> {
self.env.get()
}
pub(crate) fn vars_results_cached(&self) -> Option<&EnvResults> {
self.vars_results.get()
}
pub(crate) async fn path_dirs(self: &Arc<Self>) -> eyre::Result<&Vec<PathBuf>> {
Ok(&self.env_results().await?.env_paths)
}
pub(crate) async fn get_tool_request_set(self: &Arc<Self>) -> eyre::Result<&ToolRequestSet> {
self.tool_request_set
.get_or_try_init(async || ToolRequestSetBuilder::new().build(self).await)
.await
}
pub(crate) async fn get_toolset(self: &Arc<Self>) -> Result<&Toolset> {
self.toolset
.get_or_try_init(|| async {
let mut ts = Toolset::from(self.get_tool_request_set().await?.clone());
ts.resolve(self).await?;
Ok(ts)
})
.await
}
fn has_tool_alias(&self, short: &str) -> bool {
self.all_aliases
.get(short)
.is_some_and(|alias| alias.backend.is_some())
|| self.repo_urls.contains_key(short)
}
pub(crate) async fn get_tool_opts_with_overrides(
self: &Arc<Self>,
backend_arg: &Arc<BackendArg>,
) -> Result<ToolOptions> {
Ok(self
.resolve_tool_opts_with_overrides(backend_arg)
.await?
.into_effective())
}
pub(crate) async fn resolve_tool_opts_with_overrides(
self: &Arc<Self>,
backend_arg: &Arc<BackendArg>,
) -> Result<ResolvedToolOptions> {
let trs = self.get_tool_request_set().await?;
let short_match = trs.iter().find(|tr| tr.0.short == backend_arg.short);
let tool_request = short_match.or_else(|| {
if !self.has_tool_alias(&backend_arg.short) {
return None;
}
let resolved_ba = BackendArg::new(backend_arg.full(), None);
trs.iter().find(|tr| tr.0.short == resolved_ba.short)
});
let config_opts = tool_request.and_then(|tr| tr.1.first().map(|req| req.options()));
let alias_opts = self.get_backend_alias_opts(backend_arg);
Ok(backend_arg.resolve_opts_with_layers(alias_opts, config_opts, None))
}
fn get_backend_alias_opts(&self, backend_arg: &BackendArg) -> Option<ToolVersionOptions> {
if backend_arg.has_env_backend_override() {
return None;
}
let short = backend::unalias_backend(&backend_arg.short);
self.all_aliases
.get(short)
.and_then(|alias| alias.backend.as_deref())
.and_then(|backend| split_bracketed_opts(backend).map(|(_, opts)| opts))
.map(crate::toolset::parse_tool_options)
}
pub(crate) fn get_configured_plugin_type(&self, plugin_name: &str) -> Option<PluginType> {
self.repo_urls.keys().find_map(|key| {
let (plugin_type, name) = PluginType::from_plugin_config(key);
(key != name && name == plugin_name).then_some(plugin_type)
})
}
pub(crate) fn get_repo_url(&self, plugin_name: &str) -> Option<String> {
if let Some(url) = self.repo_urls.get(plugin_name)
&& (Path::new(url).is_absolute() || url.starts_with("file://"))
{
return Some(url.clone());
}
let plugin_name = self
.all_aliases
.get(plugin_name)
.and_then(|a| a.backend.clone())
.or_else(|| self.repo_urls.get(plugin_name).cloned())
.unwrap_or(plugin_name.to_string());
let plugin_name = plugin_name.strip_prefix("asdf:").unwrap_or(&plugin_name);
let plugin_name = plugin_name.strip_prefix("vfox:").unwrap_or(plugin_name);
if let Some(url) = self
.repo_urls
.keys()
.find(|k| k.ends_with(&format!(":{plugin_name}")))
.and_then(|k| self.repo_urls.get(k))
{
return Some(
if Path::new(url).is_absolute() || url.starts_with("file://") {
url.clone()
} else {
registry::full_to_url(url)
},
);
}
self.shorthands
.get(plugin_name)
.map(|full| registry::full_to_url(&full[0]))
.or_else(|| {
if registry::url_like(plugin_name) || plugin_name.split('/').count() == 2 {
Some(registry::full_to_url(plugin_name))
} else {
None
}
})
}
pub(crate) fn is_monorepo(&self) -> bool {
find_monorepo_root(&self.config_files).is_some()
}
pub(crate) fn monorepo_root(&self) -> Option<PathBuf> {
find_monorepo_root(&self.config_files)
}
pub(crate) fn monorepo_lockfile_discovery_key(
&self,
) -> Option<(PathBuf, Option<bool>, Vec<String>)> {
let cf = find_monorepo_config(&self.config_files)?;
let monorepo = cf.monorepo();
Some((
cf.get_path().to_path_buf(),
monorepo.and_then(|config| config.lockfile),
monorepo
.map(|config| config.config_roots.clone())
.unwrap_or_default(),
))
}
pub(crate) fn workspace_project_graph(
&self,
) -> Result<Arc<crate::task::workspace::WorkspaceProjectGraph>> {
let graph = self.workspace_project_graph_for_task_loading()?;
if let Some(error) = graph.provider_discovery_error() {
bail!("failed to discover workspace providers: {error}");
}
Ok(graph)
}
pub(crate) async fn monorepo_global_task_inputs(self: &Arc<Self>) -> Result<Vec<String>> {
let monorepo_config = find_monorepo_config(&self.config_files)
.ok_or_else(|| eyre!("no config file in scope sets monorepo_root = true"))?;
let monorepo_root = monorepo_config
.project_root()
.ok_or_else(|| eyre!("monorepo root config has no project root"))?
.to_path_buf();
let configs = self
.config_files
.values()
.filter(|cf| cf.config_root() == monorepo_root)
.collect::<Vec<_>>();
let task_inputs = ResolvedTaskInputs::from_configs(&configs);
let mut task = Task {
name: "affected".to_string(),
cf: Some(monorepo_config.clone()),
config_root: Some(monorepo_root),
..Default::default()
};
apply_task_config_inputs(&mut task, self, &task_inputs).await?;
Ok(task.sources)
}
fn workspace_project_graph_for_task_loading(
&self,
) -> Result<Arc<crate::task::workspace::WorkspaceProjectGraph>> {
if let Some(graph) = self.workspace_project_graph_cache.lock().unwrap().clone() {
return Ok(graph);
}
let graph = Arc::new(self.discover_workspace_project_graph()?);
*self.workspace_project_graph_cache.lock().unwrap() = Some(graph.clone());
Ok(graph)
}
fn discover_workspace_project_graph(
&self,
) -> Result<crate::task::workspace::WorkspaceProjectGraph> {
let monorepo_config = find_monorepo_config(&self.config_files)
.ok_or_else(|| eyre!("no config file in scope sets monorepo_root = true"))?;
let monorepo_root = monorepo_config
.project_root()
.ok_or_else(|| eyre!("monorepo root config has no project root"))?;
let overrides = monorepo_config
.monorepo()
.map(|config| &config.projects)
.cloned()
.unwrap_or_default();
let cargo = crate::task::workspace::cargo::CargoWorkspaceProvider;
let go = crate::task::workspace::go::GoWorkspaceProvider;
let node = crate::task::workspace::node::NodeWorkspaceProvider;
let uv = crate::task::workspace::uv::UvWorkspaceProvider;
crate::task::workspace::WorkspaceProjectGraph::discover_all_with_overrides_lenient(
&[&cargo, &go, &node, &uv],
&monorepo_root,
&overrides,
)
}
pub(crate) fn monorepo_lockfile_root(&self) -> Option<PathBuf> {
let cf = find_monorepo_config(&self.config_files)?;
let setting = cf.monorepo().and_then(|m| m.lockfile);
if !monorepo_lockfile_enabled_for_version(&version::V, setting) {
return None;
}
let monorepo_root = cf.project_root().map(|p| p.to_path_buf())?;
if setting == Some(true) {
return Some(monorepo_root);
}
match self.monorepo_config_root_dirs(None) {
Ok(config_roots) if !config_roots.is_empty() => Some(monorepo_root),
Ok(_) | Err(_) => None,
}
}
pub(crate) fn lockfile_creation_enabled(&self) -> bool {
Settings::get().lockfile_creation_enabled()
&& self.config_files.values().any(|cf| {
cf.settings()
.is_some_and(|settings| settings.lockfile == Some(true))
})
}
pub(crate) fn monorepo_config_root_dirs_for_lockfiles(&self) -> Result<Vec<PathBuf>> {
self.monorepo_config_root_dirs(None)
}
fn monorepo_config_root_dirs_with_filenames(
&self,
filenames: &[String],
) -> Result<Vec<PathBuf>> {
self.monorepo_config_root_dirs(Some(filenames))
}
pub(crate) fn monorepo_config_root_dirs(
&self,
filenames: Option<&[String]>,
) -> Result<Vec<PathBuf>> {
let monorepo_config = find_monorepo_config(&self.config_files)
.ok_or_else(|| eyre!("no config file in scope sets monorepo_root = true"))?;
let monorepo_root = monorepo_config
.project_root()
.ok_or_else(|| eyre!("monorepo root config has no project root"))?;
let patterns = &monorepo_config
.monorepo()
.ok_or_else(|| eyre!("[monorepo].config_roots is required for monorepo operations"))?
.config_roots;
if patterns.is_empty() {
bail!("[monorepo].config_roots is required for monorepo operations");
}
let roots = match filenames {
Some(filenames) => {
expand_config_roots_with_filenames(&monorepo_root, patterns, None, filenames)?
}
None => expand_config_root_dirs(&monorepo_root, patterns, None)?,
};
if roots.is_empty() {
bail!("[monorepo].config_roots did not match any config roots");
}
Ok(roots)
}
pub(crate) async fn monorepo_union_tool_request_set(
self: &Arc<Self>,
) -> Result<ToolRequestSet> {
Ok(self.monorepo_union().await?.tool_request_set)
}
pub(crate) async fn monorepo_lockfile_union(self: &Arc<Self>) -> Result<MonorepoUnion> {
self.monorepo_union_with_root_toolset(true).await
}
pub(crate) async fn monorepo_union(self: &Arc<Self>) -> Result<MonorepoUnion> {
self.monorepo_union_with_root_toolset(false).await
}
async fn monorepo_union_with_root_toolset(
self: &Arc<Self>,
include_root_toolset: bool,
) -> Result<MonorepoUnion> {
let idiomatic_filenames = load_idiomatic_filenames().await;
let config_filenames = idiomatic_filenames
.keys()
.chain(DEFAULT_CONFIG_FILENAMES.iter())
.cloned()
.collect_vec();
let roots = self.monorepo_config_root_dirs_with_filenames(&config_filenames)?;
let mut config_files = self.config_files.clone();
let mut base_config_files = self.config_files.clone();
base_config_files.retain(|path, _| {
is_global_config(path) || !roots.iter().any(|root| path.starts_with(root))
});
let mut union = ToolRequestSet::new();
for root in roots {
let root_idiomatic_filenames =
idiomatic_filenames_for_root(&root, &idiomatic_filenames).await?;
let root_config_filenames = root_idiomatic_filenames
.keys()
.chain(DEFAULT_CONFIG_FILENAMES.iter())
.cloned()
.collect_vec();
let root_paths = config_paths_in_dir_with_filenames(&root, &root_config_filenames);
let mut root_config_files =
load_config_files_from_paths(&root_paths, &root_idiomatic_filenames).await?;
for (path, cf) in root_config_files.clone() {
config_files.entry(path).or_insert(cf);
}
for (path, cf) in base_config_files.clone() {
root_config_files.entry(path).or_insert(cf);
}
let root_trs = ToolRequestSetBuilder::new()
.with_config_files(root_config_files)
.without_runtime_args()
.build(self)
.await?;
extend_monorepo_tool_request_set(&mut union, &root_trs);
}
if include_root_toolset {
let root_trs = ToolRequestSetBuilder::new()
.with_config_files(base_config_files)
.without_runtime_args()
.build(self)
.await?;
extend_monorepo_tool_request_set(&mut union, &root_trs);
}
union.unknown_tools = union.unknown_tools.into_iter().unique().collect();
let repo_urls = load_plugins(&config_files)?;
Ok(MonorepoUnion {
config_files,
tool_request_set: union,
repo_urls,
})
}
pub(crate) async fn tasks(&self) -> Result<Arc<BTreeMap<String, Task>>> {
self.tasks_with_context(None).await
}
pub(crate) async fn tasks_with_context(
&self,
ctx: Option<&crate::task::TaskLoadContext>,
) -> Result<Arc<BTreeMap<String, Task>>> {
let cache_key = ctx.cloned().unwrap_or_default();
if let Some(cached) = self.tasks_cache.get(&cache_key) {
return Ok(cached.value().clone());
}
let tasks = measure!("config::load_all_tasks_with_context", {
self.load_all_tasks_with_context(ctx).await?
});
let tasks_arc = Arc::new(tasks);
self.tasks_cache.insert(cache_key, tasks_arc.clone());
Ok(tasks_arc)
}
pub(crate) async fn reload_tasks_with_context(
&self,
ctx: Option<&crate::task::TaskLoadContext>,
) -> Result<Arc<BTreeMap<String, Task>>> {
let cache_key = ctx.cloned().unwrap_or_default();
self.tasks_cache.remove(&cache_key);
self.tasks_with_context(ctx).await
}
pub(crate) async fn tasks_with_aliases(&self) -> Result<BTreeMap<String, Task>> {
let tasks = self.tasks().await?;
Ok(tasks
.values()
.flat_map(|t| {
t.aliases
.iter()
.map(|a| (a.to_string(), t.clone()))
.chain(once((t.name.clone(), t.clone())))
.collect::<Vec<_>>()
})
.collect())
}
pub(crate) async fn resolve_alias(&self, backend: &ABackend, v: &str) -> Result<String> {
if let Some(plugin_aliases) = self.all_aliases.get(&backend.ba().short)
&& let Some(alias) = plugin_aliases.versions.get(v)
{
return Ok(alias.clone());
}
if let Some(alias) = backend.get_aliases()?.get(v) {
return Ok(alias.clone());
}
Ok(v.to_string())
}
fn load_all_aliases(&self) -> AliasMap {
let mut aliases: AliasMap = self.aliases.clone();
let plugin_aliases: Vec<_> = backend::alias_backends()
.into_iter()
.map(|backend| {
let aliases = backend.get_aliases().unwrap_or_else(|err| {
warn!("get_aliases: {err}");
BTreeMap::new()
});
(backend.ba().clone(), aliases)
})
.collect();
for (ba, plugin_aliases) in plugin_aliases {
for (from, to) in plugin_aliases {
aliases
.entry(ba.short.to_string())
.or_default()
.versions
.insert(from, to);
}
}
for (short, plugin_aliases) in &self.aliases {
let alias = aliases.entry(short.clone()).or_default();
if let Some(full) = &plugin_aliases.backend {
alias.backend = Some(full.clone());
}
for (from, to) in &plugin_aliases.versions {
alias.versions.insert(from.clone(), to.clone());
}
}
aliases
}
async fn load_all_tasks_with_context(
&self,
ctx: Option<&crate::task::TaskLoadContext>,
) -> Result<BTreeMap<String, Task>> {
let config = Config::get().await?;
time!("load_all_tasks");
let workspace_graph = (Settings::get().experimental && config.monorepo_root().is_some())
.then(|| config.workspace_project_graph_for_task_loading());
let task_definitions = collect_task_definitions(
&config.config_files,
workspace_graph
.as_ref()
.and_then(|graph| graph.as_ref().ok())
.map(Arc::as_ref),
);
let mut local_tasks =
load_local_tasks_with_context(&config, ctx, &task_definitions).await?;
let global_tasks = load_global_tasks(&config, &task_definitions).await?;
local_tasks.retain(|local| {
!global_tasks
.iter()
.any(|global| tasks_have_same_source(local, global))
});
let mut tasks: BTreeMap<String, Task> = local_tasks
.into_iter()
.chain(global_tasks)
.rev()
.inspect(|t| {
trace!(
"loaded task {} – {}",
&t.name,
display_path(&t.config_source)
)
})
.map(|t| (t.name.clone(), t))
.collect();
let settings = Settings::get();
if settings.experimental && !settings.task.auto_infer.is_empty() {
let inferred_tasks = match workspace_graph.as_ref() {
Some(Ok(graph)) => {
inferred_workspace_tasks(
&config,
&task_definitions,
graph,
&settings.task.auto_infer,
)
.await
}
Some(Err(err)) => {
warn!(
"failed to load workspace project graph; inferred tasks and root task \
defaults are unavailable: {err:#}"
);
Vec::new()
}
None => Vec::new(),
};
for task in inferred_tasks {
if tasks.contains_key(&task.name) {
let available_aliases = task
.aliases
.into_iter()
.filter(|alias| {
!tasks.contains_key(alias)
&& !tasks
.values()
.any(|explicit| explicit.aliases.contains(alias))
})
.collect::<Vec<_>>();
tasks
.get_mut(&task.name)
.expect("explicit task name was just found")
.aliases
.extend(available_aliases);
continue;
}
let explicit_name = task
.aliases
.iter()
.find(|alias| tasks.contains_key(*alias))
.cloned()
.or_else(|| {
task.aliases.iter().find_map(|alias| {
tasks.iter().find_map(|(name, explicit)| {
(explicit.file.is_some() && strip_task_extension(name) == alias)
.then(|| name.clone())
})
})
})
.or_else(|| {
task.aliases.iter().find_map(|alias| {
tasks.iter().find_map(|(name, explicit)| {
explicit.aliases.contains(alias).then(|| name.clone())
})
})
});
if let Some(explicit_name) = explicit_name {
let explicit = tasks
.get_mut(&explicit_name)
.expect("explicit task name was just found");
if !explicit.aliases.contains(&task.name) {
explicit.aliases.push(task.name);
}
continue;
}
if tasks
.values()
.any(|explicit| explicit.aliases.contains(&task.name))
{
continue;
}
tasks.insert(task.name.clone(), task);
}
}
if Settings::get().experimental
&& let Some(monorepo_root) = config.monorepo_root()
{
match workspace_graph.as_ref() {
Some(Ok(graph)) => {
let mut project_ids_by_root = BTreeMap::new();
for project in graph.projects() {
project_ids_by_root
.entry(file::desymlink_path(&monorepo_root.join(&project.root)))
.or_insert_with(BTreeSet::new)
.insert(project.id.clone());
}
for task in tasks.values_mut() {
task.resolve_workspace_task_dependencies(graph, &project_ids_by_root)?;
}
}
Some(Err(err)) => {
for task in tasks.values_mut() {
task.set_workspace_task_dependency_error(err);
}
}
_ => {}
}
}
let all_tasks = tasks.clone();
for task in tasks.values_mut() {
task.display_name = task.display_name(&all_tasks);
}
time!("load_all_tasks {count}", count = tasks.len(),);
Ok(tasks)
}
pub(crate) async fn get_tracked_config_files(&self) -> Result<ConfigMap> {
let mut config_files: ConfigMap = ConfigMap::default();
let mut idiomatic_settings_by_root =
BTreeMap::<PathBuf, settings::IdiomaticVersionFileSettings>::new();
let require_trust_before_detection = Settings::get().paranoid && !Settings::safe_mode();
for path in Tracker::list_all()?.into_iter() {
if config_path_is_ignored(&path, false) {
debug!("skipping ignored tracked config: {}", display_path(&path));
continue;
}
let trust_root = config_file::config_trust_root(&path);
if require_trust_before_detection
&& !is_global_config(&path)
&& !config_file::is_trusted(&trust_root)
{
debug!("skipping untrusted tracked config: {}", display_path(&path));
continue;
}
let detection = match config_file::detect_config_file_type_by_filename(&path) {
Some(config_type) => config_file::ConfigFileDetection::Recognized(config_type),
None => {
let config_root = config_file::config_root::config_root(&path);
let idiomatic_settings = match idiomatic_settings_by_root.get(&config_root) {
Some(settings) => settings.clone(),
None => {
let settings = settings::IdiomaticVersionFileSettings::resolve_from(
&config_root,
settings::SettingsLoadPolicy::TRUSTED_HIERARCHY,
)?;
idiomatic_settings_by_root.insert(config_root, settings.clone());
settings
}
};
config_file::detect_config_file_with_settings(&path, &idiomatic_settings).await
}
};
if config_file::detection_requires_trust(&path, &detection)
&& !is_global_config(&path)
&& !config_file::is_trusted(&trust_root)
{
debug!("skipping untrusted tracked config: {}", display_path(&path));
continue;
}
if matches!(
&detection,
config_file::ConfigFileDetection::DisabledIdiomatic
) {
debug!(
"skipping disabled idiomatic tracked config: {}",
display_path(&path)
);
continue;
}
match config_file::parse_detected(&path, detection).await {
Ok(cf) => {
config_files.insert(path, cf);
}
Err(err) => {
warn!(
"error loading tracked config file {}: {err:#}",
display_path(&path)
);
}
}
}
Ok(config_files)
}
pub(crate) fn global_config(&self) -> Result<MiseToml> {
let settings_path = global_config_path();
match settings_path.exists() {
false => {
trace!("settings does not exist {:?}", settings_path);
Ok(MiseToml::init(&settings_path))
}
true => MiseToml::from_file(&settings_path)
.wrap_err_with(|| eyre!("Error parsing {}", display_path(&settings_path))),
}
}
fn validate(&self) -> eyre::Result<()> {
self.validate_versions()?;
Ok(())
}
fn validate_versions(&self) -> eyre::Result<()> {
for cf in self.config_files.values() {
if let Some(spec) = cf.min_version() {
Self::enforce_min_version_spec(spec)?;
}
}
Ok(())
}
pub(crate) fn enforce_min_version_spec(spec: &MinVersionSpec) -> eyre::Result<()> {
let cur = &*version::V;
if let Some(required) = spec.hard_violation(cur) {
let min = style::eyellow(required);
let cur = style::eyellow(cur);
let msg = format!("mise version {min} is required, but you are using {cur}");
bail!(crate::cli::self_update::append_self_update_instructions(
msg
));
} else if let Some(recommended) = spec.soft_violation(cur) {
let min = style::eyellow(recommended);
let cur = style::eyellow(cur);
let msg = format!("mise version {min} is recommended, but you are using {cur}");
warn!(
"{}",
crate::cli::self_update::append_self_update_instructions(msg)
);
}
Ok(())
}
async fn load_env(self: &Arc<Self>) -> Result<EnvResults> {
if Settings::no_env() || Settings::get().no_env.unwrap_or(false) {
return Ok(EnvResults::default());
}
time!("load_env start");
let cache_enabled = CachedNonToolEnv::is_enabled();
let cache_key = if cache_enabled {
let config_files: Vec<(PathBuf, u64)> = self
.config_files
.keys()
.map(|p| (p.clone(), get_file_mtime(p).unwrap_or(0)))
.collect();
let settings_hash = compute_settings_hash();
let base_path = join_paths(env::PATH.iter())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
Some(CachedNonToolEnv::compute_cache_key(
&config_files,
&settings_hash,
&base_path,
))
} else {
None
};
if let Some(cache_key) = cache_key.as_ref()
&& let Some(cached) = CachedNonToolEnv::load(cache_key)?
{
let mut env_results = EnvResults {
env: cached.env.clone(),
vars: Default::default(),
env_remove: cached.env_remove.clone(),
env_files: cached.env_files.clone(),
env_paths: cached.env_paths.clone(),
env_scripts: cached.env_scripts.clone(),
redactions: cached.redactions.clone(),
redaction_exclusions: cached.redaction_exclusions.clone(),
caller_env_keys: cached.caller_env_keys.clone(),
tool_add_paths: Vec::new(),
watch_files: cached.watch_files.clone(),
has_uncacheable: false,
};
if !load_command_wrappers(&self.config_files)?.is_empty()
&& !env_results.env_paths.contains(&*dirs::COMMAND_WRAPPERS)
{
env_results
.env_paths
.insert(0, dirs::COMMAND_WRAPPERS.clone());
}
let redact_keys = self
.redaction_keys()
.into_iter()
.chain(env_results.redactions.clone())
.collect_vec();
self.add_redactions_excluding(
redact_keys,
&env_results.redactable_env(&env::PRISTINE_ENV),
&env_results.redaction_exclusions,
);
if log::log_enabled!(log::Level::Trace) {
trace!("{env_results:#?}");
} else if !env_results.is_empty() {
debug!("{env_results:?}");
}
trace!("env_cache: using cached non-tool env results");
return Ok(env_results);
}
let entries = self
.config_files
.iter()
.rev()
.map(|(source, cf)| {
cf.env_entries()
.map(|ee| ee.into_iter().map(|e| (e, source.clone())))
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
let mut env_results = EnvResults::resolve(
self,
self.tera_ctx.clone(),
&env::PRISTINE_ENV,
entries,
EnvResolveOptions {
vars: false,
tools: ToolsFilter::NonToolsOnly,
warn_on_missing_required: *env::WARN_ON_MISSING_REQUIRED_ENV,
},
)
.await?;
if !load_command_wrappers(&self.config_files)?.is_empty() {
env_results
.env_paths
.insert(0, dirs::COMMAND_WRAPPERS.clone());
}
for env_file in Settings::get().env_files() {
if env_results.env_files.contains(&env_file) {
continue;
}
debug!("env_file: {}", display_path(&env_file));
match dotenvy::from_path_iter(&env_file) {
Ok(iter) => {
env_results.env_files.push(env_file.clone());
for item in iter {
match item {
Ok((k, v)) => {
env_results.env.insert(k, (v, env_file.clone()));
}
Err(err) => warn!("env_file: {err}"),
}
}
}
Err(err) => trace!("env_file: {err}"),
}
}
let redact_keys = self
.redaction_keys()
.into_iter()
.chain(env_results.redactions.clone())
.collect_vec();
self.add_redactions_excluding(
redact_keys,
&env_results.redactable_env(&env::PRISTINE_ENV),
&env_results.redaction_exclusions,
);
if cache_enabled
&& !env_results.has_uncacheable
&& let Some(cache_key) = cache_key
{
let mut watch_files = env_results.watch_files.clone();
watch_files.extend(env_results.env_files.clone());
watch_files.extend(env_results.env_scripts.clone());
let watch_file_mtimes: Vec<u64> = watch_files
.iter()
.map(|p| get_file_mtime(p).unwrap_or(0))
.collect();
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cached = CachedNonToolEnv {
env: env_results.env.clone(),
env_remove: env_results.env_remove.clone(),
env_files: env_results.env_files.clone(),
env_paths: env_results.env_paths.clone(),
env_scripts: env_results.env_scripts.clone(),
redactions: env_results.redactions.clone(),
redaction_exclusions: env_results.redaction_exclusions.clone(),
caller_env_keys: env_results.caller_env_keys.clone(),
watch_files,
watch_file_mtimes,
created_at: now,
mise_version: env!("CARGO_PKG_VERSION").to_string(),
cache_key_debug: cache_key.clone(),
};
if let Err(e) = cached.save(&cache_key) {
debug!("env_cache: failed to save non-tool env cache: {}", e);
}
}
if log::log_enabled!(log::Level::Trace) {
trace!("{env_results:#?}");
} else if !env_results.is_empty() {
debug!("{env_results:?}");
}
Ok(env_results)
}
pub(crate) async fn hooks(&self) -> Result<&Vec<(PathBuf, Option<PathBuf>, Hook)>> {
self.hooks
.get_or_try_init(|| async {
self.config_files
.values()
.map(|cf| {
let project_root = cf.project_root();
let is_global = project_root.is_none();
let config_root = cf.config_root();
let mut hooks = cf.hooks()?;
if is_global {
for h in &mut hooks {
h.global = true;
}
}
Ok((config_root, project_root, hooks))
})
.map_ok(|(config_root, project_root, hooks)| {
hooks
.into_iter()
.map(|h| (config_root.clone(), project_root.clone(), h))
.collect::<Vec<_>>()
})
.flatten_ok()
.collect()
})
.await
}
pub(crate) fn watch_file_hooks(&self) -> Result<IndexSet<(PathBuf, WatchFile)>> {
Ok(self
.config_files
.values()
.map(|cf| Ok((cf.project_root(), cf.watch_files()?)))
.collect::<Result<Vec<_>>>()?
.into_iter()
.filter_map(|(root, watch_files)| root.map(|r| (r.to_path_buf(), watch_files)))
.flat_map(|(root, watch_files)| {
watch_files
.iter()
.map(|wf| (root.clone(), wf.clone()))
.collect::<Vec<_>>()
})
.collect())
}
pub(crate) async fn watch_files(self: &Arc<Self>) -> Result<BTreeSet<WatchFilePattern>> {
let env_results = self.env_results().await?;
Ok(self
.config_files
.iter()
.map(|(p, cf)| {
let mut watch_files: Vec<WatchFilePattern> = vec![p.as_path().into()];
if let Some(parent) = p.parent() {
let lockfile = parent.join("mise.lock");
if lockfile.exists() {
watch_files.push(lockfile.into());
}
}
watch_files.extend(cf.watch_files()?.iter().map(|wf| WatchFilePattern {
root: cf.project_root().map(|pr| pr.to_path_buf()),
patterns: wf.patterns.clone(),
}));
Ok(watch_files)
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.chain(env_results.env_files.iter().map(|p| p.as_path().into()))
.chain(env_results.env_scripts.iter().map(|p| p.as_path().into()))
.chain(env_results.watch_files.iter().map(|p| p.as_path().into()))
.chain(
Settings::get()
.env_files()
.iter()
.map(|p| p.as_path().into()),
)
.chain(self.tera_files.iter().map(|p| p.as_path().into()))
.collect())
}
pub(crate) fn redaction_keys(&self) -> Vec<String> {
self.config_files
.values()
.flat_map(|cf| cf.redactions().0.iter())
.cloned()
.collect()
}
pub(crate) fn add_redactions_excluding(
&self,
redactions: impl IntoIterator<Item = String>,
env: &EnvMap,
exclusions: &BTreeSet<String>,
) {
let mut r = _REDACTOR.lock().unwrap();
let new_redactions = redactions.into_iter().flat_map(|pattern| {
let matcher = Wildcard::new(vec![pattern]);
env.iter()
.filter(|(k, _)| !exclusions.contains(*k) && matcher.match_any(k))
.map(|(_, v)| v.clone())
.collect::<Vec<_>>()
});
*r = r.with_additional(new_redactions);
}
pub(crate) fn redactions(&self) -> Arc<IndexSet<String>> {
_REDACTOR.lock().unwrap().patterns_arc()
}
pub(crate) fn redact(&self, input: &str) -> String {
_REDACTOR.lock().unwrap().redact(input)
}
}
fn configs_at_root<'a>(dir: &Path, config_files: &'a ConfigMap) -> Vec<&'a Arc<dyn ConfigFile>> {
let mut configs: Vec<&'a Arc<dyn ConfigFile>> = DEFAULT_CONFIG_FILENAMES
.iter()
.rev()
.flat_map(|f| {
if is_glob_pattern(f) {
load_config_glob(dir, f)
.into_iter()
.rev()
.filter_map(|path| config_files.get(&path))
.collect::<Vec<_>>()
} else {
config_files
.get(&dir.join(f))
.into_iter()
.collect::<Vec<_>>()
}
})
.collect();
let mut seen = std::collections::HashSet::new();
configs.retain(|cf| seen.insert(cf.get_path().to_path_buf()));
configs
}
fn get_project_root(config_files: &ConfigMap) -> Option<PathBuf> {
let project_root = config_files
.values()
.find_map(|cf| cf.project_root())
.map(|pr| pr.to_path_buf());
trace!("project_root: {project_root:?}");
project_root
}
fn find_monorepo_root(config_files: &ConfigMap) -> Option<PathBuf> {
find_monorepo_config(config_files).and_then(|cf| cf.project_root().map(|p| p.to_path_buf()))
}
fn find_monorepo_config(config_files: &ConfigMap) -> Option<&Arc<dyn ConfigFile>> {
config_files
.values()
.find(|cf| cf.monorepo_root() == Some(true))
}
async fn load_bootstrap_config_maps(config: &Config) -> Result<Vec<BootstrapConfigMap>> {
let Some((declaring_config, patterns)) = config.config_files.iter().find_map(|(path, cf)| {
cf.bootstrap_config()
.and_then(|bootstrap| bootstrap.config_roots.map(|patterns| (path, patterns)))
}) else {
return Ok(vec![]);
};
if patterns.is_empty() {
return Ok(vec![]);
}
let declaring_root = config
.config_files
.get(declaring_config)
.expect("declaring bootstrap config is loaded")
.config_root();
let mut roots = expand_bootstrap_config_roots(&declaring_root, &patterns)?;
roots.sort();
roots.dedup();
if roots.is_empty() {
bail!("[bootstrap].config_roots did not match any config roots");
}
let mut base = config.config_files.clone();
base.retain(|path, _| {
is_global_config(path)
|| !path
.canonicalize()
.is_ok_and(|path| roots.iter().any(|root| path.starts_with(root)))
});
let mut maps = vec![BootstrapConfigMap {
config_files: base,
tera_ctx: config.tera_ctx.clone(),
}];
let idiomatic_filenames = BTreeMap::new();
for root in roots {
let paths = config_paths_in_dir_with_filenames(&root, &DEFAULT_CONFIG_FILENAMES);
let config_files = load_config_files_from_paths(&paths, &idiomatic_filenames).await?;
let vars_config = config.with_config_files(config_files.clone());
let vars_results = load_vars(&vars_config).await?;
let mut vars = config.vars.clone();
vars.extend(
vars_results
.vars
.iter()
.map(|(key, (value, _))| (key.clone(), value.clone())),
);
let mut tera_ctx = config.tera_ctx.clone();
tera_ctx.insert("vars", &vars);
tera_ctx.insert("config_root", &root);
maps.push(BootstrapConfigMap {
config_files,
tera_ctx,
});
}
Ok(maps)
}
async fn load_idiomatic_filenames() -> BTreeMap<String, Vec<String>> {
let settings = Settings::get();
let enable_tools = settings.idiomatic_version_file_enable_tools.clone();
let disable_files = settings.idiomatic_version_file_disable_files.clone();
if !settings.idiomatic_version_file_disable_tools.is_empty() {
deprecated!(
"idiomatic_version_file_disable_tools",
"is deprecated, use idiomatic_version_file_enable_tools instead"
);
}
load_idiomatic_filenames_for_tools(&enable_tools, &disable_files).await
}
async fn load_idiomatic_filenames_for_tools(
enable_tools: &BTreeSet<String>,
disable_files: &BTreeSet<String>,
) -> BTreeMap<String, Vec<String>> {
if enable_tools.is_empty() {
return BTreeMap::new();
}
let mut jset = JoinSet::new();
for tool in backend::list() {
let enable_tools = enable_tools.clone();
let disable_files = disable_files.clone();
jset.spawn(async move {
if !enable_tools.contains(tool.id()) {
return vec![];
}
match tool.idiomatic_filenames().await {
Ok(filenames) => filenames
.iter()
.filter(|filename| {
!idiomatic_version_file_disabled(&disable_files, tool.id(), filename)
})
.map(|f| (f.to_string(), tool.id().to_string()))
.collect::<Vec<_>>(),
Err(err) => {
eprintln!("Error: {err}");
vec![]
}
}
});
}
let idiomatic = jset
.join_all()
.await
.into_iter()
.flatten()
.collect::<Vec<_>>();
let mut idiomatic_filenames = BTreeMap::new();
for (filename, plugin) in idiomatic {
idiomatic_filenames
.entry(filename)
.or_insert_with(Vec::new)
.push(plugin);
}
idiomatic_filenames
}
fn idiomatic_version_file_disabled(
disable_files: &BTreeSet<String>,
tool: &str,
filename: &str,
) -> bool {
disable_files.iter().any(|entry| {
entry
.rsplit_once(':')
.is_some_and(|(disabled_tool, disabled_filename)| {
disabled_tool == tool && disabled_filename == filename
})
})
}
async fn idiomatic_filenames_for_root(
root: &Path,
default_idiomatic_filenames: &BTreeMap<String, Vec<String>>,
) -> Result<BTreeMap<String, Vec<String>>> {
let rooted = settings::IdiomaticVersionFileSettings::resolve_from(
root,
settings::SettingsLoadPolicy::HIERARCHY,
)?;
if rooted == settings::IdiomaticVersionFileSettings::current() {
return Ok(default_idiomatic_filenames.clone());
}
Ok(load_idiomatic_filenames_for_tools(&rooted.enable_tools, &rooted.disable_files).await)
}
static LOCAL_CONFIG_FILENAMES: Lazy<IndexSet<&'static str>> = Lazy::new(|| {
let mut paths: IndexSet<&'static str> = IndexSet::new();
if let Some(o) = &*env::MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES {
paths.extend(o.iter().map(|s| s.as_str()));
} else {
paths.extend([
".tool-versions",
&*env::MISE_DEFAULT_TOOL_VERSIONS_FILENAME, ]);
}
if !env::MISE_OVERRIDE_CONFIG_FILENAMES.is_empty() {
paths.extend(
env::MISE_OVERRIDE_CONFIG_FILENAMES
.iter()
.map(|s| s.as_str()),
)
} else {
paths.extend([
".config/mise/conf.d/*.toml",
".config/mise/config.toml",
".config/mise/mise.toml",
".config/mise.toml",
".mise/conf.d/*.toml",
".mise/config.toml",
"mise/conf.d/*.toml",
"mise/config.toml",
".rtx.toml",
"mise.toml",
&*env::MISE_DEFAULT_CONFIG_FILENAME, ".mise.toml",
".config/mise/config.local.toml",
".config/mise/mise.local.toml",
".config/mise.local.toml",
".mise/config.local.toml",
"mise/config.local.toml",
".rtx.local.toml",
"mise.local.toml",
".mise.local.toml",
]);
}
paths
});
fn env_config_patterns(env: &str) -> Vec<String> {
env_config_patterns_with_conf_d(env, env::env_conf_d())
}
fn env_config_patterns_with_conf_d(env: &str, env_conf_d: bool) -> Vec<String> {
let env = glob::Pattern::escape(env);
let mut patterns = vec![];
if env_conf_d {
patterns.push(format!(".config/mise/conf.d/*.{env}.toml"));
}
patterns.extend([
format!(".config/mise/config.{env}.toml"),
format!(".config/mise.{env}.toml"),
]);
if env_conf_d {
patterns.push(format!("mise/conf.d/*.{env}.toml"));
}
patterns.extend([
format!("mise/config.{env}.toml"),
format!("mise.{env}.toml"),
]);
if env_conf_d {
patterns.push(format!(".mise/conf.d/*.{env}.toml"));
}
patterns.extend([
format!(".mise/config.{env}.toml"),
format!(".mise.{env}.toml"),
]);
if env_conf_d {
patterns.push(format!(".config/mise/conf.d/*.{env}.local.toml"));
}
patterns.extend([
format!(".config/mise/config.{env}.local.toml"),
format!(".config/mise.{env}.local.toml"),
]);
if env_conf_d {
patterns.push(format!("mise/conf.d/*.{env}.local.toml"));
}
patterns.extend([
format!("mise/config.{env}.local.toml"),
format!("mise.{env}.local.toml"),
]);
if env_conf_d {
patterns.push(format!(".mise/conf.d/*.{env}.local.toml"));
}
patterns.extend([
format!(".mise/config.{env}.local.toml"),
format!(".mise.{env}.local.toml"),
]);
patterns
}
pub(crate) static DEFAULT_CONFIG_FILENAMES: Lazy<Vec<String>> = Lazy::new(|| {
let mut filenames = LOCAL_CONFIG_FILENAMES
.iter()
.map(|f| f.to_string())
.collect_vec();
for env in &*env::MISE_ENV_WITH_AUTO {
filenames.extend(env_config_patterns(env));
}
filenames
});
static TOML_CONFIG_FILENAMES: Lazy<Vec<String>> = Lazy::new(|| {
DEFAULT_CONFIG_FILENAMES
.iter()
.filter(|s| s.ends_with(".toml"))
.map(|s| s.to_string())
.collect()
});
static TOML_CONFIG_MATCHERS: Lazy<Vec<globset::GlobMatcher>> = Lazy::new(|| {
TOML_CONFIG_FILENAMES
.iter()
.filter_map(|pattern| {
globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.map_err(|e| warn!("failed to compile config glob pattern {pattern}: {e}"))
.ok()
.map(|glob| glob.compile_matcher())
})
.collect()
});
pub(crate) static ALL_CONFIG_FILES: Lazy<IndexSet<PathBuf>> = Lazy::new(|| {
load_config_paths(&DEFAULT_CONFIG_FILENAMES, false)
.into_iter()
.collect()
});
pub(crate) static IGNORED_CONFIG_FILES: Lazy<IndexSet<PathBuf>> = Lazy::new(|| {
load_config_paths(&DEFAULT_CONFIG_FILENAMES, true)
.into_iter()
.filter(|p| {
let ctr = config_trust_root(p);
if config_file::is_ignored_via_setting(&ctr) || config_file::is_ignored_via_setting(p) {
return true;
}
(config_file::is_persisted_ignored(&ctr) || config_file::is_persisted_ignored(p))
&& !config_file::is_trusted_via_config_paths(p)
})
.collect()
});
type GlobResults = HashMap<(PathBuf, String), Vec<PathBuf>>;
static GLOB_RESULTS: Lazy<Mutex<GlobResults>> = Lazy::new(Default::default);
pub(crate) fn glob(dir: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
let mut results = GLOB_RESULTS.lock().unwrap();
let key = (dir.to_path_buf(), pattern.to_string());
if let Some(glob) = results.get(&key) {
return Ok(glob.clone());
}
let paths = glob::glob(dir.join(pattern).to_string_lossy().as_ref())?
.filter_map(|p| p.ok())
.collect_vec();
results.insert(key, paths.clone());
Ok(paths)
}
fn config_glob(dir: &Path, pattern: &str) -> Vec<PathBuf> {
glob(dir, pattern)
.unwrap_or_default()
.into_iter()
.filter(|path| {
!is_conf_d_file(path)
|| !path
.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with('.'))
})
.collect()
}
fn is_unconditional_conf_d_pattern(pattern: &str) -> bool {
pattern.ends_with("conf.d/*.toml")
}
fn conf_d_file_environment(path: &Path) -> Option<(&str, bool)> {
if !is_conf_d_file(path) {
return None;
}
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 warn_on_dotted_conf_d_file(path: &Path) {
if settings::is_loaded()
&& env::env_conf_d_setting().is_none()
&& !env::env_conf_d()
&& is_environment_conf_d_file(path)
{
deprecated_at!(
"2026.8.10",
"2027.8.10",
"dotted_conf_d_filename",
"dots in unconditional conf.d filenames are deprecated because the suffix will become an environment selector; rename fragments such as `node.tools.toml` to `node-tools.toml`, or set `env_conf_d = true` in a miserc.toml file to opt in now"
);
}
}
fn load_config_glob(dir: &Path, pattern: &str) -> Vec<PathBuf> {
let paths = config_glob(dir, pattern);
if !env::env_conf_d() {
for path in &paths {
warn_on_dotted_conf_d_file(path);
}
return paths;
}
paths
.into_iter()
.filter(|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()
}
pub(crate) fn config_files_in_dir(dir: &Path) -> IndexSet<PathBuf> {
DEFAULT_CONFIG_FILENAMES
.iter()
.flat_map(|f| config_glob(dir, f))
.collect()
}
pub(crate) fn config_paths_in_dir(dir: &Path) -> Vec<PathBuf> {
config_paths_in_dir_with_filenames(dir, &DEFAULT_CONFIG_FILENAMES)
}
pub(crate) fn environments_for_config_path(path: &Path) -> Vec<String> {
let Some(filename) = path.file_name().and_then(|name| name.to_str()) else {
return vec![];
};
env::MISE_ENV_WITH_AUTO
.iter()
.filter(|environment| {
["config", "mise", ".mise"].into_iter().any(|prefix| {
filename == format!("{prefix}.{environment}.toml")
|| filename == format!("{prefix}.{environment}.local.toml")
}) || (env::env_conf_d()
&& conf_d_file_environment(path)
.is_some_and(|(file_environment, _)| file_environment == environment.as_str()))
})
.cloned()
.collect()
}
fn config_paths_in_dir_with_filenames(dir: &Path, filenames: &[String]) -> Vec<PathBuf> {
let config_paths: Vec<PathBuf> = filenames
.iter()
.rev()
.flat_map(|f| {
if is_glob_pattern(f) {
load_config_glob(dir, f).into_iter().rev().collect()
} else {
let path = dir.join(f);
if path.exists() { vec![path] } else { vec![] }
}
})
.collect();
let mut seen = std::collections::HashSet::new();
config_paths
.into_iter()
.filter(|p| seen.insert(p.clone()))
.collect()
}
fn all_dirs() -> Result<Vec<PathBuf>> {
file::all_dirs(env::current_dir()?, &env::MISE_CEILING_PATHS)
}
fn all_dirs_from(start_dir: &Path) -> Result<Vec<PathBuf>> {
file::all_dirs(start_dir, &env::MISE_CEILING_PATHS)
}
pub(crate) fn is_tool_versions_file(p: &Path) -> bool {
p.file_name()
.is_some_and(|f| f.to_string_lossy().ends_with(".tool-versions"))
}
fn first_config_file(files: &IndexSet<PathBuf>) -> Option<&PathBuf> {
let writable = || files.iter().filter(|p| !is_conf_d_file(p));
writable()
.find(|p| !is_tool_versions_file(p))
.or_else(|| writable().next())
}
fn is_conf_d_file(p: &Path) -> bool {
p.parent()
.is_some_and(|d| d.file_name().is_some_and(|n| n == "conf.d"))
}
fn loadable_config_files_in_dir(dir: &Path, filenames: &[String]) -> IndexSet<PathBuf> {
if config_dir_is_ignored(dir, false) {
return IndexSet::new();
}
filenames
.iter()
.flat_map(|f| load_config_glob(dir, f))
.unique_by(|p| file::desymlink_path(p))
.filter(|p| !config_path_is_ignored(p, false))
.collect()
}
pub(crate) fn config_file_in_dir(dir: &Path) -> PathBuf {
let files = loadable_config_files_in_dir(dir, &DEFAULT_CONFIG_FILENAMES);
if let Some(cf) = first_config_file(&files)
&& !is_global_config(cf)
{
return cf.clone();
}
let fallback = match Settings::get().asdf_compat {
true => dir.join(&*MISE_DEFAULT_TOOL_VERSIONS_FILENAME),
false => dir.join(&*MISE_DEFAULT_CONFIG_FILENAME),
};
if config_dir_is_ignored(dir, false) || config_path_is_ignored(&fallback, false) {
warn_once!(
"{p} is excluded from config loading, so mise will not read back what it writes there",
p = display_path(&fallback)
);
}
fallback
}
fn nearest_local_config_file(start: &Path, filenames: &[String]) -> Option<PathBuf> {
if Settings::no_config() {
return None;
}
for dir in all_dirs_from(start).unwrap_or_default() {
if config_dir_is_ignored(&dir, false) {
continue;
}
let files: IndexSet<PathBuf> = filenames
.iter()
.flat_map(|f| glob(&dir, f).unwrap_or_default())
.unique_by(|p| file::desymlink_path(p))
.filter(|p| !config_path_is_ignored(p, false))
.collect();
if let Some(cf) = first_config_file(&files)
&& !is_global_config(cf)
{
return Some(cf.clone());
}
}
None
}
pub(crate) fn config_file_from_dir(p: &Path) -> PathBuf {
if !p.is_dir() {
return p.to_path_buf();
}
if let Some(cf) = env::current_dir()
.ok()
.and_then(|cwd| nearest_local_config_file(&cwd, &DEFAULT_CONFIG_FILENAMES))
{
return cf;
}
match Settings::get().asdf_compat {
true => p.join(&*MISE_DEFAULT_TOOL_VERSIONS_FILENAME),
false => p.join(&*MISE_DEFAULT_CONFIG_FILENAME),
}
}
fn load_config_paths_from_dirs(
dirs: Vec<PathBuf>,
config_filenames: &[String],
include_ignored: bool,
) -> Vec<PathBuf> {
let mut config_files = dirs
.iter()
.flat_map(|dir| {
if config_dir_is_ignored(dir, include_ignored) {
vec![]
} else {
config_paths_in_dir_with_filenames(dir, config_filenames)
}
})
.collect::<Vec<_>>();
config_files.extend(global_config_files().into_iter().rev());
config_files.extend(system_config_files().into_iter().rev());
config_files
.into_iter()
.unique_by(|p| file::desymlink_path(p))
.filter(|p| !config_path_is_ignored(p, include_ignored))
.collect()
}
pub(crate) fn load_config_paths_from(
start_dir: &Path,
config_filenames: &[String],
include_ignored: bool,
) -> Vec<PathBuf> {
if Settings::no_config() {
return vec![];
}
load_config_paths_from_dirs(
all_dirs_from(start_dir).unwrap_or_default(),
config_filenames,
include_ignored,
)
}
pub(crate) fn load_config_paths(
config_filenames: &[String],
include_ignored: bool,
) -> Vec<PathBuf> {
if Settings::no_config() {
return vec![];
}
load_config_paths_from_dirs(
all_dirs().unwrap_or_default(),
config_filenames,
include_ignored,
)
}
fn should_warn_auto_env(
version: &versions::Versioning,
setting: Option<bool>,
auto_envs_active: bool,
) -> bool {
setting.is_none()
&& !auto_envs_active
&& *version >= versions::Versioning::new("2026.12.0").unwrap()
&& !env::auto_env_default_for_version(version)
}
fn monorepo_lockfile_default_for_version(version: &versions::Versioning) -> bool {
*version >= versions::Versioning::new(MONOREPO_LOCKFILE_DEFAULT_AT).unwrap()
}
fn monorepo_lockfile_enabled_for_version(
version: &versions::Versioning,
setting: Option<bool>,
) -> bool {
setting.unwrap_or_else(|| monorepo_lockfile_default_for_version(version))
}
fn should_warn_monorepo_lockfile_default(
version: &versions::Versioning,
setting: Option<bool>,
lockfile_enabled: bool,
monorepo_lockfiles_exist: bool,
) -> bool {
setting.is_none()
&& lockfile_enabled
&& monorepo_lockfiles_exist
&& *version >= versions::Versioning::new(MONOREPO_LOCKFILE_WARN_AT).unwrap()
&& !monorepo_lockfile_default_for_version(version)
}
fn warn_if_monorepo_lockfile_default_changes(config: &Config) {
debug_assert!(
!monorepo_lockfile_default_for_version(&version::V),
"monorepo lockfiles are now default-on; remove warn_if_monorepo_lockfile_default_changes() and should_warn_monorepo_lockfile_default()"
);
let Some(cf) = find_monorepo_config(&config.config_files) else {
return;
};
let setting = cf.monorepo().and_then(|m| m.lockfile);
if !should_warn_monorepo_lockfile_default(
&version::V,
setting,
Settings::get().lockfile_enabled(),
monorepo_lockfiles_exist(config, cf),
) {
return;
}
warn_once!(
"Monorepo lockfiles will default to a single root lockfile starting in mise {MONOREPO_LOCKFILE_DEFAULT_AT}. \
Set `[monorepo] lockfile = true` in {} to opt in now, or `lockfile = false` to keep per-subproject lockfiles and silence this warning.",
display_path(cf.get_path())
);
}
fn monorepo_lockfiles_exist(config: &Config, monorepo_config: &Arc<dyn ConfigFile>) -> bool {
let Some(monorepo_root) = monorepo_config.project_root() else {
return false;
};
let mut lockfile_paths = IndexSet::new();
for (config_path, cf) in &config.config_files {
if !config_path.starts_with(&monorepo_root) || !cf.source().is_mise_toml() {
continue;
}
lockfile_paths.insert(lockfile::lockfile_path_for_config(config_path, None).0);
lockfile_paths.insert(
lockfile::lockfile_path_for_config(config_path, Some(monorepo_root.as_path())).0,
);
}
if let Some(monorepo) = monorepo_config.monorepo()
&& let Ok(config_roots) =
expand_config_root_dirs(&monorepo_root, &monorepo.config_roots, None)
{
for config_root in config_roots {
for lockfile_path in lockfile::lockfile_variant_paths_in_dir(&config_root) {
lockfile_paths.insert(lockfile_path);
}
for config_path in config_paths_in_dir(&config_root) {
lockfile_paths.insert(lockfile::lockfile_path_for_config(&config_path, None).0);
lockfile_paths.insert(
lockfile::lockfile_path_for_config(&config_path, Some(monorepo_root.as_path()))
.0,
);
}
}
}
lockfile_paths.iter().any(|path| path.exists())
}
fn warn_if_auto_env_files_exist() {
debug_assert!(
!env::auto_env_default_for_version(&version::V),
"auto_env is now default-on; remove warn_if_auto_env_files_exist() and should_warn_auto_env()"
);
if !should_warn_auto_env(
&version::V,
env::auto_env_setting(),
!env::AUTO_ENV_NAMES.is_empty(),
) || *env::IS_RUNNING_AS_SHIM
{
return;
}
if env::ARGS
.read()
.unwrap()
.iter()
.skip(1)
.take_while(|a| *a != "--")
.any(|a| a == "hook-env")
{
return;
}
let found = detect_auto_env_candidate_files();
if !found.is_empty() {
warn_once!(
"Found platform-specific config file(s) that mise will load automatically starting in 2027.6.0: {}. \
Set MISE_AUTO_ENV=true (or `auto_env = true` in .miserc.toml) to enable this now, \
or `auto_env = false` to keep the current behavior and silence this warning. \
See https://mise.jdx.dev/configuration/environments.html#platform-environments",
found.iter().map(display_path).join(", ")
);
}
}
fn detect_auto_env_candidate_files() -> Vec<PathBuf> {
let candidate_envs = env::platform_env_names()
.into_iter()
.filter(|name| !env::MISE_ENV.contains(name))
.collect_vec();
let mut found = IndexSet::new();
for dir in all_dirs().unwrap_or_default() {
if config_file::is_ignored_via_setting(&dir) {
continue;
}
for env_name in &candidate_envs {
for pattern in env_config_patterns(env_name) {
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)
}),
);
}
}
}
for dir in [*dirs::CONFIG, *dirs::SYSTEM_CONFIG] {
for env_name in &candidate_envs {
if env::env_conf_d() {
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"),
format!("config.{env_name}.local.toml"),
format!("mise.{env_name}.local.toml"),
] {
let p = dir.join(filename);
if p.is_file() {
found.insert(p);
}
}
}
}
found
.into_iter()
.filter(|p| !config_path_is_ignored(p, false))
.collect()
}
pub(crate) async fn load_config_hierarchy_from_dir(
start_dir: &Path,
) -> Result<(Vec<PathBuf>, BTreeMap<String, Vec<String>>)> {
if Settings::no_config() {
return Ok((vec![], BTreeMap::new()));
}
let default_idiomatic_files = load_idiomatic_filenames().await;
let idiomatic_files = idiomatic_filenames_for_root(start_dir, &default_idiomatic_files).await?;
let config_filenames: Vec<String> = idiomatic_files
.keys()
.cloned()
.chain(DEFAULT_CONFIG_FILENAMES.iter().cloned())
.collect();
let dirs = all_dirs_from(start_dir)?;
let mut config_files = dirs
.iter()
.flat_map(|dir| {
if config_dir_is_ignored(dir, false) {
vec![]
} else {
config_filenames
.iter()
.rev()
.flat_map(|f| load_config_glob(dir, f).into_iter().rev())
.collect()
}
})
.collect::<Vec<_>>();
config_files.extend(global_config_files().into_iter().rev());
config_files.extend(system_config_files().into_iter().rev());
let paths = config_files
.into_iter()
.unique_by(|p| file::desymlink_path(p))
.filter(|p| !config_path_is_ignored(p, false))
.collect();
Ok((paths, idiomatic_files))
}
pub(crate) fn is_global_config(path: &Path) -> bool {
config_set_contains(&global_config_files(), path) || is_system_config(path)
}
pub(crate) fn is_system_config(path: &Path) -> bool {
config_set_contains(&system_config_files(), path)
}
fn config_set_contains(set: &IndexSet<PathBuf>, path: &Path) -> bool {
if set.contains(path) {
return true;
}
let target = file::desymlink_path(path);
set.iter().any(|p| file::desymlink_path(p) == target)
}
fn resolved_task_file(task: &Task) -> Option<PathBuf> {
let file = task.file.as_ref()?;
let file_str = file.to_string_lossy().to_string();
let rendered = if contains_template_syntax(&file_str) {
let mut tera = get_empty_tera();
let mut tera_ctx = ::tera::Context::new();
tera_ctx.insert("config_root", &task.config_root.clone().unwrap_or_default());
match render_str(&mut tera, &file_str, &tera_ctx) {
Ok(rendered) => rendered,
Err(err) => {
debug!(
"failed to resolve task file for source comparison ({}): {err:#}",
task.name
);
return None;
}
}
} else {
file_str
};
let path = file::replace_path(&rendered);
let path = if path.is_absolute() {
path
} else if let Some(root) = &task.config_root {
root.join(path)
} else {
path
};
Some(file::desymlink_path(&path))
}
fn resolved_task_source(task: &Task) -> Option<PathBuf> {
if task.file.is_some() {
resolved_task_file(task)
} else if task.config_source.as_os_str().is_empty() {
None
} else {
Some(file::desymlink_path(&task.config_source))
}
}
fn tasks_have_same_source(left: &Task, right: &Task) -> bool {
left.name == right.name
&& resolved_task_source(left)
.zip(resolved_task_source(right))
.is_some_and(|(left, right)| file::same_file(&left, &right))
}
fn is_default_config_dir_override_filtered(path: &Path) -> bool {
*env::MISE_CONFIG_DIR_OVERRIDDEN
&& !config_set_contains(&global_config_files(), path)
&& path.starts_with(&*env::MISE_DEFAULT_CONFIG_DIR)
}
fn config_dir_is_ignored(dir: &Path, include_ignored: bool) -> bool {
!include_ignored && config_file::is_ignored_via_setting(dir)
}
fn config_path_is_ignored(path: &Path, include_ignored: bool) -> bool {
if is_default_config_dir_override_filtered(path) {
return true;
}
if include_ignored {
return false;
}
let ctr = config_trust_root(path);
if config_file::is_ignored_via_setting(&ctr) || config_file::is_ignored_via_setting(path) {
return true;
}
if config_file::is_persisted_ignored(&ctr) || config_file::is_persisted_ignored(path) {
return !config_file::is_trusted_via_config_paths(path);
}
false
}
static GLOBAL_CONFIG_FILES: Lazy<Mutex<Option<IndexSet<PathBuf>>>> = Lazy::new(Default::default);
static SYSTEM_CONFIG_FILES: Lazy<Mutex<Option<IndexSet<PathBuf>>>> = Lazy::new(Default::default);
pub(crate) fn global_config_files() -> IndexSet<PathBuf> {
let mut g = GLOBAL_CONFIG_FILES.lock().unwrap();
if let Some(g) = &*g {
return g.clone();
}
if let Some(global_config_file) = &*env::MISE_GLOBAL_CONFIG_FILE {
return vec![global_config_file.clone()].into_iter().collect();
}
let mut config_files: IndexSet<PathBuf> = config_files_from_dir(&dirs::CONFIG);
if !*env::MISE_USE_TOML {
config_files.insert(dirs::HOME.join(env::MISE_DEFAULT_TOOL_VERSIONS_FILENAME.as_str()));
};
*g = Some(config_files.clone());
config_files
}
pub(crate) fn system_config_files() -> IndexSet<PathBuf> {
let mut s = SYSTEM_CONFIG_FILES.lock().unwrap();
if let Some(s) = &*s {
return s.clone();
}
if let Some(p) = &*env::MISE_SYSTEM_CONFIG_FILE {
return vec![p.clone()].into_iter().collect();
}
let config_files = config_files_from_dir(&dirs::SYSTEM_CONFIG);
*s = Some(config_files.clone());
config_files
}
fn config_files_from_dir(dir: &Path) -> IndexSet<PathBuf> {
let mut files = IndexSet::new();
for p in file::ls(&dir.join("conf.d")).unwrap_or_default() {
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")
&& (!env::env_conf_d() || !is_environment_conf_d_file(&p))
{
warn_on_dotted_conf_d_file(&p);
files.insert(p);
}
}
files.extend([dir.join("config.toml"), dir.join("mise.toml")]);
for environment in &*env::MISE_ENV_WITH_AUTO {
if env::env_conf_d() {
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 {
if env::env_conf_d() {
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<PathBuf> {
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('.'))
&& conf_d_file_environment(path) == Some((environment, local))
})
.collect()
}
pub(crate) fn global_config_path() -> PathBuf {
let files = global_config_files();
first_config_file(&files)
.cloned()
.or_else(|| env::MISE_GLOBAL_CONFIG_FILE.clone())
.unwrap_or_else(|| dirs::CONFIG.join("config.toml"))
}
pub(crate) fn system_config_path() -> PathBuf {
let files = system_config_files();
first_config_file(&files)
.cloned()
.or_else(|| env::MISE_SYSTEM_CONFIG_FILE.clone())
.unwrap_or_else(|| dirs::SYSTEM_CONFIG.join("config.toml"))
}
pub(crate) fn top_toml_config() -> Option<PathBuf> {
load_config_paths(&TOML_CONFIG_FILENAMES, false)
.iter()
.find(|p| p.to_string_lossy().ends_with(".toml"))
.map(|p| p.to_path_buf())
}
pub(crate) static ALL_TOML_CONFIG_FILES: Lazy<IndexSet<PathBuf>> = Lazy::new(|| {
load_config_paths(&TOML_CONFIG_FILENAMES, false)
.into_iter()
.collect()
});
pub(crate) fn local_toml_config_path() -> PathBuf {
static CWD: Lazy<PathBuf> = Lazy::new(|| PathBuf::from("."));
local_toml_config_path_from_dir(dirs::CWD.as_ref().unwrap_or(&CWD))
}
pub(crate) fn local_toml_config_path_from_dir(cwd: &Path) -> PathBuf {
nearest_local_config_file(cwd, &TOML_CONFIG_FILENAMES)
.unwrap_or_else(|| cwd.join(&*env::MISE_DEFAULT_CONFIG_FILENAME))
}
#[derive(Debug, Default)]
pub(crate) struct ConfigPathOptions {
pub global: bool,
pub path: Option<PathBuf>,
pub env: Option<String>,
pub cwd: Option<PathBuf>,
pub prefer_toml: bool,
pub prevent_home_local: bool,
}
pub(crate) fn resolve_target_config_path(opts: ConfigPathOptions) -> Result<PathBuf> {
let cwd = match opts.cwd {
Some(ref path) => path.clone(),
None => env::current_dir()?,
};
if let Some(ref path) = opts.path {
let path = path.absolutize()?.to_path_buf();
if path.is_file() {
return Ok(path);
} else if path.is_dir() {
let resolved = config_file_in_dir(&path);
if opts.prefer_toml && !resolved.to_string_lossy().ends_with(".toml") {
return Ok(path.join(&*env::MISE_DEFAULT_CONFIG_FILENAME));
}
return Ok(resolved);
} else {
return Ok(path);
}
}
if opts.global {
return Ok(global_config_path());
}
if let Some(ref env_name) = opts.env {
let dotfile_path = cwd.join(format!(".mise.{}.toml", env_name));
if dotfile_path.exists() {
return Ok(dotfile_path);
} else {
return Ok(cwd.join(format!("mise.{}.toml", env_name)));
}
}
if opts.prevent_home_local && env::in_home_dir() {
return Ok(global_config_path());
}
if opts.prefer_toml {
Ok(local_toml_config_path_from_dir(&cwd))
} else {
Ok(config_file_from_dir(&cwd))
}
}
async fn load_all_config_files(
config_filenames: &[PathBuf],
idiomatic_filenames: &BTreeMap<String, Vec<String>>,
) -> Result<ConfigMap> {
backend::load_tools().await?;
let mut config_map = ConfigMap::default();
for f in config_filenames.iter().unique() {
if f.is_dir() {
continue;
}
let cf = match parse_config_file(f, idiomatic_filenames).await {
Ok(cfg) => cfg,
Err(err) => {
return Err(err.wrap_err(format!(
"error parsing config file: {}",
style::ebold(display_path(f))
)));
}
};
if let Err(err) = Tracker::track(f) {
warn!("tracking config: {err:#}");
}
if cf.monorepo_root() == Some(true)
&& let Err(err) = config_file::mark_as_monorepo_root(f)
{
warn!("failed to mark monorepo root: {err:#}");
}
config_map.insert(f.clone(), cf);
}
Ok(config_map)
}
pub(crate) async fn load_config_files_from_paths(
config_paths: &[PathBuf],
idiomatic_filenames: &BTreeMap<String, Vec<String>>,
) -> Result<ConfigMap> {
backend::load_tools().await?;
let mut config_map = ConfigMap::default();
for f in config_paths.iter().unique() {
if f.is_dir() {
continue;
}
let cf = match parse_config_file(f, idiomatic_filenames).await {
Ok(cfg) => cfg,
Err(err) => {
return Err(err.wrap_err(format!(
"error parsing config file: {}",
style::ebold(display_path(f))
)));
}
};
config_map.insert(f.clone(), cf);
}
Ok(config_map)
}
async fn parse_config_file(
f: &PathBuf,
idiomatic_filenames: &BTreeMap<String, Vec<String>>,
) -> Result<Arc<dyn ConfigFile>> {
warn_on_dotted_conf_d_file(f);
let plugins = matching_idiomatic_tools(f, idiomatic_filenames);
if plugins.is_empty() {
config_file::parse(f).await
} else {
trace!("idiomatic version file: {}", display_path(f));
let tools = backend::list()
.into_iter()
.filter(|backend| plugins.contains(&backend.to_string()))
.collect::<Vec<_>>();
IdiomaticVersionFile::parse(f.into(), tools)
.await
.map(|f| Arc::new(f) as Arc<dyn ConfigFile>)
}
}
fn matching_idiomatic_tools(
path: &Path,
idiomatic_filenames: &BTreeMap<String, Vec<String>>,
) -> Vec<String> {
config_file::matching_idiomatic_filenames(path, idiomatic_filenames.keys().map(String::as_str))
.into_iter()
.flat_map(|filename| idiomatic_filenames.get(filename).into_iter().flatten())
.unique()
.cloned()
.collect()
}
fn load_aliases(config_files: &ConfigMap) -> Result<AliasMap> {
let mut aliases: AliasMap = AliasMap::new();
for config_file in config_files.values() {
for (plugin, plugin_aliases) in config_file.aliases()? {
let alias = aliases.entry(plugin.clone()).or_default();
if let Some(full) = plugin_aliases.backend {
alias.backend = Some(full);
}
for (from, to) in plugin_aliases.versions {
alias.versions.insert(from, to);
}
}
}
trace!("load_aliases: {}", aliases.len());
Ok(aliases)
}
fn load_shell_aliases(config_files: &ConfigMap) -> Result<EnvWithSources> {
let mut shell_aliases: EnvWithSources = EnvWithSources::new();
let safe_mode = Settings::safe_mode();
for config_file in config_files.values().rev() {
let path = config_file.get_path().to_path_buf();
if safe_mode && !is_global_config(&path) {
continue;
}
for (name, cmd) in config_file.shell_aliases()? {
shell_aliases.insert(name, (cmd, path.clone()));
}
}
trace!("load_shell_aliases: {}", shell_aliases.len());
Ok(shell_aliases)
}
pub(crate) fn load_command_wrappers(
config_files: &ConfigMap,
) -> Result<IndexMap<String, CommandWrapper>> {
let mut wrappers = IndexMap::new();
let safe_mode = Settings::safe_mode();
for config_file in config_files.values().rev() {
if safe_mode && !is_global_config(config_file.get_path()) {
continue;
}
for (name, wrapper) in config_file.command_wrappers()? {
if wrapper.command().trim().is_empty() {
bail!("command wrapper for {name:?} must have a non-blank command");
}
wrappers.insert(name, wrapper);
}
}
Ok(wrappers)
}
fn load_plugins(config_files: &ConfigMap) -> Result<HashMap<String, String>> {
let mut plugins = HashMap::new();
for config_file in config_files.values() {
for (plugin, url) in config_file.plugins()? {
plugins.insert(plugin.clone(), url.clone());
}
}
trace!("load_plugins: {}", plugins.len());
Ok(plugins)
}
pub(crate) async fn resolve_vars_from_config_files(
config: &Arc<Config>,
config_files: &ConfigMap,
) -> Result<EnvResults> {
let entries = config_files
.iter()
.rev()
.map(|(source, cf)| {
cf.vars_entries()
.map(|ee| ee.into_iter().map(|e| (e, source.clone())))
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
EnvResults::resolve(
config,
config.tera_ctx.clone(),
&env::PRISTINE_ENV,
entries,
EnvResolveOptions {
vars: true,
tools: ToolsFilter::NonToolsOnly,
warn_on_missing_required: false,
},
)
.await
}
async fn load_vars(config: &Arc<Config>) -> Result<EnvResults> {
time!("load_vars start");
let vars_results = resolve_vars_from_config_files(config, &config.config_files).await?;
time!("load_vars done");
if log::log_enabled!(log::Level::Trace) {
trace!("{vars_results:#?}");
} else if !vars_results.is_empty() {
debug!("{vars_results:?}");
}
Ok(vars_results)
}
impl Debug for Config {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let config_files = self
.config_files
.iter()
.map(|(p, _)| display_path(p))
.collect::<Vec<_>>();
let mut s = f.debug_struct("Config");
s.field("Config Files", &config_files);
let default_ctx = crate::task::TaskLoadContext::default();
if let Some(tasks) = self.tasks_cache.get(&default_ctx) {
s.field(
"Tasks",
&tasks.values().map(|t| t.to_string()).collect_vec(),
);
}
if let Some(env) = self.env_maybe()
&& !env.is_empty()
{
s.field("Env", &env);
}
if let Some(env_results) = self.env.get() {
if !env_results.env_files.is_empty() {
s.field("Path Dirs", &env_results.env_paths);
}
if !env_results.env_scripts.is_empty() {
s.field("Scripts", &env_results.env_scripts);
}
if !env_results.env_files.is_empty() {
s.field("Files", &env_results.env_files);
}
}
if !self.aliases.is_empty() {
s.field("Aliases", &self.aliases);
}
s.finish()
}
}
#[derive(Clone, Debug, Default)]
struct TaskDefinitions {
templates: IndexMap<String, SourcedTaskTemplate>,
workspace_defaults: Option<WorkspaceTaskDefaults>,
}
#[derive(Clone, Debug)]
struct SourcedTaskTemplate {
template: TaskTemplate,
source: PathBuf,
}
#[derive(Clone, Debug)]
struct WorkspaceTaskDefaults {
project_roots: BTreeSet<PathBuf>,
tasks: IndexMap<String, TaskTemplate>,
source: PathBuf,
}
fn collect_task_definitions(
config_files: &ConfigMap,
workspace_graph: Option<&crate::task::workspace::WorkspaceProjectGraph>,
) -> TaskDefinitions {
let mut definitions = TaskDefinitions::default();
for cf in config_files.values().rev() {
for (name, template) in cf.task_templates() {
definitions.templates.insert(
name,
SourcedTaskTemplate {
template,
source: cf.get_path().to_path_buf(),
},
);
}
}
let workspace_defaults = Settings::get()
.experimental
.then(|| {
let cf = find_monorepo_config(config_files)?;
let root = cf.project_root()?.to_path_buf();
let monorepo = cf.monorepo()?;
let tasks = monorepo.task_defaults.clone();
let mut project_roots = expand_config_root_dirs(&root, &monorepo.config_roots, None)
.unwrap_or_default()
.into_iter()
.map(|project_root| file::desymlink_path(&project_root))
.collect::<BTreeSet<_>>();
project_roots.extend(
monorepo
.projects
.values()
.filter(|project| !project.remove)
.filter_map(|project| project.root.as_ref())
.map(|project_root| file::desymlink_path(&root.join(project_root))),
);
if let Some(graph) = workspace_graph {
project_roots.extend(
graph
.projects()
.map(|project| file::desymlink_path(&root.join(&project.root))),
);
}
(!tasks.is_empty()).then_some(WorkspaceTaskDefaults {
project_roots,
tasks,
source: cf.get_path().to_path_buf(),
})
})
.flatten();
definitions.workspace_defaults = workspace_defaults;
definitions
}
fn resolve_task_template(task: &mut Task, definitions: &TaskDefinitions) -> Result<()> {
if let Some(template_name) = &task.extends {
let template = definitions.templates.get(template_name).ok_or_else(|| {
eyre!(
"Task '{}' extends template '{}' which was not found. \
Available templates: {}",
task.name,
template_name,
if definitions.templates.is_empty() {
"(none)".to_string()
} else {
definitions.templates.keys().join(", ")
}
)
})?;
task.merge_template(&template.template);
task.add_config_source(&template.source);
}
if let Some(defaults) = &definitions.workspace_defaults
&& !task.global
&& task
.config_root
.as_deref()
.map(file::desymlink_path)
.is_some_and(|root| defaults.project_roots.contains(&root))
{
let task_name = task
.name
.split_once('#')
.filter(|_| crate::task::is_workspace_project_task(&task.name))
.map_or(task.name.as_str(), |(_, name)| name);
if let Some(default) = defaults.tasks.get(task_name) {
task.merge_template(default);
task.add_config_source(&defaults.source);
}
}
Ok(())
}
fn apply_task_config_cache_default(task: &mut Task, cache: &Option<TaskCacheConfig>) {
if task.cache.is_none()
&& !task.sources.is_empty()
&& !task.outputs.is_auto()
&& let Some(cache) = cache
{
task.cache = Some(cache.clone());
}
}
fn apply_task_config_rust_cache_default(task: &mut Task, rust_cache: &Option<TaskRustCacheConfig>) {
if task.rust_cache.is_none() {
task.rust_cache = rust_cache.clone();
}
}
#[derive(Clone, Debug, Default)]
struct ResolvedTaskEnvironment {
global_env: Vec<String>,
global_pass_through_env: Vec<String>,
}
impl ResolvedTaskEnvironment {
fn from_configs(configs: &[&Arc<dyn ConfigFile>], cascaded: Option<&TaskConfig>) -> Self {
Self {
global_env: configs
.iter()
.find_map(|cf| {
let env = &cf.task_config().global_env;
(!env.is_empty()).then(|| env.clone())
})
.or_else(|| {
cascaded
.map(|tc| tc.global_env.clone())
.filter(|env| !env.is_empty())
})
.unwrap_or_default(),
global_pass_through_env: configs
.iter()
.find_map(|cf| {
let env = &cf.task_config().global_pass_through_env;
(!env.is_empty()).then(|| env.clone())
})
.or_else(|| {
cascaded
.map(|tc| tc.global_pass_through_env.clone())
.filter(|env| !env.is_empty())
})
.unwrap_or_default(),
}
}
fn apply(&self, task: &mut Task) -> Result<()> {
if self.global_env.is_empty() && self.global_pass_through_env.is_empty() {
return Ok(());
}
Settings::get().ensure_experimental("global task environment configuration")?;
if let Some(cache) = &mut task.cache {
cache.env.extend(self.global_env.iter().cloned());
cache.env.sort();
cache.env.dedup();
}
task.pass_through_env
.extend(self.global_pass_through_env.iter().cloned());
task.pass_through_env.sort();
task.pass_through_env.dedup();
Ok(())
}
}
const TASK_INPUT_GROUP_PREFIX: &str = "@group:";
#[derive(Clone, Debug, Default)]
struct ResolvedTaskInputs {
global_inputs: Option<(Vec<String>, PathBuf)>,
input_groups: IndexMap<String, (Vec<String>, PathBuf)>,
}
#[derive(Clone, Debug, Default)]
struct ResolvedTaskConfig {
inputs: ResolvedTaskInputs,
environment: ResolvedTaskEnvironment,
dir: Option<String>,
shell: Option<String>,
cache: Option<TaskCacheConfig>,
rust_cache: Option<TaskRustCacheConfig>,
}
impl ResolvedTaskInputs {
fn from_configs(configs: &[&Arc<dyn ConfigFile>]) -> Self {
Self {
global_inputs: configs.iter().find_map(|cf| {
let inputs = &cf.task_config().global_inputs;
(!inputs.is_empty()).then(|| (inputs.clone(), cf.config_root()))
}),
input_groups: configs
.iter()
.find_map(|cf| {
let groups = &cf.task_config().input_groups;
(!groups.is_empty()).then(|| {
let root = cf.config_root();
groups
.iter()
.map(|(name, entries)| (name.clone(), (entries.clone(), root.clone())))
.collect()
})
})
.unwrap_or_default(),
}
}
fn is_empty(&self) -> bool {
self.global_inputs.is_none() && self.input_groups.is_empty()
}
fn overlay(&mut self, overlay: Self) {
if overlay.global_inputs.is_some() {
self.global_inputs = overlay.global_inputs;
}
self.input_groups.extend(overlay.input_groups);
}
}
fn anchor_task_input(root: &Path, input: &str) -> String {
let (exclude, input) = input
.strip_prefix('!')
.map(|input| (true, input))
.unwrap_or((false, input));
let input = input
.strip_prefix("\\!")
.map(|input| format!("!{input}"))
.unwrap_or_else(|| input.to_string());
let path = Path::new(&input);
let anchored = if path.is_absolute() {
input
} else {
root.join(path).to_string_lossy().to_string()
};
if exclude {
format!("!{anchored}")
} else {
anchored
}
}
fn expand_task_inputs(
entries: &[String],
task_inputs: &ResolvedTaskInputs,
root: &Path,
task_name: &str,
stack: &mut Vec<String>,
anchor_literals: bool,
) -> Result<Vec<String>> {
let mut expanded = vec![];
for entry in entries {
let Some(group) = entry.strip_prefix(TASK_INPUT_GROUP_PREFIX) else {
expanded.push(if anchor_literals {
anchor_task_input(root, entry)
} else {
entry.clone()
});
continue;
};
let (inputs, group_root) = task_inputs.input_groups.get(group).ok_or_else(|| {
eyre!("task {task_name} references undefined input group {group:?} with {entry:?}")
})?;
if let Some(cycle_start) = stack.iter().position(|name| name == group) {
let mut cycle = stack[cycle_start..].to_vec();
cycle.push(group.to_string());
bail!(
"task {task_name} input group cycle: {}",
cycle.iter().join(" -> ")
);
}
stack.push(group.to_string());
expanded.extend(expand_task_inputs(
inputs,
task_inputs,
group_root,
task_name,
stack,
true,
)?);
stack.pop();
}
Ok(expanded)
}
fn render_task_input_entries(
entries: &[String],
config_root: &Path,
tera_ctx: &tera::Context,
) -> Result<Vec<String>> {
let mut tera_ctx = tera_ctx.clone();
tera_ctx.insert("config_root", config_root);
let mut tera = crate::tera::get_tera(Some(config_root));
entries
.iter()
.map(|entry| {
if contains_template_syntax(entry) {
Ok(render_str(&mut tera, entry, &tera_ctx)?)
} else {
Ok(entry.clone())
}
})
.collect()
}
async fn apply_task_config_inputs(
task: &mut Task,
config: &Arc<Config>,
task_inputs: &ResolvedTaskInputs,
) -> Result<()> {
let has_group_references = task
.sources
.iter()
.any(|source| source.starts_with(TASK_INPUT_GROUP_PREFIX));
if task_inputs.is_empty() && !has_group_references {
return Ok(());
}
Settings::get().ensure_experimental("task input groups")?;
let tera_ctx = task.tera_ctx(config).await?;
let global_inputs = match &task_inputs.global_inputs {
Some((entries, root)) => Some((
render_task_input_entries(entries, root, &tera_ctx)?,
root.clone(),
)),
None => None,
};
let input_groups = task_inputs
.input_groups
.iter()
.map(|(name, (entries, root))| {
Ok((
name.clone(),
(
render_task_input_entries(entries, root, &tera_ctx)?,
root.clone(),
),
))
})
.collect::<Result<_>>()?;
let task_inputs = ResolvedTaskInputs {
global_inputs,
input_groups,
};
let had_sources = !task.sources.is_empty();
let mut sources = expand_task_inputs(
&task.sources,
&task_inputs,
Path::new(""),
&task.name,
&mut vec![],
false,
)?;
if let Some((global_inputs, root)) = &task_inputs.global_inputs {
sources.extend(expand_task_inputs(
global_inputs,
&task_inputs,
root,
&task.name,
&mut vec![],
true,
)?);
}
task.sources = sources;
if !had_sources && !task.sources.is_empty() && task.outputs.is_empty() {
task.outputs = TaskOutputs::Auto;
}
Ok(())
}
fn default_task_includes() -> Vec<String> {
vec![
"mise-tasks".to_string(),
".mise-tasks".to_string(),
".mise/tasks".to_string(),
".config/mise/tasks".to_string(),
"mise/tasks".to_string(),
]
}
fn is_global_task_include_path(path: &Path) -> bool {
[
dirs::CONFIG.join("tasks"),
dirs::SYSTEM_CONFIG.join("tasks"),
]
.iter()
.any(|prefix| file::path_starts_with_resolved(path, prefix))
}
#[async_backtrace::framed]
pub(crate) async fn rebuild_shims_and_runtime_symlinks(
config: &Arc<Config>,
ts: &Toolset,
new_versions: &[ToolVersion],
lockfile_update_mode: lockfile::LockfileUpdateMode,
) -> Result<()> {
measure!("rebuilding runtime symlinks", {
runtime_symlinks::rebuild_for_toolset(config, ts)
.await
.wrap_err("failed to rebuild runtime symlinks")?;
});
measure!("rebuilding shims", {
shims::reshim(config, ts, false)
.await
.wrap_err("failed to rebuild shims")?;
});
lockfile::migrate_monorepo_lockfiles(config, false)?;
let pre_install_platforms = if new_versions.is_empty() {
Default::default()
} else {
lockfile::snapshot_pre_install_platforms(config, ts, new_versions)
};
let has_deferred_provenance = measure!("updating lockfiles", {
lockfile::update_lockfiles(config, ts, new_versions, lockfile_update_mode)
.wrap_err("failed to update lockfiles")?
});
if !new_versions.is_empty() || has_deferred_provenance {
measure!("auto-locking platforms", {
lockfile::auto_lock_new_versions(
config,
ts,
new_versions,
&pre_install_platforms,
lockfile_update_mode,
)
.await
.wrap_err("failed to auto-lock platforms")?;
});
}
Ok(())
}
fn prefix_monorepo_task_names(tasks: &mut [Task], dir: &Path, monorepo_root: &Path) {
if let Some(scope) = monorepo_scope(monorepo_root, dir) {
for task in tasks.iter_mut() {
task.name = format!("{scope}:{}", task.name);
}
}
}
async fn inferred_workspace_tasks(
config: &Arc<Config>,
task_definitions: &TaskDefinitions,
graph: &crate::task::workspace::WorkspaceProjectGraph,
providers: &BTreeSet<String>,
) -> Vec<Task> {
let Some(monorepo_root) = config.monorepo_root() else {
return Vec::new();
};
let mut tasks = Vec::new();
for project in graph.projects().filter(|project| {
project
.id
.as_str()
.split_once(':')
.is_some_and(|(provider, _)| providers.contains(provider))
}) {
let project_root = monorepo_root.join(&project.root);
let path_scope = monorepo_scope(&monorepo_root, &project_root);
for (name, inferred) in &project.tasks {
let aliases = path_scope
.as_ref()
.map(|scope| vec![format!("{scope}:{name}")])
.unwrap_or_default();
let mut task = Task {
name: format!("{}#{name}", project.id),
description: inferred.description.clone(),
aliases,
config_source: monorepo_root.join(&inferred.source),
config_root: Some(project_root.clone()),
raw_args: true,
run: vec![RunEntry::Script(inferred.command.clone())],
..Default::default()
};
inferred.suggestions.apply_before_defaults(&mut task);
if let Err(err) = resolve_task_template(&mut task, task_definitions) {
warn!(
"Failed to resolve inferred task {} in {}: {err:#}. Task will not be available.",
task.name,
display_path(&task.config_source)
);
continue;
}
inferred.suggestions.apply_after_defaults(&mut task);
if let Err(err) = task.render(config, &project_root).await {
warn!(
"Failed to render inferred task {} in {}: {err:#}. Task will not be available.",
task.name,
display_path(&task.config_source)
);
continue;
}
tasks.push(task);
}
}
tasks
}
fn enclosing_monorepo_roots(config_files: &ConfigMap, selected_root: &Path) -> Vec<PathBuf> {
config_files
.values()
.filter(|cf| cf.monorepo_root() == Some(true))
.filter_map(|cf| cf.project_root())
.filter(|root| {
!file::same_file(root, selected_root)
&& file::path_starts_with_resolved(selected_root, root)
})
.collect()
}
fn dir_is_in_enclosing_monorepo(
dir: &Path,
selected_root: &Path,
enclosing_roots: &[PathBuf],
) -> bool {
if enclosing_roots.is_empty() {
return false;
}
enclosing_roots
.iter()
.any(|enclosing| file::path_starts_with_resolved(dir, enclosing))
&& !file::path_starts_with_resolved(dir, selected_root)
}
async fn load_local_tasks_with_context(
config: &Arc<Config>,
ctx: Option<&crate::task::TaskLoadContext>,
templates: &TaskDefinitions,
) -> Result<Vec<Task>> {
let mut tasks = vec![];
let monorepo_config = find_monorepo_config(&config.config_files);
let monorepo_root = monorepo_config.and_then(|cf| cf.project_root().map(|p| p.to_path_buf()));
let local_config_files = config
.config_files
.iter()
.filter(|(_, cf)| !is_global_config(cf.get_path()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<IndexMap<_, _>>();
let enclosing_roots = monorepo_root
.as_deref()
.map(|root| enclosing_monorepo_roots(&config.config_files, root))
.unwrap_or_default();
for d in all_dirs()? {
if cfg!(test) && !d.starts_with(*dirs::HOME) {
continue;
}
if let Some(ref monorepo_root) = monorepo_root
&& dir_is_in_enclosing_monorepo(&d, monorepo_root, &enclosing_roots)
{
trace!(
"skipping tasks in {}: belongs to a monorepo enclosing {}",
display_path(&d),
display_path(monorepo_root)
);
continue;
}
let mut dir_tasks =
load_tasks_in_dir_with_definitions(config, &d, &local_config_files, templates).await?;
if let Some(ref monorepo_root) = monorepo_root {
prefix_monorepo_task_names(&mut dir_tasks, &d, monorepo_root);
}
tasks.extend(dir_tasks);
}
let should_load_subdirs = ctx.is_some_and(|c| c.load_all || !c.path_hints.is_empty());
if let Some(monorepo_root) = &monorepo_root {
if !should_load_subdirs {
return Ok(tasks);
}
let config_roots = monorepo_config
.and_then(|cf| cf.monorepo())
.map(|m| &m.config_roots);
let subdirs = discover_monorepo_subdirs(monorepo_root, config_roots, ctx)?;
let subdir_tasks_futures: Vec<_> = subdirs
.into_iter()
.filter(|subdir| !cfg!(test) || subdir.starts_with(*dirs::HOME))
.map(|subdir| {
let config = config.clone();
let monorepo_root = monorepo_root.clone();
let templates = templates.clone();
async move {
let exact_config_paths = config_paths_in_dir(&subdir);
let found_config = !exact_config_paths.is_empty();
let mut seen_config_paths = std::collections::HashSet::new();
let mut hierarchy_configs = config
.config_files
.iter()
.filter(|(_, cf)| {
let root = cf.config_root();
file::path_starts_with_resolved(&root, &monorepo_root)
&& file::path_starts_with_resolved(&subdir, &root)
})
.filter(|(path, _)| {
seen_config_paths.insert(file::desymlink_path(path))
})
.map(|(path, cf)| (path.clone(), cf.clone()))
.collect::<ConfigMap>();
let mut hierarchy_dirs = subdir
.ancestors()
.take_while(|dir| {
file::path_starts_with_resolved(dir, &monorepo_root)
})
.map(Path::to_path_buf)
.collect::<Vec<_>>();
hierarchy_dirs.reverse();
for hierarchy_dir in hierarchy_dirs {
for config_path in config_paths_in_dir(&hierarchy_dir) {
if !seen_config_paths.insert(file::desymlink_path(&config_path)) {
continue;
}
match config_file::parse(&config_path).await {
Ok(cf) => {
hierarchy_configs.insert(config_path, cf);
}
Err(err) => {
let rel_path = hierarchy_dir
.strip_prefix(&monorepo_root)
.unwrap_or(&hierarchy_dir);
warn!(
"Failed to parse config file {} in monorepo directory {}: {}. Cascaded task configuration from this directory will be ignored.",
config_path.display(),
rel_path.display(),
err
);
}
}
}
}
let configs = configs_at_root(&subdir, &hierarchy_configs);
let cascaded_task_config =
cascaded_task_config_for_dir(&subdir, &hierarchy_configs)?;
if found_config {
if configs.is_empty() {
return Ok(vec![]);
}
let mut tasks = load_tasks_from_configs(
&config,
&subdir,
configs,
&templates,
true,
cascaded_task_config.as_ref(),
)
.await?;
prefix_monorepo_task_names(&mut tasks, &subdir, &monorepo_root);
return Ok(tasks);
}
let mut tasks = load_tasks_from_configs(
&config,
&subdir,
vec![],
&templates,
true,
cascaded_task_config.as_ref(),
)
.await?;
prefix_monorepo_task_names(&mut tasks, &subdir, &monorepo_root);
Ok::<Vec<Task>, eyre::Report>(tasks)
}
})
.collect();
use tokio::task::JoinSet;
let mut join_set = JoinSet::new();
for future in subdir_tasks_futures {
join_set.spawn(future);
}
while let Some(result) = join_set.join_next().await {
tasks.extend(result??);
}
}
Ok(tasks)
}
fn expand_config_roots(
root: &Path,
patterns: &[String],
ctx: Option<&crate::task::TaskLoadContext>,
) -> Result<Vec<PathBuf>> {
expand_config_roots_with_filenames(root, patterns, ctx, &DEFAULT_CONFIG_FILENAMES)
}
fn expand_config_root_dirs(
root: &Path,
patterns: &[String],
ctx: Option<&crate::task::TaskLoadContext>,
) -> Result<Vec<PathBuf>> {
expand_config_roots_inner(root, patterns, ctx, None)
}
fn expand_config_roots_with_filenames(
root: &Path,
patterns: &[String],
ctx: Option<&crate::task::TaskLoadContext>,
filenames: &[String],
) -> Result<Vec<PathBuf>> {
expand_config_roots_inner(root, patterns, ctx, Some(filenames))
}
fn expand_config_roots_inner(
root: &Path,
patterns: &[String],
ctx: Option<&crate::task::TaskLoadContext>,
filenames: Option<&[String]>,
) -> Result<Vec<PathBuf>> {
expand_config_roots_inner_for("monorepo", root, patterns, ctx, filenames, false)
}
fn expand_bootstrap_config_roots(root: &Path, patterns: &[String]) -> Result<Vec<PathBuf>> {
expand_config_roots_inner_for(
"bootstrap",
root,
patterns,
None,
Some(&DEFAULT_CONFIG_FILENAMES),
true,
)
}
fn expand_config_roots_inner_for(
section: &str,
root: &Path,
patterns: &[String],
ctx: Option<&crate::task::TaskLoadContext>,
filenames: Option<&[String]>,
canonicalize_results: bool,
) -> Result<Vec<PathBuf>> {
let mut subdirs = Vec::new();
let canonical_root = match root.canonicalize() {
Ok(root) => root,
Err(err) => {
warn!(
"[{section}].config_roots: failed to resolve config root {}: {err}",
root.display()
);
return Ok(subdirs);
}
};
for pattern in patterns {
if Path::new(pattern).components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
warn!(
"[{section}].config_roots: '{}' must be a relative path within the config root",
pattern
);
continue;
}
if pattern.contains("**") {
warn!(
"[{section}].config_roots: recursive glob '**' not supported in '{}', use single-level '*' instead",
pattern
);
continue;
}
if pattern.contains('*') {
let full_pattern = root.join(pattern);
match glob::glob(&full_pattern.to_string_lossy()) {
Ok(entries) => {
for entry in entries {
match entry {
Ok(path) => {
let canonical = match path.canonicalize() {
Ok(path) => path,
Err(err) => {
warn!(
"[{section}].config_roots: failed to resolve glob match {}: {err}",
path.display()
);
continue;
}
};
if !canonical.starts_with(&canonical_root) {
warn!(
"[{section}].config_roots: glob matched path outside config root: {}",
path.display()
);
continue;
}
if canonical.is_dir()
&& filenames.is_none_or(|filenames| {
has_mise_config_with_filenames(&canonical, filenames)
})
{
subdirs.push(if canonicalize_results {
canonical
} else {
path
});
}
}
Err(e) => {
warn!("[{section}].config_roots glob error: {e}");
}
}
}
}
Err(e) => {
warn!("[{section}].config_roots invalid glob pattern '{pattern}': {e}");
}
}
} else {
let path = root.join(pattern);
let canonical = match path.canonicalize() {
Ok(path) => path,
Err(_) => {
warn!("[{section}].config_roots: '{}' does not exist", pattern);
continue;
}
};
if !canonical.starts_with(&canonical_root) {
warn!(
"[{section}].config_roots: '{}' resolves outside config root",
pattern
);
continue;
}
if canonical.is_dir() {
if filenames
.is_none_or(|filenames| has_mise_config_with_filenames(&canonical, filenames))
{
subdirs.push(if canonicalize_results {
canonical
} else {
path
});
} else {
warn!(
"[{section}].config_roots: '{}' has no mise config file",
pattern
);
}
} else {
warn!("[{section}].config_roots: '{}' is not a directory", pattern);
}
}
}
if let Some(ctx) = ctx {
subdirs.retain(|dir| {
let rel_path = dir
.strip_prefix(root)
.ok()
.and_then(|p| p.to_str())
.unwrap_or("");
ctx.should_load_subdir(rel_path, root.to_str().unwrap_or(""))
});
}
Ok(subdirs)
}
fn has_mise_config_with_filenames(dir: &Path, filenames: &[String]) -> bool {
has_config_file_with_filenames(dir, filenames)
|| dir.join(".mise/tasks").is_dir()
|| dir.join("mise-tasks").is_dir()
}
fn has_config_file_with_filenames(dir: &Path, filenames: &[String]) -> bool {
filenames.iter().any(|f| {
if is_glob_pattern(f) {
!load_config_glob(dir, f).is_empty()
} else {
dir.join(f).exists()
}
})
}
fn discover_monorepo_subdirs(
root: &Path,
config_roots: Option<&Vec<String>>,
ctx: Option<&crate::task::TaskLoadContext>,
) -> Result<Vec<PathBuf>> {
if let Some(patterns) = config_roots
&& !patterns.is_empty()
{
return expand_config_roots(root, patterns, ctx);
}
deprecated!(
"monorepo_auto_discovery",
"Automatic monorepo discovery is deprecated. \
Please define [monorepo].config_roots in your root mise.toml. \
See https://mise.jdx.dev/tasks/monorepo.html#explicit-config-roots"
);
const DEFAULT_IGNORED_DIRS: &[&str] = &["node_modules", "target", "dist", "build"];
let has_task_includes = |dir: &Path| {
default_task_includes()
.into_iter()
.any(|include| dir.join(include).exists())
};
let mut subdirs = Vec::new();
let settings = Settings::get();
let respect_gitignore = settings.task.monorepo_respect_gitignore;
let max_depth = settings.task.monorepo_depth as usize;
let excluded_dirs: Vec<&str> = if settings.task.monorepo_exclude_dirs.is_empty() {
DEFAULT_IGNORED_DIRS.to_vec()
} else {
settings
.task
.monorepo_exclude_dirs
.iter()
.map(|s| s.as_str())
.collect()
};
if respect_gitignore {
let walker = ignore::WalkBuilder::new(root)
.max_depth(Some(max_depth))
.hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .require_git(false) .build();
for entry in walker {
let entry = entry?;
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
let dir = entry.path();
if dir == root {
continue;
}
let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
if excluded_dirs.contains(&name) {
continue;
}
let has_config = has_config_file_with_filenames(dir, &DEFAULT_CONFIG_FILENAMES);
let has_task_includes = has_task_includes(dir);
if has_config || has_task_includes {
if let Some(ctx) = ctx {
let rel_path = dir
.strip_prefix(root)
.ok()
.and_then(|p| p.to_str())
.unwrap_or("");
if ctx.should_load_subdir(rel_path, root.to_str().unwrap_or("")) {
subdirs.push(dir.to_path_buf());
}
} else {
subdirs.push(dir.to_path_buf());
}
}
}
}
} else {
for entry in WalkDir::new(root)
.min_depth(1)
.max_depth(max_depth)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
!name.starts_with('.') && !excluded_dirs.contains(&name.as_ref())
})
{
let entry = entry?;
if entry.file_type().is_dir() {
let dir = entry.path();
let has_config = has_config_file_with_filenames(dir, &DEFAULT_CONFIG_FILENAMES);
let has_task_includes = has_task_includes(dir);
if has_config || has_task_includes {
if let Some(ctx) = ctx {
let rel_path = dir
.strip_prefix(root)
.ok()
.and_then(|p| p.to_str())
.unwrap_or("");
if ctx.should_load_subdir(rel_path, root.to_str().unwrap_or("")) {
subdirs.push(dir.to_path_buf());
}
} else {
subdirs.push(dir.to_path_buf());
}
}
}
}
}
Ok(subdirs)
}
async fn load_global_tasks(config: &Arc<Config>, templates: &TaskDefinitions) -> Result<Vec<Task>> {
let mut seen_configs = BTreeSet::new();
let mut config_groups = vec![];
for config_paths in [global_config_files(), system_config_files()] {
let configs = config_paths
.iter()
.rev()
.filter_map(|path| {
config.config_files.get(path).or_else(|| {
let path = file::desymlink_path(path);
config
.config_files
.iter()
.find(|(loaded, _)| file::desymlink_path(loaded) == path)
.map(|(_, cf)| cf)
})
})
.filter(|cf| seen_configs.insert(file::desymlink_path(cf.get_path())))
.collect::<Vec<_>>();
if !configs.is_empty() {
config_groups.push(configs);
}
}
let mut tasks: IndexMap<String, Task> = IndexMap::new();
let mut rendered_file_tasks = RenderedTaskCache::default();
for configs in config_groups {
let sources = load_task_sources_from_configs(
config,
&env::MISE_GLOBAL_CONFIG_ROOT,
configs,
templates,
false,
None,
Some(&mut rendered_file_tasks),
)
.await?;
rendered_file_tasks.finish_config();
for task in sources.into_tasks() {
tasks.entry(task.name.clone()).or_insert(task);
}
}
Ok(tasks.into_values().collect())
}
fn merge_file_and_config_tasks(file_tasks: Vec<Task>, config_tasks: Vec<Task>) -> Vec<Task> {
let mut by_name: IndexMap<String, Task> = IndexMap::new();
for t in prefer_windows_file_task_siblings(file_tasks) {
by_name.insert(t.name.clone(), t);
}
let mut seen_config_task_names = BTreeSet::new();
let mut pending_inline_overlays: IndexMap<String, Vec<Task>> = IndexMap::new();
for t in config_tasks {
if !seen_config_task_names.insert(t.name.clone()) {
let has_command = !t.run.is_empty() || !t.run_windows.is_empty() || t.file.is_some();
if pending_inline_overlays.contains_key(&t.name) && has_command {
let overlays = pending_inline_overlays
.shift_remove(&t.name)
.expect("pending inline overlays should be present");
let mut base = t;
for overlay in overlays.into_iter().rev() {
base.merge_toml_overlay(overlay);
}
by_name.insert(base.name.clone(), base);
} else if let Some(overlays) = pending_inline_overlays.get_mut(&t.name) {
overlays.push(t);
}
continue;
}
if let Some(existing) = by_name
.get_mut(&t.name)
.filter(|existing| existing.is_toml_include)
{
if t.config_precedence <= existing.config_precedence {
if t.run.is_empty() && t.run_windows.is_empty() && t.file.is_none() {
existing.merge_toml_overlay(t);
} else {
*existing = t;
}
}
} else if let Some(existing) = by_name.get_mut(&t.name) {
if existing.file.is_some() {
existing.merge_toml_overlay(t);
}
} else {
if t.run.is_empty() && t.run_windows.is_empty() && t.file.is_none() {
pending_inline_overlays
.entry(t.name.clone())
.or_default()
.push(t.clone());
}
by_name.insert(t.name.clone(), t);
}
}
by_name.into_values().collect()
}
fn prefer_windows_file_task_siblings(file_tasks: Vec<Task>) -> Vec<Task> {
if !cfg!(windows) {
return file_tasks;
}
prefer_windows_file_task_siblings_inner(file_tasks)
}
fn prefer_windows_file_task_siblings_inner(file_tasks: Vec<Task>) -> Vec<Task> {
let windows_exts = Settings::get()
.windows_executable_extensions
.iter()
.map(|ext| ext.to_lowercase())
.collect::<IndexSet<_>>();
let is_windows_native = |task: &Task| {
task.config_source
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| windows_exts.contains(&ext.to_lowercase()))
};
let task_key = |task: &Task| {
(
task.config_source.with_extension(""),
strip_task_extension(&task.name).to_string(),
)
};
let posix_task_keys = file_tasks
.iter()
.filter(|task| !is_windows_native(task))
.map(&task_key)
.collect::<IndexSet<_>>();
let mut windows_native_task_key_counts = IndexMap::new();
for task in file_tasks.iter().filter(|task| is_windows_native(task)) {
*windows_native_task_key_counts
.entry(task_key(task))
.or_insert(0) += 1;
}
let windows_takeover_keys = posix_task_keys
.iter()
.filter(|key| windows_native_task_key_counts.get(*key) == Some(&1))
.cloned()
.collect::<IndexSet<_>>();
file_tasks
.into_iter()
.filter_map(|mut task| {
let key = task_key(&task);
if !is_windows_native(&task) {
return if windows_takeover_keys.contains(&key) {
None
} else {
Some(task)
};
}
if windows_takeover_keys.contains(&key) {
task.name = key.1;
}
Some(task)
})
.collect()
}
fn strip_task_extension(name: &str) -> &str {
if let Some((prefix, task_part)) = name.rsplit_once(':') {
let task_without_ext = strip_extension(task_part);
if task_without_ext == task_part {
name
} else {
&name[..prefix.len() + 1 + task_without_ext.len()]
}
} else {
strip_extension(name)
}
}
async fn load_config_tasks(
config: &Arc<Config>,
cf: Arc<dyn ConfigFile>,
config_root: &Path,
templates: &TaskDefinitions,
monorepo_cf: Option<&Arc<dyn ConfigFile>>,
task_config: &ResolvedTaskConfig,
) -> Result<Vec<Task>> {
let is_global = is_global_config(cf.get_path());
let config_root = Arc::new(config_root.to_path_buf());
let mut tasks = vec![];
for t in cf.tasks().into_iter() {
let config_root = config_root.clone();
let config = config.clone();
let mut t = t.clone();
if is_global {
t.global = true;
}
if t.config_root.is_none() {
t.config_root = Some(config_root.to_path_buf());
}
if let Some(monorepo_cf) = monorepo_cf {
t.cf = Some(monorepo_cf.clone());
}
resolve_task_template(&mut t, templates)?;
if t.dir.is_none() {
t.dir = task_config.dir.clone();
}
if t.shell.is_none() {
t.shell = task_config.shell.clone();
}
match t.render(&config, &config_root).await {
Ok(()) => {
apply_task_config_inputs(&mut t, &config, &task_config.inputs).await?;
apply_task_config_cache_default(&mut t, &task_config.cache);
apply_task_config_rust_cache_default(&mut t, &task_config.rust_cache);
task_config.environment.apply(&mut t)?;
tasks.push(t);
}
Err(e) => {
if monorepo_cf.is_some() {
warn!(
"Failed to render task {} in {}: {e:#}. Task will not be available.",
t.name,
display_path(cf.get_path())
);
} else {
return Err(e);
}
}
}
}
Ok(tasks)
}
struct LoadTaskIncludesOptions<'a> {
monorepo_cf: Option<&'a Arc<dyn ConfigFile>>,
require_trust: bool,
rendered_file_tasks: Option<&'a mut RenderedTaskCache>,
excludes: &'a [PathBuf],
}
pub(crate) fn is_task_path_excluded(path: &Path, excludes: &[PathBuf]) -> bool {
Settings::get()
.task
.disable_paths
.iter()
.chain(excludes)
.any(|exclude| file::path_starts_with_resolved(path, exclude))
}
fn collect_task_files(root: &Path, excludes: &[PathBuf]) -> Result<Vec<PathBuf>> {
WalkDir::new(root)
.follow_links(true)
.into_iter()
.filter_entry(|entry| {
entry.path() == root
|| (!entry.file_name().to_string_lossy().starts_with('.')
&& !excludes
.iter()
.any(|exclude| file::path_starts_with_resolved(entry.path(), exclude)))
})
.filter_map(|entry| match entry {
Ok(entry) if entry.file_type().is_file() => Some(Ok(entry.path().to_path_buf())),
Ok(_) => None,
Err(err)
if err
.io_error()
.is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) =>
{
debug!("skipping missing task entry: {err}");
None
}
Err(err) if err.loop_ancestor().is_some() => {
debug!("skipping task path that links back into itself: {err}");
None
}
Err(err) => Some(Err(err)),
})
.try_collect::<_, Vec<PathBuf>, _>()
.map_err(Into::into)
}
async fn load_tasks_includes(
config: &Arc<Config>,
root: &Path,
config_root: &Path,
task_config: &ResolvedTaskConfig,
templates: &TaskDefinitions,
options: LoadTaskIncludesOptions<'_>,
) -> Result<Vec<Task>> {
let LoadTaskIncludesOptions {
monorepo_cf,
require_trust,
mut rendered_file_tasks,
excludes,
} = options;
if is_task_path_excluded(root, excludes) {
return Ok(vec![]);
}
if root.is_file() && root.extension().map(|e| e == "toml").unwrap_or(false) {
trust_check_task_include(root, require_trust)?;
load_task_file(
config,
root,
config_root,
task_config,
templates,
monorepo_cf,
rendered_file_tasks,
)
.await
} else if root.is_dir() {
let all_excludes = Settings::get()
.task
.disable_paths
.iter()
.chain(excludes)
.cloned()
.collect::<Vec<_>>();
let all_files = collect_task_files(root, &all_excludes)?;
let is_toml = |p: &Path| p.extension().map(|e| e == "toml").unwrap_or(false);
let (toml_files, exec_files): (Vec<_>, Vec<_>) = all_files
.into_iter()
.filter(|p| match is_toml(p) {
true => !is_mise_config_file_in_task_include(root, p),
false => file::is_executable(p),
})
.partition(|p| is_toml(p));
let mut tasks = vec![];
for path in toml_files {
trust_check_task_include(&path, require_trust)?;
tasks.extend(
load_task_file(
config,
&path,
config_root,
task_config,
templates,
monorepo_cf,
rendered_file_tasks.as_deref_mut(),
)
.await?,
);
}
let root = Arc::new(root.to_path_buf());
let config_root = Arc::new(config_root.to_path_buf());
for path in exec_files {
let root = root.clone();
let config_root = config_root.clone();
let config = config.clone();
trust_check_task_include(&path, require_trust)?;
let mut task = Task::from_path_unrendered_with_cf(
&path,
&root,
&config_root,
monorepo_cf.cloned(),
)?;
if let Err(err) = resolve_task_template(&mut task, templates) {
if monorepo_cf.is_some() {
warn!(
"Failed to resolve task {} in {}: {err:#}. Task will not be available.",
task.name,
display_path(&path)
);
continue;
} else {
return Err(err);
}
}
let cache_key = rendered_task_cache_key(&task);
if let Some(cached) = rendered_file_tasks
.as_deref()
.and_then(|cache| cache.get(&cache_key))
{
tasks.push(cached.clone());
continue;
}
if task.shell.is_none() {
task.shell = task_config.shell.clone();
}
if let Err(err) = task.render(&config, &config_root).await {
if monorepo_cf.is_some() {
warn!(
"Failed to render task {} in {}: {err:#}. Task will not be available.",
task.name,
display_path(&path)
);
continue;
} else {
return Err(err);
}
}
if task.dir.is_none()
&& let Some(ref dir) = task_config.dir
{
task.dir = Some(if contains_template_syntax(dir) {
let mut tera = crate::tera::get_tera(Some(config_root.as_ref()));
let tera_ctx = task.tera_ctx(&config).await?;
render_str(&mut tera, dir, &tera_ctx)?
} else {
dir.clone()
});
}
if let Some(cache) = rendered_file_tasks.as_deref_mut() {
cache.insert(cache_key, task.clone());
}
tasks.push(task);
}
Ok(tasks)
} else {
Ok(vec![])
}
}
fn is_mise_config_file_in_task_include(root: &Path, path: &Path) -> bool {
let Ok(relative_path) = path.strip_prefix(root) else {
return false;
};
let relative_path = relative_path.to_string_lossy().replace('\\', "/");
let file_name = path
.file_name()
.map(|file_name| file_name.to_string_lossy().replace('\\', "/"));
TOML_CONFIG_MATCHERS.iter().any(|matcher| {
matcher.is_match(&relative_path) || file_name.as_ref().is_some_and(|f| matcher.is_match(f))
})
}
async fn resolve_git_url_to_path(git_url: &str) -> Result<TaskFileArtifact> {
let no_cache = Settings::get().task.remote_no_cache.unwrap_or(false);
if !no_cache {
let task_file_providers = TaskFileProvidersBuilder::new().with_cache(true).build();
return match task_file_providers.get_provider(git_url) {
Some(provider) => provider.get_local_artifact(git_url).await,
None => bail!("No provider found for git URL: {}", git_url),
};
}
let source = RemoteSource::parse_git(git_url)
.ok_or_else(|| eyre!("No provider found for git URL: {}", git_url))?;
let cache_key = (source.url.clone(), source.git_ref.clone());
let checkout = REMOTE_TASK_INCLUDE_ARTIFACTS
.entry(cache_key)
.or_insert_with(|| Arc::new(OnceCell::new()))
.clone();
let checkout = checkout
.get_or_try_init(|| async {
let task_file_providers = TaskFileProvidersBuilder::new().with_cache(false).build();
let provider = task_file_providers
.get_provider(git_url)
.ok_or_else(|| eyre!("No provider found for git URL: {}", git_url))?;
let artifact = provider.get_local_artifact(git_url).await?;
let checkout_path = artifact
.cleanup_path()
.ok_or_else(|| eyre!("no cleanup path for no-cache Git task include"))?
.to_path_buf();
Ok::<TaskFileArtifact, eyre::Report>(artifact.with_path(checkout_path))
})
.await?;
let artifact = checkout.with_path(checkout.path.join(source.path));
validate_remote_git_path(&checkout.path, &artifact.path)?;
Ok(artifact)
}
fn is_glob_pattern(pattern: &str) -> bool {
pattern.contains('*')
|| pattern.contains('?')
|| pattern.contains('[')
|| pattern.contains(']')
|| pattern.contains('{')
|| pattern.contains('}')
}
fn expand_task_include(dir: &Path, pattern: &str) -> Vec<PathBuf> {
let pattern = file::replace_path(pattern);
let pattern = pattern.to_string_lossy();
if is_glob_pattern(&pattern) {
match glob(dir, &pattern) {
Ok(paths) => paths,
Err(err) => {
warn!(
"failed to expand glob pattern '{}' in '{}': {}",
pattern,
display_path(dir),
err
);
vec![]
}
}
} else {
let path = PathBuf::from(&*pattern);
let resolved = if path.is_absolute() {
path
} else {
dir.join(path)
};
if resolved.exists() {
vec![resolved]
} else {
vec![]
}
}
}
fn resolve_task_excludes(dir: &Path, patterns: &[String]) -> Vec<PathBuf> {
patterns
.iter()
.flat_map(|pattern| {
if is_glob_pattern(pattern) {
expand_task_include(dir, pattern)
} else {
let path = file::replace_path(pattern);
if path.is_absolute() {
vec![path]
} else {
vec![dir.join(path)]
}
}
})
.unique()
.collect()
}
fn task_include_patterns_for_dir(
dir: &Path,
config_files: &ConfigMap,
) -> Result<(Vec<String>, PathBuf, bool)> {
let configs = configs_at_root(dir, config_files);
let cascaded_task_config =
if configs.iter().find_map(|cf| cf.task_config().cascade) == Some(false) {
None
} else {
cascaded_task_config_for_dir(dir, config_files)?
};
Ok(configs
.iter()
.find_map(|cf| match cf.task_config_includes() {
Ok(Some(includes)) => Some(Ok({
(includes, cf.config_root(), false)
})),
Ok(None) => None,
Err(err) => Some(Err(err)),
})
.transpose()?
.or_else(|| {
cascaded_task_config.and_then(|tc| {
tc.task_config
.includes
.map(|includes| (includes, tc.includes_root, false))
})
})
.unwrap_or_else(|| {
(default_task_includes(), dir.to_path_buf(), true)
}))
}
pub(crate) fn task_includes_for_dir(dir: &Path, config_files: &ConfigMap) -> Result<Vec<PathBuf>> {
let (includes, resolve_dir, _) = task_include_patterns_for_dir(dir, config_files)?;
Ok(includes
.into_iter()
.flat_map(|p| {
if p.starts_with("git::") {
return vec![];
}
expand_task_include(&resolve_dir, &p)
})
.unique()
.collect::<Vec<_>>())
}
pub(crate) fn task_excludes_for_dir(dir: &Path, config_files: &ConfigMap) -> Result<Vec<PathBuf>> {
let configs = configs_at_root(dir, config_files);
let cascaded_task_config =
if configs.iter().find_map(|cf| cf.task_config().cascade) == Some(false) {
None
} else {
cascaded_task_config_for_dir(dir, config_files)?
};
let (excludes, resolve_dir) = configs
.iter()
.find_map(|cf| match cf.task_config_excludes() {
Ok(Some(excludes)) => Some(Ok((excludes, cf.config_root()))),
Ok(None) => None,
Err(err) => Some(Err(err)),
})
.transpose()?
.or_else(|| {
cascaded_task_config.and_then(|tc| {
tc.task_config
.excludes
.map(|excludes| (excludes, tc.excludes_root))
})
})
.unwrap_or_default();
Ok(resolve_task_excludes(&resolve_dir, &excludes))
}
pub(crate) fn task_creation_dir_for_dir(dir: &Path, config_files: &ConfigMap) -> Result<PathBuf> {
let (includes, resolve_dir, uses_defaults) = task_include_patterns_for_dir(dir, config_files)?;
let default_create_dir = if uses_defaults {
includes
.first()
.map(|include| resolve_dir.join(file::replace_path(include)))
} else {
None
};
if let Some(path) = includes
.iter()
.filter(|include| !include.starts_with("git::"))
.flat_map(|include| expand_task_include(&resolve_dir, include))
.find(|path| path.is_dir())
{
return Ok(path);
}
if let Some(dir) = default_create_dir {
return Ok(dir);
}
bail!("task includes do not contain an existing directory where a file task can be created")
}
pub(crate) async fn load_tasks_in_dir(
config: &Arc<Config>,
dir: &Path,
config_files: &ConfigMap,
) -> Result<Vec<Task>> {
let workspace_graph = (Settings::get().experimental && config.monorepo_root().is_some())
.then(|| config.workspace_project_graph_for_task_loading());
let definitions = collect_task_definitions(
config_files,
workspace_graph
.as_ref()
.and_then(|graph| graph.as_ref().ok())
.map(Arc::as_ref),
);
load_tasks_in_dir_with_definitions(config, dir, config_files, &definitions).await
}
async fn load_tasks_in_dir_with_definitions(
config: &Arc<Config>,
dir: &Path,
config_files: &ConfigMap,
templates: &TaskDefinitions,
) -> Result<Vec<Task>> {
let configs = configs_at_root(dir, config_files);
let cascaded_task_config = cascaded_task_config_for_dir(dir, config_files)?;
load_tasks_from_configs(
config,
dir,
configs,
templates,
false,
cascaded_task_config.as_ref(),
)
.await
}
struct TaskSources {
file_tasks: Vec<Task>,
config_tasks: Vec<Task>,
}
#[derive(Clone)]
struct CascadedTaskConfig {
task_config: TaskConfig,
inputs: ResolvedTaskInputs,
includes_root: PathBuf,
excludes_root: PathBuf,
}
fn merge_cascaded_task_config(
cascaded: &mut CascadedTaskConfig,
configs: &[&Arc<dyn ConfigFile>],
) -> Result<()> {
cascaded
.inputs
.overlay(ResolvedTaskInputs::from_configs(configs));
if let Some(dir) = configs.iter().find_map(|cf| cf.task_config().dir.clone()) {
cascaded.task_config.dir = Some(dir);
}
if let Some(shell) = configs.iter().find_map(|cf| cf.task_config().shell.clone()) {
cascaded.task_config.shell = Some(shell);
}
if let Some(cache) = configs.iter().find_map(|cf| cf.task_config().cache.clone()) {
cascaded.task_config.cache = Some(cache);
}
if let Some(rust_cache) = configs
.iter()
.find_map(|cf| cf.task_config().rust_cache.clone())
{
cascaded.task_config.rust_cache = Some(rust_cache);
}
if let Some(global_env) = configs.iter().find_map(|cf| {
let env = &cf.task_config().global_env;
(!env.is_empty()).then(|| env.clone())
}) {
cascaded.task_config.global_env = global_env;
}
if let Some(global_pass_through_env) = configs.iter().find_map(|cf| {
let env = &cf.task_config().global_pass_through_env;
(!env.is_empty()).then(|| env.clone())
}) {
cascaded.task_config.global_pass_through_env = global_pass_through_env;
}
if let Some((includes, root)) = configs
.iter()
.find_map(|cf| match cf.task_config_includes() {
Ok(Some(includes)) => Some(Ok((includes, cf.config_root()))),
Ok(None) => None,
Err(err) => Some(Err(err)),
})
.transpose()?
{
cascaded.task_config.includes = Some(includes);
cascaded.includes_root = root;
}
if let Some((excludes, root)) = configs
.iter()
.find_map(|cf| match cf.task_config_excludes() {
Ok(Some(excludes)) => Some(Ok((excludes, cf.config_root()))),
Ok(None) => None,
Err(err) => Some(Err(err)),
})
.transpose()?
{
cascaded.task_config.excludes = Some(excludes);
cascaded.excludes_root = root;
}
Ok(())
}
fn cascaded_task_config_for_dir(
dir: &Path,
config_files: &ConfigMap,
) -> Result<Option<CascadedTaskConfig>> {
let mut roots = config_files
.values()
.filter(|cf| !is_global_config(cf.get_path()))
.map(|cf| cf.config_root())
.filter(|root| !file::same_file(root, dir) && file::path_starts_with_resolved(dir, root))
.unique()
.collect::<Vec<_>>();
roots.sort_by_key(|root| root.components().count());
let mut cascaded = None;
for root in roots {
let configs = configs_at_root(&root, config_files);
match configs.iter().find_map(|cf| cf.task_config().cascade) {
Some(false) => {
cascaded = None;
continue;
}
Some(true) if cascaded.is_none() => {
cascaded = Some(CascadedTaskConfig {
task_config: TaskConfig::default(),
inputs: ResolvedTaskInputs::default(),
includes_root: root.clone(),
excludes_root: root,
});
}
_ => {}
}
if let Some(cascaded) = &mut cascaded {
merge_cascaded_task_config(cascaded, &configs)?;
}
}
Ok(cascaded)
}
#[derive(Default)]
struct RenderedTaskCache {
previous_configs: HashMap<(PathBuf, String), Task>,
current_config: HashMap<(PathBuf, String), Task>,
}
fn rendered_task_cache_key(task: &Task) -> (PathBuf, String) {
(file::desymlink_path(&task.config_source), task.name.clone())
}
impl RenderedTaskCache {
fn get(&self, key: &(PathBuf, String)) -> Option<&Task> {
self.previous_configs.get(key)
}
fn insert(&mut self, key: (PathBuf, String), task: Task) {
self.current_config.insert(key, task);
}
fn finish_config(&mut self) {
for (key, task) in self.current_config.drain() {
self.previous_configs.entry(key).or_insert(task);
}
}
}
impl TaskSources {
fn into_tasks(self) -> Vec<Task> {
let mut tasks = merge_file_and_config_tasks(self.file_tasks, self.config_tasks)
.into_iter()
.sorted_by_cached_key(|t| t.name.clone())
.collect::<Vec<_>>();
let all_tasks = tasks
.clone()
.into_iter()
.map(|t| (t.name.clone(), t))
.collect::<BTreeMap<_, _>>();
for task in tasks.iter_mut() {
task.display_name = task.display_name(&all_tasks);
}
tasks
}
}
async fn load_tasks_from_configs(
config: &Arc<Config>,
dir: &Path,
configs: Vec<&Arc<dyn ConfigFile>>,
templates: &TaskDefinitions,
monorepo_context: bool,
cascaded_task_config: Option<&CascadedTaskConfig>,
) -> Result<Vec<Task>> {
Ok(load_task_sources_from_configs(
config,
dir,
configs,
templates,
monorepo_context,
cascaded_task_config,
None,
)
.await?
.into_tasks())
}
async fn load_task_sources_from_configs(
config: &Arc<Config>,
dir: &Path,
configs: Vec<&Arc<dyn ConfigFile>>,
templates: &TaskDefinitions,
monorepo_context: bool,
cascaded_task_config: Option<&CascadedTaskConfig>,
mut rendered_file_tasks: Option<&mut RenderedTaskCache>,
) -> Result<TaskSources> {
let cascaded_task_config =
if configs.iter().find_map(|cf| cf.task_config().cascade) == Some(false) {
None
} else {
cascaded_task_config
};
let is_global = configs.iter().any(|cf| is_global_config(cf.get_path()));
let require_task_include_trust = !configs.iter().any(|cf| is_path_trusted(cf.get_path()));
let (includes, resolve_dir, include_config_precedence) = configs
.iter()
.enumerate()
.find_map(|(precedence, cf)| match cf.task_config_includes() {
Ok(Some(includes)) => Some(Ok((includes, cf.config_root(), precedence))),
Ok(None) => None,
Err(err) => Some(Err(err)),
})
.transpose()?
.or_else(|| {
cascaded_task_config.and_then(|tc| {
tc.task_config
.includes
.clone()
.map(|includes| (includes, tc.includes_root.clone(), configs.len()))
})
})
.unwrap_or_else(|| (default_task_includes(), dir.to_path_buf(), configs.len()));
let (excludes, excludes_root) = configs
.iter()
.find_map(|cf| match cf.task_config_excludes() {
Ok(Some(excludes)) => Some(Ok((excludes, cf.config_root()))),
Ok(None) => None,
Err(err) => Some(Err(err)),
})
.transpose()?
.or_else(|| {
cascaded_task_config.and_then(|tc| {
tc.task_config
.excludes
.clone()
.map(|excludes| (excludes, tc.excludes_root.clone()))
})
})
.unwrap_or_default();
let excludes = resolve_task_excludes(&excludes_root, &excludes);
let mut inputs = cascaded_task_config
.map(|tc| tc.inputs.clone())
.unwrap_or_default();
inputs.overlay(ResolvedTaskInputs::from_configs(&configs));
let task_config = ResolvedTaskConfig {
inputs,
environment: ResolvedTaskEnvironment::from_configs(
&configs,
cascaded_task_config.map(|tc| &tc.task_config),
),
dir: configs
.iter()
.find_map(|cf| cf.task_config().dir.clone())
.or_else(|| cascaded_task_config.and_then(|tc| tc.task_config.dir.clone())),
shell: configs
.iter()
.find_map(|cf| cf.task_config().shell.clone())
.or_else(|| cascaded_task_config.and_then(|tc| tc.task_config.shell.clone())),
cache: configs
.iter()
.find_map(|cf| cf.task_config().cache.clone())
.or_else(|| cascaded_task_config.and_then(|tc| tc.task_config.cache.clone())),
rust_cache: configs
.iter()
.find_map(|cf| cf.task_config().rust_cache.clone())
.or_else(|| cascaded_task_config.and_then(|tc| tc.task_config.rust_cache.clone())),
};
let mut config_tasks = vec![];
for (precedence, cf) in configs.iter().enumerate() {
let dir = dir.to_path_buf();
let monorepo_cf = monorepo_context.then_some(*cf);
let mut loaded = load_config_tasks(
config,
(*cf).clone(),
&dir,
templates,
monorepo_cf,
&task_config,
)
.await?;
for task in &mut loaded {
task.config_precedence = precedence;
}
config_tasks.extend(loaded);
}
let mut file_tasks = vec![];
let monorepo_cf = if monorepo_context {
configs.first().copied()
} else {
None
};
for include in &includes {
let artifacts = if include.starts_with("git::") {
vec![resolve_git_url_to_path(include).await?]
} else {
expand_task_include(&resolve_dir, include)
.into_iter()
.map(TaskFileArtifact::persistent)
.collect()
};
for artifact in artifacts {
let p = artifact.path;
let mut loaded = load_tasks_includes(
config,
&p,
dir,
&task_config,
templates,
LoadTaskIncludesOptions {
monorepo_cf,
require_trust: require_task_include_trust,
rendered_file_tasks: rendered_file_tasks.as_deref_mut(),
excludes: &excludes,
},
)
.await?;
for task in &mut loaded {
if task.is_toml_include {
task.config_precedence = include_config_precedence;
}
apply_task_config_inputs(task, config, &task_config.inputs).await?;
apply_task_config_cache_default(task, &task_config.cache);
apply_task_config_rust_cache_default(task, &task_config.rust_cache);
task_config.environment.apply(task)?;
}
if is_global || is_global_task_include_path(&p) {
mark_tasks_as_global(&mut loaded);
}
file_tasks.extend(loaded);
}
}
Ok(TaskSources {
file_tasks,
config_tasks,
})
}
fn trust_check_task_include(path: &Path, require_trust: bool) -> Result<()> {
if require_trust && !is_global_task_include_path(path) && task_include_requires_trust(path) {
trust_check(path)?;
}
Ok(())
}
fn task_include_requires_trust(path: &Path) -> bool {
if Settings::try_get().is_ok_and(|settings| settings.paranoid) {
return true;
}
let Ok(body) = file::read_to_string(path) else {
return true;
};
contains_template_syntax(&body) || crate::task::file_has_decoded_template(path, &body)
}
async fn load_task_file(
config: &Arc<Config>,
path: &Path,
config_root: &Path,
task_config: &ResolvedTaskConfig,
templates: &TaskDefinitions,
monorepo_cf: Option<&Arc<dyn ConfigFile>>,
mut rendered_file_tasks: Option<&mut RenderedTaskCache>,
) -> Result<Vec<Task>> {
let raw = file::read_to_string_async(path).await?;
let mut tasks = toml::from_str::<Tasks>(&raw)
.wrap_err_with(|| format!("Error parsing task file: {}", display_path(path)))?
.0;
for (name, task) in &mut tasks {
task.name = name.clone();
task.config_source = path.to_path_buf();
task.config_root = Some(config_root.to_path_buf());
task.is_toml_include = true;
if let Some(monorepo_cf) = monorepo_cf {
task.cf = Some(monorepo_cf.clone());
}
}
let mut out = vec![];
for (_, mut task) in tasks {
let config_root = config_root.to_path_buf();
resolve_task_template(&mut task, templates)?;
let cache_key = rendered_task_cache_key(&task);
if let Some(cached) = rendered_file_tasks
.as_deref()
.and_then(|cache| cache.get(&cache_key))
{
out.push(cached.clone());
continue;
}
if task.dir.is_none() {
task.dir = task_config.dir.clone();
}
if task.shell.is_none() {
task.shell = task_config.shell.clone();
}
match task.render(config, &config_root).await {
Ok(()) => {
if let Some(cache) = rendered_file_tasks.as_deref_mut() {
cache.insert(cache_key, task.clone());
}
out.push(task);
}
Err(err) => {
if monorepo_cf.is_some() {
warn!(
"Failed to render task {} in {}: {err:#}. Task will not be available.",
task.name,
display_path(path)
);
} else {
warn!("rendering task: {err:?}");
if let Some(cache) = rendered_file_tasks.as_deref_mut() {
cache.insert(cache_key, task.clone());
}
out.push(task);
}
}
}
}
Ok(out)
}
fn mark_tasks_as_global(tasks: &mut [Task]) {
tasks.iter_mut().for_each(|task| task.global = true);
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use insta::assert_debug_snapshot;
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, File};
use tempfile::TempDir;
use super::*;
#[test]
fn test_resolve_task_template_tracks_definition_sources() -> Result<()> {
let project_root = PathBuf::from("/workspace/packages/app");
let template_source = PathBuf::from("/workspace/mise.toml");
let defaults_source = PathBuf::from("/workspace/mise.local.toml");
let mut definitions = TaskDefinitions::default();
definitions.templates.insert(
"build-template".to_string(),
SourcedTaskTemplate {
template: TaskTemplate {
run: vec![RunEntry::Script("echo build".to_string())],
..Default::default()
},
source: template_source.clone(),
},
);
definitions.workspace_defaults = Some(WorkspaceTaskDefaults {
project_roots: BTreeSet::from([project_root.clone()]),
tasks: IndexMap::from([(
"build".to_string(),
TaskTemplate {
description: "default description".to_string(),
..Default::default()
},
)]),
source: defaults_source.clone(),
});
let primary_source = project_root.join("mise.toml");
let mut task = Task {
name: "build".to_string(),
extends: Some("build-template".to_string()),
config_source: primary_source.clone(),
config_root: Some(project_root),
..Default::default()
};
resolve_task_template(&mut task, &definitions)?;
assert_eq!(
task.config_sources(),
vec![
primary_source.as_path(),
template_source.as_path(),
defaults_source.as_path(),
]
);
Ok(())
}
#[test]
fn test_task_config_rust_cache_is_a_default() {
let rust_default = Some(TaskRustCacheConfig::default());
let mut inherited = Task::default();
apply_task_config_rust_cache_default(&mut inherited, &rust_default);
assert_eq!(inherited.rust_cache, rust_default);
let mut disabled = Task {
rust_cache: Some(TaskRustCacheConfig { enabled: false }),
..Default::default()
};
apply_task_config_rust_cache_default(&mut disabled, &rust_default);
assert_eq!(
disabled.rust_cache,
Some(TaskRustCacheConfig { enabled: false })
);
}
#[test]
fn test_collect_task_files_skips_dangling_symlinks() -> Result<()> {
let tmp = TempDir::new()?;
let task = tmp.path().join("task");
fs::write(&task, "echo ok")?;
std::os::unix::fs::symlink("missing", tmp.path().join("dangling"))?;
assert_eq!(collect_task_files(tmp.path(), &[])?, vec![task]);
Ok(())
}
#[test]
fn test_collect_task_files_prunes_excluded_directories() -> Result<()> {
let tmp = TempDir::new()?;
let excluded = tmp.path().join("excluded");
fs::create_dir(&excluded)?;
fs::write(excluded.join("pyproject.toml"), "[project]")?;
let task = tmp.path().join("task");
fs::write(&task, "echo ok")?;
assert_eq!(
collect_task_files(tmp.path(), std::slice::from_ref(&excluded))?,
vec![task]
);
Ok(())
}
#[test]
fn test_collect_task_files_follows_valid_directory_symlinks() -> Result<()> {
let tmp = TempDir::new()?;
let target = tmp.path().join("target");
fs::create_dir(&target)?;
fs::write(target.join("task"), "echo ok")?;
let root = tmp.path().join("root");
fs::create_dir(&root)?;
std::os::unix::fs::symlink(&target, root.join("linked"))?;
assert_eq!(
collect_task_files(&root, &[])?,
vec![root.join("linked/task")]
);
Ok(())
}
#[test]
fn test_collect_task_files_skips_links_back_into_the_walk() -> Result<()> {
let tmp = TempDir::new()?;
let root = tmp.path().join("tasks");
fs::create_dir(&root)?;
let task = root.join("healthy");
fs::write(&task, "echo ok")?;
let before = collect_task_files(&root, &[])?;
assert_eq!(before, vec![task]);
std::os::unix::fs::symlink(&root, root.join("current"))?;
assert_eq!(collect_task_files(&root, &[])?, before);
Ok(())
}
#[test]
fn test_collect_task_files_preserves_unresolvable_symlink_errors() -> Result<()> {
let tmp = TempDir::new()?;
std::os::unix::fs::symlink("loop", tmp.path().join("loop"))?;
assert!(collect_task_files(tmp.path(), &[]).is_err());
Ok(())
}
#[tokio::test]
async fn test_load() {
let config = Config::reset().await.unwrap();
assert_debug_snapshot!(config);
}
#[tokio::test]
async fn test_reset_reloads_settings() {
Settings::reset(None);
let before = Settings::get();
Config::reset().await.unwrap();
let after = Settings::get();
assert!(!Arc::ptr_eq(&before, &after));
Settings::reset(None);
}
#[test]
fn test_expand_task_inputs_supports_nested_groups() -> Result<()> {
let task_inputs = ResolvedTaskInputs {
input_groups: IndexMap::from([
(
"shared".to_string(),
(
vec![
"Cargo.toml".to_string(),
"src/**/*.rs".to_string(),
"\\!important.txt".to_string(),
],
PathBuf::from("/workspace"),
),
),
(
"production".to_string(),
(
vec![
"@group:shared".to_string(),
"!src/**/*_test.rs".to_string(),
"src/**/*.rs".to_string(),
],
PathBuf::from("/project"),
),
),
]),
..Default::default()
};
let root = Path::new("/workspace");
assert_eq!(
expand_task_inputs(
&["local.txt".to_string(), "@group:production".to_string()],
&task_inputs,
root,
"build",
&mut vec![],
false,
)?,
vec![
"local.txt",
"/workspace/Cargo.toml",
"/workspace/src/**/*.rs",
"/workspace/!important.txt",
"!/project/src/**/*_test.rs",
"/project/src/**/*.rs",
]
);
Ok(())
}
#[test]
fn test_expand_task_inputs_rejects_unknown_and_cyclic_groups() {
let task_inputs = ResolvedTaskInputs {
input_groups: IndexMap::from([
(
"a".to_string(),
(vec!["@group:b".to_string()], PathBuf::from("/workspace")),
),
(
"b".to_string(),
(vec!["@group:a".to_string()], PathBuf::from("/workspace")),
),
]),
..Default::default()
};
let unknown = expand_task_inputs(
&["@group:missing".to_string()],
&task_inputs,
Path::new("/workspace"),
"build",
&mut vec![],
false,
)
.unwrap_err();
assert!(
unknown
.to_string()
.contains("undefined input group \"missing\"")
);
let cycle = expand_task_inputs(
&["@group:a".to_string()],
&task_inputs,
Path::new("/workspace"),
"build",
&mut vec![],
false,
)
.unwrap_err();
assert!(cycle.to_string().contains("a -> b -> a"));
}
#[test]
fn test_dir_is_in_enclosing_monorepo() {
let selected = Path::new("/repo/.worktrees/wt");
let enclosing = vec![PathBuf::from("/repo")];
assert!(dir_is_in_enclosing_monorepo(
Path::new("/repo"),
selected,
&enclosing
));
assert!(dir_is_in_enclosing_monorepo(
Path::new("/repo/.worktrees"),
selected,
&enclosing
));
assert!(!dir_is_in_enclosing_monorepo(
selected, selected, &enclosing
));
assert!(!dir_is_in_enclosing_monorepo(
Path::new("/repo/.worktrees/wt/packages/api"),
selected,
&enclosing
));
assert!(!dir_is_in_enclosing_monorepo(
Path::new("/"),
selected,
&enclosing
));
assert!(!dir_is_in_enclosing_monorepo(
Path::new("/home/user"),
selected,
&enclosing
));
assert!(!dir_is_in_enclosing_monorepo(
Path::new("/repo"),
selected,
&[]
));
}
#[test]
#[cfg(unix)]
fn test_dir_is_in_enclosing_monorepo_across_symlinked_prefix() {
let tmp = TempDir::new().unwrap();
let real = tmp.path().join("real");
let selected = real.join("repo/.worktrees/wt");
fs::create_dir_all(&selected).unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let enclosing = vec![real.join("repo")];
let dir = link.join("repo");
assert!(
!dir.starts_with(&enclosing[0]),
"raw check should miss here"
);
assert!(dir_is_in_enclosing_monorepo(&dir, &selected, &enclosing));
let dir = link.join("repo/.worktrees/wt");
assert!(!dir_is_in_enclosing_monorepo(&dir, &selected, &enclosing));
}
#[test]
fn test_config_set_contains_matches_symlinked_prefix() {
let tmp = TempDir::new().unwrap();
let real_dir = tmp.path().join("real");
fs::create_dir_all(&real_dir).unwrap();
let real_file = real_dir.join("config.toml");
fs::write(&real_file, "").unwrap();
let link_dir = tmp.path().join("link");
std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
let aliased_file = link_dir.join("config.toml");
let mut set = IndexSet::new();
set.insert(real_file.clone());
assert!(config_set_contains(&set, &real_file));
assert!(config_set_contains(&set, &aliased_file));
assert!(!config_set_contains(&set, &real_dir.join("other.toml")));
}
#[test]
fn test_has_mise_config_with_glob_filenames() -> Result<()> {
let tmp = TempDir::new()?;
for pattern in [
".config/mise/conf.d/*.toml",
".mise/conf.d/*.toml",
"mise/conf.d/*.toml",
] {
let confd = tmp.path().join(pattern.trim_end_matches("/*.toml"));
fs::create_dir_all(&confd)?;
fs::write(confd.join("tools.toml"), "[tools]\n")?;
assert!(has_mise_config_with_filenames(
tmp.path(),
&[pattern.to_string()]
));
}
Ok(())
}
#[test]
fn test_project_visible_mise_conf_d_precedence() -> Result<()> {
let tmp = TempDir::new()?;
let confd = tmp.path().join("mise/conf.d");
fs::create_dir_all(&confd)?;
fs::write(confd.join("01-base.toml"), "[env]\nORDER = 'base'\n")?;
fs::write(
confd.join("02-override.toml"),
"[env]\nORDER = 'override'\n",
)?;
fs::write(confd.join(".hidden.toml"), "[env]\nORDER = 'hidden'\n")?;
fs::write(
tmp.path().join("mise/config.toml"),
"[env]\nORDER = 'config'\n",
)?;
let filenames = vec![
"mise/conf.d/*.toml".to_string(),
"mise/config.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/config.toml"),
Path::new("mise/conf.d/02-override.toml"),
Path::new("mise/conf.d/01-base.toml"),
]
);
Ok(())
}
#[test]
fn test_project_conf_d_dotted_fragments_remain_unconditional() -> 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()];
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/05-ci.ci.toml"),
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")
));
Ok(())
}
#[test]
fn test_prefer_windows_file_task_siblings_keeps_windows_native_script() {
let file_tasks = vec![
Task {
name: "pkl:gen".to_string(),
config_source: PathBuf::from("mise-tasks/pkl/gen"),
..Default::default()
},
Task {
name: "pkl:gen.ps1".to_string(),
config_source: PathBuf::from("mise-tasks/pkl/gen.ps1"),
..Default::default()
},
];
let tasks = prefer_windows_file_task_siblings_inner(file_tasks);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "pkl:gen");
assert_eq!(
tasks[0].config_source,
PathBuf::from("mise-tasks/pkl/gen.ps1")
);
}
#[test]
fn test_prefer_windows_file_task_siblings_ignores_non_windows_extension() {
let file_tasks = vec![
Task {
name: "hello".to_string(),
config_source: PathBuf::from("mise-tasks/hello"),
..Default::default()
},
Task {
name: "hello.sh".to_string(),
config_source: PathBuf::from("mise-tasks/hello.sh"),
..Default::default()
},
];
let names = prefer_windows_file_task_siblings_inner(file_tasks)
.into_iter()
.map(|task| task.name)
.collect_vec();
assert_eq!(names, vec!["hello", "hello.sh"]);
}
#[test]
fn test_prefer_windows_file_task_siblings_preserves_toml_overlay_stem() {
let file_tasks = vec![
Task {
name: "hello".to_string(),
config_source: PathBuf::from("mise-tasks/hello"),
file: Some(PathBuf::from("mise-tasks/hello")),
..Default::default()
},
Task {
name: "hello.ps1".to_string(),
config_source: PathBuf::from("mise-tasks/hello.ps1"),
file: Some(PathBuf::from("mise-tasks/hello.ps1")),
..Default::default()
},
];
let config_tasks = vec![Task {
name: "hello".to_string(),
description: "windows task metadata".to_string(),
..Default::default()
}];
let tasks = merge_file_and_config_tasks(
prefer_windows_file_task_siblings_inner(file_tasks),
config_tasks,
);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "hello");
assert_eq!(
tasks[0].config_source,
PathBuf::from("mise-tasks/hello.ps1")
);
assert_eq!(tasks[0].description, "windows task metadata");
}
#[test]
fn test_prefer_windows_file_task_siblings_keeps_exact_stem_for_matching() {
use crate::task::GetMatchingExt;
let file_tasks = vec![
Task {
name: "hello".to_string(),
config_source: PathBuf::from("mise-tasks/hello"),
..Default::default()
},
Task {
name: "hello.ps1".to_string(),
config_source: PathBuf::from("mise-tasks/hello.ps1"),
..Default::default()
},
Task {
name: "hello.sh".to_string(),
config_source: PathBuf::from("mise-tasks/hello.sh"),
..Default::default()
},
];
let tasks = prefer_windows_file_task_siblings_inner(file_tasks)
.into_iter()
.map(|task| (task.name.clone(), task))
.collect::<BTreeMap<_, _>>();
let matches = tasks.get_matching("hello").unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(
matches[0].config_source,
PathBuf::from("mise-tasks/hello.ps1")
);
}
#[test]
fn test_prefer_windows_file_task_siblings_keeps_ambiguous_windows_siblings() {
let file_tasks = vec![
Task {
name: "hello".to_string(),
config_source: PathBuf::from("mise-tasks/hello"),
..Default::default()
},
Task {
name: "hello.ps1".to_string(),
config_source: PathBuf::from("mise-tasks/hello.ps1"),
..Default::default()
},
Task {
name: "hello.cmd".to_string(),
config_source: PathBuf::from("mise-tasks/hello.cmd"),
..Default::default()
},
];
let tasks = prefer_windows_file_task_siblings_inner(file_tasks);
let task_names = tasks.iter().map(|task| task.name.as_str()).collect_vec();
let task_sources = tasks
.iter()
.map(|task| task.config_source.as_path())
.collect_vec();
assert_eq!(task_names, vec!["hello", "hello.ps1", "hello.cmd"]);
assert_eq!(
task_sources,
vec![
Path::new("mise-tasks/hello"),
Path::new("mise-tasks/hello.ps1"),
Path::new("mise-tasks/hello.cmd"),
]
);
}
#[test]
fn test_prefer_windows_file_task_siblings_scopes_to_source_family() {
let file_tasks = vec![
Task {
name: "build".to_string(),
config_source: PathBuf::from("included-tasks/build"),
..Default::default()
},
Task {
name: "build.ps1".to_string(),
config_source: PathBuf::from("mise-tasks/build.ps1"),
..Default::default()
},
];
let tasks = prefer_windows_file_task_siblings_inner(file_tasks);
let task_names = tasks.iter().map(|task| task.name.as_str()).collect_vec();
let task_sources = tasks
.iter()
.map(|task| task.config_source.as_path())
.collect_vec();
assert_eq!(task_names, vec!["build", "build.ps1"]);
assert_eq!(
task_sources,
vec![
Path::new("included-tasks/build"),
Path::new("mise-tasks/build.ps1"),
]
);
}
#[test]
fn test_prefer_windows_file_task_siblings_takes_over_a_posix_extension() {
let file_tasks = vec![
Task {
name: "build.sh".to_string(),
config_source: PathBuf::from("mise-tasks/build.sh"),
..Default::default()
},
Task {
name: "build.ps1".to_string(),
config_source: PathBuf::from("mise-tasks/build.ps1"),
..Default::default()
},
];
let tasks = prefer_windows_file_task_siblings_inner(file_tasks);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "build");
assert_eq!(
tasks[0].config_source,
PathBuf::from("mise-tasks/build.ps1")
);
}
#[test]
fn test_prefer_windows_file_task_siblings_keeps_a_dotted_task_name() {
let file_tasks = vec![
Task {
name: "build.release.sh".to_string(),
config_source: PathBuf::from("mise-tasks/build.release.sh"),
..Default::default()
},
Task {
name: "build.release.ps1".to_string(),
config_source: PathBuf::from("mise-tasks/build.release.ps1"),
..Default::default()
},
];
let tasks = prefer_windows_file_task_siblings_inner(file_tasks);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "build.release");
assert_eq!(
tasks[0].config_source,
PathBuf::from("mise-tasks/build.release.ps1")
);
}
#[test]
fn test_prefer_windows_file_task_siblings_keeps_a_lone_posix_extension() {
let file_tasks = vec![Task {
name: "build.sh".to_string(),
config_source: PathBuf::from("mise-tasks/build.sh"),
..Default::default()
}];
let tasks = prefer_windows_file_task_siblings_inner(file_tasks);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "build.sh");
assert_eq!(tasks[0].config_source, PathBuf::from("mise-tasks/build.sh"));
}
#[test]
fn test_prefer_windows_file_task_siblings_keeps_two_posix_files() {
let file_tasks = vec![
Task {
name: "build".to_string(),
config_source: PathBuf::from("mise-tasks/build"),
..Default::default()
},
Task {
name: "build.sh".to_string(),
config_source: PathBuf::from("mise-tasks/build.sh"),
..Default::default()
},
];
let tasks = prefer_windows_file_task_siblings_inner(file_tasks);
assert_eq!(tasks.len(), 2);
}
#[test]
fn test_prefer_windows_file_task_siblings_keeps_posix_when_windows_is_ambiguous() {
let file_tasks = vec![
Task {
name: "build.sh".to_string(),
config_source: PathBuf::from("mise-tasks/build.sh"),
..Default::default()
},
Task {
name: "build.ps1".to_string(),
config_source: PathBuf::from("mise-tasks/build.ps1"),
..Default::default()
},
Task {
name: "build.cmd".to_string(),
config_source: PathBuf::from("mise-tasks/build.cmd"),
..Default::default()
},
];
let tasks = prefer_windows_file_task_siblings_inner(file_tasks);
assert_eq!(tasks.len(), 3);
assert_eq!(
tasks.iter().map(|task| task.name.as_str()).collect_vec(),
vec!["build.sh", "build.ps1", "build.cmd"]
);
}
#[test]
fn test_env_config_patterns() {
assert_eq!(
env_config_patterns_with_conf_d("linux", true),
vec![
".config/mise/conf.d/*.linux.toml",
".config/mise/config.linux.toml",
".config/mise.linux.toml",
"mise/conf.d/*.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/conf.d/*.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",
]
);
assert!(
env_config_patterns_with_conf_d("linux", false)
.iter()
.all(|pattern| !pattern.contains("conf.d"))
);
}
#[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_with_conf_d("qa*", true)
.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_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();
assert!(!should_warn_auto_env(&v("2026.6.2"), None, false));
assert!(should_warn_auto_env(&v("2026.12.0"), None, false));
assert!(should_warn_auto_env(&v("2027.5.9"), None, false));
assert!(!should_warn_auto_env(&v("2026.12.0"), Some(false), false));
assert!(!should_warn_auto_env(&v("2026.12.0"), Some(true), true));
assert!(!should_warn_auto_env(&v("2026.12.0"), None, true));
assert!(!should_warn_auto_env(&v("2027.6.0"), None, true));
assert!(!should_warn_auto_env(&v("2027.6.0"), Some(false), false));
}
#[test]
fn test_monorepo_lockfile_rollout() {
let v = |s: &str| versions::Versioning::new(s).unwrap();
assert!(!monorepo_lockfile_enabled_for_version(
&v("2026.6.15"),
None
));
assert!(monorepo_lockfile_enabled_for_version(
&v("2026.6.15"),
Some(true)
));
assert!(!monorepo_lockfile_enabled_for_version(
&v("2027.6.0"),
Some(false)
));
assert!(monorepo_lockfile_enabled_for_version(&v("2027.6.0"), None));
assert!(!should_warn_monorepo_lockfile_default(
&v("2026.11.9"),
None,
true,
true
));
assert!(should_warn_monorepo_lockfile_default(
&v("2026.12.0"),
None,
true,
true
));
assert!(should_warn_monorepo_lockfile_default(
&v("2027.5.9"),
None,
true,
true
));
assert!(!should_warn_monorepo_lockfile_default(
&v("2026.12.0"),
None,
false,
true
));
assert!(!should_warn_monorepo_lockfile_default(
&v("2026.12.0"),
None,
true,
false
));
assert!(!should_warn_monorepo_lockfile_default(
&v("2026.12.0"),
Some(true),
true,
true
));
assert!(!should_warn_monorepo_lockfile_default(
&v("2026.12.0"),
Some(false),
true,
true
));
assert!(!should_warn_monorepo_lockfile_default(
&v("2027.6.0"),
None,
true,
true
));
}
#[tokio::test]
async fn test_get_tool_opts_with_overrides_keeps_inline_opts_with_config_entry() -> Result<()> {
crate::toolset::install_state::init().await?;
let source = crate::toolset::ToolSource::MiseToml(PathBuf::from("mise.toml"));
let resolved_ba = Arc::new(BackendArg::from("github:jdx/mise-test-fixtures"));
let config_opts =
crate::toolset::parse_tool_options("api_url=https://config.example/api/v3,foo=config");
let mut trs = ToolRequestSet::new();
trs.add_version(
crate::toolset::ToolRequest::new_with_options(
resolved_ba,
"1.0.0",
config_opts,
source.clone(),
)?,
&source,
);
let mut repo_urls = HashMap::new();
repo_urls.insert(
"tiny".to_string(),
"github:jdx/mise-test-fixtures".to_string(),
);
let config = Config {
tera_ctx: BASE_CONTEXT.clone(),
config_files: Default::default(),
bootstrap_config_maps: vec![],
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: get_shorthands(&Settings::get()),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases: Default::default(),
aliases: Default::default(),
project_root: Default::default(),
repo_urls,
shell_aliases: Default::default(),
tera_files: Default::default(),
vars: Default::default(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
};
config.tool_request_set.set(trs).ok();
let config = Arc::new(config);
let ba = Arc::new(BackendArg::new_raw(
"tiny".to_string(),
Some("github:jdx/mise-test-fixtures".to_string()),
"jdx/mise-test-fixtures".to_string(),
Some(crate::toolset::parse_tool_options(
"api_url=https://inline.example/api/v3",
)),
crate::cli::args::BackendResolution::new(true),
));
let opts = config.get_tool_opts_with_overrides(&ba).await?;
assert_eq!(opts.get("api_url"), Some("https://inline.example/api/v3"));
assert_eq!(opts.get("foo"), Some("config"));
Ok(())
}
#[tokio::test]
async fn test_get_tool_opts_with_overrides_keeps_inline_opts_without_config_entry() -> Result<()>
{
let config = Config::reset().await?;
let ba = Arc::new(BackendArg::from(
"tiny[api_url=https://inline.example/api/v3]",
));
let opts = config.get_tool_opts_with_overrides(&ba).await?;
assert_eq!(opts.get("api_url"), Some("https://inline.example/api/v3"));
Ok(())
}
#[tokio::test]
async fn test_resolve_tool_opts_tracks_alias_config_and_inline_sources() -> Result<()> {
crate::toolset::install_state::init().await?;
let source = crate::toolset::ToolSource::MiseToml(PathBuf::from("mise.toml"));
let config_ba = Arc::new(BackendArg::from("tiny"));
let config_opts =
crate::toolset::parse_tool_options("asset_pattern=config-pattern,bar=config");
let mut trs = ToolRequestSet::new();
trs.add_version(
crate::toolset::ToolRequest::new_with_options(
config_ba,
"1.0.0",
config_opts,
source.clone(),
)?,
&source,
);
let mut all_aliases = AliasMap::default();
all_aliases.insert(
"tiny".to_string(),
Alias {
backend: Some(
"github:jdx/mise-test-fixtures[api_url=https://alias.example/api/v3,asset_pattern=alias-pattern,foo=alias]"
.to_string(),
),
versions: Default::default(),
},
);
let config = Config {
tera_ctx: BASE_CONTEXT.clone(),
config_files: Default::default(),
bootstrap_config_maps: vec![],
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: get_shorthands(&Settings::get()),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases,
aliases: Default::default(),
project_root: Default::default(),
repo_urls: Default::default(),
shell_aliases: Default::default(),
tera_files: Default::default(),
vars: Default::default(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
};
config.tool_request_set.set(trs).ok();
let config = Arc::new(config);
let ba = Arc::new(BackendArg::from(
"tiny[api_url=https://inline.example/api/v3]",
));
let resolved = config.resolve_tool_opts_with_overrides(&ba).await?;
let opts = resolved.effective();
assert_eq!(opts.get("api_url"), Some("https://inline.example/api/v3"));
assert_eq!(opts.get("asset_pattern"), Some("config-pattern"));
assert_eq!(opts.get("foo"), Some("alias"));
assert_eq!(opts.get("bar"), Some("config"));
assert_eq!(
resolved.source_for_key("api_url"),
Some(crate::toolset::ToolOptionSource::InlineBackendArg)
);
assert_eq!(
resolved.source_for_key("asset_pattern"),
Some(crate::toolset::ToolOptionSource::Config)
);
assert_eq!(
resolved.source_for_key("foo"),
Some(crate::toolset::ToolOptionSource::BackendAlias)
);
Ok(())
}
#[tokio::test]
async fn test_resolve_tool_opts_prefers_config_over_install_manifest_opts() -> Result<()> {
crate::toolset::install_state::init().await?;
let source = crate::toolset::ToolSource::MiseToml(PathBuf::from("mise.toml"));
let config_ba = Arc::new(BackendArg::from("http:manifest-opts"));
let config_opts =
crate::toolset::parse_tool_options("version_json_path=.current,config_only=true");
let mut trs = ToolRequestSet::new();
trs.add_version(
crate::toolset::ToolRequest::new_with_options(
config_ba,
"1.0.0",
config_opts,
source.clone(),
)?,
&source,
);
let mut manifest_opts = BTreeMap::new();
manifest_opts.insert(
"version_json_path".to_string(),
toml::Value::String(".manifest".to_string()),
);
manifest_opts.insert(
"manifest_only".to_string(),
toml::Value::String("true".to_string()),
);
let ba = Arc::new(BackendArg::from(
crate::toolset::install_state::InstallStateTool {
short: "http:manifest-opts".to_string(),
full: Some("http:manifest-opts".to_string()),
versions: vec!["1.0.0".to_string()],
explicit_backend: true,
opts: manifest_opts,
installs_path: None,
},
));
let config = Config {
tera_ctx: BASE_CONTEXT.clone(),
config_files: Default::default(),
bootstrap_config_maps: vec![],
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: get_shorthands(&Settings::get()),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases: Default::default(),
aliases: Default::default(),
project_root: Default::default(),
repo_urls: Default::default(),
shell_aliases: Default::default(),
tera_files: Default::default(),
vars: Default::default(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
};
config.tool_request_set.set(trs).ok();
let config = Arc::new(config);
let resolved = config.resolve_tool_opts_with_overrides(&ba).await?;
let opts = resolved.effective();
assert_eq!(opts.get("version_json_path"), Some(".current"));
assert_eq!(
resolved.source_for_key("version_json_path"),
Some(crate::toolset::ToolOptionSource::Config)
);
assert_eq!(opts.get("manifest_only"), Some("true"));
assert_eq!(
resolved.source_for_key("manifest_only"),
Some(crate::toolset::ToolOptionSource::InstallManifest)
);
assert_eq!(opts.get("config_only"), Some("true"));
assert_eq!(
resolved.source_for_key("config_only"),
Some(crate::toolset::ToolOptionSource::Config)
);
Ok(())
}
#[tokio::test]
async fn test_resolve_tool_opts_prefers_s3_listing_config_over_install_manifest_opts()
-> Result<()> {
crate::toolset::install_state::init().await?;
let source = crate::toolset::ToolSource::MiseToml(PathBuf::from("mise.toml"));
let config_ba = Arc::new(BackendArg::from("s3:manifest-opts"));
let config_opts = crate::toolset::parse_tool_options(
"version_prefix=current/,version_regex=current-(.*)",
);
let mut trs = ToolRequestSet::new();
trs.add_version(
crate::toolset::ToolRequest::new_with_options(
config_ba,
"1.0.0",
config_opts,
source.clone(),
)?,
&source,
);
let mut manifest_opts = BTreeMap::new();
manifest_opts.insert(
"version_prefix".to_string(),
toml::Value::String("manifest/".to_string()),
);
manifest_opts.insert(
"version_regex".to_string(),
toml::Value::String("manifest-(.*)".to_string()),
);
manifest_opts.insert(
"endpoint".to_string(),
toml::Value::String("https://manifest.example".to_string()),
);
let ba = Arc::new(BackendArg::from(
crate::toolset::install_state::InstallStateTool {
short: "s3:manifest-opts".to_string(),
full: Some("s3:manifest-opts".to_string()),
versions: vec!["1.0.0".to_string()],
explicit_backend: true,
opts: manifest_opts,
installs_path: None,
},
));
let config = Config {
tera_ctx: BASE_CONTEXT.clone(),
config_files: Default::default(),
bootstrap_config_maps: vec![],
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: get_shorthands(&Settings::get()),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases: Default::default(),
aliases: Default::default(),
project_root: Default::default(),
repo_urls: Default::default(),
shell_aliases: Default::default(),
tera_files: Default::default(),
vars: Default::default(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
};
config.tool_request_set.set(trs).ok();
let config = Arc::new(config);
let resolved = config.resolve_tool_opts_with_overrides(&ba).await?;
let opts = resolved.effective();
assert_eq!(opts.get("version_prefix"), Some("current/"));
assert_eq!(
resolved.source_for_key("version_prefix"),
Some(crate::toolset::ToolOptionSource::Config)
);
assert_eq!(opts.get("version_regex"), Some("current-(.*)"));
assert_eq!(
resolved.source_for_key("version_regex"),
Some(crate::toolset::ToolOptionSource::Config)
);
assert_eq!(opts.get("endpoint"), Some("https://manifest.example"));
assert_eq!(
resolved.source_for_key("endpoint"),
Some(crate::toolset::ToolOptionSource::InstallManifest)
);
Ok(())
}
#[tokio::test]
async fn test_resolve_tool_opts_prefers_env_backend_override_over_alias_opts() -> Result<()> {
unsafe {
std::env::set_var("MISE_BACKENDS_ENV_OPTS_TEST", "github:env/repo[foo=env]");
}
let result = async {
let mut all_aliases = AliasMap::default();
all_aliases.insert(
"env-opts-test".to_string(),
Alias {
backend: Some("github:alias/repo[foo=alias,bar=alias]".to_string()),
versions: Default::default(),
},
);
let config = Config {
tera_ctx: BASE_CONTEXT.clone(),
config_files: Default::default(),
bootstrap_config_maps: vec![],
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: get_shorthands(&Settings::get()),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases,
aliases: Default::default(),
project_root: Default::default(),
repo_urls: Default::default(),
shell_aliases: Default::default(),
tera_files: Default::default(),
vars: Default::default(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
};
config.tool_request_set.set(ToolRequestSet::new()).ok();
let config = Arc::new(config);
let ba = Arc::new(BackendArg::from("env-opts-test"));
let resolved = config.resolve_tool_opts_with_overrides(&ba).await?;
let opts = resolved.effective();
assert_eq!(ba.full(), "github:env/repo[foo=env]");
assert_eq!(opts.get("foo"), Some("env"));
assert_eq!(opts.get("bar"), None);
assert_eq!(
resolved.source_for_key("foo"),
Some(crate::toolset::ToolOptionSource::BackendAlias)
);
Ok(())
}
.await;
unsafe {
std::env::remove_var("MISE_BACKENDS_ENV_OPTS_TEST");
}
result
}
#[tokio::test]
async fn test_monorepo_union_tool_request_set_preserves_matching_tools() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let api = root.join("apps/api");
let web = root.join("apps/web");
fs::create_dir_all(&api)?;
fs::create_dir_all(&web)?;
let root_config = root.join(".test.mise.toml");
fs::write(
&root_config,
r#"
monorepo_root = true
[monorepo]
config_roots = ["apps/api", "apps/web"]
"#,
)?;
fs::write(
api.join(".test.mise.toml"),
r#"
[tools]
"github:jdx/mise-test-fixtures" = "1"
"#,
)?;
fs::write(
web.join(".test.mise.toml"),
r#"
[tools]
"github:jdx/mise-test-fixtures" = "2"
"#,
)?;
let mut config_files: ConfigMap = Default::default();
config_files.insert(
root_config.clone(),
Arc::new(config_file::mise_toml::MiseToml::from_file(&root_config)?),
);
let config = Config {
tera_ctx: BASE_CONTEXT.clone(),
config_files,
bootstrap_config_maps: vec![],
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: get_shorthands(&Settings::get()),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases: Default::default(),
aliases: Default::default(),
project_root: Default::default(),
repo_urls: Default::default(),
shell_aliases: Default::default(),
tera_files: Default::default(),
vars: Default::default(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
};
let config = Arc::new(config);
assert_eq!(
config.monorepo_config_root_dirs_with_filenames(&DEFAULT_CONFIG_FILENAMES)?,
vec![api, web]
);
let trs = config.monorepo_union_tool_request_set().await?;
let fixture_versions = trs
.iter()
.find(|(ba, _, _)| ba.short.contains("mise-test-fixtures"))
.map(|(_, requests, _)| {
requests
.iter()
.map(|request| request.version().to_string())
.collect_vec()
})
.unwrap_or_default();
assert_eq!(fixture_versions, vec!["1", "2"]);
Ok(())
}
#[tokio::test]
async fn test_load_all_config_files_skips_directories() -> Result<()> {
let _config = Config::get().await?;
let temp_dir = TempDir::new()?;
let temp_path = temp_dir.path();
let sub_dir = temp_path.join("subdir");
fs::create_dir(&sub_dir)?;
let file1_path = temp_path.join("config1.toml");
let file2_path = temp_path.join("config2.toml");
File::create(&file1_path)?;
File::create(&file2_path)?;
fs::write(&file1_path, "key1 = 'value1'")?;
fs::write(&file2_path, "key2 = 'value2'")?;
let config_filenames = vec![file1_path.clone(), file2_path.clone(), sub_dir.clone()];
let idiomatic_filenames = BTreeMap::new();
let result = load_all_config_files(&config_filenames, &idiomatic_filenames).await?;
assert_eq!(result.len(), 2);
assert!(result.contains_key(&file1_path));
assert!(result.contains_key(&file2_path));
assert!(!result.contains_key(&sub_dir));
Ok(())
}
#[test]
fn test_matching_idiomatic_tools_uses_relative_paths() {
let idiomatic_filenames = BTreeMap::from([
(
"subdir/tool.json".to_string(),
vec!["nested-tool".to_string()],
),
("tool.json".to_string(), vec!["root-tool".to_string()]),
]);
assert_eq!(
matching_idiomatic_tools(
Path::new("/tmp/project/subdir/tool.json"),
&idiomatic_filenames
),
vec!["nested-tool"]
);
}
#[test]
fn test_idiomatic_version_file_disabled_is_tool_specific() {
let disabled = BTreeSet::from([
"aqua:owner/tool:tool.json".to_string(),
"node:package.json".to_string(),
"python:.python-version".to_string(),
]);
assert!(idiomatic_version_file_disabled(
&disabled,
"aqua:owner/tool",
"tool.json"
));
assert!(idiomatic_version_file_disabled(
&disabled,
"node",
"package.json"
));
assert!(!idiomatic_version_file_disabled(
&disabled,
"pnpm",
"package.json"
));
assert!(!idiomatic_version_file_disabled(
&disabled, "node", ".nvmrc"
));
}
#[tokio::test]
async fn test_get_repo_url_ssh() -> Result<()> {
let config = Config::reset().await?;
let urls = [
"ssh://git@gitlab.dev/mobile/asdf-gitique.git",
"git@github.com:user/repo.git",
"git://example.com/repo.git",
"http://example.com/repo.git",
"https://example.com/repo.git",
];
for url in urls {
assert!(
config.get_repo_url(url).is_some(),
"URL should be considered valid: {url}"
);
}
Ok(())
}
#[test]
fn test_get_repo_url_preserves_explicit_local_paths() {
let temp = tempfile::tempdir().unwrap();
let local_asdf = temp
.path()
.join("plugins/local-asdf")
.to_string_lossy()
.into_owned();
let local_vfox = temp
.path()
.join("plugins/local-vfox")
.to_string_lossy()
.into_owned();
let repo_urls = HashMap::from([
("local-asdf".to_string(), local_asdf.clone()),
("vfox:local-vfox".to_string(), local_vfox.clone()),
("remote-asdf".to_string(), "owner/asdf-plugin".to_string()),
(
"vfox:remote-vfox".to_string(),
"owner/vfox-plugin".to_string(),
),
(
"asdf:explicit-asdf".to_string(),
"owner/asdf-plugin".to_string(),
),
(
"vfox-backend:explicit-backend".to_string(),
"owner/backend-plugin".to_string(),
),
]);
let config = Config {
tera_ctx: BASE_CONTEXT.clone(),
config_files: Default::default(),
bootstrap_config_maps: vec![],
env: OnceCell::new(),
env_with_sources: OnceCell::new(),
shorthands: get_shorthands(&Settings::get()),
hooks: OnceCell::new(),
tasks_cache: Arc::new(DashMap::new()),
workspace_project_graph_cache: Mutex::new(None),
tool_request_set: OnceCell::new(),
toolset: OnceCell::new(),
all_aliases: Default::default(),
aliases: Default::default(),
project_root: Default::default(),
repo_urls,
shell_aliases: Default::default(),
tera_files: Default::default(),
vars: Default::default(),
vars_results: OnceCell::new(),
lockfile_discovery: Default::default(),
};
assert_eq!(
config.get_repo_url("local-asdf").as_deref(),
Some(local_asdf.as_str())
);
assert_eq!(
config.get_repo_url("local-vfox").as_deref(),
Some(local_vfox.as_str())
);
assert_eq!(
config.get_repo_url("remote-asdf").as_deref(),
Some("https://github.com/owner/asdf-plugin.git")
);
assert_eq!(
config.get_repo_url("remote-vfox").as_deref(),
Some("https://github.com/owner/vfox-plugin.git")
);
assert_eq!(
config.get_configured_plugin_type("local-vfox"),
Some(PluginType::Vfox)
);
assert_eq!(
config.get_configured_plugin_type("explicit-asdf"),
Some(PluginType::Asdf)
);
assert_eq!(
config.get_configured_plugin_type("explicit-backend"),
Some(PluginType::VfoxBackend)
);
assert_eq!(config.get_configured_plugin_type("local-asdf"), None);
}
#[tokio::test]
async fn test_load_task_file_supports_per_task_vars() -> Result<()> {
let config = Config::reset().await?;
let temp_dir = TempDir::new()?;
let tasks_toml = temp_dir.path().join("tasks.toml");
fs::write(
&tasks_toml,
r#"
[build]
description = "{{vars.target}}"
run = "echo build"
vars = { target = "linux" }
"#,
)?;
let tasks = load_task_file(
&config,
&tasks_toml,
temp_dir.path(),
&ResolvedTaskConfig::default(),
&TaskDefinitions::default(),
None,
None,
)
.await?;
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "build");
assert_eq!(tasks[0].description, "linux");
Ok(())
}
}
#[cfg(test)]
mod write_target_tests {
use super::*;
fn set(paths: &[&str]) -> IndexSet<PathBuf> {
paths.iter().map(PathBuf::from).collect()
}
#[test]
fn a_drop_in_never_wins_over_tool_versions() {
let files = set(&[
"/home/u/.config/mise/conf.d/10-drop-in.toml",
"/home/u/.tool-versions",
]);
assert_eq!(
first_config_file(&files),
Some(&PathBuf::from("/home/u/.tool-versions"))
);
}
#[test]
fn nothing_but_drop_ins_means_no_write_target() {
let files = set(&["/home/u/.config/mise/conf.d/10-drop-in.toml"]);
assert_eq!(first_config_file(&files), None);
}
#[test]
fn a_real_config_still_wins() {
let files = set(&[
"/home/u/.config/mise/conf.d/10-drop-in.toml",
"/home/u/.config/mise/config.toml",
"/home/u/.tool-versions",
]);
assert_eq!(
first_config_file(&files),
Some(&PathBuf::from("/home/u/.config/mise/config.toml"))
);
}
#[test]
fn tool_versions_stays_deprioritised() {
let files = set(&["/proj/.tool-versions", "/proj/mise.toml"]);
assert_eq!(
first_config_file(&files),
Some(&PathBuf::from("/proj/mise.toml"))
);
let only = set(&["/proj/.tool-versions"]);
assert_eq!(
first_config_file(&only),
Some(&PathBuf::from("/proj/.tool-versions"))
);
}
}