diff --git a/Cargo.lock b/Cargo.lock index 00f97da23..c2f109f23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" + [[package]] name = "anes" version = "0.1.6" @@ -1117,6 +1123,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" + [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -1386,9 +1398,14 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -1734,7 +1751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.15.0", + "hashbrown 0.15.1", "serde", ] @@ -5189,6 +5206,7 @@ name = "uv-pypi-types" version = "0.0.1" dependencies = [ "anyhow", + "hashbrown 0.15.1", "indexmap", "itertools 0.13.0", "jiff", @@ -5339,6 +5357,7 @@ dependencies = [ "dashmap", "either", "futures", + "hashbrown 0.15.1", "indexmap", "insta", "itertools 0.13.0", diff --git a/Cargo.toml b/Cargo.toml index ff333acaa..82f490c75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,7 @@ glob = { version = "0.3.1" } globset = { version = "0.4.15" } globwalk = { version = "0.9.1" } goblin = { version = "0.9.0", default-features = false, features = ["std", "elf32", "elf64", "endian_fd"] } +hashbrown = { version = "0.15.1" } hex = { version = "0.4.3" } home = { version = "0.5.9" } html-escape = { version = "0.2.13" } diff --git a/crates/uv-pypi-types/Cargo.toml b/crates/uv-pypi-types/Cargo.toml index 6e4701174..6ee41883c 100644 --- a/crates/uv-pypi-types/Cargo.toml +++ b/crates/uv-pypi-types/Cargo.toml @@ -23,6 +23,7 @@ uv-normalize = { workspace = true, features = ["schemars"] } uv-pep440 = { workspace = true } uv-pep508 = { workspace = true } +hashbrown = { workspace = true } indexmap = { workspace = true, features = ["serde"] } itertools = { workspace = true } jiff = { workspace = true, features = ["serde"] } diff --git a/crates/uv-pypi-types/src/conflicts.rs b/crates/uv-pypi-types/src/conflicts.rs index 2fdd9e41c..e3c1942f5 100644 --- a/crates/uv-pypi-types/src/conflicts.rs +++ b/crates/uv-pypi-types/src/conflicts.rs @@ -1,4 +1,4 @@ -use uv_normalize::{ExtraName, PackageName}; +use uv_normalize::{ExtraName, GroupName, PackageName}; /// A list of conflicting sets of extras/groups pre-defined by an end user. /// @@ -78,7 +78,7 @@ impl ConflictSet { /// extra name pair. pub fn contains(&self, package: &PackageName, extra: &ExtraName) -> bool { self.iter() - .any(|set| set.package() == package && set.extra() == extra) + .any(|set| set.package() == package && set.extra() == Some(extra)) } } @@ -111,7 +111,6 @@ impl TryFrom> for ConflictSet { /// package. #[derive( Debug, - Default, Clone, Eq, Hash, @@ -122,9 +121,14 @@ impl TryFrom> for ConflictSet { serde::Serialize, schemars::JsonSchema, )] +#[serde( + deny_unknown_fields, + try_from = "ConflictItemWire", + into = "ConflictItemWire" +)] pub struct ConflictItem { package: PackageName, - extra: ExtraName, + conflict: ConflictPackage, } impl ConflictItem { @@ -133,23 +137,43 @@ impl ConflictItem { &self.package } + /// Returns the package-specific conflict. + /// + /// i.e., Either an extra or a group name. + pub fn conflict(&self) -> &ConflictPackage { + &self.conflict + } + /// Returns the extra name of this conflicting item. - pub fn extra(&self) -> &ExtraName { - &self.extra + pub fn extra(&self) -> Option<&ExtraName> { + self.conflict.extra() + } + + /// Returns the group name of this conflicting item. + pub fn group(&self) -> Option<&GroupName> { + self.conflict.group() } /// Returns this item as a new type with its fields borrowed. pub fn as_ref(&self) -> ConflictItemRef<'_> { ConflictItemRef { package: self.package(), - extra: self.extra(), + conflict: self.conflict.as_ref(), } } } impl From<(PackageName, ExtraName)> for ConflictItem { fn from((package, extra): (PackageName, ExtraName)) -> ConflictItem { - ConflictItem { package, extra } + let conflict = ConflictPackage::Extra(extra); + ConflictItem { package, conflict } + } +} + +impl From<(PackageName, GroupName)> for ConflictItem { + fn from((package, group): (PackageName, GroupName)) -> ConflictItem { + let conflict = ConflictPackage::Group(group); + ConflictItem { package, conflict } } } @@ -160,7 +184,7 @@ impl From<(PackageName, ExtraName)> for ConflictItem { #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)] pub struct ConflictItemRef<'a> { package: &'a PackageName, - extra: &'a ExtraName, + conflict: ConflictPackageRef<'a>, } impl<'a> ConflictItemRef<'a> { @@ -169,23 +193,129 @@ impl<'a> ConflictItemRef<'a> { self.package } + /// Returns the package-specific conflict. + /// + /// i.e., Either an extra or a group name. + pub fn conflict(&self) -> ConflictPackageRef<'a> { + self.conflict + } + /// Returns the extra name of this conflicting item. - pub fn extra(&self) -> &'a ExtraName { - self.extra + pub fn extra(&self) -> Option<&'a ExtraName> { + self.conflict.extra() + } + + /// Returns the group name of this conflicting item. + pub fn group(&self) -> Option<&'a GroupName> { + self.conflict.group() } /// Converts this borrowed conflicting item to its owned variant. pub fn to_owned(&self) -> ConflictItem { ConflictItem { package: self.package().clone(), - extra: self.extra().clone(), + conflict: self.conflict.to_owned(), } } } impl<'a> From<(&'a PackageName, &'a ExtraName)> for ConflictItemRef<'a> { fn from((package, extra): (&'a PackageName, &'a ExtraName)) -> ConflictItemRef<'a> { - ConflictItemRef { package, extra } + let conflict = ConflictPackageRef::Extra(extra); + ConflictItemRef { package, conflict } + } +} + +impl<'a> From<(&'a PackageName, &'a GroupName)> for ConflictItemRef<'a> { + fn from((package, group): (&'a PackageName, &'a GroupName)) -> ConflictItemRef<'a> { + let conflict = ConflictPackageRef::Group(group); + ConflictItemRef { package, conflict } + } +} + +impl<'a> hashbrown::Equivalent for ConflictItemRef<'a> { + fn equivalent(&self, key: &ConflictItem) -> bool { + key.as_ref() == *self + } +} + +/// The actual conflicting data for a package. +/// +/// That is, either an extra or a group name. +#[derive(Debug, Clone, Eq, Hash, PartialEq, PartialOrd, Ord, schemars::JsonSchema)] +pub enum ConflictPackage { + Extra(ExtraName), + Group(GroupName), +} + +impl ConflictPackage { + /// If this conflict corresponds to an extra, then return the + /// extra name. + pub fn extra(&self) -> Option<&ExtraName> { + match *self { + ConflictPackage::Extra(ref extra) => Some(extra), + ConflictPackage::Group(_) => None, + } + } + + /// If this conflict corresponds to a group, then return the + /// group name. + pub fn group(&self) -> Option<&GroupName> { + match *self { + ConflictPackage::Group(ref group) => Some(group), + ConflictPackage::Extra(_) => None, + } + } + + /// Returns this conflict as a new type with its fields borrowed. + pub fn as_ref(&self) -> ConflictPackageRef<'_> { + match *self { + ConflictPackage::Extra(ref extra) => ConflictPackageRef::Extra(extra), + ConflictPackage::Group(ref group) => ConflictPackageRef::Group(group), + } + } +} + +/// The actual conflicting data for a package, by reference. +/// +/// That is, either a borrowed extra name or a borrowed group name. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub enum ConflictPackageRef<'a> { + Extra(&'a ExtraName), + Group(&'a GroupName), +} + +impl<'a> ConflictPackageRef<'a> { + /// If this conflict corresponds to an extra, then return the + /// extra name. + pub fn extra(&self) -> Option<&'a ExtraName> { + match *self { + ConflictPackageRef::Extra(extra) => Some(extra), + ConflictPackageRef::Group(_) => None, + } + } + + /// If this conflict corresponds to a group, then return the + /// group name. + pub fn group(&self) -> Option<&'a GroupName> { + match *self { + ConflictPackageRef::Group(group) => Some(group), + ConflictPackageRef::Extra(_) => None, + } + } + + /// Converts this borrowed conflict to its owned variant. + pub fn to_owned(&self) -> ConflictPackage { + match *self { + ConflictPackageRef::Extra(extra) => ConflictPackage::Extra(extra.clone()), + ConflictPackageRef::Group(group) => ConflictPackage::Group(group.clone()), + } + } +} + +impl<'a> hashbrown::Equivalent for ConflictPackageRef<'a> { + fn equivalent(&self, key: &ConflictPackage) -> bool { + key.as_ref() == *self } } @@ -198,6 +328,19 @@ pub enum ConflictError { /// An error for when there is one conflicting items. #[error("Each set of conflicts must have at least two entries, but found only one")] OneItem, + /// An error that occurs when the `package` field is missing. + /// + /// (This is only applicable when deserializing from the lock file. + /// When deserializing from `pyproject.toml`, the `package` field is + /// optional.) + #[error("Expected `package` field in conflicting entry")] + MissingPackage, + /// An error that occurs when both `extra` and `group` are missing. + #[error("Expected `extra` or `group` field in conflicting entry")] + MissingExtraAndGroup, + /// An error that occurs when both `extra` and `group` are present. + #[error("Expected one of `extra` or `group` in conflicting entry, but found both")] + FoundExtraAndGroup, } /// Like [`Conflicts`], but for deserialization in `pyproject.toml`. @@ -228,7 +371,10 @@ impl SchemaConflicts { let mut set = vec![]; for item in &tool_uv_set.0 { let package = item.package.clone().unwrap_or_else(|| package.clone()); - set.push(ConflictItem::from((package, item.extra.clone()))); + set.push(ConflictItem { + package: package.clone(), + conflict: item.conflict.clone(), + }); } // OK because we guarantee that // `SchemaConflictingGroupList` is valid and there aren't @@ -257,7 +403,6 @@ pub struct SchemaConflictSet(Vec); /// name. #[derive( Debug, - Default, Clone, Eq, Hash, @@ -268,11 +413,14 @@ pub struct SchemaConflictSet(Vec); serde::Serialize, schemars::JsonSchema, )] -#[serde(deny_unknown_fields)] +#[serde( + deny_unknown_fields, + try_from = "ConflictItemWire", + into = "ConflictItemWire" +)] pub struct SchemaConflictItem { - #[serde(default)] package: Option, - extra: ExtraName, + conflict: ConflictPackage, } impl<'de> serde::Deserialize<'de> for SchemaConflictSet { @@ -297,3 +445,83 @@ impl TryFrom> for SchemaConflictSet { Ok(SchemaConflictSet(items)) } } + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct ConflictItemWire { + #[serde(default)] + package: Option, + #[serde(default)] + extra: Option, + #[serde(default)] + group: Option, +} + +impl TryFrom for ConflictItem { + type Error = ConflictError; + + fn try_from(wire: ConflictItemWire) -> Result { + let Some(package) = wire.package else { + return Err(ConflictError::MissingPackage); + }; + match (wire.extra, wire.group) { + (None, None) => Err(ConflictError::MissingExtraAndGroup), + (Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup), + (Some(extra), None) => Ok(ConflictItem::from((package, extra))), + (None, Some(group)) => Ok(ConflictItem::from((package, group))), + } + } +} + +impl From for ConflictItemWire { + fn from(item: ConflictItem) -> ConflictItemWire { + match item.conflict { + ConflictPackage::Extra(extra) => ConflictItemWire { + package: Some(item.package), + extra: Some(extra), + group: None, + }, + ConflictPackage::Group(group) => ConflictItemWire { + package: Some(item.package), + extra: None, + group: Some(group), + }, + } + } +} + +impl TryFrom for SchemaConflictItem { + type Error = ConflictError; + + fn try_from(wire: ConflictItemWire) -> Result { + let package = wire.package; + match (wire.extra, wire.group) { + (None, None) => Err(ConflictError::MissingExtraAndGroup), + (Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup), + (Some(extra), None) => Ok(SchemaConflictItem { + package, + conflict: ConflictPackage::Extra(extra), + }), + (None, Some(group)) => Ok(SchemaConflictItem { + package, + conflict: ConflictPackage::Group(group), + }), + } + } +} + +impl From for ConflictItemWire { + fn from(item: SchemaConflictItem) -> ConflictItemWire { + match item.conflict { + ConflictPackage::Extra(extra) => ConflictItemWire { + package: item.package, + extra: Some(extra), + group: None, + }, + ConflictPackage::Group(group) => ConflictItemWire { + package: item.package, + extra: None, + group: Some(group), + }, + } + } +} diff --git a/crates/uv-resolver/Cargo.toml b/crates/uv-resolver/Cargo.toml index 6a881fa9b..6c9381e67 100644 --- a/crates/uv-resolver/Cargo.toml +++ b/crates/uv-resolver/Cargo.toml @@ -43,6 +43,7 @@ clap = { workspace = true, features = ["derive"], optional = true } dashmap = { workspace = true } either = { workspace = true } futures = { workspace = true } +hashbrown = { workspace = true } indexmap = { workspace = true } itertools = { workspace = true } jiff = { workspace = true, features = ["serde"] } diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index b06cc74c9..d0f3d00c0 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -25,6 +25,13 @@ pub use resolver::{ pub use version_map::VersionMap; pub use yanks::AllowedYanks; +/// A custom `HashSet` using `hashbrown`. +/// +/// We use `hashbrown` instead of `std` to get access to its `Equivalent` +/// trait. This lets use store things like `ConflictItem`, but refer to it via +/// `ConflictItemRef`. i.e., We can avoid allocs on lookups. +type FxHashbrownSet = hashbrown::HashSet; + mod bare; mod candidate_selector; diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 9bd33d96a..9dceeb6e9 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -40,8 +40,8 @@ use uv_pep440::Version; use uv_pep508::{split_scheme, MarkerEnvironment, MarkerTree, VerbatimUrl, VerbatimUrlError}; use uv_platform_tags::{TagCompatibility, TagPriority, Tags}; use uv_pypi_types::{ - redact_credentials, Conflicts, HashDigest, ParsedArchiveUrl, ParsedGitUrl, Requirement, - RequirementSource, + redact_credentials, ConflictPackage, Conflicts, HashDigest, ParsedArchiveUrl, ParsedGitUrl, + Requirement, RequirementSource, }; use uv_types::{BuildContext, HashStrategy}; use uv_workspace::dependency_groups::DependencyGroupError; @@ -634,11 +634,18 @@ impl Lock { if !self.conflicts.is_empty() { let mut list = Array::new(); - for groups in self.conflicts.iter() { - list.push(each_element_on_its_line_array(groups.iter().map(|group| { + for set in self.conflicts.iter() { + list.push(each_element_on_its_line_array(set.iter().map(|item| { let mut table = InlineTable::new(); - table.insert("package", Value::from(group.package().to_string())); - table.insert("extra", Value::from(group.extra().to_string())); + table.insert("package", Value::from(item.package().to_string())); + match item.conflict() { + ConflictPackage::Extra(ref extra) => { + table.insert("extra", Value::from(extra.to_string())); + } + ConflictPackage::Group(ref group) => { + table.insert("group", Value::from(group.to_string())); + } + } table }))); } diff --git a/crates/uv-resolver/src/resolver/environment.rs b/crates/uv-resolver/src/resolver/environment.rs index 0c6fb0f71..91fa9d274 100644 --- a/crates/uv-resolver/src/resolver/environment.rs +++ b/crates/uv-resolver/src/resolver/environment.rs @@ -1,7 +1,5 @@ use std::sync::Arc; -use rustc_hash::{FxHashMap, FxHashSet}; -use uv_normalize::{ExtraName, PackageName}; use uv_pep508::{MarkerEnvironment, MarkerTree}; use uv_pypi_types::{ConflictItem, ConflictItemRef, ResolverMarkerEnvironment}; @@ -97,7 +95,7 @@ enum Kind { /// The markers associated with this resolver fork. markers: MarkerTree, /// Conflicting group exclusions. - exclude: Arc>>, + exclude: Arc>, }, } @@ -135,7 +133,7 @@ impl ResolverEnvironment { let kind = Kind::Universal { initial_forks: initial_forks.into(), markers: MarkerTree::TRUE, - exclude: Arc::new(FxHashMap::default()), + exclude: Arc::new(crate::FxHashbrownSet::default()), }; ResolverEnvironment { kind } } @@ -166,10 +164,7 @@ impl ResolverEnvironment { pub(crate) fn included_by_group(&self, group: ConflictItemRef<'_>) -> bool { match self.kind { Kind::Specific { .. } => true, - Kind::Universal { ref exclude, .. } => !exclude - .get(group.package()) - .map(|set| set.contains(group.extra())) - .unwrap_or(false), + Kind::Universal { ref exclude, .. } => !exclude.contains(&group), } } @@ -227,7 +222,7 @@ impl ResolverEnvironment { /// specific marker environment. i.e., "pip"-style resolution. pub(crate) fn exclude_by_group( &self, - groups: impl IntoIterator, + items: impl IntoIterator, ) -> ResolverEnvironment { match self.kind { Kind::Specific { .. } => { @@ -238,12 +233,9 @@ impl ResolverEnvironment { ref markers, ref exclude, } => { - let mut exclude: FxHashMap<_, _> = (**exclude).clone(); - for group in groups { - exclude - .entry(group.package().clone()) - .or_default() - .insert(group.extra().clone()); + let mut exclude: crate::FxHashbrownSet<_> = (**exclude).clone(); + for item in items { + exclude.insert(item); } let kind = Kind::Universal { initial_forks: Arc::clone(initial_forks), diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index b3b51d742..4a20dbcc6 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -2950,7 +2950,7 @@ struct Fork { /// This exists to make some access patterns more efficient. Namely, /// it makes it easy to check whether there's a dependency with a /// particular conflicting group in this fork. - conflicts: FxHashMap>, + conflicts: crate::FxHashbrownSet, /// The resolver environment for this fork. /// /// Principally, this corresponds to the markers in this for. So in the @@ -2971,7 +2971,7 @@ impl Fork { fn new(env: ResolverEnvironment) -> Fork { Fork { dependencies: vec![], - conflicts: FxHashMap::default(), + conflicts: crate::FxHashbrownSet::default(), env, } } @@ -2979,10 +2979,7 @@ impl Fork { /// Add a dependency to this fork. fn add_dependency(&mut self, dep: PubGrubDependency) { if let Some(conflicting_item) = dep.package.conflicting_item() { - self.conflicts - .entry(conflicting_item.package().clone()) - .or_default() - .insert(conflicting_item.extra().clone()); + self.conflicts.insert(conflicting_item.to_owned()); } self.dependencies.push(dep); } @@ -3001,9 +2998,7 @@ impl Fork { return true; } if let Some(conflicting_item) = dep.package.conflicting_item() { - if let Some(set) = self.conflicts.get_mut(conflicting_item.package()) { - set.remove(conflicting_item.extra()); - } + self.conflicts.remove(&conflicting_item); } false }); @@ -3011,11 +3006,8 @@ impl Fork { /// Returns true if any of the dependencies in this fork contain a /// dependency with the given package and extra values. - fn contains_conflicting_item(&self, group: ConflictItemRef<'_>) -> bool { - self.conflicts - .get(group.package()) - .map(|set| set.contains(group.extra())) - .unwrap_or(false) + fn contains_conflicting_item(&self, item: ConflictItemRef<'_>) -> bool { + self.conflicts.contains(&item) } /// Exclude the given groups from this fork. @@ -3031,9 +3023,7 @@ impl Fork { return true; } if let Some(conflicting_item) = dep.package.conflicting_item() { - if let Some(set) = self.conflicts.get_mut(conflicting_item.package()) { - set.remove(conflicting_item.extra()); - } + self.conflicts.remove(&conflicting_item); } false }); diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 6e309f8a6..201d6d26f 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -22,7 +22,7 @@ use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::MarkerTreeContents; -use uv_pypi_types::{ConflictSet, Conflicts, Requirement}; +use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts, Requirement}; use uv_python::{ EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, @@ -86,7 +86,12 @@ pub(crate) enum ProjectError { _1.iter().map(|extra| format!("`{extra}`")).collect::>().join(", "), _0 .iter() - .map(|group| format!("`{}[{}]`", group.package(), group.extra())) + .map(|item| { + match item.conflict() { + ConflictPackage::Extra(ref extra) => format!("`{}[{}]`", item.package(), extra), + ConflictPackage::Group(ref group) => format!("`{}:{}`", item.package(), group), + } + }) .collect::>() .join(", "), )] diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 4eaa15e34..233ffdcfc 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -287,8 +287,9 @@ pub(super) async fn do_sync( for set in conflicts.iter() { let conflicting = set .iter() - .filter(|item| extras.contains(item.extra())) - .map(|item| item.extra().clone()) + .filter_map(|item| item.extra()) + .filter(|extra| extras.contains(extra)) + .map(|extra| extra.clone()) .collect::>(); if conflicting.len() >= 2 { return Err(ProjectError::ExtraIncompatibility(set.clone(), conflicting));