Skip to main content

everruns_capability/
id.rs

1//! Open, validated capability identifiers.
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::CapabilityError;
6
7/// The namespace reserved for Everruns-internal capability machinery.
8///
9/// Application- and third-party-authored capability ids must not start with
10/// this prefix; [`validate_capability_id`] rejects it.
11pub const RESERVED_CAPABILITY_ID_NAMESPACE: &str = "__everruns_";
12
13/// Capability identifier — an open, string-based ID.
14///
15/// IDs are open strings rather than variants in a central enum, so new
16/// capabilities can be added without database migrations or Everruns source
17/// edits. A stable identifier is made from ASCII letters, digits, `_`, `-`,
18/// `.`, or `:`, starts with a letter or `_`, and fits within 128 bytes; the
19/// `__everruns_` namespace is reserved.
20///
21/// [`CapabilityId::new`] is infallible so persisted and in-flight values can
22/// be carried without re-validation; boundaries that accept new identifiers
23/// (Framework agent build, product write paths) enforce the grammar with
24/// [`validate_capability_id`] / [`CapabilityId::validate`] or the validating
25/// [`FromStr`](std::str::FromStr) implementation.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(transparent)]
28pub struct CapabilityId(String);
29
30impl CapabilityId {
31    /// Create a capability ID from a string without validating it.
32    ///
33    /// Use this for values that were already validated at a boundary (or that
34    /// predate validation). New identifiers should go through
35    /// [`CapabilityId::parse`] or a boundary that calls
36    /// [`validate_capability_id`].
37    pub fn new(id: impl Into<String>) -> Self {
38        Self(id.into())
39    }
40
41    /// Create a validated capability ID.
42    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    /// Validate this ID against the open-ID grammar and reserved namespaces.
49    pub fn validate(&self) -> Result<(), CapabilityError> {
50        validate_capability_id(&self.0)
51    }
52
53    /// Get the ID as a string slice.
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57
58    /// Consume the ID and return the underlying string.
59    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
114/// Validate a capability identifier against the shared open-ID grammar.
115///
116/// The rules are the single source of truth for Framework build validation
117/// and product write paths: non-empty, at most 128 bytes, ASCII letters,
118/// digits, `_`, `-`, `.` or `:` only, starting with a letter or `_`, and not
119/// in the reserved `__everruns_` namespace.
120pub 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
156// ============================================================================
157// Plugin capability ID helpers
158// ============================================================================
159//
160// `plugin:` is one of the open reference namespaces (`mcp:`, `skill:`,
161// `declarative:`, `plugin:`). The other namespace helpers live with their
162// capability implementations; the plugin helpers live here because plugin
163// references are pure identity (no implementation crate owns them).
164//
165// Server-managed plugin refs use stable installation public IDs. Standalone
166// Agent Plugins refs use manifest names of at most 64 ASCII bytes, so persisted
167// capability reference columns reserve 71 bytes including `plugin:`.
168
169/// The `plugin:` prefix used to identify installed plugin capabilities.
170pub const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
171
172/// Construct a plugin capability reference from its stable identity suffix.
173///
174/// Server-managed plugins use the installation public ID. Standalone runtime
175/// plugins use the manifest name because they do not have an installation row.
176pub fn plugin_capability_id(identity: &str) -> String {
177    format!("{PLUGIN_CAPABILITY_PREFIX}{identity}")
178}
179
180/// Return `true` if `capability_id` is a `plugin:…` reference.
181pub fn is_plugin_capability(capability_id: &str) -> bool {
182    capability_id.starts_with(PLUGIN_CAPABILITY_PREFIX)
183}
184
185/// Strip the `plugin:` prefix and return the identity suffix.
186pub 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", // Similar prefix is not the reserved namespace.
216            &"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}