Skip to main content

zoi_cli/cmd/
install.rs

1use crate::cmd::ux;
2use crate::pkg::{config, install, lock, resolve, transaction, types};
3use anyhow::{Result, anyhow};
4use colored::Colorize;
5use indicatif::MultiProgress;
6use mlua::LuaSerdeExt;
7use rayon::prelude::*;
8use serde_json::json;
9use std::collections::{HashMap, HashSet};
10use std::sync::Mutex;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use zoi_project as project;
13
14/// The primary high-level orchestration for the `zoi install` command.
15///
16/// This function coordinates:
17/// - Context Resolution: Decides between global, user, or project-local scopes.
18/// - Dependency Resolution: Triggers the SAT solver to build the dependency graph.
19/// - Safety Checks: Validates policy compliance, security advisories, and file conflicts.
20/// - Transactional Execution: Executes the install plan within an atomic transaction.
21/// - Lockfile Updates: Synchronizes `zoi.lock` for project-local installations.
22pub fn run(
23    sources: &[String],
24    repo: Option<String>,
25    force: bool,
26    all_optional: bool,
27    yes: bool,
28    scope: Option<crate::cli::InstallScope>,
29    local: bool,
30    global: bool,
31    save: bool,
32    build_type: Option<String>,
33    dry_run: bool,
34    plugin_manager: Option<&crate::pkg::plugin::PluginManager>,
35    build: bool,
36    frozen: bool,
37    explain: bool,
38    plan_json: bool,
39    retry: u32,
40    verbose: bool,
41    purl: bool,
42) -> Result<()> {
43    crate::pkg::install::util::set_download_retry_attempts(retry);
44
45    // --- Phase 1: Context & Scope Resolution ---
46    // We decide whether this is a global, user, or project-local installation
47    // and whether we are operating in 'frozen' mode from a lockfile.
48    let mut scope_override = scope.map(|s| match s {
49        crate::cli::InstallScope::User => types::Scope::User,
50        crate::cli::InstallScope::System => types::Scope::System,
51        crate::cli::InstallScope::Project => types::Scope::Project,
52    });
53
54    if local {
55        scope_override = Some(types::Scope::Project);
56    } else if global {
57        scope_override = Some(types::Scope::User);
58    }
59
60    if scope_override.is_none() {
61        scope_override = Some(crate::pkg::utils::resolve_fallback_scope());
62    }
63
64    if frozen {
65        if repo.is_some() || !sources.is_empty() {
66            return Err(anyhow!(
67                "--frozen can only be used without explicit sources or --repo."
68            ));
69        }
70        if save {
71            return Err(anyhow!(
72                "--save cannot be used with --frozen because the lockfile must remain unchanged."
73            ));
74        }
75        if !std::path::Path::new("zoi.lua").exists() {
76            return Err(anyhow!(
77                "--frozen requires a local zoi.lua in the current project."
78            ));
79        }
80        if !std::path::Path::new("zoi.lock").exists() {
81            return Err(anyhow!(
82                "--frozen requires zoi.lock. Generate it first with a normal project install."
83            ));
84        }
85        if let Some(scope) = scope_override
86            && scope != types::Scope::Project
87        {
88            return Err(anyhow!(
89                "--frozen is only supported for project scope installs."
90            ));
91        }
92        scope_override = Some(types::Scope::Project);
93        crate::pkg::frozen::set_frozen(true);
94    }
95
96    let lockfile_exists = sources.is_empty()
97        && repo.is_none()
98        && std::path::Path::new("zoi.lock").exists()
99        && (std::path::Path::new("zoi.lua").exists() || std::path::Path::new("zoi.yaml").exists());
100
101    let mut sources_to_process: Vec<String> = sources.to_vec();
102    let mut is_project_install = false;
103    let mut frozen_packages = None;
104    if frozen {
105        let lockfile = project::lockfile::read_zoi_lock()?;
106        let locked_packages = project::lockfile::locked_packages(&lockfile);
107        sources_to_process = locked_packages
108            .iter()
109            .map(|entry| entry.source.clone())
110            .collect();
111        if sources_to_process.is_empty() {
112            return Err(anyhow!("zoi.lock is empty. Cannot continue with --frozen."));
113        }
114        frozen_packages = Some(locked_packages);
115        println!(
116            "{} --frozen enabled. Installing pinned lockfile sources only...",
117            "::".bold().blue()
118        );
119        is_project_install = true;
120    } else if sources.is_empty() && repo.is_none() {
121        if std::path::Path::new("zoi.lua").exists() || std::path::Path::new("zoi.yaml").exists() {
122            if let Ok(config) = project::config::load() {
123                let config_file = if std::path::Path::new("zoi.lua").exists() {
124                    "zoi.lua"
125                } else {
126                    "zoi.yaml"
127                };
128                if lockfile_exists {
129                    println!(
130                        "{} zoi.lock found. Installing from {} then verifying...",
131                        "::".bold().blue(),
132                        config_file
133                    );
134                } else {
135                    println!(
136                        "{} Installing project packages from {}...",
137                        "::".bold().blue(),
138                        config_file
139                    );
140                }
141                sources_to_process = config.pkgs.clone();
142                if scope_override.is_none() {
143                    scope_override = Some(types::Scope::Project);
144                }
145                is_project_install = true;
146            }
147        } else if let Some(pm) = plugin_manager
148            && pm.trigger_project_install_hook()?
149        {
150            return Ok(());
151        }
152    }
153
154    if let Some(repo_spec) = repo {
155        if scope_override == Some(types::Scope::Project) {
156            return Err(anyhow!(
157                "Installing from a repository to a project scope is not supported."
158            ));
159        }
160        let repo_install_scope = scope_override.map(|s| match s {
161            types::Scope::User => crate::cli::SetupScope::User,
162            types::Scope::System => crate::cli::SetupScope::System,
163            types::Scope::Project => unreachable!(),
164        });
165
166        if dry_run {
167            println!(
168                "{} Dry-run: would install from repository '{}'",
169                "::".bold().yellow(),
170                repo_spec
171            );
172            return Ok(());
173        }
174
175        crate::pkg::repo_install::run(
176            &repo_spec,
177            force,
178            all_optional,
179            yes,
180            repo_install_scope,
181            plugin_manager,
182        )?;
183        return Ok(());
184    }
185
186    if sources_to_process.is_empty() {
187        return Ok(());
188    }
189
190    if purl {
191        let mut resolved_purls = Vec::new();
192        for source in &sources_to_process {
193            println!(
194                "{} Fetching PURL package '{}'...",
195                "::".bold().blue(),
196                source
197            );
198            let ident = crate::pkg::purl::fetch_and_store_purl_package(source)?;
199            resolved_purls.push(ident);
200        }
201        sources_to_process = resolved_purls;
202    }
203
204    let config = config::read_config().unwrap_or_default();
205    let jobs = config.jobs.unwrap_or(3);
206    rayon::ThreadPoolBuilder::new()
207        .num_threads(jobs)
208        .build_global()
209        .ok();
210
211    let failed_packages = Mutex::new(Vec::new());
212    let mut temp_files = Vec::new();
213    let mut final_sources = Vec::new();
214
215    for source in &sources_to_process {
216        if source.ends_with(".lock") {
217            install::lockfile::process_lockfile(
218                source,
219                &mut final_sources,
220                &mut temp_files,
221                scope_override.unwrap_or(types::Scope::User),
222            )?;
223        } else {
224            final_sources.push(source.to_string());
225        }
226    }
227
228    let successfully_installed_sources = Mutex::new(Vec::new());
229    let installed_manifests = Mutex::new(Vec::new());
230
231    // --- Phase 2: Dependency Resolution ---
232    // We trigger the SAT solver to build the complete dependency graph.
233    // If we're in frozen mode, we build it strictly from the lockfile.
234    let (mut graph, mut non_zoi_deps) = if let Some(locked_packages) = frozen_packages.as_ref() {
235        install::resolver::build_graph_from_locked_packages(
236            locked_packages,
237            scope_override,
238            false,
239            yes,
240        )?
241    } else {
242        install::resolver::resolve_dependency_graph(
243            &final_sources,
244            scope_override,
245            force,
246            yes,
247            all_optional,
248            build_type.as_deref(),
249            false,
250        )?
251    };
252
253    let mut skipped_existing_count = 0usize;
254    if !force {
255        let mut to_remove = Vec::new();
256        for (pkg_id, node) in &graph.nodes {
257            let request_source = crate::pkg::local::package_source_string(
258                &node.registry_handle,
259                &node.pkg.repo,
260                &node.pkg.name,
261                node.sub_package.as_deref(),
262                &node.version,
263            );
264            let request = resolve::parse_source_string(&request_source)?;
265            let matches = crate::pkg::local::find_installed_manifests_matching(
266                &request,
267                scope_override.unwrap_or(node.pkg.scope),
268            )?;
269            if matches
270                .iter()
271                .any(|manifest| manifest.version == node.version)
272            {
273                println!(
274                    "{} Package '{}' is already installed at version {}. Skipping.",
275                    "::".bold().green(),
276                    node.pkg.name.cyan(),
277                    node.version.yellow()
278                );
279                to_remove.push(pkg_id.clone());
280            }
281        }
282        skipped_existing_count = to_remove.len();
283
284        for pkg_id in to_remove {
285            graph.nodes.remove(&pkg_id);
286            if let Some(children) = graph.adj.remove(&pkg_id)
287                && let Some(root_children) = graph.adj.get_mut("$root")
288            {
289                for child in children {
290                    root_children.insert(child);
291                }
292            }
293            if let Some(root_children) = graph.adj.get_mut("$root") {
294                root_children.remove(&pkg_id);
295            }
296        }
297
298        let mut valid_non_zoi_deps = std::collections::HashSet::new();
299        for source in &sources_to_process {
300            if let Ok(dep) = crate::pkg::dependencies::parse_dependency_string(source)
301                && dep.manager != "zoi"
302            {
303                valid_non_zoi_deps.insert(source.clone());
304            }
305        }
306        for node in graph.nodes.values() {
307            for dep in &node.dependencies {
308                if let Ok(dep_req) = crate::pkg::dependencies::parse_dependency_string(dep)
309                    && dep_req.manager != "zoi"
310                {
311                    valid_non_zoi_deps.insert(dep.clone());
312                }
313            }
314        }
315        non_zoi_deps.retain(|dep| valid_non_zoi_deps.contains(dep));
316    }
317
318    if graph.nodes.is_empty() && non_zoi_deps.is_empty() {
319        println!("\nAll requested packages are already installed.");
320        return Ok(());
321    }
322
323    if !dry_run {
324        if let Some(pm) = plugin_manager {
325            pm.set_context(scope_override.unwrap_or_default())?;
326        }
327        for node in graph.nodes.values() {
328            if let Some(pm) = plugin_manager {
329                let pkg_val = pm
330                    .lua
331                    .to_value(&node.pkg)
332                    .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
333                pm.trigger_hook("on_pre_install", Some(pkg_val))?;
334            }
335        }
336    }
337
338    let mut direct_packages = Vec::new();
339    let mut dependencies = Vec::new();
340
341    for node in graph.nodes.values() {
342        if matches!(node.reason, types::InstallReason::Direct) {
343            direct_packages.push(node);
344        } else {
345            dependencies.push(node);
346        }
347    }
348
349    direct_packages.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
350    dependencies.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
351
352    for node in graph.nodes.values() {
353        crate::utils::print_repo_warning(&node.pkg.repo);
354    }
355
356    // --- Phase 3: Safety & Compliance Checks ---
357    // Before touching the disk, we validate policy, signatures, and file conflicts.
358    println!("{} Looking for conflicts...", "::".bold().blue());
359    let packages_to_install: Vec<&types::Package> = graph.nodes.values().map(|n| &n.pkg).collect();
360
361    if !dry_run {
362        install::util::check_for_conflicts(&packages_to_install, yes)?;
363        for pkg in &packages_to_install {
364            if !install::util::display_updates(pkg, yes)? {
365                return Err(anyhow!("Installation aborted by user."));
366            }
367        }
368        install::util::check_policy_compliance(&graph)?;
369        install::util::check_scope_compliance(&graph)?;
370        install::util::check_for_vulnerabilities(&graph, yes)?;
371
372        let m_for_conflict_check = MultiProgress::new();
373        install::util::check_file_conflicts(&graph, yes, &m_for_conflict_check)?;
374        let _ = m_for_conflict_check.clear();
375    }
376
377    println!("{} Checking available disk space...", "::".bold().blue());
378    let install_plan =
379        install::plan::create_install_plan(&graph.nodes, build_type.as_deref(), build)?;
380
381    let mut total_download_size: u64 = 0;
382    let mut total_installed_size: u64 = 0;
383    let mut unique_downloads = HashSet::new();
384
385    for (id, node) in &graph.nodes {
386        match install_plan.get(id) {
387            Some(install::plan::InstallAction::DownloadAndInstall(details)) => {
388                if unique_downloads.insert(details.info.final_url.clone()) {
389                    total_download_size += details.download_size;
390                }
391                total_installed_size += if details.installed_size > 0 {
392                    details.installed_size
393                } else {
394                    node.pkg.installed_size.unwrap_or(0)
395                };
396            }
397            Some(install::plan::InstallAction::BuildAndInstall) => {
398                total_installed_size += node.pkg.installed_size.unwrap_or(0);
399            }
400            _ => {}
401        }
402    }
403
404    let default_handle = config
405        .default_registry
406        .as_ref()
407        .map(|r| r.handle.as_str())
408        .unwrap_or("");
409    let active_repos = &config.repos;
410
411    let format_display_name = |registry: &str, repo: &str, name: &str, sub: Option<&str>| {
412        let base_name = if let Some(s) = sub {
413            format!("{}:{}", name, s)
414        } else {
415            name.to_string()
416        };
417
418        if registry == "local" && repo.starts_with("git/") {
419            let repo_name = &repo[4..];
420            return format!("#git@{}/{}", repo_name, base_name);
421        }
422
423        if registry == default_handle || registry == "local" || registry.is_empty() {
424            if active_repos.contains(&repo.to_string()) || repo.is_empty() {
425                base_name
426            } else {
427                format!("@{}/{}", repo, base_name)
428            }
429        } else {
430            format!("#{}@{}/{}", registry, repo, base_name)
431        }
432    };
433
434    println!(
435        "\n{} Packages ({})",
436        "::".bold().blue(),
437        direct_packages.len()
438    );
439    let direct_list: Vec<_> = direct_packages
440        .iter()
441        .map(|n| {
442            let display_name = format_display_name(
443                &n.registry_handle,
444                &n.pkg.repo,
445                &n.pkg.name,
446                n.sub_package.as_deref(),
447            );
448            let version_display = if n.revision != "1" {
449                format!("{}-{}", n.version, n.revision)
450            } else {
451                n.version.clone()
452            };
453            format!("{}@{}", display_name, version_display)
454                .cyan()
455                .to_string()
456        })
457        .collect();
458    println!(" {}", direct_list.join("  "));
459
460    if verbose {
461        println!("\n{} Package origins", "::".bold().blue());
462        let mut direct_entries: Vec<_> = graph
463            .nodes
464            .iter()
465            .filter(|(_, node)| matches!(node.reason, types::InstallReason::Direct))
466            .collect();
467        direct_entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
468        for (id, node) in direct_entries {
469            let action_name = match install_plan.get(id) {
470                Some(install::plan::InstallAction::DownloadAndInstall(_)) => "download",
471                Some(install::plan::InstallAction::InstallFromArchive(_)) => "archive",
472                Some(install::plan::InstallAction::BuildAndInstall) => "build",
473                None => "unknown",
474            };
475            let origin = ux::classify_source_origin(&node.source, action_name);
476            let display_name = format_display_name(
477                &node.registry_handle,
478                &node.pkg.repo,
479                &node.pkg.name,
480                node.sub_package.as_deref(),
481            );
482            let version_display = if node.revision != "1" {
483                format!("{}-{}", node.version, node.revision)
484            } else {
485                node.version.clone()
486            };
487            println!(
488                "  - {}@{} -> {} ({})",
489                display_name.cyan(),
490                version_display,
491                origin.as_str(),
492                action_name
493            );
494        }
495    }
496
497    if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
498        println!(
499            "\n{} Dependencies ({})",
500            "::".bold().blue(),
501            dependencies.len() + non_zoi_deps.len()
502        );
503        let mut dep_list = Vec::new();
504        for n in &dependencies {
505            let display_name = format_display_name(
506                &n.registry_handle,
507                &n.pkg.repo,
508                &n.pkg.name,
509                n.sub_package.as_deref(),
510            );
511            let version_display = if n.revision != "1" {
512                format!("{}-{}", n.version, n.revision)
513            } else {
514                n.version.clone()
515            };
516            dep_list.push(
517                format!("zoi:{}@{}", display_name, version_display)
518                    .dimmed()
519                    .to_string(),
520            );
521        }
522        for d in &non_zoi_deps {
523            dep_list.push(d.dimmed().to_string());
524        }
525        println!(" {}", dep_list.join("  "));
526    }
527
528    if total_download_size > 0 {
529        println!(
530            "\nTotal Download Size:  {}",
531            crate::pkg::utils::format_bytes(total_download_size)
532        );
533    }
534    if total_installed_size > 0 {
535        println!(
536            "Total Installed Size: {}",
537            crate::pkg::utils::format_bytes(total_installed_size)
538        );
539    }
540
541    if verbose {
542        let preflight = ux::PreflightSummary::new("Install preflight")
543            .row(
544                "Scope",
545                format!("{:?}", scope_override.unwrap_or(types::Scope::User)),
546            )
547            .row("Frozen lockfile", frozen.to_string())
548            .row("Retry attempts", retry.to_string())
549            .row("Direct packages", direct_packages.len().to_string())
550            .row(
551                "Dependencies",
552                (dependencies.len() + non_zoi_deps.len()).to_string(),
553            )
554            .row(
555                "Download size",
556                crate::pkg::utils::format_bytes(total_download_size),
557            )
558            .row(
559                "Installed size",
560                crate::pkg::utils::format_bytes(total_installed_size),
561            );
562        ux::print_preflight(&preflight);
563    }
564
565    if explain {
566        let mut report = ux::ExplainReport::new("Install explanation");
567        let mut entries: Vec<_> = graph.nodes.iter().collect();
568        entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
569        for (id, node) in entries {
570            let action_name = match install_plan.get(id) {
571                Some(install::plan::InstallAction::DownloadAndInstall(_)) => "download",
572                Some(install::plan::InstallAction::InstallFromArchive(_)) => "archive",
573                Some(install::plan::InstallAction::BuildAndInstall) => "build",
574                None => "unknown",
575            };
576            let reason = match &node.reason {
577                types::InstallReason::Direct => "direct request".to_string(),
578                types::InstallReason::Dependency { parent } => {
579                    format!("dependency of {}", parent)
580                }
581            };
582            let version_display = if node.revision != "1" {
583                format!("{} (rev {})", node.version, node.revision)
584            } else {
585                node.version.clone()
586            };
587            report = report.item(
588                format!("{}@{}", node.pkg.name, version_display),
589                format!("[{}]", reason),
590                vec![format!(
591                    "via {} ({})",
592                    action_name,
593                    ux::classify_source_origin(&node.source, action_name).as_str()
594                )],
595            );
596        }
597        ux::print_explain(&report);
598    }
599
600    if plan_json {
601        let mut packages = Vec::new();
602        let mut entries: Vec<_> = graph.nodes.iter().collect();
603        entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
604        for (id, node) in entries {
605            let action_name = match install_plan.get(id) {
606                Some(install::plan::InstallAction::DownloadAndInstall(_)) => "download",
607                Some(install::plan::InstallAction::InstallFromArchive(_)) => "archive",
608                Some(install::plan::InstallAction::BuildAndInstall) => "build",
609                None => "unknown",
610            };
611            let reason = match &node.reason {
612                types::InstallReason::Direct => "direct".to_string(),
613                types::InstallReason::Dependency { parent } => format!("dependency:{}", parent),
614            };
615            packages.push(json!({
616                "id": id,
617                "name": node.pkg.name,
618                "version": node.version,
619                "revision": node.revision,
620                "sub_package": node.sub_package,
621                "repo": node.pkg.repo,
622                "registry": node.registry_handle,
623                "reason": reason,
624                "action": action_name,
625                "origin": ux::classify_source_origin(&node.source, action_name).as_str(),
626                "source": node.source,
627            }));
628        }
629
630        let plan = json!({
631            "dry_run": dry_run,
632            "frozen": frozen,
633            "retry_attempts": 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        ux::emit_plan_json_v1("install", plan)?;
646    }
647
648    if dry_run {
649        println!(
650            "\n{} Dry-run: installation plan above would be executed.",
651            "::".bold().yellow()
652        );
653        return Ok(());
654    }
655
656    // --- Phase 4: Transactional Execution ---
657    // We execute the install plan within an atomic transaction.
658    // Preparation happens in parallel, but installation follows the topological order.
659    let install_path = crate::pkg::local::get_store_base_dir(scope_override.unwrap_or_default())?;
660    std::fs::create_dir_all(&install_path)?;
661
662    let available_space = fs2::available_space(&install_path).unwrap_or(u64::MAX);
663
664    if total_installed_size > available_space {
665        return Err(anyhow!(
666            "Not enough disk space. Required: {}, Available: {}",
667            crate::pkg::utils::format_bytes(total_installed_size),
668            crate::pkg::utils::format_bytes(available_space)
669        ));
670    }
671
672    if !crate::utils::ask_for_confirmation("\nProceed with installation?", yes) {
673        let _ = lock::release_lock();
674        return Ok(());
675    }
676
677    let stages = graph.toposort()?;
678    let transaction = Mutex::new(transaction::begin()?);
679    let transaction_id = transaction.lock().unwrap().id.clone();
680    let dependency_installed_count = AtomicUsize::new(0);
681
682    println!("\n{} Preparing packages...", "::".bold().blue());
683    let m_prep = MultiProgress::new();
684    let prepared_nodes = Mutex::new(HashMap::new());
685
686    stages
687        .par_iter()
688        .flatten()
689        .try_for_each(|pkg_id| -> Result<()> {
690            let node = graph.nodes.get(pkg_id).ok_or_else(|| {
691                anyhow!(
692                    "Package node '{}' missing from graph during preparation",
693                    pkg_id
694                )
695            })?;
696            let action = install_plan.get(pkg_id).ok_or_else(|| {
697                anyhow!(
698                    "Install action missing for package '{}' during preparation",
699                    pkg_id
700                )
701            })?;
702
703            let prepared = install::installer::prepare_node(
704                node,
705                action,
706                Some(&m_prep),
707                build_type.as_deref(),
708                verbose,
709            )?;
710
711            let mut lock = prepared_nodes
712                .lock()
713                .map_err(|e| anyhow!("Prepared nodes mutex poisoned during preparation: {}", e))?;
714            lock.insert(pkg_id.clone(), prepared);
715            Ok(())
716        })?;
717
718    if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
719        println!("\n{} Installing dependencies...", "::".bold().blue());
720        let m_deps = MultiProgress::new();
721
722        if !non_zoi_deps.is_empty() {
723            let processed_deps = Mutex::new(HashSet::new());
724            let mut installed_deps_ext = Vec::new();
725            for dep_str in &non_zoi_deps {
726                let dep = match crate::pkg::dependencies::parse_dependency_string(dep_str) {
727                    Ok(d) => d,
728                    Err(e) => {
729                        eprintln!("Error parsing dependency {}: {}", dep_str, e);
730                        continue;
731                    }
732                };
733
734                if let Err(e) = crate::pkg::install::dep_install::install_dependency(
735                    &dep,
736                    "direct",
737                    scope_override.unwrap_or_default(),
738                    yes,
739                    all_optional,
740                    &processed_deps,
741                    &mut installed_deps_ext,
742                    Some(&m_deps),
743                ) {
744                    eprintln!("Failed to install dependency {}: {}", dep_str, e);
745                }
746            }
747        }
748
749        for stage in &stages {
750            stage.par_iter().try_for_each(|pkg_id| -> Result<()> {
751                let node = graph.nodes.get(pkg_id).ok_or_else(|| {
752                    anyhow!(
753                        "Package node '{}' missing from graph during installation",
754                        pkg_id
755                    )
756                })?;
757                if matches!(node.reason, types::InstallReason::Direct) {
758                    return Ok(());
759                }
760
761                let prepared = {
762                    let lock = prepared_nodes.lock().map_err(|e| {
763                        anyhow!(
764                            "Prepared nodes mutex poisoned during dependency install: {}",
765                            e
766                        )
767                    })?;
768                    lock.get(pkg_id)
769                        .cloned()
770                        .ok_or_else(|| anyhow!("Prepared node missing for: {}", pkg_id))?
771                };
772
773                match install::installer::install_prepared_node(
774                    node,
775                    &prepared,
776                    Some(&m_deps),
777                    yes,
778                    true,
779                    true,
780                    verbose,
781                ) {
782                    Ok(manifest) => {
783                        dependency_installed_count.fetch_add(1, Ordering::Relaxed);
784                        let mut tx_lock = transaction.lock().map_err(|e| {
785                            anyhow!("Transaction mutex poisoned during installation: {}", e)
786                        })?;
787                        if let Err(e) = transaction::record_operation(
788                            &mut tx_lock,
789                            types::TransactionOperation::Install {
790                                manifest: Box::new(manifest),
791                            },
792                        ) {
793                            eprintln!("Failed to record transaction operation: {}", e);
794                            return Err(anyhow!("Transaction recording failed: {}", e));
795                        }
796                    }
797                    Err(e) => {
798                        failed_packages
799                            .lock()
800                            .map_err(|e| {
801                                anyhow!("Failed packages mutex poisoned during installation: {}", e)
802                            })?
803                            .push(node.pkg.name.clone());
804                        eprintln!("Error installing {}: {}", node.pkg.name, e);
805                    }
806                }
807                Ok(())
808            })?;
809        }
810    }
811
812    println!("\n{} Installing packages...", "::".bold().blue());
813    let m_pkg = MultiProgress::new();
814
815    let mut direct_package_ids = Vec::new();
816    for stage in &stages {
817        for pkg_id in stage {
818            if let Some(node) = graph.nodes.get(pkg_id)
819                && matches!(node.reason, types::InstallReason::Direct)
820            {
821                direct_package_ids.push(pkg_id.clone());
822            }
823        }
824    }
825
826    for stage in &stages {
827        let mut stage_direct_ids = Vec::new();
828        for pkg_id in stage {
829            if let Some(node) = graph.nodes.get(pkg_id)
830                && matches!(node.reason, types::InstallReason::Direct)
831            {
832                let name = if let Some(sub) = &node.sub_package {
833                    format!("{}:{}", node.pkg.name, sub)
834                } else {
835                    node.pkg.name.clone()
836                };
837                let version_display = if node.revision != "1" {
838                    format!("{}-{}", node.version, node.revision)
839                } else {
840                    node.version.clone()
841                };
842                println!("@{}:{}", name, version_display);
843                stage_direct_ids.push(pkg_id.clone());
844            }
845        }
846
847        if stage_direct_ids.is_empty() {
848            continue;
849        }
850
851        let res = stage_direct_ids
852            .par_iter()
853            .try_for_each(|pkg_id| -> Result<()> {
854                let node = graph.nodes.get(pkg_id).ok_or_else(|| {
855                    anyhow!(
856                        "Package node '{}' missing from graph during final installation",
857                        pkg_id
858                    )
859                })?;
860
861                let prepared = {
862                    let lock = prepared_nodes.lock().map_err(|e| {
863                        anyhow!(
864                            "Prepared nodes mutex poisoned during package install: {}",
865                            e
866                        )
867                    })?;
868                    lock.get(pkg_id)
869                        .cloned()
870                        .ok_or_else(|| anyhow!("Prepared node missing for: {}", pkg_id))?
871                };
872
873                match install::installer::install_prepared_node(
874                    node,
875                    &prepared,
876                    Some(&m_pkg),
877                    yes,
878                    true,
879                    true,
880                    verbose,
881                ) {
882                    Ok(manifest) => {
883                        installed_manifests
884                            .lock()
885                            .map_err(|e| anyhow!("Installed manifests mutex poisoned: {}", e))?
886                            .push(manifest.clone());
887                        let mut tx_lock = transaction.lock().map_err(|e| {
888                            anyhow!(
889                                "Transaction mutex poisoned during direct package installation: {}",
890                                e
891                            )
892                        })?;
893                        transaction::record_operation(
894                            &mut tx_lock,
895                            types::TransactionOperation::Install {
896                                manifest: Box::new(manifest),
897                            },
898                        )?;
899                        successfully_installed_sources
900                            .lock()
901                            .map_err(|e| {
902                                anyhow!("Successfully installed sources mutex poisoned: {}", e)
903                            })?
904                            .push(node.source.clone());
905                        Ok(())
906                    }
907                    Err(e) => {
908                        failed_packages
909                            .lock()
910                            .map_err(|e| anyhow!("Failed packages mutex poisoned: {}", e))?
911                            .push(node.pkg.name.clone());
912                        eprintln!("Error installing {}: {}", node.pkg.name, e);
913                        Err(e)
914                    }
915                }
916            });
917
918        if res.is_err() {
919            break;
920        }
921    }
922
923    let direct_installed_count: usize = direct_package_ids.len();
924
925    let failed = failed_packages
926        .lock()
927        .map_err(|e| anyhow!("Failed packages mutex poisoned during finalization: {}", e))?;
928    if !failed.is_empty() {
929        println!("\n{} Rolling back changes...", "::".bold().yellow());
930        transaction::rollback(&transaction_id)?;
931        ux::print_transaction_summary(&ux::TransactionSummary {
932            command: "install".to_string(),
933            success: dependency_installed_count.load(Ordering::Relaxed) + direct_installed_count,
934            failed: failed.len(),
935            skipped: skipped_existing_count,
936        });
937        return Err(anyhow!("Installation failed for: {}", failed.join(", ")));
938    }
939
940    if let Ok(modified_files) = transaction::get_modified_files(&transaction_id) {
941        let modified_packages =
942            transaction::get_modified_packages(&transaction_id).unwrap_or_default();
943        let _ = crate::pkg::hooks::global::run_global_hooks(
944            crate::pkg::hooks::global::HookWhen::PostTransaction,
945            &modified_files,
946            &modified_packages,
947            "install",
948            scope_override.unwrap_or_default(),
949        );
950    }
951
952    if let Err(e) = transaction::commit(&transaction_id) {
953        eprintln!("Warning: Failed to commit transaction: {}", e);
954    }
955
956    let installed_manifests_vec = installed_manifests
957        .lock()
958        .map_err(|e| {
959            anyhow!(
960                "Installed manifests mutex poisoned during finalization: {}",
961                e
962            )
963        })?
964        .clone();
965    for manifest in &installed_manifests_vec {
966        if let Some(pm) = plugin_manager {
967            let pkg_val = pm
968                .lua
969                .to_value(manifest)
970                .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
971            pm.trigger_hook_nonfatal("on_post_install", Some(pkg_val));
972        }
973    }
974
975    let is_any_project_install = scope_override == Some(types::Scope::Project);
976
977    if is_any_project_install && !frozen {
978        println!("\nUpdating zoi.lock...");
979        let mut lockfile =
980            project::lockfile::read_zoi_lock().unwrap_or_else(|_| types::ZoiLockV2 {
981                version: "2".to_string(),
982                ..Default::default()
983            });
984
985        lockfile.installed_packages.clear();
986        lockfile.registries.clear();
987
988        let all_regs_config = crate::pkg::config::read_config().unwrap_or_default();
989        let mut all_configured_regs = all_regs_config.added_registries;
990        if let Some(default_reg) = all_regs_config.default_registry {
991            all_configured_regs.push(default_reg);
992        }
993
994        let store_dir = crate::pkg::local::get_store_base_dir(types::Scope::Project)?;
995        let db_dir = std::env::current_dir()?
996            .join(".zoi")
997            .join("pkgs")
998            .join("db");
999
1000        lockfile.packages_hash = Some(format!(
1001            "sha512-{}",
1002            crate::pkg::hash::calculate_dir_hash(&store_dir).unwrap_or_default()
1003        ));
1004        lockfile.registries_hash = Some(format!(
1005            "sha512-{}",
1006            crate::pkg::hash::calculate_dir_hash(&db_dir).unwrap_or_default()
1007        ));
1008
1009        let mut all_final_manifests = Vec::new();
1010
1011        let just_installed = installed_manifests.into_inner().map_err(|e| {
1012            anyhow!(
1013                "Installed manifests mutex poisoned during lockfile update: {}",
1014                e
1015            )
1016        })?;
1017        all_final_manifests.extend(just_installed);
1018
1019        for (pkg_id, node) in &graph.nodes {
1020            if all_final_manifests.iter().any(|m| {
1021                let m_id = if let Some(sub) = &m.sub_package {
1022                    format!("{}@{}:{}", m.name, m.version, sub)
1023                } else {
1024                    format!("{}@{}", m.name, m.version)
1025                };
1026                m_id == *pkg_id
1027            }) {
1028                continue;
1029            }
1030
1031            let request_source = crate::pkg::local::package_source_string(
1032                &node.registry_handle,
1033                &node.pkg.repo,
1034                &node.pkg.name,
1035                node.sub_package.as_deref(),
1036                &node.version,
1037            );
1038            if let Ok(request) = resolve::parse_source_string(&request_source)
1039                && let Ok(matches) = crate::pkg::local::find_installed_manifests_matching(
1040                    &request,
1041                    scope_override.unwrap_or(node.pkg.scope),
1042                )
1043                && let Some(m) = matches.first()
1044            {
1045                all_final_manifests.push(m.clone());
1046            }
1047        }
1048
1049        for manifest in &all_final_manifests {
1050            let packages_key = if let Some(sub) = &manifest.sub_package {
1051                format!(
1052                    "@{}/{}:{}",
1053                    manifest.repo.trim(),
1054                    manifest.name.trim(),
1055                    sub.trim()
1056                )
1057            } else {
1058                format!("@{}/{}", manifest.repo.trim(), manifest.name.trim())
1059            };
1060
1061            if let Some(reg) = all_configured_regs
1062                .iter()
1063                .find(|r| r.handle == manifest.registry_handle)
1064            {
1065                let mut revision = "unknown".to_string();
1066                let reg_path = db_dir.join(&reg.handle);
1067                if reg_path.exists()
1068                    && let Ok(repo) = git2::Repository::open(&reg_path)
1069                    && let Ok(head) = repo.head()
1070                    && let Some(target) = head.target()
1071                {
1072                    revision = target.to_string();
1073                }
1074
1075                lockfile.registries.insert(
1076                    reg.handle.clone(),
1077                    types::LockRegistryV2 {
1078                        url: reg.url.clone(),
1079                        revision,
1080                    },
1081                );
1082            }
1083
1084            let package_dir = crate::pkg::local::get_package_dir(
1085                types::Scope::Project,
1086                &manifest.registry_handle,
1087                &manifest.repo,
1088                &manifest.name,
1089            )?;
1090            let version_dir = package_dir.join(&manifest.version);
1091            let integrity =
1092                crate::pkg::hash::calculate_dir_hash(&version_dir).unwrap_or_else(|e| {
1093                    eprintln!(
1094                        "Warning: could not calculate integrity for {}: {}",
1095                        manifest.name, e
1096                    );
1097                    String::new()
1098                });
1099
1100            let why = if matches!(manifest.reason, types::InstallReason::Direct) {
1101                "direct".to_string()
1102            } else {
1103                "dependency".to_string()
1104            };
1105
1106            let detail = types::LockPackageDetailV2 {
1107                name: manifest.name.clone(),
1108                sub_package: manifest.sub_package.clone(),
1109                repo: manifest.repo.clone(),
1110                repo_type: manifest.repo_type.clone(),
1111                version: manifest.version.clone(),
1112                epoch: manifest.epoch,
1113                revision: manifest.revision.clone(),
1114                registry: manifest.registry_handle.clone(),
1115                why,
1116                description: manifest.description.clone(),
1117                package_type_install: format!("{:?}", manifest.package_type).to_lowercase(),
1118                install_method: manifest
1119                    .install_method
1120                    .clone()
1121                    .unwrap_or_else(|| "pre-built".to_string()),
1122                installed_sub_packages: manifest
1123                    .sub_package
1124                    .clone()
1125                    .map(|s| vec![s])
1126                    .unwrap_or_default(),
1127                platform: manifest.platform.clone(),
1128                hash: format!("sha512-{}", integrity),
1129                dependencies: manifest.dependencies_v2.clone(),
1130            };
1131
1132            lockfile.installed_packages.insert(packages_key, detail);
1133        }
1134
1135        if let Err(e) = project::lockfile::write_zoi_lock(&mut lockfile) {
1136            eprintln!("Warning: Failed to write zoi.lock file: {}", e);
1137        }
1138    }
1139
1140    if save && scope_override == Some(types::Scope::Project) {
1141        let successfully_installed = successfully_installed_sources.into_inner().map_err(|e| {
1142            anyhow!(
1143                "Successfully installed sources mutex poisoned during finalization: {}",
1144                e
1145            )
1146        })?;
1147
1148        if !successfully_installed.is_empty() {
1149            if std::path::Path::new("zoi.lua").exists() {
1150                println!(
1151                    "\n{} Project uses zoi.lua. Automatic saving is not supported for Lua configurations.",
1152                    "Note:".yellow().bold()
1153                );
1154                println!("   Please add the following to your packages() block in zoi.lua:");
1155                for pkg in &successfully_installed {
1156                    println!("   - \"{}\"", pkg);
1157                }
1158            } else if let Err(e) = project::config::add_packages_to_config(&successfully_installed)
1159            {
1160                eprintln!(
1161                    "{}: Failed to save packages to zoi.yaml: {}",
1162                    "Warning".yellow().bold(),
1163                    e
1164                );
1165            }
1166        }
1167    }
1168
1169    println!("\n{} Installation complete!", "Success:".green().bold());
1170
1171    if is_project_install && lockfile_exists {
1172        println!();
1173        crate::project::verify::run()?;
1174    }
1175
1176    println!("\n{} Done", "::".bold().blue());
1177    println!(
1178        "Installed ({}) packages and ({}) dependencies.",
1179        direct_packages.len(),
1180        dependencies.len() + non_zoi_deps.len()
1181    );
1182    ux::print_transaction_summary(&ux::TransactionSummary {
1183        command: "install".to_string(),
1184        success: dependency_installed_count.load(Ordering::Relaxed) + direct_installed_count,
1185        failed: 0,
1186        skipped: skipped_existing_count,
1187    });
1188
1189    Ok(())
1190}