use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::config::Config;
use crate::file::display_path;
use crate::lockfile::{self, LockResolutionResult, Lockfile};
use crate::platform::Platform;
use crate::toolset::Toolset;
use crate::ui::multi_progress_report::MultiProgressReport;
use crate::{cli::args::ToolArg, config::Settings};
use console::style;
use eyre::{Result, bail};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
type LockTool = (crate::cli::args::BackendArg, crate::toolset::ToolVersion);
#[derive(Debug, clap::Args)]
#[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
pub struct Lock {
#[clap(value_name = "TOOL", verbatim_doc_comment)]
pub tool: Vec<ToolArg>,
#[clap(long, short, verbatim_doc_comment)]
pub global: bool,
#[clap(long, short, env = "MISE_JOBS", verbatim_doc_comment)]
pub jobs: Option<usize>,
#[clap(long, short = 'n', verbatim_doc_comment)]
pub dry_run: bool,
#[clap(long, short, value_delimiter = ',', verbatim_doc_comment)]
pub platform: Vec<String>,
#[clap(long, verbatim_doc_comment)]
pub local: bool,
}
impl Lock {
pub async fn run(self) -> Result<()> {
let settings = Settings::get();
if settings.locked {
bail!(
"mise lock is disabled in --locked mode\nhint: Remove --locked or unset MISE_LOCKED=1"
);
}
let config = Config::get().await?;
let ts = config.get_toolset().await?;
let lockfile_targets = self.get_lockfile_targets(&config);
let mut has_lock_targets = false;
let mut all_provenance_errors: Vec<String> = Vec::new();
for (lockfile_path, config_paths) in &lockfile_targets {
let tools = self.get_tools_to_lock(&config, ts, lockfile_path, config_paths);
if tools.is_empty() {
let mut lockfile = Lockfile::read(lockfile_path)?;
if self.dry_run {
let stale_tools = self.stale_entries_if_pruned(&lockfile, &tools);
self.show_stale_prune_message(lockfile_path, &stale_tools, true)?;
if !stale_tools.is_empty() {
has_lock_targets = true;
}
} else {
let pruned_tools = self.prune_stale_entries_if_needed(&mut lockfile, &tools);
if !pruned_tools.is_empty() {
lockfile.write(lockfile_path)?;
self.show_stale_prune_message(lockfile_path, &pruned_tools, false)?;
has_lock_targets = true;
}
}
continue;
}
has_lock_targets = true;
let target_platforms = self.determine_target_platforms(lockfile_path)?;
miseprintln!(
"{} Targeting {} platform(s) for {}: {}",
style("→").cyan(),
target_platforms.len(),
style(display_path(lockfile_path)).cyan(),
target_platforms
.iter()
.map(|p| p.to_key())
.collect::<Vec<_>>()
.join(", ")
);
miseprintln!(
"{} Processing {} tool(s): {}",
style("→").cyan(),
tools.len(),
tools
.iter()
.map(|(ba, tv)| format!("{}@{}", ba.short, tv.version))
.collect::<Vec<_>>()
.join(", ")
);
if self.dry_run {
self.show_dry_run(&tools, &target_platforms)?;
let lockfile = Lockfile::read(lockfile_path)?;
if self.is_unfiltered_lock_run() {
let stale_tools = self.stale_entries_if_pruned(&lockfile, &tools);
self.show_stale_prune_message(lockfile_path, &stale_tools, true)?;
}
let stale_versions = self.stale_versions_if_pruned(&lockfile, &tools);
self.show_stale_version_prune_message(lockfile_path, &stale_versions, true)?;
continue;
}
let mut lockfile = Lockfile::read(lockfile_path)?;
let stale_tools = self.prune_stale_entries_if_needed(&mut lockfile, &tools);
self.show_stale_prune_message(lockfile_path, &stale_tools, false)?;
let stale_versions = self.stale_versions_if_pruned(&lockfile, &tools);
let (results, provenance_errors) = self
.process_tools(&settings, &tools, &target_platforms, &mut lockfile)
.await?;
self.prune_stale_versions(&mut lockfile, &tools);
self.show_stale_version_prune_message(lockfile_path, &stale_versions, false)?;
lockfile.write(lockfile_path)?;
let successful = results.iter().filter(|(_, _, ok)| *ok).count();
let skipped = results.len() - successful;
miseprintln!(
"{} Updated {} platform entries ({} skipped)",
style("✓").green(),
successful,
skipped
);
miseprintln!(
"{} Lockfile written to {}",
style("✓").green(),
style(display_path(lockfile_path)).cyan()
);
all_provenance_errors.extend(provenance_errors);
}
if !has_lock_targets {
miseprintln!("{} No tools configured to lock", style("!").yellow());
}
{
use crate::toolset::outdated_info::{apply_config_bumps, compute_config_bumps};
let tool_versions: Vec<(String, String)> = self
.tool
.iter()
.filter_map(|t| {
t.tvr
.as_ref()
.map(|tvr| (t.ba.short.clone(), tvr.version()))
})
.collect();
let refs: Vec<(&str, &str)> = tool_versions
.iter()
.map(|(n, v)| (n.as_str(), v.as_str()))
.collect();
let bumps = compute_config_bumps(&config, &refs);
if self.dry_run {
for bump in &bumps {
miseprintln!(
"Would update {} from {} to {} in {}",
bump.tool_name,
bump.old_version,
bump.new_version,
display_path(&bump.config_path)
);
}
} else {
apply_config_bumps(&config, &bumps)?;
}
}
if !all_provenance_errors.is_empty() {
return Err(eyre::eyre!("{}", all_provenance_errors.join("\n")));
}
Ok(())
}
fn is_unfiltered_lock_run(&self) -> bool {
self.tool.is_empty()
}
fn prune_stale_entries_if_needed(
&self,
lockfile: &mut Lockfile,
tools: &[(crate::cli::args::BackendArg, crate::toolset::ToolVersion)],
) -> BTreeSet<String> {
if !self.is_unfiltered_lock_run() {
return BTreeSet::new();
}
let (configured_tools, configured_backends) = self.configured_tool_selectors(tools);
let stale_tools =
self.stale_entries_for_selectors(lockfile, &configured_tools, &configured_backends);
if !stale_tools.is_empty() {
lockfile.retain_tools_by_short_or_backend(&configured_tools, &configured_backends);
}
stale_tools
}
fn prune_stale_versions(&self, lockfile: &mut Lockfile, tools: &[LockTool]) {
let current_versions = self.current_tool_versions(tools);
for (short, versions) in ¤t_versions {
lockfile.retain_tool_versions(short, versions);
}
}
fn stale_entries_if_pruned(
&self,
lockfile: &Lockfile,
tools: &[(crate::cli::args::BackendArg, crate::toolset::ToolVersion)],
) -> BTreeSet<String> {
if !self.is_unfiltered_lock_run() {
return BTreeSet::new();
}
let (configured_tools, configured_backends) = self.configured_tool_selectors(tools);
self.stale_entries_for_selectors(lockfile, &configured_tools, &configured_backends)
}
fn stale_versions_if_pruned(
&self,
lockfile: &Lockfile,
tools: &[LockTool],
) -> BTreeMap<String, Vec<String>> {
let current_versions = self.current_tool_versions(tools);
self.stale_versions_for_current(lockfile, ¤t_versions)
}
fn stale_versions_for_current(
&self,
lockfile: &Lockfile,
current_versions: &BTreeMap<String, BTreeSet<String>>,
) -> BTreeMap<String, Vec<String>> {
let mut stale: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (short, versions) in current_versions {
let stale_versions = lockfile.stale_tool_versions(short, versions);
if !stale_versions.is_empty() {
stale.insert(short.clone(), stale_versions);
}
}
stale
}
fn show_stale_version_prune_message(
&self,
lockfile_path: &Path,
stale_versions: &BTreeMap<String, Vec<String>>,
dry_run: bool,
) -> Result<()> {
if stale_versions.is_empty() {
return Ok(());
}
let total: usize = stale_versions.values().map(|v| v.len()).sum();
let entry_word = if total == 1 { "entry" } else { "entries" };
let (icon, message) = if dry_run {
(style("→").yellow(), "Dry run - would prune")
} else {
(style("✓").green(), "Pruned")
};
let details: Vec<String> = stale_versions
.iter()
.flat_map(|(short, versions)| versions.iter().map(move |v| format!("{short}@{v}")))
.collect();
miseprintln!(
"{} {} {} stale version {} from {}: {}",
icon,
message,
total,
entry_word,
style(display_path(lockfile_path)).cyan(),
details.join(", ")
);
Ok(())
}
fn configured_tool_selectors(
&self,
tools: &[(crate::cli::args::BackendArg, crate::toolset::ToolVersion)],
) -> (BTreeSet<String>, BTreeSet<String>) {
let configured_tools: BTreeSet<String> =
tools.iter().map(|(ba, _)| ba.short.clone()).collect();
let configured_backends: BTreeSet<String> = tools.iter().map(|(ba, _)| ba.full()).collect();
(configured_tools, configured_backends)
}
fn current_tool_versions(&self, tools: &[LockTool]) -> BTreeMap<String, BTreeSet<String>> {
let mut current_versions: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (ba, tv) in tools {
current_versions
.entry(ba.short.clone())
.or_default()
.insert(tv.version.clone());
}
current_versions
}
fn stale_entries_for_selectors(
&self,
lockfile: &Lockfile,
configured_tools: &BTreeSet<String>,
configured_backends: &BTreeSet<String>,
) -> BTreeSet<String> {
lockfile.stale_tool_shorts(configured_tools, configured_backends)
}
fn show_stale_prune_message(
&self,
lockfile_path: &Path,
stale_tools: &BTreeSet<String>,
dry_run: bool,
) -> Result<()> {
if stale_tools.is_empty() {
return Ok(());
}
let entry_word = if stale_tools.len() == 1 {
"entry"
} else {
"entries"
};
let (icon, message) = if dry_run {
(style("→").yellow(), "Dry run - would prune")
} else {
(style("✓").green(), "Pruned")
};
miseprintln!(
"{} {} {} stale tool {} from {}: {}",
icon,
message,
stale_tools.len(),
entry_word,
style(display_path(lockfile_path)).cyan(),
stale_tools.iter().cloned().collect::<Vec<_>>().join(", ")
);
Ok(())
}
fn get_lockfile_targets(&self, config: &Config) -> indexmap::IndexMap<PathBuf, Vec<PathBuf>> {
let mut targets: indexmap::IndexMap<PathBuf, Vec<PathBuf>> = indexmap::IndexMap::new();
for (path, cf) in config.config_files.iter() {
if !cf.source().is_mise_toml() {
continue;
}
if crate::config::system_config_files().contains(path) {
continue;
}
if !self.global && crate::config::global_config_files().contains(path) {
continue;
}
let (lockfile_path, is_local) = lockfile::lockfile_path_for_config(path);
if self.local && !is_local {
continue;
}
targets.entry(lockfile_path).or_default().push(path.clone());
}
targets
}
fn determine_target_platforms(&self, lockfile_path: &Path) -> Result<Vec<Platform>> {
if !self.platform.is_empty() {
return Platform::parse_multiple(&self.platform);
}
lockfile::determine_existing_platforms(lockfile_path)
}
fn get_tools_to_lock(
&self,
config: &Config,
ts: &Toolset,
target_lockfile_path: &Path,
config_paths: &[PathBuf],
) -> Vec<LockTool> {
let config_paths_set: BTreeSet<&PathBuf> = config_paths.iter().collect();
let mut all_tools: Vec<LockTool> = Vec::new();
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
for (backend, tv) in ts.list_current_versions() {
if let Some(source_path) = tv.request.source().path() {
let (source_lockfile, _) = lockfile::lockfile_path_for_config(source_path);
if source_lockfile != target_lockfile_path {
continue;
}
} else {
let is_base_lockfile = target_lockfile_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "mise.lock");
if !is_base_lockfile {
continue;
}
}
let key = (backend.ba().short.clone(), tv.version.clone());
if seen.insert(key) {
all_tools.push((backend.ba().as_ref().clone(), tv));
}
}
for (path, cf) in config.config_files.iter() {
if !config_paths_set.contains(path) {
continue;
}
if let Ok(trs) = cf.to_tool_request_set() {
for (ba, requests, _source) in trs.iter() {
for request in requests {
if let Ok(backend) = ba.backend() {
if let Some(resolved_tv) = ts.versions.get(ba.as_ref()) {
for tv in &resolved_tv.versions {
if tv.request.version() == request.version() {
let key = (ba.short.clone(), tv.version.clone());
if seen.insert(key) {
all_tools.push((ba.as_ref().clone(), tv.clone()));
}
}
}
}
if request.version() == "latest" {
let installed = backend.list_installed_versions();
if let Some(latest_version) = installed.iter().max_by(|a, b| {
versions::Versioning::new(a).cmp(&versions::Versioning::new(b))
}) {
let key = (ba.short.clone(), latest_version.clone());
if seen.insert(key) {
let tv = crate::toolset::ToolVersion::new(
request.clone(),
latest_version.clone(),
);
all_tools.push((ba.as_ref().clone(), tv));
}
}
}
}
}
}
}
}
if self.tool.is_empty() {
all_tools
} else {
let specified_versions: std::collections::HashMap<String, Option<String>> = self
.tool
.iter()
.map(|t| {
let version = t.tvr.as_ref().map(|tvr| tvr.version());
(t.ba.short.clone(), version)
})
.collect();
all_tools
.into_iter()
.filter(|(ba, _)| specified_versions.contains_key(&ba.short))
.map(|(ba, mut tv)| {
if let Some(Some(version)) = specified_versions.get(&ba.short) {
tv.version.clone_from(version);
}
(ba, tv)
})
.collect()
}
}
fn show_dry_run(&self, tools: &[LockTool], platforms: &[Platform]) -> Result<()> {
miseprintln!("{} Dry run - would update:", style("→").yellow());
for (ba, tv) in tools {
let backend = crate::backend::get(ba);
for platform in platforms {
let variants = if let Some(ref backend) = backend {
backend.platform_variants(platform)
} else {
vec![platform.clone()]
};
for variant in variants {
miseprintln!(
" {} {}@{} for {}",
style("✓").green(),
style(&ba.short).bold(),
tv.version,
style(variant.to_key()).blue()
);
}
}
}
Ok(())
}
async fn process_tools(
&self,
settings: &Settings,
tools: &[LockTool],
platforms: &[Platform],
lockfile: &mut Lockfile,
) -> Result<(Vec<(String, String, bool)>, Vec<String>)> {
let jobs = self.jobs.unwrap_or(settings.jobs);
let semaphore = Arc::new(Semaphore::new(jobs));
let mut jset: JoinSet<LockResolutionResult> = JoinSet::new();
let mut results = Vec::new();
let mpr = MultiProgressReport::get();
let mut all_tasks: Vec<(
crate::cli::args::BackendArg,
crate::toolset::ToolVersion,
Platform,
)> = Vec::new();
for (ba, tv) in tools {
let backend = crate::backend::get(ba);
for platform in platforms {
let variants = if let Some(ref backend) = backend {
backend.platform_variants(platform)
} else {
vec![platform.clone()]
};
for variant in variants {
all_tasks.push((ba.clone(), tv.clone(), variant));
}
}
}
let total_tasks = all_tasks.len();
let pr = mpr.add("lock");
pr.set_length(total_tasks as u64);
for (ba, tv, platform) in all_tasks {
let semaphore = semaphore.clone();
let backend = crate::backend::get(&ba);
jset.spawn(async move {
let _permit = semaphore.acquire().await;
lockfile::resolve_tool_lock_info(ba, tv, platform, backend).await
});
}
let mut completed = 0;
let mut provenance_errors: Vec<String> = Vec::new();
while let Some(result) = jset.join_next().await {
completed += 1;
match result {
Ok(resolution) => {
let short = resolution.0.clone();
let version = resolution.1.clone();
let platform_key = resolution.3.to_key();
let ok = resolution.4.is_ok();
if let Err(msg) = &resolution.4 {
debug!("{msg}");
}
pr.set_message(format!("{}@{} {}", short, version, platform_key));
pr.set_position(completed);
if let Err(e) = lockfile::apply_lock_result(lockfile, resolution) {
provenance_errors.push(e.to_string());
results.push((short, platform_key, false));
} else {
results.push((short, platform_key, ok));
}
}
Err(e) => {
warn!("Task failed: {}", e);
}
}
}
pr.finish_with_message(format!("{} platform entries", total_tasks));
Ok((results, provenance_errors))
}
}
static AFTER_LONG_HELP: &str = color_print::cstr!(
r#"<bold><underline>Examples:</underline></bold>
$ <bold>mise lock</bold> # update lockfile for all common platforms
$ <bold>mise lock node python</bold> # update only node and python
$ <bold>mise lock --platform linux-x64</bold> # update only linux-x64 platform
$ <bold>mise lock --dry-run</bold> # show what would be updated
$ <bold>mise lock --local</bold> # update mise.local.lock for local configs
$ <bold>mise lock --global</bold> # include global config lockfile
"#
);
#[cfg(test)]
mod tests {
use super::Lock;
use crate::cli::args::ToolArg;
use crate::lockfile::{Lockfile, PlatformInfo};
use crate::toolset::{ToolRequest, ToolSource, ToolVersion};
use std::collections::BTreeMap;
use std::str::FromStr;
use std::sync::Arc;
fn lock_cmd(tool_filters: &[&str]) -> Lock {
Lock {
tool: tool_filters
.iter()
.map(|tool| ToolArg::from_str(tool).unwrap())
.collect(),
jobs: None,
dry_run: false,
platform: vec![],
local: false,
global: false,
}
}
fn lockfile_with_dummy() -> Lockfile {
let mut lockfile = Lockfile::default();
lockfile.set_platform_info(
"dummy",
"1.0.0",
Some("asdf:dummy"),
&BTreeMap::new(),
"linux-x64",
PlatformInfo {
checksum: Some("sha256:dummy".to_string()),
..Default::default()
},
);
lockfile
}
fn lockfile_with_legacy_aqua_jq() -> Lockfile {
let mut lockfile = Lockfile::default();
lockfile.set_platform_info(
"jq",
"1.7.1",
Some("aqua:jqlang/jq"),
&BTreeMap::new(),
"linux-x64",
PlatformInfo {
checksum: Some("sha256:jq".to_string()),
..Default::default()
},
);
lockfile
}
fn configured_tool(
backend: &str,
version: &str,
) -> (crate::cli::args::BackendArg, ToolVersion) {
let ba = crate::cli::args::BackendArg::new(backend.to_string(), Some(backend.to_string()));
let request =
ToolRequest::new(Arc::new(ba.clone()), version, ToolSource::Argument).unwrap();
let tv = ToolVersion::new(request, version.to_string());
(ba, tv)
}
#[test]
fn test_is_unfiltered_lock_run_without_tool_filter() {
let cmd = lock_cmd(&[]);
assert!(cmd.is_unfiltered_lock_run());
}
#[test]
fn test_is_not_unfiltered_lock_run_with_tool_filter() {
let cmd = lock_cmd(&["tiny"]);
assert!(!cmd.is_unfiltered_lock_run());
}
#[test]
fn test_prune_stale_entries_with_empty_tools_prunes_all_entries() {
let cmd = lock_cmd(&[]);
let mut lockfile = lockfile_with_dummy();
let pruned = cmd.prune_stale_entries_if_needed(&mut lockfile, &[]);
assert_eq!(
pruned,
std::collections::BTreeSet::from(["dummy".to_string()])
);
assert!(lockfile.all_platform_keys().is_empty());
}
#[test]
fn test_prune_stale_entries_with_filter_keeps_existing_entries() {
let cmd = lock_cmd(&["tiny"]);
let mut lockfile = lockfile_with_dummy();
let pruned = cmd.prune_stale_entries_if_needed(&mut lockfile, &[]);
assert!(pruned.is_empty());
assert_eq!(
lockfile.all_platform_keys(),
std::collections::BTreeSet::from(["linux-x64".to_string()])
);
}
#[test]
fn test_prune_stale_entries_preserves_legacy_keyed_backend_match() {
let cmd = lock_cmd(&[]);
let mut lockfile = lockfile_with_legacy_aqua_jq();
let tools = vec![configured_tool("aqua:jqlang/jq", "1.7.1")];
let pruned = cmd.prune_stale_entries_if_needed(&mut lockfile, &tools);
assert!(pruned.is_empty());
assert_eq!(
lockfile.all_platform_keys(),
std::collections::BTreeSet::from(["linux-x64".to_string()])
);
}
#[test]
fn test_filtered_run_prunes_stale_version() {
let cmd = lock_cmd(&["dummy"]);
let mut lockfile = lockfile_with_dummy(); let tools = vec![configured_tool("dummy", "2.0.0")];
cmd.prune_stale_versions(&mut lockfile, &tools);
assert!(lockfile.all_platform_keys().is_empty());
}
#[test]
fn test_filtered_run_preserves_current_version() {
let cmd = lock_cmd(&["dummy"]);
let mut lockfile = lockfile_with_dummy(); let tools = vec![configured_tool("dummy", "1.0.0")];
cmd.prune_stale_versions(&mut lockfile, &tools);
assert_eq!(
lockfile.all_platform_keys(),
std::collections::BTreeSet::from(["linux-x64".to_string()])
);
}
#[test]
fn test_filtered_run_preserves_non_targeted_tools() {
let cmd = lock_cmd(&["dummy"]);
let mut lockfile = lockfile_with_dummy(); lockfile.set_platform_info(
"jq",
"1.7.1",
Some("aqua:jqlang/jq"),
&BTreeMap::new(),
"macos-x64",
PlatformInfo {
checksum: Some("sha256:jq".to_string()),
..Default::default()
},
);
let tools = vec![configured_tool("dummy", "2.0.0")];
cmd.prune_stale_versions(&mut lockfile, &tools);
assert_eq!(
lockfile.all_platform_keys(),
std::collections::BTreeSet::from(["macos-x64".to_string()])
);
}
#[test]
fn test_unfiltered_run_prunes_stale_version() {
let cmd = lock_cmd(&[]);
let mut lockfile = lockfile_with_dummy(); let tools = vec![configured_tool("dummy", "2.0.0")];
cmd.prune_stale_versions(&mut lockfile, &tools);
assert!(lockfile.all_platform_keys().is_empty());
}
#[test]
fn test_unfiltered_run_preserves_current_version() {
let cmd = lock_cmd(&[]);
let mut lockfile = lockfile_with_dummy(); let tools = vec![configured_tool("dummy", "1.0.0")];
cmd.prune_stale_versions(&mut lockfile, &tools);
assert_eq!(
lockfile.all_platform_keys(),
std::collections::BTreeSet::from(["linux-x64".to_string()])
);
}
}