Skip to main content

zoi_cli/cmd/install/
orchestrator.rs

1//! High-level installation orchestration.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Mutex;
5use std::sync::atomic::{AtomicUsize, Ordering};
6
7use anyhow::{Result, anyhow};
8use colored::Colorize;
9use indicatif::MultiProgress;
10use mlua::LuaSerdeExt;
11use rayon::prelude::*;
12use serde_json::json;
13use zoi_core::types;
14use zoi_install::{installer, lockfile, plan, preflight, resolver, util};
15use zoi_plugins::PluginManager;
16use zoi_project as project;
17use zoi_resolver::local;
18use zoi_transaction as transaction;
19
20use crate::cmd::ux;
21use crate::utils as cli_utils;
22
23/// Options for the installation orchestrator.
24pub struct InstallOptions<'a> {
25    /// The scope to install the packages into.
26    pub scope: types::Scope,
27    /// Whether to force the installation.
28    pub force: bool,
29    /// Whether to install all optional dependencies.
30    pub all_optional: bool,
31    /// Whether to skip confirmation prompts.
32    pub yes: bool,
33    /// Whether to save the installation to the project file.
34    pub save: bool,
35    /// The build type to use.
36    pub build_type: Option<&'a str>,
37    /// Whether to perform a dry run.
38    pub dry_run: bool,
39    /// The plugin manager to use.
40    pub plugin_manager: Option<&'a PluginManager>,
41    /// Whether to force building from source.
42    pub build: bool,
43    /// Whether to use the lockfile exactly (frozen).
44    pub frozen: bool,
45    /// Whether to explain decisions.
46    pub explain: bool,
47    /// Whether to emit machine-readable plan JSON.
48    pub plan_json: bool,
49    /// Number of download retry attempts.
50    pub retry: u32,
51    /// Whether to show verbose output.
52    pub verbose: bool,
53    /// Whether to use PURL (Package URL) specification.
54    pub purl: bool,
55    /// Optional project configuration override.
56    pub project_config: Option<project::config::ProjectConfig>
57}
58
59/// The installation orchestrator.
60pub struct Orchestrator<'a> {
61    /// The options used for the installation.
62    options: InstallOptions<'a>
63}
64
65impl<'a> Orchestrator<'a> {
66    /// Creates a new orchestrator with the given options.
67    pub fn new(options: InstallOptions<'a>) -> Self {
68        Self { options }
69    }
70
71    /// Runs the installation for the given sources.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if the installation fails at any stage.
76    ///
77    /// # Panics
78    ///
79    /// Panics if any of the internal mutexes (failed packages, prepared nodes,
80    /// etc.) are poisoned.
81    pub fn run(&self, sources: &[String], repo: Option<String>) -> Result<()> {
82        let options = &self.options;
83        if options.plan_json && !options.dry_run {
84            return Err(anyhow!("--plan-json requires --dry-run"));
85        }
86        util::set_download_retry_attempts(options.retry);
87
88        if sources.is_empty() && repo.is_none() && !options.frozen {
89            return Err(anyhow!("No packages specified for installation."));
90        }
91
92        let mut scope_override = Some(options.scope);
93
94        if options.frozen {
95            if repo.is_some() || !sources.is_empty() {
96                return Err(anyhow!(
97                    "--frozen can only be used without explicit sources or \
98                     --repo."
99                ));
100            }
101            if options.save {
102                return Err(anyhow!(
103                    "--save cannot be used with --frozen because the lockfile \
104                     must remain unchanged."
105                ));
106            }
107            if !std::path::Path::new("zoi.lua").exists() {
108                return Err(anyhow!(
109                    "--frozen requires a local zoi.lua in the current project."
110                ));
111            }
112            if !std::path::Path::new("zoi.lock").exists() {
113                return Err(anyhow!(
114                    "--frozen requires zoi.lock. Generate it first with a \
115                     normal project install."
116                ));
117            }
118            if let Some(scope) = scope_override
119                && scope != types::Scope::Project
120            {
121                return Err(anyhow!(
122                    "--frozen is only supported for project scope installs."
123                ));
124            }
125            scope_override = Some(types::Scope::Project);
126            zoi_core::frozen::set_frozen(true);
127        }
128
129        let lockfile_exists = sources.is_empty()
130            && repo.is_none()
131            && std::path::Path::new("zoi.lock").exists()
132            && (std::path::Path::new("zoi.lua").exists()
133                || std::path::Path::new("zoi.yaml").exists());
134
135        let mut sources_to_process: Vec<String> = sources.to_vec();
136        let mut _is_project_install = false;
137        let mut frozen_packages = None;
138
139        if options.frozen {
140            let lockfile = project::lockfile::read_zoi_lock()?;
141            let locked_packages = project::lockfile::locked_packages(&lockfile);
142            sources_to_process = locked_packages
143                .iter()
144                .map(|entry| entry.source.clone())
145                .collect();
146            if sources_to_process.is_empty() {
147                return Err(anyhow!(
148                    "zoi.lock is empty. Cannot continue with --frozen."
149                ));
150            }
151            frozen_packages = Some(locked_packages);
152            if !options.plan_json {
153                println!(
154                    "{} --frozen enabled. Installing pinned lockfile sources \
155                     only...",
156                    "::".bold().blue()
157                );
158            }
159            _is_project_install = true;
160        } else if sources.is_empty() && repo.is_none() {
161            if std::path::Path::new("zoi.lua").exists()
162                || std::path::Path::new("zoi.yaml").exists()
163            {
164                if let Ok(config) = project::config::load() {
165                    let config_file =
166                        if std::path::Path::new("zoi.lua").exists() {
167                            "zoi.lua"
168                        } else {
169                            "zoi.yaml"
170                        };
171                    if !options.plan_json {
172                        if lockfile_exists {
173                            println!(
174                                "{} zoi.lock found. Installing from {} then \
175                                 verifying...",
176                                "::".bold().blue(),
177                                config_file
178                            );
179                        } else {
180                            println!(
181                                "{} Installing project packages from {}...",
182                                "::".bold().blue(),
183                                config_file
184                            );
185                        }
186                    }
187                    sources_to_process.clone_from(&config.pkgs);
188                    if scope_override.is_none() {
189                        scope_override = Some(types::Scope::Project);
190                    }
191                    _is_project_install = true;
192                }
193            } else if let Some(pm) = options.plugin_manager
194                && pm.trigger_project_install_hook()?
195            {
196                return Ok(());
197            }
198        }
199
200        if let Some(_repo_spec) = repo {
201            if scope_override == Some(types::Scope::Project) {
202                return Err(anyhow!(
203                    "Installing from a repository to a project scope is not \
204                     supported."
205                ));
206            }
207
208            return Err(anyhow!(
209                "Repository installation not implemented in Orchestrator yet."
210            ));
211        }
212
213        if sources_to_process.is_empty() {
214            return Ok(());
215        }
216
217        if options.purl {
218            let mut resolved_purls = Vec::new();
219            for source in &sources_to_process {
220                if !options.plan_json {
221                    println!(
222                        "{} Fetching PURL package '{}'...",
223                        "::".bold().blue(),
224                        source
225                    );
226                }
227                let ident = zoi_purl::fetch_and_store_purl_package(source)?;
228                resolved_purls.push(ident);
229            }
230            sources_to_process = resolved_purls;
231        }
232
233        let config = zoi_core::config::read_config().unwrap_or_default();
234        let jobs = config.jobs.unwrap_or(3);
235        rayon::ThreadPoolBuilder::new()
236            .num_threads(jobs)
237            .build_global()
238            .ok();
239
240        let failed_packages = Mutex::new(Vec::new());
241        let mut temp_files = Vec::new();
242        let mut final_sources = Vec::new();
243
244        for source in &sources_to_process {
245            if std::path::Path::new(source)
246                .extension()
247                .is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
248            {
249                lockfile::process_lockfile(
250                    source,
251                    &mut final_sources,
252                    &mut temp_files,
253                    scope_override.unwrap_or(types::Scope::User)
254                )?;
255            } else {
256                final_sources.push(source.clone());
257            }
258        }
259
260        let successfully_installed_sources = Mutex::new(Vec::new());
261        let installed_manifests = Mutex::new(Vec::new());
262
263        // --- Phase 2: Dependency Resolution ---
264        let (mut graph, mut non_zoi_deps) =
265            if let Some(locked_packages) = frozen_packages.as_ref() {
266                resolver::build_graph_from_locked_packages(
267                    locked_packages,
268                    scope_override,
269                    options.plan_json,
270                    options.yes
271                )?
272            } else {
273                resolver::resolve_dependency_graph(
274                    &final_sources,
275                    scope_override,
276                    options.force,
277                    options.yes,
278                    options.all_optional,
279                    options.build_type,
280                    options.plan_json,
281                    options.project_config.clone()
282                )?
283            };
284
285        let config = zoi_core::config::read_config().unwrap_or_default();
286        let mut skipped_existing_count = 0usize;
287        if !options.force {
288            let mut to_remove = Vec::new();
289            for (pkg_id, node) in &graph.nodes {
290                // Check if any version of this package is already installed
291                let pkg_spec = if node.pkg.repo.is_empty() {
292                    node.pkg.name.clone()
293                } else {
294                    format!("@{}/{}", node.pkg.repo, node.pkg.name)
295                };
296
297                let request_base =
298                    zoi_resolver::resolve::parse_source_string(&pkg_spec)?;
299                let installed = local::find_installed_manifests_matching(
300                    &request_base,
301                    scope_override.unwrap_or(node.pkg.scope)
302                )?;
303
304                if !installed.is_empty() {
305                    let already_at_target = installed.iter().any(|m| {
306                        m.version == node.version && m.revision == node.revision
307                    });
308
309                    let display_name = ux::format_display_name(
310                        &node.registry_handle,
311                        &node.pkg.repo,
312                        &node.pkg.name,
313                        node.sub_package.as_deref(),
314                        &config
315                    );
316                    if !options.plan_json {
317                        if already_at_target {
318                            let full_spec =
319                                format!("{}@{}", display_name, node.version);
320                            println!(
321                                "{} Package '{}' is already installed. \
322                                 Skipping.",
323                                "::".bold().green(),
324                                full_spec.cyan()
325                            );
326                        } else {
327                            let current_version = installed
328                                .first()
329                                .map(|m| m.version.as_str())
330                                .unwrap_or_default();
331
332                            let current_spec =
333                                format!("{display_name}@{current_version}");
334                            let available_spec =
335                                format!("{}@{}", display_name, node.version);
336
337                            println!(
338                                "{} Package '{}' is already installed \
339                                 (available: {}).",
340                                "::".bold().yellow(),
341                                current_spec.cyan(),
342                                available_spec.cyan()
343                            );
344                            println!(
345                                "   {} To update to the newer version, run: {}",
346                                "Hint:".bold().blue(),
347                                format!("zoi update {pkg_spec}").italic()
348                            );
349                        }
350                    }
351                    to_remove.push(pkg_id.clone());
352                }
353            }
354            skipped_existing_count = to_remove.len();
355
356            for pkg_id in to_remove {
357                graph.nodes.remove(&pkg_id);
358                if let Some(children) = graph.adj.remove(&pkg_id)
359                    && let Some(root_children) = graph.adj.get_mut("$root")
360                {
361                    for child in children {
362                        root_children.insert(child);
363                    }
364                }
365                if let Some(root_children) = graph.adj.get_mut("$root") {
366                    root_children.remove(&pkg_id);
367                }
368            }
369
370            let mut valid_non_zoi_deps = std::collections::HashSet::new();
371            for source in &sources_to_process {
372                if let Ok(dep) = zoi_deps::parse_dependency_string(source)
373                    && dep.manager != "zoi"
374                {
375                    valid_non_zoi_deps.insert(source.clone());
376                }
377            }
378            for node in graph.nodes.values() {
379                for dep in &node.dependencies {
380                    if let Ok(dep_req) = zoi_deps::parse_dependency_string(dep)
381                        && dep_req.manager != "zoi"
382                    {
383                        valid_non_zoi_deps.insert(dep.clone());
384                    }
385                }
386            }
387            non_zoi_deps.retain(|dep| valid_non_zoi_deps.contains(dep));
388        }
389
390        if graph.nodes.is_empty() && non_zoi_deps.is_empty() {
391            println!("\nAll requested packages are already installed.");
392            return Ok(());
393        }
394
395        if !options.dry_run {
396            if let Some(pm) = options.plugin_manager {
397                pm.set_context(scope_override.unwrap_or_default())?;
398            }
399            for node in graph.nodes.values() {
400                if let Some(pm) = options.plugin_manager {
401                    let pkg_val = pm
402                        .lua
403                        .to_value(&node.pkg)
404                        .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
405                    pm.trigger_hook("on_pre_install", Some(&pkg_val))?;
406                }
407            }
408        }
409
410        let mut direct_packages = Vec::new();
411        let mut dependencies = Vec::new();
412
413        for node in graph.nodes.values() {
414            if matches!(node.reason, types::InstallReason::Direct) {
415                direct_packages.push(node);
416            } else {
417                dependencies.push(node);
418            }
419        }
420
421        direct_packages.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
422        dependencies.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
423
424        for node in graph.nodes.values() {
425            cli_utils::print_repo_warning(&node.pkg.repo);
426        }
427
428        // --- Phase 3: Safety & Compliance Checks ---
429        if !options.plan_json {
430            println!("{} Looking for conflicts...", "::".bold().blue());
431        }
432        let packages_to_install: Vec<&types::Package> =
433            graph.nodes.values().map(|n| &n.pkg).collect();
434
435        if !options.dry_run {
436            preflight::check_for_conflicts(&packages_to_install, options.yes)?;
437            for pkg in &packages_to_install {
438                if !util::display_updates(pkg, options.yes)? {
439                    return Err(anyhow!("Installation aborted by user."));
440                }
441            }
442            preflight::check_policy_compliance(&graph)?;
443            preflight::check_scope_compliance(&graph)?;
444            preflight::check_zoios_compliance(&graph)?;
445            preflight::check_for_vulnerabilities(&graph, options.yes)?;
446
447            let m_for_conflict_check = MultiProgress::new();
448            if options.plan_json {
449                m_for_conflict_check
450                    .set_draw_target(indicatif::ProgressDrawTarget::hidden());
451            }
452            preflight::check_file_conflicts(
453                &graph,
454                options.yes,
455                &m_for_conflict_check
456            )?;
457            let _ = m_for_conflict_check.clear();
458        }
459
460        if !options.plan_json {
461            println!("{} Checking available disk space...", "::".bold().blue());
462        }
463        let install_plan = plan::create_install_plan(
464            &graph.nodes,
465            options.build_type,
466            options.build
467        )?;
468
469        let mut total_download_size: u64 = 0;
470        let mut total_installed_size: u64 = 0;
471        let mut unique_downloads = HashSet::new();
472
473        for (id, node) in &graph.nodes {
474            match install_plan.get(id) {
475                Some(plan::InstallAction::DownloadAndInstall(details)) => {
476                    if unique_downloads.insert(details.info.final_url.clone()) {
477                        total_download_size += details.download_size;
478                    }
479                    total_installed_size += if details.installed_size > 0 {
480                        details.installed_size
481                    } else {
482                        node.pkg.installed_size.unwrap_or(0)
483                    };
484                }
485                Some(plan::InstallAction::BuildAndInstall) => {
486                    total_installed_size +=
487                        node.pkg.installed_size.unwrap_or(0);
488                }
489                _ => {}
490            }
491        }
492
493        if options.plan_json {
494            let mut packages = Vec::new();
495            for (id, node) in &graph.nodes {
496                let action_name = match install_plan.get(id) {
497                    Some(plan::InstallAction::DownloadAndInstall(_)) => {
498                        "download"
499                    }
500                    Some(plan::InstallAction::InstallFromArchive(_)) => {
501                        "archive"
502                    }
503                    Some(plan::InstallAction::BuildAndInstall) => "build",
504                    None => "unknown"
505                };
506                let reason = match &node.reason {
507                    types::InstallReason::Direct => "direct".to_string(),
508                    types::InstallReason::Dependency { parent } => {
509                        format!("dependency:{parent}")
510                    }
511                };
512                packages.push(json!({
513                    "id": id,
514                    "name": node.pkg.name,
515                    "version": node.version,
516                    "revision": node.revision,
517                    "sub_package": node.sub_package,
518                    "repo": node.pkg.repo,
519                    "registry": node.registry_handle,
520                    "reason": reason,
521                    "action": action_name,
522                    "source": node.source,
523                }));
524            }
525
526            let plan_data = json!({
527                "dry_run": options.dry_run,
528                "frozen": options.frozen,
529                "retry_attempts": options.retry,
530                "scope": format!("{:?}", scope_override.unwrap_or(types::Scope::User)),
531                "totals": {
532                    "direct_packages": direct_packages.len(),
533                    "dependencies": dependencies.len() + non_zoi_deps.len(),
534                    "download_bytes": total_download_size,
535                    "installed_bytes": total_installed_size,
536                    "skipped_existing": skipped_existing_count,
537                },
538                "packages": packages,
539                "non_zoi_dependencies": non_zoi_deps,
540            });
541            println!("{}", serde_json::to_string_pretty(&plan_data)?);
542            return Ok(());
543        }
544
545        if options.dry_run {
546            println!(
547                "\n{} Dry-run: installation plan above would be executed.",
548                "::".bold().yellow()
549            );
550            return Ok(());
551        }
552
553        // --- Phase 4: Transactional Execution ---
554        let install_path =
555            local::get_store_base_dir(scope_override.unwrap_or_default())?;
556        std::fs::create_dir_all(&install_path)?;
557
558        let available_space =
559            fs2::available_space(&install_path).unwrap_or(u64::MAX);
560
561        if total_installed_size > available_space {
562            return Err(anyhow!(
563                "Not enough disk space. Required: {}, Available: {}",
564                zoi_core::utils::format_bytes(total_installed_size),
565                zoi_core::utils::format_bytes(available_space)
566            ));
567        }
568
569        let config = zoi_core::config::read_config().unwrap_or_default();
570
571        println!(
572            "\n{} Packages ({})",
573            "::".bold().blue(),
574            direct_packages.len()
575        );
576        let direct_list: Vec<_> = direct_packages
577            .iter()
578            .map(|n| {
579                let display_name = ux::format_display_name(
580                    &n.registry_handle,
581                    &n.pkg.repo,
582                    &n.pkg.name,
583                    n.sub_package.as_deref(),
584                    &config
585                );
586                let version_display = if n.revision == "1" {
587                    n.version.clone()
588                } else {
589                    format!("{}-{}", n.version, n.revision)
590                };
591                format!("{display_name}@{version_display}")
592                    .cyan()
593                    .to_string()
594            })
595            .collect();
596        println!(" {}", direct_list.join("  "));
597
598        if options.verbose {
599            println!("\n{} Package origins", "::".bold().blue());
600            let mut direct_entries: Vec<_> = graph
601                .nodes
602                .iter()
603                .filter(|(_, node)| {
604                    matches!(node.reason, types::InstallReason::Direct)
605                })
606                .collect();
607            direct_entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
608            for (id, node) in direct_entries {
609                let action_name = match install_plan.get(id) {
610                    Some(plan::InstallAction::DownloadAndInstall(_)) => {
611                        "download"
612                    }
613                    Some(plan::InstallAction::InstallFromArchive(_)) => {
614                        "archive"
615                    }
616                    Some(plan::InstallAction::BuildAndInstall) => "build",
617                    None => "unknown"
618                };
619                let origin = crate::cmd::ux::classify_source_origin(
620                    &node.source,
621                    action_name
622                );
623                let display_name = ux::format_display_name(
624                    &node.registry_handle,
625                    &node.pkg.repo,
626                    &node.pkg.name,
627                    node.sub_package.as_deref(),
628                    &config
629                );
630                let version_display = if node.revision == "1" {
631                    node.version.clone()
632                } else {
633                    format!("{}-{}", node.version, node.revision)
634                };
635                println!(
636                    "  - {}@{} -> {} ({})",
637                    display_name.cyan(),
638                    version_display,
639                    origin.as_str(),
640                    action_name
641                );
642            }
643        }
644
645        if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
646            println!(
647                "\n{} Dependencies ({})",
648                "::".bold().blue(),
649                dependencies.len() + non_zoi_deps.len()
650            );
651            let mut dep_list = Vec::new();
652            for n in &dependencies {
653                let display_name = ux::format_display_name(
654                    &n.registry_handle,
655                    &n.pkg.repo,
656                    &n.pkg.name,
657                    n.sub_package.as_deref(),
658                    &config
659                );
660                let version_display = if n.revision == "1" {
661                    n.version.clone()
662                } else {
663                    format!("{}-{}", n.version, n.revision)
664                };
665                dep_list.push(
666                    format!("zoi:{display_name}@{version_display}")
667                        .dimmed()
668                        .to_string()
669                );
670            }
671            for d in &non_zoi_deps {
672                dep_list.push(d.dimmed().to_string());
673            }
674            println!(" {}", dep_list.join("  "));
675        }
676
677        if total_download_size > 0 {
678            println!(
679                "\nTotal Download Size:  {}",
680                zoi_core::utils::format_bytes(total_download_size)
681            );
682        }
683        if total_installed_size > 0 {
684            println!(
685                "Total Installed Size: {}",
686                zoi_core::utils::format_bytes(total_installed_size)
687            );
688        }
689
690        if options.verbose {
691            let preflight =
692                crate::cmd::ux::PreflightSummary::new("Install preflight")
693                    .row(
694                        "Scope",
695                        format!(
696                            "{:?}",
697                            scope_override.unwrap_or(types::Scope::User)
698                        )
699                    )
700                    .row("Frozen lockfile", options.frozen.to_string())
701                    .row("Retry attempts", options.retry.to_string())
702                    .row("Direct packages", direct_packages.len().to_string())
703                    .row(
704                        "Dependencies",
705                        (dependencies.len() + non_zoi_deps.len()).to_string()
706                    )
707                    .row(
708                        "Download size",
709                        zoi_core::utils::format_bytes(total_download_size)
710                    )
711                    .row(
712                        "Installed size",
713                        zoi_core::utils::format_bytes(total_installed_size)
714                    );
715            crate::cmd::ux::print_preflight(&preflight);
716        }
717
718        let yes = options.yes;
719        if !zoi_core::utils::ask_for_confirmation(
720            "\nProceed with installation?",
721            yes
722        ) {
723            return Ok(());
724        }
725
726        let stages = graph.toposort()?;
727        let transaction = Mutex::new(transaction::begin()?);
728        let transaction_id = transaction
729            .lock()
730            .expect("Transaction mutex poisoned")
731            .id
732            .clone();
733        let dependency_installed_count = AtomicUsize::new(0);
734
735        println!("\n{} Preparing packages...", "::".bold().blue());
736        let m_prep = MultiProgress::new();
737        let prepared_nodes = Mutex::new(HashMap::new());
738
739        let build_type = options.build_type;
740        let verbose = options.verbose;
741
742        stages
743            .par_iter()
744            .flatten()
745            .try_for_each(|pkg_id| -> Result<()> {
746                let node = graph.nodes.get(pkg_id).ok_or_else(|| {
747                    anyhow!(
748                        "Package node '{pkg_id}' missing from graph during \
749                         preparation"
750                    )
751                })?;
752                let action = install_plan.get(pkg_id).ok_or_else(|| {
753                    anyhow!(
754                        "Install action missing for package '{pkg_id}' during \
755                         preparation"
756                    )
757                })?;
758
759                let prepared = installer::prepare_node(
760                    node,
761                    action,
762                    Some(&m_prep),
763                    build_type,
764                    verbose
765                )?;
766
767                let mut lock = prepared_nodes.lock().map_err(|e| {
768                    anyhow!(
769                        "Prepared nodes mutex poisoned during preparation: {e}"
770                    )
771                })?;
772                lock.insert(pkg_id.clone(), prepared);
773                Ok(())
774            })?;
775
776        if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
777            println!("\n{} Installing dependencies...", "::".bold().blue());
778            let m_deps = MultiProgress::new();
779
780            for stage in &stages {
781                stage.par_iter().try_for_each(|pkg_id| -> Result<()> {
782                    let node = graph.nodes.get(pkg_id).ok_or_else(|| {
783                        anyhow!(
784                            "Package node '{pkg_id}' missing from graph \
785                             during installation"
786                        )
787                    })?;
788                    if matches!(node.reason, types::InstallReason::Direct) {
789                        return Ok(());
790                    }
791
792                    let prepared = {
793                        let lock = prepared_nodes.lock().map_err(|e| {
794                            anyhow!(
795                                "Prepared nodes mutex poisoned during \
796                                 dependency install: {e}"
797                            )
798                        })?;
799                        lock.get(pkg_id).cloned().ok_or_else(|| {
800                            anyhow!("Prepared node missing for: {pkg_id}")
801                        })?
802                    };
803
804                    match installer::install_prepared_node(
805                        node,
806                        &prepared,
807                        Some(&m_deps),
808                        yes,
809                        true,
810                        true,
811                        verbose
812                    ) {
813                        Ok(manifest) => {
814                            dependency_installed_count
815                                .fetch_add(1, Ordering::Relaxed);
816                            let mut tx_lock =
817                                transaction.lock().map_err(|e| {
818                                    anyhow!(
819                                        "Transaction mutex poisoned during \
820                                         installation: {e}"
821                                    )
822                                })?;
823                            if let Err(e) = transaction::record_operation(
824                                &mut tx_lock,
825                                types::TransactionOperation::Install {
826                                    manifest: Box::new(manifest)
827                                }
828                            ) {
829                                return Err(anyhow!(
830                                    "Transaction recording failed: {e}"
831                                ));
832                            }
833                        }
834                        Err(e) => {
835                            failed_packages
836                                .lock()
837                                .expect("Failed packages mutex poisoned")
838                                .push(node.pkg.name.clone());
839                            eprintln!(
840                                "Error installing {}: {}",
841                                node.pkg.name, e
842                            );
843                        }
844                    }
845                    Ok(())
846                })?;
847            }
848        }
849
850        println!("\n{} Installing packages...", "::".bold().blue());
851        let m_pkg = MultiProgress::new();
852
853        for stage in &stages {
854            let mut stage_direct_ids = Vec::new();
855            for pkg_id in stage {
856                if let Some(node) = graph.nodes.get(pkg_id)
857                    && matches!(node.reason, types::InstallReason::Direct)
858                {
859                    let name = if let Some(sub) = &node.sub_package {
860                        format!("{}:{}", node.pkg.name, sub)
861                    } else {
862                        node.pkg.name.clone()
863                    };
864                    let version_display = if node.revision == "1" {
865                        node.version.clone()
866                    } else {
867                        format!("{}-{}", node.version, node.revision)
868                    };
869                    println!("@{name}:{version_display}");
870                    stage_direct_ids.push(pkg_id.clone());
871                }
872            }
873
874            if stage_direct_ids.is_empty() {
875                continue;
876            }
877
878            let res = stage_direct_ids.par_iter().try_for_each(
879                |pkg_id| -> Result<()> {
880                    let node = graph.nodes.get(pkg_id).ok_or_else(|| {
881                        anyhow!(
882                            "Package node '{pkg_id}' missing from graph \
883                             during final installation"
884                        )
885                    })?;
886
887                    let prepared = {
888                        let lock = prepared_nodes.lock().map_err(|e| {
889                            anyhow!(
890                                "Prepared nodes mutex poisoned during package \
891                                 install: {e}"
892                            )
893                        })?;
894                        lock.get(pkg_id).cloned().ok_or_else(|| {
895                            anyhow!("Prepared node missing for: {pkg_id}")
896                        })?
897                    };
898
899                    match installer::install_prepared_node(
900                        node,
901                        &prepared,
902                        Some(&m_pkg),
903                        yes,
904                        true,
905                        true,
906                        verbose
907                    ) {
908                        Ok(manifest) => {
909                            installed_manifests
910                                .lock()
911                                .expect("Installed manifests mutex poisoned")
912                                .push(manifest.clone());
913                            let mut tx_lock =
914                                transaction.lock().map_err(|e| {
915                                    anyhow!(
916                                        "Transaction mutex poisoned during \
917                                         direct package installation: {e}"
918                                    )
919                                })?;
920                            transaction::record_operation(
921                                &mut tx_lock,
922                                types::TransactionOperation::Install {
923                                    manifest: Box::new(manifest)
924                                }
925                            )?;
926                            successfully_installed_sources
927                                .lock()
928                                .expect(
929                                    "Successfully installed sources mutex \
930                                     poisoned"
931                                )
932                                .push(node.source.clone());
933                            Ok(())
934                        }
935                        Err(e) => {
936                            failed_packages
937                                .lock()
938                                .expect("Failed packages mutex poisoned")
939                                .push(node.pkg.name.clone());
940                            eprintln!(
941                                "Error installing {}: {}",
942                                node.pkg.name, e
943                            );
944                            Err(e)
945                        }
946                    }
947                }
948            );
949
950            if res.is_err() {
951                break;
952            }
953        }
954
955        let failed = failed_packages
956            .lock()
957            .expect("Failed packages mutex poisoned");
958        if !failed.is_empty() {
959            println!("\n{} Rolling back changes...", "::".bold().yellow());
960            transaction::rollback(&transaction_id)?;
961            return Err(anyhow!(
962                "Installation failed for: {}",
963                failed.join(", ")
964            ));
965        }
966
967        if let Err(e) = transaction::commit(&transaction_id) {
968            eprintln!("Warning: Failed to commit transaction: {e}");
969        }
970
971        let installed_manifests_vec = installed_manifests
972            .lock()
973            .expect("Installed manifests mutex poisoned")
974            .clone();
975        for manifest in &installed_manifests_vec {
976            if let Some(pm) = options.plugin_manager {
977                let pkg_val = pm
978                    .lua
979                    .to_value(manifest)
980                    .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
981                pm.trigger_hook_nonfatal("on_post_install", Some(&pkg_val));
982            }
983        }
984
985        println!("\n{} Installation complete!", "Success:".green().bold());
986        Ok(())
987    }
988}