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