Skip to main content

cabin_core/
source_replacement.rs

1//! Typed source-replacement model.
2//!
3//! A *source replacement* redirects one supported index source
4//! to another supported index source for the duration of one
5//! Cabin invocation.  The mapping is local config policy - it
6//! never enters published package metadata, never affects the
7//! resolver for downstream consumers, and only swaps existing
8//! source kinds (local filesystem index, sparse-HTTP index).
9//!
10//! Public syntax (config-only):
11//!
12//! ```toml
13//! [source-replacement]
14//! "https://example.com/index" = { index-path = "../mirror" }
15//! ```
16//!
17//! The parser converts the table into a [`SourceReplacementSettings`]
18//! collection with stable ordering.  Resolution walks the chain
19//! once with cycle detection so a misconfigured chain like
20//! `A -> B -> A` surfaces a clear error before the resolver
21//! ever opens an index.
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::fmt;
25
26use camino::Utf8PathBuf;
27
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31use crate::ConfigValueSource;
32
33/// Stable, typed identifier for one supported source/index.
34///
35/// Keeping this enum closed (instead of stringly-typed `(kind,
36/// value)` pairs) means every consumer - resolver, lockfile,
37/// metadata view - agrees on what each variant means and which
38/// data it carries.  New supported kinds extend the enum
39/// explicitly.
40#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
41#[serde(tag = "kind", rename_all = "kebab-case")]
42pub enum SourceLocator {
43    /// Local filesystem index.  Carries the path verbatim; the
44    /// orchestration layer absolutises against the declaring
45    /// file's directory before consulting the index loader.
46    IndexPath { path: Utf8PathBuf },
47    /// Sparse-HTTP index.  Carries the URL verbatim; the
48    /// orchestration layer rejects credential-bearing URLs at
49    /// parse time so credentials never leak into the
50    /// effective configuration.
51    IndexUrl { url: String },
52}
53
54impl SourceLocator {
55    /// Stable lower-case label used for metadata + lockfile
56    /// output.  Matches the serde `kind` tag.
57    pub fn kind_key(&self) -> &'static str {
58        match self {
59            SourceLocator::IndexPath { .. } => "index-path",
60            SourceLocator::IndexUrl { .. } => "index-url",
61        }
62    }
63
64    /// Stable display string the user can recognize in errors
65    /// and metadata output.
66    pub fn display(&self) -> String {
67        match self {
68            SourceLocator::IndexPath { path } => path.as_str().to_owned(),
69            SourceLocator::IndexUrl { url } => url.clone(),
70        }
71    }
72}
73
74impl fmt::Display for SourceLocator {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(&self.display())
77    }
78}
79
80/// One source-replacement declaration.  The orchestration layer
81/// folds `Vec<SourceReplacementEntry>` into a
82/// [`SourceReplacementSettings`] map keyed by `original` so
83/// duplicates can be rejected deterministically.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct SourceReplacementEntry {
86    pub original: SourceLocator,
87    pub replacement: SourceLocator,
88    /// Provenance label used by `cabin metadata`.  Always a
89    /// config-flavor variant - source replacements live in the
90    /// config layer.
91    pub provenance: ConfigValueSource,
92}
93
94/// Collection of source-replacement entries plus typed
95/// resolution / cycle detection.
96///
97/// Built by `cabin-config`'s merger from the highest-priority
98/// config file's `[source-replacement]` table; lower-priority
99/// files contribute additional entries when their `original`
100/// key is not already covered, so the resulting map preserves
101/// the same "higher level overrides" semantics the rest of the
102/// config layer uses.
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct SourceReplacementSettings {
105    /// `(original -> entry)` keyed by the source being
106    /// replaced.  `BTreeMap` keeps iteration deterministic for
107    /// metadata + lockfile serialization.
108    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
109    pub entries: BTreeMap<SourceLocator, SourceReplacementEntry>,
110}
111
112impl SourceReplacementSettings {
113    /// Whether the table carries no entries.  Used by the
114    /// workspace loader / metadata view to skip emitting empty
115    /// blocks.
116    pub fn is_empty(&self) -> bool {
117        self.entries.is_empty()
118    }
119
120    /// Resolve `initial` through the replacement chain.  Returns
121    /// the terminal source plus the chain of intermediate
122    /// originals (in walk order) so the lockfile / metadata view
123    /// can record the full hop list.
124    ///
125    /// Cycles surface a [`SourceReplacementError::Cycle`]
126    /// carrying the offending hop list so users see exactly
127    /// which entries form the loop.
128    ///
129    /// # Errors
130    /// Returns [`SourceReplacementError::Cycle`] when the replacement chain
131    /// revisits a source, carrying the hop list up to and including the
132    /// repeated entry.
133    pub fn resolve(
134        &self,
135        initial: &SourceLocator,
136    ) -> Result<SourceReplacementResolution, SourceReplacementError> {
137        let mut current = initial.clone();
138        let mut visited: BTreeSet<SourceLocator> = BTreeSet::new();
139        let mut hops: Vec<SourceLocator> = Vec::new();
140        loop {
141            if !visited.insert(current.clone()) {
142                hops.push(current);
143                return Err(SourceReplacementError::Cycle { hops });
144            }
145            let Some(entry) = self.entries.get(&current) else {
146                return Ok(SourceReplacementResolution {
147                    resolved: current,
148                    hops,
149                });
150            };
151            hops.push(entry.original.clone());
152            current = entry.replacement.clone();
153        }
154    }
155}
156
157/// Result of walking the replacement chain.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct SourceReplacementResolution {
160    /// Terminal source (the value the caller should
161    /// open).  Equals the `initial` argument when no replacement
162    /// applied.
163    pub resolved: SourceLocator,
164    /// Every `original` Cabin walked through, in order.  Empty
165    /// when `initial` was already terminal.
166    pub hops: Vec<SourceLocator>,
167}
168
169/// Errors produced while parsing / resolving source
170/// replacements.  Wording is stable so integration tests can
171/// match substrings.
172#[derive(Debug, Error, Clone, PartialEq, Eq)]
173pub enum SourceReplacementError {
174    /// `replace-with` (or the inline `index-path` /
175    /// `index-url`) was missing - every entry must declare a
176    /// replacement.
177    #[error(
178        "source replacement for `{original}` is missing a replacement; expected `index-path = \"...\"` or `index-url = \"...\"`"
179    )]
180    MissingReplacement { original: String },
181
182    /// Both `index-path` and `index-url` were declared on the
183    /// same entry.  A single replacement entry may only redirect
184    /// to one source.
185    #[error(
186        "source replacement for `{original}` declares both `index-path` and `index-url`; pick exactly one"
187    )]
188    AmbiguousReplacement { original: String },
189
190    /// A URL (either the original or the replacement) carried
191    /// `userinfo` (e.g., `https://user:pass@example.com/...`).
192    /// Cabin's source-replacement model does not handle
193    /// credentials, so a URL with `userinfo` is rejected before
194    /// it can flow into log output or the lockfile.  The `url`
195    /// field is expected to be redacted (`***` in place of
196    /// userinfo) by the constructor so error rendering never
197    /// echoes the secret back to stderr / logs.
198    #[error("source replacement URL `{url}` must not contain credentials")]
199    CredentialsInUrl { url: String },
200
201    /// The same `original` key appears in two replacement
202    /// declarations at the same precedence level.
203    #[error(
204        "multiple source replacements for `{original}` are active at the same precedence level; remove one declaration"
205    )]
206    DuplicateAtSameLevel { original: String },
207
208    /// A replacement chain looped back to a previously-visited
209    /// source.
210    #[error("source replacement cycle detected: {chain}", chain = format_chain(hops))]
211    Cycle { hops: Vec<SourceLocator> },
212}
213
214fn format_chain(hops: &[SourceLocator]) -> String {
215    let mut chain = String::new();
216    for (idx, hop) in hops.iter().enumerate() {
217        if idx > 0 {
218            chain.push_str(" -> ");
219        }
220        chain.push_str(&hop.display());
221    }
222    chain
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn entry(original: SourceLocator, replacement: SourceLocator) -> SourceReplacementEntry {
230        SourceReplacementEntry {
231            original,
232            replacement,
233            provenance: ConfigValueSource::WorkspaceConfig,
234        }
235    }
236
237    fn url(s: &str) -> SourceLocator {
238        SourceLocator::IndexUrl { url: s.to_owned() }
239    }
240
241    fn path(s: &str) -> SourceLocator {
242        SourceLocator::IndexPath {
243            path: Utf8PathBuf::from(s),
244        }
245    }
246
247    #[test]
248    fn resolve_passes_terminal_source_through_unchanged() {
249        let settings = SourceReplacementSettings::default();
250        let target = url("https://example.com/index");
251        let res = settings.resolve(&target).unwrap();
252        assert_eq!(res.resolved, target);
253        assert!(res.hops.is_empty());
254    }
255
256    #[test]
257    fn resolve_walks_a_single_hop() {
258        let mut settings = SourceReplacementSettings::default();
259        let original = url("https://example.com/index");
260        let replacement = path("../mirror");
261        settings.entries.insert(
262            original.clone(),
263            entry(original.clone(), replacement.clone()),
264        );
265        let res = settings.resolve(&original).unwrap();
266        assert_eq!(res.resolved, replacement);
267        assert_eq!(res.hops, vec![original]);
268    }
269
270    #[test]
271    fn resolve_walks_a_chain_until_terminal() {
272        let mut settings = SourceReplacementSettings::default();
273        let a = url("https://example.com/a");
274        let b = url("https://example.com/b");
275        let c = path("../local");
276        settings
277            .entries
278            .insert(a.clone(), entry(a.clone(), b.clone()));
279        settings
280            .entries
281            .insert(b.clone(), entry(b.clone(), c.clone()));
282        let res = settings.resolve(&a).unwrap();
283        assert_eq!(res.resolved, c);
284        assert_eq!(res.hops, vec![a, b]);
285    }
286
287    #[test]
288    fn resolve_rejects_two_hop_cycle() {
289        let mut settings = SourceReplacementSettings::default();
290        let a = url("https://example.com/a");
291        let b = url("https://example.com/b");
292        settings
293            .entries
294            .insert(a.clone(), entry(a.clone(), b.clone()));
295        settings.entries.insert(b.clone(), entry(b, a.clone()));
296        let err = settings.resolve(&a).unwrap_err();
297        match err {
298            SourceReplacementError::Cycle { hops } => {
299                let display: Vec<String> = hops.iter().map(SourceLocator::display).collect();
300                assert_eq!(
301                    display,
302                    vec![
303                        "https://example.com/a".to_owned(),
304                        "https://example.com/b".to_owned(),
305                        "https://example.com/a".to_owned(),
306                    ]
307                );
308            }
309            other => panic!("expected Cycle, got {other:?}"),
310        }
311    }
312
313    #[test]
314    fn resolve_detects_self_loop() {
315        let mut settings = SourceReplacementSettings::default();
316        let a = url("https://example.com/a");
317        settings
318            .entries
319            .insert(a.clone(), entry(a.clone(), a.clone()));
320        let err = settings.resolve(&a).unwrap_err();
321        assert!(matches!(err, SourceReplacementError::Cycle { .. }));
322    }
323
324    #[test]
325    fn locator_kind_keys_round_trip_through_serde() {
326        let path_locator = path("../mirror");
327        let url_locator = url("https://example.com/index");
328        for locator in [path_locator, url_locator] {
329            let json = serde_json::to_string(&locator).unwrap();
330            let echoed: SourceLocator = serde_json::from_str(&json).unwrap();
331            assert_eq!(echoed, locator);
332        }
333    }
334}