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.is_empty() {
126 return Err(invalid("capability id must not be empty".to_string()));
127 }
128 if id.len() > 128 {
129 return Err(invalid(format!(
130 "capability id must be at most 128 bytes (got {})",
131 id.len()
132 )));
133 }
134 if id.starts_with(RESERVED_CAPABILITY_ID_NAMESPACE) {
135 return Err(invalid(
136 "capability id uses the reserved '__everruns_' namespace".to_string(),
137 ));
138 }
139 let mut chars = id.chars();
140 let first = chars.next().expect("non-empty checked above");
141 if !(first.is_ascii_alphabetic() || first == '_') {
142 return Err(invalid(
143 "capability id must start with a letter or underscore".to_string(),
144 ));
145 }
146 if id
147 .chars()
148 .any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':')))
149 {
150 return Err(invalid(
151 "capability id may only contain ASCII letters, digits, '_', '-', '.' or ':'"
152 .to_string(),
153 ));
154 }
155 Ok(())
156}
157
158pub const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
173
174pub fn plugin_capability_id(identity: &str) -> String {
179 format!("{PLUGIN_CAPABILITY_PREFIX}{identity}")
180}
181
182pub fn is_plugin_capability(capability_id: &str) -> bool {
184 capability_id.starts_with(PLUGIN_CAPABILITY_PREFIX)
185}
186
187pub fn parse_plugin_capability_id(capability_id: &str) -> Option<&str> {
189 capability_id.strip_prefix(PLUGIN_CAPABILITY_PREFIX)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn from_str_validates() {
198 assert_eq!(
199 "noop".parse::<CapabilityId>().unwrap(),
200 CapabilityId::new("noop")
201 );
202 assert!("".parse::<CapabilityId>().is_err());
203 assert!("2fast".parse::<CapabilityId>().is_err());
204 }
205
206 #[test]
207 fn valid_ids_pass() {
208 for id in [
209 "noop",
210 "current_time",
211 "vendor.search",
212 "mcp:550e8400-e29b-41d4-a716-446655440000",
213 "plugin:plg_0193",
214 "declarative:research_pack",
215 "_private",
216 "a",
217 "__everruns", &"a".repeat(128),
219 ] {
220 validate_capability_id(id).unwrap_or_else(|e| panic!("{id}: {e}"));
221 }
222 }
223
224 #[test]
225 fn invalid_ids_fail_with_reasons() {
226 for (id, fragment) in [
227 ("", "must not be empty"),
228 ("2fast", "start with a letter"),
229 ("has space", "may only contain"),
230 ("vendor/custom", "may only contain"),
231 ("éclair", "start with a letter"),
232 ("vendor.éclair", "may only contain"),
233 ("__everruns_private", "reserved"),
234 (&"x".repeat(129), "at most 128 bytes"),
235 ] {
236 let err = validate_capability_id(id).unwrap_err();
237 assert!(
238 err.reason().contains(fragment),
239 "{id:?}: {} should contain {fragment:?}",
240 err.reason()
241 );
242 assert_eq!(err.id(), id);
243 }
244 }
245
246 #[test]
247 fn serde_is_transparent() {
248 for text in ["current_time", "my_custom_capability"] {
249 let id = CapabilityId::new(text);
250 assert_eq!(id.to_string(), text);
251 assert_eq!(id.as_str(), text);
252 assert_eq!(serde_json::to_value(&id).unwrap(), serde_json::json!(text));
253 let parsed: CapabilityId = serde_json::from_value(serde_json::json!(text)).unwrap();
254 assert_eq!(parsed, id);
255 }
256 }
257
258 #[test]
259 fn plugin_helpers_round_trip() {
260 let id = plugin_capability_id("plg_01");
261 assert_eq!(id, "plugin:plg_01");
262 assert!(is_plugin_capability(&id));
263 assert_eq!(parse_plugin_capability_id(&id), Some("plg_01"));
264 assert!(!is_plugin_capability("noop"));
265 assert_eq!(parse_plugin_capability_id("noop"), None);
266 assert!(!is_plugin_capability("pluginish:plg_01"));
267 assert_eq!(parse_plugin_capability_id("pluginish:plg_01"), None);
268 }
269}