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