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