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;
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}