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