zoi/cmd/
install.rs

1use crate::pkg::{self, config, install, local, lock, transaction, types};
2use crate::project;
3use anyhow::{Result, anyhow};
4use colored::Colorize;
5use indicatif::MultiProgress;
6use rayon::prelude::*;
7use std::collections::{HashMap, HashSet};
8use std::sync::Mutex;
9
10pub fn run(
11    sources: &[String],
12    repo: Option<String>,
13    force: bool,
14    all_optional: bool,
15    yes: bool,
16    scope: Option<crate::cli::InstallScope>,
17    local: bool,
18    global: bool,
19    save: bool,
20    build_type: Option<String>,
21) -> Result<()> {
22    let mut scope_override = scope.map(|s| match s {
23        crate::cli::InstallScope::User => types::Scope::User,
24        crate::cli::InstallScope::System => types::Scope::System,
25        crate::cli::InstallScope::Project => types::Scope::Project,
26    });
27
28    if local {
29        scope_override = Some(types::Scope::Project);
30    } else if global {
31        scope_override = Some(types::Scope::User);
32    }
33
34    let lockfile_exists = sources.is_empty()
35        && repo.is_none()
36        && std::path::Path::new("zoi.lock").exists()
37        && std::path::Path::new("zoi.yaml").exists();
38
39    let mut sources_to_process: Vec<String> = sources.to_vec();
40    let mut is_project_install = false;
41    if sources.is_empty()
42        && repo.is_none()
43        && let Ok(config) = project::config::load()
44        && config.config.local
45    {
46        if lockfile_exists {
47            println!("zoi.lock found. Installing from zoi.yaml then verifying...");
48        } else {
49            println!("Installing project packages from zoi.yaml...");
50        }
51        sources_to_process = config.pkgs.clone();
52        scope_override = Some(types::Scope::Project);
53        is_project_install = true;
54    }
55
56    if sources_to_process.is_empty() {
57        return Ok(());
58    }
59
60    if let Some(repo_spec) = repo {
61        if scope_override == Some(types::Scope::Project) {
62            return Err(anyhow!(
63                "Installing from a repository to a project scope is not supported."
64            ));
65        }
66        let repo_install_scope = scope_override.map(|s| match s {
67            types::Scope::User => crate::cli::SetupScope::User,
68            types::Scope::System => crate::cli::SetupScope::System,
69            types::Scope::Project => unreachable!(),
70        });
71
72        crate::pkg::repo_install::run(&repo_spec, force, all_optional, yes, repo_install_scope)?;
73        return Ok(());
74    }
75
76    let config = config::read_config().unwrap_or_default();
77    let parallel_jobs = config.parallel_jobs.unwrap_or(3);
78    if parallel_jobs > 0 {
79        rayon::ThreadPoolBuilder::new()
80            .num_threads(parallel_jobs)
81            .build_global()
82            .unwrap();
83    }
84
85    let failed_packages = Mutex::new(Vec::new());
86    let mut temp_files = Vec::new();
87    let mut final_sources = Vec::new();
88
89    for source in &sources_to_process {
90        if source.ends_with("zoi.pkgs.json") {
91            install::lockfile::process_lockfile(source, &mut final_sources, &mut temp_files)?;
92        } else {
93            final_sources.push(source.to_string());
94        }
95    }
96
97    let successfully_installed_sources = Mutex::new(Vec::new());
98    let installed_manifests = Mutex::new(Vec::new());
99
100    println!("{}", "Resolving dependencies...".bold());
101
102    let (graph, non_zoi_deps) = install::resolver::resolve_dependency_graph(
103        &final_sources,
104        scope_override,
105        force,
106        yes,
107        all_optional,
108        build_type.as_deref(),
109        true,
110    )?;
111
112    let packages_to_install: Vec<&types::Package> = graph.nodes.values().map(|n| &n.pkg).collect();
113
114    let mut packages_to_replace = std::collections::HashSet::new();
115    if let Ok(installed_packages) = local::get_installed_packages() {
116        for pkg in &packages_to_install {
117            if let Some(replaces) = &pkg.replaces {
118                for replaced_pkg_name in replaces {
119                    if installed_packages
120                        .iter()
121                        .any(|p| &p.name == replaced_pkg_name)
122                    {
123                        packages_to_replace.insert(replaced_pkg_name.clone());
124                    }
125                }
126            }
127        }
128    }
129
130    if !packages_to_replace.is_empty() {
131        println!("\nThe following packages will be replaced:");
132        for pkg_name in &packages_to_replace {
133            println!("- {}", pkg_name);
134        }
135        if !crate::utils::ask_for_confirmation(
136            "\nDo you want to continue with the replacement?",
137            yes,
138        ) {
139            return Ok(());
140        }
141    }
142
143    println!("{}", "Looking for conflicts...".bold());
144    install::util::check_for_conflicts(&packages_to_install, yes)?;
145
146    let m_for_conflict_check = MultiProgress::new();
147    install::util::check_file_conflicts(&graph, yes, &m_for_conflict_check)?;
148    let _ = m_for_conflict_check.clear();
149
150    let install_plan = install::plan::create_install_plan(&graph.nodes)?;
151
152    let mut to_download = HashMap::new();
153    let mut to_build = HashMap::new();
154
155    for (id, node) in &graph.nodes {
156        match install_plan.get(id) {
157            Some(install::plan::InstallAction::DownloadAndInstall(details)) => {
158                to_download.insert(id.clone(), (node, details.clone()));
159            }
160            Some(install::plan::InstallAction::BuildAndInstall) => {
161                to_build.insert(id.clone(), node);
162            }
163            _ => {}
164        }
165    }
166
167    let total_download_size: u64 = to_download.values().map(|(_, d)| d.download_size).sum();
168    let total_installed_size: u64 = to_download
169        .values()
170        .map(|(n, _)| n.pkg.installed_size.unwrap_or(0))
171        .sum();
172
173    println!("\n--- Summary ---");
174
175    if !to_download.is_empty() {
176        println!("\nPackages to download:");
177        let pkg_list: Vec<_> = to_download
178            .values()
179            .map(|(n, _)| {
180                if let Some(sub) = &n.sub_package {
181                    format!("{}:{}@{}", n.pkg.name, sub, n.version)
182                } else {
183                    format!("{}@{}", n.pkg.name, n.version)
184                }
185            })
186            .collect();
187        println!("{}", pkg_list.join(" "));
188        println!(
189            "Total Download Size:  {}",
190            crate::utils::format_bytes(total_download_size)
191        );
192        println!(
193            "Total Installed Size: {}",
194            crate::utils::format_bytes(total_installed_size)
195        );
196    }
197
198    if !to_build.is_empty() {
199        println!("Packages to build from source:");
200        let pkg_list: Vec<_> = to_build
201            .values()
202            .map(|n| {
203                if let Some(sub) = &n.sub_package {
204                    format!("{}:{}@{}", n.pkg.name, sub, n.version)
205                } else {
206                    format!("{}@{}", n.pkg.name, n.version)
207                }
208            })
209            .collect();
210        println!("{}", pkg_list.join(" "));
211    }
212
213    if !non_zoi_deps.is_empty() {
214        println!("\nExternal dependencies:");
215        let pkg_list: Vec<_> = non_zoi_deps.iter().map(|d| d.cyan().to_string()).collect();
216        println!("{}", pkg_list.join(" "));
217    }
218
219    if !to_build.is_empty() {
220    } else {
221        println!("\n{}", "Checking available disk space...".bold());
222        let install_path =
223            crate::pkg::local::get_store_base_dir(scope_override.unwrap_or_default())?;
224
225        std::fs::create_dir_all(&install_path)?;
226
227        let available_space = match fs2::available_space(&install_path) {
228            Ok(space) => space,
229            Err(e) => {
230                eprintln!(
231                    "{}: Could not check available disk space: {}",
232                    "Warning".yellow().bold(),
233                    e
234                );
235                u64::MAX
236            }
237        };
238
239        if total_installed_size > available_space {
240            return Err(anyhow!(
241                "Not enough disk space. Required: {}, Available: {}",
242                crate::utils::format_bytes(total_installed_size),
243                crate::utils::format_bytes(available_space)
244            ));
245        }
246    }
247
248    if !crate::utils::ask_for_confirmation("\n:: Proceed with installation?", yes) {
249        let _ = lock::release_lock();
250        return Ok(());
251    }
252
253    let mut final_install_plan = install_plan.clone();
254
255    if !to_download.is_empty() {
256        println!("\n:: Downloading packages...");
257        let mut download_groups: HashMap<String, (&install::plan::PrebuiltDetails, Vec<&str>)> =
258            HashMap::new();
259
260        for (node, details) in to_download.values() {
261            let entry = download_groups
262                .entry(details.info.final_url.clone())
263                .or_insert((details, Vec::new()));
264            if let Some(sub) = &node.sub_package {
265                entry.1.push(sub);
266            }
267        }
268
269        let downloaded_archives: Mutex<HashMap<String, std::path::PathBuf>> =
270            Mutex::new(HashMap::new());
271        let m_for_dl = MultiProgress::new();
272
273        download_groups.par_iter().for_each(|(url, (details, _))| {
274            let first_node = to_download
275                .values()
276                .find(|(_, d)| d.info.final_url == *url)
277                .unwrap()
278                .0;
279
280            match install::installer::download_and_cache_archive(
281                first_node,
282                details,
283                Some(&m_for_dl),
284            ) {
285                Ok(path) => {
286                    downloaded_archives
287                        .lock()
288                        .unwrap()
289                        .insert(url.clone(), path);
290                }
291                Err(e) => {
292                    eprintln!("Failed to download {}: {}", url, e);
293                    failed_packages.lock().unwrap().push(url.clone());
294                }
295            }
296        });
297
298        let downloaded_archives_map = downloaded_archives.lock().unwrap();
299        for (id, (_, details)) in &to_download {
300            if let Some(downloaded_path) = downloaded_archives_map.get(&details.info.final_url) {
301                final_install_plan.insert(
302                    id.clone(),
303                    install::plan::InstallAction::InstallFromArchive(downloaded_path.clone()),
304                );
305            }
306        }
307    }
308
309    let stages = graph.toposort()?;
310
311    let transaction = transaction::begin()?;
312
313    for pkg_name in packages_to_replace {
314        println!("Replacing package: {}", pkg_name);
315        match pkg::uninstall::run(&pkg_name, None) {
316            Ok(uninstalled_manifest) => {
317                if let Err(e) = transaction::record_operation(
318                    &transaction.id,
319                    types::TransactionOperation::Uninstall {
320                        manifest: Box::new(uninstalled_manifest),
321                    },
322                ) {
323                    eprintln!("Failed to record uninstall of replaced package: {}", e);
324                }
325            }
326            Err(e) => {
327                eprintln!("Failed to uninstall replaced package '{}': {}", pkg_name, e);
328            }
329        }
330    }
331
332    println!("\n:: Starting installation...");
333    let mut overall_success = true;
334    let m = MultiProgress::new();
335
336    for (i, stage) in stages.iter().enumerate() {
337        println!(
338            ":: Installing Stage {}/{} ({} packages)",
339            i + 1,
340            stages.len(),
341            stage.len()
342        );
343
344        stage.par_iter().for_each(|pkg_id| {
345            let node = graph.nodes.get(pkg_id).unwrap();
346            let action = final_install_plan.get(pkg_id).unwrap();
347
348            match install::installer::install_node(
349                node,
350                action,
351                Some(&m),
352                build_type.as_deref(),
353                yes,
354            ) {
355                Ok(manifest) => {
356                    println!("Successfully installed {}", node.pkg.name.green());
357                    installed_manifests.lock().unwrap().push(manifest.clone());
358
359                    if let Err(e) = transaction::record_operation(
360                        &transaction.id,
361                        types::TransactionOperation::Install {
362                            manifest: Box::new(manifest),
363                        },
364                    ) {
365                        eprintln!(
366                            "Error: Failed to record transaction operation for {}: {}",
367                            node.pkg.name, e
368                        );
369                        failed_packages.lock().unwrap().push(node.pkg.name.clone());
370                    }
371
372                    if matches!(node.reason, types::InstallReason::Direct) {
373                        successfully_installed_sources
374                            .lock()
375                            .unwrap()
376                            .push(node.source.clone());
377                    }
378                }
379                Err(e) => {
380                    eprintln!(
381                        "{}: Failed to install {}: {}",
382                        "Error".red().bold(),
383                        node.pkg.name,
384                        e
385                    );
386                    failed_packages.lock().unwrap().push(node.pkg.name.clone());
387                }
388            }
389        });
390
391        let failed = failed_packages.lock().unwrap();
392        if !failed.is_empty() {
393            eprintln!(
394                "\n{}: Installation failed at stage {}.",
395                "Error".red().bold(),
396                i + 1
397            );
398            overall_success = false;
399            break;
400        }
401    }
402
403    if !overall_success {
404        let failed_list = failed_packages.into_inner().unwrap();
405        eprintln!(
406            "\n{}: The following packages failed to install:",
407            "Error".red().bold()
408        );
409        for pkg in &failed_list {
410            eprintln!("  - {}", pkg);
411        }
412
413        eprintln!("\n{} Rolling back changes...", "---".yellow().bold());
414        if let Err(e) = transaction::rollback(&transaction.id) {
415            eprintln!("\nCRITICAL: Rollback failed: {}", e);
416            eprintln!(
417                "The system may be in an inconsistent state. The transaction log is at ~/.zoi/transactions/{}.json",
418                transaction.id
419            );
420        } else {
421            println!("\n{} Rollback successful.", "Success:".green().bold());
422        }
423
424        return Err(anyhow!(
425            "Installation failed for: {}",
426            failed_list.join(", ")
427        ));
428    }
429
430    if let Err(e) = transaction::commit(&transaction.id) {
431        eprintln!("Warning: Failed to commit transaction: {}", e);
432    }
433
434    if !non_zoi_deps.is_empty() {
435        println!("\n:: Installing external dependencies...");
436        let processed_deps = Mutex::new(HashSet::new());
437        let mut installed_deps_ext = Vec::new();
438        for dep_str in &non_zoi_deps {
439            let dep = match crate::pkg::dependencies::parse_dependency_string(dep_str) {
440                Ok(d) => d,
441                Err(e) => {
442                    eprintln!(
443                        "{}: Could not parse dependency string '{}': {}",
444                        "Error".red().bold(),
445                        dep_str,
446                        e
447                    );
448                    continue;
449                }
450            };
451
452            if let Err(e) = crate::pkg::dependencies::install_dependency(
453                &dep,
454                "direct",
455                scope_override.unwrap_or_default(),
456                yes,
457                all_optional,
458                &processed_deps,
459                &mut installed_deps_ext,
460                Some(&m),
461            ) {
462                eprintln!(
463                    "{}: Failed to install external dependency {}: {}",
464                    "Error".red().bold(),
465                    dep_str,
466                    e
467                );
468            }
469        }
470    }
471
472    let is_any_project_install = scope_override == Some(types::Scope::Project);
473
474    if is_any_project_install {
475        if is_project_install && lockfile_exists {
476        } else {
477            println!("\nUpdating zoi.lock...");
478            let mut lockfile =
479                project::lockfile::read_zoi_lock().unwrap_or_else(|_| types::ZoiLock {
480                    version: "1".to_string(),
481                    ..Default::default()
482                });
483
484            lockfile.packages.clear();
485            lockfile.details.clear();
486
487            let all_regs_config = crate::pkg::config::read_config().unwrap_or_default();
488            let mut all_configured_regs = all_regs_config.added_registries;
489            if let Some(default_reg) = all_regs_config.default_registry {
490                all_configured_regs.push(default_reg);
491            }
492
493            let installed_manifests = installed_manifests.into_inner().unwrap();
494            for manifest in &installed_manifests {
495                let name_with_sub = if let Some(sub) = &manifest.sub_package {
496                    format!("{}:{}", manifest.name, sub)
497                } else {
498                    manifest.name.clone()
499                };
500
501                let full_id = format!(
502                    "#{}@{}/{}",
503                    manifest.registry_handle, manifest.repo, name_with_sub
504                );
505                lockfile.packages.insert(full_id, manifest.version.clone());
506
507                if let Some(reg) = all_configured_regs
508                    .iter()
509                    .find(|r| r.handle == manifest.registry_handle)
510                {
511                    lockfile
512                        .registries
513                        .insert(reg.handle.clone(), reg.url.clone());
514                }
515
516                let package_dir = crate::pkg::local::get_package_dir(
517                    types::Scope::Project,
518                    &manifest.registry_handle,
519                    &manifest.repo,
520                    &manifest.name,
521                )?;
522                let latest_dir = package_dir.join("latest");
523                let integrity =
524                    crate::pkg::hash::calculate_dir_hash(&latest_dir).unwrap_or_else(|e| {
525                        eprintln!(
526                            "Warning: could not calculate integrity for {}: {}",
527                            manifest.name, e
528                        );
529                        String::new()
530                    });
531
532                let pkg_id = if let Some(sub) = &manifest.sub_package {
533                    format!("{}@{}:{}", manifest.name, manifest.version, sub)
534                } else {
535                    format!("{}@{}", manifest.name, manifest.version)
536                };
537
538                let dependencies: Vec<String> = graph
539                    .adj
540                    .get(&pkg_id)
541                    .map(|deps| {
542                        deps.iter()
543                            .map(|dep_id| {
544                                let node = graph.nodes.get(dep_id).unwrap();
545                                if let Some(sub) = &node.pkg.sub_packages {
546                                    format!(
547                                        "#{}@{}/{}:{}",
548                                        node.registry_handle,
549                                        node.pkg.repo,
550                                        node.pkg.name,
551                                        sub.join(",")
552                                    )
553                                } else {
554                                    format!(
555                                        "#{}@{}/{}",
556                                        node.registry_handle, node.pkg.repo, node.pkg.name
557                                    )
558                                }
559                            })
560                            .collect()
561                    })
562                    .unwrap_or_default();
563
564                let detail = types::LockPackageDetail {
565                    version: manifest.version.clone(),
566                    sub_package: manifest.sub_package.clone(),
567                    integrity,
568                    dependencies,
569                    options_dependencies: manifest.chosen_options.clone(),
570                    optionals_dependencies: manifest.chosen_optionals.clone(),
571                };
572
573                let registry_key = format!("#{}", manifest.registry_handle);
574                let short_id = format!("@{}/{}", manifest.repo, name_with_sub);
575
576                lockfile
577                    .details
578                    .entry(registry_key)
579                    .or_default()
580                    .insert(short_id, detail);
581            }
582
583            if let Err(e) = project::lockfile::write_zoi_lock(&lockfile) {
584                eprintln!("Warning: Failed to write zoi.lock file: {}", e);
585            }
586        }
587    }
588
589    if save && scope_override == Some(types::Scope::Project) {
590        let successfully_installed = successfully_installed_sources.into_inner().unwrap();
591        if !successfully_installed.is_empty()
592            && let Err(e) = project::config::add_packages_to_config(&successfully_installed)
593        {
594            eprintln!(
595                "{}: Failed to save packages to zoi.yaml: {}",
596                "Warning".yellow().bold(),
597                e
598            );
599        }
600    }
601
602    println!("\n{} Installation complete!", "Success:".green().bold());
603
604    if is_project_install && lockfile_exists {
605        println!();
606        project::verify::run()?;
607    }
608    Ok(())
609}