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.
This commit is contained in:
2026-03-16 22:47:35 -05:00
parent 82f3b625ee
commit 2bc9e7a525
14 changed files with 1108 additions and 140 deletions
Generated
+1 -1
View File
@@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2"
[[package]]
name = "depot"
version = "0.26.0"
version = "0.26.1"
dependencies = [
"anyhow",
"ar",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "depot"
version = "0.26.0"
version = "0.26.1"
edition = "2024"
[lints.rust]
+1 -1
View File
@@ -1,6 +1,6 @@
project(
'depot',
version: '0.26.0',
version: '0.26.1',
meson_version: '>=0.60.0',
default_options: ['buildtype=release'],
)
+11 -7
View File
@@ -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),
+568 -54
View File
@@ -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<String>,
replacement_removals: Vec<String>,
renamed_transition: Option<RenamedPackageTransition>,
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<Vec<String>> {
let installed = db::get_installed_packages(db_path)?;
let mut replacements: Vec<String> = 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<Vec<InstalledPackageOutcome>> {
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<i64>,
@@ -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,
&current.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,
&current.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,
&current.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<String>,
) -> Result<HashMap<String, SourceUpdateCandidate>> {
let mut best: HashMap<String, SourceUpdateCandidate> = 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,
&current.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 != &current.repo_name {
repo_name < &current.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<String>,
) -> Result<HashMap<String, (i32, db::repo::BinaryRepoPackageRecord)>> {
let mut best: HashMap<String, (i32, db::repo::BinaryRepoPackageRecord)> = 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<i64>,
source_replacement_candidates: &HashMap<String, SourceUpdateCandidate>,
binary_replacement_candidates: &HashMap<String, (i32, db::repo::BinaryRepoPackageRecord)>,
source_candidates: &HashMap<String, SourceUpdateCandidate>,
binary_candidates: &HashMap<String, (i32, db::repo::BinaryRepoPackageRecord)>,
prefer_binary: bool,
@@ -2594,6 +2749,60 @@ fn select_update_candidate(
let mut best: Option<UpdateCandidate> = 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<String> = 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,
+21 -21
View File
@@ -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<String> {
for suffix in suffixes {
@@ -264,3 +243,24 @@ fn find_tool(prefix: &str, suffixes: &[&str], required: bool) -> Result<String>
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"
);
}
}
+3 -2
View File
@@ -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();
+65 -1
View File
@@ -87,6 +87,7 @@ struct IndexedPackage {
sha512: String,
provides: Vec<String>,
conflicts: Vec<String>,
replaces: Vec<String>,
runtime_dependencies: Vec<String>,
optional_dependencies: Vec<String>,
archive_files: Vec<String>,
@@ -125,6 +126,7 @@ pub struct BinaryRepoPackageRecord {
pub license: Option<String>,
pub provides: Vec<String>,
pub conflicts: Vec<String>,
pub replaces: Vec<String>,
pub runtime_dependencies: Vec<String>,
pub optional_dependencies: Vec<String>,
}
@@ -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<Vec<Str
Ok(rows.filter_map(|r| r.ok()).collect())
}
fn query_package_replaces(conn: &Connection, package_id: i64) -> Result<Vec<String>> {
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<Vec<String>> {
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(),
}
+1
View File
@@ -715,6 +715,7 @@ mod tests {
provides: vec!["libfoo".into()],
conflicts: Vec::new(),
replaces: Vec::new(),
lib32: None,
},
)]),
package_dependencies: Default::default(),
+114 -5
View File
@@ -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<String, PathBuf>,
/// Provided name -> spec paths (can be multiple)
by_provides: HashMap<String, Vec<PathBuf>>,
/// Replaced package name -> replacement spec paths (can be multiple)
by_replaces: HashMap<String, Vec<PathBuf>>,
}
/// Source package search result from `PackageIndex`.
@@ -58,6 +62,7 @@ pub struct SourceSearchHit {
pub name: String,
pub path: PathBuf,
pub provides: Vec<String>,
pub replaces: Vec<String>,
}
#[derive(Debug, Clone)]
@@ -66,6 +71,7 @@ struct IndexedSpecRows {
rel: String,
provides: Vec<String>,
conflicts: Vec<String>,
replaces: Vec<String>,
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<TAB><name><TAB><relative-spec-path>`
/// - Provides row: `V<TAB><feature><TAB><relative-spec-path>`
/// - Conflicts row: `C<TAB><name><TAB><relative-spec-path>`
/// - Replaces row: `X<TAB><name><TAB><relative-spec-path>`
/// - Dependency rows:
/// - `B<TAB><dep><TAB><relative-spec-path>` for build dependencies
/// - `R<TAB><dep><TAB><relative-spec-path>` 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<PathBuf> {
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::<Vec<_>>()
);
}
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<PathBuf> {
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<PathBuf> {
self.by_provides.get(name).cloned().unwrap_or_default()
@@ -496,17 +555,29 @@ impl PackageIndex {
.push(provided.clone());
}
}
let mut replaces_by_path: HashMap<PathBuf, Vec<String>> = 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")));
}
}
+86 -3
View File
@@ -665,7 +665,11 @@ pub fn create_interactive() -> Result<PackageSpec> {
"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<String> {
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<String> {
),
);
}
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]
+38
View File
@@ -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();
+107 -4
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conflicts: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replaces: Vec<String>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conflicts: Vec<String>,
/// Reserved for future package replacement feature
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[allow(dead_code)]
pub replaces: Vec<String>,
/// Optional alternatives override used only for the generated `lib32-*` companion package.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lib32: Option<AlternativeGroup>,
}
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<Alternatives> {
self.lib32.as_ref().map(|group| Alternatives {
provides: group.provides.clone(),
conflicts: group.conflicts.clone(),
replaces: group.replaces.clone(),
lib32: None,
})
}
}
/// Source tarball information
+91 -40
View File
@@ -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<String>,
replaces: Vec<String>,
}
struct Resolver<'a> {
@@ -419,6 +421,55 @@ impl<'a> Resolver<'a> {
let mut out = Vec::new();
let mut seen = HashSet::<String>::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(),
}),