use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::ConfigValueSource;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum SourceLocator {
IndexPath { path: PathBuf },
IndexUrl { url: String },
}
impl SourceLocator {
pub fn kind_key(&self) -> &'static str {
match self {
SourceLocator::IndexPath { .. } => "index-path",
SourceLocator::IndexUrl { .. } => "index-url",
}
}
pub fn display(&self) -> String {
match self {
SourceLocator::IndexPath { path } => path.display().to_string(),
SourceLocator::IndexUrl { url } => url.clone(),
}
}
}
impl fmt::Display for SourceLocator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.display())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceReplacementEntry {
pub original: SourceLocator,
pub replacement: SourceLocator,
pub provenance: ConfigValueSource,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceReplacementSettings {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub entries: BTreeMap<SourceLocator, SourceReplacementEntry>,
}
impl SourceReplacementSettings {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn resolve(
&self,
initial: &SourceLocator,
) -> Result<SourceReplacementResolution, SourceReplacementError> {
let mut current = initial.clone();
let mut visited: BTreeSet<SourceLocator> = BTreeSet::new();
let mut hops: Vec<SourceLocator> = Vec::new();
loop {
if !visited.insert(current.clone()) {
hops.push(current);
return Err(SourceReplacementError::Cycle { hops });
}
let Some(entry) = self.entries.get(¤t) else {
return Ok(SourceReplacementResolution {
resolved: current,
hops,
});
};
hops.push(entry.original.clone());
current = entry.replacement.clone();
}
}
pub fn replaces(&self, original: &SourceLocator) -> bool {
self.entries.contains_key(original)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceReplacementResolution {
pub resolved: SourceLocator,
pub hops: Vec<SourceLocator>,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum SourceReplacementError {
#[error(
"source replacement for `{original}` is missing a replacement; expected `index-path = \"...\"` or `index-url = \"...\"`"
)]
MissingReplacement { original: String },
#[error(
"source replacement for `{original}` declares both `index-path` and `index-url`; pick exactly one"
)]
AmbiguousReplacement { original: String },
#[error("source replacement URL `{url}` must not contain credentials")]
CredentialsInUrl { url: String },
#[error(
"multiple source replacements for `{original}` are active at the same precedence level; remove one declaration"
)]
DuplicateAtSameLevel { original: String },
#[error("source replacement cycle detected: {chain}", chain = format_chain(hops))]
Cycle { hops: Vec<SourceLocator> },
}
fn format_chain(hops: &[SourceLocator]) -> String {
hops.iter()
.map(SourceLocator::display)
.collect::<Vec<_>>()
.join(" -> ")
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(original: SourceLocator, replacement: SourceLocator) -> SourceReplacementEntry {
SourceReplacementEntry {
original,
replacement,
provenance: ConfigValueSource::WorkspaceConfig,
}
}
fn url(s: &str) -> SourceLocator {
SourceLocator::IndexUrl { url: s.to_owned() }
}
fn path(s: &str) -> SourceLocator {
SourceLocator::IndexPath {
path: PathBuf::from(s),
}
}
#[test]
fn resolve_passes_terminal_source_through_unchanged() {
let settings = SourceReplacementSettings::default();
let target = url("https://example.com/index");
let res = settings.resolve(&target).unwrap();
assert_eq!(res.resolved, target);
assert!(res.hops.is_empty());
}
#[test]
fn resolve_walks_a_single_hop() {
let mut settings = SourceReplacementSettings::default();
let original = url("https://example.com/index");
let replacement = path("../mirror");
settings.entries.insert(
original.clone(),
entry(original.clone(), replacement.clone()),
);
let res = settings.resolve(&original).unwrap();
assert_eq!(res.resolved, replacement);
assert_eq!(res.hops, vec![original]);
}
#[test]
fn resolve_walks_a_chain_until_terminal() {
let mut settings = SourceReplacementSettings::default();
let a = url("https://example.com/a");
let b = url("https://example.com/b");
let c = path("../local");
settings
.entries
.insert(a.clone(), entry(a.clone(), b.clone()));
settings
.entries
.insert(b.clone(), entry(b.clone(), c.clone()));
let res = settings.resolve(&a).unwrap();
assert_eq!(res.resolved, c);
assert_eq!(res.hops, vec![a, b]);
}
#[test]
fn resolve_rejects_two_hop_cycle() {
let mut settings = SourceReplacementSettings::default();
let a = url("https://example.com/a");
let b = url("https://example.com/b");
settings
.entries
.insert(a.clone(), entry(a.clone(), b.clone()));
settings.entries.insert(b.clone(), entry(b, a.clone()));
let err = settings.resolve(&a).unwrap_err();
match err {
SourceReplacementError::Cycle { hops } => {
let display: Vec<String> = hops.iter().map(SourceLocator::display).collect();
assert_eq!(
display,
vec![
"https://example.com/a".to_owned(),
"https://example.com/b".to_owned(),
"https://example.com/a".to_owned(),
]
);
}
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn resolve_detects_self_loop() {
let mut settings = SourceReplacementSettings::default();
let a = url("https://example.com/a");
settings
.entries
.insert(a.clone(), entry(a.clone(), a.clone()));
let err = settings.resolve(&a).unwrap_err();
assert!(matches!(err, SourceReplacementError::Cycle { .. }));
}
#[test]
fn replaces_returns_true_only_for_declared_originals() {
let mut settings = SourceReplacementSettings::default();
let a = url("https://example.com/a");
let b = path("/mirror");
settings
.entries
.insert(a.clone(), entry(a.clone(), b.clone()));
assert!(settings.replaces(&a));
assert!(!settings.replaces(&b));
}
#[test]
fn locator_kind_keys_round_trip_through_serde() {
let path_locator = path("../mirror");
let url_locator = url("https://example.com/index");
for locator in [path_locator, url_locator] {
let json = serde_json::to_string(&locator).unwrap();
let echoed: SourceLocator = serde_json::from_str(&json).unwrap();
assert_eq!(echoed, locator);
}
}
}