use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::backend::Backend;
use crate::config::{Alias, Config};
use crate::file::make_symlink_or_file;
use crate::plugins::VERSION_REGEX;
use crate::semver::split_version_prefix;
use crate::toolset::{ToolRequest, Toolset};
use crate::{backend, env, file};
use eyre::{Result, WrapErr};
use indexmap::IndexMap;
use itertools::Itertools;
use versions::Versioning;
pub(crate) async fn rebuild_for_toolset(config: &Config, ts: &Toolset) -> Result<()> {
rebuild_for_backends(config, ts, ts.list_cached_and_current_backends()).await
}
pub(crate) async fn rebuild_for_backends(
config: &Config,
ts: &Toolset,
backends: impl IntoIterator<Item = Arc<dyn Backend>>,
) -> Result<()> {
let rebuilds = backends.into_iter().flat_map(|backend| {
install_dirs_for(&backend)
.into_iter()
.map(move |installs_dir| (backend.clone(), installs_dir))
});
run_all_rebuilds(rebuilds, |(backend, installs_dir)| {
rebuild_symlinks_in_dir(config, ts, &backend, &installs_dir).wrap_err_with(|| {
format!(
"failed to rebuild runtime symlinks for {} in {}",
backend.ba().short,
installs_dir.display()
)
})
})
}
fn run_all_rebuilds<T>(
rebuilds: impl IntoIterator<Item = T>,
mut rebuild: impl FnMut(T) -> Result<()>,
) -> Result<()> {
let errors = rebuilds
.into_iter()
.filter_map(|item| rebuild(item).err())
.collect_vec();
if errors.is_empty() {
return Ok(());
}
Err(eyre::eyre!(
"{} runtime symlink repair(s) failed:\n{}",
errors.len(),
errors.iter().map(|err| format!("{err:#}")).join("\n")
))
}
pub(crate) async fn migrate_real_dirs(config: &Config) -> Result<()> {
for backend in backend::list() {
for installs_dir in install_dirs_for(&backend) {
migrate_real_dirs_in_dir(config, &backend, &installs_dir)?;
}
}
Ok(())
}
fn install_dirs_for(backend: &Arc<dyn Backend>) -> Vec<PathBuf> {
let ba = backend.ba();
let mut dirs = vec![ba.installs_path.clone()];
let tool_dir_name = ba.tool_dir_name();
for shared_dir in env::shared_install_dirs() {
let dir = shared_dir.join(&tool_dir_name);
if dir.is_dir() && !dirs.contains(&dir) {
dirs.push(dir);
}
}
dirs
}
fn rebuild_symlinks_in_dir(
config: &Config,
ts: &Toolset,
backend: &Arc<dyn Backend>,
installs_dir: &Path,
) -> Result<()> {
let concrete_installs = installed_versions_in_dir(installs_dir)
.into_iter()
.filter(|v| is_concrete_install(v))
.collect::<std::collections::HashSet<_>>();
let symlinks = list_symlinks_for_dir(config, Some(ts), backend, installs_dir);
for (from, to) in symlinks {
let from_name = from.clone();
let from = installs_dir.join(from);
if from.exists() {
if is_runtime_symlink(&from) {
if file::resolve_symlink(&from)?.unwrap_or_default() == to {
continue;
}
trace!("Removing existing symlink: {}", from.display());
file::remove_file(&from)?;
} else if from
.file_name()
.zip(to.file_name())
.is_some_and(|(f, t)| f != t)
&& !concrete_installs.contains(&from_name)
{
trace!("Replacing stale runtime dir: {}", from.display());
file::remove_all(&from)?;
} else {
continue;
}
}
make_symlink_or_file(&to, &from)?;
}
remove_missing_symlinks_in_dir(installs_dir)?;
Ok(())
}
fn migrate_real_dirs_in_dir(
config: &Config,
backend: &Arc<dyn Backend>,
installs_dir: &Path,
) -> Result<()> {
let concrete_installs = installed_versions_in_dir(installs_dir)
.into_iter()
.filter(|v| is_concrete_install(v))
.collect::<std::collections::HashSet<_>>();
let symlinks = list_symlinks_for_dir(config, None, backend, installs_dir);
for (from, to) in symlinks {
let from_name = from.clone();
let from = installs_dir.join(from);
if !from.exists() || is_runtime_symlink(&from) || concrete_installs.contains(&from_name) {
continue;
}
trace!("Replacing stale runtime dir: {}", from.display());
file::remove_all(&from)?;
make_symlink_or_file(&to, &from)?;
}
Ok(())
}
fn list_symlinks_for_dir(
config: &Config,
ts: Option<&Toolset>,
backend: &Arc<dyn Backend>,
installs_dir: &Path,
) -> IndexMap<String, PathBuf> {
let mut symlinks = IndexMap::new();
let rel_path = |x: &String| PathBuf::from(".").join(x.clone());
for v in installed_versions_in_dir(installs_dir) {
if is_temporary_runtime_label(&v) {
continue;
}
let (prefix, version) = split_version_prefix(&v);
let Some(versions) = Versioning::new(version) else {
continue;
};
let mut partial = vec![];
while versions.nth(partial.len()).is_some() && versions.nth(partial.len() + 1).is_some() {
let version = versions.nth(partial.len()).unwrap();
partial.push(version.to_string());
let from = format!("{}{}", prefix, partial.join("."));
symlinks.insert(from, rel_path(&v));
}
symlinks.insert(format!("{prefix}latest"), rel_path(&v));
for (from, to) in &config
.all_aliases
.get(&backend.ba().short)
.unwrap_or(&Alias::default())
.versions
{
if from.contains('/') {
continue;
}
if !v.starts_with(to) {
continue;
}
symlinks.insert(format!("{prefix}{from}"), rel_path(&v));
}
}
if let Some(ts) = ts {
for (b, tv) in ts.list_current_versions() {
if b.ba() != backend.ba() {
continue;
}
if !matches!(tv.request, ToolRequest::Sub { .. }) {
continue;
}
let Some(from) = tv.runtime_pathname() else {
continue;
};
let install_path = tv.install_path();
if install_path.parent() != Some(installs_dir) || !install_path.exists() {
continue;
}
if let Some(to) = install_path
.file_name()
.map(|to| PathBuf::from(".").join(to))
{
symlinks.insert(from, to);
}
}
}
symlinks = symlinks
.into_iter()
.sorted_by_cached_key(|(k, _)| (Versioning::new(k), k.to_string()))
.collect();
symlinks
}
fn installed_versions_in_dir(installs_dir: &Path) -> Vec<String> {
if !installs_dir.is_dir() {
return vec![];
}
file::dir_subdirs(installs_dir)
.unwrap_or_default()
.into_iter()
.filter(|v| !v.starts_with('.'))
.filter(|v| !is_runtime_symlink(&installs_dir.join(v)))
.filter(|v| !installs_dir.join(v).join("incomplete").exists())
.filter(|v| !VERSION_REGEX.is_match(v))
.sorted_by_cached_key(|v| (Versioning::new(v), v.to_string()))
.collect()
}
fn is_concrete_install(v: &str) -> bool {
let (_, version) = split_version_prefix(v);
version.chars().any(|c| c.is_ascii_digit()) && Versioning::new(version).is_some()
}
fn is_temporary_runtime_label(v: &str) -> bool {
debug_assert!(
{
let remove_version = Versioning::new("2026.10.0").unwrap();
*crate::cli::version::V < remove_version
},
"Temporary runtime symlink migration guard should be removed in version 2026.10.0."
);
v == "latest"
}
pub(crate) fn remove_missing_symlinks(backend: Arc<dyn Backend>) -> Result<()> {
remove_missing_symlinks_in_dir(&backend.ba().installs_path)
}
fn remove_missing_symlinks_in_dir(installs_dir: &Path) -> Result<()> {
if !installs_dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(installs_dir)? {
let entry = entry?;
let path = entry.path();
if let Some(target) = runtime_symlink_target(&path)
&& !installs_dir.join(target).exists()
{
trace!("Removing missing symlink: {}", path.display());
file::remove_file(path)?;
}
}
file::remove_dir_ignore(installs_dir, vec![".mise.backend.json", ".mise.backend"])?;
Ok(())
}
pub(crate) fn is_runtime_symlink(path: &Path) -> bool {
runtime_symlink_target(path).is_some()
}
fn runtime_symlink_target(path: &Path) -> Option<PathBuf> {
if let Ok(Some(link)) = file::resolve_symlink(path)
&& link.starts_with("./")
{
return Some(link);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn run_all_rebuilds_attempts_every_item() {
let mut attempted = vec![];
let err = run_all_rebuilds([1, 2, 3], |item| {
attempted.push(item);
if item == 1 || item == 3 {
eyre::bail!("repair {item} failed");
}
Ok(())
})
.unwrap_err();
assert_eq!(attempted, [1, 2, 3]);
let message = format!("{err:#}");
assert!(message.contains("repair 1 failed"));
assert!(message.contains("repair 3 failed"));
}
#[test]
fn remove_missing_symlinks_in_dir_removes_dangling_pointers() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let installs_dir = temp_dir.path().join("installs").join("dummy");
fs::create_dir_all(installs_dir.join("1.0.0"))?;
make_symlink_or_file(Path::new("./1.0.0"), &installs_dir.join("1"))?;
make_symlink_or_file(Path::new("./2.0.0"), &installs_dir.join("2"))?;
remove_missing_symlinks_in_dir(&installs_dir)?;
assert!(fs::symlink_metadata(installs_dir.join("2")).is_err());
assert!(installs_dir.join("1.0.0").is_dir());
assert!(is_runtime_symlink(&installs_dir.join("1")));
Ok(())
}
#[test]
fn remove_missing_symlinks_in_dir_removes_dir_when_only_dangling_pointers_remain() -> Result<()>
{
let temp_dir = tempfile::tempdir()?;
let installs_dir = temp_dir.path().join("installs").join("dummy");
fs::create_dir_all(&installs_dir)?;
fs::write(installs_dir.join(".mise.backend.json"), "{}")?;
make_symlink_or_file(Path::new("./2.0.0"), &installs_dir.join("2"))?;
make_symlink_or_file(Path::new("./2.0.0"), &installs_dir.join("latest"))?;
remove_missing_symlinks_in_dir(&installs_dir)?;
assert!(!installs_dir.exists());
Ok(())
}
}