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