a3s_code_core/capability/
id.rs1use std::fmt;
2use std::num::NonZeroU64;
3
4use serde::Serialize;
5
6use super::CapabilitySetError;
7
8pub const MAX_CAPABILITY_IDENTIFIER_BYTES: usize = 256;
9pub const USE_CAPABILITY_SNAPSHOT_CURSOR_SCHEMA: &str = "a3s.use.capability-snapshot-cursor.v1";
10
11#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
13#[serde(transparent)]
14pub struct Sha256Digest(Box<str>);
15
16impl Sha256Digest {
17 pub fn new(value: impl Into<String>) -> Result<Self, CapabilitySetError> {
18 let value = value.into();
19 let valid = value.len() == 71
20 && value.starts_with("sha256:")
21 && value[7..]
22 .bytes()
23 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'));
24 if !valid {
25 return Err(CapabilitySetError::InvalidDigest { field: "digest" });
26 }
27 Ok(Self(value.into_boxed_str()))
28 }
29
30 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33}
34
35impl fmt::Display for Sha256Digest {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 formatter.write_str(self.as_str())
38 }
39}
40
41#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum CapabilityKind {
46 Tool,
47 Skill,
48 Agent,
49 Command,
50 Hook,
51 Mcp,
52 Flow,
53 KnowledgeSurface,
54 Knowledge,
55 Ui,
56 Context,
57}
58
59impl CapabilityKind {
60 pub const fn as_str(self) -> &'static str {
61 match self {
62 Self::Tool => "tool",
63 Self::Skill => "skill",
64 Self::Agent => "agent",
65 Self::Command => "command",
66 Self::Hook => "hook",
67 Self::Mcp => "mcp",
68 Self::Flow => "flow",
69 Self::KnowledgeSurface => "knowledge-surface",
70 Self::Knowledge => "knowledge",
71 Self::Ui => "ui",
72 Self::Context => "context",
73 }
74 }
75}
76
77impl fmt::Display for CapabilityKind {
78 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79 formatter.write_str(self.as_str())
80 }
81}
82
83#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
85#[serde(transparent)]
86pub struct CapabilitySourceId(Box<str>);
87
88impl CapabilitySourceId {
89 pub(super) fn scoped(
90 prefix: &'static str,
91 local: impl Into<String>,
92 ) -> Result<Self, CapabilitySetError> {
93 let local = local.into();
94 validate_identifier("source", &local)?;
95 let value = format!("{prefix}/{local}");
96 if value.len() > MAX_CAPABILITY_IDENTIFIER_BYTES {
97 return Err(CapabilitySetError::BoundExceeded {
98 field: "source",
99 max: MAX_CAPABILITY_IDENTIFIER_BYTES,
100 });
101 }
102 Ok(Self(value.into_boxed_str()))
103 }
104
105 pub fn as_str(&self) -> &str {
106 &self.0
107 }
108}
109
110impl fmt::Display for CapabilitySourceId {
111 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112 formatter.write_str(self.as_str())
113 }
114}
115
116#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
118#[serde(rename_all = "camelCase")]
119pub struct CapabilityId {
120 source: CapabilitySourceId,
121 kind: CapabilityKind,
122 local_id: Box<str>,
123}
124
125impl CapabilityId {
126 pub fn new(
127 source: &super::CapabilitySource,
128 kind: CapabilityKind,
129 local_id: impl Into<String>,
130 ) -> Result<Self, CapabilitySetError> {
131 let local_id = local_id.into();
132 validate_identifier("local_id", &local_id)?;
133 Ok(Self {
134 source: source.id().clone(),
135 kind,
136 local_id: local_id.into_boxed_str(),
137 })
138 }
139
140 pub fn source(&self) -> &CapabilitySourceId {
141 &self.source
142 }
143
144 pub const fn kind(&self) -> CapabilityKind {
145 self.kind
146 }
147
148 pub fn local_id(&self) -> &str {
149 &self.local_id
150 }
151}
152
153impl fmt::Display for CapabilityId {
154 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155 write!(formatter, "{}:{}:{}", self.source, self.kind, self.local_id)
156 }
157}
158
159#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
161#[serde(rename_all = "camelCase")]
162pub struct UseCapabilityGeneration {
163 schema: &'static str,
164 generation: u64,
165 revision: Sha256Digest,
166 registry_revision: Sha256Digest,
167}
168
169impl UseCapabilityGeneration {
170 pub fn new(generation: u64, revision: Sha256Digest, registry_revision: Sha256Digest) -> Self {
171 Self {
172 schema: USE_CAPABILITY_SNAPSHOT_CURSOR_SCHEMA,
173 generation,
174 revision,
175 registry_revision,
176 }
177 }
178
179 pub const fn schema(&self) -> &'static str {
180 self.schema
181 }
182
183 pub const fn generation(&self) -> u64 {
184 self.generation
185 }
186
187 pub fn revision(&self) -> &Sha256Digest {
188 &self.revision
189 }
190
191 pub fn registry_revision(&self) -> &Sha256Digest {
192 &self.registry_revision
193 }
194}
195
196#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
198#[serde(rename_all = "camelCase")]
199pub struct UsePackageGeneration {
200 package_id: Box<str>,
201 component_id: Box<str>,
202 route: Box<str>,
203 version: Box<str>,
204 lifecycle_generation: NonZeroU64,
205 package_digest: Sha256Digest,
206 manifest_digest: Sha256Digest,
207}
208
209impl UsePackageGeneration {
210 pub fn new(
211 package_id: impl Into<String>,
212 component_id: impl Into<String>,
213 route: impl Into<String>,
214 version: impl Into<String>,
215 lifecycle_generation: u64,
216 package_digest: Sha256Digest,
217 manifest_digest: Sha256Digest,
218 ) -> Result<Self, CapabilitySetError> {
219 let package_id = package_id.into();
220 let component_id = component_id.into();
221 let route = route.into();
222 let version = version.into();
223 validate_identifier("package_id", &package_id)?;
224 validate_identifier("component_id", &component_id)?;
225 validate_identifier("route", &route)?;
226 validate_bounded_text("version", &version, 128)?;
227 let lifecycle_generation =
228 NonZeroU64::new(lifecycle_generation).ok_or(CapabilitySetError::InvalidGeneration {
229 field: "lifecycle_generation",
230 })?;
231 Ok(Self {
232 package_id: package_id.into_boxed_str(),
233 component_id: component_id.into_boxed_str(),
234 route: route.into_boxed_str(),
235 version: version.into_boxed_str(),
236 lifecycle_generation,
237 package_digest,
238 manifest_digest,
239 })
240 }
241
242 pub fn package_id(&self) -> &str {
243 &self.package_id
244 }
245
246 pub fn component_id(&self) -> &str {
247 &self.component_id
248 }
249
250 pub fn route(&self) -> &str {
251 &self.route
252 }
253
254 pub fn version(&self) -> &str {
255 &self.version
256 }
257
258 pub const fn lifecycle_generation(&self) -> u64 {
259 self.lifecycle_generation.get()
260 }
261
262 pub fn package_digest(&self) -> &Sha256Digest {
263 &self.package_digest
264 }
265
266 pub fn manifest_digest(&self) -> &Sha256Digest {
267 &self.manifest_digest
268 }
269}
270
271#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
273#[serde(transparent)]
274pub struct CodeCatalogGeneration(u64);
275
276impl CodeCatalogGeneration {
277 pub const INITIAL: Self = Self(0);
278
279 pub const fn new(value: u64) -> Self {
280 Self(value)
281 }
282
283 pub const fn get(self) -> u64 {
284 self.0
285 }
286
287 pub const fn checked_next(self) -> Option<Self> {
288 match self.0.checked_add(1) {
289 Some(value) => Some(Self(value)),
290 None => None,
291 }
292 }
293}
294
295pub(super) fn validate_identifier(
296 field: &'static str,
297 value: &str,
298) -> Result<(), CapabilitySetError> {
299 if value.is_empty() {
300 return Err(CapabilitySetError::InvalidIdentifier {
301 field,
302 reason: "it is empty",
303 });
304 }
305 if value.len() > MAX_CAPABILITY_IDENTIFIER_BYTES {
306 return Err(CapabilitySetError::BoundExceeded {
307 field,
308 max: MAX_CAPABILITY_IDENTIFIER_BYTES,
309 });
310 }
311 if !value.bytes().all(|byte| {
312 byte.is_ascii_lowercase()
313 || byte.is_ascii_digit()
314 || matches!(byte, b'-' | b'_' | b'.' | b'/')
315 }) {
316 return Err(CapabilitySetError::InvalidIdentifier {
317 field,
318 reason: "it contains non-canonical characters",
319 });
320 }
321 if !value
322 .as_bytes()
323 .first()
324 .is_some_and(u8::is_ascii_alphanumeric)
325 || !value
326 .as_bytes()
327 .last()
328 .is_some_and(u8::is_ascii_alphanumeric)
329 || value
330 .split('/')
331 .any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
332 {
333 return Err(CapabilitySetError::InvalidIdentifier {
334 field,
335 reason: "it has an unsafe boundary or path segment",
336 });
337 }
338 Ok(())
339}
340
341fn validate_bounded_text(
342 field: &'static str,
343 value: &str,
344 max: usize,
345) -> Result<(), CapabilitySetError> {
346 if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) {
347 return Err(CapabilitySetError::InvalidIdentifier {
348 field,
349 reason: "it is empty, padded, or contains control characters",
350 });
351 }
352 if value.len() > max {
353 return Err(CapabilitySetError::BoundExceeded { field, max });
354 }
355 Ok(())
356}