Skip to main content

cabin_core/
patch.rs

1//! Typed patch / override model.
2//!
3//! A *patch* replaces a registry-resolved package candidate with
4//! a local source for the duration of one Cabin invocation. The
5//! patch is local development policy — it is never serialized
6//! into published package metadata, never affects the resolver
7//! for downstream consumers, and never triggers network access.
8//!
9//! Public syntax lives in two places:
10//!
11//! - the workspace-root `cabin.toml`'s `[patch]` table
12//!   (root/workspace policy);
13//! - any `.cabin/config.toml`'s `[patch]` table (user / workspace
14//!   / package / explicit policy from the config layer).
15//!
16//! Both forms produce the same typed model. The orchestration
17//! layer in `cabin` merges the two and the workspace loader
18//! stitches the patched packages into the package graph.
19
20use std::collections::BTreeMap;
21use std::fmt;
22
23use camino::Utf8PathBuf;
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28use crate::ConfigValueSource;
29
30/// Kind of source a patch points at. Today only local paths are
31/// supported. The enum is closed: adding new source kinds is a
32/// deliberate change that requires matching parser, validator,
33/// and resolver work.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum PatchSourceKind {
37    /// A local filesystem path that contains a Cabin package
38    /// (a directory with a `cabin.toml` file).
39    Path,
40}
41
42impl PatchSourceKind {
43    /// Stable lower-case label used in JSON output and error
44    /// messages.
45    pub const fn as_key(self) -> &'static str {
46        match self {
47            PatchSourceKind::Path => "path",
48        }
49    }
50}
51
52impl fmt::Display for PatchSourceKind {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(self.as_key())
55    }
56}
57
58/// What a patch redirects the patched package to. Each variant
59/// pairs the [`PatchSourceKind`] with its concrete data.
60///
61/// Rationale: keeping the variant data closed (instead of a
62/// stringly-typed "spec" string) means the resolver, fetch
63/// pipeline, and metadata view all agree on what each patch
64/// actually points at. Future kinds (artifact archive, local
65/// index reference) extend this enum explicitly.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(tag = "kind", rename_all = "kebab-case")]
68pub enum PatchSource {
69    /// `path = "../fmt"`. Carries the path *as written* in the
70    /// declaring file. Resolution against the file's directory
71    /// happens one layer up in the orchestration code so this
72    /// type stays free of filesystem context.
73    Path { path: Utf8PathBuf },
74}
75
76impl PatchSource {
77    /// Stable kind label, useful for metadata / lockfile output.
78    pub fn kind(&self) -> PatchSourceKind {
79        match self {
80            PatchSource::Path { .. } => PatchSourceKind::Path,
81        }
82    }
83
84    /// Build a [`PatchSource`] from a `[patch]` row's `path` field —
85    /// the only supported patch grammar today. Requires the path,
86    /// trims it, and rejects empty / whitespace, surfacing
87    /// [`PatchValidationError::MissingSource`] on failure. Shared by
88    /// the manifest and config parsers so the path→source rule lives
89    /// next to the type; each caller keeps its own outer error
90    /// wrapping and its own package-name validation.
91    ///
92    /// # Errors
93    /// Returns [`PatchValidationError::MissingSource`] when `raw_path` is
94    /// `None` or empty/whitespace-only after trimming.
95    pub fn from_path_field(
96        package: &str,
97        raw_path: Option<String>,
98    ) -> Result<PatchSource, PatchValidationError> {
99        match raw_path {
100            Some(path) if !path.trim().is_empty() => Ok(PatchSource::Path {
101                path: Utf8PathBuf::from(path.trim()),
102            }),
103            _ => Err(PatchValidationError::MissingSource {
104                package: package.to_owned(),
105            }),
106        }
107    }
108}
109
110/// Provenance label for a patch entry. Mirrors the precedence
111/// ladder Cabin walks for patch resolution and is surfaced
112/// verbatim in `cabin metadata` so users can audit which file
113/// supplied each active patch.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "kebab-case")]
116pub enum PatchProvenance {
117    /// The workspace-root `cabin.toml`'s `[patch]` table.
118    Manifest,
119    /// A `.cabin/config.toml`'s `[patch]` table. The inner
120    /// [`ConfigValueSource`] identifies which config file
121    /// supplied the value.
122    Config(ConfigValueSource),
123}
124
125impl PatchProvenance {
126    /// Stable lower-case label used in JSON output, matching the
127    /// `value_source` keys from the config layer.
128    pub fn as_key(self) -> String {
129        match self {
130            PatchProvenance::Manifest => "manifest".to_owned(),
131            PatchProvenance::Config(source) => source.as_key().to_owned(),
132        }
133    }
134}
135
136impl fmt::Display for PatchProvenance {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.write_str(&self.as_key())
139    }
140}
141
142/// One patch entry as declared in a single source file. Carries
143/// the relative `source` value plus the absolute path of the file
144/// that declared it so the orchestration layer can resolve any
145/// relative paths against the right base directory.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct DeclaredPatch {
148    pub source: PatchSource,
149    /// Absolute path of the file that declared this patch
150    /// (`cabin.toml` for manifest patches, `.cabin/config.toml`
151    /// for config patches). Used as the base for resolving
152    /// relative `path` values.
153    pub declared_in: Utf8PathBuf,
154    pub provenance: PatchProvenance,
155}
156
157/// Workspace-root manifest's `[patch]` declarations. Member
158/// manifests cannot declare patches — the workspace loader
159/// rejects them — so reading off the root is sufficient.
160#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161pub struct PatchManifestSettings {
162    /// `(package name -> source)`. Iteration is deterministic
163    /// via [`BTreeMap`].
164    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
165    pub entries: BTreeMap<crate::PackageName, PatchSource>,
166}
167
168impl PatchManifestSettings {
169    /// Whether the table carries no entries. Mirrors the
170    /// `is_empty` helpers on the other workspace-root-only
171    /// settings types so the workspace loader can reject member
172    /// manifests with a uniform check.
173    pub fn is_empty(&self) -> bool {
174        self.entries.is_empty()
175    }
176}
177
178/// Errors produced while validating patch declarations. Wording
179/// is intentionally stable so integration tests can match
180/// substrings.
181#[derive(Debug, Error, Clone, PartialEq, Eq)]
182pub enum PatchValidationError {
183    /// A patch table did not declare any source. The expected
184    /// shape is `{ path = "..." }`; the parser surfaces this when
185    /// no recognized key was supplied.
186    #[error("patch for package `{package}` is missing a source; expected `path = \"...\"`")]
187    MissingSource { package: String },
188
189    /// The patched package directory does not contain a
190    /// `cabin.toml`. Cabin prefers a clear early error to the
191    /// later confusing "manifest not found" failure.
192    #[error(
193        "patch for package `{package}` points to `{path}`, but that path does not contain a cabin.toml"
194    )]
195    MissingManifest { package: String, path: String },
196
197    /// The patched directory's `cabin.toml` exists and parses but
198    /// declares no `[package]` table (for example a pure
199    /// `[workspace]` root), so there is no package to patch in
200    /// with.
201    #[error(
202        "patch for package `{package}` points to `{path}`, but its cabin.toml declares no `[package]`"
203    )]
204    ManifestHasNoPackage { package: String, path: String },
205
206    /// The patched package's manifest declares a different
207    /// `[package].name` than the patch table key.
208    #[error(
209        "patch for package `{package}` points to package `{actual}`; patch package name must match `{package}`"
210    )]
211    PackageNameMismatch { package: String, actual: String },
212
213    /// The patched package's version does not satisfy the
214    /// version requirement of an active dependency on it.
215    #[error(
216        "patch package `{package}` has version `{version}`, which does not satisfy dependency requirement `{requirement}`"
217    )]
218    VersionMismatch {
219        package: String,
220        version: String,
221        requirement: String,
222    },
223
224    /// The same package name appears in two patch declarations
225    /// at the same precedence level. Across precedence levels
226    /// the higher level overrides; *within* a level, duplicates
227    /// are rejected so two co-equal config files cannot silently
228    /// disagree about a patch.
229    #[error(
230        "multiple patches for package `{package}` are active at the same precedence level; remove one patch declaration"
231    )]
232    DuplicateAtSameLevel { package: String },
233}