use eyre::{WrapErr, eyre};
use indexmap::IndexMap;
use itertools::Itertools;
use once_cell::sync::OnceCell;
use serde::Deserialize;
use serde::de::Visitor;
use serde::{Deserializer, de};
use std::fmt::{Debug, Formatter};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{
collections::{BTreeMap, HashMap},
sync::{Mutex, MutexGuard},
};
use tera::Context as TeraContext;
use tera::Value as TeraValue;
use toml_edit::{Array, DocumentMut, InlineTable, Item, Key, Value, table, value};
use versions::Versioning;
use crate::cli::args::{BackendArg, ToolVersionType};
use crate::config::config_file::{
ConfigFile, TaskConfig, config_trust_root, is_ignored, trust, trust_check,
};
use crate::config::config_file::{config_root, toml::deserialize_arr};
use crate::config::env_directive::{AgeFormat, EnvDirective, EnvDirectiveOptions, RequiredValue};
use crate::config::settings::SettingsPartial;
use crate::config::{Alias, AliasMap, Config, Settings};
use crate::deps::DepsConfig;
use crate::env_diff::EnvMap;
use crate::file::{create_dir_all, display_path};
use crate::hooks::{Hook, HookDef, Hooks};
use crate::oci::OciConfig;
use crate::redactions::Redactions;
use crate::registry::REGISTRY;
use crate::system::{BootstrapTomlConfig, DotfilesTomlConfig};
use crate::task::{Task, TaskTemplate};
use crate::tera::{BASE_CONTEXT, contains_template_syntax, get_tera, render_str};
use crate::toolset::{ToolRequest, ToolRequestSet, ToolSource, ToolVersionOptions};
use crate::watch_files::WatchFile;
use crate::{env, file};
use super::diagnostic::toml_parse_error;
use super::min_version::MinVersionSpec;
const LEGACY_ENV_KEYS_DEPRECATED_WARN_AT: &str = "2026.4.17";
const LEGACY_ENV_KEYS_DEPRECATED_REMOVE_AT: &str = "2027.4.0";
fn toml_value_to_edit(v: toml::Value) -> Value {
match v {
toml::Value::String(s) => Value::from(s),
toml::Value::Integer(i) => Value::from(i),
toml::Value::Float(f) => Value::from(f),
toml::Value::Boolean(b) => Value::from(b),
toml::Value::Datetime(dt) => {
dt.to_string()
.parse::<toml_edit::Datetime>()
.map(Value::from)
.unwrap_or_else(|_| Value::from(dt.to_string()))
}
toml::Value::Array(arr) => {
let mut edit_arr = Array::new();
for item in arr {
edit_arr.push(toml_value_to_edit(item));
}
Value::Array(edit_arr)
}
toml::Value::Table(table) => {
let mut edit_table = InlineTable::new();
for (k, v) in table {
edit_table.insert(k, toml_value_to_edit(v));
}
Value::InlineTable(edit_table)
}
}
}
fn normalize_option_template_value(value: toml::Value) -> toml::Value {
match value {
toml::Value::String(s) => toml::Value::String(s.replace("{{version}}", "{version}")),
value => value,
}
}
fn should_normalize_option_template(key: &str) -> bool {
!matches!(key, "os" | "depends" | "install_env") && !key.starts_with("install_env.")
}
fn insert_tool_option<E>(
options: &mut ToolVersionOptions,
key: String,
value: toml::Value,
) -> std::result::Result<(), E>
where
E: de::Error,
{
let value = if should_normalize_option_template(&key) {
normalize_option_template_value(value)
} else {
value
};
options.insert_option(key, value).map_err(de::Error::custom)
}
fn insert_core_options(table: &mut InlineTable, options: ToolVersionOptions) {
let core = options.core;
if let Some(os) = core.os
&& !os.is_empty()
{
let mut arr = Array::new();
for o in os {
arr.push(Value::from(o));
}
table.insert("os", Value::Array(arr));
}
if let Some(depends) = core.depends
&& !depends.is_empty()
{
let mut arr = Array::new();
for dep in depends {
arr.push(Value::from(dep));
}
table.insert("depends", Value::Array(arr));
}
if !core.install_env.is_empty() {
let mut env = InlineTable::new();
for (k, v) in core.install_env {
env.insert(k, v.into());
}
table.insert("install_env", env.into());
}
}
#[derive(Default, Deserialize)]
pub struct MiseToml {
#[serde(rename = "_")]
custom: Option<toml::Value>,
#[serde(default, deserialize_with = "deserialize_min_version")]
min_version: Option<MinVersionSpec>,
#[serde(skip)]
context: TeraContext,
#[serde(skip)]
path: PathBuf,
#[serde(default, deserialize_with = "deserialize_arr")]
env_file: Vec<String>,
#[serde(default, deserialize_with = "deserialize_arr")]
dotenv: Vec<String>,
#[serde(default)]
env: EnvList,
#[serde(default, deserialize_with = "deserialize_arr")]
env_path: Vec<String>,
#[serde(default)]
alias: AliasMap,
#[serde(default)]
tool_alias: AliasMap,
#[serde(default)]
shell_alias: IndexMap<String, String>,
#[serde(skip)]
doc: Mutex<OnceCell<DocumentMut>>,
#[serde(default)]
hooks: IndexMap<Hooks, HookDef>,
#[serde(default)]
tools: Mutex<IndexMap<BackendArg, MiseTomlToolList>>,
#[serde(default)]
plugins: HashMap<String, String>,
#[serde(default)]
redactions: Redactions,
#[serde(default)]
task_config: TaskConfig,
#[serde(default)]
tasks: Tasks,
#[serde(default)]
task_templates: TaskTemplates,
#[serde(default)]
watch_files: Vec<WatchFile>,
#[serde(default)]
deps: Option<DepsConfig>,
#[serde(default)]
oci: Option<OciConfig>,
#[serde(default)]
bootstrap: Option<BootstrapTomlConfig>,
#[serde(default)]
dotfiles: Option<DotfilesTomlConfig>,
#[serde(default)]
vars: EnvList,
#[serde(default)]
settings: SettingsPartial,
#[serde(default, alias = "experimental_monorepo_root")]
monorepo_root: Option<bool>,
#[serde(default)]
monorepo: Option<MonorepoConfig>,
}
#[derive(Debug, Default, Clone)]
pub struct MiseTomlToolList(Vec<MiseTomlTool>);
#[derive(Debug, Clone)]
pub struct MiseTomlTool {
pub tt: ToolVersionType,
pub options: Option<ToolVersionOptions>,
}
#[derive(Debug, Default, Clone)]
pub struct Tasks(pub BTreeMap<String, Task>);
#[derive(Debug, Default, Clone)]
pub struct TaskTemplates(pub IndexMap<String, TaskTemplate>);
#[derive(Debug, Default, Clone)]
pub struct EnvList(pub(crate) Vec<EnvDirective>);
#[derive(Debug, Default, Clone, Deserialize)]
pub struct MonorepoConfig {
#[serde(default)]
pub config_roots: Vec<String>,
}
impl EnvList {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl MiseToml {
fn enforce_min_version_fallback(body: &str) -> eyre::Result<()> {
if let Ok(val) = toml::from_str::<toml::Value>(body)
&& let Some(min_val) = val.get("min_version")
{
let mut hard_req: Option<versions::Versioning> = None;
let mut soft_req: Option<versions::Versioning> = None;
match min_val {
toml::Value::String(s) => {
hard_req = versions::Versioning::new(s);
}
toml::Value::Table(t) => {
if let Some(toml::Value::String(s)) = t.get("hard") {
hard_req = versions::Versioning::new(s);
}
if let Some(toml::Value::String(s)) = t.get("soft") {
soft_req = versions::Versioning::new(s);
}
}
_ => {}
}
if let Some(spec) =
crate::config::config_file::min_version::MinVersionSpec::new(hard_req, soft_req)
{
crate::config::Config::enforce_min_version_spec(&spec)?;
}
}
Ok(())
}
pub fn init(path: &Path) -> Self {
let mut context = BASE_CONTEXT.clone();
context.insert(
"config_root",
config_root::config_root(path).to_str().unwrap(),
);
let mut rf = Self {
path: path.to_path_buf(),
context,
..Default::default()
};
rf.update_context_env(env::PRISTINE_ENV.clone());
rf
}
pub fn from_file(path: &Path) -> eyre::Result<Self> {
let body = file::read_to_string(path)?;
Self::from_str(&body, path)
}
pub fn from_str(body: &str, path: &Path) -> eyre::Result<Self> {
if !Self::is_trust_exempt(body, path) {
trust_check(path)?;
}
trace!("parsing: {}", display_path(path));
let des = toml::Deserializer::parse(body).map_err(|e| toml_parse_error(&e, body, path))?;
let de_res = serde_ignored::deserialize(des, |p| {
warn!("unknown field in {}: {p}", display_path(path));
});
let mut rf: MiseToml = match de_res {
Ok(rf) => rf,
Err(err) => {
Self::enforce_min_version_fallback(body)?;
return Err(toml_parse_error(&err, body, path));
}
};
rf.context = BASE_CONTEXT.clone();
rf.context.insert(
"config_root",
config_root::config_root(path).to_str().unwrap(),
);
rf.update_context_env(env::PRISTINE_ENV.clone());
rf.path = path.to_path_buf();
let project_root = rf.project_root().map(|p| p.to_path_buf());
for task in rf.tasks.0.values_mut() {
task.config_source.clone_from(&rf.path);
task.config_root = project_root.clone();
}
Ok(rf)
}
pub fn path_is_trust_exempt(path: &Path) -> bool {
file::read_to_string(path).is_ok_and(|body| Self::is_trust_exempt(&body, path))
}
fn is_trust_exempt(body: &str, path: &Path) -> bool {
if Settings::try_get().is_ok_and(|settings| settings.paranoid) {
return false;
}
if is_ignored(&config_trust_root(path)) || is_ignored(path) {
return false;
}
is_safe_config_body(body)
}
fn doc(&self) -> eyre::Result<DocumentMut> {
self.doc
.lock()
.unwrap()
.get_or_try_init(|| {
let body = file::read_to_string(&self.path).unwrap_or_default();
Ok(body.parse()?)
})
.cloned()
}
fn warn_deprecated_env_keys(&self) {
if !self.env_file.is_empty() {
deprecated_at!(
LEGACY_ENV_KEYS_DEPRECATED_WARN_AT,
LEGACY_ENV_KEYS_DEPRECATED_REMOVE_AT,
"config.env_file",
"`env_file` in {} is deprecated. Use `env._.file` instead.",
display_path(&self.path)
);
}
if !self.dotenv.is_empty() {
deprecated_at!(
LEGACY_ENV_KEYS_DEPRECATED_WARN_AT,
LEGACY_ENV_KEYS_DEPRECATED_REMOVE_AT,
"config.dotenv",
"`dotenv` in {} is deprecated. Use `env._.file` instead.",
display_path(&self.path)
);
}
if !self.env_path.is_empty() {
deprecated_at!(
LEGACY_ENV_KEYS_DEPRECATED_WARN_AT,
LEGACY_ENV_KEYS_DEPRECATED_REMOVE_AT,
"config.env_path",
"`env_path` in {} is deprecated. Use `env._.path` instead.",
display_path(&self.path)
);
}
}
fn doc_mut(&self) -> eyre::Result<MutexGuard<'_, OnceCell<DocumentMut>>> {
self.doc()?;
Ok(self.doc.lock().unwrap())
}
pub fn set_backend_alias(&mut self, fa: &BackendArg, to: &str) -> eyre::Result<()> {
self.doc_mut()?
.get_mut()
.unwrap()
.entry("tool_alias")
.or_insert_with(table)
.as_table_like_mut()
.unwrap()
.insert(&fa.short, value(to));
Ok(())
}
pub fn set_alias(&mut self, fa: &BackendArg, from: &str, to: &str) -> eyre::Result<()> {
self.tool_alias
.entry(fa.short.to_string())
.or_default()
.versions
.insert(from.into(), to.into());
self.doc_mut()?
.get_mut()
.unwrap()
.entry("tool_alias")
.or_insert_with(table)
.as_table_like_mut()
.unwrap()
.entry(&fa.to_string())
.or_insert_with(table)
.as_table_like_mut()
.unwrap()
.entry("versions")
.or_insert_with(table)
.as_table_like_mut()
.unwrap()
.insert(from, value(to));
Ok(())
}
pub fn remove_backend_alias(&mut self, fa: &BackendArg) -> eyre::Result<()> {
let mut doc = self.doc_mut()?;
let doc = doc.get_mut().unwrap();
for section in ["tool_alias", "alias"] {
if let Some(aliases) = doc.get_mut(section).and_then(|v| v.as_table_mut()) {
aliases.remove(&fa.short);
if aliases.is_empty() {
doc.as_table_mut().remove(section);
}
}
}
Ok(())
}
pub fn remove_alias(&mut self, fa: &BackendArg, from: &str) -> eyre::Result<()> {
for alias_map in [&mut self.tool_alias, &mut self.alias] {
if let Some(aliases) = alias_map.get_mut(&fa.short) {
aliases.versions.shift_remove(from);
if aliases.versions.is_empty() && aliases.backend.is_none() {
alias_map.shift_remove(&fa.short);
}
}
}
let mut doc = self.doc_mut()?;
let doc = doc.get_mut().unwrap();
for section in ["tool_alias", "alias"] {
if let Some(aliases) = doc.get_mut(section).and_then(|v| v.as_table_mut()) {
if let Some(alias) = aliases
.get_mut(&fa.to_string())
.and_then(|v| v.as_table_mut())
{
if let Some(versions) = alias.get_mut("versions").and_then(|v| v.as_table_mut())
{
versions.remove(from);
if versions.is_empty() {
alias.remove("versions");
}
}
if alias.is_empty() {
aliases.remove(&fa.to_string());
}
}
if aliases.is_empty() {
doc.as_table_mut().remove(section);
}
}
}
Ok(())
}
pub fn set_shell_alias(&mut self, name: &str, command: &str) -> eyre::Result<()> {
self.shell_alias.insert(name.into(), command.into());
self.doc_mut()?
.get_mut()
.unwrap()
.entry("shell_alias")
.or_insert_with(table)
.as_table_like_mut()
.unwrap()
.insert(name, value(command));
Ok(())
}
pub fn remove_shell_alias(&mut self, name: &str) -> eyre::Result<()> {
self.shell_alias.shift_remove(name);
let mut doc = self.doc_mut()?;
let doc = doc.get_mut().unwrap();
if let Some(shell_alias) = doc.get_mut("shell_alias").and_then(|v| v.as_table_mut()) {
shell_alias.remove(name);
if shell_alias.is_empty() {
doc.as_table_mut().remove("shell_alias");
}
}
Ok(())
}
pub fn update_env<V: Into<Value>>(&mut self, key: &str, value: V) -> eyre::Result<()> {
let mut doc = self.doc_mut()?;
let mut env_tbl = doc
.get_mut()
.unwrap()
.entry("env")
.or_insert_with(table)
.as_table_mut()
.unwrap();
let key_parts = key.split('.').collect_vec();
for (i, k) in key_parts.iter().enumerate() {
if i == key_parts.len() - 1 {
let k = get_key_with_decor(env_tbl, k);
env_tbl.insert_formatted(&k, toml_edit::value(value));
break;
} else if !env_tbl.contains_key(k) {
env_tbl.insert_formatted(&Key::from(*k), toml_edit::table());
}
env_tbl = env_tbl.get_mut(k).unwrap().as_table_mut().unwrap();
}
Ok(())
}
pub fn update_bootstrap_package(&mut self, spec: &str, version: &str) -> eyre::Result<()> {
self.bootstrap
.get_or_insert_with(Default::default)
.packages
.insert(spec.to_string(), version.to_string());
let mut doc = self.doc_mut()?;
let bootstrap = doc
.get_mut()
.unwrap()
.entry("bootstrap")
.or_insert_with(table)
.as_table_mut()
.unwrap();
bootstrap.set_implicit(true);
let packages = bootstrap
.entry("packages")
.or_insert_with(table)
.as_table_mut()
.unwrap();
let key = get_key_with_decor(packages, spec);
packages.insert_formatted(&key, toml_edit::value(version));
Ok(())
}
#[cfg(unix)]
pub fn update_bootstrap_brew_tap(&mut self, tap: &str, url: &str) -> eyre::Result<()> {
self.bootstrap
.get_or_insert_with(Default::default)
.brew
.taps
.insert(tap.to_string(), url.to_string());
let mut doc = self.doc_mut()?;
let bootstrap = doc
.get_mut()
.unwrap()
.entry("bootstrap")
.or_insert_with(table)
.as_table_mut()
.unwrap();
bootstrap.set_implicit(true);
let brew = bootstrap
.entry("brew")
.or_insert_with(table)
.as_table_mut()
.unwrap();
brew.set_implicit(true);
let taps = brew
.entry("taps")
.or_insert_with(table)
.as_table_mut()
.unwrap();
let key = get_key_with_decor(taps, tap);
taps.insert_formatted(&key, toml_edit::value(url));
Ok(())
}
#[cfg(unix)]
pub fn remove_bootstrap_brew_tap(&mut self, tap: &str) -> eyre::Result<()> {
if let Some(bootstrap) = &mut self.bootstrap {
bootstrap.brew.taps.shift_remove(tap);
}
let mut doc = self.doc_mut()?;
let doc = doc.get_mut().unwrap();
if let Some(bootstrap) = doc.get_mut("bootstrap").and_then(|v| v.as_table_mut())
&& let Some(brew) = bootstrap.get_mut("brew").and_then(|v| v.as_table_mut())
&& let Some(taps) = brew.get_mut("taps").and_then(|v| v.as_table_mut())
{
taps.remove(tap);
if taps.is_empty() {
brew.remove("taps");
if brew.is_empty() {
bootstrap.remove("brew");
if bootstrap.is_empty() {
doc.remove("bootstrap");
}
}
}
}
Ok(())
}
pub fn update_env_age(
&mut self,
key: &str,
value: &str,
format: Option<AgeFormat>,
) -> eyre::Result<()> {
let mut doc = self.doc_mut()?;
let mut env_tbl = doc
.get_mut()
.unwrap()
.entry("env")
.or_insert_with(table)
.as_table_mut()
.unwrap();
let mut outer_table = InlineTable::new();
match format {
Some(AgeFormat::Zstd) => {
let mut age_table = InlineTable::new();
age_table.insert("value", value.into());
age_table.insert("format", "zstd".into());
outer_table.insert("age", Value::InlineTable(age_table));
}
Some(AgeFormat::Raw) | None => {
outer_table.insert("age", value.into());
}
}
let key_parts = key.split('.').collect_vec();
for (i, k) in key_parts.iter().enumerate() {
if i == key_parts.len() - 1 {
let k = get_key_with_decor(env_tbl, k);
env_tbl
.insert_formatted(&k, toml_edit::Item::Value(Value::InlineTable(outer_table)));
break;
} else if !env_tbl.contains_key(k) {
env_tbl.insert_formatted(&Key::from(*k), toml_edit::table());
}
env_tbl = env_tbl.get_mut(k).unwrap().as_table_mut().unwrap();
}
Ok(())
}
pub fn remove_env(&mut self, key: &str) -> eyre::Result<()> {
let mut doc = self.doc_mut()?;
let env_tbl = doc
.get_mut()
.unwrap()
.entry("env")
.or_insert_with(table)
.as_table_mut()
.unwrap();
env_tbl.remove(key);
Ok(())
}
fn update_context_env(&mut self, mut base_env: EnvMap) {
for e in &self.env.0 {
match e {
EnvDirective::Val(key, value, _) => {
base_env.insert(key.clone(), value.clone());
}
EnvDirective::Default(key, value, _) => {
if base_env.get(key).is_none_or(|v| v.is_empty()) {
base_env.insert(key.clone(), value.clone());
}
}
_ => {}
}
}
self.context.insert("env", &base_env);
}
fn parse_template(&self, input: &str) -> eyre::Result<String> {
self.parse_template_with_context(&self.context, input)
}
fn parse_template_with_context(
&self,
context: &TeraContext,
input: &str,
) -> eyre::Result<String> {
if !contains_template_syntax(input) {
return Ok(input.to_string());
}
let dir = self.path.parent();
let mut tera = get_tera(dir);
let output = render_str(&mut tera, input, context).wrap_err_with(|| {
let p = display_path(&self.path);
eyre!("failed to parse template {input} in {p}")
})?;
Ok(output)
}
}
impl ConfigFile for MiseToml {
fn get_path(&self) -> &Path {
self.path.as_path()
}
fn min_version(&self) -> Option<&MinVersionSpec> {
self.min_version.as_ref()
}
fn plugins(&self) -> eyre::Result<HashMap<String, String>> {
self.plugins
.clone()
.into_iter()
.map(|(k, v)| {
let v = self.parse_template(&v)?;
Ok((k, v))
})
.collect()
}
fn env_entries(&self) -> eyre::Result<Vec<EnvDirective>> {
self.warn_deprecated_env_keys();
let env_entries = self.env.0.iter().cloned();
let path_entries = self
.env_path
.iter()
.map(|p| EnvDirective::Path(p.clone(), Default::default()))
.collect_vec();
let env_files = self
.env_file
.iter()
.chain(&self.dotenv)
.map(|p| EnvDirective::File(p.clone(), Default::default()))
.collect_vec();
let all = path_entries
.into_iter()
.chain(env_files)
.chain(env_entries)
.collect::<Vec<_>>();
Ok(all)
}
fn vars_entries(&self) -> eyre::Result<Vec<EnvDirective>> {
Ok(self.vars.0.clone())
}
fn tasks(&self) -> Vec<&Task> {
self.tasks.0.values().collect()
}
fn task_templates(&self) -> IndexMap<String, TaskTemplate> {
self.task_templates.0.clone()
}
fn remove_tool(&self, fa: &BackendArg) -> eyre::Result<()> {
let mut tools = self.tools.lock().unwrap();
tools.shift_remove(fa);
let mut doc = self.doc_mut()?;
let doc = doc.get_mut().unwrap();
if let Some(tools) = doc.get_mut("tools")
&& let Some(tools) = tools.as_table_like_mut()
{
tools.remove(&fa.to_string());
if tools.is_empty() {
doc.as_table_mut().remove("tools");
}
}
Ok(())
}
fn replace_versions(&self, ba: &BackendArg, versions: Vec<ToolRequest>) -> eyre::Result<()> {
trace!("replacing versions {ba:?} {versions:?}");
let mut tools = self.tools.lock().unwrap();
let is_tools_sorted = is_tools_sorted(&tools); let existing = tools.entry(ba.clone()).or_default();
let output_empty_opts = |opts: &ToolVersionOptions| {
if opts.os.as_ref().is_some_and(|o| !o.is_empty())
|| opts.depends.as_ref().is_some_and(|d| !d.is_empty())
|| !opts.install_env.is_empty()
{
return false;
}
if let Some(reg_ba) = REGISTRY.get(ba.short.as_str()).and_then(|b| b.ba())
&& reg_ba.opts.as_ref().is_some_and(|o| o == opts)
{
return true;
}
opts.is_empty()
};
existing.0 = versions
.iter()
.map(|tr| MiseTomlTool::from(tr.clone()))
.collect();
trace!("done replacing versions");
let mut doc = self.doc_mut()?;
trace!("got doc");
let tools = doc
.get_mut()
.unwrap()
.entry("tools")
.or_insert_with(table)
.as_table_mut()
.unwrap();
let key = get_key_with_decor(tools, ba.short.as_str());
if ba.short != ba.full() {
tools.remove(&ba.full());
}
if versions.len() == 1 {
let options = versions[0].options();
if output_empty_opts(&options) {
tools.insert_formatted(&key, value(versions[0].version()));
} else {
let mut table = InlineTable::new();
table.insert("version", versions[0].version().into());
for (k, v) in &options.opts {
table.insert(k, toml_value_to_edit(v.clone()));
}
insert_core_options(&mut table, options);
tools.insert_formatted(&key, table.into());
}
} else {
let mut arr = Array::new();
for tr in versions {
let v = tr.version();
if output_empty_opts(&tr.options()) {
arr.push(v.to_string());
} else {
let mut table = InlineTable::new();
table.insert("version", v.to_string().into());
let options = tr.options();
for (k, v) in &options.opts {
table.insert(k, toml_value_to_edit(v.clone()));
}
insert_core_options(&mut table, options);
arr.push(table);
}
}
tools.insert_formatted(&key, Item::Value(Value::Array(arr)));
}
if is_tools_sorted {
tools.sort_values();
}
Ok(())
}
fn save(&self) -> eyre::Result<()> {
let contents = self.dump()?;
if let Some(parent) = self.path.parent() {
create_dir_all(parent)?;
}
file::write(&self.path, contents)?;
trust(&config_trust_root(&self.path))?;
Ok(())
}
fn dump(&self) -> eyre::Result<String> {
Ok(self.doc()?.to_string())
}
fn source(&self) -> ToolSource {
ToolSource::MiseToml(self.path.clone())
}
fn to_tool_request_set(&self) -> eyre::Result<ToolRequestSet> {
let source = ToolSource::MiseToml(self.path.clone());
let mut trs = ToolRequestSet::new();
let tools = self.tools.lock().unwrap();
let mut context = self.context.clone();
if let Some(config) = Config::maybe_get()
&& let Some(env_results) = config.env_results_cached()
{
let mut env_vars: EnvMap =
if let Some(TeraValue::Object(existing_env)) = context.get("env") {
existing_env
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
} else {
env::PRISTINE_ENV.clone()
};
for key in &env_results.env_remove {
env_vars.remove(key);
}
env_vars.extend(
env_results
.env
.iter()
.map(|(k, (v, _))| (k.clone(), v.clone())),
);
context.insert("env", &env_vars);
}
if context.get("vars").is_none()
&& let Some(config) = Config::maybe_get()
{
if let Some(vars_results) = config.vars_results_cached() {
let vars = vars_results
.vars
.iter()
.map(|(k, (v, _))| (k.clone(), v.clone()))
.collect::<IndexMap<_, _>>();
context.insert("vars", &vars);
} else if !config.vars.is_empty() {
context.insert("vars", &config.vars);
}
}
for (ba, tvp) in tools.iter() {
for tool in &tvp.0 {
let version = self.parse_template_with_context(&context, &tool.tt.to_string())?;
let tvr = if let Some(mut options) = tool.options.clone() {
let mut opts_context = context.clone();
opts_context.insert("version", "{{ version }}");
for v in options.opts.values_mut() {
if let toml::Value::String(s) = v {
*s = self.parse_template_with_context(&opts_context, s)?;
}
}
let mut ba = ba.clone();
let mut ba_opts = ba.opts().clone();
let backend_type = ba.backend_type();
ba_opts.opts.retain(|k, _| {
!crate::backend::is_install_time_option_key_for_type(&backend_type, k)
});
ba_opts.merge(&options.opts);
if let Some(rt) = crate::registry::REGISTRY.get(ba.short.as_str()) {
let full = ba.full();
let mut registry_opts = rt.backend_options(&full);
if let Some(start) = full.rfind('[')
&& full.ends_with(']')
{
let inline = crate::toolset::parse_tool_options(
&full[start + 1..full.len() - 1],
);
for (k, v) in inline.opts {
registry_opts.opts.entry(k).or_insert(v);
}
}
for (k, v) in registry_opts.opts {
ba_opts.opts.entry(k).or_insert(v);
}
}
ba_opts.os = options.os.clone();
ba_opts.depends = options.depends.clone();
ba_opts.install_env = options.install_env.clone();
ba.set_opts(Some(ba_opts.clone()));
ToolRequest::new_opts(ba.into(), &version, ba_opts, source.clone())?
} else {
ToolRequest::new(ba.clone().into(), &version, source.clone())?
};
trs.add_version(tvr, &source);
}
}
Ok(trs)
}
fn aliases(&self) -> eyre::Result<AliasMap> {
if !self.alias.is_empty() {
deprecated!(
"alias",
"[alias] is deprecated, use [tool_alias] instead in {}",
display_path(&self.path)
);
}
let mut combined: AliasMap = self.alias.clone();
for (k, v) in &self.tool_alias {
combined.insert(k.clone(), v.clone());
}
combined
.iter()
.map(|(k, v)| {
let versions = v
.clone()
.versions
.into_iter()
.map(|(k, v)| {
let v = self.parse_template(&v)?;
Ok::<(String, String), eyre::Report>((k, v))
})
.collect::<eyre::Result<IndexMap<_, _>>>()?;
Ok((
k.clone(),
Alias {
backend: v.backend.clone(),
versions,
},
))
})
.collect()
}
fn shell_aliases(&self) -> eyre::Result<IndexMap<String, String>> {
self.shell_alias
.iter()
.map(|(k, v)| {
let v = self.parse_template(v)?;
Ok((k.clone(), v))
})
.collect()
}
fn task_config(&self) -> &TaskConfig {
&self.task_config
}
fn task_config_includes(&self) -> eyre::Result<Option<Vec<String>>> {
self.task_config
.includes
.as_ref()
.map(|includes| {
includes
.iter()
.map(|include| self.parse_template(include))
.collect()
})
.transpose()
}
fn monorepo_root(&self) -> Option<bool> {
self.monorepo_root
}
fn monorepo(&self) -> Option<&MonorepoConfig> {
self.monorepo.as_ref()
}
fn redactions(&self) -> &Redactions {
&self.redactions
}
fn watch_files(&self) -> eyre::Result<Vec<WatchFile>> {
self.watch_files
.iter()
.map(|wf| {
Ok(WatchFile {
patterns: wf
.patterns
.iter()
.map(|p| self.parse_template(p))
.collect::<eyre::Result<Vec<String>>>()?,
run: wf
.run
.as_ref()
.map(|r| self.parse_template(r))
.transpose()?,
shell: wf
.shell
.as_ref()
.map(|s| self.parse_template(s))
.transpose()?,
task: wf
.task
.as_ref()
.map(|t| self.parse_template(t))
.transpose()?,
})
})
.collect()
}
fn hooks(&self) -> eyre::Result<Vec<Hook>> {
Ok(self
.hooks
.iter()
.map(|(hook_type, def)| {
let mut hooks = def.clone().into_hooks(*hook_type);
for hook in hooks.iter_mut() {
hook.render_templates(|s| self.parse_template(s))?;
}
eyre::Ok(hooks)
})
.collect::<eyre::Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect())
}
fn deps_config(&self) -> Option<DepsConfig> {
self.deps.clone()
}
fn oci_config(&self) -> Option<OciConfig> {
self.oci.clone()
}
fn bootstrap_config(&self) -> Option<BootstrapTomlConfig> {
self.bootstrap.clone()
}
fn dotfiles_config(&self) -> Option<DotfilesTomlConfig> {
self.dotfiles.clone()
}
}
fn get_key_with_decor(table: &toml_edit::Table, key: &str) -> Key {
let mut key = Key::from(key);
if let Some((k, _)) = table.get_key_value(&key) {
if let Some(prefix) = k.leaf_decor().prefix() {
key.leaf_decor_mut().set_prefix(prefix.clone());
}
if let Some(suffix) = k.leaf_decor().suffix() {
key.leaf_decor_mut().set_suffix(suffix.clone());
}
}
key
}
impl Debug for MiseToml {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let tools = self.to_tool_request_set().unwrap().to_string();
let title = format!("MiseToml({}): {tools}", &display_path(&self.path));
let mut d = f.debug_struct(&title);
if let Some(min_version) = &self.min_version {
d.field("min_version", min_version);
}
if !self.env_file.is_empty() {
d.field("env_file", &self.env_file);
}
if !self.dotenv.is_empty() {
d.field("dotenv", &self.dotenv);
}
if let Ok(env) = self.env_entries()
&& !env.is_empty()
{
d.field("env", &env);
}
if !self.alias.is_empty() {
d.field("alias", &self.alias);
}
if !self.plugins.is_empty() {
d.field("plugins", &self.plugins);
}
if self.task_config.includes.is_some() {
d.field("task_config", &self.task_config);
}
d.finish()
}
}
impl Clone for MiseToml {
fn clone(&self) -> Self {
Self {
custom: self.custom.clone(),
min_version: self.min_version.clone(),
context: self.context.clone(),
path: self.path.clone(),
env_file: self.env_file.clone(),
dotenv: self.dotenv.clone(),
env: self.env.clone(),
env_path: self.env_path.clone(),
alias: self.alias.clone(),
tool_alias: self.tool_alias.clone(),
shell_alias: self.shell_alias.clone(),
doc: Mutex::new(self.doc.lock().unwrap().clone()),
hooks: self.hooks.clone(),
tools: Mutex::new(self.tools.lock().unwrap().clone()),
redactions: self.redactions.clone(),
plugins: self.plugins.clone(),
tasks: self.tasks.clone(),
task_templates: self.task_templates.clone(),
task_config: self.task_config.clone(),
settings: self.settings.clone(),
watch_files: self.watch_files.clone(),
deps: self.deps.clone(),
oci: self.oci.clone(),
bootstrap: self.bootstrap.clone(),
dotfiles: self.dotfiles.clone(),
vars: self.vars.clone(),
monorepo_root: self.monorepo_root,
monorepo: self.monorepo.clone(),
}
}
}
impl From<ToolRequest> for MiseTomlTool {
fn from(tr: ToolRequest) -> Self {
match tr {
ToolRequest::Version {
version,
options,
backend: _backend,
source: _source,
} => Self {
tt: ToolVersionType::Version(version),
options: if options.is_empty() {
None
} else {
Some(options)
},
},
ToolRequest::Path {
path,
options,
backend: _backend,
source: _source,
} => Self {
tt: ToolVersionType::Path(path),
options: if options.is_empty() {
None
} else {
Some(options)
},
},
ToolRequest::Prefix {
prefix,
options,
backend: _backend,
source: _source,
} => Self {
tt: ToolVersionType::Prefix(prefix),
options: if options.is_empty() {
None
} else {
Some(options)
},
},
ToolRequest::Ref {
ref_,
ref_type,
options,
backend: _backend,
source: _source,
} => Self {
tt: ToolVersionType::Ref(ref_, ref_type),
options: if options.is_empty() {
None
} else {
Some(options)
},
},
ToolRequest::Sub {
sub,
options,
orig_version,
backend: _backend,
source: _source,
} => Self {
tt: ToolVersionType::Sub { sub, orig_version },
options: if options.is_empty() {
None
} else {
Some(options)
},
},
ToolRequest::System {
options,
backend: _backend,
source: _source,
} => Self {
tt: ToolVersionType::System,
options: if options.is_empty() {
None
} else {
Some(options)
},
},
}
}
}
fn deserialize_min_version<'de, D>(deserializer: D) -> Result<Option<MinVersionSpec>, D::Error>
where
D: Deserializer<'de>,
{
struct MinVersionVisitor;
impl<'de> Visitor<'de> for MinVersionVisitor {
type Value = Option<MinVersionSpec>;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("string or table for min_version")
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(None)
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(None)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(self)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let version = Versioning::new(v)
.ok_or_else(|| versions::Error::IllegalVersioning(v.to_string()))
.map_err(E::custom)?;
Ok(MinVersionSpec::new(Some(version), None))
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
self.visit_str(&v)
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let mut hard: Option<Versioning> = None;
let mut soft: Option<Versioning> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"hard" => {
if hard.is_some() {
return Err(de::Error::duplicate_field("hard"));
}
let value: String = map.next_value()?;
let version = Versioning::new(&value)
.ok_or_else(|| versions::Error::IllegalVersioning(value.clone()))
.map_err(de::Error::custom)?;
hard = Some(version);
}
"soft" => {
if soft.is_some() {
return Err(de::Error::duplicate_field("soft"));
}
let value: String = map.next_value()?;
let version = Versioning::new(&value)
.ok_or_else(|| versions::Error::IllegalVersioning(value.clone()))
.map_err(de::Error::custom)?;
soft = Some(version);
}
other => {
return Err(de::Error::unknown_field(other, &["hard", "soft"]));
}
}
}
Ok(MinVersionSpec::new(hard, soft))
}
}
deserializer.deserialize_option(MinVersionVisitor)
}
impl<'de> de::Deserialize<'de> for EnvList {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct EnvManVisitor;
impl<'de> Visitor<'de> for EnvManVisitor {
type Value = EnvList;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("env table or array of env tables")
}
fn visit_seq<S>(self, mut seq: S) -> std::result::Result<Self::Value, S::Error>
where
S: de::SeqAccess<'de>,
{
let mut env = vec![];
while let Some(list) = seq.next_element::<EnvList>()? {
env.extend(list.0);
}
Ok(EnvList(env))
}
fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let mut env = vec![];
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"_" | "mise" => {
#[derive(Deserialize)]
#[serde(untagged)]
enum MiseTomlEnvDirective {
Single {
#[serde(alias = "path")]
value: String,
#[serde(flatten)]
options: EnvDirectiveOptions,
},
Multiple {
#[serde(alias = "value", alias = "path", alias = "paths")]
values: Vec<String>,
#[serde(flatten)]
options: EnvDirectiveOptions,
},
}
impl FromStr for MiseTomlEnvDirective {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(MiseTomlEnvDirective::Single {
value: s.to_string(),
options: Default::default(),
})
}
}
struct EnvDirectivePythonVenv {
path: String,
create: bool,
python: Option<String>,
uv_create_args: Option<Vec<String>>,
python_create_args: Option<Vec<String>>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct EnvDirectivePython {
#[serde(default)]
venv: Option<EnvDirectivePythonVenv>,
}
#[derive(Deserialize)]
struct EnvDirectives {
#[serde(default, deserialize_with = "deserialize_arr")]
path: Vec<MiseTomlEnvDirective>,
#[serde(default, deserialize_with = "deserialize_arr")]
file: Vec<MiseTomlEnvDirective>,
#[serde(default, deserialize_with = "deserialize_arr")]
source: Vec<MiseTomlEnvDirective>,
#[serde(default)]
python: EnvDirectivePython,
#[serde(flatten)]
other: BTreeMap<String, toml::Value>,
}
impl<'de> de::Deserialize<'de> for EnvDirectivePythonVenv {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct EnvDirectivePythonVenvVisitor;
impl<'de> Visitor<'de> for EnvDirectivePythonVenvVisitor {
type Value = EnvDirectivePythonVenv;
fn expecting(
&self,
formatter: &mut Formatter,
) -> std::fmt::Result
{
formatter.write_str("python venv directive")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(EnvDirectivePythonVenv {
path: v.into(),
create: false,
python: None,
uv_create_args: None,
python_create_args: None,
})
}
fn visit_map<M>(
self,
mut map: M,
) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let mut path = None;
let mut create = false;
let mut python = None;
let mut uv_create_args = None;
let mut python_create_args = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"path" => {
path = Some(map.next_value()?);
}
"create" => {
create = map.next_value()?;
}
"python" => {
python = Some(map.next_value()?);
}
"uv_create_args" => {
uv_create_args = Some(map.next_value()?);
}
"python_create_args" => {
python_create_args =
Some(map.next_value()?);
}
_ => {
return Err(de::Error::unknown_field(
&key,
&["path", "create"],
));
}
}
}
let path = path
.ok_or_else(|| de::Error::missing_field("path"))?;
Ok(EnvDirectivePythonVenv {
path,
create,
python,
uv_create_args,
python_create_args,
})
}
}
const FIELDS: &[&str] = &["path", "create"];
deserializer.deserialize_struct(
"PythonVenv",
FIELDS,
EnvDirectivePythonVenvVisitor,
)
}
}
fn flatten_directives<F>(
directives: Vec<MiseTomlEnvDirective>,
constructor: F,
) -> impl Iterator<Item = EnvDirective>
where
F: Fn(String, EnvDirectiveOptions) -> EnvDirective + 'static,
{
directives.into_iter().flat_map(move |d| match d {
MiseTomlEnvDirective::Single { value, options } => {
vec![constructor(value, options)]
}
MiseTomlEnvDirective::Multiple { values, options } => values
.into_iter()
.map(|v| constructor(v, options.clone()))
.collect(),
})
}
let directives = map.next_value::<EnvDirectives>()?;
env.extend(flatten_directives(directives.path, EnvDirective::Path));
env.extend(flatten_directives(directives.file, EnvDirective::File));
env.extend(flatten_directives(directives.source, EnvDirective::Source));
for (key, mut value) in directives.other {
let mut opts = EnvDirectiveOptions::default();
if let Some(table) = value.as_table_mut()
&& let Some(tools) = table.remove("tools")
{
opts.tools = tools.as_bool().unwrap_or(false);
}
env.push(EnvDirective::Module(key, value, opts));
}
if let Some(venv) = directives.python.venv {
env.push(EnvDirective::PythonVenv {
path: venv.path,
create: venv.create,
python: venv.python,
uv_create_args: venv.uv_create_args,
python_create_args: venv.python_create_args,
options: EnvDirectiveOptions {
tools: true,
redact: Some(false),
required: RequiredValue::False,
},
});
}
}
_ => {
#[derive(Deserialize)]
#[serde(untagged)]
pub enum PrimitiveVal {
Str(String),
Int(i64),
Bool(bool),
}
impl PrimitiveVal {
fn into_value_string(self) -> Option<String> {
match self {
PrimitiveVal::Str(s) => Some(s),
PrimitiveVal::Int(i) => Some(i.to_string()),
PrimitiveVal::Bool(true) => Some("true".to_string()),
PrimitiveVal::Bool(false) => None,
}
}
fn into_default_string(self) -> Option<String> {
match self {
PrimitiveVal::Str(s) => Some(s),
PrimitiveVal::Int(i) => Some(i.to_string()),
PrimitiveVal::Bool(_) => None,
}
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Val {
AgeComplex {
age: AgeComplexVal,
},
AgeWithOptions {
age: String,
#[serde(flatten)]
options: EnvDirectiveOptions,
},
Map {
value: PrimitiveVal,
#[serde(flatten)]
options: EnvDirectiveOptions,
},
DefaultMap {
default: PrimitiveVal,
#[serde(flatten)]
options: EnvDirectiveOptions,
},
OptionsOnly {
#[serde(flatten)]
options: EnvDirectiveOptions,
},
Primitive(PrimitiveVal),
}
#[derive(Deserialize)]
struct AgeComplexVal {
value: String,
#[serde(default)]
format: Option<AgeFormat>,
#[serde(flatten)]
options: EnvDirectiveOptions,
}
let raw_value = map.next_value::<toml::Value>()?;
if let Some(table) = raw_value.as_table() {
let has_default = table.contains_key("default");
if has_default && table.contains_key("value") {
return Err(serde::de::Error::custom(format!(
"Environment variable '{}' cannot have both 'value' and 'default'. The 'value' field always overwrites, while 'default' only applies when the variable is unset or empty. Remove either the 'value' field or the 'default' field.",
key
)));
}
if has_default && table.contains_key("required") {
return Err(serde::de::Error::custom(format!(
"Environment variable '{}' cannot have both 'default' and 'required'. The 'required' flag means the variable must be defined elsewhere, while 'default' provides a fallback value. Remove either the 'default' field or the 'required' flag.",
key
)));
}
if has_default && table.contains_key("age") {
return Err(serde::de::Error::custom(format!(
"Environment variable '{}' cannot have both 'age' and 'default'. Remove either the 'age' field or the 'default' field.",
key
)));
}
}
let val_result = raw_value.try_into::<Val>().map_err(|e| {
serde::de::Error::custom(format!(
"failed to parse environment variable '{}': {}",
key, e
))
})?;
match &val_result {
Val::AgeComplex { age } => {
let directive = EnvDirective::Age {
key: key.clone(),
value: age.value.clone(),
format: age.format.clone(),
options: age.options.clone(),
};
env.push(directive);
continue;
}
Val::AgeWithOptions { age, options } => {
let directive = EnvDirective::Age {
key: key.clone(),
value: age.clone(),
format: None, options: options.clone(),
};
env.push(directive);
continue;
}
_ => {}
}
let directive = match val_result {
Val::Primitive(p) => match p.into_value_string() {
Some(s) => {
EnvDirective::Val(key, s, EnvDirectiveOptions::default())
}
None => EnvDirective::Rm(key, EnvDirectiveOptions::default()),
},
Val::Map { value, options } => {
if options.required.is_required() {
return Err(serde::de::Error::custom(format!(
"Environment variable '{}' cannot have both 'value' and 'required'. The 'required' flag means the variable must be defined elsewhere (in the environment or a later config file). Remove either the 'value' field or the 'required' flag.",
key
)));
}
match value.into_value_string() {
Some(s) => EnvDirective::Val(key, s, options),
None => EnvDirective::Rm(key, options),
}
}
Val::DefaultMap { default, options } => {
let Some(default) = default.into_default_string() else {
return Err(serde::de::Error::custom(format!(
"Environment variable '{}' default cannot be a boolean. Use a string or integer fallback instead.",
key
)));
};
EnvDirective::Default(key, default, options)
}
Val::OptionsOnly { options } => {
if !options.required.is_required() {
return Err(serde::de::Error::custom(format!(
"Environment variable '{}' has no value. Either provide a value or set required=true to indicate it must be defined elsewhere.",
key
)));
}
EnvDirective::Required(key, options)
}
Val::AgeComplex { .. } | Val::AgeWithOptions { .. } => {
unreachable!() }
};
env.push(directive);
}
}
}
Ok(EnvList(env))
}
}
deserializer.deserialize_any(EnvManVisitor)
}
}
impl<'de> de::Deserialize<'de> for MiseTomlToolList {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct MiseTomlToolListVisitor;
impl<'de> Visitor<'de> for MiseTomlToolListVisitor {
type Value = MiseTomlToolList;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("tool list")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let tt: ToolVersionType = v
.parse()
.map_err(|e| de::Error::custom(format!("invalid tool: {e}")))?;
Ok(MiseTomlToolList(vec![MiseTomlTool { tt, options: None }]))
}
fn visit_seq<S>(self, mut seq: S) -> std::result::Result<Self::Value, S::Error>
where
S: de::SeqAccess<'de>,
{
let mut tools = vec![];
while let Some(tool) = seq.next_element::<MiseTomlTool>()? {
tools.push(tool);
}
Ok(MiseTomlToolList(tools))
}
fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let mut options: ToolVersionOptions = Default::default();
let mut tt: Option<ToolVersionType> = None;
while let Some((k, v)) = map.next_entry::<String, toml::Value>()? {
match k.as_str() {
"version" => {
tt = Some(v.as_str().unwrap().parse().map_err(de::Error::custom)?);
}
"path" | "prefix" | "ref" => {
tt = Some(
format!("{k}:{}", v.as_str().unwrap())
.parse()
.map_err(de::Error::custom)?,
);
}
_ => {
insert_tool_option(&mut options, k, v)?;
}
}
}
if let Some(tt) = tt {
Ok(MiseTomlToolList(vec![MiseTomlTool {
tt,
options: Some(options),
}]))
} else {
Err(de::Error::custom("missing version"))
}
}
}
deserializer.deserialize_any(MiseTomlToolListVisitor)
}
}
impl<'de> de::Deserialize<'de> for MiseTomlTool {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct MiseTomlToolVisitor;
impl<'de> Visitor<'de> for MiseTomlToolVisitor {
type Value = MiseTomlTool;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("tool definition")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let tt: ToolVersionType = v
.parse()
.map_err(|e| de::Error::custom(format!("invalid tool: {e}")))?;
Ok(MiseTomlTool { tt, options: None })
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let mut options: ToolVersionOptions = Default::default();
let mut tt = ToolVersionType::System;
while let Some((k, v)) = map.next_entry::<String, toml::Value>()? {
match k.as_str() {
"version" => {
tt = v.as_str().unwrap().parse().map_err(de::Error::custom)?;
}
"path" | "prefix" | "ref" => {
tt = format!("{k}:{}", v.as_str().unwrap())
.parse()
.map_err(de::Error::custom)?;
}
_ => {
insert_tool_option(&mut options, k, v)?;
}
}
}
Ok(MiseTomlTool {
tt,
options: Some(options),
})
}
}
deserializer.deserialize_any(MiseTomlToolVisitor)
}
}
impl<'de> de::Deserialize<'de> for Tasks {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct TasksVisitor;
impl<'de> Visitor<'de> for TasksVisitor {
type Value = Tasks;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("task, string, or array of strings")
}
fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
struct TaskDef(Task);
impl<'de> de::Deserialize<'de> for TaskDef {
fn deserialize<D>(deserializer: D) -> std::result::Result<TaskDef, D::Error>
where
D: de::Deserializer<'de>,
{
struct TaskDefVisitor;
impl<'de> Visitor<'de> for TaskDefVisitor {
type Value = TaskDef;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("task definition")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(TaskDef(Task {
run: vec![crate::task::RunEntry::Script(v.to_string())],
..Default::default()
}))
}
fn visit_seq<S>(self, mut seq: S) -> Result<Self::Value, S::Error>
where
S: de::SeqAccess<'de>,
{
let mut run = vec![];
while let Some(s) = seq.next_element::<crate::task::RunEntry>()? {
run.push(s);
}
Ok(TaskDef(Task {
run,
..Default::default()
}))
}
fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let t = de::Deserialize::deserialize(
de::value::MapAccessDeserializer::new(map),
)?;
Ok(TaskDef(t))
}
}
deserializer.deserialize_any(TaskDefVisitor)
}
}
let mut tasks = BTreeMap::new();
while let Some(name) = map.next_key::<String>()? {
let mut task = map.next_value::<TaskDef>()?.0;
task.name.clone_from(&name);
tasks.insert(name, task);
}
Ok(Tasks(tasks))
}
}
deserializer.deserialize_any(TasksVisitor)
}
}
impl<'de> de::Deserialize<'de> for TaskTemplates {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct TaskTemplatesVisitor;
impl<'de> Visitor<'de> for TaskTemplatesVisitor {
type Value = TaskTemplates;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("map of task template names to template definitions")
}
fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let mut templates = IndexMap::new();
while let Some(name) = map.next_key::<String>()? {
let template: TaskTemplate = map.next_value()?;
templates.insert(name, template);
}
Ok(TaskTemplates(templates))
}
}
deserializer.deserialize_any(TaskTemplatesVisitor)
}
}
impl<'de> de::Deserialize<'de> for BackendArg {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct BackendArgVisitor;
impl Visitor<'_> for BackendArgVisitor {
type Value = BackendArg;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("backend argument")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(v.into())
}
}
deserializer.deserialize_any(BackendArgVisitor)
}
}
impl<'de> de::Deserialize<'de> for Alias {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct AliasVisitor;
impl<'de> Visitor<'de> for AliasVisitor {
type Value = Alias;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("alias")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(Alias {
backend: Some(v.to_string()),
..Default::default()
})
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let mut backend = None;
let mut versions = IndexMap::new();
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"backend" => {
backend = Some(map.next_value()?);
}
"versions" => {
versions = map.next_value()?;
}
_ => {
deprecated!(
"TOOL_VERSION_ALIASES",
"tool version aliases should be `alias.<TOOL>.versions.<FROM> = <TO>`, not `alias.<TOOL>.<FROM> = <TO>`"
);
versions.insert(key, map.next_value()?);
}
}
}
Ok(Alias { backend, versions })
}
}
deserializer.deserialize_any(AliasVisitor)
}
}
fn is_safe_config_body(body: &str) -> bool {
if contains_template_syntax(body) {
return false;
}
let Ok(toml::Value::Table(table)) = toml::from_str::<toml::Value>(body) else {
return false;
};
if toml_table_has_template(&table) {
return false;
}
table.iter().all(|(key, value)| match key.as_str() {
"min_version" | "tasks" => true,
"tools" => value.as_table().is_some_and(|tools| {
tools.values().all(|version| match version {
toml::Value::String(_) => true,
toml::Value::Array(versions) => {
versions.iter().all(|v| matches!(v, toml::Value::String(_)))
}
_ => false,
})
}),
_ => false,
})
}
pub(crate) fn toml_value_has_template(value: &toml::Value) -> bool {
match value {
toml::Value::String(s) => contains_template_syntax(s),
toml::Value::Array(arr) => arr.iter().any(toml_value_has_template),
toml::Value::Table(t) => toml_table_has_template(t),
_ => false,
}
}
fn toml_table_has_template(table: &toml::Table) -> bool {
table
.iter()
.any(|(k, v)| contains_template_syntax(k) || toml_value_has_template(v))
}
fn is_tools_sorted(tools: &IndexMap<BackendArg, MiseTomlToolList>) -> bool {
let mut last = None;
for k in tools.keys() {
if let Some(last) = last
&& k < last
{
return false;
}
last = Some(k);
}
true
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use std::sync::Arc;
use indoc::{formatdoc, indoc};
use insta::{assert_debug_snapshot, assert_snapshot};
use test_log::test;
use crate::dirs;
use crate::file;
use crate::test::replace_path;
use crate::toolset::{CoreToolOptions, ToolRequest};
use crate::{config::Config, dirs::CWD};
use super::*;
#[tokio::test]
async fn test_fixture() {
let _config = Config::get().await.unwrap();
let cf = MiseToml::from_file(&dirs::HOME.join("fixtures/.mise.toml")).unwrap();
assert_debug_snapshot!(cf.env_entries().unwrap());
assert_debug_snapshot!(cf.plugins().unwrap());
assert_snapshot!(replace_path(&format!(
"{:#?}",
cf.to_tool_request_set().unwrap()
)));
assert_debug_snapshot!(cf.alias);
assert_snapshot!(replace_path(&format!("{:#?}", &cf)));
}
#[tokio::test]
async fn test_env() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
formatdoc! {r#"
min_version = "2024.1.1"
[env]
foo="bar"
foo2='qux\nquux'
foo3="qux\nquux"
"#},
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let dump = cf.dump().unwrap();
let env = parse_env(file::read_to_string(&p).unwrap());
assert_debug_snapshot!(env, @r#""foo=bar\nfoo2=qux\\nquux\nfoo3=qux\nquux""#);
let cf: Box<dyn ConfigFile> = Box::new(cf);
with_settings!({
assert_snapshot!(dump);
assert_snapshot!(cf);
assert_debug_snapshot!(cf);
});
}
#[tokio::test]
async fn test_env_var_in_tool() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
r#"
[env]
TERRAFORM_VERSION = '1.0.0'
JQ_PREFIX = '1.6'
[tools]
terraform = "{{env.TERRAFORM_VERSION}}"
jq = { prefix = "{{ env.JQ_PREFIX }}" }
"#,
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
assert_snapshot!(replace_path(&format!(
"{:#?}",
cf.to_tool_request_set().unwrap().tools
)));
}
#[tokio::test]
async fn test_bootstrap_packages() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
r#"
[bootstrap.packages]
"apt:libssl-dev" = "latest"
"apt:curl" = "8.5.0-2"
"brew:postgresql@17" = "latest"
"future-manager:whatever" = "latest"
[bootstrap.brew.taps]
"railwaycat/emacsmacport" = "https://github.com/railwaycat/homebrew-emacsmacport"
[bootstrap.hooks.pre-packages]
run = "echo preparing"
[bootstrap.hooks.post-tools]
run = ["echo one", "echo two"]
"#,
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let system = cf.bootstrap_config().unwrap();
assert_eq!(system.packages.get("apt:libssl-dev").unwrap(), "latest");
assert_eq!(system.packages.get("apt:curl").unwrap(), "8.5.0-2");
assert_eq!(system.packages.get("brew:postgresql@17").unwrap(), "latest");
assert_eq!(
system.brew.taps.get("railwaycat/emacsmacport").unwrap(),
"https://github.com/railwaycat/homebrew-emacsmacport"
);
assert!(system.hooks.get("pre-packages").unwrap().is_table());
assert!(system.hooks.get("post-tools").unwrap().is_table());
assert_eq!(system.user.login_shell, None);
assert_eq!(
system.packages.get("future-manager:whatever").unwrap(),
"latest"
);
file::write(&p, "[tools]\n").unwrap();
let cf = MiseToml::from_file(&p).unwrap();
assert!(cf.bootstrap_config().is_none());
file::remove_file(&p).unwrap();
}
#[tokio::test]
async fn test_bootstrap_login_shell() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
r#"
[bootstrap.user]
login_shell = "/bin/zsh"
"#,
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let system = cf.bootstrap_config().unwrap();
assert_eq!(system.user.login_shell.as_deref(), Some("/bin/zsh"));
file::remove_file(&p).unwrap();
}
#[tokio::test]
async fn test_bootstrap_macos_defaults() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
r#"
[bootstrap.macos.defaults]
NSGlobalDomain = { KeyRepeat = 2, ApplePressAndHoldEnabled = false }
"com.apple.dock" = { autohide = true, tilesize = 48, magnification-scale = 1.5, orientation = "left", future-array = [1, 2] }
[bootstrap.macos.dock]
show_recents = false
[bootstrap.macos.finder]
show_all_files = true
preferred_view_style = "list"
[bootstrap.macos.keyboard]
initial_key_repeat = 15
[bootstrap.macos.trackpad]
tap_to_click = true
"#,
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let system = cf.bootstrap_config().unwrap();
let global = system.macos.defaults.get("NSGlobalDomain").unwrap();
assert_eq!(global.get("KeyRepeat").unwrap(), &toml::Value::Integer(2));
assert_eq!(
global.get("ApplePressAndHoldEnabled").unwrap(),
&toml::Value::Boolean(false)
);
let dock = system.macos.defaults.get("com.apple.dock").unwrap();
assert_eq!(dock.get("autohide").unwrap(), &toml::Value::Boolean(true));
assert_eq!(dock.get("tilesize").unwrap(), &toml::Value::Integer(48));
assert_eq!(
dock.get("magnification-scale").unwrap(),
&toml::Value::Float(1.5)
);
assert_eq!(
dock.get("orientation").unwrap(),
&toml::Value::String("left".into())
);
assert!(dock.get("future-array").unwrap().is_array());
assert_eq!(
system.macos.dock.get("show_recents").unwrap(),
&toml::Value::Boolean(false)
);
assert_eq!(
system.macos.finder.get("show_all_files").unwrap(),
&toml::Value::Boolean(true)
);
assert_eq!(
system.macos.finder.get("preferred_view_style").unwrap(),
&toml::Value::String("list".into())
);
assert_eq!(
system.macos.keyboard.get("initial_key_repeat").unwrap(),
&toml::Value::Integer(15)
);
assert_eq!(
system.macos.trackpad.get("tap_to_click").unwrap(),
&toml::Value::Boolean(true)
);
file::remove_file(&p).unwrap();
}
#[tokio::test]
async fn test_bootstrap_macos_launchd_agents() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
r#"
[bootstrap.macos.launchd.agents.my-sync]
program = "~/.local/bin/my-sync"
args = ["--watch"]
run_at_load = true
start_interval = 300
environment = { PATH = "/usr/bin:/bin" }
working_directory = "~"
stdout_path = "~/Library/Logs/my-sync.log"
kickstart = true
"#,
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let system = cf.bootstrap_config().unwrap();
let agent = system.macos.launchd.agents.get("my-sync").unwrap();
assert_eq!(agent.program.as_deref(), Some("~/.local/bin/my-sync"));
assert_eq!(agent.args, vec!["--watch"]);
assert!(agent.run_at_load);
assert_eq!(agent.start_interval, Some(300));
assert_eq!(
agent.environment.get("PATH").map(String::as_str),
Some("/usr/bin:/bin")
);
assert_eq!(agent.working_directory.as_deref(), Some("~"));
assert_eq!(
agent.stdout_path.as_deref(),
Some("~/Library/Logs/my-sync.log")
);
assert!(agent.kickstart);
file::remove_file(&p).unwrap();
}
#[tokio::test]
async fn test_update_bootstrap_package() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(&p, "[tools]\njq = \"latest\"\n").unwrap();
let mut cf = MiseToml::from_file(&p).unwrap();
cf.update_bootstrap_package("apt:curl", "latest").unwrap();
cf.update_bootstrap_package("brew:postgresql@17", "latest")
.unwrap();
cf.update_bootstrap_package("apt:curl", "8.5.0-2").unwrap();
assert_snapshot!(cf.dump().unwrap(), @r#"
[tools]
jq = "latest"
[bootstrap.packages]
"apt:curl" = "8.5.0-2"
"brew:postgresql@17" = "latest"
"#);
let system = cf.bootstrap_config().unwrap();
assert_eq!(system.packages.get("apt:curl").unwrap(), "8.5.0-2");
file::remove_file(&p).unwrap();
}
#[tokio::test]
async fn test_core_options_do_not_normalize_version_placeholder() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
r#"
[tools]
node = { version = "1.0.0", depends = ["{{version}}"], install_env = { FOO = "{{version}}" }, url = "https://example.com/{{version}}" }
"#,
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let trs = cf.to_tool_request_set().unwrap();
let node_req = trs
.tools
.iter()
.find(|(ba, _)| ba.short == "node")
.and_then(|(_, reqs)| reqs.first())
.unwrap();
let opts = node_req.options();
assert_eq!(opts.depends, Some(vec!["{{version}}".to_string()]));
assert_eq!(
opts.install_env.get("FOO").map(String::as_str),
Some("{{version}}")
);
assert_eq!(opts.get("url"), Some("https://example.com/{version}"));
file::remove_file(&p).unwrap();
}
#[tokio::test]
async fn test_tool_options_preserve_quoted_literal_dotted_keys() {
crate::toolset::install_state::init().await.unwrap();
let cf = MiseToml::from_str(
r#"
[tools."aqua:example/vars-tool"]
version = "1.0.0"
fixture_version = "2.0.0"
"vars.fixture_version" = "1.0.0"
"#,
std::path::Path::new("mise.toml"),
)
.unwrap();
let trs = cf.to_tool_request_set().unwrap();
let (_, requests, _) = trs.iter().next().unwrap();
let opts = requests[0].options();
assert_eq!(opts.get("fixture_version"), Some("2.0.0"));
assert_eq!(opts.get("vars.fixture_version"), Some("1.0.0"));
}
#[tokio::test]
async fn test_env_source_var_in_tool() {
let cwd = CWD.as_ref().unwrap();
let script = cwd.join("set-go-version.sh");
let config_file = cwd.join(".test.mise.toml");
file::write(&script, "export MY_GO_VERSION=\"1.2.3\"\n").unwrap();
file::write(
&config_file,
r#"
[env]
_.source = "./set-go-version.sh"
[tools]
go = "{{env.MY_GO_VERSION}}"
"#,
)
.unwrap();
let config = Config::reset().await.unwrap();
let trs = config.get_tool_request_set().await.unwrap();
let go_req = trs
.tools
.iter()
.find(|(ba, _)| ba.short == "go")
.and_then(|(_, reqs)| reqs.first())
.unwrap();
assert_eq!(go_req.version(), "1.2.3");
file::remove_file(&config_file).unwrap();
file::remove_file(&script).unwrap();
}
#[tokio::test]
async fn test_env_array_valid() {
let _config = Config::get().await.unwrap();
let env = parse_env(formatdoc! {r#"
[[env]]
foo="bar"
[[env]]
bar="baz"
[[env]]
foo2='qux\nquux'
bar2="qux\nquux"
"#});
assert_snapshot!(env, @r"
foo=bar
bar=baz
foo2=qux\nquux
bar2=qux
quux
");
}
#[tokio::test]
async fn test_path_dirs() {
let _config = Config::get().await.unwrap();
let env = parse_env(formatdoc! {r#"
env_path=["/foo", "./bar"]
[env]
foo="bar"
"#});
assert_snapshot!(env, @r#"
_.path = "/foo"
_.path = "./bar"
foo=bar
"#);
let env = parse_env(formatdoc! {r#"
env_path="./bar"
"#});
assert_snapshot!(env, @r#"_.path = "./bar""#);
let env = parse_env(formatdoc! {r#"
[env]
_.path = "./bar"
"#});
assert_debug_snapshot!(env, @r#""_.path = \"./bar\"""#);
let env = parse_env(formatdoc! {r#"
[env]
_.path = ["/foo", "./bar"]
"#});
assert_snapshot!(env, @r#"
_.path = "/foo"
_.path = "./bar"
"#);
let env = parse_env(formatdoc! {r#"
[[env]]
_.path = "/foo"
[[env]]
_.path = "./bar"
"#});
assert_snapshot!(env, @r#"
_.path = "/foo"
_.path = "./bar"
"#);
let env = parse_env(formatdoc! {r#"
env_path = "/foo"
[env]
_.path = "./bar"
"#});
assert_snapshot!(env, @r#"
_.path = "/foo"
_.path = "./bar"
"#);
}
#[tokio::test]
async fn test_env_file() {
let _config = Config::get().await.unwrap();
let env = parse_env(formatdoc! {r#"
env_file = ".env"
"#});
assert_debug_snapshot!(env, @r#""_.file = \".env\"""#);
let env = parse_env(formatdoc! {r#"
env_file=[".env", ".env2"]
"#});
assert_debug_snapshot!(env, @r#""_.file = \".env\"\n_.file = \".env2\"""#);
let env = parse_env(formatdoc! {r#"
[env]
_.file = ".env"
"#});
assert_debug_snapshot!(env, @r#""_.file = \".env\"""#);
let env = parse_env(formatdoc! {r#"
[env]
_.file = [".env", ".env2"]
"#});
assert_debug_snapshot!(env, @r#""_.file = \".env\"\n_.file = \".env2\"""#);
let env = parse_env(formatdoc! {r#"
dotenv = ".env"
[env]
_.file = ".env2"
"#});
assert_debug_snapshot!(env, @r#""_.file = \".env\"\n_.file = \".env2\"""#);
let env = parse_env(formatdoc! {r#"
env_file = ".env"
dotenv = ".env2"
"#});
assert_debug_snapshot!(env, @r#""_.file = \".env\"\n_.file = \".env2\"""#);
}
#[tokio::test]
async fn test_set_alias() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
formatdoc! {r#"
[tool_alias.node.versions]
16 = "16.0.0"
18 = "18.0.0"
"#},
)
.unwrap();
let mut cf = MiseToml::from_file(&p).unwrap();
let node = "node".into();
let python = "python".into();
cf.set_alias(&node, "18", "18.0.1").unwrap();
cf.set_alias(&node, "20", "20.0.0").unwrap();
cf.set_alias(&python, "3.10", "3.10.0").unwrap();
assert_debug_snapshot!(cf.tool_alias);
let cf: Box<dyn ConfigFile> = Box::new(cf);
assert_snapshot!(cf);
file::remove_file(&p).unwrap();
}
#[test]
fn test_tasks_confirm_parses() {
let body = r#"
[tasks.deploy]
confirm = { message = "Are you sure you want to deploy to ({{ env.HOME }})?", default = "no" }
run = 'echo " $usage_environment"'
"#;
let path = std::path::Path::new("/tmp/mise.toml");
let rf = MiseToml::from_str(body, path).unwrap();
let task = rf.tasks.0.get("deploy").expect("deploy task should exist");
assert!(matches!(
task.confirm,
Some(crate::task::TaskConfirm::Options { .. })
));
}
#[test]
fn test_task_templates_confirm_parses() {
let body = r#"
[task_templates.deploy]
confirm = { message = "Are you sure?", default = "no" }
run = 'echo "template"'
"#;
let path = std::path::Path::new("/tmp/mise.toml");
let rf = MiseToml::from_str(body, path).unwrap();
let template = rf
.task_templates
.0
.get("deploy")
.expect("deploy template should exist");
assert!(matches!(
template.confirm,
Some(crate::task::TaskConfirm::Options { .. })
));
}
#[tokio::test]
async fn test_remove_alias() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
formatdoc! {r#"
[alias.node.versions]
16 = "16.0.0"
18 = "18.0.0"
[alias.python.versions]
"3.10" = "3.10.0"
"#},
)
.unwrap();
let mut cf = MiseToml::from_file(&p).unwrap();
let node = "node".into();
let python = "python".into();
cf.remove_alias(&node, "16").unwrap();
cf.remove_alias(&python, "3.10").unwrap();
assert_debug_snapshot!(cf.alias);
let cf: Box<dyn ConfigFile> = Box::new(cf);
assert_snapshot!(cf.dump().unwrap());
assert_snapshot!(cf);
assert_debug_snapshot!(cf);
file::remove_file(&p).unwrap();
}
#[tokio::test]
async fn test_replace_versions() {
let _config = Config::get().await.unwrap();
let p = PathBuf::from("/tmp/.mise.toml");
file::write(
&p,
formatdoc! {r#"
[tools]
node = ["16.0.0", "18.0.0"]
"#},
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let node = "node".into();
cf.replace_versions(
&node,
vec![
ToolRequest::new(Arc::new("node".into()), "16.0.1", ToolSource::Unknown).unwrap(),
ToolRequest::new(Arc::new("node".into()), "18.0.1", ToolSource::Unknown).unwrap(),
],
)
.unwrap();
assert_debug_snapshot!(cf.to_toolset().unwrap());
let cf: Box<dyn ConfigFile> = Box::new(cf);
assert_snapshot!(cf.dump().unwrap());
assert_snapshot!(cf);
assert_debug_snapshot!(cf);
file::remove_all(&p).unwrap();
}
#[tokio::test]
async fn test_remove_plugin() {
let _config = Config::get().await.unwrap();
let p = PathBuf::from("/tmp/.mise.toml");
file::write(
&p,
formatdoc! {r#"
[tools]
node = ["16.0.0", "18.0.0"]
"#},
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
cf.remove_tool(&"node".into()).unwrap();
assert_debug_snapshot!(cf.to_toolset().unwrap());
let cf: Box<dyn ConfigFile> = Box::new(cf);
assert_snapshot!(cf.dump().unwrap());
assert_snapshot!(cf);
assert_debug_snapshot!(cf);
}
#[test]
fn test_env_entries() {
let toml = formatdoc! {r#"
[env]
foo1="1"
rm=false
_.path="/foo"
foo2="2"
_.file=".env"
foo3="3"
"#};
assert_snapshot!(parse_env(toml), @r#"
foo1=1
unset rm
_.path = "/foo"
_.file = ".env"
foo2=2
foo3=3
"#);
}
#[test]
fn test_env_default_entries() {
let toml = indoc! {r#"
[env]
foo1 = { default = "fallback" }
foo2 = { default = 2, tools = true }
"#}
.to_string();
assert_snapshot!(parse_env(toml), @r#"
foo1 default=fallback
foo2 default=2
"#);
}
#[test]
fn test_env_default_invalid_combinations() {
let err = parse_error("[env]\nFOO = { value = \"x\", default = \"y\" }\n");
assert!(
err.contains("cannot have both 'value' and 'default'"),
"{err}"
);
let err = parse_error("[env]\nFOO = { required = true, default = \"y\" }\n");
assert!(
err.contains("cannot have both 'default' and 'required'"),
"{err}"
);
let err = parse_error("[env]\nFOO = { age = \"AGE-SECRET\", default = \"y\" }\n");
assert!(
err.contains("cannot have both 'age' and 'default'"),
"{err}"
);
let err = parse_error("[env]\nFOO = { default = false }\n");
assert!(err.contains("default cannot be a boolean"), "{err}");
}
#[test]
fn test_env_arr() {
let toml = formatdoc! {r#"
[[env]]
foo1="1"
rm=false
_.path="/foo"
foo2="2"
_.file=".env"
foo3="3"
_.source="/baz1"
[[env]]
foo4="4"
rm=false
_.file=".env2"
foo5="5"
_.path="/bar"
foo6="6"
_.source="/baz2"
"#};
assert_snapshot!(parse_env(toml), @r#"
foo1=1
unset rm
_.path = "/foo"
_.file = ".env"
_.source = "/baz1"
foo2=2
foo3=3
foo4=4
unset rm
_.path = "/bar"
_.file = ".env2"
_.source = "/baz2"
foo5=5
foo6=6
"#);
}
fn parse(s: String) -> MiseToml {
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(&p, s).unwrap();
let cfg = MiseToml::from_file(&p).unwrap();
file::remove_file(&p).unwrap();
cfg
}
fn parse_env(toml: String) -> String {
parse(toml).env_entries().unwrap().into_iter().join("\n")
}
fn parse_error(toml: &str) -> String {
#[derive(Debug, Deserialize)]
struct TestConfig {
#[allow(dead_code)]
env: EnvList,
}
toml::from_str::<TestConfig>(toml).unwrap_err().to_string()
}
#[test]
fn test_is_safe_config_body() {
assert!(is_safe_config_body(""));
assert!(is_safe_config_body(indoc! {r#"
min_version = "2024.1.1"
[tools]
node = "20"
python = ["3.11", "3.12"]
"cargo:eza" = "latest"
"#}));
assert!(is_safe_config_body(indoc! {r#"
[tasks.build]
run = "cargo build"
dir = "src"
env = { FOO = "bar" }
[tasks.test]
depends = ["build"]
run = ["cargo test"]
"#}));
assert!(!is_safe_config_body(indoc! {r#"
[tools]
node = "{{ exec(command='echo 20') }}"
"#}));
assert!(!is_safe_config_body(indoc! {r#"
[tools]
node = { version = "20", postinstall = "corepack enable" }
"#}));
assert!(!is_safe_config_body(indoc! {r#"
[tools]
node = [{ version = "20" }]
"#}));
assert!(!is_safe_config_body(indoc! {r#"
[tasks.build]
run = "cargo build"
description = "{{ exec(command='echo hi') }}"
"#}));
assert!(!is_safe_config_body(
"[tools]\nnode = \"\\u007b\\u007b exec(command='echo 20') \\u007d\\u007d\"\n"
));
assert!(!is_safe_config_body(
"[tasks.build]\nrun = \"cargo build\"\ndescription = \"\\u007b\\u007b exec(command='echo hi') \\u007d\\u007d\"\n"
));
assert!(!is_safe_config_body(
"[tasks]\n\"\\u007b\\u007b exec() \\u007d\\u007d\" = { run = \"x\" }\n"
));
for body in [
"[env]\nFOO = \"bar\"",
"[task_config]\nincludes = [\"tasks.toml\"]",
"[hooks]\nenter = \"echo hi\"",
"[settings]\nparanoid = false",
"[alias]\nnode = \"asdf:foo/bar\"",
"[plugins]\nfoo = \"https://example.com/foo.git\"",
"env_file = \".env\"",
] {
assert!(!is_safe_config_body(body), "should require trust: {body}");
}
assert!(!is_safe_config_body("[tools"));
}
#[tokio::test]
async fn test_table_syntax_preserves_registry_defaults() {
let _config = Config::get().await.unwrap();
let cf = parse(formatdoc! {r#"
[tools]
ansible = {{ version = "latest" }}
"#});
let trs = cf.to_tool_request_set().unwrap();
let tools = trs.tools;
let ansible_requests = tools
.iter()
.find(|(ba, _)| ba.short == "ansible")
.map(|(_, reqs)| reqs)
.expect("ansible should be in tool request set");
let opts = ansible_requests[0].options();
assert_eq!(
opts.get_string("uvx").as_deref(),
Some("false"),
"registry default uvx=false should be preserved with table syntax"
);
assert_eq!(
opts.get("pipx_args"),
Some("--include-deps"),
"registry default pipx_args=--include-deps should be preserved with table syntax"
);
let cf2 = parse(formatdoc! {r#"
[tools]
ansible = {{ version = "latest", uvx = "true" }}
"#});
let trs2 = cf2.to_tool_request_set().unwrap();
let ansible2 = trs2
.tools
.iter()
.find(|(ba, _)| ba.short == "ansible")
.map(|(_, reqs)| reqs)
.expect("ansible should be in tool request set");
let opts2 = ansible2[0].options();
assert_eq!(
opts2.get_string("uvx").as_deref(),
Some("true"),
"user-provided uvx=true should override registry default uvx=false"
);
assert_eq!(
opts2.get("pipx_args"),
Some("--include-deps"),
"non-overridden registry default pipx_args should still be preserved"
);
}
#[tokio::test]
async fn test_depends_field_parsing() {
let _config = Config::get().await.unwrap();
let cf = parse(formatdoc! {r#"
[tools]
dummy = {{ version = "latest", depends = ["tiny"] }}
"#});
let trs = cf.to_tool_request_set().unwrap();
let dummy = trs
.tools
.iter()
.find(|(ba, _)| ba.short == "dummy")
.map(|(_, reqs)| reqs)
.expect("dummy should be in tool request set");
let opts = dummy[0].options();
assert_eq!(
opts.depends,
Some(vec!["tiny".to_string()]),
"depends should be parsed as a named field"
);
assert!(
!opts.opts.contains_key("depends"),
"depends should not leak into opts"
);
}
#[tokio::test]
async fn test_depends_field_single_string() {
let _config = Config::get().await.unwrap();
let cf = parse(formatdoc! {r#"
[tools]
dummy = {{ version = "latest", depends = "tiny" }}
"#});
let trs = cf.to_tool_request_set().unwrap();
let dummy = trs
.tools
.iter()
.find(|(ba, _)| ba.short == "dummy")
.map(|(_, reqs)| reqs)
.expect("dummy should be in tool request set");
let opts = dummy[0].options();
assert_eq!(
opts.depends,
Some(vec!["tiny".to_string()]),
"single string depends should be wrapped in a vec"
);
}
#[tokio::test]
async fn test_os_field_single_string() {
let _config = Config::get().await.unwrap();
let cf = parse(formatdoc! {r#"
[tools]
dummy = {{ version = "latest", os = "linux" }}
"#});
let trs = cf.to_tool_request_set().unwrap();
let dummy = trs
.tools
.iter()
.find(|(ba, _)| ba.short == "dummy")
.map(|(_, reqs)| reqs)
.expect("dummy should be in tool request set");
let opts = dummy[0].options();
assert_eq!(
opts.os,
Some(vec!["linux".to_string()]),
"single string os should be wrapped in a vec"
);
assert!(
!opts.opts.contains_key("os"),
"os should not leak into opts"
);
}
#[tokio::test]
async fn test_replace_versions_preserves_named_core_options() {
let _config = Config::get().await.unwrap();
let p = CWD
.as_ref()
.unwrap()
.join(".replace-core-options.mise.toml");
file::write(
&p,
formatdoc! {r#"
[tools]
needs-dummy = "1.0.0"
"#},
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let needs_dummy = "needs-dummy".into();
let mut options = ToolVersionOptions {
core: CoreToolOptions {
os: Some(vec!["linux".to_string()]),
depends: Some(vec!["dummy".to_string()]),
..Default::default()
},
..Default::default()
};
options
.install_env
.insert("FOO".to_string(), "bar".to_string());
cf.replace_versions(
&needs_dummy,
vec![
ToolRequest::new_opts(
Arc::new("needs-dummy".into()),
"1.0.1",
options,
ToolSource::Unknown,
)
.unwrap(),
],
)
.unwrap();
let dump = cf.dump().unwrap();
assert!(dump.contains("depends"), "depends should be written back");
assert!(
dump.contains("dummy"),
"depends value should be written back"
);
assert!(dump.contains("os"), "os should be written back");
assert!(
dump.contains("install_env"),
"install_env should be written back"
);
file::remove_file(&p).unwrap();
}
#[tokio::test]
async fn test_replace_versions_omits_empty_os() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".replace-empty-os.mise.toml");
file::write(
&p,
formatdoc! {r#"
[tools]
dummy = "1.0.0"
"#},
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let dummy = "dummy".into();
let options = ToolVersionOptions {
core: CoreToolOptions {
os: Some(vec![]),
..Default::default()
},
..Default::default()
};
cf.replace_versions(
&dummy,
vec![
ToolRequest::new_opts(
Arc::new("dummy".into()),
"1.0.1",
options,
ToolSource::Unknown,
)
.unwrap(),
],
)
.unwrap();
let dump = cf.dump().unwrap();
assert!(dump.contains(r#"dummy = "1.0.1""#));
assert!(!dump.contains("os"), "empty os should not be written back");
file::remove_file(&p).unwrap();
}
#[tokio::test]
async fn test_bootstrap_linux_systemd_units() {
let _config = Config::get().await.unwrap();
let p = CWD.as_ref().unwrap().join(".test.mise.toml");
file::write(
&p,
r#"
[bootstrap.linux.systemd.units.my-sync]
description = "sync files"
after = ["network-online.target"]
wants = ["network-online.target"]
exec_start = "~/.local/bin/my-sync --watch"
environment = { PATH = "/usr/bin:/bin" }
working_directory = "~"
restart = "on-failure"
restart_sec = "5s"
standard_output = "append:%h/.local/state/my-sync.log"
wanted_by = ["default.target"]
"#,
)
.unwrap();
let cf = MiseToml::from_file(&p).unwrap();
let system = cf.bootstrap_config().unwrap();
let unit = system.linux.systemd.units.get("my-sync").unwrap();
assert_eq!(unit.description.as_deref(), Some("sync files"));
assert_eq!(unit.after, vec!["network-online.target"]);
assert_eq!(unit.wants, vec!["network-online.target"]);
assert_eq!(
unit.exec_start.as_deref(),
Some("~/.local/bin/my-sync --watch")
);
assert_eq!(
unit.environment.get("PATH").map(String::as_str),
Some("/usr/bin:/bin")
);
assert_eq!(unit.working_directory.as_deref(), Some("~"));
assert_eq!(unit.restart.as_deref(), Some("on-failure"));
assert_eq!(unit.restart_sec.as_deref(), Some("5s"));
assert_eq!(
unit.standard_output.as_deref(),
Some("append:%h/.local/state/my-sync.log")
);
assert_eq!(
unit.wanted_by.as_deref(),
Some(["default.target".to_string()].as_slice())
);
file::remove_file(&p).unwrap();
}
}