From 2bc9e7a525e661737ac22d2f447c8aae35264557 Mon Sep 17 00:00:00 2001 From: SFG545 Date: Mon, 16 Mar 2026 22:47:35 -0500 Subject: [PATCH] Implement package replacement feature across the codebase - Introduced a new `replaces` field in various structs to support package replacements. - Updated database schema to include a `replaces` table and corresponding queries. - Modified the package index to handle replacements and ensure they take precedence over exact package names. - Enhanced the `create_source_repo_index` function to include replacement entries in the index. - Updated tests to validate the new replacement functionality, including parsing and metadata generation. - Refactored relevant functions and structures to accommodate the new `replaces` feature in package specifications and alternatives. --- Cargo.lock | 2 +- Cargo.toml | 2 +- meson.build | 2 +- src/builder/mod.rs | 18 +- src/commands.rs | 622 +++++++++++++++++++++++++++++++++---- src/cross.rs | 42 +-- src/db/mod.rs | 5 +- src/db/repo.rs | 66 +++- src/deps.rs | 1 + src/index.rs | 119 ++++++- src/package/interactive.rs | 89 +++++- src/package/packager.rs | 38 +++ src/package/spec.rs | 111 ++++++- src/planner.rs | 131 +++++--- 14 files changed, 1108 insertions(+), 140 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0097781..f12b695 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.26.0" +version = "0.26.1" dependencies = [ "anyhow", "ar", diff --git a/Cargo.toml b/Cargo.toml index 0523a10..281b1da 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.26.0" +version = "0.26.1" edition = "2024" [lints.rust] diff --git a/meson.build b/meson.build index 9ae3bbc..25e291b 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.26.0', + version: '0.26.1', meson_version: '>=0.60.0', default_options: ['buildtype=release'], ) diff --git a/src/builder/mod.rs b/src/builder/mod.rs index 25b2b94..b56dc99 100755 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -890,19 +890,23 @@ mod tests { #[test] fn test_effective_rustflags_applies_replace_rules() { - let mut flags = BuildFlags::default(); - flags.rustflags = vec!["-C".into(), "debuginfo=2".into()]; - flags.replace_rustflags = vec!["debuginfo=2=>opt-level=2".into()]; + let flags = BuildFlags { + rustflags: vec!["-C".into(), "debuginfo=2".into()], + replace_rustflags: vec!["debuginfo=2=>opt-level=2".into()], + ..BuildFlags::default() + }; assert_eq!(effective_rustflags(&flags), vec!["-C", "opt-level=2"]); } #[test] fn test_effective_rustflags_appends_rustltoflags_when_enabled() { - let mut flags = BuildFlags::default(); - flags.rustflags = vec!["-C".into(), "opt-level=3".into()]; - flags.rustltoflags = vec!["-Clinker-plugin-lto".into(), "-Cembed-bitcode=yes".into()]; - flags.use_lto = true; + let flags = BuildFlags { + rustflags: vec!["-C".into(), "opt-level=3".into()], + rustltoflags: vec!["-Clinker-plugin-lto".into(), "-Cembed-bitcode=yes".into()], + use_lto: true, + ..BuildFlags::default() + }; assert_eq!( effective_rustflags(&flags), diff --git a/src/commands.rs b/src/commands.rs index b82223f..fd05fb1 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -756,7 +756,8 @@ fn package_spec_from_repo_record( alternatives: package::Alternatives { provides: record.provides.clone(), conflicts: record.conflicts.clone(), - replaces: Vec::new(), + replaces: record.replaces.clone(), + lib32: None, }, manual_sources: Vec::new(), source: Vec::new(), @@ -1224,6 +1225,7 @@ fn build_lib32_companion_package( struct PlannedStagedInstall { is_update: bool, remove_paths: Vec, + replacement_removals: Vec, renamed_transition: Option, hook_context: install::hooks::HookExecutionContextOwned, } @@ -1427,6 +1429,24 @@ fn collect_conflicting_installed_packages( Ok(removals) } +fn collect_installed_replacement_packages( + db_path: &Path, + pkg_spec: &package::PackageSpec, +) -> Result> { + let installed = db::get_installed_packages(db_path)?; + let mut replacements: Vec = pkg_spec + .alternatives + .replaces + .iter() + .filter(|name| *name != &pkg_spec.package.name) + .filter(|name| installed.contains(*name)) + .cloned() + .collect(); + replacements.sort(); + replacements.dedup(); + Ok(replacements) +} + fn remove_installed_package_with_hooks( package: &str, rootfs: &Path, @@ -1651,9 +1671,11 @@ fn plan_staged_install( let db_path = config.installed_db_path(rootfs); let new_manifest = staging::generate_manifest_with_dirs(destdir)?; + let replacement_removals = collect_installed_replacement_packages(&db_path, pkg_spec)?; let renamed_transition = build_renamed_package_transition(&db_path, pkg_spec, &new_manifest)?; let is_update = db::get_package_version(&db_path, &pkg_spec.package.name)?.is_some() - || renamed_transition.is_some(); + || renamed_transition.is_some() + || !replacement_removals.is_empty(); let mut remove_paths = db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_manifest)?; if let Some(transition) = &renamed_transition { @@ -1695,6 +1717,7 @@ fn plan_staged_install( Ok(PlannedStagedInstall { is_update, remove_paths, + replacement_removals, renamed_transition, hook_context: install::hooks::HookExecutionContextOwned { operation, @@ -1831,7 +1854,22 @@ fn install_planned_packages_to_rootfs( config: &config::Config, ) -> Result> { let mut installed = Vec::with_capacity(plans.len()); - for plan in plans { + let mut removed_replacements = HashSet::new(); + for (idx, plan) in plans.iter().enumerate() { + ui::info(format!( + "{}/{} Installing package {}-{}-{}", + idx + 1, + plans.len(), + plan.spec.package.name, + plan.spec.package.version, + plan.spec.package.revision + )); + for package in &plan.staged.replacement_removals { + if removed_replacements.insert(package.clone()) { + ui::info(format!("Removing package {}...", package)); + remove_installed_package_with_hooks(package, rootfs, config)?; + } + } install_staged_to_rootfs(&plan.spec, &plan.destdir, rootfs, config, &plan.staged)?; installed.push(InstalledPackageOutcome { package: plan.spec.package.clone(), @@ -2046,12 +2084,18 @@ fn run_search_command( } else { format!(" provides={}", hit.provides.join(",")) }; + let replaces = if hit.replaces.is_empty() { + String::new() + } else { + format!(" replaces={}", hit.replaces.join(",")) + }; ui::info(format!( - " {} [{}] {}{}", + " {} [{}] {}{}{}", hit.name, source_hit_origin(config, &hit.path), hit.path.display(), - provides + provides, + replaces )); } } @@ -2242,6 +2286,7 @@ enum UpdateOrigin { struct UpdateCandidate { installed_package: String, candidate_package: String, + replaces_installed: bool, installed_version: String, installed_revision: u32, installed_completed_at: Option, @@ -2264,6 +2309,66 @@ struct SourceUpdateCandidate { spec: package::PackageSpec, } +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, @@ -2482,30 +2587,7 @@ fn collect_best_source_update_candidates( }; let replace = match best.get(&stream_name) { - Some(current) => 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 - } - } - } - } - }, + Some(current) => source_update_candidate_is_better(&candidate, current), None => true, }; @@ -2518,6 +2600,46 @@ fn collect_best_source_update_candidates( 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, @@ -2548,30 +2670,13 @@ fn collect_best_binary_update_candidates( } let replace = match best.get(&stream_name) { - Some((current_priority, current)) => 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_cfg.priority != *current_priority { - repo_cfg.priority < *current_priority - } else if repo_name != ¤t.repo_name { - repo_name < ¤t.repo_name - } else { - record.filename < current.filename - } - } - } - } - }, + Some((current_priority, current)) => binary_update_candidate_is_better( + repo_name, + repo_cfg.priority, + &record, + *current_priority, + current, + ), None => true, }; @@ -2584,9 +2689,59 @@ fn collect_best_binary_update_candidates( 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) +} + 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, @@ -2594,6 +2749,60 @@ fn select_update_candidate( 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, @@ -2607,6 +2816,7 @@ fn select_update_candidate( 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, @@ -2637,6 +2847,7 @@ fn select_update_candidate( 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, @@ -2707,14 +2918,37 @@ fn collect_update_candidates( } 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(); @@ -2727,6 +2961,8 @@ fn collect_update_candidates( 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, @@ -2791,6 +3027,28 @@ fn run_update_install_command( ) } +fn confirm_update_replacements(candidates: &[UpdateCandidate]) -> Result<()> { + for candidate in candidates { + if !candidate.replaces_installed { + continue; + } + if !ui::prompt_yes_no( + &format!( + "replace {} with {}?", + candidate.installed_package, candidate.candidate_package + ), + true, + )? { + anyhow::bail!( + "Replacement declined: {} -> {}", + candidate.installed_package, + candidate.candidate_package + ); + } + } + Ok(()) +} + fn run_update_command( packages: &[String], config: &config::Config, @@ -2860,6 +3118,10 @@ fn run_update_command( anyhow::bail!("Aborted"); } + if !options.dry_run { + confirm_update_replacements(&updates)?; + } + resolve_installed_conflicts_for_subjects( &conflict_subjects, options.rootfs, @@ -5671,6 +5933,7 @@ mod tests { license: None, provides: Vec::new(), conflicts: Vec::new(), + replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), } @@ -5862,6 +6125,7 @@ optional = [] license: Some("MIT".into()), provides: vec!["pkg-virtual".into()], conflicts: Vec::new(), + replaces: Vec::new(), runtime_dependencies: vec!["glibc".into()], optional_dependencies: vec!["manpages".into()], }; @@ -6163,6 +6427,7 @@ optional = [] license: Some("ISC".into()), provides: Vec::new(), conflicts: Vec::new(), + replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }; @@ -6515,6 +6780,8 @@ optional = [] let selected = select_update_candidate( &installed_records[0], installed_records[0].completed_at, + &HashMap::new(), + &HashMap::new(), &source_candidates, &HashMap::new(), false, @@ -6601,6 +6868,7 @@ optional = [] license: None, provides: Vec::new(), conflicts: Vec::new(), + replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }, @@ -6610,6 +6878,8 @@ optional = [] let selected = select_update_candidate( &installed, None, + &HashMap::new(), + &HashMap::new(), &source_candidates, &binary_candidates, true, @@ -6666,6 +6936,8 @@ optional = [] let selected = select_update_candidate( &installed, Some(100), + &HashMap::new(), + &HashMap::new(), &source_candidates, &HashMap::new(), true, @@ -6675,6 +6947,245 @@ optional = [] assert_eq!(selected.candidate_completed_at, Some(200)); } + #[test] + fn select_update_candidate_prefers_replacement_candidate() { + let installed = db::InstalledPackageRecord { + name: "findutils".into(), + real_name: None, + version: "4.9.0".into(), + revision: 1, + abi_breaking: false, + completed_at: Some(100), + }; + + let source_spec = package::PackageSpec { + package: package::PackageInfo { + name: "findutils".into(), + real_name: None, + version: "5.0.0".into(), + revision: 1, + description: "findutils".into(), + homepage: "https://example.test/findutils".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let replacement_spec = package::PackageSpec { + package: package::PackageInfo { + name: "busybox".into(), + real_name: None, + version: "1.36.1".into(), + revision: 1, + description: "busybox".into(), + homepage: "https://example.test/busybox".into(), + abi_breaking: false, + license: vec!["GPL-2.0-only".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives { + provides: Vec::new(), + conflicts: Vec::new(), + replaces: vec!["findutils".into()], + lib32: None, + }, + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let source_candidates = HashMap::from([( + "findutils".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 5, + path: PathBuf::from("/tmp/findutils.toml"), + completed_at: Some(200), + spec: source_spec, + }, + )]); + let source_replacement_candidates = HashMap::from([( + "findutils".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 0, + path: PathBuf::from("/tmp/busybox.toml"), + completed_at: Some(150), + spec: replacement_spec, + }, + )]); + + let selected = select_update_candidate( + &installed, + installed.completed_at, + &source_replacement_candidates, + &HashMap::new(), + &source_candidates, + &HashMap::new(), + false, + ) + .expect("expected replacement update candidate"); + + assert!(selected.replaces_installed); + assert_eq!(selected.installed_package, "findutils"); + assert_eq!(selected.candidate_package, "busybox"); + } + + #[test] + fn install_planned_packages_to_rootfs_defers_replacement_removal_until_replacing_plan() + -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + + let old_spec = package::PackageSpec { + package: package::PackageInfo { + name: "findutils".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "findutils".into(), + homepage: "https://example.test/findutils".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let old_dest = rootfs.path().join("old-dest"); + fs::create_dir_all(old_dest.join("usr/bin"))?; + fs::write(old_dest.join("usr/bin/find"), "old-find")?; + install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; + + let alpha_spec = package::PackageSpec { + package: package::PackageInfo { + name: "alpha".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "alpha".into(), + homepage: "https://example.test/alpha".into(), + abi_breaking: false, + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let alpha_dest = rootfs.path().join("alpha-dest"); + fs::create_dir_all(alpha_dest.join("usr/bin"))?; + fs::create_dir_all(alpha_dest.join("scripts"))?; + fs::write(alpha_dest.join("usr/bin/alpha"), "alpha")?; + fs::write( + alpha_dest.join("scripts/post_install"), + "if [ -e \"$DEPOT_ROOTFS/usr/bin/find\" ]; then printf '%s' present > \"$DEPOT_ROOTFS/alpha-marker\"; else printf '%s' missing > \"$DEPOT_ROOTFS/alpha-marker\"; fi\n", + )?; + + let replacement_spec = package::PackageSpec { + package: package::PackageInfo { + name: "busybox".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "busybox".into(), + homepage: "https://example.test/busybox".into(), + abi_breaking: false, + license: vec!["GPL-2.0-only".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives { + provides: Vec::new(), + conflicts: Vec::new(), + replaces: vec!["findutils".into()], + lib32: None, + }, + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let replacement_dest = rootfs.path().join("replacement-dest"); + fs::create_dir_all(replacement_dest.join("usr/bin"))?; + fs::write(replacement_dest.join("usr/bin/find"), "new-find")?; + + let mut plans = Vec::new(); + plans.extend(plan_package_outputs_for_install( + &alpha_spec, + &alpha_dest, + rootfs.path(), + &cfg, + )?); + plans.extend(plan_package_outputs_for_install( + &replacement_spec, + &replacement_dest, + rootfs.path(), + &cfg, + )?); + + install_planned_packages_to_rootfs(&plans, rootfs.path(), &cfg)?; + + assert_eq!( + fs::read_to_string(rootfs.path().join("alpha-marker"))?, + "present" + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/bin/find"))?, + "new-find" + ); + assert!( + db::get_package_version(&cfg.installed_db_path(rootfs.path()), "findutils")?.is_none() + ); + assert_eq!( + db::get_package_version(&cfg.installed_db_path(rootfs.path()), "busybox")?, + Some("1.0".into()) + ); + Ok(()) + } + #[test] fn collect_update_candidates_skips_source_when_prefer_binary_is_enabled() -> Result<()> { let temp = tempfile::tempdir().context("Failed to create temp dir")?; @@ -6908,6 +7419,7 @@ optional = [] UpdateCandidate { installed_package: "pkg".into(), candidate_package: "pkg".into(), + replaces_installed: false, installed_version: "1.0".into(), installed_revision: 1, installed_completed_at: None, @@ -6926,6 +7438,7 @@ optional = [] UpdateCandidate { installed_package: "helper".into(), candidate_package: "helper".into(), + replaces_installed: false, installed_version: "1.0".into(), installed_revision: 1, installed_completed_at: None, @@ -6944,6 +7457,7 @@ optional = [] UpdateCandidate { installed_package: "tool".into(), candidate_package: "tool".into(), + replaces_installed: false, installed_version: "1.0".into(), installed_revision: 1, installed_completed_at: None, @@ -7511,7 +8025,7 @@ optional = [] "lib32-compiler-rt-1.0-1-x86_64.depot.pkg.tar.zst", ); - let steps = vec![ + let steps = [ planner::PlannedStep { package: "expat".into(), action: planner::PlanAction::InstallBinary, diff --git a/src/cross.rs b/src/cross.rs index 34d8750..21ec38a 100755 --- a/src/cross.rs +++ b/src/cross.rs @@ -220,27 +220,6 @@ pub fn lib32_target_triple(host: &str) -> String { } } -#[cfg(test)] -mod tests { - use super::lib32_target_triple; - - #[test] - fn lib32_target_triple_preserves_explicit_x86_variant() { - assert_eq!( - lib32_target_triple("x86_64-sfg-linux-gnu"), - "i686-sfg-linux-gnu" - ); - assert_eq!( - lib32_target_triple("i686-sfg-linux-gnu"), - "i686-sfg-linux-gnu" - ); - assert_eq!( - lib32_target_triple("i586-sfg-linux-gnu"), - "i586-sfg-linux-gnu" - ); - } -} - /// Find a cross-compilation tool in PATH and return its absolute path fn find_tool(prefix: &str, suffixes: &[&str], required: bool) -> Result { for suffix in suffixes { @@ -264,3 +243,24 @@ fn find_tool(prefix: &str, suffixes: &[&str], required: bool) -> Result Err(anyhow::anyhow!("Tool not found")) } + +#[cfg(test)] +mod tests { + use super::lib32_target_triple; + + #[test] + fn lib32_target_triple_preserves_explicit_x86_variant() { + assert_eq!( + lib32_target_triple("x86_64-sfg-linux-gnu"), + "i686-sfg-linux-gnu" + ); + assert_eq!( + lib32_target_triple("i686-sfg-linux-gnu"), + "i686-sfg-linux-gnu" + ); + assert_eq!( + lib32_target_triple("i586-sfg-linux-gnu"), + "i586-sfg-linux-gnu" + ); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index fcaf552..9266567 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -877,6 +877,7 @@ mod tests { provides: vec![format!("{}-virtual", name)], conflicts: Vec::new(), replaces: Vec::new(), + lib32: None, }, manual_sources: Vec::new(), source: vec![Source { @@ -979,8 +980,8 @@ mod tests { let ts = filetime::FileTime::from_unix_time(1_700_000_000, 0); filetime::set_file_mtime(&file, ts).unwrap(); - filetime::set_file_mtime(&dest.join("usr"), ts).unwrap(); - filetime::set_file_mtime(&dest.join("usr/bin"), ts).unwrap(); + filetime::set_file_mtime(dest.join("usr"), ts).unwrap(); + filetime::set_file_mtime(dest.join("usr/bin"), ts).unwrap(); filetime::set_file_mtime(&dest, ts).unwrap(); register_package(&db_path, &spec, &dest).unwrap(); diff --git a/src/db/repo.rs b/src/db/repo.rs index d4b387f..3efd6d1 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -87,6 +87,7 @@ struct IndexedPackage { sha512: String, provides: Vec, conflicts: Vec, + replaces: Vec, runtime_dependencies: Vec, optional_dependencies: Vec, archive_files: Vec, @@ -125,6 +126,7 @@ pub struct BinaryRepoPackageRecord { pub license: Option, pub provides: Vec, pub conflicts: Vec, + pub replaces: Vec, pub runtime_dependencies: Vec, pub optional_dependencies: Vec, } @@ -317,6 +319,11 @@ impl RepoManager { name TEXT NOT NULL, FOREIGN KEY(package_id) REFERENCES packages(id) ); + CREATE TABLE replaces ( + package_id INTEGER, + name TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); CREATE TABLE dependencies ( package_id INTEGER, kind TEXT NOT NULL, @@ -338,6 +345,7 @@ impl RepoManager { "CREATE INDEX idx_packages_name ON packages(name); CREATE INDEX idx_provides_name ON provides(name); CREATE INDEX idx_conflicts_name ON conflicts(name); + CREATE INDEX idx_replaces_name ON replaces(name); CREATE INDEX idx_dependencies_name ON dependencies(name); CREATE INDEX idx_dependencies_kind ON dependencies(kind); CREATE INDEX idx_repo_files_path ON files(path);", @@ -369,6 +377,7 @@ impl RepoManager { let mut license = None; let mut provides = Vec::new(); let mut conflicts = Vec::new(); + let mut replaces = Vec::new(); let mut runtime_dependencies = Vec::new(); let mut optional_dependencies = Vec::new(); let mut archive_files = Vec::new(); @@ -439,6 +448,14 @@ impl RepoManager { .map(String::from) .collect(); } + if let Some(replaces_arr) = metadata.get("replaces").and_then(|v| v.as_array()) + { + replaces = replaces_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } if let Some(runtime_arr) = metadata .get("dependencies") .and_then(|v| v.get("runtime")) @@ -506,6 +523,7 @@ impl RepoManager { sha512, provides, conflicts, + replaces, runtime_dependencies, optional_dependencies, archive_files, @@ -529,6 +547,7 @@ impl RepoManager { sha512, provides, conflicts, + replaces, runtime_dependencies, optional_dependencies, archive_files, @@ -570,6 +589,12 @@ impl RepoManager { params![package_id, conflict], )?; } + for replacement in replaces { + conn.execute( + "INSERT INTO replaces (package_id, name) VALUES (?1, ?2)", + params![package_id, replacement], + )?; + } for dep in runtime_dependencies { conn.execute( @@ -1592,6 +1617,26 @@ fn query_package_conflicts(conn: &Connection, package_id: i64) -> Result Result> { + let has_replaces_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='replaces'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_replaces_table { + return Ok(Vec::new()); + } + + let mut stmt = conn.prepare("SELECT name FROM replaces WHERE package_id = ?1 ORDER BY name")?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + fn query_package_runtime_deps(conn: &Connection, package_id: i64) -> Result> { let has_dependencies_table: bool = conn .query_row( @@ -1677,13 +1722,26 @@ fn find_cached_binary_repo_packages( p.license FROM packages p WHERE lower(p.name) = lower(?1) + OR EXISTS ( + SELECT 1 FROM replaces rp + WHERE rp.package_id = p.id + AND lower(rp.name) = lower(?1) + ) OR EXISTS ( SELECT 1 FROM provides pr WHERE pr.package_id = p.id AND lower(pr.name) = lower(?1) ) ORDER BY - CASE WHEN lower(p.name) = lower(?1) THEN 0 ELSE 1 END, + CASE + WHEN EXISTS ( + SELECT 1 FROM replaces rp + WHERE rp.package_id = p.id + AND lower(rp.name) = lower(?1) + ) THEN 0 + WHEN lower(p.name) = lower(?1) THEN 1 + ELSE 2 + END, p.name ASC" ); let mut stmt = conn.prepare(&sql)?; @@ -1709,6 +1767,7 @@ fn find_cached_binary_repo_packages( license: row.get(13)?, provides: Vec::new(), conflicts: Vec::new(), + replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }, @@ -1720,6 +1779,7 @@ fn find_cached_binary_repo_packages( let (package_id, mut rec) = row?; rec.provides = query_package_provides(&conn, package_id)?; rec.conflicts = query_package_conflicts(&conn, package_id)?; + rec.replaces = query_package_replaces(&conn, package_id)?; rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; out.push(rec); @@ -1791,6 +1851,7 @@ fn list_cached_binary_repo_packages( license: row.get(13)?, provides: Vec::new(), conflicts: Vec::new(), + replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }, @@ -1802,6 +1863,7 @@ fn list_cached_binary_repo_packages( let (package_id, mut rec) = row?; rec.provides = query_package_provides(&conn, package_id)?; rec.conflicts = query_package_conflicts(&conn, package_id)?; + rec.replaces = query_package_replaces(&conn, package_id)?; rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; out.push(rec); @@ -2754,6 +2816,7 @@ revision = 1 license: None, provides: Vec::new(), conflicts: Vec::new(), + replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }; @@ -2792,6 +2855,7 @@ revision = 1 license: None, provides: Vec::new(), conflicts: Vec::new(), + replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), } diff --git a/src/deps.rs b/src/deps.rs index 3a500ea..cf93180 100755 --- a/src/deps.rs +++ b/src/deps.rs @@ -715,6 +715,7 @@ mod tests { provides: vec!["libfoo".into()], conflicts: Vec::new(), replaces: Vec::new(), + lib32: None, }, )]), package_dependencies: Default::default(), diff --git a/src/index.rs b/src/index.rs index 6df3139..873014a 100755 --- a/src/index.rs +++ b/src/index.rs @@ -14,9 +14,11 @@ use walkdir::WalkDir; pub const SOURCE_REPO_INDEX_FILENAME: &str = "depot-index.tsv"; const SOURCE_REPO_INDEX_HEADER_V1: &str = "depot-source-index-v1"; const SOURCE_REPO_INDEX_HEADER_V2: &str = "depot-source-index-v2"; +const SOURCE_REPO_INDEX_HEADER_V3: &str = "depot-source-index-v3"; const SOURCE_REPO_INDEX_KIND_PACKAGE: &str = "P"; const SOURCE_REPO_INDEX_KIND_PROVIDES: &str = "V"; const SOURCE_REPO_INDEX_KIND_CONFLICTS: &str = "C"; +const SOURCE_REPO_INDEX_KIND_REPLACES: &str = "X"; const SOURCE_REPO_INDEX_KIND_DEP_BUILD: &str = "B"; const SOURCE_REPO_INDEX_KIND_DEP_RUNTIME: &str = "R"; const SOURCE_REPO_INDEX_KIND_DEP_TEST: &str = "T"; @@ -50,6 +52,8 @@ pub struct PackageIndex { by_name: HashMap, /// Provided name -> spec paths (can be multiple) by_provides: HashMap>, + /// Replaced package name -> replacement spec paths (can be multiple) + by_replaces: HashMap>, } /// Source package search result from `PackageIndex`. @@ -58,6 +62,7 @@ pub struct SourceSearchHit { pub name: String, pub path: PathBuf, pub provides: Vec, + pub replaces: Vec, } #[derive(Debug, Clone)] @@ -66,6 +71,7 @@ struct IndexedSpecRows { rel: String, provides: Vec, conflicts: Vec, + replaces: Vec, deps: Vec<(String, String)>, } @@ -77,10 +83,11 @@ pub fn source_repo_index_path(repo_root: &Path) -> PathBuf { /// Create/update the source-repo package index at `repo_root`. /// /// The file format is line-based TSV with deterministic ordering: -/// - Header: `depot-source-index-v2` +/// - Header: `depot-source-index-v3` /// - Package name row: `P` /// - Provides row: `V` /// - Conflicts row: `C` +/// - Replaces row: `X` /// - Dependency rows: /// - `B` for build dependencies /// - `R` for runtime dependencies @@ -140,6 +147,16 @@ pub fn create_source_repo_index( provides.insert(provide.clone()); } + let mut replaces = BTreeSet::new(); + for alternatives in spec.package_alternatives.values() { + for replacement in &alternatives.replaces { + replaces.insert(replacement.clone()); + } + } + for replacement in &spec.alternatives.replaces { + replaces.insert(replacement.clone()); + } + let mut deps = BTreeSet::new(); for dep in &spec.dependencies.build { deps.insert((SOURCE_REPO_INDEX_KIND_DEP_BUILD.to_string(), dep.clone())); @@ -173,6 +190,7 @@ pub fn create_source_repo_index( rel, provides: provides.into_iter().collect(), conflicts: conflicts.into_iter().collect(), + replaces: replaces.into_iter().collect(), deps: deps.into_iter().collect(), }); } @@ -191,6 +209,13 @@ pub fn create_source_repo_index( spec_row.rel.clone(), )); } + for replacement in &spec_row.replaces { + rows.push(( + SOURCE_REPO_INDEX_KIND_REPLACES.to_string(), + replacement.clone(), + spec_row.rel.clone(), + )); + } for conflict in &spec_row.conflicts { rows.push(( SOURCE_REPO_INDEX_KIND_CONFLICTS.to_string(), @@ -209,7 +234,7 @@ pub fn create_source_repo_index( }); let mut out = String::new(); - out.push_str(SOURCE_REPO_INDEX_HEADER_V2); + out.push_str(SOURCE_REPO_INDEX_HEADER_V3); out.push('\n'); for (kind, name, rel) in &rows { if name.contains('\n') || name.contains('\r') || name.contains('\t') { @@ -359,7 +384,10 @@ impl PackageIndex { .next() .ok_or_else(|| anyhow::anyhow!("Missing source index header"))?; let header = header.trim(); - if header != SOURCE_REPO_INDEX_HEADER_V1 && header != SOURCE_REPO_INDEX_HEADER_V2 { + if header != SOURCE_REPO_INDEX_HEADER_V1 + && header != SOURCE_REPO_INDEX_HEADER_V2 + && header != SOURCE_REPO_INDEX_HEADER_V3 + { anyhow::bail!( "Unsupported source index header '{}' in {}", header, @@ -396,6 +424,12 @@ impl PackageIndex { .or_default() .push(path); } + SOURCE_REPO_INDEX_KIND_REPLACES => { + self.by_replaces + .entry(name.to_string()) + .or_default() + .push(path); + } SOURCE_REPO_INDEX_KIND_CONFLICTS | SOURCE_REPO_INDEX_KIND_DEP_BUILD | SOURCE_REPO_INDEX_KIND_DEP_RUNTIME @@ -452,10 +486,30 @@ impl PackageIndex { .or_default() .push(path.clone()); } + for replacement in &spec.alternatives.replaces { + self.by_replaces + .entry(replacement.clone()) + .or_default() + .push(path.clone()); + } } /// Find a spec by package name or provides pub fn find(&self, name: &str) -> Option { + if let Some(paths) = self.by_replaces.get(name) { + if paths.len() > 1 { + crate::log_warn!( + "Multiple packages replace '{}': {:?}", + name, + paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + ); + } + return paths.first().cloned(); + } + // First try by name if let Some(path) = self.by_name.get(name) { return Some(path.clone()); @@ -479,6 +533,11 @@ impl PackageIndex { None } + /// Return all source specs that replace the requested package name. + pub fn find_replacements(&self, name: &str) -> Vec { + self.by_replaces.get(name).cloned().unwrap_or_default() + } + /// Return all source specs that provide the requested feature/package name. pub fn find_providers(&self, name: &str) -> Vec { self.by_provides.get(name).cloned().unwrap_or_default() @@ -496,17 +555,29 @@ impl PackageIndex { .push(provided.clone()); } } + let mut replaces_by_path: HashMap> = HashMap::new(); + for (replacement, paths) in &self.by_replaces { + for path in paths { + replaces_by_path + .entry(path.clone()) + .or_default() + .push(replacement.clone()); + } + } let mut hits = Vec::new(); for (name, path) in &self.by_name { let provides = provides_by_path.remove(path).unwrap_or_default(); + let replaces = replaces_by_path.remove(path).unwrap_or_default(); let name_match = name.to_ascii_lowercase().contains(&q); let provides_match = provides.iter().any(|p| p.to_ascii_lowercase().contains(&q)); - if name_match || provides_match { + let replaces_match = replaces.iter().any(|r| r.to_ascii_lowercase().contains(&q)); + if name_match || provides_match || replaces_match { hits.push(SourceSearchHit { name: name.clone(), path: path.clone(), provides, + replaces, }); } } @@ -639,7 +710,7 @@ mod tests { assert!(stats.index_path.exists()); let index_text = std::fs::read_to_string(stats.index_path).unwrap(); - assert!(index_text.starts_with("depot-source-index-v2\n")); + assert!(index_text.starts_with("depot-source-index-v3\n")); assert!(index_text.contains("C\tbusybox\tcore/hello/hello.toml\n")); assert!(index_text.contains("R\tglibc\tcore/hello/hello.toml\n")); @@ -712,4 +783,42 @@ mod tests { assert_eq!(providers.len(), 1); assert!(providers[0].ends_with(Path::new("core/base/base.toml"))); } + + #[test] + fn replacement_index_entries_take_precedence_over_exact_package_names() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("depot"); + write_meta_spec( + &repo_root.join("core/busybox/busybox.toml"), + "busybox", + &[], + &[], + &[], + ); + write_meta_spec( + &repo_root.join("core/findutils/findutils.toml"), + "findutils", + &[], + &[], + &[], + ); + + let busybox_spec = repo_root.join("core/busybox/busybox.toml"); + let text = std::fs::read_to_string(&busybox_spec).unwrap(); + std::fs::write( + &busybox_spec, + format!( + "{}\n[alternatives]\nreplaces = [\"findutils\"]\n", + text.trim_end() + ), + ) + .unwrap(); + + let index = PackageIndex::build_with_repo_dir(Some(repo_root)); + let resolved = index.find("findutils").expect("replacement should resolve"); + assert!(resolved.ends_with(Path::new("core/busybox/busybox.toml"))); + let replacements = index.find_replacements("findutils"); + assert_eq!(replacements.len(), 1); + assert!(replacements[0].ends_with(Path::new("core/busybox/busybox.toml"))); + } } diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 65e718d..010a30c 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -665,7 +665,11 @@ pub fn create_interactive() -> Result { "Conflicting package/provide", "Installed package or provided name that must be removed before install", )?, - replaces: Vec::new(), + replaces: prompt_repeating_list( + "Replaced package", + "Package name that should resolve to this package and be replaced during install/update", + )?, + lib32: None, } } else { Alternatives::default() @@ -848,7 +852,11 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { root.insert("source".into(), Value::Array(arr)); } - if !spec.alternatives.provides.is_empty() || !spec.alternatives.conflicts.is_empty() { + if !spec.alternatives.provides.is_empty() + || !spec.alternatives.conflicts.is_empty() + || !spec.alternatives.replaces.is_empty() + || spec.alternatives.lib32.is_some() + { let mut alternatives_tbl = Table::new(); if !spec.alternatives.provides.is_empty() { alternatives_tbl.insert( @@ -874,6 +882,60 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.alternatives.replaces.is_empty() { + alternatives_tbl.insert( + "replaces".into(), + Value::Array( + spec.alternatives + .replaces + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if let Some(lib32) = &spec.alternatives.lib32 { + let mut lib32_tbl = Table::new(); + if !lib32.provides.is_empty() { + lib32_tbl.insert( + "provides".into(), + Value::Array( + lib32 + .provides + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !lib32.conflicts.is_empty() { + lib32_tbl.insert( + "conflicts".into(), + Value::Array( + lib32 + .conflicts + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !lib32.replaces.is_empty() { + lib32_tbl.insert( + "replaces".into(), + Value::Array( + lib32 + .replaces + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !lib32_tbl.is_empty() { + alternatives_tbl.insert("lib32".into(), Value::Table(lib32_tbl)); + } + } root.insert("alternatives".into(), Value::Table(alternatives_tbl)); } @@ -1861,7 +1923,12 @@ mod tests { alternatives: Alternatives { provides: vec!["editor".into(), "sh".into()], conflicts: vec!["nano".into(), "busybox-sh".into()], - replaces: Vec::new(), + replaces: vec!["vi".into()], + lib32: Some(crate::package::AlternativeGroup { + provides: Vec::new(), + conflicts: Vec::new(), + replaces: vec!["lib32-vi".into()], + }), }, manual_sources: Vec::new(), source: vec![Source { @@ -1896,6 +1963,18 @@ mod tests { .get("conflicts") .and_then(|v| v.as_array()) .expect("expected alternatives.conflicts array"); + let replaces = alternatives + .get("replaces") + .and_then(|v| v.as_array()) + .expect("expected alternatives.replaces array"); + let lib32 = alternatives + .get("lib32") + .and_then(|v| v.as_table()) + .expect("expected alternatives.lib32 table"); + let lib32_replaces = lib32 + .get("replaces") + .and_then(|v| v.as_array()) + .expect("expected alternatives.lib32.replaces array"); assert_eq!(provides.len(), 2); assert_eq!(provides[0].as_str(), Some("editor")); @@ -1903,6 +1982,10 @@ mod tests { assert_eq!(conflicts.len(), 2); assert_eq!(conflicts[0].as_str(), Some("nano")); assert_eq!(conflicts[1].as_str(), Some("busybox-sh")); + assert_eq!(replaces.len(), 1); + assert_eq!(replaces[0].as_str(), Some("vi")); + assert_eq!(lib32_replaces.len(), 1); + assert_eq!(lib32_replaces[0].as_str(), Some("lib32-vi")); } #[test] diff --git a/src/package/packager.rs b/src/package/packager.rs index 9ea7952..671acae 100644 --- a/src/package/packager.rs +++ b/src/package/packager.rs @@ -192,6 +192,17 @@ impl Packager { .collect(), ), ); + map.insert( + "replaces".to_string(), + toml::Value::Array( + self.spec + .alternatives + .replaces + .iter() + .map(|s| toml::Value::String(s.clone())) + .collect(), + ), + ); // Add install-relevant dependency kinds for repo/runtime consumers. let mut deps = toml::map::Map::new(); @@ -434,6 +445,12 @@ mod tests { assert_eq!(val.get("version").and_then(|v| v.as_str()), Some("1.0")); assert_eq!(val.get("revision").and_then(|v| v.as_integer()), Some(1)); assert_eq!(val.get("license").and_then(|v| v.as_str()), Some("MIT")); + assert!( + val.get("replaces") + .and_then(|v| v.as_array()) + .expect("replaces should be an array") + .is_empty() + ); assert!( crate::metadata_time::parse_completed_at_value(&val).is_some(), "expected RFC3339 UTC completed_at" @@ -508,6 +525,27 @@ mod tests { assert_eq!(keep[1].as_str(), Some("etc/passwd")); } + #[test] + fn test_generate_metadata_toml_includes_replaces() { + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path(); + + let mut packager = mk_packager(dest.to_path_buf()); + packager.spec.alternatives.replaces = vec!["findutils".into(), "diffutils".into()]; + packager.generate_metadata_toml().unwrap(); + + let meta_path = dest.join(".metadata.toml"); + let content = fs::read_to_string(meta_path).unwrap(); + let val: toml::Value = toml::from_str(&content).unwrap(); + let replaces = val + .get("replaces") + .and_then(|v| v.as_array()) + .expect("replaces should be an array"); + assert_eq!(replaces.len(), 2); + assert_eq!(replaces[0].as_str(), Some("findutils")); + assert_eq!(replaces[1].as_str(), Some("diffutils")); + } + #[test] fn test_create_package_skips_purged_payload_paths() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/package/spec.rs b/src/package/spec.rs index 9ccb19d..f499972 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -318,6 +318,18 @@ impl PackageSpec { /// /// If no per-output override exists, returns the top-level alternatives. pub fn alternatives_for_output(&self, pkg_name: &str) -> Alternatives { + if pkg_name == self.lib32_package_name() { + return self + .package_alternatives + .get(pkg_name) + .cloned() + .unwrap_or_else(|| { + self.alternatives + .lib32_alternatives() + .unwrap_or_else(|| self.alternatives.primary_alternatives()) + }); + } + if self.docs_parent_output_name(pkg_name).is_some() { return self .package_alternatives @@ -2001,6 +2013,60 @@ provides = ["binutils"] ); } + #[test] + fn parse_lib32_alternatives_override() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "llvm" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/llvm.tar.gz" +sha256 = "skip" +extract_dir = "llvm" + +[build] +type = "custom" + +[alternatives] +provides = ["toolchain"] +replaces = ["clang"] + +[alternatives.lib32] +provides = ["lib32-toolchain"] +conflicts = ["lib32-gcc"] +replaces = ["lib32-clang"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.alternatives_for_output("llvm").replaces, + vec!["clang".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("lib32-llvm").provides, + vec!["lib32-toolchain".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("lib32-llvm").conflicts, + vec!["lib32-gcc".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("lib32-llvm").replaces, + vec!["lib32-clang".to_string()] + ); + } + #[test] fn parse_python_build_type() { let tmp = tempfile::tempdir().unwrap(); @@ -3648,6 +3714,9 @@ impl fmt::Display for PackageSpec { if !self.alternatives.conflicts.is_empty() { writeln!(f, "Conflicts: {}", self.alternatives.conflicts.join(", "))?; } + if !self.alternatives.replaces.is_empty() { + writeln!(f, "Replaces: {}", self.alternatives.replaces.join(", "))?; + } Ok(()) } } @@ -3724,17 +3793,51 @@ impl PackageSpec { } } -/// Package alternatives such as virtual provides and install conflicts. -#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] +/// Nested alternatives override group used for output-specific variants such as `lib32-*`. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct AlternativeGroup { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub provides: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub conflicts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub replaces: Vec, +} + +/// Package alternatives such as virtual provides, install conflicts, and replacements. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct Alternatives { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub provides: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub conflicts: Vec, - /// Reserved for future package replacement feature #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[allow(dead_code)] pub replaces: Vec, + /// Optional alternatives override used only for the generated `lib32-*` companion package. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lib32: Option, +} + +impl Alternatives { + /// Return the top-level alternatives set without any nested output-specific overrides. + pub fn primary_alternatives(&self) -> Alternatives { + Alternatives { + provides: self.provides.clone(), + conflicts: self.conflicts.clone(), + replaces: self.replaces.clone(), + lib32: None, + } + } + + /// Return the optional lib32-specific alternatives override set. + pub fn lib32_alternatives(&self) -> Option { + self.lib32.as_ref().map(|group| Alternatives { + provides: group.provides.clone(), + conflicts: group.conflicts.clone(), + replaces: group.replaces.clone(), + lib32: None, + }) + } } /// Source tarball information diff --git a/src/planner.rs b/src/planner.rs index aa94340..9280f2d 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -122,6 +122,7 @@ enum CandidateKind { #[derive(Debug, Clone, Copy, Eq, PartialEq)] enum MatchKind { + Replaces, Exact, Provides, } @@ -145,6 +146,7 @@ struct LocalSpecHit { spec_name: String, path: PathBuf, provides: Vec, + replaces: Vec, } struct Resolver<'a> { @@ -419,6 +421,55 @@ impl<'a> Resolver<'a> { let mut out = Vec::new(); let mut seen = HashSet::::new(); + // Local sibling fallback (e.g. ../foo/*.toml when building from a local tree). + // Prefer these candidates before probing configured repos so local development + // remains deterministic and does not block on external repository I/O. + let local_sibling_root = requester_spec_path + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .map(Path::to_path_buf) + .or_else(|| self.opts.local_sibling_root.clone()); + if let Some(root) = local_sibling_root { + for hit in self.local_sibling_hits(&root)? { + let exact = hit.spec_name.eq_ignore_ascii_case(dep_name); + let replaces = hit + .replaces + .iter() + .any(|p| p.eq_ignore_ascii_case(dep_name)); + let provides = hit + .provides + .iter() + .any(|p| p.eq_ignore_ascii_case(dep_name)); + if !(exact || provides || replaces) { + continue; + } + let key = format!("src:{}", hit.path.display()); + if !seen.insert(key) { + continue; + } + out.push(Candidate { + package: hit.spec_name.clone(), + kind: CandidateKind::Source { + path: hit.path.clone(), + local_sibling: true, + }, + match_kind: if replaces { + MatchKind::Replaces + } else if exact { + MatchKind::Exact + } else { + MatchKind::Provides + }, + sort_repo_priority: -10, + sort_label: "source:local-sibling".to_string(), + }); + } + + if !out.is_empty() { + return Ok(out); + } + } + // Binary repos let host_arch = std::env::consts::ARCH; let mut binary_repos: Vec<_> = self @@ -441,6 +492,12 @@ impl<'a> Resolver<'a> { for rec in records { let match_kind = if rec.name.eq_ignore_ascii_case(dep_name) { MatchKind::Exact + } else if rec + .replaces + .iter() + .any(|replacement| replacement.eq_ignore_ascii_case(dep_name)) + { + MatchKind::Replaces } else { MatchKind::Provides }; @@ -467,7 +524,14 @@ impl<'a> Resolver<'a> { // Global source index if let Some(path) = self.pkg_index.find(dep_name) { let spec = self.load_spec(&path)?; - let match_kind = if spec.package.name.eq_ignore_ascii_case(dep_name) { + let match_kind = if spec + .alternatives + .replaces + .iter() + .any(|replacement| replacement.eq_ignore_ascii_case(dep_name)) + { + MatchKind::Replaces + } else if spec.package.name.eq_ignore_ascii_case(dep_name) { MatchKind::Exact } else { MatchKind::Provides @@ -488,6 +552,25 @@ impl<'a> Resolver<'a> { } } + // Additional source providers from index (for provider prompt) + for path in self.pkg_index.find_replacements(dep_name) { + let spec = self.load_spec(&path)?; + let key = format!("src:{}", path.display()); + if !seen.insert(key) { + continue; + } + out.push(Candidate { + package: spec.package.name.clone(), + kind: CandidateKind::Source { + path: path.clone(), + local_sibling: false, + }, + match_kind: MatchKind::Replaces, + sort_repo_priority: 0, + sort_label: format!("source:{}", source_label_for_path(self.config, &path)), + }); + } + // Additional source providers from index (for provider prompt) for path in self.pkg_index.find_providers(dep_name) { let spec = self.load_spec(&path)?; @@ -511,43 +594,6 @@ impl<'a> Resolver<'a> { }); } - // Local sibling fallback (e.g. ../foo/*.toml when building from a local tree) - let local_sibling_root = requester_spec_path - .and_then(|p| p.parent()) - .and_then(|p| p.parent()) - .map(Path::to_path_buf) - .or_else(|| self.opts.local_sibling_root.clone()); - if let Some(root) = local_sibling_root { - for hit in self.local_sibling_hits(&root)? { - let exact = hit.spec_name.eq_ignore_ascii_case(dep_name); - let provides = hit - .provides - .iter() - .any(|p| p.eq_ignore_ascii_case(dep_name)); - if !(exact || provides) { - continue; - } - let key = format!("src:{}", hit.path.display()); - if !seen.insert(key) { - continue; - } - out.push(Candidate { - package: hit.spec_name.clone(), - kind: CandidateKind::Source { - path: hit.path.clone(), - local_sibling: true, - }, - match_kind: if exact { - MatchKind::Exact - } else { - MatchKind::Provides - }, - sort_repo_priority: -10, - sort_label: "source:local-sibling".to_string(), - }); - } - } - Ok(out) } @@ -584,6 +630,7 @@ impl<'a> Resolver<'a> { spec_name: spec.package.name.clone(), path: path.to_path_buf(), provides: spec.alternatives.provides.clone(), + replaces: spec.alternatives.replaces.clone(), }); } } @@ -691,6 +738,7 @@ fn source_label_for_path(config: &Config, path: &Path) -> String { fn format_candidate_label(c: &Candidate) -> String { let match_label = match c.match_kind { + MatchKind::Replaces => "replaces", MatchKind::Exact => "exact", MatchKind::Provides => "provides", }; @@ -750,8 +798,9 @@ fn candidate_sort_key(c: &Candidate, prefer_binary: bool) -> (i32, i32, i32, Str (false, true) => 1, }; let match_rank = match c.match_kind { - MatchKind::Exact => 0, - MatchKind::Provides => 1, + MatchKind::Replaces => 0, + MatchKind::Exact => 1, + MatchKind::Provides => 2, }; ( kind_rank, @@ -852,6 +901,7 @@ mod tests { provides: vec!["libfoo".into()], conflicts: Vec::new(), replaces: Vec::new(), + lib32: None, }, )]), package_dependencies: BTreeMap::from([( @@ -897,6 +947,7 @@ mod tests { license: None, provides: Vec::new(), conflicts: Vec::new(), + replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }),