everruns_capability/
id.rs1use serde::{Deserialize, Serialize};
4
5use crate::error::CapabilityError;
6
7pub const RESERVED_CAPABILITY_ID_NAMESPACE: &str = "__everruns_";
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(transparent)]
28pub struct CapabilityId(String);
29
30impl CapabilityId {
31 pub fn new(id: impl Into<String>) -> Self {
38 Self(id.into())
39 }
40
41 pub fn parse(id: impl Into<String>) -> Result<Self, CapabilityError> {
43 let id = id.into();
44 validate_capability_id(&id)?;
45 Ok(Self(id))
46 }
47
48 pub fn validate(&self) -> Result<(), CapabilityError> {
50 validate_capability_id(&self.0)
51 }
52
53 pub fn as_str(&self) -> &str {
55 &self.0
56 }
57
58 pub fn into_string(self) -> String {
60 self.0
61 }
62}
63
64impl std::fmt::Display for CapabilityId {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "{}", self.0)
67 }
68}
69
70impl std::str::FromStr for CapabilityId {
71 type Err = CapabilityError;
72
73 fn from_str(s: &str) -> Result<Self, Self::Err> {
74 Self::parse(s)
75 }
76}
77
78impl From<&str> for CapabilityId {
79 fn from(s: &str) -> Self {
80 Self::new(s)
81 }
82}
83
84impl From<String> for CapabilityId {
85 fn from(s: String) -> Self {
86 Self(s)
87 }
88}
89
90impl AsRef<str> for CapabilityId {
91 fn as_ref(&self) -> &str {
92 &self.0
93 }
94}
95
96impl std::borrow::Borrow<str> for CapabilityId {
97 fn borrow(&self) -> &str {
98 &self.0
99 }
100}
101
102impl PartialEq<str> for CapabilityId {
103 fn eq(&self, other: &str) -> bool {
104 self.0 == other
105 }
106}
107
108impl PartialEq<&str> for CapabilityId {
109 fn eq(&self, other: &&str) -> bool {
110 self.0 == *other
111 }
112}
113
114pub fn validate_capability_id(id: &str) -> Result<(), CapabilityError> {
121 let invalid = |reason: String| CapabilityError::InvalidId {
122 id: id.to_string(),
123 reason,
124 };
125 if id.len() > 128 {
126 return Err(invalid(format!(
127 "capability id must be at most 128 bytes (got {})",
128 id.len()
129 )));
130 }
131 if id.starts_with(RESERVED_CAPABILITY_ID_NAMESPACE) {
132 return Err(invalid(
133 "capability id uses the reserved '__everruns_' namespace".to_string(),
134 ));
135 }
136 let Some(first) = id.chars().next() else {
137 return Err(invalid("capability id must not be empty".to_string()));
138 };
139 if !(first.is_ascii_alphabetic() || first == '_') {
140 return Err(invalid(
141 "capability id must start with a letter or underscore".to_string(),
142 ));
143 }
144 if id
145 .chars()
146 .any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':')))
147 {
148 return Err(invalid(
149 "capability id may only contain ASCII letters, digits, '_', '-', '.' or ':'"
150 .to_string(),
151 ));
152 }
153 Ok(())
154}
155
156pub const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
171
172pub fn plugin_capability_id(identity: &str) -> String {
177 format!("{PLUGIN_CAPABILITY_PREFIX}{identity}")
178}
179
180pub fn is_plugin_capability(capability_id: &str) -> bool {
182 capability_id.starts_with(PLUGIN_CAPABILITY_PREFIX)
183}
184
185pub fn parse_plugin_capability_id(capability_id: &str) -> Option<&str> {
187 capability_id.strip_prefix(PLUGIN_CAPABILITY_PREFIX)
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn from_str_validates() {
196 assert_eq!(
197 "noop".parse::<CapabilityId>().unwrap(),
198 CapabilityId::new("noop")
199 );
200 assert!("".parse::<CapabilityId>().is_err());
201 assert!("2fast".parse::<CapabilityId>().is_err());
202 }
203
204 #[test]
205 fn valid_ids_pass() {
206 for id in [
207 "noop",
208 "current_time",
209 "vendor.search",
210 "mcp:550e8400-e29b-41d4-a716-446655440000",
211 "plugin:plg_0193",
212 "declarative:research_pack",
213 "_private",
214 "a",
215 "__everruns", &"a".repeat(128),
217 ] {
218 validate_capability_id(id).unwrap_or_else(|e| panic!("{id}: {e}"));
219 }
220 }
221
222 #[test]
223 fn invalid_ids_fail_with_reasons() {
224 for (id, fragment) in [
225 ("", "must not be empty"),
226 ("2fast", "start with a letter"),
227 ("has space", "may only contain"),
228 ("vendor/custom", "may only contain"),
229 ("éclair", "start with a letter"),
230 ("vendor.éclair", "may only contain"),
231 ("__everruns_private", "reserved"),
232 (&"x".repeat(129), "at most 128 bytes"),
233 ] {
234 let err = validate_capability_id(id).unwrap_err();
235 assert!(
236 err.reason().contains(fragment),
237 "{id:?}: {} should contain {fragment:?}",
238 err.reason()
239 );
240 assert_eq!(err.id(), id);
241 }
242 }
243
244 #[test]
245 fn serde_is_transparent() {
246 for text in ["current_time", "my_custom_capability"] {
247 let id = CapabilityId::new(text);
248 assert_eq!(id.to_string(), text);
249 assert_eq!(id.as_str(), text);
250 assert_eq!(serde_json::to_value(&id).unwrap(), serde_json::json!(text));
251 let parsed: CapabilityId = serde_json::from_value(serde_json::json!(text)).unwrap();
252 assert_eq!(parsed, id);
253 }
254 }
255
256 #[test]
257 fn plugin_helpers_round_trip() {
258 let id = plugin_capability_id("plg_01");
259 assert_eq!(id, "plugin:plg_01");
260 assert!(is_plugin_capability(&id));
261 assert_eq!(parse_plugin_capability_id(&id), Some("plg_01"));
262 assert!(!is_plugin_capability("noop"));
263 assert_eq!(parse_plugin_capability_id("noop"), None);
264 assert!(!is_plugin_capability("pluginish:plg_01"));
265 assert_eq!(parse_plugin_capability_id("pluginish:plg_01"), None);
266 }
267}