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                // The request is built directly from the node instead of
291                // round-tripping through a source string because parsing
292                // lowercases identifiers and drops sub-package information,
293                // which made already-installed detection unreliable.
294                let request_base = zoi_resolver::resolve::PackageRequest {
295                    handle: None,
296                    repo: (!node.pkg.repo.is_empty())
297                        .then(|| node.pkg.repo.to_lowercase()),
298                    name: node.pkg.name.to_lowercase(),
299                    sub_package: node.sub_package.clone(),
300                    version_spec: None
301                };
302
303                let target_scope = scope_override.unwrap_or(node.pkg.scope);
304                let installed = local::find_installed_manifests_matching(
305                    &request_base,
306                    target_scope
307                )?;
308
309                if installed.is_empty() {
310                    // Not installed in the requested scope. If it exists in
311                    // another scope, inform the user but continue installing.
312                    let other_scopes = [
313                        types::Scope::Project,
314                        types::Scope::User,
315                        types::Scope::System
316                    ]
317                    .into_iter()
318                    .filter(|s| *s != target_scope);
319                    for other_scope in other_scopes {
320                        if !local::find_installed_manifests_matching(
321                            &request_base,
322                            other_scope
323                        )?
324                        .is_empty()
325                        {
326                            let display_name = ux::format_display_name(
327                                &node.registry_handle,
328                                &node.pkg.repo,
329                                &node.pkg.name,
330                                node.sub_package.as_deref(),
331                                &config
332                            );
333                            if !options.plan_json {
334                                println!(
335                                    "{} Package '{}' is already installed in \
336                                     {:?} scope. Installing into {:?} scope \
337                                     anyway.",
338                                    "::".bold().blue(),
339                                    display_name.cyan(),
340                                    other_scope,
341                                    target_scope
342                                );
343                            }
344                            break;
345                        }
346                    }
347                    continue;
348                }
349
350                let already_at_target = installed.iter().any(|m| {
351                    m.version == node.version && m.revision == node.revision
352                });
353
354                let display_name = ux::format_display_name(
355                    &node.registry_handle,
356                    &node.pkg.repo,
357                    &node.pkg.name,
358                    node.sub_package.as_deref(),
359                    &config
360                );
361                if !options.plan_json {
362                    if already_at_target {
363                        let full_spec =
364                            format!("{}@{}", display_name, node.version);
365                        println!(
366                            "{} Package '{}' is already installed. Skipping.",
367                            "::".bold().green(),
368                            full_spec.cyan()
369                        );
370                    } else {
371                        let current_version = installed
372                            .first()
373                            .map(|m| m.version.as_str())
374                            .unwrap_or_default();
375
376                        let current_spec =
377                            format!("{display_name}@{current_version}");
378                        let available_spec =
379                            format!("{}@{}", display_name, node.version);
380
381                        println!(
382                            "{} Package '{}' is already installed (available: \
383                             {}).",
384                            "::".bold().yellow(),
385                            current_spec.cyan(),
386                            available_spec.cyan()
387                        );
388                    }
389                    println!(
390                        "   {} To update it, run: {}",
391                        "Hint:".bold().blue(),
392                        format!("zoi update {}", node.pkg.name).italic()
393                    );
394                }
395                to_remove.push(pkg_id.clone());
396            }
397            skipped_existing_count = to_remove.len();
398
399            for pkg_id in to_remove {
400                graph.nodes.remove(&pkg_id);
401                if let Some(children) = graph.adj.remove(&pkg_id)
402                    && let Some(root_children) = graph.adj.get_mut("$root")
403                {
404                    for child in children {
405                        root_children.insert(child);
406                    }
407                }
408                if let Some(root_children) = graph.adj.get_mut("$root") {
409                    root_children.remove(&pkg_id);
410                }
411            }
412
413            let mut valid_non_zoi_deps = std::collections::HashSet::new();
414            for source in &sources_to_process {
415                if let Ok(dep) = zoi_deps::parse_dependency_string(source)
416                    && dep.manager != "zoi"
417                {
418                    valid_non_zoi_deps.insert(source.clone());
419                }
420            }
421            for node in graph.nodes.values() {
422                for dep in &node.dependencies {
423                    if let Ok(dep_req) = zoi_deps::parse_dependency_string(dep)
424                        && dep_req.manager != "zoi"
425                    {
426                        valid_non_zoi_deps.insert(dep.clone());
427                    }
428                }
429            }
430            non_zoi_deps.retain(|dep| valid_non_zoi_deps.contains(dep));
431        }
432
433        if graph.nodes.is_empty() && non_zoi_deps.is_empty() {
434            println!("\nAll requested packages are already installed.");
435            return Ok(());
436        }
437
438        if !options.dry_run {
439            if let Some(pm) = options.plugin_manager {
440                pm.set_context(scope_override.unwrap_or_default())?;
441            }
442            for node in graph.nodes.values() {
443                if let Some(pm) = options.plugin_manager {
444                    let pkg_val = pm
445                        .lua
446                        .to_value(&node.pkg)
447                        .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
448                    pm.trigger_hook("on_pre_install", Some(&pkg_val))?;
449                }
450            }
451        }
452
453        let mut direct_packages = Vec::new();
454        let mut dependencies = Vec::new();
455
456        for node in graph.nodes.values() {
457            if matches!(node.reason, types::InstallReason::Direct) {
458                direct_packages.push(node);
459            } else {
460                dependencies.push(node);
461            }
462        }
463
464        direct_packages.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
465        dependencies.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
466
467        for node in graph.nodes.values() {
468            cli_utils::print_repo_warning(&node.pkg.repo);
469        }
470
471        // --- Phase 3: Safety & Compliance Checks ---
472        if !options.plan_json {
473            println!("{} Looking for conflicts...", "::".bold().blue());
474        }
475        let packages_to_install: Vec<&types::Package> =
476            graph.nodes.values().map(|n| &n.pkg).collect();
477
478        if !options.dry_run {
479            preflight::check_for_conflicts(&packages_to_install, options.yes)?;
480            for pkg in &packages_to_install {
481                if !util::display_updates(pkg, options.yes)? {
482                    return Err(anyhow!("Installation aborted by user."));
483                }
484            }
485            preflight::check_policy_compliance(&graph)?;
486            preflight::check_scope_compliance(&graph)?;
487            preflight::check_zoios_compliance(&graph)?;
488            preflight::check_for_vulnerabilities(&graph, options.yes)?;
489
490            let m_for_conflict_check = MultiProgress::new();
491            if options.plan_json {
492                m_for_conflict_check
493                    .set_draw_target(indicatif::ProgressDrawTarget::hidden());
494            }
495            preflight::check_file_conflicts(
496                &graph,
497                options.yes,
498                &m_for_conflict_check
499            )?;
500            let _ = m_for_conflict_check.clear();
501        }
502
503        if !options.plan_json {
504            println!("{} Checking available disk space...", "::".bold().blue());
505        }
506        let install_plan = plan::create_install_plan(
507            &graph.nodes,
508            options.build_type,
509            options.build
510        )?;
511
512        let mut total_download_size: u64 = 0;
513        let mut total_installed_size: u64 = 0;
514        let mut unique_downloads = HashSet::new();
515
516        for (id, node) in &graph.nodes {
517            match install_plan.get(id) {
518                Some(plan::InstallAction::DownloadAndInstall(details)) => {
519                    if unique_downloads.insert(details.info.final_url.clone()) {
520                        total_download_size += details.download_size;
521                    }
522                    total_installed_size += if details.installed_size > 0 {
523                        details.installed_size
524                    } else {
525                        node.pkg.installed_size.unwrap_or(0)
526                    };
527                }
528                Some(plan::InstallAction::BuildAndInstall) => {
529                    total_installed_size +=
530                        node.pkg.installed_size.unwrap_or(0);
531                }
532                _ => {}
533            }
534        }
535
536        if options.plan_json {
537            let mut packages = Vec::new();
538            for (id, node) in &graph.nodes {
539                let action_name = match install_plan.get(id) {
540                    Some(plan::InstallAction::DownloadAndInstall(_)) => {
541                        "download"
542                    }
543                    Some(plan::InstallAction::InstallFromArchive(_)) => {
544                        "archive"
545                    }
546                    Some(plan::InstallAction::BuildAndInstall) => "build",
547                    None => "unknown"
548                };
549                let reason = match &node.reason {
550                    types::InstallReason::Direct => "direct".to_string(),
551                    types::InstallReason::Dependency { parent } => {
552                        format!("dependency:{parent}")
553                    }
554                };
555                packages.push(json!({
556                    "id": id,
557                    "name": node.pkg.name,
558                    "version": node.version,
559                    "revision": node.revision,
560                    "sub_package": node.sub_package,
561                    "repo": node.pkg.repo,
562                    "registry": node.registry_handle,
563                    "reason": reason,
564                    "action": action_name,
565                    "source": node.source,
566                }));
567            }
568
569            let plan_data = json!({
570                "dry_run": options.dry_run,
571                "frozen": options.frozen,
572                "retry_attempts": options.retry,
573                "scope": format!("{:?}", scope_override.unwrap_or(types::Scope::User)),
574                "totals": {
575                    "direct_packages": direct_packages.len(),
576                    "dependencies": dependencies.len() + non_zoi_deps.len(),
577                    "download_bytes": total_download_size,
578                    "installed_bytes": total_installed_size,
579                    "skipped_existing": skipped_existing_count,
580                },
581                "packages": packages,
582                "non_zoi_dependencies": non_zoi_deps,
583            });
584            println!("{}", serde_json::to_string_pretty(&plan_data)?);
585            return Ok(());
586        }
587
588        if options.dry_run {
589            println!(
590                "\n{} Dry-run: installation plan above would be executed.",
591                "::".bold().yellow()
592            );
593            return Ok(());
594        }
595
596        // --- Phase 4: Transactional Execution ---
597        let install_path =
598            local::get_store_base_dir(scope_override.unwrap_or_default())?;
599        std::fs::create_dir_all(&install_path)?;
600
601        let available_space =
602            fs2::available_space(&install_path).unwrap_or(u64::MAX);
603
604        if total_installed_size > available_space {
605            return Err(anyhow!(
606                "Not enough disk space. Required: {}, Available: {}",
607                zoi_core::utils::format_bytes(total_installed_size),
608                zoi_core::utils::format_bytes(available_space)
609            ));
610        }
611
612        let config = zoi_core::config::read_config().unwrap_or_default();
613
614        println!(
615            "\n{} Packages ({})",
616            "::".bold().blue(),
617            direct_packages.len()
618        );
619        let direct_list: Vec<_> = direct_packages
620            .iter()
621            .map(|n| {
622                let display_name = ux::format_display_name(
623                    &n.registry_handle,
624                    &n.pkg.repo,
625                    &n.pkg.name,
626                    n.sub_package.as_deref(),
627                    &config
628                );
629                let version_display = if n.revision == "1" {
630                    n.version.clone()
631                } else {
632                    format!("{}-{}", n.version, n.revision)
633                };
634                format!("{display_name}@{version_display}")
635                    .cyan()
636                    .to_string()
637            })
638            .collect();
639        println!(" {}", direct_list.join("  "));
640
641        if options.verbose {
642            println!("\n{} Package origins", "::".bold().blue());
643            let mut direct_entries: Vec<_> = graph
644                .nodes
645                .iter()
646                .filter(|(_, node)| {
647                    matches!(node.reason, types::InstallReason::Direct)
648                })
649                .collect();
650            direct_entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
651            for (id, node) in direct_entries {
652                let action_name = match install_plan.get(id) {
653                    Some(plan::InstallAction::DownloadAndInstall(_)) => {
654                        "download"
655                    }
656                    Some(plan::InstallAction::InstallFromArchive(_)) => {
657                        "archive"
658                    }
659                    Some(plan::InstallAction::BuildAndInstall) => "build",
660                    None => "unknown"
661                };
662                let origin = crate::cmd::ux::classify_source_origin(
663                    &node.source,
664                    action_name
665                );
666                let display_name = ux::format_display_name(
667                    &node.registry_handle,
668                    &node.pkg.repo,
669                    &node.pkg.name,
670                    node.sub_package.as_deref(),
671                    &config
672                );
673                let version_display = if node.revision == "1" {
674                    node.version.clone()
675                } else {
676                    format!("{}-{}", node.version, node.revision)
677                };
678                println!(
679                    "  - {}@{} -> {} ({})",
680                    display_name.cyan(),
681                    version_display,
682                    origin.as_str(),
683                    action_name
684                );
685            }
686        }
687
688        if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
689            println!(
690                "\n{} Dependencies ({})",
691                "::".bold().blue(),
692                dependencies.len() + non_zoi_deps.len()
693            );
694            let mut dep_list = Vec::new();
695            for n in &dependencies {
696                let display_name = ux::format_display_name(
697                    &n.registry_handle,
698                    &n.pkg.repo,
699                    &n.pkg.name,
700                    n.sub_package.as_deref(),
701                    &config
702                );
703                let version_display = if n.revision == "1" {
704                    n.version.clone()
705                } else {
706                    format!("{}-{}", n.version, n.revision)
707                };
708                dep_list.push(
709                    format!("zoi:{display_name}@{version_display}")
710                        .dimmed()
711                        .to_string()
712                );
713            }
714            for d in &non_zoi_deps {
715                dep_list.push(d.dimmed().to_string());
716            }
717            println!(" {}", dep_list.join("  "));
718        }
719
720        if total_download_size > 0 {
721            println!(
722                "\nTotal Download Size:  {}",
723                zoi_core::utils::format_bytes(total_download_size)
724            );
725        }
726        if total_installed_size > 0 {
727            println!(
728                "Total Installed Size: {}",
729                zoi_core::utils::format_bytes(total_installed_size)
730            );
731        }
732
733        if options.verbose {
734            let preflight =
735                crate::cmd::ux::PreflightSummary::new("Install preflight")
736                    .row(
737                        "Scope",
738                        format!(
739                            "{:?}",
740                            scope_override.unwrap_or(types::Scope::User)
741                        )
742                    )
743                    .row("Frozen lockfile", options.frozen.to_string())
744                    .row("Retry attempts", options.retry.to_string())
745                    .row("Direct packages", direct_packages.len().to_string())
746                    .row(
747                        "Dependencies",
748                        (dependencies.len() + non_zoi_deps.len()).to_string()
749                    )
750                    .row(
751                        "Download size",
752                        zoi_core::utils::format_bytes(total_download_size)
753                    )
754                    .row(
755                        "Installed size",
756                        zoi_core::utils::format_bytes(total_installed_size)
757                    );
758            crate::cmd::ux::print_preflight(&preflight);
759        }
760
761        let yes = options.yes;
762        if !zoi_core::utils::ask_for_confirmation(
763            "\nProceed with installation?",
764            yes
765        ) {
766            return Ok(());
767        }
768
769        let stages = graph.toposort()?;
770        let transaction = Mutex::new(transaction::begin()?);
771        let transaction_id = transaction
772            .lock()
773            .expect("Transaction mutex poisoned")
774            .id
775            .clone();
776        let dependency_installed_count = AtomicUsize::new(0);
777
778        println!("\n{} Preparing packages...", "::".bold().blue());
779        let m_prep = MultiProgress::new();
780        let prepared_nodes = Mutex::new(HashMap::new());
781
782        let build_type = options.build_type;
783        let verbose = options.verbose;
784
785        stages
786            .par_iter()
787            .flatten()
788            .try_for_each(|pkg_id| -> Result<()> {
789                let node = graph.nodes.get(pkg_id).ok_or_else(|| {
790                    anyhow!(
791                        "Package node '{pkg_id}' missing from graph during \
792                         preparation"
793                    )
794                })?;
795                let action = install_plan.get(pkg_id).ok_or_else(|| {
796                    anyhow!(
797                        "Install action missing for package '{pkg_id}' during \
798                         preparation"
799                    )
800                })?;
801
802                let prepared = installer::prepare_node(
803                    node,
804                    action,
805                    Some(&m_prep),
806                    build_type,
807                    verbose
808                )?;
809
810                let mut lock = prepared_nodes.lock().map_err(|e| {
811                    anyhow!(
812                        "Prepared nodes mutex poisoned during preparation: {e}"
813                    )
814                })?;
815                lock.insert(pkg_id.clone(), prepared);
816                Ok(())
817            })?;
818
819        if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
820            println!("\n{} Installing dependencies...", "::".bold().blue());
821            let m_deps = MultiProgress::new();
822
823            for stage in &stages {
824                stage.par_iter().try_for_each(|pkg_id| -> Result<()> {
825                    let node = graph.nodes.get(pkg_id).ok_or_else(|| {
826                        anyhow!(
827                            "Package node '{pkg_id}' missing from graph \
828                             during installation"
829                        )
830                    })?;
831                    if matches!(node.reason, types::InstallReason::Direct) {
832                        return Ok(());
833                    }
834
835                    let prepared = {
836                        let lock = prepared_nodes.lock().map_err(|e| {
837                            anyhow!(
838                                "Prepared nodes mutex poisoned during \
839                                 dependency install: {e}"
840                            )
841                        })?;
842                        lock.get(pkg_id).cloned().ok_or_else(|| {
843                            anyhow!("Prepared node missing for: {pkg_id}")
844                        })?
845                    };
846
847                    match installer::install_prepared_node(
848                        node,
849                        &prepared,
850                        Some(&m_deps),
851                        yes,
852                        true,
853                        true,
854                        verbose
855                    ) {
856                        Ok(manifest) => {
857                            dependency_installed_count
858                                .fetch_add(1, Ordering::Relaxed);
859                            let mut tx_lock =
860                                transaction.lock().map_err(|e| {
861                                    anyhow!(
862                                        "Transaction mutex poisoned during \
863                                         installation: {e}"
864                                    )
865                                })?;
866                            if let Err(e) = transaction::record_operation(
867                                &mut tx_lock,
868                                types::TransactionOperation::Install {
869                                    manifest: Box::new(manifest)
870                                }
871                            ) {
872                                return Err(anyhow!(
873                                    "Transaction recording failed: {e}"
874                                ));
875                            }
876                        }
877                        Err(e) => {
878                            failed_packages
879                                .lock()
880                                .expect("Failed packages mutex poisoned")
881                                .push(node.pkg.name.clone());
882                            eprintln!(
883                                "Error installing {}: {}",
884                                node.pkg.name, e
885                            );
886                        }
887                    }
888                    Ok(())
889                })?;
890            }
891        }
892
893        println!("\n{} Installing packages...", "::".bold().blue());
894        let m_pkg = MultiProgress::new();
895
896        for stage in &stages {
897            let mut stage_direct_ids = Vec::new();
898            for pkg_id in stage {
899                if let Some(node) = graph.nodes.get(pkg_id)
900                    && matches!(node.reason, types::InstallReason::Direct)
901                {
902                    let name = if let Some(sub) = &node.sub_package {
903                        format!("{}:{}", node.pkg.name, sub)
904                    } else {
905                        node.pkg.name.clone()
906                    };
907                    let version_display = if node.revision == "1" {
908                        node.version.clone()
909                    } else {
910                        format!("{}-{}", node.version, node.revision)
911                    };
912                    println!("@{name}:{version_display}");
913                    stage_direct_ids.push(pkg_id.clone());
914                }
915            }
916
917            if stage_direct_ids.is_empty() {
918                continue;
919            }
920
921            let res = stage_direct_ids.par_iter().try_for_each(
922                |pkg_id| -> Result<()> {
923                    let node = graph.nodes.get(pkg_id).ok_or_else(|| {
924                        anyhow!(
925                            "Package node '{pkg_id}' missing from graph \
926                             during final installation"
927                        )
928                    })?;
929
930                    let prepared = {
931                        let lock = prepared_nodes.lock().map_err(|e| {
932                            anyhow!(
933                                "Prepared nodes mutex poisoned during package \
934                                 install: {e}"
935                            )
936                        })?;
937                        lock.get(pkg_id).cloned().ok_or_else(|| {
938                            anyhow!("Prepared node missing for: {pkg_id}")
939                        })?
940                    };
941
942                    match installer::install_prepared_node(
943                        node,
944                        &prepared,
945                        Some(&m_pkg),
946                        yes,
947                        true,
948                        true,
949                        verbose
950                    ) {
951                        Ok(manifest) => {
952                            installed_manifests
953                                .lock()
954                                .expect("Installed manifests mutex poisoned")
955                                .push(manifest.clone());
956                            let mut tx_lock =
957                                transaction.lock().map_err(|e| {
958                                    anyhow!(
959                                        "Transaction mutex poisoned during \
960                                         direct package installation: {e}"
961                                    )
962                                })?;
963                            transaction::record_operation(
964                                &mut tx_lock,
965                                types::TransactionOperation::Install {
966                                    manifest: Box::new(manifest)
967                                }
968                            )?;
969                            successfully_installed_sources
970                                .lock()
971                                .expect(
972                                    "Successfully installed sources mutex \
973                                     poisoned"
974                                )
975                                .push(node.source.clone());
976                            Ok(())
977                        }
978                        Err(e) => {
979                            failed_packages
980                                .lock()
981                                .expect("Failed packages mutex poisoned")
982                                .push(node.pkg.name.clone());
983                            eprintln!(
984                                "Error installing {}: {}",
985                                node.pkg.name, e
986                            );
987                            Err(e)
988                        }
989                    }
990                }
991            );
992
993            if res.is_err() {
994                break;
995            }
996        }
997
998        let failed = failed_packages
999            .lock()
1000            .expect("Failed packages mutex poisoned");
1001        if !failed.is_empty() {
1002            println!("\n{} Rolling back changes...", "::".bold().yellow());
1003            transaction::rollback(&transaction_id)?;
1004            return Err(anyhow!(
1005                "Installation failed for: {}",
1006                failed.join(", ")
1007            ));
1008        }
1009
1010        if let Err(e) = transaction::commit(&transaction_id) {
1011            eprintln!("Warning: Failed to commit transaction: {e}");
1012        }
1013
1014        let installed_manifests_vec = installed_manifests
1015            .lock()
1016            .expect("Installed manifests mutex poisoned")
1017            .clone();
1018        for manifest in &installed_manifests_vec {
1019            if let Some(pm) = options.plugin_manager {
1020                let pkg_val = pm
1021                    .lua
1022                    .to_value(manifest)
1023                    .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
1024                pm.trigger_hook_nonfatal("on_post_install", Some(&pkg_val));
1025            }
1026        }
1027
1028        println!("\n{} Installation complete!", "Success:".green().bold());
1029        Ok(())
1030    }
1031}