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 display_and_as_str() {
198 assert_eq!(CapabilityId::new("noop").to_string(), "noop");
199 assert_eq!(CapabilityId::new("current_time").as_str(), "current_time");
200 }
201
202 #[test]
203 fn from_str_validates() {
204 assert_eq!(
205 "noop".parse::<CapabilityId>().unwrap(),
206 CapabilityId::new("noop")
207 );
208 assert!("".parse::<CapabilityId>().is_err());
209 assert!("2fast".parse::<CapabilityId>().is_err());
210 }
211
212 #[test]
213 fn valid_ids_pass() {
214 for id in [
215 "noop",
216 "current_time",
217 "vendor.search",
218 "mcp:550e8400-e29b-41d4-a716-446655440000",
219 "plugin:plg_0193",
220 "declarative:research_pack",
221 "_private",
222 "a",
223 ] {
224 validate_capability_id(id).unwrap_or_else(|e| panic!("{id}: {e}"));
225 }
226 }
227
228 #[test]
229 fn invalid_ids_fail_with_reasons() {
230 for (id, fragment) in [
231 ("", "must not be empty"),
232 ("2fast", "start with a letter"),
233 ("has space", "may only contain"),
234 ("vendor/custom", "may only contain"),
235 ("__everruns_private", "reserved"),
236 (&"x".repeat(129), "at most 128 bytes"),
237 ] {
238 let err = validate_capability_id(id).unwrap_err();
239 assert!(
240 err.reason().contains(fragment),
241 "{id:?}: {} should contain {fragment:?}",
242 err.reason()
243 );
244 assert_eq!(err.id(), id);
245 }
246 }
247
248 #[test]
249 fn serde_is_transparent() {
250 let id = CapabilityId::new("current_time");
251 assert_eq!(serde_json::to_string(&id).unwrap(), "\"current_time\"");
252 let parsed: CapabilityId = serde_json::from_str("\"current_time\"").unwrap();
253 assert_eq!(parsed, id);
254 }
255
256 #[test]
257 fn hash_dedupes() {
258 use std::collections::HashSet;
259 let mut set = HashSet::new();
260 set.insert(CapabilityId::new("noop"));
261 set.insert(CapabilityId::new("current_time"));
262 set.insert(CapabilityId::new("noop"));
263 assert_eq!(set.len(), 2);
264 }
265
266 #[test]
267 fn plugin_helpers_round_trip() {
268 let id = plugin_capability_id("plg_01");
269 assert_eq!(id, "plugin:plg_01");
270 assert!(is_plugin_capability(&id));
271 assert_eq!(parse_plugin_capability_id(&id), Some("plg_01"));
272 assert!(!is_plugin_capability("noop"));
273 assert_eq!(parse_plugin_capability_id("noop"), None);
274 }
275}