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 let force_redownload = options.force;
860
861 stages
862 .par_iter()
863 .flatten()
864 .try_for_each(|pkg_id| -> Result<()> {
865 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
866 anyhow!(
867 "Package node '{pkg_id}' missing from graph during \
868 preparation"
869 )
870 })?;
871 let action = install_plan.get(pkg_id).ok_or_else(|| {
872 anyhow!(
873 "Install action missing for package '{pkg_id}' during \
874 preparation"
875 )
876 })?;
877
878 let prepared = installer::prepare_node(
879 node,
880 action,
881 Some(&m_prep),
882 build_type,
883 verbose,
884 force_redownload
885 )?;
886
887 let mut lock = prepared_nodes.lock().map_err(|e| {
888 anyhow!(
889 "Prepared nodes mutex poisoned during preparation: {e}"
890 )
891 })?;
892 lock.insert(pkg_id.clone(), prepared);
893 Ok(())
894 })?;
895
896 if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
897 println!("\n{} Installing dependencies...", "::".bold().blue());
898 let m_deps = MultiProgress::new();
899
900 for stage in &stages {
901 stage.par_iter().try_for_each(|pkg_id| -> Result<()> {
902 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
903 anyhow!(
904 "Package node '{pkg_id}' missing from graph \
905 during installation"
906 )
907 })?;
908 if matches!(node.reason, types::InstallReason::Direct) {
909 return Ok(());
910 }
911
912 let prepared = {
913 let lock = prepared_nodes.lock().map_err(|e| {
914 anyhow!(
915 "Prepared nodes mutex poisoned during \
916 dependency install: {e}"
917 )
918 })?;
919 lock.get(pkg_id).cloned().ok_or_else(|| {
920 anyhow!("Prepared node missing for: {pkg_id}")
921 })?
922 };
923
924 match installer::install_prepared_node(
925 node,
926 &prepared,
927 Some(&m_deps),
928 yes,
929 true,
930 true,
931 verbose
932 ) {
933 Ok(manifest) => {
934 dependency_installed_count
935 .fetch_add(1, Ordering::Relaxed);
936 let mut tx_lock =
937 transaction.lock().map_err(|e| {
938 anyhow!(
939 "Transaction mutex poisoned during \
940 installation: {e}"
941 )
942 })?;
943 if let Err(e) = transaction::record_operation(
944 &mut tx_lock,
945 types::TransactionOperation::Install {
946 manifest: Box::new(manifest)
947 }
948 ) {
949 return Err(anyhow!(
950 "Transaction recording failed: {e}"
951 ));
952 }
953 }
954 Err(e) => {
955 failed_packages
956 .lock()
957 .expect("Failed packages mutex poisoned")
958 .push(node.pkg.name.clone());
959 eprintln!(
960 "Error installing {}: {}",
961 node.pkg.name, e
962 );
963 }
964 }
965 Ok(())
966 })?;
967 }
968 }
969
970 if options.deps_only {
971 println!(
972 "\n{} Skipping direct packages (--deps-only).",
973 "::".bold().yellow()
974 );
975 } else {
976 println!("\n{} Installing packages...", "::".bold().blue());
977 let m_pkg = MultiProgress::new();
978
979 for stage in &stages {
980 let mut stage_direct_ids = Vec::new();
981 for pkg_id in stage {
982 if let Some(node) = graph.nodes.get(pkg_id)
983 && matches!(node.reason, types::InstallReason::Direct)
984 {
985 let name = if let Some(sub) = &node.sub_package {
986 format!("{}:{}", node.pkg.name, sub)
987 } else {
988 node.pkg.name.clone()
989 };
990 let version_display = if node.revision == "1" {
991 node.version.clone()
992 } else {
993 format!("{}-{}", node.version, node.revision)
994 };
995 println!("@{name}:{version_display}");
996 stage_direct_ids.push(pkg_id.clone());
997 }
998 }
999
1000 if stage_direct_ids.is_empty() {
1001 continue;
1002 }
1003
1004 let res = stage_direct_ids.par_iter().try_for_each(
1005 |pkg_id| -> Result<()> {
1006 let node =
1007 graph.nodes.get(pkg_id).ok_or_else(|| {
1008 anyhow!(
1009 "Package node '{pkg_id}' missing from \
1010 graph during final installation"
1011 )
1012 })?;
1013
1014 let prepared = {
1015 let lock = prepared_nodes.lock().map_err(|e| {
1016 anyhow!(
1017 "Prepared nodes mutex poisoned during \
1018 package install: {e}"
1019 )
1020 })?;
1021 lock.get(pkg_id).cloned().ok_or_else(|| {
1022 anyhow!("Prepared node missing for: {pkg_id}")
1023 })?
1024 };
1025
1026 match installer::install_prepared_node(
1027 node,
1028 &prepared,
1029 Some(&m_pkg),
1030 yes,
1031 true,
1032 true,
1033 verbose
1034 ) {
1035 Ok(manifest) => {
1036 installed_manifests
1037 .lock()
1038 .expect(
1039 "Installed manifests mutex poisoned"
1040 )
1041 .push(manifest.clone());
1042 let mut tx_lock =
1043 transaction.lock().map_err(|e| {
1044 anyhow!(
1045 "Transaction mutex poisoned \
1046 during direct package \
1047 installation: {e}"
1048 )
1049 })?;
1050 transaction::record_operation(
1051 &mut tx_lock,
1052 types::TransactionOperation::Install {
1053 manifest: Box::new(manifest)
1054 }
1055 )?;
1056 successfully_installed_sources
1057 .lock()
1058 .expect(
1059 "Successfully installed sources mutex \
1060 poisoned"
1061 )
1062 .push(node.source.clone());
1063 Ok(())
1064 }
1065 Err(e) => {
1066 failed_packages
1067 .lock()
1068 .expect("Failed packages mutex poisoned")
1069 .push(node.pkg.name.clone());
1070 eprintln!(
1071 "Error installing {}: {}",
1072 node.pkg.name, e
1073 );
1074 Err(e)
1075 }
1076 }
1077 }
1078 );
1079
1080 if res.is_err() {
1081 break;
1082 }
1083 }
1084 }
1085
1086 let failed = failed_packages
1087 .lock()
1088 .expect("Failed packages mutex poisoned");
1089 if !failed.is_empty() {
1090 println!("\n{} Rolling back changes...", "::".bold().yellow());
1091 transaction::rollback(&transaction_id)?;
1092 return Err(anyhow!(
1093 "Installation failed for: {}",
1094 failed.join(", ")
1095 ));
1096 }
1097
1098 if let Err(e) = transaction::commit(&transaction_id) {
1099 eprintln!("Warning: Failed to commit transaction: {e}");
1100 }
1101
1102 let installed_manifests_vec = installed_manifests
1103 .lock()
1104 .expect("Installed manifests mutex poisoned")
1105 .clone();
1106 for manifest in &installed_manifests_vec {
1107 if let Some(pm) = options.plugin_manager {
1108 let pkg_val = pm
1109 .lua
1110 .to_value(manifest)
1111 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
1112 pm.trigger_hook_nonfatal("on_post_install", Some(&pkg_val));
1113 }
1114 }
1115
1116 println!("\n{} Installation complete!", "Success:".green().bold());
1117 Ok(())
1118 }
1119}