Skip to main content

zoi_cli/cmd/install/
orchestrator.rs

1//! High-level installation orchestration.
2
3use 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
22/// Options for the installation orchestrator.
23pub struct InstallOptions<'a> {
24    /// The scope to install the packages into.
25    pub scope: types::Scope,
26    /// Whether to force the installation.
27    pub force: bool,
28    /// Whether to install all optional dependencies.
29    pub all_optional: bool,
30    /// Whether to skip confirmation prompts.
31    pub yes: bool,
32    /// Whether to save the installation to the project file.
33    pub save: bool,
34    /// The build type to use.
35    pub build_type: Option<&'a str>,
36    /// Whether to perform a dry run.
37    pub dry_run: bool,
38    /// The plugin manager to use.
39    pub plugin_manager: Option<&'a PluginManager>,
40    /// Whether to force building from source.
41    pub build: bool,
42    /// Whether to use the lockfile exactly (frozen).
43    pub frozen: bool,
44    /// Whether to explain decisions.
45    pub explain: bool,
46    /// Whether to emit machine-readable plan JSON.
47    pub plan_json: bool,
48    /// Number of download retry attempts.
49    pub retry: u32,
50    /// Whether to show verbose output.
51    pub verbose: bool,
52    /// Whether to use PURL (Package URL) specification.
53    pub purl: bool,
54    /// Optional project configuration override.
55    pub project_config: Option<project::config::ProjectConfig>
56}
57
58/// The installation orchestrator.
59pub struct Orchestrator<'a> {
60    /// The options used for the installation.
61    options: InstallOptions<'a>
62}
63
64impl<'a> Orchestrator<'a> {
65    /// Creates a new orchestrator with the given options.
66    pub fn new(options: InstallOptions<'a>) -> Self {
67        Self { options }
68    }
69
70    /// Runs the installation for the given sources.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if the installation fails at any stage.
75    ///
76    /// # Panics
77    ///
78    /// Panics if any of the internal mutexes (failed packages, prepared nodes,
79    /// etc.) are poisoned.
80    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        // 1. Initial lock and setup
89        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        // --- Phase 2: Dependency Resolution ---
257        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        // --- Phase 3: Safety & Compliance Checks ---
385        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        // --- Phase 4: Transactional Execution ---
501        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}