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 pkg_spec = if node.pkg.repo.is_empty() {
292 node.pkg.name.clone()
293 } else {
294 format!("@{}/{}", node.pkg.repo, node.pkg.name)
295 };
296
297 let request_base =
298 zoi_resolver::resolve::parse_source_string(&pkg_spec)?;
299 let installed = local::find_installed_manifests_matching(
300 &request_base,
301 scope_override.unwrap_or(node.pkg.scope)
302 )?;
303
304 if !installed.is_empty() {
305 let already_at_target = installed.iter().any(|m| {
306 m.version == node.version && m.revision == node.revision
307 });
308
309 let display_name = ux::format_display_name(
310 &node.registry_handle,
311 &node.pkg.repo,
312 &node.pkg.name,
313 node.sub_package.as_deref(),
314 &config
315 );
316 if !options.plan_json {
317 if already_at_target {
318 let full_spec =
319 format!("{}@{}", display_name, node.version);
320 println!(
321 "{} Package '{}' is already installed. \
322 Skipping.",
323 "::".bold().green(),
324 full_spec.cyan()
325 );
326 } else {
327 let current_version = installed
328 .first()
329 .map(|m| m.version.as_str())
330 .unwrap_or_default();
331
332 let current_spec =
333 format!("{display_name}@{current_version}");
334 let available_spec =
335 format!("{}@{}", display_name, node.version);
336
337 println!(
338 "{} Package '{}' is already installed \
339 (available: {}).",
340 "::".bold().yellow(),
341 current_spec.cyan(),
342 available_spec.cyan()
343 );
344 println!(
345 " {} To update to the newer version, run: {}",
346 "Hint:".bold().blue(),
347 format!("zoi update {pkg_spec}").italic()
348 );
349 }
350 }
351 to_remove.push(pkg_id.clone());
352 }
353 }
354 skipped_existing_count = to_remove.len();
355
356 for pkg_id in to_remove {
357 graph.nodes.remove(&pkg_id);
358 if let Some(children) = graph.adj.remove(&pkg_id)
359 && let Some(root_children) = graph.adj.get_mut("$root")
360 {
361 for child in children {
362 root_children.insert(child);
363 }
364 }
365 if let Some(root_children) = graph.adj.get_mut("$root") {
366 root_children.remove(&pkg_id);
367 }
368 }
369
370 let mut valid_non_zoi_deps = std::collections::HashSet::new();
371 for source in &sources_to_process {
372 if let Ok(dep) = zoi_deps::parse_dependency_string(source)
373 && dep.manager != "zoi"
374 {
375 valid_non_zoi_deps.insert(source.clone());
376 }
377 }
378 for node in graph.nodes.values() {
379 for dep in &node.dependencies {
380 if let Ok(dep_req) = zoi_deps::parse_dependency_string(dep)
381 && dep_req.manager != "zoi"
382 {
383 valid_non_zoi_deps.insert(dep.clone());
384 }
385 }
386 }
387 non_zoi_deps.retain(|dep| valid_non_zoi_deps.contains(dep));
388 }
389
390 if graph.nodes.is_empty() && non_zoi_deps.is_empty() {
391 println!("\nAll requested packages are already installed.");
392 return Ok(());
393 }
394
395 if !options.dry_run {
396 if let Some(pm) = options.plugin_manager {
397 pm.set_context(scope_override.unwrap_or_default())?;
398 }
399 for node in graph.nodes.values() {
400 if let Some(pm) = options.plugin_manager {
401 let pkg_val = pm
402 .lua
403 .to_value(&node.pkg)
404 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
405 pm.trigger_hook("on_pre_install", Some(&pkg_val))?;
406 }
407 }
408 }
409
410 let mut direct_packages = Vec::new();
411 let mut dependencies = Vec::new();
412
413 for node in graph.nodes.values() {
414 if matches!(node.reason, types::InstallReason::Direct) {
415 direct_packages.push(node);
416 } else {
417 dependencies.push(node);
418 }
419 }
420
421 direct_packages.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
422 dependencies.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
423
424 for node in graph.nodes.values() {
425 cli_utils::print_repo_warning(&node.pkg.repo);
426 }
427
428 if !options.plan_json {
430 println!("{} Looking for conflicts...", "::".bold().blue());
431 }
432 let packages_to_install: Vec<&types::Package> =
433 graph.nodes.values().map(|n| &n.pkg).collect();
434
435 if !options.dry_run {
436 preflight::check_for_conflicts(&packages_to_install, options.yes)?;
437 for pkg in &packages_to_install {
438 if !util::display_updates(pkg, options.yes)? {
439 return Err(anyhow!("Installation aborted by user."));
440 }
441 }
442 preflight::check_policy_compliance(&graph)?;
443 preflight::check_scope_compliance(&graph)?;
444 preflight::check_zoios_compliance(&graph)?;
445 preflight::check_for_vulnerabilities(&graph, options.yes)?;
446
447 let m_for_conflict_check = MultiProgress::new();
448 if options.plan_json {
449 m_for_conflict_check
450 .set_draw_target(indicatif::ProgressDrawTarget::hidden());
451 }
452 preflight::check_file_conflicts(
453 &graph,
454 options.yes,
455 &m_for_conflict_check
456 )?;
457 let _ = m_for_conflict_check.clear();
458 }
459
460 if !options.plan_json {
461 println!("{} Checking available disk space...", "::".bold().blue());
462 }
463 let install_plan = plan::create_install_plan(
464 &graph.nodes,
465 options.build_type,
466 options.build
467 )?;
468
469 let mut total_download_size: u64 = 0;
470 let mut total_installed_size: u64 = 0;
471 let mut unique_downloads = HashSet::new();
472
473 for (id, node) in &graph.nodes {
474 match install_plan.get(id) {
475 Some(plan::InstallAction::DownloadAndInstall(details)) => {
476 if unique_downloads.insert(details.info.final_url.clone()) {
477 total_download_size += details.download_size;
478 }
479 total_installed_size += if details.installed_size > 0 {
480 details.installed_size
481 } else {
482 node.pkg.installed_size.unwrap_or(0)
483 };
484 }
485 Some(plan::InstallAction::BuildAndInstall) => {
486 total_installed_size +=
487 node.pkg.installed_size.unwrap_or(0);
488 }
489 _ => {}
490 }
491 }
492
493 if options.plan_json {
494 let mut packages = Vec::new();
495 for (id, node) in &graph.nodes {
496 let action_name = match install_plan.get(id) {
497 Some(plan::InstallAction::DownloadAndInstall(_)) => {
498 "download"
499 }
500 Some(plan::InstallAction::InstallFromArchive(_)) => {
501 "archive"
502 }
503 Some(plan::InstallAction::BuildAndInstall) => "build",
504 None => "unknown"
505 };
506 let reason = match &node.reason {
507 types::InstallReason::Direct => "direct".to_string(),
508 types::InstallReason::Dependency { parent } => {
509 format!("dependency:{parent}")
510 }
511 };
512 packages.push(json!({
513 "id": id,
514 "name": node.pkg.name,
515 "version": node.version,
516 "revision": node.revision,
517 "sub_package": node.sub_package,
518 "repo": node.pkg.repo,
519 "registry": node.registry_handle,
520 "reason": reason,
521 "action": action_name,
522 "source": node.source,
523 }));
524 }
525
526 let plan_data = json!({
527 "dry_run": options.dry_run,
528 "frozen": options.frozen,
529 "retry_attempts": options.retry,
530 "scope": format!("{:?}", scope_override.unwrap_or(types::Scope::User)),
531 "totals": {
532 "direct_packages": direct_packages.len(),
533 "dependencies": dependencies.len() + non_zoi_deps.len(),
534 "download_bytes": total_download_size,
535 "installed_bytes": total_installed_size,
536 "skipped_existing": skipped_existing_count,
537 },
538 "packages": packages,
539 "non_zoi_dependencies": non_zoi_deps,
540 });
541 println!("{}", serde_json::to_string_pretty(&plan_data)?);
542 return Ok(());
543 }
544
545 if options.dry_run {
546 println!(
547 "\n{} Dry-run: installation plan above would be executed.",
548 "::".bold().yellow()
549 );
550 return Ok(());
551 }
552
553 let install_path =
555 local::get_store_base_dir(scope_override.unwrap_or_default())?;
556 std::fs::create_dir_all(&install_path)?;
557
558 let available_space =
559 fs2::available_space(&install_path).unwrap_or(u64::MAX);
560
561 if total_installed_size > available_space {
562 return Err(anyhow!(
563 "Not enough disk space. Required: {}, Available: {}",
564 zoi_core::utils::format_bytes(total_installed_size),
565 zoi_core::utils::format_bytes(available_space)
566 ));
567 }
568
569 let config = zoi_core::config::read_config().unwrap_or_default();
570
571 println!(
572 "\n{} Packages ({})",
573 "::".bold().blue(),
574 direct_packages.len()
575 );
576 let direct_list: Vec<_> = direct_packages
577 .iter()
578 .map(|n| {
579 let display_name = ux::format_display_name(
580 &n.registry_handle,
581 &n.pkg.repo,
582 &n.pkg.name,
583 n.sub_package.as_deref(),
584 &config
585 );
586 let version_display = if n.revision == "1" {
587 n.version.clone()
588 } else {
589 format!("{}-{}", n.version, n.revision)
590 };
591 format!("{display_name}@{version_display}")
592 .cyan()
593 .to_string()
594 })
595 .collect();
596 println!(" {}", direct_list.join(" "));
597
598 if options.verbose {
599 println!("\n{} Package origins", "::".bold().blue());
600 let mut direct_entries: Vec<_> = graph
601 .nodes
602 .iter()
603 .filter(|(_, node)| {
604 matches!(node.reason, types::InstallReason::Direct)
605 })
606 .collect();
607 direct_entries.sort_by(|a, b| a.1.pkg.name.cmp(&b.1.pkg.name));
608 for (id, node) in direct_entries {
609 let action_name = match install_plan.get(id) {
610 Some(plan::InstallAction::DownloadAndInstall(_)) => {
611 "download"
612 }
613 Some(plan::InstallAction::InstallFromArchive(_)) => {
614 "archive"
615 }
616 Some(plan::InstallAction::BuildAndInstall) => "build",
617 None => "unknown"
618 };
619 let origin = crate::cmd::ux::classify_source_origin(
620 &node.source,
621 action_name
622 );
623 let display_name = ux::format_display_name(
624 &node.registry_handle,
625 &node.pkg.repo,
626 &node.pkg.name,
627 node.sub_package.as_deref(),
628 &config
629 );
630 let version_display = if node.revision == "1" {
631 node.version.clone()
632 } else {
633 format!("{}-{}", node.version, node.revision)
634 };
635 println!(
636 " - {}@{} -> {} ({})",
637 display_name.cyan(),
638 version_display,
639 origin.as_str(),
640 action_name
641 );
642 }
643 }
644
645 if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
646 println!(
647 "\n{} Dependencies ({})",
648 "::".bold().blue(),
649 dependencies.len() + non_zoi_deps.len()
650 );
651 let mut dep_list = Vec::new();
652 for n in &dependencies {
653 let display_name = ux::format_display_name(
654 &n.registry_handle,
655 &n.pkg.repo,
656 &n.pkg.name,
657 n.sub_package.as_deref(),
658 &config
659 );
660 let version_display = if n.revision == "1" {
661 n.version.clone()
662 } else {
663 format!("{}-{}", n.version, n.revision)
664 };
665 dep_list.push(
666 format!("zoi:{display_name}@{version_display}")
667 .dimmed()
668 .to_string()
669 );
670 }
671 for d in &non_zoi_deps {
672 dep_list.push(d.dimmed().to_string());
673 }
674 println!(" {}", dep_list.join(" "));
675 }
676
677 if total_download_size > 0 {
678 println!(
679 "\nTotal Download Size: {}",
680 zoi_core::utils::format_bytes(total_download_size)
681 );
682 }
683 if total_installed_size > 0 {
684 println!(
685 "Total Installed Size: {}",
686 zoi_core::utils::format_bytes(total_installed_size)
687 );
688 }
689
690 if options.verbose {
691 let preflight =
692 crate::cmd::ux::PreflightSummary::new("Install preflight")
693 .row(
694 "Scope",
695 format!(
696 "{:?}",
697 scope_override.unwrap_or(types::Scope::User)
698 )
699 )
700 .row("Frozen lockfile", options.frozen.to_string())
701 .row("Retry attempts", options.retry.to_string())
702 .row("Direct packages", direct_packages.len().to_string())
703 .row(
704 "Dependencies",
705 (dependencies.len() + non_zoi_deps.len()).to_string()
706 )
707 .row(
708 "Download size",
709 zoi_core::utils::format_bytes(total_download_size)
710 )
711 .row(
712 "Installed size",
713 zoi_core::utils::format_bytes(total_installed_size)
714 );
715 crate::cmd::ux::print_preflight(&preflight);
716 }
717
718 let yes = options.yes;
719 if !zoi_core::utils::ask_for_confirmation(
720 "\nProceed with installation?",
721 yes
722 ) {
723 return Ok(());
724 }
725
726 let stages = graph.toposort()?;
727 let transaction = Mutex::new(transaction::begin()?);
728 let transaction_id = transaction
729 .lock()
730 .expect("Transaction mutex poisoned")
731 .id
732 .clone();
733 let dependency_installed_count = AtomicUsize::new(0);
734
735 println!("\n{} Preparing packages...", "::".bold().blue());
736 let m_prep = MultiProgress::new();
737 let prepared_nodes = Mutex::new(HashMap::new());
738
739 let build_type = options.build_type;
740 let verbose = options.verbose;
741
742 stages
743 .par_iter()
744 .flatten()
745 .try_for_each(|pkg_id| -> Result<()> {
746 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
747 anyhow!(
748 "Package node '{pkg_id}' missing from graph during \
749 preparation"
750 )
751 })?;
752 let action = install_plan.get(pkg_id).ok_or_else(|| {
753 anyhow!(
754 "Install action missing for package '{pkg_id}' during \
755 preparation"
756 )
757 })?;
758
759 let prepared = installer::prepare_node(
760 node,
761 action,
762 Some(&m_prep),
763 build_type,
764 verbose
765 )?;
766
767 let mut lock = prepared_nodes.lock().map_err(|e| {
768 anyhow!(
769 "Prepared nodes mutex poisoned during preparation: {e}"
770 )
771 })?;
772 lock.insert(pkg_id.clone(), prepared);
773 Ok(())
774 })?;
775
776 if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
777 println!("\n{} Installing dependencies...", "::".bold().blue());
778 let m_deps = MultiProgress::new();
779
780 for stage in &stages {
781 stage.par_iter().try_for_each(|pkg_id| -> Result<()> {
782 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
783 anyhow!(
784 "Package node '{pkg_id}' missing from graph \
785 during installation"
786 )
787 })?;
788 if matches!(node.reason, types::InstallReason::Direct) {
789 return Ok(());
790 }
791
792 let prepared = {
793 let lock = prepared_nodes.lock().map_err(|e| {
794 anyhow!(
795 "Prepared nodes mutex poisoned during \
796 dependency install: {e}"
797 )
798 })?;
799 lock.get(pkg_id).cloned().ok_or_else(|| {
800 anyhow!("Prepared node missing for: {pkg_id}")
801 })?
802 };
803
804 match installer::install_prepared_node(
805 node,
806 &prepared,
807 Some(&m_deps),
808 yes,
809 true,
810 true,
811 verbose
812 ) {
813 Ok(manifest) => {
814 dependency_installed_count
815 .fetch_add(1, Ordering::Relaxed);
816 let mut tx_lock =
817 transaction.lock().map_err(|e| {
818 anyhow!(
819 "Transaction mutex poisoned during \
820 installation: {e}"
821 )
822 })?;
823 if let Err(e) = transaction::record_operation(
824 &mut tx_lock,
825 types::TransactionOperation::Install {
826 manifest: Box::new(manifest)
827 }
828 ) {
829 return Err(anyhow!(
830 "Transaction recording failed: {e}"
831 ));
832 }
833 }
834 Err(e) => {
835 failed_packages
836 .lock()
837 .expect("Failed packages mutex poisoned")
838 .push(node.pkg.name.clone());
839 eprintln!(
840 "Error installing {}: {}",
841 node.pkg.name, e
842 );
843 }
844 }
845 Ok(())
846 })?;
847 }
848 }
849
850 println!("\n{} Installing packages...", "::".bold().blue());
851 let m_pkg = MultiProgress::new();
852
853 for stage in &stages {
854 let mut stage_direct_ids = Vec::new();
855 for pkg_id in stage {
856 if let Some(node) = graph.nodes.get(pkg_id)
857 && matches!(node.reason, types::InstallReason::Direct)
858 {
859 let name = if let Some(sub) = &node.sub_package {
860 format!("{}:{}", node.pkg.name, sub)
861 } else {
862 node.pkg.name.clone()
863 };
864 let version_display = if node.revision == "1" {
865 node.version.clone()
866 } else {
867 format!("{}-{}", node.version, node.revision)
868 };
869 println!("@{name}:{version_display}");
870 stage_direct_ids.push(pkg_id.clone());
871 }
872 }
873
874 if stage_direct_ids.is_empty() {
875 continue;
876 }
877
878 let res = stage_direct_ids.par_iter().try_for_each(
879 |pkg_id| -> Result<()> {
880 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
881 anyhow!(
882 "Package node '{pkg_id}' missing from graph \
883 during final installation"
884 )
885 })?;
886
887 let prepared = {
888 let lock = prepared_nodes.lock().map_err(|e| {
889 anyhow!(
890 "Prepared nodes mutex poisoned during package \
891 install: {e}"
892 )
893 })?;
894 lock.get(pkg_id).cloned().ok_or_else(|| {
895 anyhow!("Prepared node missing for: {pkg_id}")
896 })?
897 };
898
899 match installer::install_prepared_node(
900 node,
901 &prepared,
902 Some(&m_pkg),
903 yes,
904 true,
905 true,
906 verbose
907 ) {
908 Ok(manifest) => {
909 installed_manifests
910 .lock()
911 .expect("Installed manifests mutex poisoned")
912 .push(manifest.clone());
913 let mut tx_lock =
914 transaction.lock().map_err(|e| {
915 anyhow!(
916 "Transaction mutex poisoned during \
917 direct package installation: {e}"
918 )
919 })?;
920 transaction::record_operation(
921 &mut tx_lock,
922 types::TransactionOperation::Install {
923 manifest: Box::new(manifest)
924 }
925 )?;
926 successfully_installed_sources
927 .lock()
928 .expect(
929 "Successfully installed sources mutex \
930 poisoned"
931 )
932 .push(node.source.clone());
933 Ok(())
934 }
935 Err(e) => {
936 failed_packages
937 .lock()
938 .expect("Failed packages mutex poisoned")
939 .push(node.pkg.name.clone());
940 eprintln!(
941 "Error installing {}: {}",
942 node.pkg.name, e
943 );
944 Err(e)
945 }
946 }
947 }
948 );
949
950 if res.is_err() {
951 break;
952 }
953 }
954
955 let failed = failed_packages
956 .lock()
957 .expect("Failed packages mutex poisoned");
958 if !failed.is_empty() {
959 println!("\n{} Rolling back changes...", "::".bold().yellow());
960 transaction::rollback(&transaction_id)?;
961 return Err(anyhow!(
962 "Installation failed for: {}",
963 failed.join(", ")
964 ));
965 }
966
967 if let Err(e) = transaction::commit(&transaction_id) {
968 eprintln!("Warning: Failed to commit transaction: {e}");
969 }
970
971 let installed_manifests_vec = installed_manifests
972 .lock()
973 .expect("Installed manifests mutex poisoned")
974 .clone();
975 for manifest in &installed_manifests_vec {
976 if let Some(pm) = options.plugin_manager {
977 let pkg_val = pm
978 .lua
979 .to_value(manifest)
980 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
981 pm.trigger_hook_nonfatal("on_post_install", Some(&pkg_val));
982 }
983 }
984
985 println!("\n{} Installation complete!", "Success:".green().bold());
986 Ok(())
987 }
988}