use semver::{Version, VersionReq};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginRef {
pub name: String,
pub version: Option<VersionConstraint>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionConstraint {
Exact(Version),
Range(VersionReq),
}
impl PluginRef {
pub fn parse(s: &str) -> Result<Self, VersionError> {
if let Some(at_pos) = s.find('@') {
let name = s[..at_pos].to_string();
let version_str = &s[at_pos + 1..];
if name.is_empty() {
return Err(VersionError::EmptyName);
}
let version = parse_version_constraint(version_str)?;
Ok(PluginRef {
name,
version: Some(version),
})
} else {
if s.is_empty() {
return Err(VersionError::EmptyName);
}
Ok(PluginRef {
name: s.to_string(),
version: None,
})
}
}
pub fn matches(&self, version: &Version) -> bool {
match &self.version {
None => true, Some(VersionConstraint::Exact(v)) => v == version,
Some(VersionConstraint::Range(req)) => req.matches(version),
}
}
pub fn select_version<'a>(&self, available: &'a [Version]) -> Option<&'a Version> {
let mut matching: Vec<_> = available.iter().filter(|v| self.matches(v)).collect();
matching.sort_by(|a, b| b.cmp(a));
matching.first().copied()
}
}
fn parse_version_constraint(s: &str) -> Result<VersionConstraint, VersionError> {
if s.starts_with('^')
|| s.starts_with('~')
|| s.starts_with('>')
|| s.starts_with('<')
|| s.starts_with('=')
{
let req = VersionReq::parse(s).map_err(|e| VersionError::InvalidRange(e.to_string()))?;
Ok(VersionConstraint::Range(req))
} else {
let version = Version::parse(s).map_err(|e| VersionError::InvalidVersion(e.to_string()))?;
Ok(VersionConstraint::Exact(version))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionError {
EmptyName,
InvalidVersion(String),
InvalidRange(String),
}
impl std::fmt::Display for VersionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VersionError::EmptyName => write!(f, "plugin name cannot be empty"),
VersionError::InvalidVersion(e) => write!(f, "invalid version: {}", e),
VersionError::InvalidRange(e) => write!(f, "invalid version range: {}", e),
}
}
}
impl std::error::Error for VersionError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_name_only() {
let r = PluginRef::parse("rate-limit").unwrap();
assert_eq!(r.name, "rate-limit");
assert!(r.version.is_none());
}
#[test]
fn parse_exact_version() {
let r = PluginRef::parse("rate-limit@1.0.0").unwrap();
assert_eq!(r.name, "rate-limit");
assert!(matches!(r.version, Some(VersionConstraint::Exact(_))));
}
#[test]
fn parse_caret_range() {
let r = PluginRef::parse("rate-limit@^1.0.0").unwrap();
assert_eq!(r.name, "rate-limit");
assert!(matches!(r.version, Some(VersionConstraint::Range(_))));
}
#[test]
fn parse_tilde_range() {
let r = PluginRef::parse("rate-limit@~1.0.0").unwrap();
assert_eq!(r.name, "rate-limit");
assert!(matches!(r.version, Some(VersionConstraint::Range(_))));
}
#[test]
fn parse_gte_range() {
let r = PluginRef::parse("rate-limit@>=1.0.0").unwrap();
assert_eq!(r.name, "rate-limit");
assert!(matches!(r.version, Some(VersionConstraint::Range(_))));
}
#[test]
fn parse_empty_name_fails() {
let r = PluginRef::parse("");
assert!(matches!(r, Err(VersionError::EmptyName)));
}
#[test]
fn parse_empty_name_with_at_fails() {
let r = PluginRef::parse("@1.0.0");
assert!(matches!(r, Err(VersionError::EmptyName)));
}
#[test]
fn parse_invalid_version_fails() {
let r = PluginRef::parse("rate-limit@not-a-version");
assert!(matches!(r, Err(VersionError::InvalidVersion(_))));
}
#[test]
fn matches_no_constraint() {
let r = PluginRef::parse("rate-limit").unwrap();
assert!(r.matches(&Version::parse("1.0.0").unwrap()));
assert!(r.matches(&Version::parse("2.0.0").unwrap()));
assert!(r.matches(&Version::parse("0.1.0").unwrap()));
}
#[test]
fn matches_exact_version() {
let r = PluginRef::parse("rate-limit@1.0.0").unwrap();
assert!(r.matches(&Version::parse("1.0.0").unwrap()));
assert!(!r.matches(&Version::parse("1.0.1").unwrap()));
assert!(!r.matches(&Version::parse("2.0.0").unwrap()));
}
#[test]
fn matches_caret_range() {
let r = PluginRef::parse("rate-limit@^1.0.0").unwrap();
assert!(r.matches(&Version::parse("1.0.0").unwrap()));
assert!(r.matches(&Version::parse("1.5.0").unwrap()));
assert!(r.matches(&Version::parse("1.9.9").unwrap()));
assert!(!r.matches(&Version::parse("2.0.0").unwrap()));
assert!(!r.matches(&Version::parse("0.9.0").unwrap()));
}
#[test]
fn matches_tilde_range() {
let r = PluginRef::parse("rate-limit@~1.2.0").unwrap();
assert!(r.matches(&Version::parse("1.2.0").unwrap()));
assert!(r.matches(&Version::parse("1.2.5").unwrap()));
assert!(!r.matches(&Version::parse("1.3.0").unwrap()));
assert!(!r.matches(&Version::parse("2.0.0").unwrap()));
}
#[test]
fn select_version_highest() {
let r = PluginRef::parse("rate-limit@^1.0.0").unwrap();
let versions = vec![
Version::parse("1.0.0").unwrap(),
Version::parse("1.2.0").unwrap(),
Version::parse("1.5.0").unwrap(),
Version::parse("2.0.0").unwrap(),
];
let selected = r.select_version(&versions);
assert_eq!(selected, Some(&Version::parse("1.5.0").unwrap()));
}
#[test]
fn select_version_no_match() {
let r = PluginRef::parse("rate-limit@^3.0.0").unwrap();
let versions = vec![
Version::parse("1.0.0").unwrap(),
Version::parse("2.0.0").unwrap(),
];
let selected = r.select_version(&versions);
assert!(selected.is_none());
}
#[test]
fn select_version_latest_when_no_constraint() {
let r = PluginRef::parse("rate-limit").unwrap();
let versions = vec![
Version::parse("1.0.0").unwrap(),
Version::parse("2.0.0").unwrap(),
Version::parse("1.5.0").unwrap(),
];
let selected = r.select_version(&versions);
assert_eq!(selected, Some(&Version::parse("2.0.0").unwrap()));
}
}