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    /// Whether the supplied `original` source has a replacement
157    /// declared. Useful when the orchestration layer wants to
158    /// know if applying replacement changed anything (so the
159    /// metadata / lockfile view can show "unchanged" cleanly).
160    pub fn replaces(&self, original: &SourceLocator) -> bool {
161        self.entries.contains_key(original)
162    }
163}
164
165/// Result of walking the replacement chain.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct SourceReplacementResolution {
168    /// Terminal source (the value the caller should actually
169    /// open). Equals the `initial` argument when no replacement
170    /// applied.
171    pub resolved: SourceLocator,
172    /// Every `original` Cabin walked through, in order. Empty
173    /// when `initial` was already terminal.
174    pub hops: Vec<SourceLocator>,
175}
176
177/// Errors produced while parsing / resolving source
178/// replacements. Wording is stable so integration tests can
179/// match substrings.
180#[derive(Debug, Error, Clone, PartialEq, Eq)]
181pub enum SourceReplacementError {
182    /// `replace-with` (or the inline `index-path` /
183    /// `index-url`) was missing — every entry must declare a
184    /// replacement.
185    #[error(
186        "source replacement for `{original}` is missing a replacement; expected `index-path = \"...\"` or `index-url = \"...\"`"
187    )]
188    MissingReplacement { original: String },
189
190    /// Both `index-path` and `index-url` were declared on the
191    /// same entry. A single replacement entry may only redirect
192    /// to one source.
193    #[error(
194        "source replacement for `{original}` declares both `index-path` and `index-url`; pick exactly one"
195    )]
196    AmbiguousReplacement { original: String },
197
198    /// A URL (either the original or the replacement) carried
199    /// `userinfo` (e.g., `https://user:pass@example.com/...`).
200    /// Cabin's source-replacement model does not handle
201    /// credentials, so a URL with `userinfo` is rejected before
202    /// it can flow into log output or the lockfile. The `url`
203    /// field is expected to be redacted (`***` in place of
204    /// userinfo) by the constructor so error rendering never
205    /// echoes the secret back to stderr / logs.
206    #[error("source replacement URL `{url}` must not contain credentials")]
207    CredentialsInUrl { url: String },
208
209    /// The same `original` key appears in two replacement
210    /// declarations at the same precedence level.
211    #[error(
212        "multiple source replacements for `{original}` are active at the same precedence level; remove one declaration"
213    )]
214    DuplicateAtSameLevel { original: String },
215
216    /// A replacement chain looped back to a previously-visited
217    /// source.
218    #[error("source replacement cycle detected: {chain}", chain = format_chain(hops))]
219    Cycle { hops: Vec<SourceLocator> },
220}
221
222fn format_chain(hops: &[SourceLocator]) -> String {
223    hops.iter()
224        .map(SourceLocator::display)
225        .collect::<Vec<_>>()
226        .join(" -> ")
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn entry(original: SourceLocator, replacement: SourceLocator) -> SourceReplacementEntry {
234        SourceReplacementEntry {
235            original,
236            replacement,
237            provenance: ConfigValueSource::WorkspaceConfig,
238        }
239    }
240
241    fn url(s: &str) -> SourceLocator {
242        SourceLocator::IndexUrl { url: s.to_owned() }
243    }
244
245    fn path(s: &str) -> SourceLocator {
246        SourceLocator::IndexPath {
247            path: Utf8PathBuf::from(s),
248        }
249    }
250
251    #[test]
252    fn resolve_passes_terminal_source_through_unchanged() {
253        let settings = SourceReplacementSettings::default();
254        let target = url("https://example.com/index");
255        let res = settings.resolve(&target).unwrap();
256        assert_eq!(res.resolved, target);
257        assert!(res.hops.is_empty());
258    }
259
260    #[test]
261    fn resolve_walks_a_single_hop() {
262        let mut settings = SourceReplacementSettings::default();
263        let original = url("https://example.com/index");
264        let replacement = path("../mirror");
265        settings.entries.insert(
266            original.clone(),
267            entry(original.clone(), replacement.clone()),
268        );
269        let res = settings.resolve(&original).unwrap();
270        assert_eq!(res.resolved, replacement);
271        assert_eq!(res.hops, vec![original]);
272    }
273
274    #[test]
275    fn resolve_walks_a_chain_until_terminal() {
276        let mut settings = SourceReplacementSettings::default();
277        let a = url("https://example.com/a");
278        let b = url("https://example.com/b");
279        let c = path("../local");
280        settings
281            .entries
282            .insert(a.clone(), entry(a.clone(), b.clone()));
283        settings
284            .entries
285            .insert(b.clone(), entry(b.clone(), c.clone()));
286        let res = settings.resolve(&a).unwrap();
287        assert_eq!(res.resolved, c);
288        assert_eq!(res.hops, vec![a, b]);
289    }
290
291    #[test]
292    fn resolve_rejects_two_hop_cycle() {
293        let mut settings = SourceReplacementSettings::default();
294        let a = url("https://example.com/a");
295        let b = url("https://example.com/b");
296        settings
297            .entries
298            .insert(a.clone(), entry(a.clone(), b.clone()));
299        settings.entries.insert(b.clone(), entry(b, a.clone()));
300        let err = settings.resolve(&a).unwrap_err();
301        match err {
302            SourceReplacementError::Cycle { hops } => {
303                let display: Vec<String> = hops.iter().map(SourceLocator::display).collect();
304                assert_eq!(
305                    display,
306                    vec![
307                        "https://example.com/a".to_owned(),
308                        "https://example.com/b".to_owned(),
309                        "https://example.com/a".to_owned(),
310                    ]
311                );
312            }
313            other => panic!("expected Cycle, got {other:?}"),
314        }
315    }
316
317    #[test]
318    fn resolve_detects_self_loop() {
319        let mut settings = SourceReplacementSettings::default();
320        let a = url("https://example.com/a");
321        settings
322            .entries
323            .insert(a.clone(), entry(a.clone(), a.clone()));
324        let err = settings.resolve(&a).unwrap_err();
325        assert!(matches!(err, SourceReplacementError::Cycle { .. }));
326    }
327
328    #[test]
329    fn replaces_returns_true_only_for_declared_originals() {
330        let mut settings = SourceReplacementSettings::default();
331        let a = url("https://example.com/a");
332        let b = path("/mirror");
333        settings
334            .entries
335            .insert(a.clone(), entry(a.clone(), b.clone()));
336        assert!(settings.replaces(&a));
337        assert!(!settings.replaces(&b));
338    }
339
340    #[test]
341    fn locator_kind_keys_round_trip_through_serde() {
342        let path_locator = path("../mirror");
343        let url_locator = url("https://example.com/index");
344        for locator in [path_locator, url_locator] {
345            let json = serde_json::to_string(&locator).unwrap();
346            let echoed: SourceLocator = serde_json::from_str(&json).unwrap();
347            assert_eq!(echoed, locator);
348        }
349    }
350}