diff --git a/Cargo.lock b/Cargo.lock index 8301c5a..2c1c409 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "ar", diff --git a/Cargo.toml b/Cargo.toml index 445fcc5..5b894ef 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.14.0" +version = "0.15.0" edition = "2024" [lints.rust] diff --git a/meson.build b/meson.build index 33d1a4b..5d55479 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.14.0', + version: '0.15.0', meson_version: '>=0.60.0', ) diff --git a/src/commands.rs b/src/commands.rs index cc4673d..377a51b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use git2::Direction; use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use std::cmp::Ordering; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::io::IsTerminal; use std::path::{Path, PathBuf}; @@ -258,6 +258,13 @@ fn package_spec_from_archive_metadata(metadata: &toml::Value) -> package::Packag .map(String::from) .collect(); } + if let Some(conflicts) = metadata.get("conflicts").and_then(|v| v.as_array()) { + spec.alternatives.conflicts = conflicts + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } spec } @@ -297,6 +304,7 @@ fn package_spec_from_repo_record( packages: Vec::new(), alternatives: package::Alternatives { provides: record.provides.clone(), + conflicts: record.conflicts.clone(), replaces: Vec::new(), }, manual_sources: Vec::new(), @@ -728,6 +736,265 @@ struct PlannedPackageInstall { staged: PlannedStagedInstall, } +#[derive(Debug, Clone)] +struct InstallConflictSubject { + package: String, + provides: Vec, + conflicts: Vec, +} + +#[derive(Debug, Clone)] +struct InstalledConflictPackage { + name: String, + provides: Vec, +} + +fn install_conflict_subjects_for_output_spec( + spec: &package::PackageSpec, +) -> Vec { + spec.outputs() + .into_iter() + .map(|output| { + let alternatives = spec.alternatives_for_output(&output.name); + InstallConflictSubject { + package: output.name, + provides: alternatives.provides, + conflicts: alternatives.conflicts, + } + }) + .collect() +} + +fn install_conflict_subjects_for_spec( + spec: &package::PackageSpec, + include_primary: bool, + include_lib32: bool, +) -> Vec { + let mut subjects = Vec::new(); + if include_primary { + subjects.extend(install_conflict_subjects_for_output_spec(spec)); + } + if include_lib32 { + subjects.extend(install_conflict_subjects_for_output_spec( + &make_lib32_package_spec(spec), + )); + } + subjects +} + +fn install_conflict_subject_for_binary_record( + record: &db::repo::BinaryRepoPackageRecord, +) -> InstallConflictSubject { + InstallConflictSubject { + package: record.name.clone(), + provides: record.provides.clone(), + conflicts: record.conflicts.clone(), + } +} + +fn matching_conflict_names( + conflicts: &[String], + package_name: &str, + provides: &[String], +) -> Vec { + let mut matches = Vec::new(); + for conflict in conflicts { + if conflict == package_name || provides.iter().any(|provided| provided == conflict) { + matches.push(conflict.clone()); + } + } + matches.sort(); + matches.dedup(); + matches +} + +fn validate_no_transaction_conflicts(subjects: &[InstallConflictSubject]) -> Result<()> { + let mut violations = BTreeSet::new(); + for (idx, left) in subjects.iter().enumerate() { + for right in subjects.iter().skip(idx + 1) { + let left_hits = + matching_conflict_names(&left.conflicts, &right.package, &right.provides); + if !left_hits.is_empty() { + violations.insert(format!( + "{} conflicts with {} via {}", + left.package, + right.package, + left_hits.join(", ") + )); + } + let right_hits = + matching_conflict_names(&right.conflicts, &left.package, &left.provides); + if !right_hits.is_empty() { + violations.insert(format!( + "{} conflicts with {} via {}", + right.package, + left.package, + right_hits.join(", ") + )); + } + } + } + + if violations.is_empty() { + return Ok(()); + } + + let mut message = + String::from("Cannot install conflicting packages in the same transaction:\n"); + for violation in violations { + message.push_str(" "); + message.push_str(&violation); + message.push('\n'); + } + anyhow::bail!(message.trim_end().to_string()); +} + +fn collect_installed_conflict_packages(db_path: &Path) -> Result> { + let mut installed = Vec::new(); + for record in db::list_installed_package_records(db_path)? { + installed.push(InstalledConflictPackage { + provides: db::get_package_provides(db_path, &record.name)?, + name: record.name, + }); + } + Ok(installed) +} + +fn collect_conflicting_installed_packages( + subjects: &[InstallConflictSubject], + installed: &[InstalledConflictPackage], +) -> Result>> { + validate_no_transaction_conflicts(subjects)?; + let planned_packages: HashSet<_> = subjects + .iter() + .map(|subject| subject.package.clone()) + .collect(); + let mut removals: BTreeMap> = BTreeMap::new(); + + for subject in subjects { + for installed_pkg in installed { + if installed_pkg.name == subject.package { + continue; + } + let matched = matching_conflict_names( + &subject.conflicts, + &installed_pkg.name, + &installed_pkg.provides, + ); + if matched.is_empty() { + continue; + } + if planned_packages.contains(&installed_pkg.name) { + anyhow::bail!( + "Cannot install conflicting packages in the same transaction: {} conflicts with {}", + subject.package, + installed_pkg.name + ); + } + removals + .entry(installed_pkg.name.clone()) + .or_default() + .insert(subject.package.clone()); + } + } + + Ok(removals) +} + +fn remove_installed_package_with_hooks( + package: &str, + rootfs: &Path, + config: &config::Config, +) -> Result<()> { + let db_path = config.installed_db_path(rootfs); + let affected_paths = db::get_package_files(&db_path, package)?; + install::hooks::run_transaction_hooks( + rootfs, + &install::hooks::HookExecutionContext { + phase: install::hooks::HookPhase::Pre, + operation: install::hooks::HookOperation::Remove, + package, + affected_paths: &affected_paths, + }, + )?; + let script_dir = install::scripts::installed_scripts_dir(rootfs, package); + let _ = install::scripts::run_hook_if_present( + &script_dir, + install::scripts::Hook::PreRemove, + rootfs, + package, + )?; + db::remove_package(&db_path, package, rootfs)?; + let post_remove = install::scripts::run_hook_if_present( + &script_dir, + install::scripts::Hook::PostRemove, + rootfs, + package, + ); + let cleanup_scripts = install::scripts::remove_installed_scripts(rootfs, package); + post_remove?; + cleanup_scripts?; + install::hooks::run_transaction_hooks( + rootfs, + &install::hooks::HookExecutionContext { + phase: install::hooks::HookPhase::Post, + operation: install::hooks::HookOperation::Remove, + package, + affected_paths: &affected_paths, + }, + )?; + ui::success(format!("Successfully removed {}", package)); + Ok(()) +} + +fn resolve_installed_conflicts_for_subjects( + subjects: &[InstallConflictSubject], + rootfs: &Path, + config: &config::Config, + dry_run: bool, +) -> Result<()> { + if subjects.is_empty() { + return Ok(()); + } + + let db_path = config.installed_db_path(rootfs); + let installed = collect_installed_conflict_packages(&db_path)?; + let removals = collect_conflicting_installed_packages(subjects, &installed)?; + if removals.is_empty() { + return Ok(()); + } + + let prompt_entries: Vec = removals + .iter() + .map(|(package, conflicted_by)| { + format!( + "{} (conflicts with {})", + package, + conflicted_by.iter().cloned().collect::>().join(", ") + ) + }) + .collect(); + + if dry_run { + ui::info(format!( + "Dry run: would remove conflicting installed package(s): {}", + prompt_entries.join(", ") + )); + return Ok(()); + } + + if !ui::prompt_package_action("conflict removal", &prompt_entries, true)? { + anyhow::bail!("Aborted"); + } + + for package in removals.keys() { + ui::info(format!("Removing conflicting package: {}", package)); + remove_installed_package_with_hooks(package, rootfs, config)?; + } + + Ok(()) +} + fn plan_staged_install( pkg_spec: &package::PackageSpec, destdir: &Path, @@ -1288,6 +1555,7 @@ struct UpdateCandidate { candidate_completed_at: Option, runtime_dependencies: Vec, provides: Vec, + conflicts: Vec, repo_priority: i32, origin: UpdateOrigin, } @@ -1648,6 +1916,7 @@ fn select_update_candidate( 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(), @@ -1676,6 +1945,7 @@ fn select_update_candidate( 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(), @@ -1840,6 +2110,16 @@ fn run_update_command( }) .collect(); + let conflict_subjects: Vec<_> = updates + .iter() + .map(|candidate| InstallConflictSubject { + package: candidate.package.clone(), + provides: candidate.provides.clone(), + conflicts: candidate.conflicts.clone(), + }) + .collect(); + validate_no_transaction_conflicts(&conflict_subjects)?; + ui::info(format!("{} package(s) can be updated:", updates.len())); for target in &targets { ui::info(format!(" {}", target)); @@ -1862,6 +2142,13 @@ fn run_update_command( anyhow::bail!("Aborted"); } + resolve_installed_conflicts_for_subjects( + &conflict_subjects, + options.rootfs, + config, + options.dry_run, + )?; + if !missing_deps.is_empty() { let dep_plan = planner::build_dependency_install_plan( config, @@ -2405,6 +2692,27 @@ fn execute_install_plan_with_child_commands( anyhow::bail!("Aborted"); } + let mut conflict_subjects = Vec::new(); + for step in &actionable_steps { + match &step.origin { + planner::PlanOrigin::Source { path, .. } => { + let mut spec = package::PackageSpec::from_file(path) + .with_context(|| format!("Failed to parse spec {}", path.display()))?; + spec.apply_config(config); + conflict_subjects.extend(install_conflict_subjects_for_spec( + &spec, + true, + spec.build.flags.build_32, + )); + } + planner::PlanOrigin::Binary { record, .. } => { + conflict_subjects.push(install_conflict_subject_for_binary_record(record)); + } + planner::PlanOrigin::Installed => {} + } + } + resolve_installed_conflicts_for_subjects(&conflict_subjects, rootfs, config, options.dry_run)?; + if options.dry_run { ui::info("Dry run enabled, no install/build actions executed."); return Ok(()); @@ -2753,6 +3061,17 @@ fn run_direct_archive_install_requests( staged_dirs.push(staging_dir); } + let mut conflict_subjects = Vec::new(); + for pkg_spec in &pkg_specs { + conflict_subjects.extend(install_conflict_subjects_for_spec(pkg_spec, true, false)); + } + resolve_installed_conflicts_for_subjects( + &conflict_subjects, + options.rootfs, + config, + options.dry_run, + )?; + if options.dry_run { ui::info("Dry run enabled, stopping before install/build work."); return Ok(false); @@ -2892,6 +3211,21 @@ fn run_direct_install_request( )); } + let mut conflict_subjects = install_conflict_subjects_for_spec( + &pkg_spec, + !options.lib32_only, + staging_dir.is_none() && (options.lib32_only || pkg_spec.build.flags.build_32), + ); + if staging_dir.is_some() { + conflict_subjects = install_conflict_subjects_for_spec(&pkg_spec, true, false); + } + resolve_installed_conflicts_for_subjects( + &conflict_subjects, + options.rootfs, + config, + options.dry_run, + )?; + if options.dry_run { ui::info("Dry run enabled, stopping before install/build work."); return Ok(false); @@ -3222,48 +3556,11 @@ pub fn run(cli: Cli) -> Result<()> { let remove_lock_path = locking::lock_path(&config); let _remove_lock_guard = locking::try_write(&mut remove_lock, &remove_lock_path, "remove")?; - let db_path = config.db_dir.join("packages.db"); let removal_targets = vec![package.clone()]; if !ui::prompt_package_action("removal", &removal_targets, true)? { anyhow::bail!("Aborted"); } - let affected_paths = db::get_package_files(&db_path, &package)?; - install::hooks::run_transaction_hooks( - &cli.rootfs, - &install::hooks::HookExecutionContext { - phase: install::hooks::HookPhase::Pre, - operation: install::hooks::HookOperation::Remove, - package: &package, - affected_paths: &affected_paths, - }, - )?; - let script_dir = install::scripts::installed_scripts_dir(&cli.rootfs, &package); - let _ = install::scripts::run_hook_if_present( - &script_dir, - install::scripts::Hook::PreRemove, - &cli.rootfs, - &package, - )?; - db::remove_package(&db_path, &package, &cli.rootfs)?; - let post_remove = install::scripts::run_hook_if_present( - &script_dir, - install::scripts::Hook::PostRemove, - &cli.rootfs, - &package, - ); - let cleanup_scripts = install::scripts::remove_installed_scripts(&cli.rootfs, &package); - post_remove?; - cleanup_scripts?; - install::hooks::run_transaction_hooks( - &cli.rootfs, - &install::hooks::HookExecutionContext { - phase: install::hooks::HookPhase::Post, - operation: install::hooks::HookOperation::Remove, - package: &package, - affected_paths: &affected_paths, - }, - )?; - ui::success(format!("Successfully removed {}", package)); + remove_installed_package_with_hooks(&package, &cli.rootfs, &config)?; } Commands::Build { spec_pos, @@ -3736,11 +4033,13 @@ pub fn run(cli: Cli) -> Result<()> { stats.index_path.display() )); ui::info(format!( - "Indexed {} spec(s) from {} TOML file(s): package rows={} provides rows={} ignored_toml={}", + "Indexed {} spec(s) from {} TOML file(s): package rows={} provides rows={} conflicts rows={} dependency rows={} ignored_toml={}", stats.specs_indexed, stats.toml_files_scanned, stats.package_rows, stats.provides_rows, + stats.conflicts_rows, + stats.dependency_rows, stats.ignored_toml_files )); } @@ -4173,6 +4472,7 @@ mod tests { homepage: Some("https://example.test".into()), license: Some("MIT".into()), provides: vec!["pkg-virtual".into()], + conflicts: Vec::new(), runtime_dependencies: vec!["glibc".into()], optional_dependencies: vec!["manpages".into()], }; @@ -4228,6 +4528,7 @@ mod tests { fn write_archive( archive_path: &Path, package_name: &str, + conflicts: &[&str], payload_path: &str, payload: &[u8], ) -> Result<()> { @@ -4244,8 +4545,20 @@ mod tests { payload_header.set_cksum(); tar.append(&payload_header, payload)?; + let conflicts_toml = if conflicts.is_empty() { + String::new() + } else { + format!( + "conflicts = [{}]\n", + conflicts + .iter() + .map(|conflict| format!("\"{conflict}\"")) + .collect::>() + .join(", ") + ) + }; let metadata = format!( - "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n\n[dependencies]\nruntime = []\noptional = []\n" + "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" ); let mut meta_header = tar::Header::new_gnu(); meta_header.set_path(".metadata.toml")?; @@ -4263,8 +4576,8 @@ mod tests { let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); - write_archive(&archive_a, "alpha", "usr/bin/alpha", b"alpha")?; - write_archive(&archive_b, "beta", "usr/bin/beta", b"beta")?; + write_archive(&archive_a, "alpha", &[], "usr/bin/alpha", b"alpha")?; + write_archive(&archive_b, "beta", &[], "usr/bin/beta", b"beta")?; let mut cfg = config::Config::for_rootfs(rootfs.path()); cfg.build_dir = rootfs.path().join("var/cache/depot/build"); @@ -4301,6 +4614,110 @@ mod tests { Ok(()) } + #[test] + fn direct_archive_install_rejects_conflicting_archives_in_same_batch() -> Result<()> { + fn write_archive( + archive_path: &Path, + package_name: &str, + conflicts: &[&str], + ) -> Result<()> { + let file = fs::File::create(archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = zstd::stream::write::Encoder::new(file, 3) + .context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + + let payload = package_name.as_bytes(); + let mut payload_header = tar::Header::new_gnu(); + payload_header.set_path(format!("usr/bin/{package_name}"))?; + payload_header.set_size(payload.len() as u64); + payload_header.set_mode(0o755); + payload_header.set_cksum(); + tar.append(&payload_header, payload)?; + + let conflicts_toml = if conflicts.is_empty() { + String::new() + } else { + format!( + "conflicts = [{}]\n", + conflicts + .iter() + .map(|conflict| format!("\"{conflict}\"")) + .collect::>() + .join(", ") + ) + }; + let metadata = format!( + "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" + ); + let mut meta_header = tar::Header::new_gnu(); + meta_header.set_path(".metadata.toml")?; + meta_header.set_size(metadata.len() as u64); + meta_header.set_mode(0o644); + meta_header.set_cksum(); + tar.append(&meta_header, metadata.as_bytes())?; + + let encoder = tar.into_inner()?; + encoder.finish()?; + Ok(()) + } + + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); + let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); + write_archive(&archive_a, "alpha", &["beta"])?; + write_archive(&archive_b, "beta", &[])?; + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let err = run_direct_archive_install_requests( + DirectInstallOptions { + rootfs: rootfs.path(), + no_deps: true, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + lib32_only: false, + install_test_deps: false, + }, + &cfg, + &[archive_a, archive_b], + false, + ) + .expect_err("conflicting archives should be rejected"); + + assert!( + err.to_string() + .contains("Cannot install conflicting packages in the same transaction") + ); + Ok(()) + } + + #[test] + fn collect_conflicting_installed_packages_matches_by_name_and_provide() -> Result<()> { + let removals = collect_conflicting_installed_packages( + &[InstallConflictSubject { + package: "beta".into(), + provides: Vec::new(), + conflicts: vec!["alpha".into(), "editor".into()], + }], + &[InstalledConflictPackage { + name: "alpha".into(), + provides: vec!["editor".into()], + }], + )?; + + assert_eq!( + removals.get("alpha"), + Some(&BTreeSet::from(["beta".to_string()])) + ); + Ok(()) + } + #[test] #[cfg(unix)] fn binary_archive_install_preserves_setuid_permissions() -> Result<()> { @@ -4354,6 +4771,7 @@ mod tests { homepage: Some("https://example.test".into()), license: Some("ISC".into()), provides: Vec::new(), + conflicts: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }; @@ -4567,6 +4985,7 @@ optional = [] homepage: None, license: None, provides: Vec::new(), + conflicts: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }, @@ -4741,6 +5160,7 @@ optional = [] candidate_completed_at: None, runtime_dependencies: vec!["glibc".into(), "helper-virtual".into()], provides: Vec::new(), + conflicts: Vec::new(), repo_priority: 0, origin: UpdateOrigin::Source { repo_name: "source".into(), @@ -4757,6 +5177,7 @@ optional = [] candidate_completed_at: None, runtime_dependencies: Vec::new(), provides: vec!["helper-virtual".into()], + conflicts: Vec::new(), repo_priority: 0, origin: UpdateOrigin::Source { repo_name: "source".into(), @@ -4773,6 +5194,7 @@ optional = [] candidate_completed_at: None, runtime_dependencies: vec!["newdep".into()], provides: Vec::new(), + conflicts: Vec::new(), repo_priority: 0, origin: UpdateOrigin::Source { repo_name: "source".into(), @@ -4787,6 +5209,28 @@ optional = [] Ok(()) } + #[test] + fn validate_no_transaction_conflicts_rejects_conflicting_updates() { + let err = validate_no_transaction_conflicts(&[ + InstallConflictSubject { + package: "alpha".into(), + provides: Vec::new(), + conflicts: vec!["beta".into()], + }, + InstallConflictSubject { + package: "beta".into(), + provides: Vec::new(), + conflicts: Vec::new(), + }, + ]) + .expect_err("conflicting update set should be rejected"); + + assert!( + err.to_string() + .contains("Cannot install conflicting packages in the same transaction") + ); + } + #[test] fn compare_versions_for_updates_handles_semver_and_date_versions() { assert_eq!( diff --git a/src/db/mod.rs b/src/db/mod.rs index 439ce18..59b8544 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -565,6 +565,25 @@ pub fn list_installed_package_records(db_path: &Path) -> Result Result> { + if !db_path.exists() { + return Ok(Vec::new()); + } + + let conn = Connection::open(db_path)?; + init_db(&conn)?; + let mut stmt = conn.prepare( + "SELECT pr.provides_name + FROM provides pr + JOIN packages p ON p.id = pr.package_id + WHERE p.name = ?1 + ORDER BY pr.provides_name", + )?; + let rows = stmt.query_map(params![name], |row| row.get(0))?; + Ok(rows.filter_map(|row| row.ok()).collect()) +} + /// Get set of all provided package names (alternatives) pub fn get_all_provides(db_path: &Path) -> Result> { use std::collections::HashSet; @@ -653,6 +672,7 @@ mod tests { packages: Vec::new(), alternatives: Alternatives { provides: vec![format!("{}-virtual", name)], + conflicts: Vec::new(), replaces: Vec::new(), }, manual_sources: Vec::new(), diff --git a/src/db/repo.rs b/src/db/repo.rs index 15ea9a0..38e665a 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -83,6 +83,7 @@ struct IndexedPackage { sha256: String, sha512: String, provides: Vec, + conflicts: Vec, runtime_dependencies: Vec, optional_dependencies: Vec, archive_files: Vec, @@ -118,6 +119,7 @@ pub struct BinaryRepoPackageRecord { pub homepage: Option, pub license: Option, pub provides: Vec, + pub conflicts: Vec, pub runtime_dependencies: Vec, pub optional_dependencies: Vec, } @@ -296,6 +298,11 @@ impl RepoManager { name TEXT NOT NULL, FOREIGN KEY(package_id) REFERENCES packages(id) ); + CREATE TABLE conflicts ( + package_id INTEGER, + name TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); CREATE TABLE dependencies ( package_id INTEGER, kind TEXT NOT NULL, @@ -316,6 +323,7 @@ impl RepoManager { conn.execute_batch( "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_dependencies_name ON dependencies(name); CREATE INDEX idx_dependencies_kind ON dependencies(kind); CREATE INDEX idx_repo_files_path ON files(path);", @@ -344,6 +352,7 @@ impl RepoManager { let mut homepage = None; let mut license = None; let mut provides = Vec::new(); + let mut conflicts = Vec::new(); let mut runtime_dependencies = Vec::new(); let mut optional_dependencies = Vec::new(); let mut archive_files = Vec::new(); @@ -397,6 +406,15 @@ impl RepoManager { .map(String::from) .collect(); } + if let Some(conflicts_arr) = + metadata.get("conflicts").and_then(|v| v.as_array()) + { + conflicts = conflicts_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")) @@ -460,6 +478,7 @@ impl RepoManager { sha256, sha512, provides, + conflicts, runtime_dependencies, optional_dependencies, archive_files, @@ -480,6 +499,7 @@ impl RepoManager { sha256, sha512, provides, + conflicts, runtime_dependencies, optional_dependencies, archive_files, @@ -513,6 +533,12 @@ impl RepoManager { params![package_id, provide], )?; } + for conflict in conflicts { + conn.execute( + "INSERT INTO conflicts (package_id, name) VALUES (?1, ?2)", + params![package_id, conflict], + )?; + } for dep in runtime_dependencies { conn.execute( @@ -1461,6 +1487,27 @@ fn query_package_provides(conn: &Connection, package_id: i64) -> Result Result> { + let has_conflicts_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='conflicts'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_conflicts_table { + return Ok(Vec::new()); + } + + let mut stmt = + conn.prepare("SELECT name FROM conflicts 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( @@ -1563,6 +1610,7 @@ fn find_cached_binary_repo_packages( homepage: row.get(10)?, license: row.get(11)?, provides: Vec::new(), + conflicts: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }, @@ -1573,6 +1621,7 @@ fn find_cached_binary_repo_packages( for row in rows { let (package_id, mut rec) = row?; rec.provides = query_package_provides(&conn, package_id)?; + rec.conflicts = query_package_conflicts(&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); @@ -1629,6 +1678,7 @@ fn list_cached_binary_repo_packages( homepage: row.get(10)?, license: row.get(11)?, provides: Vec::new(), + conflicts: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }, @@ -1639,6 +1689,7 @@ fn list_cached_binary_repo_packages( for row in rows { let (package_id, mut rec) = row?; rec.provides = query_package_provides(&conn, package_id)?; + rec.conflicts = query_package_conflicts(&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); @@ -2463,6 +2514,7 @@ license = ["MIT", "Apache-2.0"] homepage: None, license: None, provides: Vec::new(), + conflicts: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }; @@ -2498,6 +2550,7 @@ license = ["MIT", "Apache-2.0"] homepage: None, license: None, provides: Vec::new(), + conflicts: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), } diff --git a/src/deps.rs b/src/deps.rs index 585bef5..fadd9a2 100755 --- a/src/deps.rs +++ b/src/deps.rs @@ -495,6 +495,7 @@ mod tests { "foo-libs".to_string(), crate::package::Alternatives { provides: vec!["libfoo".into()], + conflicts: Vec::new(), replaces: Vec::new(), }, )]), diff --git a/src/index.rs b/src/index.rs index 9d28f79..6df3139 100755 --- a/src/index.rs +++ b/src/index.rs @@ -4,7 +4,7 @@ use crate::package::PackageSpec; use anyhow::{Context, Result}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::ffi::OsStr; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -12,9 +12,15 @@ use walkdir::WalkDir; /// Filename for the source-repo index written at a repo root. pub const SOURCE_REPO_INDEX_FILENAME: &str = "depot-index.tsv"; -const SOURCE_REPO_INDEX_HEADER: &str = "depot-source-index-v1"; +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_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_DEP_BUILD: &str = "B"; +const SOURCE_REPO_INDEX_KIND_DEP_RUNTIME: &str = "R"; +const SOURCE_REPO_INDEX_KIND_DEP_TEST: &str = "T"; +const SOURCE_REPO_INDEX_KIND_DEP_OPTIONAL: &str = "O"; /// Statistics for generating a source repository index file. #[derive(Debug, Clone, PartialEq, Eq)] @@ -29,6 +35,10 @@ pub struct SourceRepoIndexStats { pub package_rows: usize, /// Number of provides rows written to the index. pub provides_rows: usize, + /// Number of conflict rows written to the index. + pub conflicts_rows: usize, + /// Number of dependency rows written to the index. + pub dependency_rows: usize, /// Number of discovered TOML files ignored because they were not package specs. pub ignored_toml_files: usize, } @@ -50,6 +60,15 @@ pub struct SourceSearchHit { pub provides: Vec, } +#[derive(Debug, Clone)] +struct IndexedSpecRows { + name: String, + rel: String, + provides: Vec, + conflicts: Vec, + deps: Vec<(String, String)>, +} + /// Return the source index file path for `repo_root`. pub fn source_repo_index_path(repo_root: &Path) -> PathBuf { repo_root.join(SOURCE_REPO_INDEX_FILENAME) @@ -58,9 +77,15 @@ 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-v1` +/// - Header: `depot-source-index-v2` /// - Package name row: `P` /// - Provides row: `V` +/// - Conflicts row: `C` +/// - Dependency rows: +/// - `B` for build dependencies +/// - `R` for runtime dependencies +/// - `T` for test dependencies +/// - `O` for optional dependencies pub fn create_source_repo_index( repo_root: &Path, subdirs: &[String], @@ -73,7 +98,7 @@ pub fn create_source_repo_index( } let scan_roots = resolve_scan_roots(&repo_root, subdirs)?; - let mut spec_rows: Vec<(String, String, Vec)> = Vec::new(); + let mut spec_rows: Vec = Vec::new(); let mut toml_files_scanned = 0usize; let mut ignored_toml_files = 0usize; @@ -95,23 +120,87 @@ pub fn create_source_repo_index( ) })?; let rel = rel.to_string_lossy().replace('\\', "/"); - spec_rows.push((spec.package.name.clone(), rel, spec.alternatives.provides)); + let mut conflicts = BTreeSet::new(); + for alternatives in spec.package_alternatives.values() { + for conflict in &alternatives.conflicts { + conflicts.insert(conflict.clone()); + } + } + for conflict in &spec.alternatives.conflicts { + conflicts.insert(conflict.clone()); + } + + let mut provides = BTreeSet::new(); + for alternatives in spec.package_alternatives.values() { + for provide in &alternatives.provides { + provides.insert(provide.clone()); + } + } + for provide in &spec.alternatives.provides { + provides.insert(provide.clone()); + } + + let mut deps = BTreeSet::new(); + for dep in &spec.dependencies.build { + deps.insert((SOURCE_REPO_INDEX_KIND_DEP_BUILD.to_string(), dep.clone())); + } + for dep in &spec.dependencies.runtime { + deps.insert((SOURCE_REPO_INDEX_KIND_DEP_RUNTIME.to_string(), dep.clone())); + } + for dep in &spec.dependencies.test { + deps.insert((SOURCE_REPO_INDEX_KIND_DEP_TEST.to_string(), dep.clone())); + } + for dep in &spec.dependencies.optional { + deps.insert((SOURCE_REPO_INDEX_KIND_DEP_OPTIONAL.to_string(), dep.clone())); + } + for dep_overrides in spec.package_dependencies.values() { + for dep in &dep_overrides.build { + deps.insert((SOURCE_REPO_INDEX_KIND_DEP_BUILD.to_string(), dep.clone())); + } + for dep in &dep_overrides.runtime { + deps.insert((SOURCE_REPO_INDEX_KIND_DEP_RUNTIME.to_string(), dep.clone())); + } + for dep in &dep_overrides.test { + deps.insert((SOURCE_REPO_INDEX_KIND_DEP_TEST.to_string(), dep.clone())); + } + for dep in &dep_overrides.optional { + deps.insert((SOURCE_REPO_INDEX_KIND_DEP_OPTIONAL.to_string(), dep.clone())); + } + } + + spec_rows.push(IndexedSpecRows { + name: spec.package.name.clone(), + rel, + provides: provides.into_iter().collect(), + conflicts: conflicts.into_iter().collect(), + deps: deps.into_iter().collect(), + }); } let mut rows: Vec<(String, String, String)> = Vec::new(); - for (name, rel, provides) in &spec_rows { + for spec_row in &spec_rows { rows.push(( SOURCE_REPO_INDEX_KIND_PACKAGE.to_string(), - name.clone(), - rel.clone(), + spec_row.name.clone(), + spec_row.rel.clone(), )); - for provided in provides { + for provided in &spec_row.provides { rows.push(( SOURCE_REPO_INDEX_KIND_PROVIDES.to_string(), provided.clone(), - rel.clone(), + spec_row.rel.clone(), )); } + for conflict in &spec_row.conflicts { + rows.push(( + SOURCE_REPO_INDEX_KIND_CONFLICTS.to_string(), + conflict.clone(), + spec_row.rel.clone(), + )); + } + for (dep_kind, dep_name) in &spec_row.deps { + rows.push((dep_kind.clone(), dep_name.clone(), spec_row.rel.clone())); + } } rows.sort_by(|a, b| { a.0.cmp(&b.0) @@ -120,7 +209,7 @@ pub fn create_source_repo_index( }); let mut out = String::new(); - out.push_str(SOURCE_REPO_INDEX_HEADER); + out.push_str(SOURCE_REPO_INDEX_HEADER_V2); out.push('\n'); for (kind, name, rel) in &rows { if name.contains('\n') || name.contains('\r') || name.contains('\t') { @@ -159,6 +248,22 @@ pub fn create_source_repo_index( .iter() .filter(|(kind, _, _)| kind == SOURCE_REPO_INDEX_KIND_PROVIDES) .count(); + let conflicts_rows = rows + .iter() + .filter(|(kind, _, _)| kind == SOURCE_REPO_INDEX_KIND_CONFLICTS) + .count(); + let dependency_rows = rows + .iter() + .filter(|(kind, _, _)| { + matches!( + kind.as_str(), + SOURCE_REPO_INDEX_KIND_DEP_BUILD + | SOURCE_REPO_INDEX_KIND_DEP_RUNTIME + | SOURCE_REPO_INDEX_KIND_DEP_TEST + | SOURCE_REPO_INDEX_KIND_DEP_OPTIONAL + ) + }) + .count(); Ok(SourceRepoIndexStats { index_path, @@ -166,6 +271,8 @@ pub fn create_source_repo_index( specs_indexed: spec_rows.len(), package_rows, provides_rows, + conflicts_rows, + dependency_rows, ignored_toml_files, }) } @@ -251,7 +358,8 @@ impl PackageIndex { let header = lines .next() .ok_or_else(|| anyhow::anyhow!("Missing source index header"))?; - if header.trim() != SOURCE_REPO_INDEX_HEADER { + let header = header.trim(); + if header != SOURCE_REPO_INDEX_HEADER_V1 && header != SOURCE_REPO_INDEX_HEADER_V2 { anyhow::bail!( "Unsupported source index header '{}' in {}", header, @@ -288,6 +396,11 @@ impl PackageIndex { .or_default() .push(path); } + SOURCE_REPO_INDEX_KIND_CONFLICTS + | SOURCE_REPO_INDEX_KIND_DEP_BUILD + | SOURCE_REPO_INDEX_KIND_DEP_RUNTIME + | SOURCE_REPO_INDEX_KIND_DEP_TEST + | SOURCE_REPO_INDEX_KIND_DEP_OPTIONAL => {} _ => { anyhow::bail!( "Unknown source index row type '{}' on line {} in {}", @@ -461,22 +574,51 @@ fn scan_toml_files(scan_roots: &[PathBuf]) -> Result> { mod tests { use super::*; - fn write_meta_spec(path: &Path, name: &str, provides: &[&str]) { + fn write_meta_spec( + path: &Path, + name: &str, + provides: &[&str], + conflicts: &[&str], + runtime_deps: &[&str], + ) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).unwrap(); } - let provides_line = if provides.is_empty() { + let alternatives_lines = if provides.is_empty() && conflicts.is_empty() { String::new() } else { - let quoted = provides + let mut section = String::from("[alternatives]\n"); + if !provides.is_empty() { + let quoted = provides + .iter() + .map(|p| format!("\"{p}\"")) + .collect::>() + .join(", "); + section.push_str(&format!("provides = [{quoted}]\n")); + } + if !conflicts.is_empty() { + let quoted = conflicts + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + section.push_str(&format!("conflicts = [{quoted}]\n")); + } + section.push('\n'); + section + }; + let deps_line = if runtime_deps.is_empty() { + String::new() + } else { + let quoted = runtime_deps .iter() .map(|p| format!("\"{p}\"")) .collect::>() .join(", "); - format!("[alternatives]\nprovides = [{quoted}]\n\n") + format!("[dependencies]\nruntime = [{quoted}]\n\n") }; let content = format!( - "[package]\nname = \"{name}\"\nversion = \"1.0.0\"\ndescription = \"test\"\nhomepage = \"https://example.com\"\nlicense = \"MIT\"\n\n{provides_line}[build]\ntype = \"meta\"\n" + "[package]\nname = \"{name}\"\nversion = \"1.0.0\"\ndescription = \"test\"\nhomepage = \"https://example.com\"\nlicense = \"MIT\"\n\n{alternatives_lines}{deps_line}[build]\ntype = \"meta\"\n" ); std::fs::write(path, content).unwrap(); } @@ -486,14 +628,21 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("depot"); let spec_path = repo_root.join("core/hello/hello.toml"); - write_meta_spec(&spec_path, "hello", &["sh"]); + write_meta_spec(&spec_path, "hello", &["sh"], &["busybox"], &["glibc"]); let stats = create_source_repo_index(&repo_root, &["core".to_string()]).unwrap(); assert_eq!(stats.specs_indexed, 1); assert_eq!(stats.package_rows, 1); assert_eq!(stats.provides_rows, 1); + assert_eq!(stats.conflicts_rows, 1); + assert_eq!(stats.dependency_rows, 1); 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.contains("C\tbusybox\tcore/hello/hello.toml\n")); + assert!(index_text.contains("R\tglibc\tcore/hello/hello.toml\n")); + let index = PackageIndex::build_with_repo_dir(Some(repo_root.clone())); let hit = index.find("hello").expect("package name should resolve"); assert!(hit.ends_with(Path::new("core/hello/hello.toml"))); @@ -508,7 +657,7 @@ mod tests { let repo_store = tmp.path().join("repo-store"); let repo_root = repo_store.join("vertex"); let spec_path = repo_root.join("core/base/base.toml"); - write_meta_spec(&spec_path, "base", &[]); + write_meta_spec(&spec_path, "base", &[], &[], &[]); let index = PackageIndex::build_with_repo_dir(Some(repo_store)); let hit = index @@ -522,7 +671,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("depot"); let spec_path = repo_root.join("core/curl/curl.toml"); - write_meta_spec(&spec_path, "curl", &["libcurl"]); + write_meta_spec(&spec_path, "curl", &["libcurl"], &[], &[]); std::fs::create_dir_all(&repo_root).unwrap(); std::fs::write(source_repo_index_path(&repo_root), "invalid-header\n").unwrap(); @@ -544,4 +693,23 @@ mod tests { .contains("must be a relative path without '..'") ); } + + #[test] + fn build_with_repo_dir_accepts_v1_index_files() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("depot"); + std::fs::create_dir_all(repo_root.join("core/base")).unwrap(); + std::fs::write( + source_repo_index_path(&repo_root), + "depot-source-index-v1\nP\tbase\tcore/base/base.toml\nV\tsh\tcore/base/base.toml\n", + ) + .unwrap(); + + let index = PackageIndex::build_with_repo_dir(Some(repo_root.clone())); + let hit = index.find("base").expect("package name should resolve"); + assert!(hit.ends_with(Path::new("core/base/base.toml"))); + let providers = index.find_providers("sh"); + assert_eq!(providers.len(), 1); + assert!(providers[0].ends_with(Path::new("core/base/base.toml"))); + } } diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 4650a60..982682b 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -655,6 +655,21 @@ pub fn create_interactive() -> Result { "Optional dependency", "Package that enables optional runtime functionality", )?; + let alternatives = if show_advanced { + Alternatives { + provides: prompt_repeating_list( + "Alternative provided name", + "Virtual package name(s) this package satisfies, e.g. sh, editor, libfoo", + )?, + conflicts: prompt_repeating_list( + "Conflicting package/provide", + "Installed package or provided name that must be removed before install", + )?, + replaces: Vec::new(), + } + } else { + Alternatives::default() + }; Ok(PackageSpec { package: PackageInfo { @@ -666,7 +681,7 @@ pub fn create_interactive() -> Result { license, }, packages: Vec::new(), - alternatives: Alternatives::default(), + alternatives, manual_sources, source: sources, build: Build { build_type, flags }, @@ -818,6 +833,35 @@ 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() { + let mut alternatives_tbl = Table::new(); + if !spec.alternatives.provides.is_empty() { + alternatives_tbl.insert( + "provides".into(), + Value::Array( + spec.alternatives + .provides + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !spec.alternatives.conflicts.is_empty() { + alternatives_tbl.insert( + "conflicts".into(), + Value::Array( + spec.alternatives + .conflicts + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + root.insert("alternatives".into(), Value::Table(alternatives_tbl)); + } + // build (only include set fields) let mut build_tbl = Table::new(); let build_type = match spec.build.build_type { @@ -1757,6 +1801,65 @@ mod tests { assert_eq!(optional_deps[0].as_str(), Some("gtk-doc")); } + #[test] + fn spec_to_minimal_toml_includes_alternatives_conflicts_and_provides() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives { + provides: vec!["editor".into(), "sh".into()], + conflicts: vec!["nano".into(), "busybox-sh".into()], + replaces: Vec::new(), + }, + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + let val: toml::Value = toml::from_str(&toml).unwrap(); + let alternatives = val + .get("alternatives") + .and_then(|v| v.as_table()) + .expect("expected alternatives table"); + let provides = alternatives + .get("provides") + .and_then(|v| v.as_array()) + .expect("expected alternatives.provides array"); + let conflicts = alternatives + .get("conflicts") + .and_then(|v| v.as_array()) + .expect("expected alternatives.conflicts array"); + + assert_eq!(provides.len(), 2); + assert_eq!(provides[0].as_str(), Some("editor")); + assert_eq!(provides[1].as_str(), Some("sh")); + assert_eq!(conflicts.len(), 2); + assert_eq!(conflicts[0].as_str(), Some("nano")); + assert_eq!(conflicts[1].as_str(), Some("busybox-sh")); + } + #[test] fn spec_to_minimal_toml_supports_metapackage_without_sources() { let spec = PackageSpec { diff --git a/src/package/packager.rs b/src/package/packager.rs index 06711d9..7a92197 100644 --- a/src/package/packager.rs +++ b/src/package/packager.rs @@ -171,6 +171,17 @@ impl Packager { .collect(), ), ); + map.insert( + "conflicts".to_string(), + toml::Value::Array( + self.spec + .alternatives + .conflicts + .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(); diff --git a/src/package/spec.rs b/src/package/spec.rs index c086377..958488c 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -1557,9 +1557,11 @@ type = "custom" [alternatives] provides = ["toolchain"] +conflicts = ["gcc"] [package_alternatives.clang] provides = ["cc", "c++", "gcc"] +conflicts = ["clang-legacy"] [package_alternatives.llvm] provides = ["binutils"] @@ -1572,14 +1574,26 @@ provides = ["binutils"] spec.alternatives_for_output("llvm").provides, vec!["binutils".to_string()] ); + assert_eq!( + spec.alternatives_for_output("llvm").conflicts, + Vec::::new() + ); assert_eq!( spec.alternatives_for_output("clang").provides, vec!["cc".to_string(), "c++".to_string(), "gcc".to_string()] ); + assert_eq!( + spec.alternatives_for_output("clang").conflicts, + vec!["clang-legacy".to_string()] + ); assert_eq!( spec.alternatives_for_output("other").provides, vec!["toolchain".to_string()] ); + assert_eq!( + spec.alternatives_for_output("other").conflicts, + vec!["gcc".to_string()] + ); } #[test] @@ -3001,6 +3015,9 @@ impl fmt::Display for PackageSpec { if !self.alternatives.provides.is_empty() { writeln!(f, "Provides: {}", self.alternatives.provides.join(", "))?; } + if !self.alternatives.conflicts.is_empty() { + writeln!(f, "Conflicts: {}", self.alternatives.conflicts.join(", "))?; + } Ok(()) } } @@ -3064,11 +3081,13 @@ impl PackageSpec { } } -/// Package alternatives (provides/replaces) +/// Package alternatives such as virtual provides and install conflicts. #[derive(Debug, Clone, Default, 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)] diff --git a/src/planner.rs b/src/planner.rs index 5b4aa9d..3b11f9d 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -814,6 +814,7 @@ mod tests { "foo-libs".into(), Alternatives { provides: vec!["libfoo".into()], + conflicts: Vec::new(), replaces: Vec::new(), }, )]), @@ -856,6 +857,7 @@ mod tests { homepage: None, license: None, provides: Vec::new(), + conflicts: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), }),