diff --git a/e2e/cli/test_error_display b/e2e/cli/test_error_display index 88f96d45a1..c3cef32c0a 100644 --- a/e2e/cli/test_error_display +++ b/e2e/cli/test_error_display @@ -59,7 +59,7 @@ local_assert_fail "mise install github:nonexistent-org/nonexistent-repo@latest" # Test 4: Plugin not found echo "Test 4: Plugin not found" local_assert_fail "mise install nonexistent-tool@1.0.0" \ - "mise ERROR nonexistent-tool not found in mise tool registry" + "mise ERROR Failed to install nonexistent-tool@1.0.0: nonexistent-tool not found in mise tool registry" # Test 5: Multiple tool failures (could be single or multiple depending on timing) echo "Test 5: Multiple tool failures" diff --git a/src/toolset/helpers.rs b/src/toolset/helpers.rs index d7e3d31ccd..2b1488f140 100644 --- a/src/toolset/helpers.rs +++ b/src/toolset/helpers.rs @@ -1,9 +1,5 @@ -use std::collections::HashSet; use std::sync::Arc; -use eyre::Result; -use itertools::Itertools; - use crate::backend::Backend; use crate::toolset::tool_request::ToolRequest; use crate::toolset::tool_version::ToolVersion; @@ -24,28 +20,3 @@ pub(super) fn show_python_install_hint(versions: &[ToolRequest]) { "mise use python@3.12 python@3.11" ); } - -pub(super) fn get_leaf_dependencies(requests: &[ToolRequest]) -> Result> { - // reverse maps potential shorts like "cargo-binstall" for "cargo:cargo-binstall" - let versions_hash = requests - .iter() - .flat_map(|tr| tr.ba().all_fulls()) - .collect::>(); - let leaves = requests - .iter() - .map(|tr| { - match tr.backend()?.get_all_dependencies(true)?.iter().all(|dep| { - // dep is a dependency of tr so if it is in versions_hash (meaning it's also being installed) then it is not a leaf node - !dep.all_fulls() - .iter() - .any(|full| versions_hash.contains(full)) - }) { - true => Ok(Some(tr)), - false => Ok(None), - } - }) - .flatten_ok() - .map_ok(|tr| tr.clone()) - .collect::>>()?; - Ok(leaves) -} diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index 10c80f5a19..65d9f010db 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -35,6 +35,7 @@ mod helpers; mod install_options; pub(crate) mod install_state; pub(crate) mod outdated_info; +mod tool_deps; pub(crate) mod tool_request; mod tool_request_set; mod tool_source; diff --git a/src/toolset/tool_deps.rs b/src/toolset/tool_deps.rs new file mode 100644 index 0000000000..a0718d7fbc --- /dev/null +++ b/src/toolset/tool_deps.rs @@ -0,0 +1,333 @@ +use std::collections::{HashMap, HashSet}; + +use eyre::Result; +use indexmap::IndexSet; +use petgraph::Direction; +use petgraph::algo::is_cyclic_directed; +use petgraph::stable_graph::{NodeIndex, StableGraph}; +use tokio::sync::mpsc; + +use crate::toolset::tool_request::ToolRequest; + +/// Unique key for a tool request (backend full name + version) +pub type ToolKey = String; + +/// Creates a unique key for a ToolRequest +fn tool_key(tr: &ToolRequest) -> ToolKey { + format!("{}@{}", tr.ba().full(), tr.version()) +} + +/// Manages a dependency graph of tools for installation scheduling. +/// Uses Kahn's algorithm to emit tools that are ready to install +/// (i.e., all their dependencies have been installed). +#[derive(Debug)] +pub struct ToolDeps { + /// The dependency graph where edges point from a tool to its dependencies + /// (i.e., edge A→B means "A depends on B", so B must be installed first). + /// Uses StableGraph to maintain valid node indices after removals. + graph: StableGraph, + /// Maps tool keys to their node indices in the graph + node_indices: HashMap, + /// Tools that have already been sent for installation + sent: HashSet, + /// Tools that are blocked due to dependency failures or cycles + blocked: HashSet, + /// Channel sender for emitting ready tools (None signals completion). + /// Initially created with a dummy receiver that is dropped; the real + /// receiver is created when `subscribe()` is called. + tx: mpsc::UnboundedSender>, +} + +impl ToolDeps { + /// Creates a new ToolDeps from a list of tool requests. + /// Builds the dependency graph based on each tool's dependencies. + /// Duplicate tool requests (same backend and version) are deduplicated. + pub fn new(requests: Vec) -> Result { + let mut graph = StableGraph::new(); + let mut node_indices = HashMap::new(); + + // First pass: add all requested tools to the graph, deduplicating by key + for tr in &requests { + let key = tool_key(tr); + // Skip duplicates - only add the first occurrence + if node_indices.contains_key(&key) { + continue; + } + let idx = graph.add_node(tr.clone()); + node_indices.insert(key, idx); + } + + // Build a set of all tool identifiers being installed for dependency lookup + let versions_hash: HashSet = + requests.iter().flat_map(|tr| tr.ba().all_fulls()).collect(); + + // Second pass: add edges for dependencies + for tr in &requests { + let tr_key = tool_key(tr); + // Skip if this is a duplicate we didn't add + let Some(&tr_idx) = node_indices.get(&tr_key) else { + continue; + }; + + // Get all dependencies for this tool + if let Ok(backend) = tr.backend() + && let Ok(deps) = backend.get_all_dependencies(true) + { + for dep_ba in deps { + // Check if this dependency is being installed + let dep_fulls = dep_ba.all_fulls(); + if dep_fulls.iter().any(|full| versions_hash.contains(full)) { + // Find the matching tool request in our set + for other_tr in &requests { + let other_fulls = other_tr.ba().all_fulls(); + if dep_fulls.iter().any(|f| other_fulls.contains(f)) { + let other_key = tool_key(other_tr); + if tr_key != other_key + && let Some(&other_idx) = node_indices.get(&other_key) + { + // Edge from tr to dep means "tr depends on dep" + graph.update_edge(tr_idx, other_idx, ()); + } + } + } + } + } + } + } + + // Create a dummy channel - the real one is created in subscribe() + let (tx, _) = mpsc::unbounded_channel(); + + let mut deps = Self { + graph, + node_indices, + sent: HashSet::new(), + blocked: HashSet::new(), + tx, + }; + + // Detect and block any cycles + deps.detect_and_block_cycles(); + + Ok(deps) + } + + /// Subscribe to receive tools that are ready to install. + /// Returns a receiver that will emit Some(ToolRequest) for each ready tool, + /// followed by None when all tools have been processed. + pub fn subscribe(&mut self) -> mpsc::UnboundedReceiver> { + let (tx, rx) = mpsc::unbounded_channel(); + self.tx = tx; + self.emit_leaves(); + rx + } + + /// Mark a tool as successfully installed and emit any newly-ready tools. + pub fn complete_success(&mut self, tr: &ToolRequest) { + let key = tool_key(tr); + self.remove_node(&key); + self.emit_leaves(); + } + + /// Mark a tool as failed and block all transitive dependents. + pub fn complete_failure(&mut self, tr: &ToolRequest) { + let key = tool_key(tr); + + // Find and block all transitive dependents before removing the node + if let Some(&idx) = self.node_indices.get(&key) { + let dependents = self.get_transitive_dependents(idx); + for dep_idx in dependents { + if let Some(dep_tr) = self.graph.node_weight(dep_idx) { + let dep_key = tool_key(dep_tr); + self.blocked.insert(dep_key); + } + } + } + + self.remove_node(&key); + self.emit_leaves(); + } + + /// Returns whether all tools have been processed + pub fn is_empty(&self) -> bool { + self.graph.node_count() == 0 + } + + /// Returns the list of blocked tools (those whose dependencies failed or are in cycles) + pub fn blocked_tools(&self) -> Vec { + self.graph + .node_indices() + .filter_map(|idx| { + let tr = self.graph.node_weight(idx)?; + if self.blocked.contains(&tool_key(tr)) { + Some(tr.clone()) + } else { + None + } + }) + .collect() + } + + /// Detect cycles in the graph and mark all nodes in cycles as blocked + fn detect_and_block_cycles(&mut self) { + if !is_cyclic_directed(&self.graph) { + return; + } + + // Find all nodes that are part of cycles by checking which nodes + // have no path to a leaf (a node with out-degree 0) + let mut can_reach_leaf: HashSet = HashSet::new(); + + // Start with all leaf nodes + for idx in self.graph.node_indices() { + if self + .graph + .neighbors_directed(idx, Direction::Outgoing) + .next() + .is_none() + { + can_reach_leaf.insert(idx); + } + } + + // Propagate backwards: if a node points to a node that can reach a leaf, + // then it can also reach a leaf + let mut changed = true; + while changed { + changed = false; + for idx in self.graph.node_indices() { + if can_reach_leaf.contains(&idx) { + continue; + } + // Check if any dependency can reach a leaf + let deps_can_reach = self + .graph + .neighbors_directed(idx, Direction::Outgoing) + .all(|dep_idx| can_reach_leaf.contains(&dep_idx)); + if deps_can_reach + && self + .graph + .neighbors_directed(idx, Direction::Outgoing) + .next() + .is_some() + { + can_reach_leaf.insert(idx); + changed = true; + } + } + } + + // Any node that cannot reach a leaf is in a cycle - block it + for idx in self.graph.node_indices() { + if !can_reach_leaf.contains(&idx) + && let Some(tr) = self.graph.node_weight(idx) + { + let key = tool_key(tr); + self.blocked.insert(key); + } + } + } + + /// Emit all tools that have no remaining dependencies (leaf nodes) + fn emit_leaves(&mut self) { + let leaves = self.find_leaves(); + + for tr in leaves { + let key = tool_key(&tr); + + // Skip if already sent, blocked, or completed + if self.sent.contains(&key) || self.blocked.contains(&key) { + continue; + } + + if self.sent.insert(key) { + trace!("Scheduling tool install: {}", tr); + if let Err(e) = self.tx.send(Some(tr)) { + trace!("Error sending tool: {e:?}"); + } + } + } + + // Check if we're done + if self.is_all_done() { + trace!("All tool installations finished"); + if let Err(e) = self.tx.send(None) { + trace!("Error closing tool stream: {e:?}"); + } + } + } + + /// Find all leaf nodes (tools with no unsatisfied dependencies) + fn find_leaves(&self) -> Vec { + self.graph + .externals(Direction::Outgoing) + .filter_map(|idx| self.graph.node_weight(idx).cloned()) + .collect() + } + + /// Check if all tools have been processed (sent, completed, or blocked) + fn is_all_done(&self) -> bool { + // All done if graph is empty + if self.is_empty() { + return true; + } + + // Or if all remaining tools are blocked + self.graph.node_indices().all(|idx| { + self.graph + .node_weight(idx) + .map(|tr| self.blocked.contains(&tool_key(tr))) + .unwrap_or(true) + }) + } + + /// Remove a node from the graph by its key. + /// Uses StableGraph so other node indices remain valid. + fn remove_node(&mut self, key: &ToolKey) { + if let Some(&idx) = self.node_indices.get(key) { + self.graph.remove_node(idx); + self.node_indices.remove(key); + } + } + + /// Get all transitive dependents of a node (tools that depend on this one) + fn get_transitive_dependents(&self, start_idx: NodeIndex) -> IndexSet { + let mut dependents = IndexSet::new(); + let mut stack = vec![start_idx]; + + while let Some(idx) = stack.pop() { + // Find all nodes that have an edge TO this node (i.e., depend on it) + for neighbor in self.graph.neighbors_directed(idx, Direction::Incoming) { + if dependents.insert(neighbor) { + stack.push(neighbor); + } + } + } + + dependents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_deps() { + let deps = ToolDeps::new(vec![]).unwrap(); + assert!(deps.is_empty()); + } + + #[test] + fn test_find_leaves_empty_graph() { + let deps = ToolDeps::new(vec![]).unwrap(); + let leaves = deps.find_leaves(); + assert!(leaves.is_empty()); + } + + #[test] + fn test_is_all_done_empty() { + let deps = ToolDeps::new(vec![]).unwrap(); + assert!(deps.is_all_done()); + } +} diff --git a/src/toolset/toolset_install.rs b/src/toolset/toolset_install.rs index 5508561c34..73117e5ecd 100644 --- a/src/toolset/toolset_install.rs +++ b/src/toolset/toolset_install.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use eyre::Result; use indexmap::IndexSet; use itertools::Itertools; -use tokio::{sync::Semaphore, task::JoinSet}; +use tokio::sync::{Mutex, Semaphore}; +use tokio::task::JoinSet; use crate::config::Config; use crate::config::settings::Settings; @@ -13,8 +14,9 @@ use crate::hooks::{Hooks, InstalledToolInfo}; use crate::install_context::InstallContext; use crate::plugins::PluginType; use crate::toolset::Toolset; -use crate::toolset::helpers::{get_leaf_dependencies, show_python_install_hint}; +use crate::toolset::helpers::show_python_install_hint; use crate::toolset::install_options::InstallOptions; +use crate::toolset::tool_deps::ToolDeps; use crate::toolset::tool_request::ToolRequest; use crate::toolset::tool_source::ToolSource; use crate::toolset::tool_version::ToolVersion; @@ -121,37 +123,32 @@ impl Toolset { self.init_request_options(&mut versions); show_python_install_hint(&versions); - // Handle dependencies by installing in dependency order - let mut installed = vec![]; - let mut leaf_deps = get_leaf_dependencies(&versions)?; + // Ensure plugins are installed before building dependency graph + let plugin_errors = self.ensure_plugins_installed(config, &versions, opts).await; - while !leaf_deps.is_empty() { - if leaf_deps.len() < versions.len() { - debug!("installing {} leaf tools first", leaf_deps.len()); - } - versions.retain(|tr| !leaf_deps.contains(tr)); - match self.install_some_versions(config, leaf_deps, opts).await { - Ok(leaf_versions) => installed.extend(leaf_versions), - Err(Error::InstallFailed { - successful_installations, - failed_installations, - }) => { - // Count both successes and failures toward footer progress - mpr.footer_inc(successful_installations.len() + failed_installations.len()); - installed.extend(successful_installations); - - return Err(Error::InstallFailed { - successful_installations: installed, - failed_installations, - } - .into()); - } - Err(e) => return Err(e.into()), - } + // Filter out tools with plugin errors + let tools_with_plugin_errors: HashSet<_> = + plugin_errors.iter().map(|(tr, _)| tr.clone()).collect(); + let versions_to_install: Vec<_> = versions + .into_iter() + .filter(|tr| !tools_with_plugin_errors.contains(tr)) + .collect(); + + // Build dependency graph and install using Kahn's algorithm + let (installed, failed) = self + .install_with_deps(config, versions_to_install, opts) + .await; - leaf_deps = get_leaf_dependencies(&versions)?; + // Update footer for plugin errors + let plugin_error_count = plugin_errors.len(); + if plugin_error_count > 0 { + mpr.footer_inc(plugin_error_count); } + // Combine plugin errors with installation failures + let mut all_failed = plugin_errors; + all_failed.extend(failed); + // Skip config reload and resolve in dry-run mode if !opts.dry_run { // Reload config and resolve (ignoring errors like the original does) @@ -207,50 +204,38 @@ impl Toolset { if !opts.dry_run { mpr.footer_finish(); } - Ok(installed) + + // Return appropriate result + if all_failed.is_empty() { + Ok(installed) + } else { + Err(Error::InstallFailed { + successful_installations: installed, + failed_installations: all_failed, + } + .into()) + } } - pub(super) async fn install_some_versions( - &mut self, + /// Ensure all plugins for the requested tools are installed + async fn ensure_plugins_installed( + &self, config: &Arc, - versions: Vec, + versions: &[ToolRequest], opts: &InstallOptions, - ) -> Result, Error> { - debug!("install_some_versions: {}", versions.iter().join(" ")); - - // Group versions by backend - let versions_clone = versions.clone(); - let queue: Result> = versions - .into_iter() - .rev() - .chunk_by(|v| v.ba().clone()) - .into_iter() - .map(|(ba, v)| Ok((ba.backend()?, v.collect_vec()))) - .collect(); + ) -> Vec<(ToolRequest, eyre::Error)> { + let mut plugin_errors = Vec::new(); + let mut checked_backends = HashSet::new(); - let queue = match queue { - Ok(q) => q, - Err(e) => { - // If we can't build the queue, return error for all versions - let failed_installations: Vec<_> = versions_clone - .into_iter() - .map(|tr| (tr, eyre::eyre!("{}", e))) - .collect(); - return Err(Error::InstallFailed { - successful_installations: vec![], - failed_installations, - }); + for tr in versions { + let ba = tr.ba(); + if checked_backends.contains(ba) { + continue; } - }; - - // Don't initialize header here - it's already done in install_all_versions + checked_backends.insert(ba.clone()); - // Track plugin installation errors to avoid early returns - let mut plugin_errors = Vec::new(); - - // Ensure plugins are installed - for (backend, trs) in &queue { - if let Some(plugin) = backend.plugin() + if let Ok(backend) = tr.backend() + && let Some(plugin) = backend.plugin() && !plugin.is_installed() { let mpr = MultiProgressReport::get(); @@ -265,18 +250,56 @@ impl Toolset { } }) { - // Collect plugin installation errors instead of returning early - let plugin_name = backend.ba().short.clone(); - for tr in trs { - plugin_errors.push(( - tr.clone(), - eyre::eyre!("Plugin '{}' installation failed: {}", plugin_name, e), - )); + // Collect errors for all tools using this plugin + for tr2 in versions { + if tr2.ba() == ba { + plugin_errors.push(( + tr2.clone(), + eyre::eyre!("Plugin '{}' installation failed: {}", ba.short, e), + )); + } } } } } + plugin_errors + } + + /// Install tools using Kahn's algorithm for dependency ordering. + /// Returns (successful_installations, failed_installations). + async fn install_with_deps( + &self, + config: &Arc, + versions: Vec, + opts: &InstallOptions, + ) -> (Vec, Vec<(ToolRequest, eyre::Error)>) { + if versions.is_empty() { + return (vec![], vec![]); + } + + // Build index map to preserve original request order + let request_order: HashMap = versions + .iter() + .enumerate() + .map(|(i, tr)| (format!("{}@{}", tr.ba().full(), tr.version()), i)) + .collect(); + + // Build dependency graph + let tool_deps = match ToolDeps::new(versions.clone()) { + Ok(deps) => Arc::new(Mutex::new(deps)), + Err(e) => { + // If we can't build the graph, return error for all versions + let failed: Vec<_> = versions + .into_iter() + .map(|tr| (tr, eyre::eyre!("Failed to build dependency graph: {}", e))) + .collect(); + return (vec![], failed); + } + }; + + let mut rx = tool_deps.lock().await.subscribe(); + let raw = opts.raw || Settings::get().raw; let jobs = match raw { true => 1, @@ -284,129 +307,151 @@ impl Toolset { }; let semaphore = Arc::new(Semaphore::new(jobs)); let ts = Arc::new(self.clone()); - let mut tset: JoinSet)>> = JoinSet::new(); let opts = Arc::new(opts.clone()); - // Track semaphore acquisition errors - let mut semaphore_errors = Vec::new(); - - // Track which tools are being processed by each task for better error reporting - // Use a HashMap to map task IDs to their tools - let mut task_tools: HashMap> = HashMap::new(); - - // Track which tools already have plugin errors to avoid duplicate reporting - let mut tools_with_plugin_errors: HashSet = HashSet::new(); - for (tr, _) in &plugin_errors { - tools_with_plugin_errors.insert(tr.clone()); - } - - for (ba, trs) in queue { - let ts = ts.clone(); - let permit = match semaphore.clone().acquire_owned().await { - Ok(p) => p, - Err(e) => { - // Collect semaphore acquisition errors instead of returning early - for tr in trs { - semaphore_errors - .push((tr, eyre::eyre!("Failed to acquire semaphore: {}", e))); + let mut installed = vec![]; + let mut failed = vec![]; + let mut jset: JoinSet<(ToolRequest, Result)> = JoinSet::new(); + // Track in-flight tools to recover from task panics + let mut in_flight: HashMap = HashMap::new(); + + loop { + tokio::select! { + // Use `biased` to ensure completed installations are handled before starting new ones. + // This priority ordering ensures dependency tracking stays correct: we must process + // completions (which may unblock dependents) before spawning new installations. + biased; + + // Handle completed installations first (higher priority) + Some(result) = jset.join_next() => { + let mpr = MultiProgressReport::get(); + match result { + Ok((tr, Ok(tv))) => { + mpr.footer_inc(1); + installed.push(tv); + tool_deps.lock().await.complete_success(&tr); + } + Ok((tr, Err(e))) => { + mpr.footer_inc(1); + failed.push((tr.clone(), e)); + tool_deps.lock().await.complete_failure(&tr); + } + Err(e) => { + // Task panicked - try to recover the tool request from in_flight tracking + mpr.footer_inc(1); + if let Some(tr) = in_flight.remove(&e.id()) { + failed.push((tr.clone(), eyre::eyre!("Installation task panicked: {e:#}"))); + tool_deps.lock().await.complete_failure(&tr); + } else { + warn!("Task panicked but tool request not found: {e:#}"); + } + } } - continue; } - }; - let opts = opts.clone(); - let ba = ba.clone(); - let config = config.clone(); - // Filter out tools that already have plugin errors - let filtered_trs: Vec = trs - .into_iter() - .filter(|tr| !tools_with_plugin_errors.contains(tr)) - .collect(); + // Receive new tools to install + Some(maybe_tr) = rx.recv() => { + match maybe_tr { + Some(tr) => { + // Spawn installation task + let permit = match semaphore.clone().acquire_owned().await { + Ok(p) => p, + Err(e) => { + // Mark as failed and notify tool_deps so dependents are blocked + MultiProgressReport::get().footer_inc(1); + failed.push((tr.clone(), eyre::eyre!("Failed to acquire semaphore: {}", e))); + tool_deps.lock().await.complete_failure(&tr); + continue; + } + }; + + let config = config.clone(); + let ts = ts.clone(); + let opts = opts.clone(); + let tr_clone = tr.clone(); + + let handle = jset.spawn(async move { + let _permit = permit; + let result = Self::install_single_tool(&config, &ts, &tr, &opts).await; + (tr, result) + }); + in_flight.insert(handle.id(), tr_clone); + } + None => { + // All tools have been emitted, wait for remaining tasks + break; + } + } + } - // Skip spawning task if no tools remain after filtering - if filtered_trs.is_empty() { - continue; + else => break, } + } - // Track the tools for this task using the task ID - let task_id = tset.len(); - task_tools.insert(task_id, filtered_trs.clone()); - - tset.spawn(async move { - let _permit = permit; - let mpr = MultiProgressReport::get(); - let mut results = vec![]; - - for tr in filtered_trs { - let result = async { - let tv = tr.resolve(&config, &opts.resolve_options).await?; - - let ctx = InstallContext { - config: config.clone(), - ts: ts.clone(), - pr: mpr.add_with_options(&tv.style(), opts.dry_run), - force: opts.force, - dry_run: opts.dry_run, - locked: opts.locked, - }; - // Avoid wrapping the backend error here so the error location - // points to the backend implementation (more helpful for debugging). - ba.install_version(ctx, tv).await + // Wait for all remaining tasks to complete + while let Some(result) = jset.join_next().await { + let mpr = MultiProgressReport::get(); + match result { + Ok((tr, Ok(tv))) => { + mpr.footer_inc(1); + installed.push(tv); + tool_deps.lock().await.complete_success(&tr); + } + Ok((tr, Err(e))) => { + mpr.footer_inc(1); + failed.push((tr.clone(), e)); + tool_deps.lock().await.complete_failure(&tr); + } + Err(e) => { + mpr.footer_inc(1); + if let Some(tr) = in_flight.remove(&e.id()) { + failed.push((tr.clone(), eyre::eyre!("Installation task panicked: {e:#}"))); + tool_deps.lock().await.complete_failure(&tr); + } else { + warn!("Task panicked but tool request not found: {e:#}"); } - .await; - - results.push((tr, result)); - // Bump footer for each completed tool - MultiProgressReport::get().footer_inc(1); } - results - }); - } - - let mut task_results = vec![]; - - // Collect results from spawned tasks - while let Some(res) = tset.join_next().await { - match res { - Ok(results) => task_results.extend(results), - Err(e) => panic!("task join error: {e:#}"), } } - // Reverse task results to maintain original order (since we reversed when building queue) - task_results.reverse(); - - let mut all_results = vec![]; + // Add blocked tools to failures + let blocked = tool_deps.lock().await.blocked_tools(); + for tr in blocked { + failed.push((tr.clone(), eyre::eyre!("Skipped due to failed dependency"))); + MultiProgressReport::get().footer_inc(1); + } - // Add plugin errors first (in original order) - all_results.extend(plugin_errors.into_iter().map(|(tr, e)| (tr, Err(e)))); + // Sort installed versions by original request order to preserve user's intended ordering + installed.sort_by_key(|tv| { + let key = format!("{}@{}", tv.ba().full(), tv.request.version()); + request_order.get(&key).copied().unwrap_or(usize::MAX) + }); - // Add semaphore errors (in original order) - all_results.extend(semaphore_errors.into_iter().map(|(tr, e)| (tr, Err(e)))); + (installed, failed) + } - // Add task results (already in correct order after reversal) - all_results.extend(task_results); + /// Install a single tool + async fn install_single_tool( + config: &Arc, + ts: &Arc, + tr: &ToolRequest, + opts: &Arc, + ) -> Result { + let mpr = MultiProgressReport::get(); - // Process results and separate successes from failures - let mut successful_installations = vec![]; - let mut failed_installations = vec![]; + let tv = tr.resolve(config, &opts.resolve_options).await?; + let backend = tr.backend()?; - for (tr, result) in all_results { - match result { - Ok(tv) => successful_installations.push(tv), - Err(e) => failed_installations.push((tr, e)), - } - } + let ctx = InstallContext { + config: config.clone(), + ts: ts.clone(), + pr: mpr.add_with_options(&tv.style(), opts.dry_run), + force: opts.force, + dry_run: opts.dry_run, + locked: opts.locked, + }; - // Return appropriate result - if failed_installations.is_empty() { - Ok(successful_installations) - } else { - Err(Error::InstallFailed { - successful_installations, - failed_installations, - }) - } + backend.install_version(ctx, tv).await } pub async fn install_missing_bin(