use crate::backend::backend_type::BackendType;
use crate::cli::args::BackendArg;
use crate::file::display_path;
use crate::git::Git;
use crate::plugins::PluginType;
use crate::toolset::{EPHEMERAL_OPT_KEYS, parse_tool_options};
use crate::{dirs, env, file, runtime_symlinks};
use eyre::{Ok, Result};
use heck::ToKebabCase;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use versions::Versioning;
fn normalize_version_for_sort(v: &str) -> &str {
v.strip_prefix('v')
.or_else(|| v.strip_prefix('V'))
.unwrap_or(v)
}
type InstallStatePlugins = BTreeMap<String, PluginType>;
type InstallStateTools = BTreeMap<String, InstallStateTool>;
type MutexResult<T> = Result<Arc<T>>;
#[derive(Debug, Clone)]
pub struct InstallStateTool {
pub short: String,
pub full: Option<String>,
pub versions: Vec<String>,
pub explicit_backend: bool,
pub opts: BTreeMap<String, toml::Value>,
pub installs_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ManifestTool {
short: String,
#[serde(skip_serializing_if = "Option::is_none")]
full: Option<String>,
#[serde(default = "default_true")]
explicit_backend: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
opts: BTreeMap<String, toml::Value>,
}
fn default_true() -> bool {
true
}
type Manifest = BTreeMap<String, ManifestTool>;
static INSTALL_STATE_PLUGINS: Mutex<Option<Arc<InstallStatePlugins>>> = Mutex::new(None);
static INSTALL_STATE_TOOLS: Mutex<Option<Arc<InstallStateTools>>> = Mutex::new(None);
static MANIFEST_LOCK: Mutex<()> = Mutex::new(());
fn manifest_path() -> PathBuf {
dirs::INSTALLS.join(".mise-installs.toml")
}
fn read_manifest() -> Manifest {
read_manifest_from(&manifest_path())
}
fn read_manifest_from(path: &Path) -> Manifest {
match file::read_to_string(path) {
std::result::Result::Ok(body) => match toml::from_str(&body) {
std::result::Result::Ok(m) => m,
Err(err) => {
warn!(
"failed to parse manifest at {}: {err:#}",
display_path(path)
);
Default::default()
}
},
Err(_) => Default::default(),
}
}
fn write_manifest(manifest: &Manifest) -> Result<()> {
write_manifest_to(&manifest_path(), manifest)
}
fn write_manifest_to(path: &Path, manifest: &Manifest) -> Result<()> {
let body = toml::to_string_pretty(manifest)?;
file::write(path, body.trim())?;
Ok(())
}
fn read_legacy_backend_meta(short: &str) -> Option<(String, Option<String>, bool)> {
let json_path = dirs::INSTALLS.join(short).join(".mise.backend.json");
if json_path.exists()
&& let std::result::Result::Ok(f) = file::open(&json_path)
&& let std::result::Result::Ok(json) = serde_json::from_reader::<_, serde_json::Value>(f)
{
let full = json.get("id").and_then(|id| id.as_str()).map(String::from);
let s = json
.get("short")
.and_then(|s| s.as_str())
.unwrap_or(short)
.to_string();
return Some((s, full, true));
}
let path = dirs::INSTALLS
.join(short.to_kebab_case())
.join(".mise.backend");
if !path.exists() {
return None;
}
let body = match file::read_to_string(&path) {
std::result::Result::Ok(body) => body,
Err(err) => {
warn!(
"failed to read backend meta at {}: {err:?}",
display_path(&path)
);
return None;
}
};
let lines: Vec<&str> = body.lines().filter(|f| !f.is_empty()).collect();
let s = lines.first().unwrap_or(&short).to_string();
let full = lines.get(1).map(|f| f.to_string());
let explicit_backend = lines.get(2).is_some_and(|v| *v == "1");
Some((s, full, explicit_backend))
}
pub(crate) async fn init() -> Result<()> {
let (plugins, tools) = tokio::join!(
tokio::task::spawn(async { measure!("init_plugins", { init_plugins().await }) }),
tokio::task::spawn(async { measure!("init_tools", { init_tools().await }) }),
);
plugins??;
tools??;
Ok(())
}
async fn init_plugins() -> MutexResult<InstallStatePlugins> {
if let Some(plugins) = INSTALL_STATE_PLUGINS
.lock()
.expect("INSTALL_STATE_PLUGINS lock failed")
.clone()
{
return Ok(plugins);
}
let dirs = file::dir_subdirs(&dirs::PLUGINS)?;
let plugins: InstallStatePlugins = dirs
.into_iter()
.filter_map(|d| {
time!("init_plugins {d}");
let path = dirs::PLUGINS.join(&d);
if is_banned_plugin(&path) {
info!("removing banned plugin {d}");
let _ = file::remove_all(&path);
None
} else if path.join("metadata.lua").exists() {
if has_backend_methods(&path) {
Some((d, PluginType::VfoxBackend))
} else {
Some((d, PluginType::Vfox))
}
} else if path.join("bin").join("list-all").exists() {
Some((d, PluginType::Asdf))
} else {
None
}
})
.collect();
let plugins = Arc::new(plugins);
*INSTALL_STATE_PLUGINS
.lock()
.expect("INSTALL_STATE_PLUGINS lock failed") = Some(plugins.clone());
Ok(plugins)
}
async fn init_tools() -> MutexResult<InstallStateTools> {
if let Some(tools) = INSTALL_STATE_TOOLS
.lock()
.expect("INSTALL_STATE_TOOLS lock failed")
.clone()
{
return Ok(tools);
}
let manifest = read_manifest();
let subdirs = file::dir_subdirs(&dirs::INSTALLS)?;
let mut updated_manifest: Option<Manifest> = None;
let mut tools = BTreeMap::new();
for dir_name in subdirs {
let dir = dirs::INSTALLS.join(&dir_name);
let versions: Vec<String> = file::dir_subdirs(&dir)
.unwrap_or_else(|err| {
warn!("reading versions in {} failed: {err:?}", display_path(&dir));
Default::default()
})
.into_iter()
.filter(|v| !v.starts_with('.'))
.filter(|v| !runtime_symlinks::is_runtime_symlink(&dir.join(v)))
.filter(|v| !dir.join(v).join("incomplete").exists())
.sorted_by_cached_key(|v| {
let normalized = normalize_version_for_sort(v);
(Versioning::new(normalized), v.to_string())
})
.collect();
if versions.is_empty() {
continue;
}
let (short, full, explicit_backend, opts) = if let Some(mt) = manifest.get(&dir_name) {
let mut full = mt.full.clone();
let mut opts = mt.opts.clone();
if opts.is_empty()
&& let Some(ref f) = full
&& let Some((stripped_str, opts_str)) = crate::cli::args::split_bracketed_opts(f)
{
let stripped = stripped_str.to_string();
let parsed = parse_tool_options(opts_str);
for (k, v) in &parsed.opts {
if EPHEMERAL_OPT_KEYS.contains(&k.as_str()) {
continue;
}
opts.insert(k.clone(), v.clone());
}
full = Some(stripped);
let m = updated_manifest.get_or_insert_with(|| manifest.clone());
m.insert(
dir_name.clone(),
ManifestTool {
short: mt.short.clone(),
full: full.clone(),
explicit_backend: mt.explicit_backend,
opts: opts.clone(),
},
);
}
(mt.short.clone(), full, mt.explicit_backend, opts)
} else if let Some((s, full, explicit)) = read_legacy_backend_meta(&dir_name) {
let m = updated_manifest.get_or_insert_with(|| manifest.clone());
m.insert(
dir_name.clone(),
ManifestTool {
short: s.clone(),
full: full.clone(),
explicit_backend: explicit,
opts: BTreeMap::new(),
},
);
(s, full, explicit, BTreeMap::new())
} else {
(dir_name.clone(), None, true, BTreeMap::new())
};
let tool = InstallStateTool {
short: short.clone(),
full,
versions,
explicit_backend,
opts,
installs_path: Some(dir),
};
time!("init_tools {short}");
tools.insert(short, tool);
}
if let Some(ref m) = updated_manifest {
let _lock = MANIFEST_LOCK.lock().expect("MANIFEST_LOCK lock failed");
if let Err(err) = write_manifest(m) {
warn!("failed to write install manifest: {err:#}");
}
}
for shared_dir in env::shared_install_dirs_early() {
if !shared_dir.is_dir() {
continue;
}
let shared_manifest_path = shared_dir.join(".mise-installs.toml");
let shared_manifest = read_manifest_from(&shared_manifest_path);
let shared_subdirs = match file::dir_subdirs(&shared_dir) {
std::result::Result::Ok(d) => d,
Err(err) => {
warn!(
"reading shared install dir {} failed: {err:?}",
display_path(&shared_dir)
);
continue;
}
};
for dir_name in shared_subdirs {
let dir = shared_dir.join(&dir_name);
let versions: Vec<String> = file::dir_subdirs(&dir)
.unwrap_or_else(|err| {
warn!("reading versions in {} failed: {err:?}", display_path(&dir));
Default::default()
})
.into_iter()
.filter(|v| !v.starts_with('.'))
.filter(|v| !runtime_symlinks::is_runtime_symlink(&dir.join(v)))
.filter(|v| !dir.join(v).join("incomplete").exists())
.sorted_by_cached_key(|v| {
let normalized = normalize_version_for_sort(v);
(Versioning::new(normalized), v.to_string())
})
.collect();
if versions.is_empty() {
continue;
}
let (short, full, explicit_backend, opts) =
if let Some(mt) = shared_manifest.get(&dir_name) {
(
mt.short.clone(),
mt.full.clone(),
mt.explicit_backend,
mt.opts.clone(),
)
} else {
(dir_name.clone(), None, true, BTreeMap::new())
};
let tool = tools
.entry(short.clone())
.or_insert_with(|| InstallStateTool {
short: short.clone(),
full: full.clone(),
versions: Vec::new(),
explicit_backend,
opts: opts.clone(),
installs_path: Some(dir),
});
for v in versions {
if !tool.versions.contains(&v) {
tool.versions.push(v);
}
}
tool.versions.sort_by_cached_key(|v| {
let normalized = normalize_version_for_sort(v);
(Versioning::new(normalized), v.to_string())
});
if tool.full.is_none() {
tool.full = full;
}
}
}
for (short, pt) in init_plugins().await?.iter() {
let full = match pt {
PluginType::Asdf => format!("asdf:{short}"),
PluginType::Vfox => format!("vfox:{short}"),
PluginType::VfoxBackend => short.clone(),
};
let tool = tools
.entry(short.clone())
.or_insert_with(|| InstallStateTool {
short: short.clone(),
full: Some(full.clone()),
versions: Default::default(),
explicit_backend: true,
opts: BTreeMap::new(),
installs_path: None,
});
tool.full = Some(full);
}
let tools = Arc::new(tools);
*INSTALL_STATE_TOOLS
.lock()
.expect("INSTALL_STATE_TOOLS lock failed") = Some(tools.clone());
Ok(tools)
}
pub fn list_plugins() -> Arc<BTreeMap<String, PluginType>> {
INSTALL_STATE_PLUGINS
.lock()
.expect("INSTALL_STATE_PLUGINS lock failed")
.as_ref()
.expect("INSTALL_STATE_PLUGINS is None")
.clone()
}
fn is_banned_plugin(path: &Path) -> bool {
if path.ends_with("gradle") {
let repo = Git::new(path);
if let Some(url) = repo.get_remote_url() {
return url == "https://github.com/rfrancis/asdf-gradle.git";
}
}
false
}
fn has_backend_methods(plugin_path: &Path) -> bool {
plugin_path
.join("hooks")
.join("backend_install.lua")
.exists()
}
pub fn get_tool_full(short: &str) -> Option<String> {
list_tools().get(short).and_then(|t| t.full.clone())
}
pub fn get_plugin_type(short: &str) -> Option<PluginType> {
list_plugins().get(short).cloned()
}
pub fn list_tools() -> Arc<BTreeMap<String, InstallStateTool>> {
INSTALL_STATE_TOOLS
.lock()
.expect("INSTALL_STATE_TOOLS lock failed")
.as_ref()
.expect("INSTALL_STATE_TOOLS is None")
.clone()
}
pub fn backend_type(short: &str) -> Result<Option<BackendType>> {
let backend_type = list_tools()
.get(short)
.and_then(|ist| ist.full.as_ref())
.map(|full| BackendType::guess(full));
if let Some(BackendType::Unknown) = backend_type
&& let Some((plugin_name, _)) = short.split_once(':')
&& let Some(PluginType::VfoxBackend) = get_plugin_type(plugin_name)
{
return Ok(Some(BackendType::VfoxBackend(plugin_name.to_string())));
}
Ok(backend_type)
}
pub fn list_versions(short: &str) -> Vec<String> {
list_tools()
.get(short)
.map(|tool| tool.versions.clone())
.unwrap_or_default()
}
pub async fn add_plugin(short: &str, plugin_type: PluginType) -> Result<()> {
let mut plugins = init_plugins().await?.deref().clone();
plugins.insert(short.to_string(), plugin_type);
*INSTALL_STATE_PLUGINS
.lock()
.expect("INSTALL_STATE_PLUGINS lock failed") = Some(Arc::new(plugins));
Ok(())
}
pub fn write_backend_meta(ba: &BackendArg) -> Result<()> {
write_backend_meta_to(ba, &manifest_path())
}
pub fn write_backend_meta_to(ba: &BackendArg, path: &Path) -> Result<()> {
let full = ba.full_without_opts();
let explicit = ba.has_explicit_backend();
let mut opts_map: BTreeMap<String, toml::Value> = BTreeMap::new();
if let Some(o) = ba.opts.as_ref() {
for (k, v) in &o.opts {
if !EPHEMERAL_OPT_KEYS.contains(&k.as_str()) {
opts_map.insert(k.clone(), v.clone());
}
}
}
let _lock = MANIFEST_LOCK.lock().expect("MANIFEST_LOCK lock failed");
let mut manifest = read_manifest_from(path);
manifest.insert(
ba.short.to_kebab_case(),
ManifestTool {
short: ba.short.clone(),
full: Some(full),
explicit_backend: explicit,
opts: opts_map,
},
);
write_manifest_to(path, &manifest)?;
Ok(())
}
pub fn incomplete_file_path(short: &str, v: &str) -> PathBuf {
dirs::CACHE
.join(short.to_kebab_case())
.join(v)
.join("incomplete")
}
fn checksum_file_path(short: &str, v: &str) -> PathBuf {
dirs::INSTALLS
.join(short.to_kebab_case())
.join(v)
.join(".mise.checksum")
}
pub fn write_checksum(short: &str, v: &str, checksum: &str) -> Result<()> {
let path = checksum_file_path(short, v);
file::write(&path, checksum)?;
Ok(())
}
pub fn read_checksum(short: &str, v: &str) -> Option<String> {
let path = checksum_file_path(short, v);
if path.exists() {
file::read_to_string(&path).ok()
} else {
None
}
}
pub fn reset() {
*INSTALL_STATE_PLUGINS
.lock()
.expect("INSTALL_STATE_PLUGINS lock failed") = None;
*INSTALL_STATE_TOOLS
.lock()
.expect("INSTALL_STATE_TOOLS lock failed") = None;
super::tool_version::reset_install_path_cache();
}
#[cfg(test)]
mod tests {
use super::normalize_version_for_sort;
use itertools::Itertools;
use std::collections::BTreeMap;
use versions::Versioning;
#[test]
fn test_normalize_version_for_sort() {
assert_eq!(normalize_version_for_sort("v1.0.0"), "1.0.0");
assert_eq!(normalize_version_for_sort("V1.0.0"), "1.0.0");
assert_eq!(normalize_version_for_sort("1.0.0"), "1.0.0");
assert_eq!(normalize_version_for_sort("latest"), "latest");
}
#[test]
fn test_version_sorting_with_v_prefix() {
let versions = ["v2.0.51", "2.0.35", "2.0.52"];
let sorted_without_norm: Vec<_> = versions
.iter()
.sorted_by_cached_key(|v| (Versioning::new(v), v.to_string()))
.collect();
println!("Without normalization: {:?}", sorted_without_norm);
let sorted_with_norm: Vec<_> = versions
.iter()
.sorted_by_cached_key(|v| {
let normalized = normalize_version_for_sort(v);
(Versioning::new(normalized), v.to_string())
})
.collect();
println!("With normalization: {:?}", sorted_with_norm);
assert_eq!(**sorted_with_norm.last().unwrap(), "2.0.52");
assert_eq!(**sorted_with_norm.get(1).unwrap(), "v2.0.51");
assert_eq!(**sorted_with_norm.first().unwrap(), "2.0.35");
}
#[test]
fn test_manifest_roundtrip() {
use super::{Manifest, ManifestTool};
let mut manifest = Manifest::new();
manifest.insert(
"node".to_string(),
ManifestTool {
short: "node".to_string(),
full: Some("core:node".to_string()),
explicit_backend: true,
opts: BTreeMap::new(),
},
);
manifest.insert(
"bun".to_string(),
ManifestTool {
short: "bun".to_string(),
full: Some("aqua:oven-sh/bun".to_string()),
explicit_backend: false,
opts: BTreeMap::new(),
},
);
manifest.insert(
"tiny".to_string(),
ManifestTool {
short: "tiny".to_string(),
full: None,
explicit_backend: true,
opts: BTreeMap::new(),
},
);
let serialized = toml::to_string_pretty(&manifest).unwrap();
let deserialized: Manifest = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.len(), 3);
assert_eq!(deserialized["node"].full.as_deref(), Some("core:node"));
assert!(deserialized["node"].explicit_backend);
assert_eq!(
deserialized["bun"].full.as_deref(),
Some("aqua:oven-sh/bun")
);
assert!(!deserialized["bun"].explicit_backend);
assert!(deserialized["tiny"].full.is_none());
assert!(deserialized["tiny"].explicit_backend);
}
#[test]
fn test_manifest_with_opts_roundtrip() {
use super::{Manifest, ManifestTool};
let mut opts = BTreeMap::new();
opts.insert(
"url".to_string(),
toml::Value::String("https://example.com/tool.tar.gz".to_string()),
);
opts.insert(
"bin_path".to_string(),
toml::Value::String("bin".to_string()),
);
let mut platforms = toml::map::Map::new();
let mut linux = toml::map::Map::new();
linux.insert(
"url".to_string(),
toml::Value::String("https://example.com/linux.tar.gz".to_string()),
);
platforms.insert("linux-x64".to_string(), toml::Value::Table(linux));
opts.insert("platforms".to_string(), toml::Value::Table(platforms));
let mut manifest = Manifest::new();
manifest.insert(
"hello".to_string(),
ManifestTool {
short: "hello".to_string(),
full: Some("http:hello".to_string()),
explicit_backend: true,
opts,
},
);
let serialized = toml::to_string_pretty(&manifest).unwrap();
let deserialized: Manifest = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized["hello"].full.as_deref(), Some("http:hello"));
assert_eq!(
deserialized["hello"].opts.get("url"),
Some(&toml::Value::String(
"https://example.com/tool.tar.gz".to_string()
))
);
assert_eq!(
deserialized["hello"].opts.get("bin_path"),
Some(&toml::Value::String("bin".to_string()))
);
let platforms = deserialized["hello"].opts.get("platforms").unwrap();
assert!(platforms.is_table());
let linux = platforms.get("linux-x64").unwrap();
assert_eq!(
linux.get("url").unwrap().as_str(),
Some("https://example.com/linux.tar.gz")
);
}
#[test]
fn test_manifest_backward_compat_bracketed_full() {
use super::Manifest;
let toml_str = r#"
[hello]
short = "hello"
full = "http:hello[url = \"https://example.com/tool.tar.gz\", bin_path = \"bin\"]"
explicit_backend = true
"#;
let manifest: Manifest = toml::from_str(toml_str).unwrap();
let mt = &manifest["hello"];
assert!(mt.opts.is_empty());
assert!(mt.full.as_ref().unwrap().contains('['));
}
}