use serde::{Deserialize, Serialize};
use crate::error::CapabilityError;
pub const RESERVED_CAPABILITY_ID_NAMESPACE: &str = "__everruns_";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CapabilityId(String);
impl CapabilityId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn parse(id: impl Into<String>) -> Result<Self, CapabilityError> {
let id = id.into();
validate_capability_id(&id)?;
Ok(Self(id))
}
pub fn validate(&self) -> Result<(), CapabilityError> {
validate_capability_id(&self.0)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl std::fmt::Display for CapabilityId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::str::FromStr for CapabilityId {
type Err = CapabilityError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl From<&str> for CapabilityId {
fn from(s: &str) -> Self {
Self::new(s)
}
}
impl From<String> for CapabilityId {
fn from(s: String) -> Self {
Self(s)
}
}
impl AsRef<str> for CapabilityId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for CapabilityId {
fn borrow(&self) -> &str {
&self.0
}
}
impl PartialEq<str> for CapabilityId {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for CapabilityId {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
pub fn validate_capability_id(id: &str) -> Result<(), CapabilityError> {
let invalid = |reason: String| CapabilityError::InvalidId {
id: id.to_string(),
reason,
};
if id.is_empty() {
return Err(invalid("capability id must not be empty".to_string()));
}
if id.len() > 128 {
return Err(invalid(format!(
"capability id must be at most 128 bytes (got {})",
id.len()
)));
}
if id.starts_with(RESERVED_CAPABILITY_ID_NAMESPACE) {
return Err(invalid(
"capability id uses the reserved '__everruns_' namespace".to_string(),
));
}
let mut chars = id.chars();
let first = chars.next().expect("non-empty checked above");
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(invalid(
"capability id must start with a letter or underscore".to_string(),
));
}
if id
.chars()
.any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':')))
{
return Err(invalid(
"capability id may only contain ASCII letters, digits, '_', '-', '.' or ':'"
.to_string(),
));
}
Ok(())
}
pub const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
pub fn plugin_capability_id(identity: &str) -> String {
format!("{PLUGIN_CAPABILITY_PREFIX}{identity}")
}
pub fn is_plugin_capability(capability_id: &str) -> bool {
capability_id.starts_with(PLUGIN_CAPABILITY_PREFIX)
}
pub fn parse_plugin_capability_id(capability_id: &str) -> Option<&str> {
capability_id.strip_prefix(PLUGIN_CAPABILITY_PREFIX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_and_as_str() {
assert_eq!(CapabilityId::new("noop").to_string(), "noop");
assert_eq!(CapabilityId::new("current_time").as_str(), "current_time");
}
#[test]
fn from_str_validates() {
assert_eq!(
"noop".parse::<CapabilityId>().unwrap(),
CapabilityId::new("noop")
);
assert!("".parse::<CapabilityId>().is_err());
assert!("2fast".parse::<CapabilityId>().is_err());
}
#[test]
fn valid_ids_pass() {
for id in [
"noop",
"current_time",
"vendor.search",
"mcp:550e8400-e29b-41d4-a716-446655440000",
"plugin:plg_0193",
"declarative:research_pack",
"_private",
"a",
] {
validate_capability_id(id).unwrap_or_else(|e| panic!("{id}: {e}"));
}
}
#[test]
fn invalid_ids_fail_with_reasons() {
for (id, fragment) in [
("", "must not be empty"),
("2fast", "start with a letter"),
("has space", "may only contain"),
("vendor/custom", "may only contain"),
("__everruns_private", "reserved"),
(&"x".repeat(129), "at most 128 bytes"),
] {
let err = validate_capability_id(id).unwrap_err();
assert!(
err.reason().contains(fragment),
"{id:?}: {} should contain {fragment:?}",
err.reason()
);
assert_eq!(err.id(), id);
}
}
#[test]
fn serde_is_transparent() {
let id = CapabilityId::new("current_time");
assert_eq!(serde_json::to_string(&id).unwrap(), "\"current_time\"");
let parsed: CapabilityId = serde_json::from_str("\"current_time\"").unwrap();
assert_eq!(parsed, id);
}
#[test]
fn hash_dedupes() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(CapabilityId::new("noop"));
set.insert(CapabilityId::new("current_time"));
set.insert(CapabilityId::new("noop"));
assert_eq!(set.len(), 2);
}
#[test]
fn plugin_helpers_round_trip() {
let id = plugin_capability_id("plg_01");
assert_eq!(id, "plugin:plg_01");
assert!(is_plugin_capability(&id));
assert_eq!(parse_plugin_capability_id(&id), Some("plg_01"));
assert!(!is_plugin_capability("noop"));
assert_eq!(parse_plugin_capability_id("noop"), None);
}
}