use super::*; use crate::commands::repo::groups::scan_package_specs; #[derive(Clone, Copy)] pub(crate) struct UpdateCommandOptions<'a> { pub(crate) rootfs: &'a Path, pub(crate) no_deps: bool, pub(crate) no_flags: bool, pub(crate) cross_prefix: Option<&'a str>, pub(crate) clean: bool, pub(crate) dry_run: bool, pub(crate) assume_yes: bool, pub(crate) install_test_deps: bool, } #[derive(Debug, Clone)] pub(crate) enum UpdateOrigin { Source { repo_name: String, path: PathBuf, }, Binary { repo_name: String, record: Box, }, } #[derive(Debug, Clone)] pub(crate) struct UpdateCandidate { pub(crate) installed_package: String, pub(crate) candidate_package: String, pub(crate) replaces_installed: bool, pub(crate) installed_version: String, pub(crate) installed_revision: u32, pub(crate) installed_completed_at: Option, pub(crate) candidate_version: String, pub(crate) candidate_revision: u32, pub(crate) candidate_completed_at: Option, pub(crate) runtime_dependencies: Vec, pub(crate) provides: Vec, pub(crate) conflicts: Vec, pub(crate) repo_priority: i32, pub(crate) origin: UpdateOrigin, } #[derive(Debug, Clone)] pub(crate) struct SourceUpdateCandidate { pub(crate) repo_name: String, pub(crate) repo_priority: i32, pub(crate) path: PathBuf, pub(crate) completed_at: Option, pub(crate) spec: package::PackageSpec, } pub(crate) fn source_update_candidate_is_better( candidate: &SourceUpdateCandidate, current: &SourceUpdateCandidate, ) -> bool { match compare_package_release( &candidate.spec.package.version, candidate.spec.package.revision, ¤t.spec.package.version, current.spec.package.revision, ) { Ordering::Greater => true, Ordering::Less => false, Ordering::Equal => match compare_completed_at(candidate.completed_at, current.completed_at) { Ordering::Greater => true, Ordering::Less => false, Ordering::Equal => { if candidate.repo_priority != current.repo_priority { candidate.repo_priority < current.repo_priority } else if candidate.repo_name != current.repo_name { candidate.repo_name < current.repo_name } else { candidate.path < current.path } } }, } } fn binary_update_candidate_is_better( repo_name: &str, repo_priority: i32, record: &db::repo::BinaryRepoPackageRecord, current_priority: i32, current: &db::repo::BinaryRepoPackageRecord, ) -> bool { match compare_package_release( &record.version, record.revision, ¤t.version, current.revision, ) { Ordering::Greater => true, Ordering::Less => false, Ordering::Equal => match compare_completed_at(record.completed_at, current.completed_at) { Ordering::Greater => true, Ordering::Less => false, Ordering::Equal => { if repo_priority != current_priority { repo_priority < current_priority } else if repo_name != current.repo_name { repo_name < current.repo_name.as_str() } else { record.filename < current.filename } } }, } } fn compare_package_release( left_version: &str, left_revision: u32, right_version: &str, right_revision: u32, ) -> Ordering { super::compare_versions_for_updates(left_version, right_version) .then_with(|| left_revision.cmp(&right_revision)) } pub(crate) fn compare_completed_at(left: Option, right: Option) -> Ordering { match (left, right) { (Some(left), Some(right)) => left.cmp(&right), (Some(_), None) => Ordering::Greater, (None, Some(_)) => Ordering::Less, (None, None) => Ordering::Equal, } } fn update_candidate_is_newer_than_installed( candidate_version: &str, candidate_revision: u32, candidate_completed_at: Option, installed_version: &str, installed_revision: u32, installed_completed_at: Option, ) -> bool { match compare_package_release( candidate_version, candidate_revision, installed_version, installed_revision, ) { Ordering::Greater => true, Ordering::Less => false, Ordering::Equal => { compare_completed_at(candidate_completed_at, installed_completed_at) == Ordering::Greater } } } fn path_modified_unix_timestamp(path: &Path) -> Result> { let metadata = fs::metadata(path) .with_context(|| format!("Failed to read metadata for {}", path.display()))?; let modified = metadata .modified() .with_context(|| format!("Failed to read modification time for {}", path.display()))?; Ok(Some(crate::metadata_time::system_time_to_unix(modified)?)) } fn installed_package_completed_at( installed: &db::InstalledPackageRecord, db_path: &Path, rootfs: &Path, ) -> Result> { if installed.completed_at.is_some() { return Ok(installed.completed_at); } let mut latest = None; for relative_path in db::get_package_files(db_path, &installed.name)? { let path = rootfs.join(&relative_path); if !path.exists() { continue; } if let Some(modified) = path_modified_unix_timestamp(&path)? { latest = Some(latest.map_or(modified, |current: i64| current.max(modified))); } } Ok(latest) } fn update_origin_is_binary(origin: &UpdateOrigin) -> bool { matches!(origin, UpdateOrigin::Binary { .. }) } fn update_origin_label(origin: &UpdateOrigin) -> String { match origin { UpdateOrigin::Source { repo_name, path } => { format!("source:{repo_name}:{}", path.display()) } UpdateOrigin::Binary { repo_name, record } => { format!("binary:{repo_name}:{}", record.filename) } } } pub(crate) fn update_candidate_is_preferred( candidate: &UpdateCandidate, current: &UpdateCandidate, prefer_binary: bool, ) -> bool { match compare_package_release( &candidate.candidate_version, candidate.candidate_revision, ¤t.candidate_version, current.candidate_revision, ) { Ordering::Greater => true, Ordering::Less => false, Ordering::Equal => match compare_completed_at( candidate.candidate_completed_at, current.candidate_completed_at, ) { Ordering::Greater => true, Ordering::Less => false, Ordering::Equal => match ( update_origin_is_binary(&candidate.origin), update_origin_is_binary(¤t.origin), ) { (true, false) => prefer_binary, (false, true) => !prefer_binary, _ => { if candidate.repo_priority != current.repo_priority { candidate.repo_priority < current.repo_priority } else { update_origin_label(&candidate.origin) < update_origin_label(¤t.origin) } } }, }, } } fn candidate_request_path( candidate: &UpdateCandidate, config: &config::Config, rootfs: &Path, ) -> Result { match &candidate.origin { UpdateOrigin::Source { path, .. } => Ok(path.clone()), UpdateOrigin::Binary { repo_name, record } => { let repo_cfg = config .binary_repos .get(repo_name) .with_context(|| format!("Binary repo '{}' not found in config", repo_name))?; db::repo::fetch_binary_package_archive( repo_name, repo_cfg, rootfs, record, &config.package_cache_dir, ) } } } pub(crate) fn sync_source_repositories_for_update(config: &config::Config) -> Result<()> { if config.repo_settings.prefer_binary { return Ok(()); } let mirrors = config.enabled_source_mirror_map(); if mirrors.is_empty() { return Ok(()); } let mut repo_lock = locking::open_lock(config)?; let repo_lock_path = locking::lock_path(config); let _repo_lock_guard = locking::try_write(&mut repo_lock, &repo_lock_path, "update sync")?; db::repo::sync_mirrors(&config.repo_clone_dir, &mirrors)?; Ok(()) } fn configured_source_scan_roots(config: &config::Config) -> Vec<(String, i32, PathBuf)> { let mut roots = Vec::new(); let mut repos: Vec<_> = config .source_repos .iter() .filter(|(_, repo)| repo.enabled) .collect(); repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); for (repo_name, repo) in repos { let repo_root = config.repo_clone_dir.join(repo_name); if repo.subdirs.is_empty() { roots.push((repo_name.clone(), repo.priority, repo_root)); } else { for subdir in &repo.subdirs { roots.push((repo_name.clone(), repo.priority, repo_root.join(subdir))); } } } roots } pub(crate) fn collect_best_source_update_candidates( config: &config::Config, target_real_names: &HashSet, ) -> Result> { let mut best: HashMap = HashMap::new(); for (repo_name, repo_priority, root) in configured_source_scan_roots(config) { if !root.exists() { continue; } for spec_path in scan_package_specs(&root)? { let mut spec = package::PackageSpec::from_file(&spec_path)?; spec.apply_config(config); let stream_name = spec.package.effective_real_name().to_string(); if !target_real_names.contains(&stream_name) { continue; } let candidate = SourceUpdateCandidate { repo_name: repo_name.clone(), repo_priority, path: spec_path.clone(), completed_at: path_modified_unix_timestamp(&spec_path)?, spec, }; let replace = match best.get(&stream_name) { Some(current) => source_update_candidate_is_better(&candidate, current), None => true, }; if replace { best.insert(stream_name, candidate); } } } Ok(best) } fn collect_best_source_replacement_candidates( config: &config::Config, target_packages: &HashSet, ) -> Result> { let mut best: HashMap = HashMap::new(); for (repo_name, repo_priority, root) in configured_source_scan_roots(config) { if !root.exists() { continue; } for spec_path in scan_package_specs(&root)? { let mut spec = package::PackageSpec::from_file(&spec_path)?; spec.apply_config(config); let candidate = SourceUpdateCandidate { repo_name: repo_name.clone(), repo_priority, path: spec_path.clone(), completed_at: path_modified_unix_timestamp(&spec_path)?, spec, }; for replaced in &candidate.spec.alternatives.replaces { if !target_packages.contains(replaced) { continue; } let replace = match best.get(replaced) { Some(current) => source_update_candidate_is_better(&candidate, current), None => true, }; if replace { best.insert(replaced.clone(), candidate.clone()); } } } } Ok(best) } fn collect_best_binary_update_candidates( config: &config::Config, rootfs: &Path, target_real_names: &HashSet, ) -> Result> { let mut best: HashMap = HashMap::new(); let host_arch = std::env::consts::ARCH; let mut repos: Vec<_> = config .binary_repos .iter() .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) .collect(); repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); for (repo_name, repo_cfg) in repos { let records = db::repo::list_binary_repo_packages( repo_name, repo_cfg, rootfs, &config.package_cache_dir, ) .with_context(|| format!("Failed to list binary repo '{}'", repo_name))?; for record in records { let stream_name = record.effective_real_name().to_string(); if !target_real_names.contains(&stream_name) { continue; } let replace = match best.get(&stream_name) { Some((current_priority, current)) => binary_update_candidate_is_better( repo_name, repo_cfg.priority, &record, *current_priority, current, ), None => true, }; if replace { best.insert(stream_name, (repo_cfg.priority, record)); } } } Ok(best) } fn collect_best_binary_replacement_candidates( config: &config::Config, rootfs: &Path, target_packages: &HashSet, ) -> Result> { let mut best: HashMap = HashMap::new(); let host_arch = std::env::consts::ARCH; let mut repos: Vec<_> = config .binary_repos .iter() .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) .collect(); repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); for (repo_name, repo_cfg) in repos { let records = db::repo::list_binary_repo_packages( repo_name, repo_cfg, rootfs, &config.package_cache_dir, ) .with_context(|| format!("Failed to list binary repo '{}'", repo_name))?; for record in records { for replaced in &record.replaces { if !target_packages.contains(replaced) { continue; } let replace = match best.get(replaced) { Some((current_priority, current)) => binary_update_candidate_is_better( repo_name, repo_cfg.priority, &record, *current_priority, current, ), None => true, }; if replace { best.insert(replaced.clone(), (repo_cfg.priority, record.clone())); } } } } Ok(best) } pub(crate) fn select_update_candidate( installed: &db::InstalledPackageRecord, installed_completed_at: Option, source_replacement_candidates: &HashMap, binary_replacement_candidates: &HashMap, source_candidates: &HashMap, binary_candidates: &HashMap, prefer_binary: bool, ) -> Option { let mut best: Option = None; let stream_name = installed.effective_real_name(); if let Some(candidate) = source_replacement_candidates.get(&installed.name) { best = Some(UpdateCandidate { installed_package: installed.name.clone(), candidate_package: candidate.spec.package.name.clone(), replaces_installed: true, installed_version: installed.version.clone(), installed_revision: installed.revision, installed_completed_at, candidate_version: candidate.spec.package.version.clone(), candidate_revision: candidate.spec.package.revision, candidate_completed_at: candidate.completed_at, runtime_dependencies: candidate.spec.dependencies.runtime.clone(), provides: candidate.spec.alternatives.provides.clone(), conflicts: candidate.spec.alternatives.conflicts.clone(), repo_priority: candidate.repo_priority, origin: UpdateOrigin::Source { repo_name: candidate.repo_name.clone(), path: candidate.path.clone(), }, }); } if let Some((repo_priority, record)) = binary_replacement_candidates.get(&installed.name) { let binary_candidate = UpdateCandidate { installed_package: installed.name.clone(), candidate_package: record.name.clone(), replaces_installed: true, installed_version: installed.version.clone(), installed_revision: installed.revision, installed_completed_at, candidate_version: record.version.clone(), candidate_revision: record.revision, candidate_completed_at: record.completed_at, runtime_dependencies: record.runtime_dependencies.clone(), provides: record.provides.clone(), conflicts: record.conflicts.clone(), repo_priority: *repo_priority, origin: UpdateOrigin::Binary { repo_name: record.repo_name.clone(), record: Box::new(record.clone()), }, }; if best.as_ref().is_none_or(|current| { update_candidate_is_preferred(&binary_candidate, current, prefer_binary) }) { best = Some(binary_candidate); } } if best.is_some() { return best; } if let Some(candidate) = source_candidates.get(stream_name) && update_candidate_is_newer_than_installed( &candidate.spec.package.version, candidate.spec.package.revision, candidate.completed_at, &installed.version, installed.revision, installed_completed_at, ) { best = Some(UpdateCandidate { installed_package: installed.name.clone(), candidate_package: candidate.spec.package.name.clone(), replaces_installed: false, installed_version: installed.version.clone(), installed_revision: installed.revision, installed_completed_at, candidate_version: candidate.spec.package.version.clone(), candidate_revision: candidate.spec.package.revision, candidate_completed_at: candidate.completed_at, runtime_dependencies: candidate.spec.dependencies.runtime.clone(), provides: candidate.spec.alternatives.provides.clone(), conflicts: candidate.spec.alternatives.conflicts.clone(), repo_priority: candidate.repo_priority, origin: UpdateOrigin::Source { repo_name: candidate.repo_name.clone(), path: candidate.path.clone(), }, }); } if let Some((repo_priority, record)) = binary_candidates.get(stream_name) && update_candidate_is_newer_than_installed( &record.version, record.revision, record.completed_at, &installed.version, installed.revision, installed_completed_at, ) { let binary_candidate = UpdateCandidate { installed_package: installed.name.clone(), candidate_package: record.name.clone(), replaces_installed: false, installed_version: installed.version.clone(), installed_revision: installed.revision, installed_completed_at, candidate_version: record.version.clone(), candidate_revision: record.revision, candidate_completed_at: record.completed_at, runtime_dependencies: record.runtime_dependencies.clone(), provides: record.provides.clone(), conflicts: record.conflicts.clone(), repo_priority: *repo_priority, origin: UpdateOrigin::Binary { repo_name: record.repo_name.clone(), record: Box::new(record.clone()), }, }; if best.as_ref().is_none_or(|current| { update_candidate_is_preferred(&binary_candidate, current, prefer_binary) }) { best = Some(binary_candidate); } } best } pub(crate) fn collect_update_candidates( config: &config::Config, rootfs: &Path, requested_packages: &[String], ) -> Result> { let db_path = config.installed_db_path(rootfs); let installed = db::list_installed_package_records(&db_path)?; if installed.is_empty() { return Ok(Vec::new()); } let mut installed_by_real_name: HashMap> = HashMap::new(); for record in &installed { installed_by_real_name .entry(record.effective_real_name().to_string()) .or_default() .push(record); } let active_by_real_name: HashMap = installed_by_real_name .iter() .filter_map(|(real_name, records)| { super::super::select_primary_installed_record(records.iter().copied()) .cloned() .map(|record| (real_name.clone(), record)) }) .collect(); let target_real_names: HashSet = if requested_packages.is_empty() { active_by_real_name.keys().cloned().collect() } else { let mut targets = HashSet::new(); for package in requested_packages { if let Some(record) = installed.iter().find(|record| record.name == *package) { targets.insert(record.effective_real_name().to_string()); } else if active_by_real_name.contains_key(package) { targets.insert(package.clone()); } else { ui::warn(format!("Package '{}' is not installed; skipping", package)); } } targets }; let target_package_names: HashSet = if requested_packages.is_empty() { active_by_real_name .values() .map(|record| record.name.clone()) .collect() } else { let mut targets = HashSet::new(); for package in requested_packages { if let Some(record) = installed.iter().find(|record| record.name == *package) { targets.insert(record.name.clone()); } else if let Some(record) = active_by_real_name.get(package) { targets.insert(record.name.clone()); } } targets }; let source_candidates = if config.repo_settings.prefer_binary { HashMap::new() } else { collect_best_source_update_candidates(config, &target_real_names)? }; let source_replacement_candidates = if config.repo_settings.prefer_binary { HashMap::new() } else { collect_best_source_replacement_candidates(config, &target_package_names)? }; let binary_candidates = collect_best_binary_update_candidates(config, rootfs, &target_real_names)?; let binary_replacement_candidates = collect_best_binary_replacement_candidates(config, rootfs, &target_package_names)?; let mut updates = Vec::new(); let mut targets: Vec<_> = target_real_names.into_iter().collect(); targets.sort(); for target in targets { let Some(installed) = active_by_real_name.get(&target) else { continue; }; let installed_completed_at = installed_package_completed_at(installed, &db_path, rootfs)?; if let Some(candidate) = select_update_candidate( installed, installed_completed_at, &source_replacement_candidates, &binary_replacement_candidates, &source_candidates, &binary_candidates, config.repo_settings.prefer_binary, ) { updates.push(candidate); } } Ok(updates) } pub(crate) fn collect_missing_update_dependencies( candidates: &[UpdateCandidate], db_path: &Path, ) -> Result> { let mut planned_provides = HashSet::new(); for candidate in candidates { planned_provides.insert(candidate.candidate_package.clone()); for provide in &candidate.provides { planned_provides.insert(provide.clone()); } } let mut missing = Vec::new(); for candidate in candidates { for dep in &candidate.runtime_dependencies { if planned_provides.contains(deps::dep_name(dep)) { continue; } if deps::is_dep_satisfied_in_db(dep, db_path)? { continue; } if !missing.contains(dep) { missing.push(dep.clone()); } } } Ok(missing) } fn update_release(version: &str, revision: u32) -> String { format!("{version}-{revision}") } fn package_word(count: usize) -> &'static str { if count == 1 { "package" } else { "packages" } } fn update_package_label(candidate: &UpdateCandidate) -> String { if candidate.replaces_installed { format!( "{} → {}", candidate.installed_package, candidate.candidate_package ) } else { candidate.candidate_package.clone() } } fn format_update_summary( updates: &[UpdateCandidate], missing_dependencies: &[String], download_size: Option, ) -> String { let labels: Vec<_> = updates.iter().map(update_package_label).collect(); let label_width = labels .iter() .map(|label| label.chars().count()) .max() .unwrap_or(0); let mut output = format!("Updates ({})\n", updates.len()); for (candidate, label) in updates.iter().zip(labels) { let installed = update_release(&candidate.installed_version, candidate.installed_revision); let available = update_release(&candidate.candidate_version, candidate.candidate_revision); let newer_build = candidate.installed_version == candidate.candidate_version && candidate.installed_revision == candidate.candidate_revision && compare_completed_at( candidate.candidate_completed_at, candidate.installed_completed_at, ) == Ordering::Greater; let build_note = if newer_build { " (newer build)" } else { "" }; output.push_str(&format!( " {label:, ) -> Option { let mut total = 0u64; for candidate in updates { let UpdateOrigin::Binary { record, .. } = &candidate.origin else { return None; }; total = total.checked_add(record.size)?; } if let Some(plan) = dependency_plan { for step in plan.actionable_steps() { match &step.origin { planner::PlanOrigin::Binary { record, .. } => { total = total.checked_add(record.size)?; } planner::PlanOrigin::Source { .. } => return None, planner::PlanOrigin::Installed => {} } } } Some(total) } fn start_update_status(package_count: usize) -> (ProgressBar, bool) { let interactive = std::io::stdout().is_terminal() && std::io::stderr().is_terminal(); if !interactive { println!( "Updating {package_count} {}...", package_word(package_count) ); return (ProgressBar::hidden(), false); } let status = ProgressBar::new_spinner(); status.set_style( ProgressStyle::default_spinner() .template("{spinner:.cyan} {msg}") .unwrap_or_else(|_| ProgressStyle::default_spinner()), ); status.enable_steady_tick(std::time::Duration::from_millis(80)); status.set_message(format!( "Updating {package_count} {}", package_word(package_count) )); (status, true) } fn print_update_complete(updates: &[UpdateCandidate], interactive: bool) { if interactive { println!("Updated {} {}", updates.len(), package_word(updates.len())); return; } for candidate in updates { println!( "Updated {} {} → {}", update_package_label(candidate), update_release(&candidate.installed_version, candidate.installed_revision), update_release(&candidate.candidate_version, candidate.candidate_revision) ); } } pub(crate) fn run_update_command( packages: &[String], config: &config::Config, options: UpdateCommandOptions<'_>, ) -> Result<()> { let updates = collect_update_candidates(config, options.rootfs, packages)?; if updates.is_empty() { println!("All packages are up to date."); return Ok(()); } let conflict_subjects: Vec<_> = updates .iter() .map(|candidate| super::super::InstallConflictSubject { package: candidate.candidate_package.clone(), provides: candidate.provides.clone(), conflicts: candidate.conflicts.clone(), }) .collect(); super::super::validate_no_transaction_conflicts(&conflict_subjects)?; let db_path = config.installed_db_path(options.rootfs); let missing_deps = if options.no_deps { Vec::new() } else { collect_missing_update_dependencies(&updates, &db_path)? }; let dependency_plan = if missing_deps.is_empty() { None } else { Some(planner::build_dependency_install_plan( config, options.rootfs, &missing_deps, planner::PlannerOptions { assume_yes: options.assume_yes, prefer_binary: config.repo_settings.prefer_binary, local_sibling_root: None, include_test_deps: options.install_test_deps, lib32_only_requested_specs: false, }, )?) }; let download_size = planned_update_download_size(&updates, dependency_plan.as_ref()); print!( "{}", format_update_summary(&updates, &missing_deps, download_size) ); if !options.dry_run && !options.assume_yes && !ui::prompt_yes_no("Proceed?", true)? { anyhow::bail!("Aborted"); } if options.dry_run { super::super::resolve_installed_conflicts_for_subjects( &conflict_subjects, options.rootfs, config, true, )?; } if options.dry_run { if let Some(plan) = dependency_plan.as_ref() { crate::commands::print_plan_summary(plan); } println!("Dry run: no changes made."); return Ok(()); } let (status, interactive) = start_update_status(updates.len()); let update_result = (|| -> Result<()> { let mut transaction_requests = Vec::new(); if let Some(plan) = dependency_plan.as_ref() { crate::commands::print_plan_summary(plan); transaction_requests.extend(super::super::install_requests_for_plan( plan, config, options.rootfs, )?); } for candidate in &updates { status.set_message(format!( "Updating {} {} · preparing {}", updates.len(), package_word(updates.len()), candidate.candidate_package )); let request = candidate_request_path(candidate, config, options.rootfs)?; transaction_requests.push(request); } status.set_message(format!( "Updating {} {} · installing", updates.len(), package_word(updates.len()) )); super::super::run_update_transaction_install_requests( super::super::DirectInstallOptions { rootfs: options.rootfs, no_deps: true, no_flags: options.no_flags, cross_prefix: options.cross_prefix, clean: options.clean, dry_run: false, lib32_only: false, install_test_deps: options.install_test_deps, }, config, &transaction_requests, )?; if options.clean { crate::commands::clean_build_workspace(config)?; } Ok(()) })(); status.finish_and_clear(); update_result?; print_update_complete(&updates, interactive); Ok(()) } #[cfg(test)] mod ux_tests { use super::*; fn binary_record(name: &str, version: &str, size: u64) -> db::repo::BinaryRepoPackageRecord { db::repo::BinaryRepoPackageRecord { repo_name: "core".into(), name: name.into(), real_name: None, version: version.into(), revision: 1, abi_breaking: false, built_against: Vec::new(), completed_at: None, filename: format!("{name}-{version}-1-x86_64.depot.pkg.tar.zst"), size, sha512: String::new(), description: None, homepage: None, license: None, provides: Vec::new(), conflicts: Vec::new(), replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), groups: Vec::new(), } } fn binary_update( name: &str, installed_version: &str, candidate_version: &str, size: u64, ) -> UpdateCandidate { UpdateCandidate { installed_package: name.into(), candidate_package: name.into(), replaces_installed: false, installed_version: installed_version.into(), installed_revision: 1, installed_completed_at: None, candidate_version: candidate_version.into(), candidate_revision: 1, candidate_completed_at: None, runtime_dependencies: Vec::new(), provides: Vec::new(), conflicts: Vec::new(), repo_priority: 0, origin: UpdateOrigin::Binary { repo_name: "core".into(), record: Box::new(binary_record(name, candidate_version, size)), }, } } #[test] fn update_summary_is_compact_and_aligned() { let updates = [ binary_update("curl", "8.11.0", "8.12.0", 3 * 1024), binary_update("openssl", "3.4.0", "3.4.1", 5 * 1024), ]; assert_eq!( format_update_summary(&updates, &["ca-certificates".into()], Some(8 * 1024)), concat!( "Updates (2)\n", " curl 8.11.0-1 → 8.12.0-1\n", " openssl 3.4.0-1 → 3.4.1-1\n", "\n", "Download: 8.0 KiB\n", "Dependencies: ca-certificates\n", "\n", ) ); } #[test] fn update_summary_marks_replacements_and_newer_builds() { let mut update = binary_update("icu78", "78.1", "79.1", 1024); update.candidate_package = "icu79".into(); update.replaces_installed = true; update.installed_version = "79.1".into(); update.installed_completed_at = Some(1); update.candidate_completed_at = Some(2); let summary = format_update_summary(&[update], &[], Some(1024)); assert!(summary.contains("icu78 → icu79")); assert!(summary.contains("79.1-1 → 79.1-1 (newer build)")); } #[test] fn planned_download_size_requires_all_binary_inputs() { let binary = binary_update("curl", "8.11.0", "8.12.0", 3 * 1024); assert_eq!( planned_update_download_size(std::slice::from_ref(&binary), None), Some(3 * 1024) ); let mut source = binary; source.origin = UpdateOrigin::Source { repo_name: "core".into(), path: PathBuf::from("/repo/curl.toml"), }; assert_eq!(planned_update_download_size(&[source], None), None); } }