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.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
158// ============================================================================
159// Plugin capability ID helpers
160// ============================================================================
161//
162// `plugin:` is one of the open reference namespaces (`mcp:`, `skill:`,
163// `declarative:`, `plugin:`). The other namespace helpers live with their
164// capability implementations; the plugin helpers live here because plugin
165// references are pure identity (no implementation crate owns them).
166//
167// Server-managed plugin refs use stable installation public IDs. Standalone
168// Agent Plugins refs use manifest names of at most 64 ASCII bytes, so persisted
169// capability reference columns reserve 71 bytes including `plugin:`.
170
171/// The `plugin:` prefix used to identify installed plugin capabilities.
172pub const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
173
174/// Construct a plugin capability reference from its stable identity suffix.
175///
176/// Server-managed plugins use the installation public ID. Standalone runtime
177/// plugins use the manifest name because they do not have an installation row.
178pub fn plugin_capability_id(identity: &str) -> String {
179    format!("{PLUGIN_CAPABILITY_PREFIX}{identity}")
180}
181
182/// Return `true` if `capability_id` is a `plugin:…` reference.
183pub fn is_plugin_capability(capability_id: &str) -> bool {
184    capability_id.starts_with(PLUGIN_CAPABILITY_PREFIX)
185}
186
187/// Strip the `plugin:` prefix and return the identity suffix.
188pub 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", // Similar prefix is not the reserved namespace.
218            &"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}