Skip to main content

cabin_workspace/
patch.rs

1//! Resolve the active patch set for one Cabin invocation.
2//!
3//! The CLI calls [`resolve_active_patches`] after loading the
4//! initial workspace graph and the merged effective config. The
5//! returned [`ActivePatchSet`] is the typed input the rest of
6//! the pipeline consumes:
7//!
8//! - the artifact pipeline filters out patched names so a
9//!   patched dep is never re-fetched from the registry;
10//! - the workspace loader stitches the patched manifests in via
11//!   [`crate::PatchedPackageSource`];
12//! - the lockfile records each entry so `--locked` can detect
13//!   stale patch policy;
14//! - the metadata view reports each entry deterministically.
15//!
16//! Validation runs eagerly: missing paths, missing `cabin.toml`,
17//! package-name mismatches, and version-requirement mismatches
18//! all surface as [`PatchResolutionError`] before any consumer
19//! sees the resolved set. Wording is stable so integration tests
20//! can match substrings.
21
22use 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/// One fully-resolved patch entry. Pairs the typed source with
34/// the loaded patch [`Package`] so downstream consumers do not
35/// need to re-parse the patched `cabin.toml`.
36#[derive(Debug, Clone)]
37pub struct ActivePatch {
38    pub name: PackageName,
39    pub source: PatchSource,
40    pub provenance: PatchProvenance,
41    /// Absolute path of the patched package's `cabin.toml`.
42    pub manifest_path: PathBuf,
43    /// Absolute path of the patched package's directory.
44    pub manifest_dir: PathBuf,
45    /// The path *as written* in the declaring file. Useful for
46    /// metadata / lockfile output where we prefer to show the
47    /// user-visible relative form rather than the absolute
48    /// canonical path.
49    pub declared_path: PathBuf,
50    /// Parsed patched [`Package`]. Carried through so the
51    /// loader does not have to re-parse the manifest.
52    pub package: Package,
53}
54
55/// Container for the active patch set. Ordered by package name
56/// for deterministic iteration.
57#[derive(Debug, Clone, Default)]
58pub struct ActivePatchSet {
59    entries: Vec<ActivePatch>,
60}
61
62impl ActivePatchSet {
63    /// Iterate entries in deterministic (package-name) order.
64    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    /// Whether the set carries any entries. Used by the CLI to
80    /// short-circuit the no-patches path.
81    pub fn is_empty(&self) -> bool {
82        self.entries.is_empty()
83    }
84
85    /// Number of active patches.
86    pub fn len(&self) -> usize {
87        self.entries.len()
88    }
89
90    /// Lookup by package name.
91    pub fn get(&self, name: &PackageName) -> Option<&ActivePatch> {
92        self.entries.iter().find(|p| &p.name == name)
93    }
94
95    /// Set of patched package names. Useful for callers that
96    /// need to filter the registry list / closure detection by
97    /// name.
98    pub fn patched_names(&self) -> BTreeSet<&str> {
99        self.entries.iter().map(|p| p.name.as_str()).collect()
100    }
101
102    /// Patched package names as owned strings. Convenient for
103    /// callers that need to hold the set across the loader /
104    /// artifact-pipeline boundary without lifetime juggling.
105    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    /// Adapt this set into the workspace loader's
113    /// [`PatchedPackageSource`] shape. The loader stitches each
114    /// entry as a local-kind package whose `(name, version,
115    /// manifest_path)` is supplied here, so the mapping
116    /// belongs next to the loader's input type rather than in
117    /// CLI orchestration code.
118    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
130/// Versioned dependencies declared by the patched manifests
131/// themselves.
132///
133/// The normal workspace-closure walker only sees packages that
134/// have already been stitched into the [`PackageGraph`]. Patch
135/// resolution happens earlier, so callers use this helper to add
136/// registry dependencies introduced by patched packages to the
137/// resolver input. The filtering policy matches
138/// `collect_closure_versioned_deps_excluding_with_dev` for a
139/// non-test build: normal deps are active, dev deps are
140/// declaration-only, optional deps are skipped until a feature
141/// resolver can prove them enabled, target predicates are evaluated
142/// against the host, and patched names are excluded.
143///
144/// # Errors
145/// Returns [`crate::WorkspaceError::IncompatibleWorkspaceRequirements`]
146/// when the requirements collected for a single dependency name
147/// cannot be combined into one [`semver::VersionReq`] (the joined
148/// requirement string fails to parse).
149pub 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
196/// Inputs to [`resolve_active_patches`]. Bundling them keeps the
197/// call site readable and the function signature stable as new
198/// inputs land.
199pub struct PatchResolutionInputs<'a> {
200    /// Loaded initial workspace graph. Used to find the
201    /// workspace root manifest and its `[patch]` table, and to
202    /// look up version requirements for patched names.
203    pub graph: &'a PackageGraph,
204    /// Manifest-declared patches plus the directory the manifest
205    /// lives in (for relative path resolution).
206    pub manifest_patches: &'a cabin_core::PatchManifestSettings,
207    /// Config-derived patches keyed by package name. Each entry
208    /// carries the directory of the config file that declared
209    /// it so relative paths resolve against the right base.
210    pub config_patches: &'a BTreeMap<PackageName, ConfigPatchInput>,
211}
212
213/// One config-derived patch entry as the orchestration layer
214/// hands it off to the resolver. The orchestration layer maps
215/// `cabin_config::EffectivePatch` into this shape so this crate
216/// stays free of `cabin-config` dependency.
217#[derive(Debug, Clone)]
218pub struct ConfigPatchInput {
219    pub source: PatchSource,
220    pub provenance: PatchProvenance,
221    /// Directory of the config file that declared this patch.
222    pub declared_in: PathBuf,
223}
224
225/// Resolve the active patch set deterministically.
226///
227/// Precedence: config patches override manifest patches on
228/// overlap. Within a single layer (one manifest table or one
229/// merged config map) duplicates are impossible because both
230/// inputs are keyed by [`PackageName`]; across layers the higher
231/// layer wins and the lower one is dropped silently — this
232/// matches the rest of the config-vs-manifest precedence ladder.
233///
234/// # Errors
235/// Returns a [`PatchResolutionError`] when a merged patch fails to
236/// resolve: [`PatchResolutionError::Validation`] when the patched
237/// `cabin.toml` is missing, declares no `[package]`, names a
238/// different package, or carries a version no active requirement
239/// accepts; and [`PatchResolutionError::ManifestParse`] when the
240/// patched manifest cannot be loaded or its path canonicalized.
241pub fn resolve_active_patches(
242    inputs: &PatchResolutionInputs<'_>,
243) -> Result<ActivePatchSet, PatchResolutionError> {
244    let root_dir = inputs.graph.root_dir.clone();
245
246    // Merge: start with manifest patches, overlay config
247    // patches. The overlay drops the manifest entry; we record
248    // the winning provenance so metadata can show it.
249    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    // Collect version requirements per patched name from the
276    // initial graph so we can validate patched versions before
277    // returning. We iterate every dep edge in every loaded
278    // package; only Version-source deps contribute requirements.
279    let requirements = collect_version_requirements(inputs.graph, &merged);
280
281    // Resolve each merged entry into an `ActivePatch`.
282    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
298/// Walk every loaded package and collect, per patched name,
299/// the set of `Version`-source requirements that are *actually
300/// active* for the current invocation. Inactive declarations
301/// (dev / system kinds, target-conditioned deps that do not
302/// match the host platform, optional deps regardless of feature
303/// state) are skipped so a patch on a dormant dependency does
304/// not cause spurious version-mismatch errors.
305///
306/// Optional deps are conservative: even if a feature would
307/// enable them, this function lacks the cross-package feature
308/// resolution result and so cannot decide membership precisely.
309/// The orchestration layer handles enabled optional patches via
310/// the resolver / loader path, where the patched manifest is
311/// used directly and any version mismatch surfaces against the
312/// real resolver input.
313fn 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            // Version-requirement validation. Each requirement
400            // collected from active dep edges must accept the
401            // patched version; otherwise we surface a clear error
402            // before the loader stitches the wrong manifest.
403            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            // Canonicalize the manifest path so the workspace
418            // loader's dedup-by-canonical-path machinery sees a
419            // consistent value.
420            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/// Errors produced by [`resolve_active_patches`]. Wording is
446/// stable so integration tests can match substrings.
447#[derive(Debug, Error)]
448pub enum PatchResolutionError {
449    /// A patch failed structural validation (missing source,
450    /// missing cabin.toml, name mismatch, version mismatch, …).
451    /// Wraps the typed [`PatchValidationError`] from `cabin-core`
452    /// so the inner error carries its own user-readable wording.
453    #[error("invalid patch for `{package}`: {source}")]
454    Validation {
455        package: String,
456        #[source]
457        source: PatchValidationError,
458    },
459
460    /// Cabin could not load the patched manifest. The inner
461    /// `reason` is the manifest crate's display message — kept
462    /// as a string so this crate stays free of a transitive
463    /// `cabin-manifest` error dependency.
464    #[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    /// Build a workspace where `app` references `fmt` through
483    /// `dep_block` and patches it with a local fork at version
484    /// `0.1.0`. The block is the user's chance to switch dep kind
485    /// / optional / target condition while keeping the rest of the
486    /// fixture identical.
487    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        // The patched directory's `cabin.toml` exists and parses
512        // but is a pure `[workspace]` root with no `[package]`.
513        // The error must say the manifest declares no `[package]`,
514        // not the misleading "does not contain a cabin.toml".
515        let dir = TempDir::new().unwrap();
516        let graph = fixture(&dir, "[dependencies]\nfmt = \">=0.1\"");
517        // Overwrite the patched `fmt` manifest with a workspace-only
518        // table: it parses fine but exposes no package to patch in.
519        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        // `fmt` is referenced only as a dev dep with an
539        // unsatisfiable requirement. The patch's `0.1.0` would
540        // never satisfy `>= 99`, but dev deps are not active for
541        // the default build, so validation must skip the edge.
542        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        // Optional deps are conservatively skipped: their
552        // activation depends on feature resolution we don't
553        // perform here. If the feature later enables them, the
554        // resolver path surfaces any version mismatch using the
555        // patched manifest directly.
556        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        // Pick a target the host can never match so the
568        // requirement is dormant on this invocation.
569        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        // Negative control: an active normal dep with an
582        // unsatisfiable requirement must still surface
583        // `VersionMismatch`. Without this, the gating change
584        // could silently regress the original validation.
585        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}