1use std::collections::btree_map::Entry;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt;
4use std::sync::mpsc::Receiver;
5
6use semver::{Version, VersionReq};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10use crate::{ModuleId, RUNTIME_API_VERSION, SchemaValidationError, UiValue, ValueSchema};
11
12#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
13#[serde(try_from = "String", into = "String")]
14pub struct CapabilityId(String);
15
16impl CapabilityId {
17 pub fn parse(value: impl Into<String>) -> Result<Self, CapabilityError> {
23 let value = value.into();
24 if value
25 .split_once('.')
26 .is_some_and(|(namespace, name)| is_identifier(namespace) && is_identifier(name))
27 {
28 Ok(Self(value))
29 } else {
30 Err(CapabilityError::InvalidId(value))
31 }
32 }
33
34 #[must_use]
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl TryFrom<String> for CapabilityId {
41 type Error = CapabilityError;
42
43 fn try_from(value: String) -> Result<Self, Self::Error> {
44 Self::parse(value)
45 }
46}
47
48impl From<CapabilityId> for String {
49 fn from(value: CapabilityId) -> Self {
50 value.0
51 }
52}
53
54fn is_identifier(value: &str) -> bool {
55 !value.is_empty()
56 && !value.starts_with('_')
57 && !value.ends_with('_')
58 && !value.contains("__")
59 && value.chars().all(|character| {
60 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
61 })
62}
63
64#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
65pub struct CapabilityMethod {
66 pub input: ValueSchema,
67 pub output: ValueSchema,
68}
69
70#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
71pub struct CapabilityDescriptor {
72 pub id: CapabilityId,
73 pub version: Version,
74 pub methods: BTreeMap<String, CapabilityMethod>,
75}
76
77pub trait CapabilityHandler {
78 fn call(&mut self, method: &str, input: UiValue) -> Result<UiValue, String>;
85}
86
87pub struct TaskWork {
88 work: Box<dyn FnOnce(crate::TaskCancellation) -> Result<UiValue, String> + Send + 'static>,
89}
90
91impl TaskWork {
92 #[must_use]
93 pub fn new(work: impl FnOnce() -> Result<UiValue, String> + Send + 'static) -> Self {
94 Self {
95 work: Box::new(move |_| work()),
96 }
97 }
98
99 #[must_use]
100 pub fn cancellable(
101 work: impl FnOnce(crate::TaskCancellation) -> Result<UiValue, String> + Send + 'static,
102 ) -> Self {
103 Self {
104 work: Box::new(work),
105 }
106 }
107
108 pub(crate) fn run(self, cancellation: crate::TaskCancellation) -> Result<UiValue, String> {
109 (self.work)(cancellation)
110 }
111}
112
113pub trait AsyncCapabilityHandler {
114 fn start(&mut self, method: &str, input: UiValue) -> Result<TaskWork, String>;
120}
121
122pub struct SubscriptionWork {
129 work: Box<dyn FnOnce(crate::SubscriptionEmitter) + Send + 'static>,
130}
131
132impl SubscriptionWork {
133 #[must_use]
135 pub fn new(work: impl FnOnce(crate::SubscriptionEmitter) + Send + 'static) -> Self {
136 Self {
137 work: Box::new(work),
138 }
139 }
140
141 #[must_use]
144 pub fn from_receiver(receiver: Receiver<UiValue>) -> Self {
145 Self::new(move |emitter| {
146 loop {
147 if emitter.close_reason().is_some() {
148 break;
149 }
150 match receiver.recv_timeout(std::time::Duration::from_millis(50)) {
151 Ok(value) => {
152 if emitter.emit_blocking(value).is_err() {
153 break;
154 }
155 }
156 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
157 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
158 }
159 }
160 })
161 }
162
163 pub(crate) fn run(self, emitter: crate::SubscriptionEmitter) {
164 (self.work)(emitter);
165 }
166}
167
168pub trait SubscriptionCapabilityHandler {
169 fn subscribe(&mut self, method: &str, input: UiValue) -> Result<SubscriptionWork, String>;
179}
180
181struct CapabilityEntry {
182 descriptor: CapabilityDescriptor,
183 sync: Option<Box<dyn CapabilityHandler>>,
184 task: Option<Box<dyn AsyncCapabilityHandler>>,
185 subscription: Option<Box<dyn SubscriptionCapabilityHandler>>,
186}
187
188#[derive(Default)]
189pub struct CapabilityRegistry {
190 entries: BTreeMap<CapabilityId, CapabilityEntry>,
191 active: BTreeSet<CapabilityId>,
192}
193
194impl fmt::Debug for CapabilityRegistry {
195 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196 formatter
197 .debug_struct("CapabilityRegistry")
198 .field("registered", &self.entries.keys().collect::<Vec<_>>())
199 .field("active", &self.active)
200 .finish()
201 }
202}
203
204impl CapabilityRegistry {
205 #[must_use]
206 pub fn new() -> Self {
207 Self::default()
208 }
209
210 pub fn register(
217 &mut self,
218 descriptor: CapabilityDescriptor,
219 handler: impl CapabilityHandler + 'static,
220 ) -> Result<(), CapabilityError> {
221 validate_descriptor(&descriptor)?;
222 let entry = self.entry_for_descriptor(descriptor)?;
223 if entry.sync.is_some() {
224 return Err(CapabilityError::DuplicateMode("sync"));
225 }
226 entry.sync = Some(Box::new(handler));
227 Ok(())
228 }
229
230 pub fn register_async(
236 &mut self,
237 descriptor: CapabilityDescriptor,
238 handler: impl AsyncCapabilityHandler + 'static,
239 ) -> Result<(), CapabilityError> {
240 validate_descriptor(&descriptor)?;
241 let entry = self.entry_for_descriptor(descriptor)?;
242 if entry.task.is_some() {
243 return Err(CapabilityError::DuplicateMode("task"));
244 }
245 entry.task = Some(Box::new(handler));
246 Ok(())
247 }
248
249 pub fn register_subscription(
255 &mut self,
256 descriptor: CapabilityDescriptor,
257 handler: impl SubscriptionCapabilityHandler + 'static,
258 ) -> Result<(), CapabilityError> {
259 validate_descriptor(&descriptor)?;
260 let entry = self.entry_for_descriptor(descriptor)?;
261 if entry.subscription.is_some() {
262 return Err(CapabilityError::DuplicateMode("subscription"));
263 }
264 entry.subscription = Some(Box::new(handler));
265 Ok(())
266 }
267
268 fn entry_for_descriptor(
269 &mut self,
270 descriptor: CapabilityDescriptor,
271 ) -> Result<&mut CapabilityEntry, CapabilityError> {
272 let id = descriptor.id.clone();
273 match self.entries.entry(id.clone()) {
274 Entry::Vacant(entry) => Ok(entry.insert(CapabilityEntry {
275 descriptor,
276 sync: None,
277 task: None,
278 subscription: None,
279 })),
280 Entry::Occupied(entry) => {
281 if entry.get().descriptor == descriptor {
282 Ok(entry.into_mut())
283 } else {
284 Err(CapabilityError::DescriptorMismatch(id))
285 }
286 }
287 }
288 }
289
290 pub fn activate(
297 &mut self,
298 requirements: &BTreeMap<CapabilityId, VersionReq>,
299 ) -> Result<(), CapabilityError> {
300 let mut active = BTreeSet::new();
301 for (id, requirement) in requirements {
302 let entry = self
303 .entries
304 .get(id)
305 .ok_or_else(|| CapabilityError::Missing(id.clone()))?;
306 if !requirement.matches(&entry.descriptor.version) {
307 return Err(CapabilityError::VersionMismatch {
308 id: id.clone(),
309 required: requirement.clone(),
310 actual: entry.descriptor.version.clone(),
311 });
312 }
313 active.insert(id.clone());
314 }
315 self.active = active;
316 Ok(())
317 }
318
319 pub fn call(
326 &mut self,
327 id: &CapabilityId,
328 method: &str,
329 input: UiValue,
330 ) -> Result<UiValue, CapabilityError> {
331 if !self.active.contains(id) {
332 return Err(CapabilityError::NotDeclared(id.clone()));
333 }
334 let entry = self
335 .entries
336 .get_mut(id)
337 .ok_or_else(|| CapabilityError::Missing(id.clone()))?;
338 let schema = entry
339 .descriptor
340 .methods
341 .get(method)
342 .cloned()
343 .ok_or_else(|| CapabilityError::UnknownMethod {
344 id: id.clone(),
345 method: method.to_owned(),
346 })?;
347 schema
348 .input
349 .validate_ui_value(&input)
350 .map_err(|source| CapabilityError::InvalidInput {
351 id: id.clone(),
352 method: method.to_owned(),
353 source,
354 })?;
355 let handler = entry
356 .sync
357 .as_mut()
358 .ok_or(CapabilityError::UnsupportedMode("sync"))?;
359 let output = handler
360 .call(method, input)
361 .map_err(|message| CapabilityError::Handler {
362 id: id.clone(),
363 method: method.to_owned(),
364 message,
365 })?;
366 schema.output.validate_ui_value(&output).map_err(|source| {
367 CapabilityError::InvalidOutput {
368 id: id.clone(),
369 method: method.to_owned(),
370 source,
371 }
372 })?;
373 Ok(output)
374 }
375
376 pub fn start_task(
382 &mut self,
383 id: &CapabilityId,
384 method: &str,
385 input: UiValue,
386 ) -> Result<(TaskWork, ValueSchema), CapabilityError> {
387 let (entry, schema) = self.entry_and_method(id, method, &input)?;
388 let handler = entry
389 .task
390 .as_mut()
391 .ok_or(CapabilityError::UnsupportedMode("task"))?;
392 let work = handler
393 .start(method, input)
394 .map_err(|message| CapabilityError::Handler {
395 id: id.clone(),
396 method: method.to_owned(),
397 message,
398 })?;
399 Ok((work, schema.output))
400 }
401
402 pub fn start_subscription(
408 &mut self,
409 id: &CapabilityId,
410 method: &str,
411 input: UiValue,
412 ) -> Result<(SubscriptionWork, ValueSchema), CapabilityError> {
413 let (entry, schema) = self.entry_and_method(id, method, &input)?;
414 let handler = entry
415 .subscription
416 .as_mut()
417 .ok_or(CapabilityError::UnsupportedMode("subscription"))?;
418 let work =
419 handler
420 .subscribe(method, input)
421 .map_err(|message| CapabilityError::Handler {
422 id: id.clone(),
423 method: method.to_owned(),
424 message,
425 })?;
426 Ok((work, schema.output))
427 }
428
429 fn entry_and_method(
430 &mut self,
431 id: &CapabilityId,
432 method: &str,
433 input: &UiValue,
434 ) -> Result<(&mut CapabilityEntry, CapabilityMethod), CapabilityError> {
435 if !self.active.contains(id) {
436 return Err(CapabilityError::NotDeclared(id.clone()));
437 }
438 let entry = self
439 .entries
440 .get_mut(id)
441 .ok_or_else(|| CapabilityError::Missing(id.clone()))?;
442 let schema = entry
443 .descriptor
444 .methods
445 .get(method)
446 .cloned()
447 .ok_or_else(|| CapabilityError::UnknownMethod {
448 id: id.clone(),
449 method: method.to_owned(),
450 })?;
451 schema
452 .input
453 .validate_ui_value(input)
454 .map_err(|source| CapabilityError::InvalidInput {
455 id: id.clone(),
456 method: method.to_owned(),
457 source,
458 })?;
459 Ok((entry, schema))
460 }
461}
462
463fn validate_descriptor(descriptor: &CapabilityDescriptor) -> Result<(), CapabilityError> {
464 if descriptor.methods.is_empty() {
465 return Err(CapabilityError::NoMethods(descriptor.id.clone()));
466 }
467 if let Some(method) = descriptor
468 .methods
469 .keys()
470 .find(|method| !is_identifier(method))
471 {
472 return Err(CapabilityError::InvalidMethod(method.clone()));
473 }
474 Ok(())
475}
476
477#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
478pub struct AppManifest {
479 pub entry: ModuleId,
480 pub runtime_api: u32,
481 #[serde(default)]
482 pub capabilities: BTreeMap<CapabilityId, VersionReq>,
483}
484
485impl AppManifest {
486 #[must_use]
487 pub fn new(entry: ModuleId) -> Self {
488 Self {
489 entry,
490 runtime_api: RUNTIME_API_VERSION,
491 capabilities: BTreeMap::new(),
492 }
493 }
494
495 pub fn with_capability(mut self, id: &str, requirement: &str) -> Result<Self, CapabilityError> {
501 let id = CapabilityId::parse(id)?;
502 let requirement = VersionReq::parse(requirement).map_err(|source| {
503 CapabilityError::InvalidVersionRequirement {
504 requirement: requirement.to_owned(),
505 source,
506 }
507 })?;
508 self.capabilities.insert(id, requirement);
509 Ok(self)
510 }
511
512 pub fn activate(&self, registry: &mut CapabilityRegistry) -> Result<(), CapabilityError> {
518 if self.runtime_api != RUNTIME_API_VERSION {
519 return Err(CapabilityError::RuntimeApiMismatch {
520 required: self.runtime_api,
521 actual: RUNTIME_API_VERSION,
522 });
523 }
524 registry.activate(&self.capabilities)
525 }
526
527 pub fn validate_components(
534 &self,
535 components: &crate::ComponentRegistry,
536 ) -> Result<(), CapabilityError> {
537 for (component_id, component) in components.iter() {
538 for (raw_id, required) in &component.metadata.capabilities {
539 let id = CapabilityId::parse(raw_id)?;
540 let Some(declared) = self.capabilities.get(&id) else {
541 return Err(CapabilityError::ComponentCapabilityMissing {
542 component: component_id.clone(),
543 capability: id,
544 });
545 };
546 if declared != required {
547 return Err(CapabilityError::ComponentCapabilityMismatch {
548 component: component_id.clone(),
549 capability: id,
550 component_required: required.clone(),
551 app_declared: declared.clone(),
552 });
553 }
554 }
555 }
556 Ok(())
557 }
558}
559
560#[derive(Debug, Error)]
561pub enum CapabilityError {
562 #[error("capability ID `{0}` must be a namespaced `snake_case` identifier")]
563 InvalidId(String),
564 #[error("invalid capability version requirement `{requirement}`: {source}")]
565 InvalidVersionRequirement {
566 requirement: String,
567 #[source]
568 source: semver::Error,
569 },
570 #[error("capability `{0:?}` was registered with conflicting descriptors")]
571 DescriptorMismatch(CapabilityId),
572 #[error("capability handler mode `{0}` is already registered")]
573 DuplicateMode(&'static str),
574 #[error("capability does not implement `{0}` mode")]
575 UnsupportedMode(&'static str),
576 #[error("capability `{0:?}` must declare at least one method")]
577 NoMethods(CapabilityId),
578 #[error("capability method `{0}` must be a `snake_case` identifier")]
579 InvalidMethod(String),
580 #[error("required capability `{0:?}` is not registered")]
581 Missing(CapabilityId),
582 #[error("capability `{id:?}` requires {required}, host provides {actual}")]
583 VersionMismatch {
584 id: CapabilityId,
585 required: VersionReq,
586 actual: Version,
587 },
588 #[error("capability `{0:?}` was not declared by the application manifest")]
589 NotDeclared(CapabilityId),
590 #[error("capability `{id:?}` has no method `{method}`")]
591 UnknownMethod { id: CapabilityId, method: String },
592 #[error("input for `{id:?}.{method}` is invalid: {source}")]
593 InvalidInput {
594 id: CapabilityId,
595 method: String,
596 source: SchemaValidationError,
597 },
598 #[error("output from `{id:?}.{method}` is invalid: {source}")]
599 InvalidOutput {
600 id: CapabilityId,
601 method: String,
602 source: SchemaValidationError,
603 },
604 #[error("capability `{id:?}.{method}` failed: {message}")]
605 Handler {
606 id: CapabilityId,
607 method: String,
608 message: String,
609 },
610 #[error("application requires runtime API {required}, current API is {actual}")]
611 RuntimeApiMismatch { required: u32, actual: u32 },
612 #[error(
613 "component `{component}` requires capability `{capability:?}` missing from the app manifest"
614 )]
615 ComponentCapabilityMissing {
616 component: ModuleId,
617 capability: CapabilityId,
618 },
619 #[error(
620 "component `{component}` requires capability `{capability:?}` at {component_required}, but the app declares {app_declared}"
621 )]
622 ComponentCapabilityMismatch {
623 component: ModuleId,
624 capability: CapabilityId,
625 component_required: VersionReq,
626 app_declared: VersionReq,
627 },
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::{
634 ComponentDefinition, ComponentMetadata, ComponentRegistry, ComponentSchema, RuntimeApiRange,
635 };
636
637 struct Echo;
638
639 impl CapabilityHandler for Echo {
640 fn call(&mut self, method: &str, input: UiValue) -> Result<UiValue, String> {
641 if method == "echo" {
642 Ok(input)
643 } else {
644 Err("unexpected method".to_owned())
645 }
646 }
647 }
648
649 struct AsyncEcho;
650
651 impl AsyncCapabilityHandler for AsyncEcho {
652 fn start(&mut self, method: &str, input: UiValue) -> Result<TaskWork, String> {
653 if method == "echo" {
654 Ok(TaskWork::new(move || Ok(input)))
655 } else {
656 Err("unexpected method".to_owned())
657 }
658 }
659 }
660
661 fn descriptor() -> CapabilityDescriptor {
662 CapabilityDescriptor {
663 id: CapabilityId::parse("app.echo").unwrap(),
664 version: Version::new(1, 2, 0),
665 methods: BTreeMap::from([(
666 "echo".to_owned(),
667 CapabilityMethod {
668 input: ValueSchema::string(),
669 output: ValueSchema::string(),
670 },
671 )]),
672 }
673 }
674
675 #[test]
676 fn manifest_controls_capability_access_and_versions() {
677 let mut registry = CapabilityRegistry::new();
678 registry.register(descriptor(), Echo).unwrap();
679 assert!(matches!(
680 registry.call(
681 &CapabilityId::parse("app.echo").unwrap(),
682 "echo",
683 UiValue::String("no".to_owned()),
684 ),
685 Err(CapabilityError::NotDeclared(_))
686 ));
687
688 let manifest = AppManifest {
689 entry: ModuleId::parse("main").unwrap(),
690 runtime_api: RUNTIME_API_VERSION,
691 capabilities: BTreeMap::from([(
692 CapabilityId::parse("app.echo").unwrap(),
693 VersionReq::parse("^1.0").unwrap(),
694 )]),
695 };
696 manifest.activate(&mut registry).unwrap();
697 assert_eq!(
698 registry
699 .call(
700 &CapabilityId::parse("app.echo").unwrap(),
701 "echo",
702 UiValue::String("yes".to_owned()),
703 )
704 .unwrap(),
705 UiValue::String("yes".to_owned())
706 );
707 }
708
709 #[test]
710 fn manifest_must_cover_component_capability_requirements() {
711 let component = ComponentDefinition::new(
712 ComponentMetadata {
713 id: ModuleId::parse("components/remote_status").unwrap(),
714 export: "RemoteStatus".to_owned(),
715 version: Version::new(0, 1, 0),
716 runtime_api: RuntimeApiRange::new(2, 3),
717 dependencies: BTreeSet::new(),
718 capabilities: BTreeMap::from([(
719 "app.echo".to_owned(),
720 VersionReq::parse("^1.0").unwrap(),
721 )]),
722 assets: BTreeSet::new(),
723 },
724 ComponentSchema::default(),
725 )
726 .unwrap();
727 let mut components = ComponentRegistry::new();
728 components.register(component, RUNTIME_API_VERSION).unwrap();
729 let missing = AppManifest::new(ModuleId::parse("main").unwrap());
730 assert!(matches!(
731 missing.validate_components(&components),
732 Err(CapabilityError::ComponentCapabilityMissing { .. })
733 ));
734 let declared = missing.with_capability("app.echo", "^1.0").unwrap();
735 declared.validate_components(&components).unwrap();
736 }
737
738 #[test]
739 fn output_is_validated_even_for_rust_handlers() {
740 struct BadOutput;
741 impl CapabilityHandler for BadOutput {
742 fn call(&mut self, _: &str, _: UiValue) -> Result<UiValue, String> {
743 Ok(UiValue::Bool(true))
744 }
745 }
746 let mut registry = CapabilityRegistry::new();
747 registry.register(descriptor(), BadOutput).unwrap();
748 registry
749 .activate(&BTreeMap::from([(
750 CapabilityId::parse("app.echo").unwrap(),
751 VersionReq::STAR,
752 )]))
753 .unwrap();
754 assert!(matches!(
755 registry.call(
756 &CapabilityId::parse("app.echo").unwrap(),
757 "echo",
758 UiValue::String("input".to_owned()),
759 ),
760 Err(CapabilityError::InvalidOutput { .. })
761 ));
762 }
763
764 #[test]
765 fn async_handler_builds_validated_worker_without_moving_rhai_callbacks() {
766 let mut registry = CapabilityRegistry::new();
767 registry.register_async(descriptor(), AsyncEcho).unwrap();
768 registry
769 .activate(&BTreeMap::from([(
770 CapabilityId::parse("app.echo").unwrap(),
771 VersionReq::STAR,
772 )]))
773 .unwrap();
774 let (work, output) = registry
775 .start_task(
776 &CapabilityId::parse("app.echo").unwrap(),
777 "echo",
778 UiValue::String("async".to_owned()),
779 )
780 .unwrap();
781 let value = work.run(crate::TaskCancellation::default()).unwrap();
782 output.validate_ui_value(&value).unwrap();
783 assert_eq!(value, UiValue::String("async".to_owned()));
784 }
785}