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}