use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use cabin_core::{
DependencySource, Package, PackageName, PatchProvenance, PatchSource, PatchValidationError,
};
use thiserror::Error;
use crate::graph::PackageGraph;
use crate::loader::PatchedPackageSource;
#[derive(Debug, Clone)]
pub struct ActivePatch {
pub name: PackageName,
pub source: PatchSource,
pub provenance: PatchProvenance,
pub manifest_path: PathBuf,
pub manifest_dir: PathBuf,
pub declared_path: PathBuf,
pub package: Package,
}
#[derive(Debug, Clone, Default)]
pub struct ActivePatchSet {
entries: Vec<ActivePatch>,
}
impl ActivePatchSet {
pub fn iter(&self) -> std::slice::Iter<'_, ActivePatch> {
self.entries.iter()
}
}
impl<'a> IntoIterator for &'a ActivePatchSet {
type Item = &'a ActivePatch;
type IntoIter = std::slice::Iter<'a, ActivePatch>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter()
}
}
impl ActivePatchSet {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn get(&self, name: &PackageName) -> Option<&ActivePatch> {
self.entries.iter().find(|p| &p.name == name)
}
pub fn patched_names(&self) -> BTreeSet<&str> {
self.entries.iter().map(|p| p.name.as_str()).collect()
}
pub fn owned_patched_names(&self) -> BTreeSet<String> {
self.entries
.iter()
.map(|p| p.name.as_str().to_owned())
.collect()
}
pub fn workspace_sources(&self) -> Vec<PatchedPackageSource> {
self.entries
.iter()
.map(|entry| PatchedPackageSource {
name: entry.name.clone(),
version: entry.package.version.clone(),
manifest_path: entry.manifest_path.clone(),
})
.collect()
}
}
pub fn collect_patched_versioned_deps(
active_patches: &ActivePatchSet,
excluded_names: &BTreeSet<String>,
) -> Result<BTreeMap<PackageName, semver::VersionReq>, crate::WorkspaceError> {
let host_platform = cabin_core::TargetPlatform::current();
let mut combined: BTreeMap<PackageName, Vec<String>> = BTreeMap::new();
for patch in active_patches {
for dep in &patch.package.dependencies {
if !dep.kind.is_resolved_by_default() {
continue;
}
if !dep.matches_platform(&host_platform) {
continue;
}
if dep.optional {
continue;
}
if excluded_names.contains(dep.name.as_str()) {
continue;
}
if let DependencySource::Version(req) = &dep.source {
combined
.entry(dep.name.clone())
.or_default()
.push(req.to_string());
}
}
}
let mut out = BTreeMap::new();
for (name, mut reqs) in combined {
reqs.sort();
reqs.dedup();
let parsed =
crate::selection::combine_version_reqs(&reqs).map_err(|(requirements, source)| {
crate::WorkspaceError::IncompatibleWorkspaceRequirements {
name: name.as_str().to_owned(),
requirements,
source,
}
})?;
out.insert(name, parsed);
}
Ok(out)
}
pub struct PatchResolutionInputs<'a> {
pub graph: &'a PackageGraph,
pub manifest_patches: &'a cabin_core::PatchManifestSettings,
pub config_patches: &'a BTreeMap<PackageName, ConfigPatchInput>,
}
#[derive(Debug, Clone)]
pub struct ConfigPatchInput {
pub source: PatchSource,
pub provenance: PatchProvenance,
pub declared_in: PathBuf,
}
pub fn resolve_active_patches(
inputs: &PatchResolutionInputs<'_>,
) -> Result<ActivePatchSet, PatchResolutionError> {
let root_dir = inputs.graph.root_dir.clone();
let mut merged: BTreeMap<PackageName, MergedEntry> = BTreeMap::new();
for (name, source) in &inputs.manifest_patches.entries {
merged.insert(
name.clone(),
MergedEntry {
source: source.clone(),
provenance: PatchProvenance::Manifest,
base_dir: root_dir.clone(),
},
);
}
for (name, entry) in inputs.config_patches {
let base_dir = entry
.declared_in
.parent()
.map_or_else(|| root_dir.clone(), Path::to_path_buf);
merged.insert(
name.clone(),
MergedEntry {
source: entry.source.clone(),
provenance: entry.provenance,
base_dir,
},
);
}
let requirements = collect_version_requirements(inputs.graph, &merged);
let mut entries: Vec<ActivePatch> = Vec::with_capacity(merged.len());
for (name, entry) in merged {
let resolved = resolve_one_patch(&name, entry, &requirements)?;
entries.push(resolved);
}
entries.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
Ok(ActivePatchSet { entries })
}
struct MergedEntry {
source: PatchSource,
provenance: PatchProvenance,
base_dir: PathBuf,
}
fn collect_version_requirements(
graph: &PackageGraph,
merged: &BTreeMap<PackageName, MergedEntry>,
) -> BTreeMap<PackageName, Vec<semver::VersionReq>> {
let host_platform = cabin_core::TargetPlatform::current();
let mut out: BTreeMap<PackageName, Vec<semver::VersionReq>> = BTreeMap::new();
for pkg in &graph.packages {
for dep in &pkg.package.dependencies {
if !merged.contains_key(&dep.name) {
continue;
}
if !dep.kind.is_resolved_by_default() {
continue;
}
if !dep.matches_platform(&host_platform) {
continue;
}
if dep.optional {
continue;
}
if let DependencySource::Version(req) = &dep.source {
out.entry(dep.name.clone()).or_default().push(req.clone());
}
}
}
for reqs in out.values_mut() {
reqs.sort_by_cached_key(std::string::ToString::to_string);
reqs.dedup_by(|a, b| a.to_string() == b.to_string());
}
out
}
fn resolve_one_patch(
name: &PackageName,
entry: MergedEntry,
requirements: &BTreeMap<PackageName, Vec<semver::VersionReq>>,
) -> Result<ActivePatch, PatchResolutionError> {
let MergedEntry {
source,
provenance,
base_dir,
} = entry;
match source {
PatchSource::Path {
path: declared_path,
} => {
let absolute_dir = if declared_path.is_absolute() {
declared_path.clone()
} else {
base_dir.join(&declared_path)
};
let manifest_path = absolute_dir.join("cabin.toml");
if !manifest_path.is_file() {
return Err(PatchResolutionError::Validation {
package: name.as_str().to_owned(),
source: PatchValidationError::MissingManifest {
package: name.as_str().to_owned(),
path: declared_path.display().to_string(),
},
});
}
let parsed = cabin_manifest::load_manifest(&manifest_path).map_err(|err| {
PatchResolutionError::ManifestParse {
package: name.as_str().to_owned(),
path: manifest_path.clone(),
reason: err.to_string(),
}
})?;
let package = parsed
.package
.ok_or_else(|| PatchResolutionError::Validation {
package: name.as_str().to_owned(),
source: PatchValidationError::ManifestHasNoPackage {
package: name.as_str().to_owned(),
path: declared_path.display().to_string(),
},
})?;
if &package.name != name {
return Err(PatchResolutionError::Validation {
package: name.as_str().to_owned(),
source: PatchValidationError::PackageNameMismatch {
package: name.as_str().to_owned(),
actual: package.name.as_str().to_owned(),
},
});
}
if let Some(reqs) = requirements.get(name) {
for req in reqs {
if !req.matches(&package.version) {
return Err(PatchResolutionError::Validation {
package: name.as_str().to_owned(),
source: PatchValidationError::VersionMismatch {
package: name.as_str().to_owned(),
version: package.version.to_string(),
requirement: req.to_string(),
},
});
}
}
}
let canonical_manifest = std::fs::canonicalize(&manifest_path).map_err(|err| {
PatchResolutionError::ManifestParse {
package: name.as_str().to_owned(),
path: manifest_path.clone(),
reason: err.to_string(),
}
})?;
let canonical_dir = canonical_manifest
.parent()
.map_or(absolute_dir, Path::to_path_buf);
Ok(ActivePatch {
name: name.clone(),
source: PatchSource::Path {
path: declared_path.clone(),
},
provenance,
manifest_path: canonical_manifest,
manifest_dir: canonical_dir,
declared_path,
package,
})
}
}
}
#[derive(Debug, Error)]
pub enum PatchResolutionError {
#[error("invalid patch for `{package}`: {source}")]
Validation {
package: String,
#[source]
source: PatchValidationError,
},
#[error(
"failed to parse patch manifest for `{package}` at {path}: {reason}",
path = path.display()
)]
ManifestParse {
package: String,
path: PathBuf,
reason: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::load_workspace;
use assert_fs::TempDir;
use assert_fs::prelude::*;
fn fixture(parent: &TempDir, dep_block: &str) -> PackageGraph {
parent
.child("fmt/cabin.toml")
.write_str("[package]\nname = \"fmt\"\nversion = \"0.1.0\"\n")
.unwrap();
let manifest = format!(
"[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n{dep_block}\n\n[patch]\nfmt = {{ path = \"../fmt\" }}\n",
);
parent.child("app/cabin.toml").write_str(&manifest).unwrap();
load_workspace(parent.path().join("app/cabin.toml")).unwrap()
}
fn resolve_with(graph: &PackageGraph) -> Result<ActivePatchSet, PatchResolutionError> {
let manifest_patches = &graph.root_settings.patches;
let empty: BTreeMap<PackageName, ConfigPatchInput> = BTreeMap::new();
resolve_active_patches(&PatchResolutionInputs {
graph,
manifest_patches,
config_patches: &empty,
})
}
#[test]
fn patch_target_without_package_table_reports_no_package() {
let dir = TempDir::new().unwrap();
let graph = fixture(&dir, "[dependencies]\nfmt = \">=0.1\"");
dir.child("fmt/cabin.toml")
.write_str("[workspace]\nmembers = []\n")
.unwrap();
let err = resolve_with(&graph).expect_err("workspace-only patch target must be rejected");
match err {
PatchResolutionError::Validation { source, .. } => {
assert!(
matches!(source, PatchValidationError::ManifestHasNoPackage { .. }),
"expected ManifestHasNoPackage, got {source:?}"
);
}
PatchResolutionError::ManifestParse { .. } => {
panic!("expected Validation error, got ManifestParse")
}
}
}
#[test]
fn dev_only_dep_does_not_block_patch_version() {
let dir = TempDir::new().unwrap();
let graph = fixture(&dir, "[dev-dependencies]\nfmt = \">=99\"");
let resolved = resolve_with(&graph).expect("dev-only requirement must not gate patch");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved.iter().next().unwrap().name.as_str(), "fmt");
}
#[test]
fn optional_dep_does_not_block_patch_version() {
let dir = TempDir::new().unwrap();
let graph = fixture(
&dir,
"[dependencies]\nfmt = { version = \">=99\", optional = true }",
);
let resolved = resolve_with(&graph).expect("optional requirement must not gate patch");
assert_eq!(resolved.len(), 1);
}
#[test]
fn target_mismatched_dep_does_not_block_patch_version() {
let dir = TempDir::new().unwrap();
let graph = fixture(
&dir,
"[target.'cfg(os = \"never-an-os\")'.dependencies]\nfmt = \">=99\"",
);
let resolved =
resolve_with(&graph).expect("non-matching target requirement must not gate patch");
assert_eq!(resolved.len(), 1);
}
#[test]
fn active_normal_dep_still_validates_patch_version() {
let dir = TempDir::new().unwrap();
let graph = fixture(&dir, "[dependencies]\nfmt = \">=99\"");
let err = resolve_with(&graph).expect_err("active requirement must reject patch");
match err {
PatchResolutionError::Validation { source, .. } => {
assert!(
matches!(source, PatchValidationError::VersionMismatch { .. }),
"expected VersionMismatch, got {source:?}"
);
}
PatchResolutionError::ManifestParse { .. } => {
panic!("expected Validation error, got ManifestParse")
}
}
}
#[test]
fn patched_manifest_versioned_deps_follow_workspace_policy() {
let dir = TempDir::new().unwrap();
dir.child("fmt/cabin.toml")
.write_str(
r#"[package]
name = "fmt"
version = "0.1.0"
[dependencies]
spdlog = "^1.13"
fmt = "^99"
optional-lib = { version = "^2", optional = true }
[dev-dependencies]
testkit = "^1"
[target.'cfg(os = "never-an-os")'.dependencies]
target-only = "^1"
"#,
)
.unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=0.1"
[patch]
fmt = { path = "../fmt" }
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("app/cabin.toml")).unwrap();
let patches = resolve_with(&graph).unwrap();
let excluded = patches.owned_patched_names();
let deps = collect_patched_versioned_deps(&patches, &excluded).unwrap();
let rendered: BTreeMap<_, _> = deps
.iter()
.map(|(name, req)| (name.as_str().to_owned(), req.to_string()))
.collect();
assert_eq!(
rendered,
BTreeMap::from([("spdlog".to_owned(), "^1.13".to_owned())])
);
}
}