Skip to main content

modkit/
client_hub.rs

1//! Minimalistic, type-safe `ClientHub`.
2//!
3//! Design goals:
4//! - Providers register an implementation once (local or remote).
5//! - Consumers fetch by *interface type* (trait object): `get::<dyn my::Api>()`.
6//! - For plugin-like scenarios, multiple implementations of the same interface can coexist
7//!   under different scopes (e.g. selected by GTS instance ID).
8//!
9//! Implementation details:
10//! - Key = type name. We use `type_name::<T>()`, which works for `T = dyn Trait`.
11//! - Value = `Arc<T>` stored as `Box<dyn Any + Send + Sync>` (downcast on read).
12//! - Sync hot path: `get()` is non-async; no hidden per-entry cells or lazy slots.
13//!
14//! Notes:
15//! - Re-registering overwrites the previous value atomically; existing Arcs held by consumers remain valid.
16//! - For testing, just register a mock under the same trait type.
17
18use parking_lot::RwLock;
19use std::{any::Any, collections::HashMap, fmt, sync::Arc};
20
21/// Stable type key for trait objects — uses fully-qualified `type_name::<T>()`.
22#[derive(Clone, Eq, PartialEq, Hash)]
23pub struct TypeKey(&'static str);
24
25impl TypeKey {
26    #[inline]
27    fn of<T: ?Sized + 'static>() -> Self {
28        TypeKey(std::any::type_name::<T>())
29    }
30}
31
32impl fmt::Debug for TypeKey {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(self.0)
35    }
36}
37
38/// A scope for resolving multiple implementations of the same interface type.
39///
40/// This is intentionally opaque: the scope semantics are defined by the caller.
41/// One common scope is a full GTS entity/instance ID.
42#[derive(Clone, Eq, PartialEq, Hash)]
43pub struct ClientScope(Arc<str>);
44
45impl ClientScope {
46    /// Create a new scope from an arbitrary string.
47    #[inline]
48    #[must_use]
49    pub fn new(scope: impl Into<Arc<str>>) -> Self {
50        Self(scope.into())
51    }
52
53    /// Create a scope derived from a GTS identifier.
54    ///
55    /// Internally we prefix the scope to avoid accidental collisions with other scope kinds.
56    #[must_use]
57    pub fn gts_id(gts_id: &str) -> Self {
58        let mut s = String::with_capacity("gts:".len() + gts_id.len());
59        s.push_str("gts:");
60        s.push_str(gts_id);
61        Self(Arc::<str>::from(s))
62    }
63
64    #[inline]
65    #[must_use]
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69}
70
71impl fmt::Debug for ClientScope {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(self.as_str())
74    }
75}
76
77#[derive(Clone, Eq, PartialEq, Hash)]
78struct ScopedKey {
79    type_key: TypeKey,
80    scope: ClientScope,
81}
82
83impl fmt::Debug for ScopedKey {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.debug_struct("ScopedKey")
86            .field("type_key", &self.type_key)
87            .field("scope", &self.scope)
88            .finish()
89    }
90}
91
92#[derive(Debug, thiserror::Error)]
93pub enum ClientHubError {
94    #[error("client not found: type={type_key:?}")]
95    NotFound { type_key: TypeKey },
96
97    #[error("type mismatch in hub for type={type_key:?}")]
98    TypeMismatch { type_key: TypeKey },
99
100    #[error("scoped client not found: type={type_key:?} scope={scope:?}")]
101    ScopedNotFound {
102        type_key: TypeKey,
103        scope: ClientScope,
104    },
105
106    #[error("type mismatch in hub for type={type_key:?} scope={scope:?}")]
107    ScopedTypeMismatch {
108        type_key: TypeKey,
109        scope: ClientScope,
110    },
111}
112
113type Boxed = Box<dyn Any + Send + Sync>;
114
115/// Internal map type for the client hub.
116type ClientMap = HashMap<TypeKey, Boxed>;
117
118/// Internal map type for the scoped client hub.
119type ScopedClientMap = HashMap<ScopedKey, Boxed>;
120
121/// Type-safe registry of clients keyed by interface type.
122#[derive(Default)]
123pub struct ClientHub {
124    map: RwLock<ClientMap>,
125    scoped_map: RwLock<ScopedClientMap>,
126}
127
128impl ClientHub {
129    #[inline]
130    #[must_use]
131    pub fn new() -> Self {
132        Self {
133            map: RwLock::new(HashMap::new()),
134            scoped_map: RwLock::new(HashMap::new()),
135        }
136    }
137}
138
139impl ClientHub {
140    /// Register a client under the interface type `T`.
141    /// `T` can be a trait object like `dyn my_module::api::MyClient`.
142    pub fn register<T>(&self, client: Arc<T>)
143    where
144        T: ?Sized + Send + Sync + 'static,
145    {
146        let type_key = TypeKey::of::<T>();
147        let mut w = self.map.write();
148        w.insert(type_key, Box::new(client));
149    }
150
151    /// Register a scoped client under the interface type `T`.
152    ///
153    /// This enables multiple implementations of the same interface to coexist,
154    /// distinguished by a caller-defined `ClientScope` (e.g., a GTS instance ID).
155    pub fn register_scoped<T>(&self, scope: ClientScope, client: Arc<T>)
156    where
157        T: ?Sized + Send + Sync + 'static,
158    {
159        let key = ScopedKey {
160            type_key: TypeKey::of::<T>(),
161            scope,
162        };
163        let mut w = self.scoped_map.write();
164        w.insert(key, Box::new(client));
165    }
166
167    /// Fetch a client by interface type `T`.
168    ///
169    /// # Errors
170    /// Returns `ClientHubError::NotFound` if no client is registered for the type.
171    /// Returns `ClientHubError::TypeMismatch` if the stored type doesn't match.
172    pub fn get<T>(&self) -> Result<Arc<T>, ClientHubError>
173    where
174        T: ?Sized + Send + Sync + 'static,
175    {
176        let type_key = TypeKey::of::<T>();
177        let r = self.map.read();
178
179        let boxed = r.get(&type_key).ok_or(ClientHubError::NotFound {
180            type_key: type_key.clone(),
181        })?;
182
183        // Stored value is exactly `Arc<T>`; downcast is safe and cheap.
184        if let Some(arc_t) = boxed.downcast_ref::<Arc<T>>() {
185            return Ok(arc_t.clone());
186        }
187        Err(ClientHubError::TypeMismatch { type_key })
188    }
189
190    /// Fetch a scoped client by interface type `T` and scope.
191    ///
192    /// # Errors
193    /// Returns `ClientHubError::ScopedNotFound` if no client is registered for the `(type, scope)` pair.
194    /// Returns `ClientHubError::ScopedTypeMismatch` if the stored type doesn't match.
195    pub fn get_scoped<T>(&self, scope: &ClientScope) -> Result<Arc<T>, ClientHubError>
196    where
197        T: ?Sized + Send + Sync + 'static,
198    {
199        let key = ScopedKey {
200            type_key: TypeKey::of::<T>(),
201            scope: scope.clone(),
202        };
203        let r = self.scoped_map.read();
204
205        let boxed = r.get(&key).ok_or_else(|| ClientHubError::ScopedNotFound {
206            type_key: key.type_key.clone(),
207            scope: key.scope.clone(),
208        })?;
209
210        if let Some(arc_t) = boxed.downcast_ref::<Arc<T>>() {
211            return Ok(arc_t.clone());
212        }
213        Err(ClientHubError::ScopedTypeMismatch {
214            type_key: key.type_key,
215            scope: key.scope,
216        })
217    }
218
219    /// Try to fetch a scoped client by interface type `T` and scope.
220    ///
221    /// Returns `None` if not found or if the stored type doesn't match.
222    pub fn try_get_scoped<T>(&self, scope: &ClientScope) -> Option<Arc<T>>
223    where
224        T: ?Sized + Send + Sync + 'static,
225    {
226        let key = ScopedKey {
227            type_key: TypeKey::of::<T>(),
228            scope: scope.clone(),
229        };
230        let r = self.scoped_map.read();
231        let boxed = r.get(&key)?;
232
233        boxed.downcast_ref::<Arc<T>>().cloned()
234    }
235
236    /// Remove a client by interface type; returns the removed client if it was present.
237    pub fn remove<T>(&self) -> Option<Arc<T>>
238    where
239        T: ?Sized + Send + Sync + 'static,
240    {
241        let type_key = TypeKey::of::<T>();
242        let mut w = self.map.write();
243        let boxed = w.remove(&type_key)?;
244        boxed.downcast::<Arc<T>>().ok().map(|b| *b)
245    }
246
247    /// Remove a scoped client by interface type + scope; returns the removed client if it was present.
248    pub fn remove_scoped<T>(&self, scope: &ClientScope) -> Option<Arc<T>>
249    where
250        T: ?Sized + Send + Sync + 'static,
251    {
252        let key = ScopedKey {
253            type_key: TypeKey::of::<T>(),
254            scope: scope.clone(),
255        };
256        let mut w = self.scoped_map.write();
257        let boxed = w.remove(&key)?;
258        boxed.downcast::<Arc<T>>().ok().map(|b| *b)
259    }
260
261    /// Clear everything (useful in tests).
262    pub fn clear(&self) {
263        self.map.write().clear();
264        self.scoped_map.write().clear();
265    }
266
267    /// Introspection: (total entries).
268    pub fn len(&self) -> usize {
269        self.map.read().len() + self.scoped_map.read().len()
270    }
271
272    /// Check if the hub is empty.
273    pub fn is_empty(&self) -> bool {
274        self.map.read().is_empty() && self.scoped_map.read().is_empty()
275    }
276}
277
278#[cfg(test)]
279#[cfg_attr(coverage_nightly, coverage(off))]
280mod tests {
281    use super::*;
282
283    #[async_trait::async_trait]
284    trait TestApi: Send + Sync {
285        async fn id(&self) -> usize;
286    }
287
288    struct ImplA(usize);
289    #[async_trait::async_trait]
290    impl TestApi for ImplA {
291        async fn id(&self) -> usize {
292            self.0
293        }
294    }
295
296    #[tokio::test]
297    async fn register_and_get_dyn_trait() {
298        let hub = ClientHub::new();
299        let api: Arc<dyn TestApi> = Arc::new(ImplA(7));
300        hub.register::<dyn TestApi>(api.clone());
301
302        let got = hub.get::<dyn TestApi>().unwrap();
303        assert_eq!(got.id().await, 7);
304        assert_eq!(Arc::as_ptr(&api), Arc::as_ptr(&got));
305    }
306
307    #[tokio::test]
308    async fn remove_works() {
309        let hub = ClientHub::new();
310        let api: Arc<dyn TestApi> = Arc::new(ImplA(42));
311        hub.register::<dyn TestApi>(api);
312
313        assert!(hub.get::<dyn TestApi>().is_ok());
314
315        let removed = hub.remove::<dyn TestApi>();
316        assert!(removed.is_some());
317        assert!(hub.get::<dyn TestApi>().is_err());
318    }
319
320    #[tokio::test]
321    async fn overwrite_replaces_atomically() {
322        let hub = ClientHub::new();
323        hub.register::<dyn TestApi>(Arc::new(ImplA(1)));
324
325        let old = hub.get::<dyn TestApi>().unwrap();
326        assert_eq!(old.id().await, 1);
327
328        hub.register::<dyn TestApi>(Arc::new(ImplA(2)));
329
330        let new = hub.get::<dyn TestApi>().unwrap();
331        assert_eq!(new.id().await, 2);
332
333        // Old Arc is still valid
334        assert_eq!(old.id().await, 1);
335    }
336
337    #[tokio::test]
338    async fn scoped_register_and_get_dyn_trait() {
339        let hub = ClientHub::new();
340        let scope_a = ClientScope::gts_id(
341            "gts.x.core.modkit.plugins.v1~x.core.tenant_resolver.plugin.v1~contoso.app._.plugin.v1.0",
342        );
343        let scope_b = ClientScope::gts_id(
344            "gts.x.core.modkit.plugins.v1~x.core.tenant_resolver.plugin.v1~fabrikam.app._.plugin.v1.0",
345        );
346
347        let api_a: Arc<dyn TestApi> = Arc::new(ImplA(1));
348        let api_b: Arc<dyn TestApi> = Arc::new(ImplA(2));
349
350        hub.register_scoped::<dyn TestApi>(scope_a.clone(), api_a.clone());
351        hub.register_scoped::<dyn TestApi>(scope_b.clone(), api_b.clone());
352
353        assert_eq!(
354            hub.get_scoped::<dyn TestApi>(&scope_a).unwrap().id().await,
355            1
356        );
357        assert_eq!(
358            hub.get_scoped::<dyn TestApi>(&scope_b).unwrap().id().await,
359            2
360        );
361    }
362
363    #[test]
364    fn scoped_get_is_independent_from_global_get() {
365        let hub = ClientHub::new();
366        let scope = ClientScope::gts_id(
367            "gts.x.core.modkit.plugins.v1~x.core.tenant_resolver.plugin.v1~fabrikam.app._.plugin.v1.0",
368        );
369        hub.register::<str>(Arc::from("global"));
370        hub.register_scoped::<str>(scope.clone(), Arc::from("scoped"));
371
372        assert_eq!(&*hub.get::<str>().unwrap(), "global");
373        assert_eq!(&*hub.get_scoped::<str>(&scope).unwrap(), "scoped");
374    }
375
376    #[test]
377    fn try_get_scoped_returns_some_on_hit() {
378        let hub = ClientHub::new();
379        let scope = ClientScope::gts_id(
380            "gts.x.core.modkit.plugins.v1~x.core.tenant_resolver.plugin.v1~contoso.app._.plugin.v1.0",
381        );
382        hub.register_scoped::<str>(scope.clone(), Arc::from("scoped"));
383
384        let got = hub.try_get_scoped::<str>(&scope);
385        assert_eq!(got.as_deref(), Some("scoped"));
386    }
387
388    #[test]
389    fn try_get_scoped_returns_none_on_miss() {
390        let hub = ClientHub::new();
391        let scope = ClientScope::gts_id(
392            "gts.x.core.modkit.plugins.v1~x.core.tenant_resolver.plugin.v1~fabrikam.app._.plugin.v1.0",
393        );
394
395        let got = hub.try_get_scoped::<str>(&scope);
396        assert!(got.is_none());
397    }
398}