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