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::{lock, 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::utils as cli_utils;
21
22pub struct InstallOptions<'a> {
24 pub scope: types::Scope,
26 pub force: bool,
28 pub all_optional: bool,
30 pub yes: bool,
32 pub save: bool,
34 pub build_type: Option<&'a str>,
36 pub dry_run: bool,
38 pub plugin_manager: Option<&'a PluginManager>,
40 pub build: bool,
42 pub frozen: bool,
44 pub explain: bool,
46 pub plan_json: bool,
48 pub retry: u32,
50 pub verbose: bool,
52 pub purl: bool,
54 pub project_config: Option<project::config::ProjectConfig>
56}
57
58pub struct Orchestrator<'a> {
60 options: InstallOptions<'a>
62}
63
64impl<'a> Orchestrator<'a> {
65 pub fn new(options: InstallOptions<'a>) -> Self {
67 Self { options }
68 }
69
70 pub fn run(&self, sources: &[String], repo: Option<String>) -> Result<()> {
81 let options = &self.options;
82 util::set_download_retry_attempts(options.retry);
83
84 if sources.is_empty() && repo.is_none() && !options.frozen {
85 return Err(anyhow!("No packages specified for installation."));
86 }
87
88 let _lock = lock::acquire_lock()?;
90
91 let mut scope_override = Some(options.scope);
92
93 if options.frozen {
94 if repo.is_some() || !sources.is_empty() {
95 return Err(anyhow!(
96 "--frozen can only be used without explicit sources or \
97 --repo."
98 ));
99 }
100 if options.save {
101 return Err(anyhow!(
102 "--save cannot be used with --frozen because the lockfile \
103 must remain unchanged."
104 ));
105 }
106 if !std::path::Path::new("zoi.lua").exists() {
107 return Err(anyhow!(
108 "--frozen requires a local zoi.lua in the current project."
109 ));
110 }
111 if !std::path::Path::new("zoi.lock").exists() {
112 return Err(anyhow!(
113 "--frozen requires zoi.lock. Generate it first with a \
114 normal project install."
115 ));
116 }
117 if let Some(scope) = scope_override
118 && scope != types::Scope::Project
119 {
120 return Err(anyhow!(
121 "--frozen is only supported for project scope installs."
122 ));
123 }
124 scope_override = Some(types::Scope::Project);
125 zoi_core::frozen::set_frozen(true);
126 }
127
128 let lockfile_exists = sources.is_empty()
129 && repo.is_none()
130 && std::path::Path::new("zoi.lock").exists()
131 && (std::path::Path::new("zoi.lua").exists()
132 || std::path::Path::new("zoi.yaml").exists());
133
134 let mut sources_to_process: Vec<String> = sources.to_vec();
135 let mut _is_project_install = false;
136 let mut frozen_packages = None;
137
138 if options.frozen {
139 let lockfile = project::lockfile::read_zoi_lock()?;
140 let locked_packages = project::lockfile::locked_packages(&lockfile);
141 sources_to_process = locked_packages
142 .iter()
143 .map(|entry| entry.source.clone())
144 .collect();
145 if sources_to_process.is_empty() {
146 return Err(anyhow!(
147 "zoi.lock is empty. Cannot continue with --frozen."
148 ));
149 }
150 frozen_packages = Some(locked_packages);
151 println!(
152 "{} --frozen enabled. Installing pinned lockfile sources \
153 only...",
154 "::".bold().blue()
155 );
156 _is_project_install = true;
157 } else if sources.is_empty() && repo.is_none() {
158 if std::path::Path::new("zoi.lua").exists()
159 || std::path::Path::new("zoi.yaml").exists()
160 {
161 if let Ok(config) = project::config::load() {
162 let config_file =
163 if std::path::Path::new("zoi.lua").exists() {
164 "zoi.lua"
165 } else {
166 "zoi.yaml"
167 };
168 if lockfile_exists {
169 println!(
170 "{} zoi.lock found. Installing from {} then \
171 verifying...",
172 "::".bold().blue(),
173 config_file
174 );
175 } else {
176 println!(
177 "{} Installing project packages from {}...",
178 "::".bold().blue(),
179 config_file
180 );
181 }
182 sources_to_process.clone_from(&config.pkgs);
183 if scope_override.is_none() {
184 scope_override = Some(types::Scope::Project);
185 }
186 _is_project_install = true;
187 }
188 } else if let Some(pm) = options.plugin_manager
189 && pm.trigger_project_install_hook()?
190 {
191 return Ok(());
192 }
193 }
194
195 if let Some(_repo_spec) = repo {
196 if scope_override == Some(types::Scope::Project) {
197 return Err(anyhow!(
198 "Installing from a repository to a project scope is not \
199 supported."
200 ));
201 }
202
203 return Err(anyhow!(
204 "Repository installation not implemented in Orchestrator yet."
205 ));
206 }
207
208 if sources_to_process.is_empty() {
209 return Ok(());
210 }
211
212 if options.purl {
213 let mut resolved_purls = Vec::new();
214 for source in &sources_to_process {
215 println!(
216 "{} Fetching PURL package '{}'...",
217 "::".bold().blue(),
218 source
219 );
220 let ident = zoi_purl::fetch_and_store_purl_package(source)?;
221 resolved_purls.push(ident);
222 }
223 sources_to_process = resolved_purls;
224 }
225
226 let config = zoi_core::config::read_config().unwrap_or_default();
227 let jobs = config.jobs.unwrap_or(3);
228 rayon::ThreadPoolBuilder::new()
229 .num_threads(jobs)
230 .build_global()
231 .ok();
232
233 let failed_packages = Mutex::new(Vec::new());
234 let mut temp_files = Vec::new();
235 let mut final_sources = Vec::new();
236
237 for source in &sources_to_process {
238 if std::path::Path::new(source)
239 .extension()
240 .is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
241 {
242 lockfile::process_lockfile(
243 source,
244 &mut final_sources,
245 &mut temp_files,
246 scope_override.unwrap_or(types::Scope::User)
247 )?;
248 } else {
249 final_sources.push(source.clone());
250 }
251 }
252
253 let successfully_installed_sources = Mutex::new(Vec::new());
254 let installed_manifests = Mutex::new(Vec::new());
255
256 let (mut graph, mut non_zoi_deps) =
258 if let Some(locked_packages) = frozen_packages.as_ref() {
259 resolver::build_graph_from_locked_packages(
260 locked_packages,
261 scope_override,
262 false,
263 options.yes
264 )?
265 } else {
266 resolver::resolve_dependency_graph(
267 &final_sources,
268 scope_override,
269 options.force,
270 options.yes,
271 options.all_optional,
272 options.build_type,
273 false,
274 options.project_config.clone()
275 )?
276 };
277
278 let mut skipped_existing_count = 0usize;
279 if !options.force {
280 let mut to_remove = Vec::new();
281 for (pkg_id, node) in &graph.nodes {
282 let request_source = local::package_source_string(
283 &node.registry_handle,
284 &node.pkg.repo,
285 &node.pkg.name,
286 node.sub_package.as_deref(),
287 &node.version
288 );
289 let request = zoi_resolver::resolve::parse_source_string(
290 &request_source
291 )?;
292 let matches = local::find_installed_manifests_matching(
293 &request,
294 scope_override.unwrap_or(node.pkg.scope)
295 )?;
296 if matches
297 .iter()
298 .any(|manifest| manifest.version == node.version)
299 {
300 println!(
301 "{} Package '{}' is already installed at version {}. \
302 Skipping.",
303 "::".bold().green(),
304 node.pkg.name.cyan(),
305 node.version.yellow()
306 );
307 to_remove.push(pkg_id.clone());
308 }
309 }
310 skipped_existing_count = to_remove.len();
311
312 for pkg_id in to_remove {
313 graph.nodes.remove(&pkg_id);
314 if let Some(children) = graph.adj.remove(&pkg_id)
315 && let Some(root_children) = graph.adj.get_mut("$root")
316 {
317 for child in children {
318 root_children.insert(child);
319 }
320 }
321 if let Some(root_children) = graph.adj.get_mut("$root") {
322 root_children.remove(&pkg_id);
323 }
324 }
325
326 let mut valid_non_zoi_deps = std::collections::HashSet::new();
327 for source in &sources_to_process {
328 if let Ok(dep) = zoi_deps::parse_dependency_string(source)
329 && dep.manager != "zoi"
330 {
331 valid_non_zoi_deps.insert(source.clone());
332 }
333 }
334 for node in graph.nodes.values() {
335 for dep in &node.dependencies {
336 if let Ok(dep_req) = zoi_deps::parse_dependency_string(dep)
337 && dep_req.manager != "zoi"
338 {
339 valid_non_zoi_deps.insert(dep.clone());
340 }
341 }
342 }
343 non_zoi_deps.retain(|dep| valid_non_zoi_deps.contains(dep));
344 }
345
346 if graph.nodes.is_empty() && non_zoi_deps.is_empty() {
347 println!("\nAll requested packages are already installed.");
348 return Ok(());
349 }
350
351 if !options.dry_run {
352 if let Some(pm) = options.plugin_manager {
353 pm.set_context(scope_override.unwrap_or_default())?;
354 }
355 for node in graph.nodes.values() {
356 if let Some(pm) = options.plugin_manager {
357 let pkg_val = pm
358 .lua
359 .to_value(&node.pkg)
360 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
361 pm.trigger_hook("on_pre_install", Some(&pkg_val))?;
362 }
363 }
364 }
365
366 let mut direct_packages = Vec::new();
367 let mut dependencies = Vec::new();
368
369 for node in graph.nodes.values() {
370 if matches!(node.reason, types::InstallReason::Direct) {
371 direct_packages.push(node);
372 } else {
373 dependencies.push(node);
374 }
375 }
376
377 direct_packages.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
378 dependencies.sort_by(|a, b| a.pkg.name.cmp(&b.pkg.name));
379
380 for node in graph.nodes.values() {
381 cli_utils::print_repo_warning(&node.pkg.repo);
382 }
383
384 println!("{} Looking for conflicts...", "::".bold().blue());
386 let packages_to_install: Vec<&types::Package> =
387 graph.nodes.values().map(|n| &n.pkg).collect();
388
389 if !options.dry_run {
390 preflight::check_for_conflicts(&packages_to_install, options.yes)?;
391 for pkg in &packages_to_install {
392 if !util::display_updates(pkg, options.yes)? {
393 return Err(anyhow!("Installation aborted by user."));
394 }
395 }
396 preflight::check_policy_compliance(&graph)?;
397 preflight::check_scope_compliance(&graph)?;
398 preflight::check_zoios_compliance(&graph)?;
399 preflight::check_for_vulnerabilities(&graph, options.yes)?;
400
401 let m_for_conflict_check = MultiProgress::new();
402 preflight::check_file_conflicts(
403 &graph,
404 options.yes,
405 &m_for_conflict_check
406 )?;
407 let _ = m_for_conflict_check.clear();
408 }
409
410 println!("{} Checking available disk space...", "::".bold().blue());
411 let install_plan = plan::create_install_plan(
412 &graph.nodes,
413 options.build_type,
414 options.build
415 )?;
416
417 let mut total_download_size: u64 = 0;
418 let mut total_installed_size: u64 = 0;
419 let mut unique_downloads = HashSet::new();
420
421 for (id, node) in &graph.nodes {
422 match install_plan.get(id) {
423 Some(plan::InstallAction::DownloadAndInstall(details)) => {
424 if unique_downloads.insert(details.info.final_url.clone()) {
425 total_download_size += details.download_size;
426 }
427 total_installed_size += if details.installed_size > 0 {
428 details.installed_size
429 } else {
430 node.pkg.installed_size.unwrap_or(0)
431 };
432 }
433 Some(plan::InstallAction::BuildAndInstall) => {
434 total_installed_size +=
435 node.pkg.installed_size.unwrap_or(0);
436 }
437 _ => {}
438 }
439 }
440
441 if options.plan_json {
442 let mut packages = Vec::new();
443 for (id, node) in &graph.nodes {
444 let action_name = match install_plan.get(id) {
445 Some(plan::InstallAction::DownloadAndInstall(_)) => {
446 "download"
447 }
448 Some(plan::InstallAction::InstallFromArchive(_)) => {
449 "archive"
450 }
451 Some(plan::InstallAction::BuildAndInstall) => "build",
452 None => "unknown"
453 };
454 let reason = match &node.reason {
455 types::InstallReason::Direct => "direct".to_string(),
456 types::InstallReason::Dependency { parent } => {
457 format!("dependency:{parent}")
458 }
459 };
460 packages.push(json!({
461 "id": id,
462 "name": node.pkg.name,
463 "version": node.version,
464 "revision": node.revision,
465 "sub_package": node.sub_package,
466 "repo": node.pkg.repo,
467 "registry": node.registry_handle,
468 "reason": reason,
469 "action": action_name,
470 "source": node.source,
471 }));
472 }
473
474 let plan_data = json!({
475 "dry_run": options.dry_run,
476 "frozen": options.frozen,
477 "retry_attempts": options.retry,
478 "scope": format!("{:?}", scope_override.unwrap_or(types::Scope::User)),
479 "totals": {
480 "direct_packages": direct_packages.len(),
481 "dependencies": dependencies.len() + non_zoi_deps.len(),
482 "download_bytes": total_download_size,
483 "installed_bytes": total_installed_size,
484 "skipped_existing": skipped_existing_count,
485 },
486 "packages": packages,
487 "non_zoi_dependencies": non_zoi_deps,
488 });
489 println!("{}", serde_json::to_string_pretty(&plan_data)?);
490 }
491
492 if options.dry_run {
493 println!(
494 "\n{} Dry-run: installation plan above would be executed.",
495 "::".bold().yellow()
496 );
497 return Ok(());
498 }
499
500 let install_path =
502 local::get_store_base_dir(scope_override.unwrap_or_default())?;
503 std::fs::create_dir_all(&install_path)?;
504
505 let available_space =
506 fs2::available_space(&install_path).unwrap_or(u64::MAX);
507
508 if total_installed_size > available_space {
509 return Err(anyhow!(
510 "Not enough disk space. Required: {total_installed_size}, \
511 Available: {available_space}"
512 ));
513 }
514
515 let yes = options.yes;
516 if !zoi_core::utils::ask_for_confirmation(
517 "\nProceed with installation?",
518 yes
519 ) {
520 let _ = lock::release_lock();
521 return Ok(());
522 }
523
524 let stages = graph.toposort()?;
525 let transaction = Mutex::new(transaction::begin()?);
526 let transaction_id = transaction
527 .lock()
528 .expect("Transaction mutex poisoned")
529 .id
530 .clone();
531 let dependency_installed_count = AtomicUsize::new(0);
532
533 println!("\n{} Preparing packages...", "::".bold().blue());
534 let m_prep = MultiProgress::new();
535 let prepared_nodes = Mutex::new(HashMap::new());
536
537 let build_type = options.build_type;
538 let verbose = options.verbose;
539
540 stages
541 .par_iter()
542 .flatten()
543 .try_for_each(|pkg_id| -> Result<()> {
544 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
545 anyhow!(
546 "Package node '{pkg_id}' missing from graph during \
547 preparation"
548 )
549 })?;
550 let action = install_plan.get(pkg_id).ok_or_else(|| {
551 anyhow!(
552 "Install action missing for package '{pkg_id}' during \
553 preparation"
554 )
555 })?;
556
557 let prepared = installer::prepare_node(
558 node,
559 action,
560 Some(&m_prep),
561 build_type,
562 verbose
563 )?;
564
565 let mut lock = prepared_nodes.lock().map_err(|e| {
566 anyhow!(
567 "Prepared nodes mutex poisoned during preparation: {e}"
568 )
569 })?;
570 lock.insert(pkg_id.clone(), prepared);
571 Ok(())
572 })?;
573
574 if !dependencies.is_empty() || !non_zoi_deps.is_empty() {
575 println!("\n{} Installing dependencies...", "::".bold().blue());
576 let m_deps = MultiProgress::new();
577
578 for stage in &stages {
579 stage.par_iter().try_for_each(|pkg_id| -> Result<()> {
580 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
581 anyhow!(
582 "Package node '{pkg_id}' missing from graph \
583 during installation"
584 )
585 })?;
586 if matches!(node.reason, types::InstallReason::Direct) {
587 return Ok(());
588 }
589
590 let prepared = {
591 let lock = prepared_nodes.lock().map_err(|e| {
592 anyhow!(
593 "Prepared nodes mutex poisoned during \
594 dependency install: {e}"
595 )
596 })?;
597 lock.get(pkg_id).cloned().ok_or_else(|| {
598 anyhow!("Prepared node missing for: {pkg_id}")
599 })?
600 };
601
602 match installer::install_prepared_node(
603 node,
604 &prepared,
605 Some(&m_deps),
606 yes,
607 true,
608 true,
609 verbose
610 ) {
611 Ok(manifest) => {
612 dependency_installed_count
613 .fetch_add(1, Ordering::Relaxed);
614 let mut tx_lock =
615 transaction.lock().map_err(|e| {
616 anyhow!(
617 "Transaction mutex poisoned during \
618 installation: {e}"
619 )
620 })?;
621 if let Err(e) = transaction::record_operation(
622 &mut tx_lock,
623 types::TransactionOperation::Install {
624 manifest: Box::new(manifest)
625 }
626 ) {
627 return Err(anyhow!(
628 "Transaction recording failed: {e}"
629 ));
630 }
631 }
632 Err(e) => {
633 failed_packages
634 .lock()
635 .expect("Failed packages mutex poisoned")
636 .push(node.pkg.name.clone());
637 eprintln!(
638 "Error installing {}: {}",
639 node.pkg.name, e
640 );
641 }
642 }
643 Ok(())
644 })?;
645 }
646 }
647
648 println!("\n{} Installing packages...", "::".bold().blue());
649 let m_pkg = MultiProgress::new();
650
651 for stage in &stages {
652 let mut stage_direct_ids = Vec::new();
653 for pkg_id in stage {
654 if let Some(node) = graph.nodes.get(pkg_id)
655 && matches!(node.reason, types::InstallReason::Direct)
656 {
657 let name = if let Some(sub) = &node.sub_package {
658 format!("{}:{}", node.pkg.name, sub)
659 } else {
660 node.pkg.name.clone()
661 };
662 let version_display = if node.revision == "1" {
663 node.version.clone()
664 } else {
665 format!("{}-{}", node.version, node.revision)
666 };
667 println!("@{name}:{version_display}");
668 stage_direct_ids.push(pkg_id.clone());
669 }
670 }
671
672 if stage_direct_ids.is_empty() {
673 continue;
674 }
675
676 let res = stage_direct_ids.par_iter().try_for_each(
677 |pkg_id| -> Result<()> {
678 let node = graph.nodes.get(pkg_id).ok_or_else(|| {
679 anyhow!(
680 "Package node '{pkg_id}' missing from graph \
681 during final installation"
682 )
683 })?;
684
685 let prepared = {
686 let lock = prepared_nodes.lock().map_err(|e| {
687 anyhow!(
688 "Prepared nodes mutex poisoned during package \
689 install: {e}"
690 )
691 })?;
692 lock.get(pkg_id).cloned().ok_or_else(|| {
693 anyhow!("Prepared node missing for: {pkg_id}")
694 })?
695 };
696
697 match installer::install_prepared_node(
698 node,
699 &prepared,
700 Some(&m_pkg),
701 yes,
702 true,
703 true,
704 verbose
705 ) {
706 Ok(manifest) => {
707 installed_manifests
708 .lock()
709 .expect("Installed manifests mutex poisoned")
710 .push(manifest.clone());
711 let mut tx_lock =
712 transaction.lock().map_err(|e| {
713 anyhow!(
714 "Transaction mutex poisoned during \
715 direct package installation: {e}"
716 )
717 })?;
718 transaction::record_operation(
719 &mut tx_lock,
720 types::TransactionOperation::Install {
721 manifest: Box::new(manifest)
722 }
723 )?;
724 successfully_installed_sources
725 .lock()
726 .expect(
727 "Successfully installed sources mutex \
728 poisoned"
729 )
730 .push(node.source.clone());
731 Ok(())
732 }
733 Err(e) => {
734 failed_packages
735 .lock()
736 .expect("Failed packages mutex poisoned")
737 .push(node.pkg.name.clone());
738 eprintln!(
739 "Error installing {}: {}",
740 node.pkg.name, e
741 );
742 Err(e)
743 }
744 }
745 }
746 );
747
748 if res.is_err() {
749 break;
750 }
751 }
752
753 let failed = failed_packages
754 .lock()
755 .expect("Failed packages mutex poisoned");
756 if !failed.is_empty() {
757 println!("\n{} Rolling back changes...", "::".bold().yellow());
758 transaction::rollback(&transaction_id)?;
759 return Err(anyhow!(
760 "Installation failed for: {}",
761 failed.join(", ")
762 ));
763 }
764
765 if let Err(e) = transaction::commit(&transaction_id) {
766 eprintln!("Warning: Failed to commit transaction: {e}");
767 }
768
769 let installed_manifests_vec = installed_manifests
770 .lock()
771 .expect("Installed manifests mutex poisoned")
772 .clone();
773 for manifest in &installed_manifests_vec {
774 if let Some(pm) = options.plugin_manager {
775 let pkg_val = pm
776 .lua
777 .to_value(manifest)
778 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
779 pm.trigger_hook_nonfatal("on_post_install", Some(&pkg_val));
780 }
781 }
782
783 println!("\n{} Installation complete!", "Success:".green().bold());
784 Ok(())
785 }
786}