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