1use std::collections::{BTreeMap, BTreeSet};
23use std::path::{Path, PathBuf};
24
25use cabin_core::{
26 DependencySource, Package, PackageName, PatchProvenance, PatchSource, PatchValidationError,
27};
28use thiserror::Error;
29
30use crate::graph::PackageGraph;
31use crate::loader::PatchedPackageSource;
32
33#[derive(Debug, Clone)]
37pub struct ActivePatch {
38 pub name: PackageName,
39 pub source: PatchSource,
40 pub provenance: PatchProvenance,
41 pub manifest_path: PathBuf,
43 pub manifest_dir: PathBuf,
45 pub declared_path: PathBuf,
50 pub package: Package,
53}
54
55#[derive(Debug, Clone, Default)]
58pub struct ActivePatchSet {
59 entries: Vec<ActivePatch>,
60}
61
62impl ActivePatchSet {
63 pub fn iter(&self) -> std::slice::Iter<'_, ActivePatch> {
65 self.entries.iter()
66 }
67}
68
69impl<'a> IntoIterator for &'a ActivePatchSet {
70 type Item = &'a ActivePatch;
71 type IntoIter = std::slice::Iter<'a, ActivePatch>;
72
73 fn into_iter(self) -> Self::IntoIter {
74 self.entries.iter()
75 }
76}
77
78impl ActivePatchSet {
79 pub fn is_empty(&self) -> bool {
82 self.entries.is_empty()
83 }
84
85 pub fn len(&self) -> usize {
87 self.entries.len()
88 }
89
90 pub fn get(&self, name: &PackageName) -> Option<&ActivePatch> {
92 self.entries.iter().find(|p| &p.name == name)
93 }
94
95 pub fn patched_names(&self) -> BTreeSet<&str> {
99 self.entries.iter().map(|p| p.name.as_str()).collect()
100 }
101
102 pub fn owned_patched_names(&self) -> BTreeSet<String> {
106 self.entries
107 .iter()
108 .map(|p| p.name.as_str().to_owned())
109 .collect()
110 }
111
112 pub fn workspace_sources(&self) -> Vec<PatchedPackageSource> {
119 self.entries
120 .iter()
121 .map(|entry| PatchedPackageSource {
122 name: entry.name.clone(),
123 version: entry.package.version.clone(),
124 manifest_path: entry.manifest_path.clone(),
125 })
126 .collect()
127 }
128}
129
130pub fn collect_patched_versioned_deps(
150 active_patches: &ActivePatchSet,
151 excluded_names: &BTreeSet<String>,
152) -> Result<BTreeMap<PackageName, semver::VersionReq>, crate::WorkspaceError> {
153 let host_platform = cabin_core::TargetPlatform::current();
154 let mut combined: BTreeMap<PackageName, Vec<String>> = BTreeMap::new();
155
156 for patch in active_patches {
157 for dep in &patch.package.dependencies {
158 if !dep.kind.is_resolved_by_default() {
159 continue;
160 }
161 if !dep.matches_platform(&host_platform) {
162 continue;
163 }
164 if dep.optional {
165 continue;
166 }
167 if excluded_names.contains(dep.name.as_str()) {
168 continue;
169 }
170 if let DependencySource::Version(req) = &dep.source {
171 combined
172 .entry(dep.name.clone())
173 .or_default()
174 .push(req.to_string());
175 }
176 }
177 }
178
179 let mut out = BTreeMap::new();
180 for (name, mut reqs) in combined {
181 reqs.sort();
182 reqs.dedup();
183 let parsed =
184 crate::selection::combine_version_reqs(&reqs).map_err(|(requirements, source)| {
185 crate::WorkspaceError::IncompatibleWorkspaceRequirements {
186 name: name.as_str().to_owned(),
187 requirements,
188 source,
189 }
190 })?;
191 out.insert(name, parsed);
192 }
193 Ok(out)
194}
195
196pub struct PatchResolutionInputs<'a> {
200 pub graph: &'a PackageGraph,
204 pub manifest_patches: &'a cabin_core::PatchManifestSettings,
207 pub config_patches: &'a BTreeMap<PackageName, ConfigPatchInput>,
211}
212
213#[derive(Debug, Clone)]
218pub struct ConfigPatchInput {
219 pub source: PatchSource,
220 pub provenance: PatchProvenance,
221 pub declared_in: PathBuf,
223}
224
225pub fn resolve_active_patches(
242 inputs: &PatchResolutionInputs<'_>,
243) -> Result<ActivePatchSet, PatchResolutionError> {
244 let root_dir = inputs.graph.root_dir.clone();
245
246 let mut merged: BTreeMap<PackageName, MergedEntry> = BTreeMap::new();
250 for (name, source) in &inputs.manifest_patches.entries {
251 merged.insert(
252 name.clone(),
253 MergedEntry {
254 source: source.clone(),
255 provenance: PatchProvenance::Manifest,
256 base_dir: root_dir.clone(),
257 },
258 );
259 }
260 for (name, entry) in inputs.config_patches {
261 let base_dir = entry
262 .declared_in
263 .parent()
264 .map_or_else(|| root_dir.clone(), Path::to_path_buf);
265 merged.insert(
266 name.clone(),
267 MergedEntry {
268 source: entry.source.clone(),
269 provenance: entry.provenance,
270 base_dir,
271 },
272 );
273 }
274
275 let requirements = collect_version_requirements(inputs.graph, &merged);
280
281 let mut entries: Vec<ActivePatch> = Vec::with_capacity(merged.len());
283 for (name, entry) in merged {
284 let resolved = resolve_one_patch(&name, entry, &requirements)?;
285 entries.push(resolved);
286 }
287 entries.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
288
289 Ok(ActivePatchSet { entries })
290}
291
292struct MergedEntry {
293 source: PatchSource,
294 provenance: PatchProvenance,
295 base_dir: PathBuf,
296}
297
298fn collect_version_requirements(
314 graph: &PackageGraph,
315 merged: &BTreeMap<PackageName, MergedEntry>,
316) -> BTreeMap<PackageName, Vec<semver::VersionReq>> {
317 let host_platform = cabin_core::TargetPlatform::current();
318 let mut out: BTreeMap<PackageName, Vec<semver::VersionReq>> = BTreeMap::new();
319 for pkg in &graph.packages {
320 for dep in &pkg.package.dependencies {
321 if !merged.contains_key(&dep.name) {
322 continue;
323 }
324 if !dep.kind.is_resolved_by_default() {
325 continue;
326 }
327 if !dep.matches_platform(&host_platform) {
328 continue;
329 }
330 if dep.optional {
331 continue;
332 }
333 if let DependencySource::Version(req) = &dep.source {
334 out.entry(dep.name.clone()).or_default().push(req.clone());
335 }
336 }
337 }
338 for reqs in out.values_mut() {
339 reqs.sort_by_cached_key(std::string::ToString::to_string);
340 reqs.dedup_by(|a, b| a.to_string() == b.to_string());
341 }
342 out
343}
344
345fn resolve_one_patch(
346 name: &PackageName,
347 entry: MergedEntry,
348 requirements: &BTreeMap<PackageName, Vec<semver::VersionReq>>,
349) -> Result<ActivePatch, PatchResolutionError> {
350 let MergedEntry {
351 source,
352 provenance,
353 base_dir,
354 } = entry;
355 match source {
356 PatchSource::Path {
357 path: declared_path,
358 } => {
359 let absolute_dir = if declared_path.is_absolute() {
360 declared_path.clone()
361 } else {
362 base_dir.join(&declared_path)
363 };
364 let manifest_path = absolute_dir.join("cabin.toml");
365 if !manifest_path.is_file() {
366 return Err(PatchResolutionError::Validation {
367 package: name.as_str().to_owned(),
368 source: PatchValidationError::MissingManifest {
369 package: name.as_str().to_owned(),
370 path: declared_path.display().to_string(),
371 },
372 });
373 }
374 let parsed = cabin_manifest::load_manifest(&manifest_path).map_err(|err| {
375 PatchResolutionError::ManifestParse {
376 package: name.as_str().to_owned(),
377 path: manifest_path.clone(),
378 reason: err.to_string(),
379 }
380 })?;
381 let package = parsed
382 .package
383 .ok_or_else(|| PatchResolutionError::Validation {
384 package: name.as_str().to_owned(),
385 source: PatchValidationError::ManifestHasNoPackage {
386 package: name.as_str().to_owned(),
387 path: declared_path.display().to_string(),
388 },
389 })?;
390 if &package.name != name {
391 return Err(PatchResolutionError::Validation {
392 package: name.as_str().to_owned(),
393 source: PatchValidationError::PackageNameMismatch {
394 package: name.as_str().to_owned(),
395 actual: package.name.as_str().to_owned(),
396 },
397 });
398 }
399 if let Some(reqs) = requirements.get(name) {
404 for req in reqs {
405 if !req.matches(&package.version) {
406 return Err(PatchResolutionError::Validation {
407 package: name.as_str().to_owned(),
408 source: PatchValidationError::VersionMismatch {
409 package: name.as_str().to_owned(),
410 version: package.version.to_string(),
411 requirement: req.to_string(),
412 },
413 });
414 }
415 }
416 }
417 let canonical_manifest = std::fs::canonicalize(&manifest_path).map_err(|err| {
421 PatchResolutionError::ManifestParse {
422 package: name.as_str().to_owned(),
423 path: manifest_path.clone(),
424 reason: err.to_string(),
425 }
426 })?;
427 let canonical_dir = canonical_manifest
428 .parent()
429 .map_or(absolute_dir, Path::to_path_buf);
430 Ok(ActivePatch {
431 name: name.clone(),
432 source: PatchSource::Path {
433 path: declared_path.clone(),
434 },
435 provenance,
436 manifest_path: canonical_manifest,
437 manifest_dir: canonical_dir,
438 declared_path,
439 package,
440 })
441 }
442 }
443}
444
445#[derive(Debug, Error)]
448pub enum PatchResolutionError {
449 #[error("invalid patch for `{package}`: {source}")]
454 Validation {
455 package: String,
456 #[source]
457 source: PatchValidationError,
458 },
459
460 #[error(
465 "failed to parse patch manifest for `{package}` at {path}: {reason}",
466 path = path.display()
467 )]
468 ManifestParse {
469 package: String,
470 path: PathBuf,
471 reason: String,
472 },
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::load_workspace;
479 use assert_fs::TempDir;
480 use assert_fs::prelude::*;
481
482 fn fixture(parent: &TempDir, dep_block: &str) -> PackageGraph {
488 parent
489 .child("fmt/cabin.toml")
490 .write_str("[package]\nname = \"fmt\"\nversion = \"0.1.0\"\n")
491 .unwrap();
492 let manifest = format!(
493 "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n{dep_block}\n\n[patch]\nfmt = {{ path = \"../fmt\" }}\n",
494 );
495 parent.child("app/cabin.toml").write_str(&manifest).unwrap();
496 load_workspace(parent.path().join("app/cabin.toml")).unwrap()
497 }
498
499 fn resolve_with(graph: &PackageGraph) -> Result<ActivePatchSet, PatchResolutionError> {
500 let manifest_patches = &graph.root_settings.patches;
501 let empty: BTreeMap<PackageName, ConfigPatchInput> = BTreeMap::new();
502 resolve_active_patches(&PatchResolutionInputs {
503 graph,
504 manifest_patches,
505 config_patches: &empty,
506 })
507 }
508
509 #[test]
510 fn patch_target_without_package_table_reports_no_package() {
511 let dir = TempDir::new().unwrap();
516 let graph = fixture(&dir, "[dependencies]\nfmt = \">=0.1\"");
517 dir.child("fmt/cabin.toml")
520 .write_str("[workspace]\nmembers = []\n")
521 .unwrap();
522 let err = resolve_with(&graph).expect_err("workspace-only patch target must be rejected");
523 match err {
524 PatchResolutionError::Validation { source, .. } => {
525 assert!(
526 matches!(source, PatchValidationError::ManifestHasNoPackage { .. }),
527 "expected ManifestHasNoPackage, got {source:?}"
528 );
529 }
530 PatchResolutionError::ManifestParse { .. } => {
531 panic!("expected Validation error, got ManifestParse")
532 }
533 }
534 }
535
536 #[test]
537 fn dev_only_dep_does_not_block_patch_version() {
538 let dir = TempDir::new().unwrap();
543 let graph = fixture(&dir, "[dev-dependencies]\nfmt = \">=99\"");
544 let resolved = resolve_with(&graph).expect("dev-only requirement must not gate patch");
545 assert_eq!(resolved.len(), 1);
546 assert_eq!(resolved.iter().next().unwrap().name.as_str(), "fmt");
547 }
548
549 #[test]
550 fn optional_dep_does_not_block_patch_version() {
551 let dir = TempDir::new().unwrap();
557 let graph = fixture(
558 &dir,
559 "[dependencies]\nfmt = { version = \">=99\", optional = true }",
560 );
561 let resolved = resolve_with(&graph).expect("optional requirement must not gate patch");
562 assert_eq!(resolved.len(), 1);
563 }
564
565 #[test]
566 fn target_mismatched_dep_does_not_block_patch_version() {
567 let dir = TempDir::new().unwrap();
570 let graph = fixture(
571 &dir,
572 "[target.'cfg(os = \"never-an-os\")'.dependencies]\nfmt = \">=99\"",
573 );
574 let resolved =
575 resolve_with(&graph).expect("non-matching target requirement must not gate patch");
576 assert_eq!(resolved.len(), 1);
577 }
578
579 #[test]
580 fn active_normal_dep_still_validates_patch_version() {
581 let dir = TempDir::new().unwrap();
586 let graph = fixture(&dir, "[dependencies]\nfmt = \">=99\"");
587 let err = resolve_with(&graph).expect_err("active requirement must reject patch");
588 match err {
589 PatchResolutionError::Validation { source, .. } => {
590 assert!(
591 matches!(source, PatchValidationError::VersionMismatch { .. }),
592 "expected VersionMismatch, got {source:?}"
593 );
594 }
595 PatchResolutionError::ManifestParse { .. } => {
596 panic!("expected Validation error, got ManifestParse")
597 }
598 }
599 }
600
601 #[test]
602 fn patched_manifest_versioned_deps_follow_workspace_policy() {
603 let dir = TempDir::new().unwrap();
604 dir.child("fmt/cabin.toml")
605 .write_str(
606 r#"[package]
607name = "fmt"
608version = "0.1.0"
609
610[dependencies]
611spdlog = "^1.13"
612fmt = "^99"
613optional-lib = { version = "^2", optional = true }
614
615[dev-dependencies]
616testkit = "^1"
617
618[target.'cfg(os = "never-an-os")'.dependencies]
619target-only = "^1"
620"#,
621 )
622 .unwrap();
623 dir.child("app/cabin.toml")
624 .write_str(
625 r#"[package]
626name = "app"
627version = "0.1.0"
628
629[dependencies]
630fmt = ">=0.1"
631
632[patch]
633fmt = { path = "../fmt" }
634"#,
635 )
636 .unwrap();
637
638 let graph = load_workspace(dir.path().join("app/cabin.toml")).unwrap();
639 let patches = resolve_with(&graph).unwrap();
640 let excluded = patches.owned_patched_names();
641
642 let deps = collect_patched_versioned_deps(&patches, &excluded).unwrap();
643 let rendered: BTreeMap<_, _> = deps
644 .iter()
645 .map(|(name, req)| (name.as_str().to_owned(), req.to_string()))
646 .collect();
647
648 assert_eq!(
649 rendered,
650 BTreeMap::from([("spdlog".to_owned(), "^1.13".to_owned())])
651 );
652 }
653}