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