Skip to main content

wasm_capability_core/
capability_registry.rs

1//! [`InMemoryCapabilityRegistry`] — concrete, data-assembled `CapabilityRegistry`,
2//! built on `svc-registry-adapter`'s real `DefaultRegistry<T: Named>` rather
3//! than hand-rolled storage.
4
5use svc_registry::{Named, Registry};
6use svc_registry_adapter::DefaultRegistry;
7use wasm_capability_contract::{CapabilityDescriptor, CapabilityProtocol, CapabilityRegistry};
8
9/// Pairs a registry key with the [`CapabilityDescriptor`] registered under
10/// it. `CapabilityDescriptor::import_name` alone cannot serve as the
11/// [`Named`] key: a deployer-scoped instance (e.g.
12/// `"grpc-egress:inventory-svc"`) shares the same `import_name`
13/// (`"grpc-egress"`) as the family's default entry, so two descriptors can
14/// legitimately share an `import_name` while needing distinct registry
15/// keys.
16struct NamedCapabilityDescriptor {
17    key: String,
18    descriptor: CapabilityDescriptor,
19}
20
21impl Named for NamedCapabilityDescriptor {
22    fn name(&self) -> &str {
23        &self.key
24    }
25}
26
27/// In-memory `CapabilityRegistry`, seeded with ADR-001's six built-in
28/// capabilities. A deployer registers additional named instances (e.g.
29/// `grpc-egress:inventory-svc`) via [`Self::register`] without editing
30/// this crate's source.
31pub struct InMemoryCapabilityRegistry {
32    entries: DefaultRegistry<NamedCapabilityDescriptor>,
33}
34
35impl InMemoryCapabilityRegistry {
36    /// Seeds the six built-in capabilities: `http-egress`, `grpc-egress`,
37    /// `llm-complete`, `mcp-egress`, `database`, `secrets` — each
38    /// `import_name` equal to its own capability name.
39    #[must_use]
40    pub fn with_defaults() -> Self {
41        let mut entries = DefaultRegistry::default();
42        for (name, protocol) in [
43            ("http-egress", CapabilityProtocol::Http),
44            ("grpc-egress", CapabilityProtocol::Grpc),
45            ("llm-complete", CapabilityProtocol::Complete),
46            ("mcp-egress", CapabilityProtocol::Mcp),
47            ("database", CapabilityProtocol::Database),
48            ("secrets", CapabilityProtocol::Secrets),
49        ] {
50            entries.register(NamedCapabilityDescriptor {
51                key: name.to_string(),
52                descriptor: CapabilityDescriptor {
53                    import_name: name,
54                    protocol,
55                },
56            });
57        }
58        Self { entries }
59    }
60
61    /// Registers `descriptor` under `name`, replacing any existing entry
62    /// with the same name. Fluent — chainable after [`Self::with_defaults`]
63    /// without disturbing the six built-ins it seeded.
64    #[must_use]
65    pub fn register(mut self, name: impl Into<String>, descriptor: CapabilityDescriptor) -> Self {
66        self.entries.register(NamedCapabilityDescriptor {
67            key: name.into(),
68            descriptor,
69        });
70        self
71    }
72}
73
74impl CapabilityRegistry for InMemoryCapabilityRegistry {
75    fn descriptor(&self, capability: &str) -> Option<&CapabilityDescriptor> {
76        self.entries.get(capability).map(|entry| &entry.descriptor)
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    /// @covers: InMemoryCapabilityRegistry::with_defaults
85    /// Every one of the six built-in capabilities must resolve to exactly
86    /// the `CapabilityProtocol` ADR-001 assigns it — a wrong protocol
87    /// would wire a capability's host import to the wrong dispatcher
88    /// family entirely.
89    #[test]
90    fn test_with_defaults_resolves_all_six_built_in_capabilities_to_correct_protocol() {
91        let registry = InMemoryCapabilityRegistry::with_defaults();
92        let expected = [
93            ("http-egress", CapabilityProtocol::Http),
94            ("grpc-egress", CapabilityProtocol::Grpc),
95            ("llm-complete", CapabilityProtocol::Complete),
96            ("mcp-egress", CapabilityProtocol::Mcp),
97            ("database", CapabilityProtocol::Database),
98            ("secrets", CapabilityProtocol::Secrets),
99        ];
100        for (name, protocol) in expected {
101            let descriptor = registry
102                .descriptor(name)
103                .unwrap_or_else(|| panic!("expected a descriptor for '{name}'"));
104            assert_eq!(descriptor.import_name, name);
105            assert_eq!(descriptor.protocol, protocol);
106        }
107    }
108
109    /// @covers: InMemoryCapabilityRegistry::descriptor
110    /// An unregistered capability name must return `None`, not panic or
111    /// fabricate a descriptor — deny-by-default extends to lookup misses.
112    #[test]
113    fn test_descriptor_returns_none_for_unregistered_capability() {
114        let registry = InMemoryCapabilityRegistry::with_defaults();
115        assert!(registry.descriptor("not-a-real-capability").is_none());
116    }
117
118    /// @covers: InMemoryCapabilityRegistry::register
119    /// Registering a new named instance must not alter or remove any of
120    /// the six defaults — a deployer extending the registry must never
121    /// accidentally break an existing capability.
122    #[test]
123    fn test_register_adds_new_entry_without_disturbing_defaults() {
124        let registry = InMemoryCapabilityRegistry::with_defaults().register(
125            "grpc-egress:inventory-svc",
126            CapabilityDescriptor {
127                import_name: "grpc-egress",
128                protocol: CapabilityProtocol::Grpc,
129            },
130        );
131
132        let scoped = registry
133            .descriptor("grpc-egress:inventory-svc")
134            .unwrap_or_else(|| panic!("expected the newly registered instance"));
135        assert_eq!(scoped.import_name, "grpc-egress");
136
137        let default = registry
138            .descriptor("grpc-egress")
139            .unwrap_or_else(|| panic!("expected the default 'grpc-egress' to still resolve"));
140        assert_eq!(default.protocol, CapabilityProtocol::Grpc);
141
142        for name in [
143            "http-egress",
144            "llm-complete",
145            "mcp-egress",
146            "database",
147            "secrets",
148        ] {
149            assert!(
150                registry.descriptor(name).is_some(),
151                "expected default '{name}' to still resolve after register()"
152            );
153        }
154    }
155
156    /// @covers: InMemoryCapabilityRegistry::register
157    /// Re-registering under an already-used key must replace the old
158    /// descriptor, not create a duplicate or silently no-op — matching
159    /// `DefaultRegistry::register`'s own documented replace-on-collision
160    /// behavior.
161    #[test]
162    fn test_register_replaces_existing_entry_with_same_key() {
163        let registry = InMemoryCapabilityRegistry::with_defaults().register(
164            "http-egress",
165            CapabilityDescriptor {
166                import_name: "http-egress-v2",
167                protocol: CapabilityProtocol::Http,
168            },
169        );
170
171        let descriptor = registry
172            .descriptor("http-egress")
173            .unwrap_or_else(|| panic!("expected 'http-egress' to still resolve"));
174        assert_eq!(descriptor.import_name, "http-egress-v2");
175    }
176}