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