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
509    // Preserve the current kernel before any file is removed. Must precede the
510    // install loop: upgrading the kernel package deletes the previous version,
511    // and with it the last copy of the kernel this machine booted from.
512    if old_manifest_ref.scope == types::Scope::System {
513        retain_kernels_before_upgrade();
514    }
515    if plan_json {
516        m.set_draw_target(indicatif::ProgressDrawTarget::hidden());
517    }
518
519    // Pre-install dependencies (non-zoi)
520    if !non_zoi_deps.is_empty() {
521        let processed_deps = Mutex::new(HashSet::new());
522        let mut installed_deps_ext = Vec::new();
523        for dep_str in non_zoi_deps {
524            let dep =
525                crate::pkg::dependencies::parse_dependency_string(dep_str)?;
526            crate::pkg::install::dep_install::install_dependency(
527                &dep,
528                "update",
529                scope,
530                yes,
531                false,
532                &processed_deps,
533                &mut installed_deps_ext,
534                Some(&m)
535            )?;
536        }
537    }
538
539    for stage in stages {
540        for pkg_id in stage {
541            let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
542                anyhow!("Package node '{pkg_id}' missing from graph")
543            })?;
544            if let Some(action) = install_plan.get(&pkg_id) {
545                match install::installer::install_node(
546                    node,
547                    action,
548                    Some(&m),
549                    None,
550                    yes,
551                    true,
552                    true,
553                    verbose,
554                    false
555                ) {
556                    Ok(m) => {
557                        if m.name == target_pkg_name {
558                            new_manifest_option = Some(m);
559                        }
560                    }
561                    Err(e) => {
562                        eprintln!("\nError: Update failed. Rolling back...");
563                        transaction::rollback(&transaction.id)?;
564                        return Err(anyhow!("Update failed: {e}"));
565                    }
566                }
567            }
568        }
569    }
570
571    if let Some(new_manifest) = new_manifest_option {
572        // Record and commit
573        transaction::record_operation(
574            &mut transaction,
575            types::TransactionOperation::Upgrade {
576                old_manifest: Box::new(old_manifest_ref.clone()),
577                new_manifest: Box::new(new_manifest.clone())
578            }
579        )?;
580
581        if let Ok(modified_files) =
582            transaction::get_modified_files(&transaction.id)
583        {
584            let modified_packages =
585                transaction::get_modified_packages(&transaction.id)
586                    .unwrap_or_default();
587            let _ = crate::pkg::hooks::global::run_global_hooks(
588                crate::pkg::hooks::global::HookWhen::PostTransaction,
589                &modified_files,
590                &modified_packages,
591                "upgrade",
592                scope
593            );
594
595            // A re-registered package may have gained, lost or changed the
596            // priority of an alternative, so the registry is reconciled rather
597            // than assumed current.
598            if let Ok(changed) =
599                zoi_core::alternatives::reconcile(&modified_packages)
600            {
601                crate::cmd::alt::announce_changes(&changed);
602            }
603
604            // A kernel upgrade has to rebuild the initramfs and refresh the
605            // bootloader menu, otherwise the machine ends up with a new kernel
606            // it cannot boot and no fallback entry.
607            if scope == zoi_core::types::Scope::System {
608                let _ =
609                    zoi_system::kernel::sync_after_transaction(&modified_files);
610            }
611        }
612
613        transaction::commit(&transaction.id)?;
614
615        if let Some(backup_files) = &old_manifest_ref.backup {
616            println!("Restoring configuration files...");
617            let old_version_dir = local::get_package_version_dir(
618                old_manifest_ref.scope,
619                &old_manifest_ref.registry_handle,
620                &old_manifest_ref.repo,
621                &old_manifest_ref.name,
622                &old_manifest_ref.version
623            )?;
624            let new_version_dir = local::get_package_version_dir(
625                new_manifest.scope,
626                &new_manifest.registry_handle,
627                &new_manifest.repo,
628                &new_manifest.name,
629                &new_manifest.version
630            )?;
631
632            crate::pkg::merge::handle_backup_files(
633                &old_version_dir,
634                &new_version_dir,
635                backup_files,
636                old_manifest_ref.scope
637            )?;
638        }
639
640        // Cleanup and finish
641        cleanup_old_versions(
642            &new_manifest.name,
643            scope,
644            &new_manifest.repo,
645            &new_manifest.registry_handle
646        )?;
647
648        if let Ok(conn) = db::open_connection("local") {
649            let _ = db::update_package(
650                &conn,
651                new_pkg_ref,
652                &new_manifest.registry_handle,
653                Some(new_manifest.scope),
654                new_manifest.sub_package.as_deref(),
655                Some(&types::InstallReason::Direct)
656            );
657        }
658
659        if let Some(hooks) = &new_pkg_ref.hooks {
660            crate::pkg::hooks::run_hooks(
661                hooks,
662                crate::pkg::hooks::HookType::PostUpgrade,
663                new_manifest.scope
664            )?;
665        }
666    }
667
668    Ok(())
669}
670
671/// Logic for updating all installed packages.
672fn run_update_all_logic(
673    yes: bool,
674    dry_run: bool,
675    explain: bool,
676    plan_json: bool,
677    verbose: bool,
678    interactive: bool
679) -> Result<()> {
680    #[derive(Clone)]
681    struct UpdateCandidate {
682        source: String,
683        new_pkg: types::Package,
684        new_version: String,
685        old_manifest: types::InstallManifest,
686        old_advisories: usize,
687        new_advisories: usize,
688        download_size: u64,
689        new_installed_size: u64
690    }
691
692    let installed_packages = local::get_installed_packages()?;
693    let mut pinned_sources = Vec::new();
694    let mut skipped_sources = Vec::new();
695    let mut up_to_date_sources = Vec::new();
696    let mut candidates: Vec<UpdateCandidate> = Vec::new();
697
698    // --- Phase 1: Upgrade Scanning ---
699    // We scan all installed packages and compare them against the latest
700    // registry metadata.
701    if !plan_json {
702        println!("{} Checking for upgrades...", "::".bold().blue());
703    }
704    let pb = ProgressBar::new(installed_packages.len() as u64);
705    if plan_json {
706        pb.set_draw_target(indicatif::ProgressDrawTarget::hidden());
707    }
708    pb.set_style(
709        ProgressStyle::default_bar()
710            .template(
711                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \
712                 {pos}/{len} ({msg})"
713            )?
714            .progress_chars("#>-")
715    );
716    pb.set_message("Checking packages...");
717
718    for manifest in installed_packages {
719        let source = if let Some(sub) = &manifest.sub_package {
720            format!(
721                "#{}@{}/{}:{}",
722                manifest.registry_handle, manifest.repo, manifest.name, sub
723            )
724        } else {
725            format!(
726                "#{}@{}/{}",
727                manifest.registry_handle, manifest.repo, manifest.name
728            )
729        };
730
731        if pin::is_pinned(&source).unwrap_or(false)
732            || pin::is_pinned(&manifest.name).unwrap_or(false)
733        {
734            pinned_sources.push(source);
735            pb.inc(1);
736            continue;
737        }
738
739        let (new_pkg, new_version, _, _, _registry_handle, _, _) =
740            match resolve::resolve_package_and_version(
741                &source,
742                Some(manifest.scope),
743                true,
744                false
745            ) {
746                Ok(result) => result,
747                Err(e) => {
748                    skipped_sources.push(format!("{source} ({e})"));
749                    pb.inc(1);
750                    continue;
751                }
752            };
753
754        if manifest.version == new_version
755            && manifest.revision == new_pkg.revision
756        {
757            up_to_date_sources.push(source);
758            pb.inc(1);
759            continue;
760        }
761
762        let (old_adv, new_adv) = advisory_counts(
763            &manifest.registry_handle,
764            &manifest.name,
765            manifest.sub_package.as_deref(),
766            &manifest.version,
767            &new_version
768        )
769        .unwrap_or((0, 0));
770
771        let (download_size, new_installed_size) =
772            install::util::get_package_sizes(
773                &new_pkg,
774                &manifest.registry_handle,
775                &new_version
776            );
777
778        candidates.push(UpdateCandidate {
779            source,
780            new_pkg,
781            new_version,
782            old_manifest: manifest,
783            old_advisories: old_adv,
784            new_advisories: new_adv,
785            download_size,
786            new_installed_size
787        });
788        pb.inc(1);
789    }
790    pb.finish_and_clear();
791
792    if candidates.is_empty() {
793        if !plan_json {
794            println!("\nAll packages are up to date.");
795        }
796        return Ok(());
797    }
798
799    if interactive && !dry_run {
800        let items: Vec<String> = candidates
801            .iter()
802            .map(|c| {
803                format!(
804                    "{}  {} -> {}",
805                    c.source, c.old_manifest.version, c.new_version
806                )
807            })
808            .collect();
809        let selected = MultiSelect::with_theme(&ColorfulTheme::default())
810            .with_prompt("Select packages to update")
811            .items(&items)
812            .interact()
813            .map_err(|e| anyhow!("Interactive selection failed: {e}"))?;
814
815        if selected.is_empty() {
816            if !plan_json {
817                println!("No packages selected.");
818            }
819            return Ok(());
820        }
821
822        let selected_set: HashSet<usize> = selected.into_iter().collect();
823        candidates = candidates
824            .into_iter()
825            .enumerate()
826            .filter(|(idx, _)| selected_set.contains(idx))
827            .map(|(_, c)| c)
828            .collect();
829    }
830
831    if !plan_json {
832        println!("{} Resolving dependencies...", "::".bold().blue());
833        println!("{} Looking for conflicts...", "::".bold().blue());
834        println!("{} Checking available disk space...", "::".bold().blue());
835
836        if !pinned_sources.is_empty() {
837            println!("\n{} Pinned (skipped)", "::".bold().blue());
838            for s in &pinned_sources {
839                println!("  - {}", s.yellow());
840            }
841        }
842
843        println!("\n{} Packages ({})", "::".bold().blue(), candidates.len());
844    }
845    let config = config::read_config().unwrap_or_default();
846
847    for candidate in &candidates {
848        let delta =
849            candidate.new_advisories as i64 - candidate.old_advisories as i64;
850        let advisory_suffix = match delta.cmp(&0) {
851            std::cmp::Ordering::Greater => {
852                format!(" (advisories +{delta})").red().to_string()
853            }
854            std::cmp::Ordering::Less => {
855                format!(" (advisories {delta})").green().to_string()
856            }
857            std::cmp::Ordering::Equal => String::new()
858        };
859
860        let display_name = ux::format_display_name(
861            &candidate.old_manifest.registry_handle,
862            &candidate.old_manifest.repo,
863            &candidate.old_manifest.name,
864            candidate.old_manifest.sub_package.as_deref(),
865            &config
866        );
867        let old_display = if candidate.old_manifest.revision == "1" {
868            candidate.old_manifest.version.clone()
869        } else {
870            format!(
871                "{}-{}",
872                candidate.old_manifest.version, candidate.old_manifest.revision
873            )
874        };
875        let new_display = if candidate.new_pkg.revision == "1" {
876            candidate.new_version.clone()
877        } else {
878            format!("{}-{}", candidate.new_version, candidate.new_pkg.revision)
879        };
880
881        if !plan_json {
882            println!(
883                "  {} -> {}{}",
884                format!("{display_name}@{old_display}").cyan(),
885                format!("{display_name}@{new_display}").cyan(),
886                advisory_suffix
887            );
888        }
889    }
890
891    let total_download_size: u64 =
892        candidates.iter().map(|c| c.download_size).sum();
893    let total_installed_size_diff: i64 = candidates
894        .iter()
895        .map(|c| {
896            c.new_installed_size as i64
897                - c.old_manifest.installed_size.unwrap_or(0) as i64
898        })
899        .sum();
900
901    let down_str = zoi_core::utils::format_bytes(total_download_size);
902    let net_str = zoi_core::utils::format_size_diff(total_installed_size_diff);
903
904    if !plan_json {
905        println!("\nTotal Download Size: {down_str:>10}");
906        println!("Net Upgrade Size:    {net_str:>10}");
907    }
908
909    if verbose && !plan_json {
910        let preflight = ux::PreflightSummary::new("Update preflight")
911            .row("Candidates", candidates.len().to_string())
912            .row("Download size", &down_str)
913            .row("Net size", &net_str);
914        ux::print_preflight(&preflight);
915    }
916
917    if explain && !plan_json {
918        let mut report = ux::ExplainReport::new("Update explanation");
919        for candidate in &candidates {
920            report = report.item(
921                candidate.source.clone(),
922                format!(
923                    "selected because {} -> {}",
924                    candidate.old_manifest.version, candidate.new_version
925                ),
926                Vec::new()
927            );
928        }
929        ux::print_explain(&report);
930    }
931
932    if plan_json {
933        let packages: Vec<_> = candidates
934            .iter()
935            .map(|c| {
936                json!({
937                    "source": c.source,
938                    "name": c.new_pkg.name,
939                    "sub_package": c.old_manifest.sub_package,
940                    "from_version": c.old_manifest.version,
941                    "to_version": c.new_version,
942                    "download_bytes": c.download_size,
943                    "net_size_bytes": c.new_installed_size as i64 - c.old_manifest.installed_size.unwrap_or(0) as i64,
944                    "advisories_old": c.old_advisories,
945                    "advisories_new": c.new_advisories,
946                })
947            })
948            .collect();
949
950        let plan = json!({
951            "dry_run": dry_run,
952            "interactive": interactive,
953            "totals": {
954                "candidates": candidates.len(),
955                "pinned_skipped": pinned_sources.len(),
956                "other_skipped": skipped_sources.len(),
957                "up_to_date": up_to_date_sources.len(),
958                "download_bytes": total_download_size,
959                "net_size_bytes": total_installed_size_diff,
960            },
961            "pinned": pinned_sources,
962            "skipped": skipped_sources,
963            "packages": packages,
964        });
965        ux::emit_plan_json_v1("update", plan)?;
966        return Ok(());
967    }
968
969    if dry_run {
970        println!(
971            "\n{} Dry-run: upgrade plan above would be executed.",
972            "::".bold().yellow()
973        );
974        return Ok(());
975    }
976
977    if !crate::utils::ask_for_confirmation(
978        "Do you want to upgrade these packages?",
979        yes
980    ) {
981        return Ok(());
982    }
983
984    // --- Phase 2: Transactional Upgrade ---
985    let transaction = Mutex::new(transaction::begin()?);
986    let transaction_id = transaction.lock().expect("mutex poisoned").id.clone();
987    let failed_updates = Mutex::new(Vec::new());
988    let successful_upgrades = Mutex::new(Vec::new());
989
990    let m = MultiProgress::new();
991
992    candidates
993        .par_iter()
994        .try_for_each(|candidate| -> Result<()> {
995            println!(
996                "\n{} Upgrading {} to {}...",
997                "::".bold().blue(),
998                candidate.source.cyan(),
999                candidate.new_version.green()
1000            );
1001
1002            if let Some(hooks) = &candidate.new_pkg.hooks {
1003                hooks::run_hooks(
1004                    hooks,
1005                    hooks::HookType::PreUpgrade,
1006                    candidate.old_manifest.scope
1007                )?;
1008            }
1009
1010            let (graph, _) = match install::resolver::resolve_dependency_graph(
1011                std::slice::from_ref(&candidate.source),
1012                Some(candidate.old_manifest.scope),
1013                true,
1014                yes,
1015                false,
1016                None,
1017                false,
1018                None
1019            ) {
1020                Ok(res) => res,
1021                Err(e) => {
1022                    eprintln!(
1023                        "{}: Failed to resolve dependencies for '{}': {}",
1024                        "Error".red().bold(),
1025                        candidate.source,
1026                        e
1027                    );
1028                    failed_updates
1029                        .lock()
1030                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1031                        .push(candidate.source.clone());
1032                    return Ok(());
1033                }
1034            };
1035
1036            if let Err(e) =
1037                zoi_install::preflight::check_policy_compliance(&graph)
1038            {
1039                eprintln!(
1040                    "{}: Policy check failed for '{}': {}",
1041                    "Error".red().bold(),
1042                    candidate.source,
1043                    e
1044                );
1045                failed_updates
1046                    .lock()
1047                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1048                    .push(candidate.source.clone());
1049                return Ok(());
1050            }
1051
1052            if let Err(e) =
1053                zoi_install::preflight::check_scope_compliance(&graph)
1054            {
1055                eprintln!(
1056                    "{}: Scope check failed for '{}': {}",
1057                    "Error".red().bold(),
1058                    candidate.source,
1059                    e
1060                );
1061                failed_updates
1062                    .lock()
1063                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1064                    .push(candidate.source.clone());
1065                return Ok(());
1066            }
1067
1068            if let Err(e) =
1069                zoi_install::preflight::check_zoios_compliance(&graph)
1070            {
1071                eprintln!(
1072                    "{}: ZoiOS check failed for '{}': {}",
1073                    "Error".red().bold(),
1074                    candidate.source,
1075                    e
1076                );
1077                failed_updates
1078                    .lock()
1079                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1080                    .push(candidate.source.clone());
1081                return Ok(());
1082            }
1083
1084            if let Err(e) =
1085                zoi_install::preflight::check_for_vulnerabilities(&graph, yes)
1086            {
1087                eprintln!(
1088                    "{}: Security check failed for '{}': {}",
1089                    "Error".red().bold(),
1090                    candidate.source,
1091                    e
1092                );
1093                failed_updates
1094                    .lock()
1095                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1096                    .push(candidate.source.clone());
1097                return Ok(());
1098            }
1099
1100            let install_plan = match install::plan::create_install_plan(
1101                &graph.nodes,
1102                None,
1103                false
1104            ) {
1105                Ok(plan) => plan,
1106                Err(e) => {
1107                    eprintln!(
1108                        "{}: Failed to create install plan for '{}': {}",
1109                        "Error".red().bold(),
1110                        candidate.source,
1111                        e
1112                    );
1113                    failed_updates
1114                        .lock()
1115                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1116                        .push(candidate.source.clone());
1117                    return Ok(());
1118                }
1119            };
1120
1121            let stages = match graph.toposort() {
1122                Ok(s) => s,
1123                Err(e) => {
1124                    eprintln!(
1125                        "{}: Failed to sort dependency graph for '{}': {}",
1126                        "Error".red().bold(),
1127                        candidate.source,
1128                        e
1129                    );
1130                    failed_updates
1131                        .lock()
1132                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1133                        .push(candidate.source.clone());
1134                    return Ok(());
1135                }
1136            };
1137
1138            // Preserve the current kernel before anything removes it. This has
1139            // to happen before the first package is installed, because
1140            // upgrading the kernel package deletes the previous
1141            // version's files, and with them the only copy of the kernel
1142            // this machine is currently running.
1143            if candidate.old_manifest.scope == types::Scope::System {
1144                retain_kernels_before_upgrade();
1145            }
1146
1147            let mut new_manifest_option: Option<types::InstallManifest> = None;
1148            for stage in stages {
1149                for pkg_id in stage {
1150                    let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
1151                        anyhow!(
1152                            "Package node '{pkg_id}' missing from graph \
1153                             during update"
1154                        )
1155                    })?;
1156                    if let Some(action) = install_plan.get(&pkg_id) {
1157                        match install::installer::install_node(
1158                            node,
1159                            action,
1160                            Some(&m),
1161                            None,
1162                            yes,
1163                            true,
1164                            true,
1165                            false,
1166                            false
1167                        ) {
1168                            Ok(m) => {
1169                                if m.name == candidate.new_pkg.name
1170                                    && m.sub_package
1171                                        == candidate.old_manifest.sub_package
1172                                {
1173                                    new_manifest_option = Some(m);
1174                                }
1175                            }
1176                            Err(e) => {
1177                                eprintln!(
1178                                    "{}: Failed to upgrade '{}': {}",
1179                                    "Error".red().bold(),
1180                                    candidate.source,
1181                                    e
1182                                );
1183                                failed_updates
1184                                    .lock()
1185                                    .map_err(|e| {
1186                                        anyhow!("mutex poisoned: {e}")
1187                                    })?
1188                                    .push(candidate.source.clone());
1189                                return Ok(());
1190                            }
1191                        }
1192                    }
1193                }
1194            }
1195
1196            if let Some(new_manifest) = new_manifest_option {
1197                if let Err(e) = transaction::record_operation(
1198                    &mut *transaction
1199                        .lock()
1200                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?,
1201                    types::TransactionOperation::Upgrade {
1202                        old_manifest: Box::new(candidate.old_manifest.clone()),
1203                        new_manifest: Box::new(new_manifest.clone())
1204                    }
1205                ) {
1206                    eprintln!(
1207                        "Error: Failed to record transaction for {}: {}",
1208                        candidate.source, e
1209                    );
1210                    failed_updates
1211                        .lock()
1212                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1213                        .push(candidate.source.clone());
1214                } else {
1215                    successful_upgrades
1216                        .lock()
1217                        .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1218                        .push((
1219                            candidate.old_manifest.clone(),
1220                            new_manifest.clone(),
1221                            candidate.new_pkg.clone()
1222                        ));
1223                }
1224            } else {
1225                eprintln!(
1226                    "Failed to get new manifest for {}",
1227                    candidate.source
1228                );
1229                failed_updates
1230                    .lock()
1231                    .map_err(|e| anyhow!("mutex poisoned: {e}"))?
1232                    .push(candidate.source.clone());
1233            }
1234            Ok(())
1235        })?;
1236
1237    let failed = failed_updates
1238        .into_inner()
1239        .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1240    if !failed.is_empty() {
1241        eprintln!(
1242            "\nError: Some packages failed to upgrade. Rolling back all \
1243             changes..."
1244        );
1245        for pkg in &failed {
1246            eprintln!("  - {pkg}");
1247        }
1248        transaction::rollback(&transaction_id)?;
1249        ux::print_transaction_summary(&ux::TransactionSummary {
1250            command: "update".to_string(),
1251            success: 0,
1252            failed: failed.len(),
1253            skipped: pinned_sources.len()
1254                + skipped_sources.len()
1255                + up_to_date_sources.len()
1256        });
1257        return Err(anyhow!("Update failed for some packages."));
1258    }
1259
1260    if let Ok(modified_files) = transaction::get_modified_files(&transaction_id)
1261    {
1262        let modified_packages =
1263            transaction::get_modified_packages(&transaction_id)
1264                .unwrap_or_default();
1265        let upgrades_lock = successful_upgrades
1266            .lock()
1267            .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1268        let first_scope = upgrades_lock
1269            .first()
1270            .map_or(types::Scope::User, |(old, _, _)| old.scope);
1271        let _ = crate::pkg::hooks::global::run_global_hooks(
1272            crate::pkg::hooks::global::HookWhen::PostTransaction,
1273            &modified_files,
1274            &modified_packages,
1275            "upgrade",
1276            first_scope
1277        );
1278
1279        // `zoi update --all` is the rolling-release upgrade path for a ZoiOS
1280        // system, so the kernel pipeline must run here too: a rolling distro
1281        // whose kernel updates do not produce a boot entry is not upgradeable.
1282        if first_scope == types::Scope::System {
1283            let _ = zoi_system::kernel::sync_after_transaction(&modified_files);
1284        }
1285
1286        if let Ok(changed) =
1287            zoi_core::alternatives::reconcile(&modified_packages)
1288        {
1289            crate::cmd::alt::announce_changes(&changed);
1290        }
1291    }
1292    transaction::commit(&transaction_id)?;
1293
1294    if !plan_json {
1295        println!("\n{}", "Success:".green());
1296    }
1297    let successful_upgrades = successful_upgrades
1298        .into_inner()
1299        .map_err(|e| anyhow!("mutex poisoned: {e}"))?;
1300    for (old_manifest, new_manifest, new_pkg) in &successful_upgrades {
1301        if let Some(backup_files) = &old_manifest.backup {
1302            if !plan_json {
1303                println!(
1304                    "Restoring configuration for {}...",
1305                    old_manifest.name.cyan()
1306                );
1307            }
1308            let old_version_dir = local::get_package_version_dir(
1309                old_manifest.scope,
1310                &old_manifest.registry_handle,
1311                &old_manifest.repo,
1312                &old_manifest.name,
1313                &old_manifest.version
1314            )?;
1315            let new_version_dir = local::get_package_version_dir(
1316                new_manifest.scope,
1317                &new_manifest.registry_handle,
1318                &new_manifest.repo,
1319                &new_manifest.name,
1320                &new_manifest.version
1321            )?;
1322            handle_backup_files(
1323                &old_version_dir,
1324                &new_version_dir,
1325                backup_files,
1326                old_manifest.scope
1327            )?;
1328        }
1329
1330        if let Err(e) = cleanup_old_versions(
1331            &new_manifest.name,
1332            new_manifest.scope,
1333            &new_manifest.repo,
1334            &new_manifest.registry_handle
1335        ) {
1336            eprintln!(
1337                "Failed to clean up old versions for {}: {}",
1338                new_manifest.name, e
1339            );
1340        }
1341
1342        if let Ok(conn) = db::open_connection("local") {
1343            let _ = db::update_package(
1344                &conn,
1345                new_pkg,
1346                &new_manifest.registry_handle,
1347                Some(new_manifest.scope),
1348                new_manifest.sub_package.as_deref(),
1349                Some(&old_manifest.reason)
1350            );
1351        }
1352
1353        if let Some(hooks) = &new_pkg.hooks
1354            && let Err(e) = hooks::run_hooks(
1355                hooks,
1356                hooks::HookType::PostUpgrade,
1357                new_manifest.scope
1358            )
1359        {
1360            eprintln!(
1361                "{}: Post-upgrade hook failed for '{}': {}",
1362                "Error".red().bold(),
1363                new_manifest.name,
1364                e
1365            );
1366        }
1367    }
1368
1369    if !plan_json {
1370        ux::print_transaction_summary(&ux::TransactionSummary {
1371            command: "update".to_string(),
1372            success: successful_upgrades.len(),
1373            failed: 0,
1374            skipped: pinned_sources.len()
1375                + skipped_sources.len()
1376                + up_to_date_sources.len()
1377        });
1378    }
1379    Ok(())
1380}
1381
1382/// Calculates the number of security advisories for a package at two different
1383/// versions.
1384fn advisory_counts(
1385    registry_handle: &str,
1386    package: &str,
1387    sub_package: Option<&str>,
1388    old_version: &str,
1389    new_version: &str
1390) -> Result<(usize, usize)> {
1391    let advisories =
1392        db::get_advisories_for_package(registry_handle, package, sub_package)?;
1393    let old_ver = Version::parse(old_version).map_err(|e| {
1394        anyhow!("failed to parse old version '{old_version}': {e}")
1395    })?;
1396    let new_ver = Version::parse(new_version).map_err(|e| {
1397        anyhow!("failed to parse new version '{new_version}': {e}")
1398    })?;
1399
1400    let mut old_count = 0usize;
1401    let mut new_count = 0usize;
1402    for adv in advisories {
1403        if let Ok(req) = semver::VersionReq::parse(&adv.affected_range) {
1404            if req.matches(&old_ver) {
1405                old_count += 1;
1406            }
1407            if req.matches(&new_ver) {
1408                new_count += 1;
1409            }
1410        }
1411    }
1412    Ok((old_count, new_count))
1413}
1414
1415/// Removes old versions of a package, keeping a limited number for potential
1416/// rollbacks.
1417/// Snapshots the installed kernels so an upgrade cannot leave the machine
1418/// without a bootable fallback.
1419///
1420/// Retention is configured through `kernel_retention` and defaults to three
1421/// kernels, matching Fedora's behaviour of keeping previous kernels selectable
1422/// in the boot menu.
1423///
1424/// Called from both the single-package and the `update --all` paths, before any
1425/// file is removed. Failures are warnings rather than errors: refusing to
1426/// update a package because a kernel snapshot could not be taken would be worse
1427/// than proceeding, and the user is told what was not preserved.
1428fn retain_kernels_before_upgrade() {
1429    let limit =
1430        zoi_core::config::read_config().map_or(3, |c| c.kernel_retention);
1431
1432    if limit == 0 {
1433        return;
1434    }
1435
1436    match zoi_system::kernel::retain_current_kernels(limit) {
1437        Ok(retained) if !retained.is_empty() => {
1438            println!(
1439                "{} Retaining kernel(s) as boot fallback: {}",
1440                "::".bold().blue(),
1441                retained.join(", ")
1442            );
1443        }
1444        Ok(_) => {}
1445        Err(e) => eprintln!(
1446            "{}: Failed to retain the current kernel: {e}. The previous \
1447             kernel may not be bootable after this update.",
1448            "Warning".yellow().bold()
1449        )
1450    }
1451}
1452
1453/// Removes store directories for versions the package no longer uses.
1454///
1455/// Only the versions that are not referenced by the newly installed manifest
1456/// are removed, so a rollback source that is still wanted survives.
1457fn cleanup_old_versions(
1458    package_name: &str,
1459    scope: types::Scope,
1460    repo: &str,
1461    registry_handle: &str
1462) -> Result<()> {
1463    let config = config::read_config()?;
1464    let rollback_enabled = config.rollback_enabled;
1465    let package_dir =
1466        local::get_package_dir(scope, registry_handle, repo, package_name)?;
1467
1468    let mut versions = Vec::new();
1469    if let Ok(entries) = fs::read_dir(&package_dir) {
1470        for entry in entries.flatten() {
1471            let path = entry.path();
1472            if path.is_dir()
1473                && let Some(version_str) =
1474                    path.file_name().and_then(|s| s.to_str())
1475                && version_str != "latest"
1476                && let Ok(version) = Version::parse(version_str)
1477            {
1478                versions.push(version);
1479            }
1480        }
1481    }
1482
1483    if versions.is_empty() {
1484        return Ok(());
1485    }
1486    versions.sort();
1487
1488    let versions_to_keep = if rollback_enabled { 3 } else { 1 };
1489    if versions.len() > versions_to_keep {
1490        let num_to_delete = versions.len() - versions_to_keep;
1491        println!("Cleaning up old versions...");
1492        let to_delete = versions
1493            .get(..num_to_delete)
1494            .ok_or_else(|| anyhow!("Version slice out of bounds"))?;
1495        for version in to_delete {
1496            let version_dir_to_delete = package_dir.join(version.to_string());
1497            println!(" - Removing {}", version_dir_to_delete.display());
1498            let _ = fs::remove_dir_all(version_dir_to_delete);
1499        }
1500    }
1501    Ok(())
1502}