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