Skip to main content

zoi_cli/cmd/
update.rs

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