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
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct SourceReplacementResolution {
160 pub resolved: SourceLocator,
164 pub hops: Vec<SourceLocator>,
167}
168
169#[derive(Debug, Error, Clone, PartialEq, Eq)]
173pub enum SourceReplacementError {
174 #[error(
178 "source replacement for `{original}` is missing a replacement; expected `index-path = \"...\"` or `index-url = \"...\"`"
179 )]
180 MissingReplacement { original: String },
181
182 #[error(
186 "source replacement for `{original}` declares both `index-path` and `index-url`; pick exactly one"
187 )]
188 AmbiguousReplacement { original: String },
189
190 #[error("source replacement URL `{url}` must not contain credentials")]
199 CredentialsInUrl { url: String },
200
201 #[error(
204 "multiple source replacements for `{original}` are active at the same precedence level; remove one declaration"
205 )]
206 DuplicateAtSameLevel { original: String },
207
208 #[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}