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
68/// Keeps only the external dependencies still required after pruning
69/// already-installed nodes.
70///
71/// A dependency survives when it is requested directly as a source or is
72/// declared by a surviving graph node. With `--deps-only`, dependencies of
73/// skipped (already-installed) nodes survive as well, because the caller
74/// explicitly wants dependencies installed even when the direct package is
75/// already present.
76fn filter_relevant_non_zoi_deps(
77    non_zoi_deps: Vec<String>,
78    sources_to_process: &[String],
79    graph: &resolver::DependencyGraph,
80    skipped_dep_strings: &[String],
81    deps_only: bool
82) -> Vec<String> {
83    fn insert_external(
84        valid: &mut HashSet<String>,
85        dep_strings: impl IntoIterator<Item = impl AsRef<str>>
86    ) {
87        for dep_str in dep_strings {
88            if let Ok(dep) = zoi_deps::parse_dependency_string(dep_str.as_ref())
89                && dep.manager != "zoi"
90            {
91                valid.insert(dep_str.as_ref().to_string());
92            }
93        }
94    }
95
96    let mut valid_non_zoi_deps = HashSet::new();
97    insert_external(&mut valid_non_zoi_deps, sources_to_process);
98    insert_external(
99        &mut valid_non_zoi_deps,
100        graph
101            .nodes
102            .values()
103            .flat_map(|node| node.dependencies.clone())
104    );
105    if deps_only {
106        insert_external(&mut valid_non_zoi_deps, skipped_dep_strings);
107    }
108    non_zoi_deps
109        .into_iter()
110        .filter(|dep| valid_non_zoi_deps.contains(dep))
111        .collect()
112}
113
114impl<'a> Orchestrator<'a> {
115    /// Creates a new orchestrator with the given options.
116    pub fn new(options: InstallOptions<'a>) -> Self {
117        Self { options }
118    }
119
120    /// Runs the installation for the given sources.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if the installation fails at any stage.
125    ///
126    /// # Panics
127    ///
128    /// Panics if any of the internal mutexes (failed packages, prepared nodes,
129    /// etc.) are poisoned.
130    pub fn run(&self, sources: &[String], repo: Option<String>) -> Result<()> {
131        let options = &self.options;
132        if options.plan_json && !options.dry_run {
133            return Err(anyhow!("--plan-json requires --dry-run"));
134        }
135        util::set_download_retry_attempts(options.retry);
136
137        if sources.is_empty() && repo.is_none() && !options.frozen {
138            return Err(anyhow!("No packages specified for installation."));
139        }
140
141        let mut scope_override = Some(options.scope);
142
143        if options.frozen {
144            if repo.is_some() || !sources.is_empty() {
145                return Err(anyhow!(
146                    "--frozen can only be used without explicit sources or \
147                     --repo."
148                ));
149            }
150            if options.save {
151                return Err(anyhow!(
152                    "--save cannot be used with --frozen because the lockfile \
153                     must remain unchanged."
154                ));
155            }
156            if !std::path::Path::new("zoi.lua").exists() {
157                return Err(anyhow!(
158                    "--frozen requires a local zoi.lua in the current project."
159                ));
160            }
161            if !std::path::Path::new("zoi.lock").exists() {
162                return Err(anyhow!(
163                    "--frozen requires zoi.lock. Generate it first with a \
164                     normal project install."
165                ));
166            }
167            if let Some(scope) = scope_override
168                && scope != types::Scope::Project
169            {
170                return Err(anyhow!(
171                    "--frozen is only supported for project scope installs."
172                ));
173            }
174            scope_override = Some(types::Scope::Project);
175            zoi_core::frozen::set_frozen(true);
176        }
177
178        let lockfile_exists = sources.is_empty()
179            && repo.is_none()
180            && std::path::Path::new("zoi.lock").exists()
181            && std::path::Path::new("zoi.lua").exists();
182
183        let mut sources_to_process: Vec<String> = sources.to_vec();
184        let mut _is_project_install = false;
185        let mut frozen_packages = None;
186
187        if options.frozen {
188            let lockfile = project::lockfile::read_zoi_lock()?;
189            let locked_packages = project::lockfile::locked_packages(&lockfile);
190            sources_to_process = locked_packages
191                .iter()
192                .map(|entry| entry.source.clone())
193                .collect();
194            if sources_to_process.is_empty() {
195                return Err(anyhow!(
196                    "zoi.lock is empty. Cannot continue with --frozen."
197                ));
198            }
199            frozen_packages = Some(locked_packages);
200            if !options.plan_json {
201                println!(
202                    "{} --frozen enabled. Installing pinned lockfile sources \
203                     only...",
204                    "::".bold().blue()
205                );
206            }
207            _is_project_install = true;
208        } else if sources.is_empty() && repo.is_none() {
209            if std::path::Path::new("zoi.lua").exists() {
210                if let Ok(config) = project::config::load() {
211                    let config_file = "zoi.lua";
212                    if !options.plan_json {
213                        if lockfile_exists {
214                            println!(
215                                "{} zoi.lock found. Installing from {} then \
216                                 verifying...",
217                                "::".bold().blue(),
218                                config_file
219                            );
220                        } else {
221                            println!(
222                                "{} Installing project packages from {}...",
223                                "::".bold().blue(),
224                                config_file
225                            );
226                        }
227                    }
228                    sources_to_process.clone_from(&config.pkgs);
229                    if scope_override.is_none() {
230                        scope_override = Some(types::Scope::Project);
231                    }
232                    _is_project_install = true;
233                }
234            } else if let Some(pm) = options.plugin_manager
235                && pm.trigger_project_install_hook()?
236            {
237                return Ok(());
238            }
239        }
240
241        if let Some(_repo_spec) = repo {
242            if scope_override == Some(types::Scope::Project) {
243                return Err(anyhow!(
244                    "Installing from a repository to a project scope is not \
245                     supported."
246                ));
247            }
248
249            return Err(anyhow!(
250                "Repository installation not implemented in Orchestrator yet."
251            ));
252        }
253
254        if sources_to_process.is_empty() {
255            return Ok(());
256        }
257
258        if options.purl {
259            let mut resolved_purls = Vec::new();
260            for source in &sources_to_process {
261                if !options.plan_json {
262                    println!(
263                        "{} Fetching PURL package '{}'...",
264                        "::".bold().blue(),
265                        source
266                    );
267                }
268                let ident = zoi_purl::fetch_and_store_purl_package(source)?;
269                resolved_purls.push(ident);
270            }
271            sources_to_process = resolved_purls;
272        }
273
274        let config = zoi_core::config::read_config().unwrap_or_default();
275        let jobs = config.jobs.unwrap_or(3);
276        rayon::ThreadPoolBuilder::new()
277            .num_threads(jobs)
278            .build_global()
279            .ok();
280
281        let failed_packages = Mutex::new(Vec::new());
282        let mut temp_files = Vec::new();
283        let mut final_sources = Vec::new();
284
285        for source in &sources_to_process {
286            if std::path::Path::new(source)
287                .extension()
288                .is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
289            {
290                lockfile::process_lockfile(
291                    source,
292                    &mut final_sources,
293                    &mut temp_files,
294                    scope_override.unwrap_or(types::Scope::User)
295                )?;
296            } else {
297                final_sources.push(source.clone());
298            }
299        }
300
301        let successfully_installed_sources = Mutex::new(Vec::new());
302        let installed_manifests = Mutex::new(Vec::new());
303
304        // --- Phase 2: Dependency Resolution ---
305        let (mut graph, mut non_zoi_deps) =
306            if let Some(locked_packages) = frozen_packages.as_ref() {
307                resolver::build_graph_from_locked_packages(
308                    locked_packages,
309                    scope_override,
310                    options.plan_json,
311                    options.yes
312                )?
313            } else {
314                resolver::resolve_dependency_graph(
315                    &final_sources,
316                    scope_override,
317                    options.force,
318                    options.yes,
319                    options.all_optional,
320                    options.build_type,
321                    options.plan_json,
322                    options.project_config.clone()
323                )?
324            };
325
326        let config = zoi_core::config::read_config().unwrap_or_default();
327        let mut skipped_existing_count = 0usize;
328        if !options.force {
329            let mut to_remove = Vec::new();
330            for (pkg_id, node) in &graph.nodes {
331                // The request is built directly from the node instead of
332                // round-tripping through a source string because parsing
333                // lowercases identifiers and drops sub-package information,
334                // which made already-installed detection unreliable.
335                let request_base = zoi_resolver::resolve::PackageRequest {
336                    handle: None,
337                    repo: (!node.pkg.repo.is_empty())
338                        .then(|| node.pkg.repo.to_lowercase()),
339                    name: node.pkg.name.to_lowercase(),
340                    sub_package: node.sub_package.clone(),
341                    version_spec: None
342                };
343
344                let target_scope = scope_override.unwrap_or(node.pkg.scope);
345                let installed = local::find_installed_manifests_matching(
346                    &request_base,
347                    target_scope
348                )?;
349
350                if installed.is_empty() {
351                    // Not installed in the requested scope. If it exists in
352                    // another scope, inform the user but continue installing.
353                    let other_scopes = [
354                        types::Scope::Project,
355                        types::Scope::User,
356                        types::Scope::System
357                    ]
358                    .into_iter()
359                    .filter(|s| *s != target_scope);
360                    for other_scope in other_scopes {
361                        if !local::find_installed_manifests_matching(
362                            &request_base,
363                            other_scope
364                        )?
365                        .is_empty()
366                        {
367                            let display_name = ux::format_display_name(
368                                &node.registry_handle,
369                                &node.pkg.repo,
370                                &node.pkg.name,
371                                node.sub_package.as_deref(),
372                                &config
373                            );
374                            if !options.plan_json {
375                                println!(
376                                    "{} Package '{}' is already installed in \
377                                     {:?} scope. Installing into {:?} scope \
378                                     anyway.",
379                                    "::".bold().blue(),
380                                    display_name.cyan(),
381                                    other_scope,
382                                    target_scope
383                                );
384                            }
385                            break;
386                        }
387                    }
388                    continue;
389                }
390
391                let already_at_target = installed.iter().any(|m| {
392                    m.version == node.version && m.revision == node.revision
393                });
394
395                let display_name = ux::format_display_name(
396                    &node.registry_handle,
397                    &node.pkg.repo,
398                    &node.pkg.name,
399                    node.sub_package.as_deref(),
400                    &config
401                );
402                if !options.plan_json {
403                    // With --deps-only the direct package is intentionally
404                    // skipped while its dependencies are still installed, so
405                    // the update hint would only mislead.
406                    let is_direct =
407                        matches!(node.reason, types::InstallReason::Direct);
408                    if already_at_target {
409                        let full_spec =
410                            format!("{}@{}", display_name, node.version);
411                        if options.deps_only && is_direct {
412                            println!(
413                                "{} Package '{}' is already installed. \
414                                 Installing its dependencies only \
415                                 (--deps-only).",
416                                "::".bold().green(),
417                                full_spec.cyan()
418                            );
419                        } else {
420                            println!(
421                                "{} Package '{}' is already installed. \
422                                 Skipping.",
423                                "::".bold().green(),
424                                full_spec.cyan()
425                            );
426                        }
427                    } else {
428                        let current_version = installed
429                            .first()
430                            .map(|m| m.version.as_str())
431                            .unwrap_or_default();
432
433                        let current_spec =
434                            format!("{display_name}@{current_version}");
435                        let available_spec =
436                            format!("{}@{}", display_name, node.version);
437
438                        println!(
439                            "{} Package '{}' is already installed (available: \
440                             {}).",
441                            "::".bold().yellow(),
442                            current_spec.cyan(),
443                            available_spec.cyan()
444                        );
445                    }
446                    if !(options.deps_only && is_direct) {
447                        println!(
448                            "   {} To update it, run: {}",
449                            "Hint:".bold().blue(),
450                            format!("zoi update {}", node.pkg.name).italic()
451                        );
452                    }
453                }
454                to_remove.push(pkg_id.clone());
455            }
456            skipped_existing_count = to_remove.len();
457
458            // Snapshot the dependency lists of skipped nodes before removal:
459            // with --deps-only the caller explicitly wants dependencies
460            // installed even when the direct package is already present.
461            let skipped_dep_strings: Vec<String> = if options.deps_only {
462                to_remove
463                    .iter()
464                    .filter_map(|id| graph.nodes.get(id))
465                    .flat_map(|node| node.dependencies.clone())
466                    .collect()
467            } else {
468                Vec::new()
469            };
470
471            for pkg_id in to_remove {
472                graph.nodes.remove(&pkg_id);
473                if let Some(children) = graph.adj.remove(&pkg_id)
474                    && let Some(root_children) = graph.adj.get_mut("$root")
475                {
476                    for child in children {
477                        root_children.insert(child);
478                    }
479                }
480                if let Some(root_children) = graph.adj.get_mut("$root") {
481                    root_children.remove(&pkg_id);
482                }
483            }
484
485            non_zoi_deps = filter_relevant_non_zoi_deps(
486                non_zoi_deps,
487                &sources_to_process,
488                &graph,
489                &skipped_dep_strings,
490                options.deps_only
491            );
492        }
493
494        if graph.nodes.is_empty() && non_zoi_deps.is_empty() {
495            println!("\nAll requested packages are already installed.");
496            return Ok(());
497        }
498
499        if !options.dry_run {
500            if let Some(pm) = options.plugin_manager {
501                pm.set_context(scope_override.unwrap_or_default())?;
502            }
503            for node in graph.nodes.values() {
504                if let Some(pm) = options.plugin_manager {
505                    let pkg_val = pm
506                        .lua
507                        .to_value(&node.pkg)
508                        .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
509                    pm.trigger_hook("on_pre_install", Some(&pkg_val))?;
510                }
511            }
512        }
513
514        let mut direct_packages = Vec::new();
515        let mut dependencies = Vec::new();
516
517        for node in graph.nodes.values() {
518            if matches!(node.reason, types::InstallReason::Direct) {
519                direct_packages.push(node);
520            } else {
521                dependencies.push(node);
522            }
523        }
524
525        direct_packages.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
526        dependencies.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
527
528        for node in graph.nodes.values() {
529            cli_utils::print_repo_warning(&node.pkg.repo);
530        }
531
532        // --- Phase 3: Safety & Compliance Checks ---
533        if !options.plan_json {
534            println!("{} Looking for conflicts...", "::".bold().blue());
535        }
536        let packages_to_install: Vec<&types::Package> =
537            graph.nodes.values().map(|n| &n.pkg).collect();
538
539        if !options.dry_run {
540            preflight::check_for_conflicts(&packages_to_install, options.yes)?;
541            for pkg in &packages_to_install {
542                if !util::display_updates(pkg, options.yes)? {
543                    return Err(anyhow!("Installation aborted by user."));
544                }
545            }
546            preflight::check_policy_compliance(&graph)?;
547            preflight::check_scope_compliance(&graph)?;
548            preflight::check_zoios_compliance(&graph)?;
549            preflight::check_for_vulnerabilities(&graph, options.yes)?;
550
551            let m_for_conflict_check = MultiProgress::new();
552            if options.plan_json {
553                m_for_conflict_check
554                    .set_draw_target(indicatif::ProgressDrawTarget::hidden());
555            }
556            preflight::check_file_conflicts(
557                &graph,
558                options.yes,
559                &m_for_conflict_check
560            )?;
561            let _ = m_for_conflict_check.clear();
562        }
563
564        if !options.plan_json {
565            println!("{} Checking available disk space...", "::".bold().blue());
566        }
567        let install_plan = plan::create_install_plan(
568            &graph.nodes,
569            options.build_type,
570            options.build
571        )?;
572
573        let mut total_download_size: u64 = 0;
574        let mut total_installed_size: u64 = 0;
575        let mut unique_downloads = HashSet::new();
576
577        for (id, node) in &graph.nodes {
578            match install_plan.get(id) {
579                Some(plan::InstallAction::DownloadAndInstall(details)) => {
580                    if unique_downloads.insert(details.info.final_url.clone()) {
581                        total_download_size += details.download_size;
582                    }
583                    total_installed_size += if details.installed_size > 0 {
584                        details.installed_size
585                    } else {
586                        node.pkg.installed_size.unwrap_or(0)
587                    };
588                }
589                Some(plan::InstallAction::BuildAndInstall) => {
590                    total_installed_size +=
591                        node.pkg.installed_size.unwrap_or(0);
592                }
593                _ => {}
594            }
595        }
596
597        if options.plan_json {
598            let mut packages = Vec::new();
599            for (id, node) in &graph.nodes {
600                let action_name = match install_plan.get(id) {
601                    Some(plan::InstallAction::DownloadAndInstall(_)) => {
602                        "download"
603                    }
604                    Some(plan::InstallAction::InstallFromArchive(_)) => {
605                        "archive"
606                    }
607                    Some(plan::InstallAction::BuildAndInstall) => "build",
608                    None => "unknown"
609                };
610                let reason = match &node.reason {
611                    types::InstallReason::Direct => "direct".to_string(),
612                    types::InstallReason::Dependency { parent } => {
613                        format!("dependency:{parent}")
614                    }
615                };
616                packages.push(json!({
617                    "id": id,
618                    "name": node.pkg.name,
619                    "version": node.version,
620                    "revision": node.revision,
621                    "sub_package": node.sub_package,
622                    "repo": node.pkg.repo,
623                    "registry": node.registry_handle,
624                    "reason": reason,
625                    "action": action_name,
626                    "source": node.source,
627                }));
628            }
629
630            let plan_data = json!({
631                "dry_run": options.dry_run,
632                "frozen": options.frozen,
633                "retry_attempts": options.retry,
634                "scope": format!("{:?}", scope_override.unwrap_or(types::Scope::User)),
635                "totals": {
636                    "direct_packages": direct_packages.len(),
637                    "dependencies": dependencies.len() + non_zoi_deps.len(),
638                    "download_bytes": total_download_size,
639                    "installed_bytes": total_installed_size,
640                    "skipped_existing": skipped_existing_count,
641                },
642                "packages": packages,
643                "non_zoi_dependencies": non_zoi_deps,
644            });
645            println!("{}", serde_json::to_string_pretty(&plan_data)?);
646            return Ok(());
647        }
648
649        if options.dry_run {
650            println!(
651                "\n{} Dry-run: installation plan above would be executed.",
652                "::".bold().yellow()
653            );
654            return Ok(());
655        }
656
657        // --- Phase 4: Transactional Execution ---
658        let install_path =
659            local::get_store_base_dir(scope_override.unwrap_or_default())?;
660        std::fs::create_dir_all(&install_path)?;
661
662        let available_space =
663            fs2::available_space(&install_path).unwrap_or(u64::MAX);
664
665        if total_installed_size > available_space {
666            return Err(anyhow!(
667                "Not enough disk space. Required: {}, Available: {}",
668                zoi_core::utils::format_bytes(total_installed_size),
669                zoi_core::utils::format_bytes(available_space)
670            ));
671        }
672
673        let config = zoi_core::config::read_config().unwrap_or_default();
674
675        println!(
676            "\n{} Packages ({})",
677            "::".bold().blue(),
678            direct_packages.len()
679        );
680        let direct_list: Vec<_> = direct_packages
681            .iter()
682            .map(|n| {
683                let display_name = ux::format_display_name(
684                    &n.registry_handle,
685                    &n.pkg.repo,
686                    &n.pkg.name,
687                    n.sub_package.as_deref(),
688                    &config
689                );
690                let version_display = if n.revision == "1" {
691                    n.version.clone()
692                } else {
693                    format!("{}-{}", n.version, n.revision)
694                };
695                format!("{display_name}@{version_display}")
696                    .cyan()
697                    .to_string()
698            })
699            .collect();
700        println!(" {}", direct_list.join("  "));
701
702        if options.verbose {
703            println!("\n{} Package origins", "::".bold().blue());
704            let mut direct_entries: Vec<_> = graph
705                .nodes
706                .iter()
707                .filter(|(_, node)| {
708                    matches!(node.reason, types::InstallReason::Direct)
709                })
710                .collect();
711            direct_entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
712            for (id, node) in direct_entries {
713                let action_name = match install_plan.get(id) {
714                    Some(plan::InstallAction::DownloadAndInstall(_)) => {
715                        "download"
716                    }
717                    Some(plan::InstallAction::InstallFromArchive(_)) => {
718                        "archive"
719                    }
720                    Some(plan::InstallAction::BuildAndInstall) => "build",
721                    None => "unknown"
722                };
723                let origin = crate::cmd::ux::classify_source_origin(
724                    &node.source,
725                    action_name
726                );
727                let display_name = ux::format_display_name(
728                    &node.registry_handle,
729                    &node.pkg.repo,
730                    &node.pkg.name,
731                    node.sub_package.as_deref(),
732                    &config
733                );
734                let version_display = if node.revision == "1" {
735                    node.version.clone()
736                } else {
737                    format!("{}-{}", node.version, node.revision)
738                };
739                println!(
740                    "  - {}@{} -> {} ({})",
741                    display_name.cyan(),
742                    version_display,
743                    origin.as_str(),
744                    action_name
745                );
746            }
747        }
748
749        if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
750            println!(
751                "\n{} Dependencies ({})",
752                "::".bold().blue(),
753                dependencies.len() + non_zoi_deps.len()
754            );
755            let mut dep_list = Vec::new();
756            for n in &dependencies {
757                let display_name = ux::format_display_name(
758                    &n.registry_handle,
759                    &n.pkg.repo,
760                    &n.pkg.name,
761                    n.sub_package.as_deref(),
762                    &config
763                );
764                let version_display = if n.revision == "1" {
765                    n.version.clone()
766                } else {
767                    format!("{}-{}", n.version, n.revision)
768                };
769                dep_list.push(
770                    format!("zoi:{display_name}@{version_display}")
771                        .dimmed()
772                        .to_string()
773                );
774            }
775            for d in &non_zoi_deps {
776                dep_list.push(d.dimmed().to_string());
777            }
778            println!(" {}", dep_list.join("  "));
779        }
780
781        if total_download_size > 0 {
782            println!(
783                "\nTotal Download Size:  {}",
784                zoi_core::utils::format_bytes(total_download_size)
785            );
786        }
787        if total_installed_size > 0 {
788            println!(
789                "Total Installed Size: {}",
790                zoi_core::utils::format_bytes(total_installed_size)
791            );
792        }
793
794        if options.verbose {
795            let preflight =
796                crate::cmd::ux::PreflightSummary::new("Install preflight")
797                    .row(
798                        "Scope",
799                        format!(
800                            "{:?}",
801                            scope_override.unwrap_or(types::Scope::User)
802                        )
803                    )
804                    .row("Frozen lockfile", options.frozen.to_string())
805                    .row("Deps only", options.deps_only.to_string())
806                    .row("Retry attempts", options.retry.to_string())
807                    .row("Direct packages", direct_packages.len().to_string())
808                    .row(
809                        "Dependencies",
810                        (dependencies.len() + non_zoi_deps.len()).to_string()
811                    )
812                    .row(
813                        "Download size",
814                        zoi_core::utils::format_bytes(total_download_size)
815                    )
816                    .row(
817                        "Installed size",
818                        zoi_core::utils::format_bytes(total_installed_size)
819                    );
820            crate::cmd::ux::print_preflight(&preflight);
821        }
822
823        if options.explain {
824            let mut report = ux::ExplainReport::new("Install explanation");
825            let mut sorted_nodes: Vec<_> = graph.nodes.iter().collect();
826            sorted_nodes.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
827            for (id, node) in sorted_nodes {
828                let action = match install_plan.get(id) {
829                    Some(plan::InstallAction::DownloadAndInstall(_)) => {
830                        "download"
831                    }
832                    Some(plan::InstallAction::InstallFromArchive(_)) => {
833                        "archive"
834                    }
835                    Some(plan::InstallAction::BuildAndInstall) => {
836                        "build from source"
837                    }
838                    None => "unknown"
839                };
840                let display_name = ux::format_display_name(
841                    &node.registry_handle,
842                    &node.pkg.repo,
843                    &node.pkg.name,
844                    node.sub_package.as_deref(),
845                    &config
846                );
847                match &node.reason {
848                    types::InstallReason::Direct => {
849                        report = report.item(
850                            format!("{display_name}@{}", node.version),
851                            "explicitly requested",
852                            vec![
853                                format!("action: {action}"),
854                                format!("source: {}", node.source),
855                            ]
856                        );
857                    }
858                    types::InstallReason::Dependency { parent } => {
859                        report = report.item(
860                            format!("{display_name}@{}", node.version),
861                            format!("required by {parent}"),
862                            Vec::new()
863                        );
864                    }
865                }
866            }
867            if skipped_existing_count > 0 {
868                report = report.item(
869                    format!("{skipped_existing_count} already installed"),
870                    "skipped (identical version and revision already present)",
871                    Vec::new()
872                );
873            }
874            for dep in &non_zoi_deps {
875                report = report.item(
876                    dep.clone(),
877                    "handled by an external package manager",
878                    Vec::new()
879                );
880            }
881            if options.deps_only {
882                report = report.item(
883                    "--deps-only",
884                    "direct packages will not be installed",
885                    Vec::new()
886                );
887            }
888            ux::print_explain(&report);
889        }
890
891        let yes = options.yes;
892        if !zoi_core::utils::ask_for_confirmation(
893            "\nProceed with installation?",
894            yes
895        ) {
896            return Ok(());
897        }
898
899        // Pre-install external (non-zoi) dependencies through their own
900        // package managers, mirroring `update.rs`. Without this they would be
901        // listed in the plan but never installed, which is exactly what made
902        // `--deps-only` a silent no-op.
903        if !non_zoi_deps.is_empty() {
904            println!(
905                "\n{} Installing external dependencies...",
906                "::".bold().blue()
907            );
908            let m_ext = MultiProgress::new();
909            let processed_deps = Mutex::new(HashSet::new());
910            let mut installed_deps_ext = Vec::new();
911            for dep_str in &non_zoi_deps {
912                let dep =
913                    crate::pkg::dependencies::parse_dependency_string(dep_str)?;
914                crate::pkg::install::dep_install::install_dependency(
915                    &dep,
916                    "install",
917                    scope_override.unwrap_or_default(),
918                    yes,
919                    options.all_optional,
920                    &processed_deps,
921                    &mut installed_deps_ext,
922                    Some(&m_ext)
923                )?;
924            }
925        }
926
927        let stages = graph.toposort()?;
928        let transaction = Mutex::new(transaction::begin()?);
929        let transaction_id = transaction
930            .lock()
931            .expect("Transaction mutex poisoned")
932            .id
933            .clone();
934        let dependency_installed_count = AtomicUsize::new(0);
935
936        println!("\n{} Preparing packages...", "::".bold().blue());
937        let m_prep = MultiProgress::new();
938        let prepared_nodes = Mutex::new(HashMap::new());
939
940        let build_type = options.build_type;
941        let verbose = options.verbose;
942        // `--force` re-installs and also re-downloads archives instead of
943        // trusting the cache, so a re-published upstream archive replaces
944        // stale cached bytes.
945        let force_redownload = options.force;
946
947        stages
948            .par_iter()
949            .flatten()
950            .try_for_each(|pkg_id| -> Result<()> {
951                let node = graph.nodes.get(pkg_id).ok_or_else(|| {
952                    anyhow!(
953                        "Package node '{pkg_id}' missing from graph during \
954                         preparation"
955                    )
956                })?;
957                let action = install_plan.get(pkg_id).ok_or_else(|| {
958                    anyhow!(
959                        "Install action missing for package '{pkg_id}' during \
960                         preparation"
961                    )
962                })?;
963
964                let prepared = installer::prepare_node(
965                    node,
966                    action,
967                    Some(&m_prep),
968                    build_type,
969                    verbose,
970                    force_redownload
971                )?;
972
973                let mut lock = prepared_nodes.lock().map_err(|e| {
974                    anyhow!(
975                        "Prepared nodes mutex poisoned during preparation: {e}"
976                    )
977                })?;
978                lock.insert(pkg_id.clone(), prepared);
979                Ok(())
980            })?;
981
982        if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
983            println!("\n{} Installing dependencies...", "::".bold().blue());
984            let m_deps = MultiProgress::new();
985
986            for stage in &stages {
987                stage.par_iter().try_for_each(|pkg_id| -> Result<()> {
988                    let node = graph.nodes.get(pkg_id).ok_or_else(|| {
989                        anyhow!(
990                            "Package node '{pkg_id}' missing from graph \
991                             during installation"
992                        )
993                    })?;
994                    if matches!(node.reason, types::InstallReason::Direct) {
995                        return Ok(());
996                    }
997
998                    let prepared = {
999                        let lock = prepared_nodes.lock().map_err(|e| {
1000                            anyhow!(
1001                                "Prepared nodes mutex poisoned during \
1002                                 dependency install: {e}"
1003                            )
1004                        })?;
1005                        lock.get(pkg_id).cloned().ok_or_else(|| {
1006                            anyhow!("Prepared node missing for: {pkg_id}")
1007                        })?
1008                    };
1009
1010                    match installer::install_prepared_node(
1011                        node,
1012                        &prepared,
1013                        Some(&m_deps),
1014                        yes,
1015                        true,
1016                        true,
1017                        verbose
1018                    ) {
1019                        Ok(manifest) => {
1020                            dependency_installed_count
1021                                .fetch_add(1, Ordering::Relaxed);
1022                            let mut tx_lock =
1023                                transaction.lock().map_err(|e| {
1024                                    anyhow!(
1025                                        "Transaction mutex poisoned during \
1026                                         installation: {e}"
1027                                    )
1028                                })?;
1029                            if let Err(e) = transaction::record_operation(
1030                                &mut tx_lock,
1031                                types::TransactionOperation::Install {
1032                                    manifest: Box::new(manifest)
1033                                }
1034                            ) {
1035                                return Err(anyhow!(
1036                                    "Transaction recording failed: {e}"
1037                                ));
1038                            }
1039                        }
1040                        Err(e) => {
1041                            failed_packages
1042                                .lock()
1043                                .expect("Failed packages mutex poisoned")
1044                                .push(node.pkg.name.clone());
1045                            eprintln!(
1046                                "Error installing {}: {}",
1047                                node.pkg.name, e
1048                            );
1049                        }
1050                    }
1051                    Ok(())
1052                })?;
1053            }
1054        }
1055
1056        if options.deps_only {
1057            println!(
1058                "\n{} Skipping direct packages (--deps-only).",
1059                "::".bold().yellow()
1060            );
1061        } else {
1062            println!("\n{} Installing packages...", "::".bold().blue());
1063            let m_pkg = MultiProgress::new();
1064
1065            for stage in &stages {
1066                let mut stage_direct_ids = Vec::new();
1067                for pkg_id in stage {
1068                    if let Some(node) = graph.nodes.get(pkg_id)
1069                        && matches!(node.reason, types::InstallReason::Direct)
1070                    {
1071                        let name = if let Some(sub) = &node.sub_package {
1072                            format!("{}:{}", node.pkg.name, sub)
1073                        } else {
1074                            node.pkg.name.clone()
1075                        };
1076                        let version_display = if node.revision == "1" {
1077                            node.version.clone()
1078                        } else {
1079                            format!("{}-{}", node.version, node.revision)
1080                        };
1081                        println!("@{name}:{version_display}");
1082                        stage_direct_ids.push(pkg_id.clone());
1083                    }
1084                }
1085
1086                if stage_direct_ids.is_empty() {
1087                    continue;
1088                }
1089
1090                let res = stage_direct_ids.par_iter().try_for_each(
1091                    |pkg_id| -> Result<()> {
1092                        let node =
1093                            graph.nodes.get(pkg_id).ok_or_else(|| {
1094                                anyhow!(
1095                                    "Package node '{pkg_id}' missing from \
1096                                     graph during final installation"
1097                                )
1098                            })?;
1099
1100                        let prepared = {
1101                            let lock = prepared_nodes.lock().map_err(|e| {
1102                                anyhow!(
1103                                    "Prepared nodes mutex poisoned during \
1104                                     package install: {e}"
1105                                )
1106                            })?;
1107                            lock.get(pkg_id).cloned().ok_or_else(|| {
1108                                anyhow!("Prepared node missing for: {pkg_id}")
1109                            })?
1110                        };
1111
1112                        match installer::install_prepared_node(
1113                            node,
1114                            &prepared,
1115                            Some(&m_pkg),
1116                            yes,
1117                            true,
1118                            true,
1119                            verbose
1120                        ) {
1121                            Ok(manifest) => {
1122                                installed_manifests
1123                                    .lock()
1124                                    .expect(
1125                                        "Installed manifests mutex poisoned"
1126                                    )
1127                                    .push(manifest.clone());
1128                                let mut tx_lock =
1129                                    transaction.lock().map_err(|e| {
1130                                        anyhow!(
1131                                            "Transaction mutex poisoned \
1132                                             during direct package \
1133                                             installation: {e}"
1134                                        )
1135                                    })?;
1136                                transaction::record_operation(
1137                                    &mut tx_lock,
1138                                    types::TransactionOperation::Install {
1139                                        manifest: Box::new(manifest)
1140                                    }
1141                                )?;
1142                                successfully_installed_sources
1143                                    .lock()
1144                                    .expect(
1145                                        "Successfully installed sources mutex \
1146                                         poisoned"
1147                                    )
1148                                    .push(node.source.clone());
1149                                Ok(())
1150                            }
1151                            Err(e) => {
1152                                failed_packages
1153                                    .lock()
1154                                    .expect("Failed packages mutex poisoned")
1155                                    .push(node.pkg.name.clone());
1156                                eprintln!(
1157                                    "Error installing {}: {}",
1158                                    node.pkg.name, e
1159                                );
1160                                Err(e)
1161                            }
1162                        }
1163                    }
1164                );
1165
1166                if res.is_err() {
1167                    break;
1168                }
1169            }
1170        }
1171
1172        let failed = failed_packages
1173            .lock()
1174            .expect("Failed packages mutex poisoned");
1175        if !failed.is_empty() {
1176            println!("\n{} Rolling back changes...", "::".bold().yellow());
1177            transaction::rollback(&transaction_id)?;
1178            return Err(anyhow!(
1179                "Installation failed for: {}",
1180                failed.join(", ")
1181            ));
1182        }
1183
1184        // Run builtin/global transaction hooks (e.g. font-cache, ldconfig)
1185        // exactly like update/uninstall do. Without this, `zoi install`
1186        // never triggers PostTransaction hooks.
1187        if let Ok(modified_files) =
1188            transaction::get_modified_files(&transaction_id)
1189        {
1190            let modified_packages =
1191                transaction::get_modified_packages(&transaction_id)
1192                    .unwrap_or_default();
1193            let _ = crate::pkg::hooks::global::run_global_hooks(
1194                crate::pkg::hooks::global::HookWhen::PostTransaction,
1195                &modified_files,
1196                &modified_packages,
1197                "install",
1198                scope_override.unwrap_or_default()
1199            );
1200        }
1201
1202        if let Err(e) = transaction::commit(&transaction_id) {
1203            eprintln!("Warning: Failed to commit transaction: {e}");
1204        }
1205
1206        let installed_manifests_vec = installed_manifests
1207            .lock()
1208            .expect("Installed manifests mutex poisoned")
1209            .clone();
1210        for manifest in &installed_manifests_vec {
1211            if let Some(pm) = options.plugin_manager {
1212                let pkg_val = pm
1213                    .lua
1214                    .to_value(manifest)
1215                    .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
1216                pm.trigger_hook_nonfatal("on_post_install", Some(&pkg_val));
1217            }
1218        }
1219
1220        println!("\n{} Installation complete!", "Success:".green().bold());
1221        Ok(())
1222    }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228
1229    fn test_node(deps: Vec<String>) -> resolver::InstallNode {
1230        resolver::InstallNode {
1231            pkg: types::Package::default(),
1232            version: "1.0.0".to_string(),
1233            revision: "1".to_string(),
1234            sub_package: None,
1235            repo_type: "official".to_string(),
1236            description: String::new(),
1237            reason: types::InstallReason::Direct,
1238            source: "test".to_string(),
1239            registry_handle: "local".to_string(),
1240            chosen_options: Vec::new(),
1241            chosen_optionals: Vec::new(),
1242            dependencies: deps,
1243            git_sha: None
1244        }
1245    }
1246
1247    fn test_graph(
1248        nodes: Vec<(&str, Vec<String>)>
1249    ) -> resolver::DependencyGraph {
1250        let mut graph = resolver::DependencyGraph::new();
1251        for (id, deps) in nodes {
1252            graph.nodes.insert(id.to_string(), test_node(deps));
1253        }
1254        graph
1255    }
1256
1257    #[test]
1258    fn deps_only_keeps_external_deps_of_skipped_nodes() {
1259        let graph = test_graph(vec![]);
1260        let kept = filter_relevant_non_zoi_deps(
1261            vec!["native:git".to_string()],
1262            &["@zillowe/gct".to_string()],
1263            &graph,
1264            &["native:git".to_string()],
1265            true
1266        );
1267        assert_eq!(kept, vec!["native:git".to_string()]);
1268    }
1269
1270    #[test]
1271    fn default_mode_drops_external_deps_of_skipped_nodes() {
1272        let graph = test_graph(vec![]);
1273        let kept = filter_relevant_non_zoi_deps(
1274            vec!["native:git".to_string()],
1275            &["@zillowe/gct".to_string()],
1276            &graph,
1277            &["native:git".to_string()],
1278            false
1279        );
1280        assert!(kept.is_empty());
1281    }
1282
1283    #[test]
1284    fn surviving_nodes_keep_their_external_deps_in_both_modes() {
1285        for deps_only in [false, true] {
1286            let graph = test_graph(vec![(
1287                "direct",
1288                vec!["pacman:go".to_string(), "zoi:helper".to_string()]
1289            )]);
1290            let kept = filter_relevant_non_zoi_deps(
1291                vec!["pacman:go".to_string(), "native:git".to_string()],
1292                &["pkg".to_string()],
1293                &graph,
1294                &["native:git".to_string()],
1295                deps_only
1296            );
1297            // `pacman:go` comes from a surviving node in both modes;
1298            // `native:git` only belongs to a skipped node.
1299            if deps_only {
1300                assert_eq!(
1301                    kept,
1302                    vec!["pacman:go".to_string(), "native:git".to_string()]
1303                );
1304            } else {
1305                assert_eq!(kept, vec!["pacman:go".to_string()]);
1306            }
1307        }
1308    }
1309
1310    #[test]
1311    fn zoi_managed_strings_are_never_external_deps() {
1312        let graph = test_graph(vec![]);
1313        let kept = filter_relevant_non_zoi_deps(
1314            vec!["zoi:helper".to_string()],
1315            &["pkg".to_string()],
1316            &graph,
1317            &["zoi:helper".to_string()],
1318            true
1319        );
1320        assert!(kept.is_empty());
1321    }
1322
1323    #[test]
1324    fn direct_external_sources_are_always_kept() {
1325        let graph = test_graph(vec![]);
1326        for deps_only in [false, true] {
1327            let kept = filter_relevant_non_zoi_deps(
1328                vec!["pacman:go".to_string()],
1329                &["pacman:go".to_string()],
1330                &graph,
1331                &[],
1332                deps_only
1333            );
1334            assert_eq!(kept, vec!["pacman:go".to_string()]);
1335        }
1336    }
1337}