Skip to main content

zoi_cli/cmd/
update.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::sync::Mutex;
4
5use anyhow::{Result, anyhow};
6use colored::Colorize;
7use dialoguer::MultiSelect;
8use dialoguer::theme::ColorfulTheme;
9use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
10use rayon::prelude::*;
11use semver::Version;
12use serde_json::json;
13
14use crate::cmd::{utils as cmd_utils, ux};
15use crate::pkg::merge::handle_backup_files;
16use crate::pkg::{
17    config, db, hooks, install, local, pin, resolve, transaction, types
18};
19
20/// The primary high-level orchestration for the `zoi update` command.
21///
22/// This function handles:
23/// - Batch Updates: When `--all` is specified, it scans all installed packages.
24/// - Targeted Updates: Updates specific packages provided by name.
25/// - Advisory Deltas: Calculates and displays changes in security
26///   vulnerabilities.
27/// - Cleanup: Automatically removes old versions after a successful upgrade (if
28///   rollbacks are not required).
29///
30/// # Errors
31///
32/// Returns an error if the update process fails for any package.
33pub fn run(
34    all: bool,
35    package_names: &[String],
36    yes: bool,
37    dry_run: bool,
38    explain: bool,
39    plan_json: bool,
40    verbose: bool,
41    interactive: bool
42) -> Result<()> {
43    if all {
44        return run_update_all_logic(
45            yes,
46            dry_run,
47            explain,
48            plan_json,
49            verbose,
50            interactive
51        );
52    }
53
54    if plan_json && !dry_run {
55        return Err(anyhow!("--plan-json requires --dry-run"));
56    }
57
58    let expanded_package_names =
59        cmd_utils::expand_split_packages(package_names, "Updating")?;
60
61    let mut failed_packages = Vec::new();
62
63    for (i, package_name) in expanded_package_names.iter().enumerate() {
64        if i > 0 {
65            println!();
66        }
67        if let Err(e) = run_update_single_logic(
68            package_name,
69            yes,
70            dry_run,
71            explain,
72            plan_json,
73            verbose
74        ) {
75            eprintln!(
76                "{}: Failed to update '{}': {}",
77                "Error".red().bold(),
78                package_name,
79                e
80            );
81            failed_packages.push(package_name.clone());
82        }
83    }
84
85    if !failed_packages.is_empty() {
86        return Err(anyhow!(
87            "The following packages failed to update: {}",
88            failed_packages.join(", ")
89        ));
90    } else if !package_names.is_empty() && !dry_run {
91        println!("\n{}", "Success:".green());
92    }
93    Ok(())
94}
95
96/// Logic for updating a single package.
97fn run_update_single_logic(
98    package_name: &str,
99    yes: bool,
100    dry_run: bool,
101    explain: bool,
102    plan_json: bool,
103    verbose: bool
104) -> Result<()> {
105    if !plan_json {
106        println!("{} Resolving dependencies...", "::".bold().blue());
107    }
108
109    let (new_pkg, new_version, _, _, registry_handle, _, _) =
110        resolve::resolve_package_and_version(package_name, None, true, yes)?;
111
112    if pin::is_pinned(package_name)? {
113        if !plan_json {
114            println!(
115                "Package '{}' is pinned. Skipping update.",
116                package_name.yellow()
117            );
118        }
119        return Ok(());
120    }
121
122    let installed_source = if let Some(sub) = &new_pkg.sub_package {
123        format!(
124            "#{}@{}/{}:{}",
125            registry_handle.as_deref().unwrap_or("local"),
126            new_pkg.repo,
127            new_pkg.name,
128            sub
129        )
130    } else {
131        format!(
132            "#{}@{}/{}",
133            registry_handle.as_deref().unwrap_or("local"),
134            new_pkg.repo,
135            new_pkg.name
136        )
137    };
138    let installed_request = resolve::parse_source_string(&installed_source)?;
139    let mut candidates = Vec::new();
140    candidates.extend(local::find_installed_manifests_matching(
141        &installed_request,
142        types::Scope::User
143    )?);
144    candidates.extend(local::find_installed_manifests_matching(
145        &installed_request,
146        types::Scope::System
147    )?);
148
149    let old_manifest = crate::cmd::installed_select::choose_installed_manifest(
150        package_name,
151        &candidates,
152        yes
153    )
154    .map_err(|e| {
155        if candidates.is_empty() {
156            anyhow!(
157                "Package '{package_name}' is not installed. Use 'zoi install' \
158                 instead."
159            )
160        } else {
161            e
162        }
163    })?;
164
165    if old_manifest.version == new_version
166        && old_manifest.revision == new_pkg.revision
167    {
168        if !plan_json {
169            println!("\nPackage is already up to date.");
170            ux::print_transaction_summary(&ux::TransactionSummary {
171                command: "update".to_string(),
172                success: 0,
173                failed: 0,
174                skipped: 1
175            });
176        }
177        return Ok(());
178    }
179
180    if !plan_json {
181        println!("{} Looking for conflicts...", "::".bold().blue());
182    }
183
184    let (graph, non_zoi_deps) = install::resolver::resolve_dependency_graph(
185        &[package_name.to_string()],
186        Some(old_manifest.scope),
187        true,
188        yes,
189        false,
190        None,
191        true,
192        None
193    )?;
194
195    if !plan_json {
196        println!("{} Checking available disk space...", "::".bold().blue());
197    }
198
199    if !dry_run {
200        let pkgs_to_check: Vec<&types::Package> =
201            graph.nodes.values().map(|n| &n.pkg).collect();
202        install::preflight::check_for_conflicts(&pkgs_to_check, yes)?;
203        for pkg in &pkgs_to_check {
204            if !install::util::display_updates(pkg, yes)? {
205                return Err(anyhow!("Update aborted by user."));
206            }
207        }
208        install::preflight::check_policy_compliance(&graph)?;
209        install::preflight::check_scope_compliance(&graph)?;
210        install::preflight::check_zoios_compliance(&graph)?;
211        install::preflight::check_for_vulnerabilities(&graph, yes)?;
212
213        let m_for_conflict_check = MultiProgress::new();
214        install::preflight::check_file_conflicts(
215            &graph,
216            yes,
217            &m_for_conflict_check
218        )?;
219        let _ = m_for_conflict_check.clear();
220    }
221
222    let install_plan =
223        install::plan::create_install_plan(&graph.nodes, None, false)?;
224
225    let mut total_download_size: u64 = 0;
226    let mut total_installed_size_diff: i64 = 0;
227    let mut packages_to_upgrade = Vec::new();
228    let config = config::read_config().unwrap_or_default();
229
230    for (pkg_id, node) in &graph.nodes {
231        let is_requested = node.pkg.name == new_pkg.name
232            && node.sub_package == old_manifest.sub_package;
233
234        let request_source = local::package_source_string(
235            &node.registry_handle,
236            &node.pkg.repo,
237            &node.pkg.name,
238            node.sub_package.as_deref(),
239            &node.version
240        );
241        let request = resolve::parse_source_string(&request_source)?;
242        let installed = local::find_installed_manifests_matching(
243            &request,
244            old_manifest.scope
245        )?;
246
247        let (current_version_display, needs_update) = if installed.is_empty() {
248            if is_requested {
249                let display = if old_manifest.revision == "1" {
250                    old_manifest.version.clone()
251                } else {
252                    format!(
253                        "{}-{}",
254                        old_manifest.version, old_manifest.revision
255                    )
256                };
257                (Some(display), true)
258            } else {
259                (None, true)
260            }
261        } else {
262            let m = installed
263                .first()
264                .ok_or_else(|| anyhow!("installed list unexpectedly empty"))?;
265            let display = if m.revision == "1" {
266                m.version.clone()
267            } else {
268                format!("{}-{}", m.version, m.revision)
269            };
270            (
271                Some(display),
272                m.version != node.version || m.revision != node.revision
273            )
274        };
275
276        if needs_update {
277            let (down_size, inst_size) = install::util::get_package_sizes(
278                &node.pkg,
279                &node.registry_handle,
280                &node.version
281            );
282            total_download_size += down_size;
283
284            let old_size = installed
285                .first()
286                .and_then(|m| m.installed_size)
287                .unwrap_or(0);
288            total_installed_size_diff += inst_size as i64 - old_size as i64;
289
290            let display_name = ux::format_display_name(
291                &node.registry_handle,
292                &node.pkg.repo,
293                &node.pkg.name,
294                node.sub_package.as_deref(),
295                &config
296            );
297
298            let new_version_display = if node.revision == "1" {
299                node.version.clone()
300            } else {
301                format!("{}-{}", node.version, node.revision)
302            };
303
304            packages_to_upgrade.push((
305                display_name,
306                current_version_display,
307                new_version_display,
308                pkg_id.clone(),
309                !is_requested
310            ));
311        }
312    }
313
314    if !plan_json {
315        println!(
316            "\n{} Packages ({})\n",
317            "::".bold().blue(),
318            packages_to_upgrade.len()
319        );
320    }
321
322    for (name, old_ver, new_ver, pkg_id, is_dep) in &packages_to_upgrade {
323        let transition = if let Some(old) = old_ver {
324            format!(
325                "{} -> {}",
326                format!("{name}@{old}").cyan(),
327                format!("{name}@{new_ver}").cyan()
328            )
329        } else {
330            format!("{} (new dependency)", format!("{name}@{new_ver}").cyan())
331        };
332        if !plan_json {
333            println!("  {transition}");
334        }
335
336        if *is_dep {
337            let node = graph.nodes.get(pkg_id).ok_or_else(|| {
338                anyhow!("Package node '{pkg_id}' missing from graph")
339            })?;
340            let pkg_dir = local::get_package_dir(
341                old_manifest.scope,
342                &node.registry_handle,
343                &node.pkg.repo,
344                &node.pkg.name
345            )?;
346            if let Ok(dependents) = local::get_dependents(&pkg_dir) {
347                let external_dependents: Vec<_> = dependents
348                    .into_iter()
349                    .filter(|dep_id| !graph.nodes.contains_key(dep_id))
350                    .collect();
351
352                if !external_dependents.is_empty() && !plan_json {
353                    println!(
354                        "   {} Updating {} may affect: {}",
355                        "Warning:".bold().yellow(),
356                        node.pkg.name.cyan(),
357                        external_dependents.join(", ").dimmed()
358                    );
359                }
360            }
361        }
362    }
363
364    let down_str = zoi_core::utils::format_bytes(total_download_size);
365    let net_str = zoi_core::utils::format_size_diff(total_installed_size_diff);
366
367    if !plan_json {
368        println!("\nTotal Download Size: {down_str:>10}");
369        println!("Net Upgrade Size:    {net_str:>10}");
370    }
371
372    if verbose && !plan_json {
373        let preflight = ux::PreflightSummary::new("Update preflight")
374            .row("Candidates", packages_to_upgrade.len().to_string())
375            .row("Scope", format!("{:?}", old_manifest.scope))
376            .row("Download size", &down_str)
377            .row("Net size", &net_str);
378        ux::print_preflight(&preflight);
379    }
380
381    if explain && !plan_json {
382        let mut report = ux::ExplainReport::new("Update explanation");
383        report = report.item(
384            new_pkg.name.clone(),
385            format!(
386                "selected because newer version {} is available over \
387                 installed {}",
388                new_version, old_manifest.version
389            ),
390            Vec::new()
391        );
392        if let Ok((old_adv, new_adv)) = advisory_counts(
393            &old_manifest.registry_handle,
394            &new_pkg.name,
395            old_manifest.sub_package.as_deref(),
396            &old_manifest.version,
397            &new_version
398        ) {
399            report = report.item(
400                "advisories",
401                format!(
402                    "old={}, new={}, delta={}",
403                    old_adv,
404                    new_adv,
405                    (new_adv as i64 - old_adv as i64)
406                ),
407                Vec::new()
408            );
409        }
410        ux::print_explain(&report);
411    }
412
413    if plan_json {
414        let plan = json!({
415            "dry_run": dry_run,
416            "package": {
417                "name": new_pkg.name,
418                "sub_package": old_manifest.sub_package,
419                "registry": old_manifest.registry_handle,
420                "repo": old_manifest.repo,
421                "scope": format!("{:?}", old_manifest.scope),
422                "from_version": old_manifest.version,
423                "to_version": new_version,
424                "download_bytes": total_download_size,
425                "net_size_bytes": total_installed_size_diff,
426            }
427        });
428        ux::emit_plan_json_v1("update", plan)?;
429        return Ok(());
430    }
431
432    if dry_run {
433        println!(
434            "\n{} Dry-run: update plan above would be executed.",
435            "::".bold().yellow()
436        );
437        return Ok(());
438    }
439
440    println!();
441    let prompt = if packages_to_upgrade.len() > 1 {
442        "Do you want to upgrade these packages?".to_string()
443    } else {
444        format!("Update from {} to {}?", old_manifest.version, new_version)
445    };
446
447    if !crate::utils::ask_for_confirmation(&prompt, yes) {
448        ux::print_transaction_summary(&ux::TransactionSummary {
449            command: "update".to_string(),
450            success: 0,
451            failed: 0,
452            skipped: packages_to_upgrade.len()
453        });
454        return Ok(());
455    }
456
457    perform_transaction(
458        &graph,
459        &install_plan,
460        &non_zoi_deps,
461        old_manifest.scope,
462        yes,
463        verbose,
464        &new_pkg.name,
465        &old_manifest,
466        &new_pkg,
467        plan_json
468    )
469}
470
471/// Executes the transactional part of the update process.
472///
473/// This involves creating a transaction, installing new package versions,
474/// recording the operations, and committing the transaction.
475fn perform_transaction(
476    graph: &install::resolver::DependencyGraph,
477    install_plan: &HashMap<String, install::plan::InstallAction>,
478    non_zoi_deps: &[String],
479    scope: types::Scope,
480    yes: bool,
481    verbose: bool,
482    target_pkg_name: &str,
483    old_manifest_ref: &types::InstallManifest,
484    new_pkg_ref: &types::Package,
485    plan_json: bool
486) -> Result<()> {
487    let mut transaction = transaction::begin()?;
488    let stages = graph.toposort()?;
489    let mut new_manifest_option: Option<types::InstallManifest> = None;
490    let m = MultiProgress::new();
491    if plan_json {
492        m.set_draw_target(indicatif::ProgressDrawTarget::hidden());
493    }
494
495    // Pre-install dependencies (non-zoi)
496    if !non_zoi_deps.is_empty() {
497        let processed_deps = Mutex::new(HashSet::new());
498        let mut installed_deps_ext = Vec::new();
499        for dep_str in non_zoi_deps {
500            let dep =
501                crate::pkg::dependencies::parse_dependency_string(dep_str)?;
502            crate::pkg::install::dep_install::install_dependency(
503                &dep,
504                "update",
505                scope,
506                yes,
507                false,
508                &processed_deps,
509                &mut installed_deps_ext,
510                Some(&m)
511            )?;
512        }
513    }
514
515    for stage in stages {
516        for pkg_id in stage {
517            let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
518                anyhow!("Package node '{pkg_id}' missing from graph")
519            })?;
520            if let Some(action) = install_plan.get(&pkg_id) {
521                match install::installer::install_node(
522                    node,
523                    action,
524                    Some(&m),
525                    None,
526                    yes,
527                    true,
528                    true,
529                    verbose
530                ) {
531                    Ok(m) => {
532                        if m.name == target_pkg_name {
533                            new_manifest_option = Some(m);
534                        }
535                    }
536                    Err(e) => {
537                        eprintln!("\nError: Update failed. Rolling back...");
538                        transaction::rollback(&transaction.id)?;
539                        return Err(anyhow!("Update failed: {e}"));
540                    }
541                }
542            }
543        }
544    }
545
546    if let Some(new_manifest) = new_manifest_option {
547        // Record and commit
548        transaction::record_operation(
549            &mut transaction,
550            types::TransactionOperation::Upgrade {
551                old_manifest: Box::new(old_manifest_ref.clone()),
552                new_manifest: Box::new(new_manifest.clone())
553            }
554        )?;
555
556        if let Ok(modified_files) =
557            transaction::get_modified_files(&transaction.id)
558        {
559            let modified_packages =
560                transaction::get_modified_packages(&transaction.id)
561                    .unwrap_or_default();
562            let _ = crate::pkg::hooks::global::run_global_hooks(
563                crate::pkg::hooks::global::HookWhen::PostTransaction,
564                &modified_files,
565                &modified_packages,
566                "upgrade",
567                scope
568            );
569        }
570
571        transaction::commit(&transaction.id)?;
572
573        if let Some(backup_files) = &old_manifest_ref.backup {
574            println!("Restoring configuration files...");
575            let old_version_dir = local::get_package_version_dir(
576                old_manifest_ref.scope,
577                &old_manifest_ref.registry_handle,
578                &old_manifest_ref.repo,
579                &old_manifest_ref.name,
580                &old_manifest_ref.version
581            )?;
582            let new_version_dir = local::get_package_version_dir(
583                new_manifest.scope,
584                &new_manifest.registry_handle,
585                &new_manifest.repo,
586                &new_manifest.name,
587                &new_manifest.version
588            )?;
589
590            crate::pkg::merge::handle_backup_files(
591                &old_version_dir,
592                &new_version_dir,
593                backup_files,
594                old_manifest_ref.scope
595            )?;
596        }
597
598        // Cleanup and finish
599        cleanup_old_versions(
600            &new_manifest.name,
601            scope,
602            &new_manifest.repo,
603            &new_manifest.registry_handle
604        )?;
605
606        if let Ok(conn) = db::open_connection("local") {
607            let _ = db::update_package(
608                &conn,
609                new_pkg_ref,
610                &new_manifest.registry_handle,
611                Some(new_manifest.scope),
612                new_manifest.sub_package.as_deref(),
613                Some(&types::InstallReason::Direct)
614            );
615        }
616
617        if let Some(hooks) = &new_pkg_ref.hooks {
618            crate::pkg::hooks::run_hooks(
619                hooks,
620                crate::pkg::hooks::HookType::PostUpgrade,
621                new_manifest.scope
622            )?;
623        }
624
625        if !plan_json {
626            println!("\n{}", "Success:".green());
627        }
628    }
629
630    Ok(())
631}
632
633/// Logic for updating all installed packages.
634fn run_update_all_logic(
635    yes: bool,
636    dry_run: bool,
637    explain: bool,
638    plan_json: bool,
639    verbose: bool,
640    interactive: bool
641) -> Result<()> {
642    #[derive(Clone)]
643    struct UpdateCandidate {
644        source: String,
645        new_pkg: types::Package,
646        new_version: String,
647        old_manifest: types::InstallManifest,
648        old_advisories: usize,
649        new_advisories: usize,
650        download_size: u64,
651        new_installed_size: u64
652    }
653
654    let installed_packages = local::get_installed_packages()?;
655    let mut pinned_sources = Vec::new();
656    let mut skipped_sources = Vec::new();
657    let mut up_to_date_sources = Vec::new();
658    let mut candidates: Vec<UpdateCandidate> = Vec::new();
659
660    // --- Phase 1: Upgrade Scanning ---
661    // We scan all installed packages and compare them against the latest
662    // registry metadata.
663    if !plan_json {
664        println!("{} Checking for upgrades...", "::".bold().blue());
665    }
666    let pb = ProgressBar::new(installed_packages.len() as u64);
667    if plan_json {
668        pb.set_draw_target(indicatif::ProgressDrawTarget::hidden());
669    }
670    pb.set_style(
671        ProgressStyle::default_bar()
672            .template(
673                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \
674                 {pos}/{len} ({msg})"
675            )?
676            .progress_chars("#>-")
677    );
678    pb.set_message("Checking packages...");
679
680    for manifest in installed_packages {
681        let source = if let Some(sub) = &manifest.sub_package {
682            format!(
683                "#{}@{}/{}:{}",
684                manifest.registry_handle, manifest.repo, manifest.name, sub
685            )
686        } else {
687            format!(
688                "#{}@{}/{}",
689                manifest.registry_handle, manifest.repo, manifest.name
690            )
691        };
692
693        if pin::is_pinned(&source).unwrap_or(false)
694            || pin::is_pinned(&manifest.name).unwrap_or(false)
695        {
696            pinned_sources.push(source);
697            pb.inc(1);
698            continue;
699        }
700
701        let (new_pkg, new_version, _, _, _registry_handle, _, _) =
702            match resolve::resolve_package_and_version(
703                &source,
704                Some(manifest.scope),
705                true,
706                false
707            ) {
708                Ok(result) => result,
709                Err(e) => {
710                    skipped_sources.push(format!("{source} ({e})"));
711                    pb.inc(1);
712                    continue;
713                }
714            };
715
716        if manifest.version == new_version
717            && manifest.revision == new_pkg.revision
718        {
719            up_to_date_sources.push(source);
720            pb.inc(1);
721            continue;
722        }
723
724        let (old_adv, new_adv) = advisory_counts(
725            &manifest.registry_handle,
726            &manifest.name,
727            manifest.sub_package.as_deref(),
728            &manifest.version,
729            &new_version
730        )
731        .unwrap_or((0, 0));
732
733        let (download_size, new_installed_size) =
734            install::util::get_package_sizes(
735                &new_pkg,
736                &manifest.registry_handle,
737                &new_version
738            );
739
740        candidates.push(UpdateCandidate {
741            source,
742            new_pkg,
743            new_version,
744            old_manifest: manifest,
745            old_advisories: old_adv,
746            new_advisories: new_adv,
747            download_size,
748            new_installed_size
749        });
750        pb.inc(1);
751    }
752    pb.finish_and_clear();
753
754    if candidates.is_empty() {
755        if !plan_json {
756            println!("\nAll packages are up to date.");
757        }
758        return Ok(());
759    }
760
761    if interactive && !dry_run {
762        let items: Vec<String> = candidates
763            .iter()
764            .map(|c| {
765                format!(
766                    "{}  {} -> {}",
767                    c.source, c.old_manifest.version, c.new_version
768                )
769            })
770            .collect();
771        let selected = MultiSelect::with_theme(&ColorfulTheme::default())
772            .with_prompt("Select packages to update")
773            .items(&items)
774            .interact()
775            .map_err(|e| anyhow!("Interactive selection failed: {e}"))?;
776
777        if selected.is_empty() {
778            if !plan_json {
779                println!("No packages selected.");
780            }
781            return Ok(());
782        }
783
784        let selected_set: HashSet<usize> = selected.into_iter().collect();
785        candidates = candidates
786            .into_iter()
787            .enumerate()
788            .filter(|(idx, _)| selected_set.contains(idx))
789            .map(|(_, c)| c)
790            .collect();
791    }
792
793    if !plan_json {
794        println!("{} Resolving dependencies...", "::".bold().blue());
795        println!("{} Looking for conflicts...", "::".bold().blue());
796        println!("{} Checking available disk space...", "::".bold().blue());
797
798        if !pinned_sources.is_empty() {
799            println!("\n{} Pinned (skipped)", "::".bold().blue());
800            for s in &pinned_sources {
801                println!("  - {}", s.yellow());
802            }
803        }
804
805        println!("\n{} Packages ({})", "::".bold().blue(), candidates.len());
806    }
807    let config = config::read_config().unwrap_or_default();
808
809    for candidate in &candidates {
810        let delta =
811            candidate.new_advisories as i64 - candidate.old_advisories as i64;
812        let advisory_suffix = match delta.cmp(&0) {
813            std::cmp::Ordering::Greater => {
814                format!(" (advisories +{delta})").red().to_string()
815            }
816            std::cmp::Ordering::Less => {
817                format!(" (advisories {delta})").green().to_string()
818            }
819            std::cmp::Ordering::Equal => String::new()
820        };
821
822        let display_name = ux::format_display_name(
823            &candidate.old_manifest.registry_handle,
824            &candidate.old_manifest.repo,
825            &candidate.old_manifest.name,
826            candidate.old_manifest.sub_package.as_deref(),
827            &config
828        );
829        let old_display = if candidate.old_manifest.revision == "1" {
830            candidate.old_manifest.version.clone()
831        } else {
832            format!(
833                "{}-{}",
834                candidate.old_manifest.version, candidate.old_manifest.revision
835            )
836        };
837        let new_display = if candidate.new_pkg.revision == "1" {
838            candidate.new_version.clone()
839        } else {
840            format!("{}-{}", candidate.new_version, candidate.new_pkg.revision)
841        };
842
843        if !plan_json {
844            println!(
845                "  {} -> {}{}",
846                format!("{display_name}@{old_display}").cyan(),
847                format!("{display_name}@{new_display}").cyan(),
848                advisory_suffix
849            );
850        }
851    }
852
853    let total_download_size: u64 =
854        candidates.iter().map(|c| c.download_size).sum();
855    let total_installed_size_diff: i64 = candidates
856        .iter()
857        .map(|c| {
858            c.new_installed_size as i64
859                - c.old_manifest.installed_size.unwrap_or(0) as i64
860        })
861        .sum();
862
863    let down_str = zoi_core::utils::format_bytes(total_download_size);
864    let net_str = zoi_core::utils::format_size_diff(total_installed_size_diff);
865
866    if !plan_json {
867        println!("\nTotal Download Size: {down_str:>10}");
868        println!("Net Upgrade Size:    {net_str:>10}");
869    }
870
871    if verbose && !plan_json {
872        let preflight = ux::PreflightSummary::new("Update preflight")
873            .row("Candidates", candidates.len().to_string())
874            .row("Download size", &down_str)
875            .row("Net size", &net_str);
876        ux::print_preflight(&preflight);
877    }
878
879    if explain && !plan_json {
880        let mut report = ux::ExplainReport::new("Update explanation");
881        for candidate in &candidates {
882            report = report.item(
883                candidate.source.clone(),
884                format!(
885                    "selected because {} -> {}",
886                    candidate.old_manifest.version, candidate.new_version
887                ),
888                Vec::new()
889            );
890        }
891        ux::print_explain(&report);
892    }
893
894    if plan_json {
895        let packages: Vec<_> = candidates
896            .iter()
897            .map(|c| {
898                json!({
899                    "source": c.source,
900                    "name": c.new_pkg.name,
901                    "sub_package": c.old_manifest.sub_package,
902                    "from_version": c.old_manifest.version,
903                    "to_version": c.new_version,
904                    "download_bytes": c.download_size,
905                    "net_size_bytes": c.new_installed_size as i64 - c.old_manifest.installed_size.unwrap_or(0) as i64,
906                    "advisories_old": c.old_advisories,
907                    "advisories_new": c.new_advisories,
908                })
909            })
910            .collect();
911
912        let plan = json!({
913            "dry_run": dry_run,
914            "interactive": interactive,
915            "totals": {
916                "candidates": candidates.len(),
917                "pinned_skipped": pinned_sources.len(),
918                "other_skipped": skipped_sources.len(),
919                "up_to_date": up_to_date_sources.len(),
920                "download_bytes": total_download_size,
921                "net_size_bytes": total_installed_size_diff,
922            },
923            "pinned": pinned_sources,
924            "skipped": skipped_sources,
925            "packages": packages,
926        });
927        ux::emit_plan_json_v1("update", plan)?;
928        return Ok(());
929    }
930
931    if dry_run {
932        println!(
933            "\n{} Dry-run: upgrade plan above would be executed.",
934            "::".bold().yellow()
935        );
936        return Ok(());
937    }
938
939    if !crate::utils::ask_for_confirmation(
940        "Do you want to upgrade these packages?",
941        yes
942    ) {
943        return Ok(());
944    }
945
946    // --- Phase 2: Transactional Upgrade ---
947    let transaction = Mutex::new(transaction::begin()?);
948    let transaction_id = transaction.lock().expect("mutex poisoned").id.clone();
949    let failed_updates = Mutex::new(Vec::new());
950    let successful_upgrades = Mutex::new(Vec::new());
951
952    let m = MultiProgress::new();
953
954    candidates
955        .par_iter()
956        .try_for_each(|candidate| -> Result<()> {
957            println!(
958                "\n{} Upgrading {} to {}...",
959                "::".bold().blue(),
960                candidate.source.cyan(),
961                candidate.new_version.green()
962            );
963
964            if let Some(hooks) = &candidate.new_pkg.hooks {
965                hooks::run_hooks(
966                    hooks,
967                    hooks::HookType::PreUpgrade,
968                    candidate.old_manifest.scope
969                )?;
970            }
971
972            let (graph, _) = match install::resolver::resolve_dependency_graph(
973                std::slice::from_ref(&candidate.source),
974                Some(candidate.old_manifest.scope),
975                true,
976                yes,
977                false,
978                None,
979                false,
980                None
981            ) {
982                Ok(res) => res,
983                Err(e) => {
984                    eprintln!(
985                        "{}: Failed to resolve dependencies for '{}': {}",
986                        "Error".red().bold(),
987                        candidate.source,
988                        e
989                    );
990                    failed_updates
991                        .lock()
992                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
993                        .push(candidate.source.clone());
994                    return Ok(());
995                }
996            };
997
998            if let Err(e) =
999                zoi_install::preflight::check_policy_compliance(&graph)
1000            {
1001                eprintln!(
1002                    "{}: Policy check failed for '{}': {}",
1003                    "Error".red().bold(),
1004                    candidate.source,
1005                    e
1006                );
1007                failed_updates
1008                    .lock()
1009                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1010                    .push(candidate.source.clone());
1011                return Ok(());
1012            }
1013
1014            if let Err(e) =
1015                zoi_install::preflight::check_scope_compliance(&graph)
1016            {
1017                eprintln!(
1018                    "{}: Scope check failed for '{}': {}",
1019                    "Error".red().bold(),
1020                    candidate.source,
1021                    e
1022                );
1023                failed_updates
1024                    .lock()
1025                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1026                    .push(candidate.source.clone());
1027                return Ok(());
1028            }
1029
1030            if let Err(e) =
1031                zoi_install::preflight::check_zoios_compliance(&graph)
1032            {
1033                eprintln!(
1034                    "{}: ZoiOS check failed for '{}': {}",
1035                    "Error".red().bold(),
1036                    candidate.source,
1037                    e
1038                );
1039                failed_updates
1040                    .lock()
1041                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1042                    .push(candidate.source.clone());
1043                return Ok(());
1044            }
1045
1046            if let Err(e) =
1047                zoi_install::preflight::check_for_vulnerabilities(&graph, yes)
1048            {
1049                eprintln!(
1050                    "{}: Security check failed for '{}': {}",
1051                    "Error".red().bold(),
1052                    candidate.source,
1053                    e
1054                );
1055                failed_updates
1056                    .lock()
1057                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1058                    .push(candidate.source.clone());
1059                return Ok(());
1060            }
1061
1062            let install_plan = match install::plan::create_install_plan(
1063                &graph.nodes,
1064                None,
1065                false
1066            ) {
1067                Ok(plan) => plan,
1068                Err(e) => {
1069                    eprintln!(
1070                        "{}: Failed to create install plan for '{}': {}",
1071                        "Error".red().bold(),
1072                        candidate.source,
1073                        e
1074                    );
1075                    failed_updates
1076                        .lock()
1077                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1078                        .push(candidate.source.clone());
1079                    return Ok(());
1080                }
1081            };
1082
1083            let stages = match graph.toposort() {
1084                Ok(s) => s,
1085                Err(e) => {
1086                    eprintln!(
1087                        "{}: Failed to sort dependency graph for '{}': {}",
1088                        "Error".red().bold(),
1089                        candidate.source,
1090                        e
1091                    );
1092                    failed_updates
1093                        .lock()
1094                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1095                        .push(candidate.source.clone());
1096                    return Ok(());
1097                }
1098            };
1099
1100            let mut new_manifest_option: Option<types::InstallManifest> = None;
1101            for stage in stages {
1102                for pkg_id in stage {
1103                    let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
1104                        anyhow!(
1105                            "Package node '{pkg_id}' missing from graph \
1106                             during update"
1107                        )
1108                    })?;
1109                    if let Some(action) = install_plan.get(&pkg_id) {
1110                        match install::installer::install_node(
1111                            node,
1112                            action,
1113                            Some(&m),
1114                            None,
1115                            yes,
1116                            true,
1117                            true,
1118                            false
1119                        ) {
1120                            Ok(m) => {
1121                                if m.name == candidate.new_pkg.name
1122                                    && m.sub_package
1123                                        == candidate.old_manifest.sub_package
1124                                {
1125                                    new_manifest_option = Some(m);
1126                                }
1127                            }
1128                            Err(e) => {
1129                                eprintln!(
1130                                    "{}: Failed to upgrade '{}': {}",
1131                                    "Error".red().bold(),
1132                                    candidate.source,
1133                                    e
1134                                );
1135                                failed_updates
1136                                    .lock()
1137                                    .map_err(|e| {
1138                                        anyhow!("mutex poisoned: {e}")
1139                                    })?
1140                                    .push(candidate.source.clone());
1141                                return Ok(());
1142                            }
1143                        }
1144                    }
1145                }
1146            }
1147
1148            if let Some(new_manifest) = new_manifest_option {
1149                if let Err(e) = transaction::record_operation(
1150                    &mut *transaction
1151                        .lock()
1152                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?,
1153                    types::TransactionOperation::Upgrade {
1154                        old_manifest: Box::new(candidate.old_manifest.clone()),
1155                        new_manifest: Box::new(new_manifest.clone())
1156                    }
1157                ) {
1158                    eprintln!(
1159                        "Error: Failed to record transaction for {}: {}",
1160                        candidate.source, e
1161                    );
1162                    failed_updates
1163                        .lock()
1164                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1165                        .push(candidate.source.clone());
1166                } else {
1167                    successful_upgrades
1168                        .lock()
1169                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1170                        .push((
1171                            candidate.old_manifest.clone(),
1172                            new_manifest.clone(),
1173                            candidate.new_pkg.clone()
1174                        ));
1175                }
1176            } else {
1177                eprintln!(
1178                    "Failed to get new manifest for {}",
1179                    candidate.source
1180                );
1181                failed_updates
1182                    .lock()
1183                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1184                    .push(candidate.source.clone());
1185            }
1186            Ok(())
1187        })?;
1188
1189    let failed = failed_updates
1190        .into_inner()
1191        .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1192    if !failed.is_empty() {
1193        eprintln!(
1194            "\nError: Some packages failed to upgrade. Rolling back all \
1195             changes..."
1196        );
1197        for pkg in &failed {
1198            eprintln!("  - {pkg}");
1199        }
1200        transaction::rollback(&transaction_id)?;
1201        ux::print_transaction_summary(&ux::TransactionSummary {
1202            command: "update".to_string(),
1203            success: 0,
1204            failed: failed.len(),
1205            skipped: pinned_sources.len()
1206                + skipped_sources.len()
1207                + up_to_date_sources.len()
1208        });
1209        return Err(anyhow!("Update failed for some packages."));
1210    }
1211
1212    if let Ok(modified_files) = transaction::get_modified_files(&transaction_id)
1213    {
1214        let modified_packages =
1215            transaction::get_modified_packages(&transaction_id)
1216                .unwrap_or_default();
1217        let upgrades_lock = successful_upgrades
1218            .lock()
1219            .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1220        let first_scope = upgrades_lock
1221            .first()
1222            .map_or(types::Scope::User, |(old, _, _)| old.scope);
1223        let _ = crate::pkg::hooks::global::run_global_hooks(
1224            crate::pkg::hooks::global::HookWhen::PostTransaction,
1225            &modified_files,
1226            &modified_packages,
1227            "upgrade",
1228            first_scope
1229        );
1230    }
1231    transaction::commit(&transaction_id)?;
1232
1233    if !plan_json {
1234        println!("\n{}", "Success:".green());
1235    }
1236    let successful_upgrades = successful_upgrades
1237        .into_inner()
1238        .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1239    for (old_manifest, new_manifest, new_pkg) in &successful_upgrades {
1240        if let Some(backup_files) = &old_manifest.backup {
1241            if !plan_json {
1242                println!(
1243                    "Restoring configuration for {}...",
1244                    old_manifest.name.cyan()
1245                );
1246            }
1247            let old_version_dir = local::get_package_version_dir(
1248                old_manifest.scope,
1249                &old_manifest.registry_handle,
1250                &old_manifest.repo,
1251                &old_manifest.name,
1252                &old_manifest.version
1253            )?;
1254            let new_version_dir = local::get_package_version_dir(
1255                new_manifest.scope,
1256                &new_manifest.registry_handle,
1257                &new_manifest.repo,
1258                &new_manifest.name,
1259                &new_manifest.version
1260            )?;
1261            handle_backup_files(
1262                &old_version_dir,
1263                &new_version_dir,
1264                backup_files,
1265                old_manifest.scope
1266            )?;
1267        }
1268
1269        if let Err(e) = cleanup_old_versions(
1270            &new_manifest.name,
1271            new_manifest.scope,
1272            &new_manifest.repo,
1273            &new_manifest.registry_handle
1274        ) {
1275            eprintln!(
1276                "Failed to clean up old versions for {}: {}",
1277                new_manifest.name, e
1278            );
1279        }
1280
1281        if let Ok(conn) = db::open_connection("local") {
1282            let _ = db::update_package(
1283                &conn,
1284                new_pkg,
1285                &new_manifest.registry_handle,
1286                Some(new_manifest.scope),
1287                new_manifest.sub_package.as_deref(),
1288                Some(&old_manifest.reason)
1289            );
1290        }
1291
1292        if let Some(hooks) = &new_pkg.hooks
1293            && let Err(e) = hooks::run_hooks(
1294                hooks,
1295                hooks::HookType::PostUpgrade,
1296                new_manifest.scope
1297            )
1298        {
1299            eprintln!(
1300                "{}: Post-upgrade hook failed for '{}': {}",
1301                "Error".red().bold(),
1302                new_manifest.name,
1303                e
1304            );
1305        }
1306    }
1307
1308    if !plan_json {
1309        ux::print_transaction_summary(&ux::TransactionSummary {
1310            command: "update".to_string(),
1311            success: successful_upgrades.len(),
1312            failed: 0,
1313            skipped: pinned_sources.len()
1314                + skipped_sources.len()
1315                + up_to_date_sources.len()
1316        });
1317    }
1318    Ok(())
1319}
1320
1321/// Calculates the number of security advisories for a package at two different
1322/// versions.
1323fn advisory_counts(
1324    registry_handle: &str,
1325    package: &str,
1326    sub_package: Option<&str>,
1327    old_version: &str,
1328    new_version: &str
1329) -> Result<(usize, usize)> {
1330    let advisories =
1331        db::get_advisories_for_package(registry_handle, package, sub_package)?;
1332    let old_ver = Version::parse(old_version).map_err(|e| {
1333        anyhow!("failed to parse old version '{old_version}': {e}")
1334    })?;
1335    let new_ver = Version::parse(new_version).map_err(|e| {
1336        anyhow!("failed to parse new version '{new_version}': {e}")
1337    })?;
1338
1339    let mut old_count = 0usize;
1340    let mut new_count = 0usize;
1341    for adv in advisories {
1342        if let Ok(req) = semver::VersionReq::parse(&adv.affected_range) {
1343            if req.matches(&old_ver) {
1344                old_count += 1;
1345            }
1346            if req.matches(&new_ver) {
1347                new_count += 1;
1348            }
1349        }
1350    }
1351    Ok((old_count, new_count))
1352}
1353
1354/// Removes old versions of a package, keeping a limited number for potential
1355/// rollbacks.
1356fn cleanup_old_versions(
1357    package_name: &str,
1358    scope: types::Scope,
1359    repo: &str,
1360    registry_handle: &str
1361) -> Result<()> {
1362    let config = config::read_config()?;
1363    let rollback_enabled = config.rollback_enabled;
1364    let package_dir =
1365        local::get_package_dir(scope, registry_handle, repo, package_name)?;
1366
1367    let mut versions = Vec::new();
1368    if let Ok(entries) = fs::read_dir(&package_dir) {
1369        for entry in entries.flatten() {
1370            let path = entry.path();
1371            if path.is_dir()
1372                && let Some(version_str) =
1373                    path.file_name().and_then(|s| s.to_str())
1374                && version_str != "latest"
1375                && let Ok(version) = Version::parse(version_str)
1376            {
1377                versions.push(version);
1378            }
1379        }
1380    }
1381
1382    if versions.is_empty() {
1383        return Ok(());
1384    }
1385    versions.sort();
1386
1387    let versions_to_keep = if rollback_enabled { 3 } else { 1 };
1388    if versions.len() > versions_to_keep {
1389        let num_to_delete = versions.len() - versions_to_keep;
1390        println!("Cleaning up old versions...");
1391        let to_delete = versions
1392            .get(..num_to_delete)
1393            .ok_or_else(|| anyhow!("Version slice out of bounds"))?;
1394        for version in to_delete {
1395            let version_dir_to_delete = package_dir.join(version.to_string());
1396            println!(" - Removing {}", version_dir_to_delete.display());
1397            let _ = fs::remove_dir_all(version_dir_to_delete);
1398        }
1399    }
1400    Ok(())
1401}