1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
41#[serde(tag = "kind", rename_all = "kebab-case")]
42pub enum SourceLocator {
43 IndexPath { path: Utf8PathBuf },
47 IndexUrl { url: String },
52}
53
54impl SourceLocator {
55 pub fn kind_key(&self) -> &'static str {
58 match self {
59 SourceLocator::IndexPath { .. } => "index-path",
60 SourceLocator::IndexUrl { .. } => "index-url",
61 }
62 }
63
64 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct SourceReplacementEntry {
86 pub original: SourceLocator,
87 pub replacement: SourceLocator,
88 pub provenance: ConfigValueSource,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct SourceReplacementSettings {
105 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
109 pub entries: BTreeMap<SourceLocator, SourceReplacementEntry>,
110}
111
112impl SourceReplacementSettings {
113 pub fn is_empty(&self) -> bool {
117 self.entries.is_empty()
118 }
119
120 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(¤t) 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 pub fn replaces(&self, original: &SourceLocator) -> bool {
161 self.entries.contains_key(original)
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct SourceReplacementResolution {
168 pub resolved: SourceLocator,
172 pub hops: Vec<SourceLocator>,
175}
176
177#[derive(Debug, Error, Clone, PartialEq, Eq)]
181pub enum SourceReplacementError {
182 #[error(
186 "source replacement for `{original}` is missing a replacement; expected `index-path = \"...\"` or `index-url = \"...\"`"
187 )]
188 MissingReplacement { original: String },
189
190 #[error(
194 "source replacement for `{original}` declares both `index-path` and `index-url`; pick exactly one"
195 )]
196 AmbiguousReplacement { original: String },
197
198 #[error("source replacement URL `{url}` must not contain credentials")]
207 CredentialsInUrl { url: String },
208
209 #[error(
212 "multiple source replacements for `{original}` are active at the same precedence level; remove one declaration"
213 )]
214 DuplicateAtSameLevel { original: String },
215
216 #[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}