stygian_graph/application/
registry.rs

1//! Service registry and dependency injection
2//!
3//! Provides a runtime registry for wiring together ports and adapters.
4//!
5//! Key features:
6//! - Thread-safe dynamic registration via `Arc<RwLock<…>>`
7//! - Builder pattern for ergonomic construction
8//! - `LazyLock`-based process-wide default registry singleton
9//! - Per-service health checks and availability status
10//!
11//! # Example
12//!
13//! ```no_run
14//! use stygian_graph::application::registry::ServiceRegistry;
15//! use stygian_graph::adapters::noop::NoopService;
16//! use std::sync::Arc;
17//!
18//! let registry = ServiceRegistry::builder()
19//!     .register("noop", Arc::new(NoopService))
20//!     .build();
21//!
22//! let svc = registry.get("noop");
23//! assert!(svc.is_some());
24//! ```
25
26use std::collections::HashMap;
27use std::sync::{Arc, LazyLock, RwLock};
28
29use serde::{Deserialize, Serialize};
30use tracing::{debug, warn};
31
32use crate::ports::ScrapingService;
33
34// ─── Availability status ──────────────────────────────────────────────────────
35
36/// Availability status of a registered service
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum ServiceStatus {
39    /// Service responded to its last health check successfully
40    Healthy,
41    /// Service is degraded but still processing requests
42    Degraded(String),
43    /// Service is unavailable
44    Unavailable(String),
45    /// Health check has not been run yet
46    Unknown,
47}
48
49impl ServiceStatus {
50    /// Returns `true` when [`ServiceStatus::Healthy`] or [`ServiceStatus::Degraded`]
51    pub const fn is_available(&self) -> bool {
52        matches!(self, Self::Healthy | Self::Degraded(_))
53    }
54}
55
56// ─── Registry entry ───────────────────────────────────────────────────────────
57
58struct RegistryEntry {
59    service: Arc<dyn ScrapingService>,
60    status: ServiceStatus,
61}
62
63// ─── ServiceRegistry ─────────────────────────────────────────────────────────
64
65/// Thread-safe runtime registry for [`ScrapingService`] adapters.
66///
67/// Use [`ServiceRegistry::builder()`] for ergonomic setup, or call
68/// [`ServiceRegistry::register`] at runtime for dynamic registration.
69///
70/// # Thread safety
71///
72/// All mutations are guarded by an `RwLock`. Reads are non-exclusive.
73pub struct ServiceRegistry {
74    entries: Arc<RwLock<HashMap<String, RegistryEntry>>>,
75}
76
77// SAFETY: RwLock poisoning only occurs on panic; panics are unrecoverable for
78// this service so unwrap is correct here.
79#[allow(clippy::unwrap_used)]
80impl ServiceRegistry {
81    /// Create an empty registry.
82    ///
83    /// # Example
84    ///
85    /// ```
86    /// use stygian_graph::application::registry::ServiceRegistry;
87    ///
88    /// let r = ServiceRegistry::new();
89    /// assert!(r.get("anything").is_none());
90    /// ```
91    pub fn new() -> Self {
92        Self {
93            entries: Arc::new(RwLock::new(HashMap::new())),
94        }
95    }
96
97    /// Return a new [`RegistryBuilder`] for ergonomic construction.
98    ///
99    /// # Example
100    ///
101    /// ```
102    /// use stygian_graph::application::registry::ServiceRegistry;
103    /// use stygian_graph::adapters::noop::NoopService;
104    /// use std::sync::Arc;
105    ///
106    /// let r = ServiceRegistry::builder()
107    ///     .register("noop", Arc::new(NoopService))
108    ///     .build();
109    ///
110    /// assert!(r.get("noop").is_some());
111    /// ```
112    pub fn builder() -> RegistryBuilder {
113        RegistryBuilder::new()
114    }
115
116    /// Register (or replace) a service at runtime.
117    ///
118    /// The service's initial status is set to [`ServiceStatus::Unknown`].
119    ///
120    /// # Example
121    ///
122    /// ```
123    /// use stygian_graph::application::registry::ServiceRegistry;
124    /// use stygian_graph::adapters::noop::NoopService;
125    /// use std::sync::Arc;
126    ///
127    /// let r = ServiceRegistry::new();
128    /// r.register("noop".to_string(), Arc::new(NoopService));
129    /// assert!(r.get("noop").is_some());
130    /// ```
131    pub fn register(&self, name: String, service: Arc<dyn ScrapingService>) {
132        let entry = RegistryEntry {
133            service,
134            status: ServiceStatus::Unknown,
135        };
136        self.entries.write().unwrap().insert(name, entry);
137    }
138
139    /// Look up a service by name.
140    ///
141    /// Returns `None` if the service is not registered.
142    pub fn get(&self, name: &str) -> Option<Arc<dyn ScrapingService>> {
143        self.entries
144            .read()
145            .unwrap()
146            .get(name)
147            .map(|e| Arc::clone(&e.service))
148    }
149
150    /// Return the current [`ServiceStatus`] for the named service.
151    ///
152    /// Returns `None` if no service is registered under that name.
153    pub fn status(&self, name: &str) -> Option<ServiceStatus> {
154        self.entries
155            .read()
156            .unwrap()
157            .get(name)
158            .map(|e| e.status.clone())
159    }
160
161    /// List all registered service names.
162    ///
163    /// # Example
164    ///
165    /// ```
166    /// use stygian_graph::application::registry::ServiceRegistry;
167    /// use stygian_graph::adapters::noop::NoopService;
168    /// use std::sync::Arc;
169    ///
170    /// let r = ServiceRegistry::new();
171    /// r.register("a".to_string(), Arc::new(NoopService));
172    /// r.register("b".to_string(), Arc::new(NoopService));
173    /// let mut names = r.names();
174    /// names.sort();
175    /// assert_eq!(names, vec!["a", "b"]);
176    /// ```
177    pub fn names(&self) -> Vec<String> {
178        self.entries.read().unwrap().keys().cloned().collect()
179    }
180
181    /// Remove a service from the registry.
182    ///
183    /// Returns `true` if a service was removed, `false` if it was not registered.
184    pub fn deregister(&self, name: &str) -> bool {
185        self.entries.write().unwrap().remove(name).is_some()
186    }
187
188    /// Run a simple connectivity health check on all registered services.
189    ///
190    /// Each service's name is pinged by executing a [`crate::ports::ServiceInput`]
191    /// with a no-op URL. Results update the stored [`ServiceStatus`]. Returns a
192    /// snapshot map of `name → status` after the checks complete.
193    #[allow(clippy::unused_async)]
194    pub async fn health_check_all(&self) -> HashMap<String, ServiceStatus> {
195        let entries_snapshot: Vec<(String, Arc<dyn ScrapingService>)> = {
196            let guard = self.entries.read().unwrap();
197            guard
198                .iter()
199                .map(|(k, v)| (k.clone(), Arc::clone(&v.service)))
200                .collect()
201        };
202
203        let mut results = HashMap::new();
204
205        for (name, svc) in entries_snapshot {
206            let status = Self::probe_service(svc);
207            debug!(service = %name, ?status, "health check");
208            {
209                let mut guard = self.entries.write().unwrap();
210                if let Some(entry) = guard.get_mut(&name) {
211                    entry.status = status.clone();
212                }
213            }
214            results.insert(name, status);
215        }
216
217        results
218    }
219
220    /// Probe a single service by calling its `name()` method and marking it
221    /// healthy. If the service panics or its name is empty we mark it degraded.
222    #[allow(clippy::needless_pass_by_value)]
223    fn probe_service(svc: Arc<dyn ScrapingService>) -> ServiceStatus {
224        let name = svc.name();
225        if name.is_empty() {
226            warn!("Service returned empty name during health probe");
227            ServiceStatus::Degraded("empty service name".to_string())
228        } else {
229            ServiceStatus::Healthy
230        }
231    }
232
233    /// Update stored status for a named service directly.
234    ///
235    /// Useful for external health-check feedback (e.g., from readiness probes).
236    pub fn update_status(&self, name: &str, status: ServiceStatus) {
237        let mut guard = self.entries.write().unwrap();
238        if let Some(entry) = guard.get_mut(name) {
239            entry.status = status;
240        }
241    }
242}
243
244impl Default for ServiceRegistry {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250// ─── Builder ──────────────────────────────────────────────────────────────────
251
252/// Builder for constructing a [`ServiceRegistry`].
253///
254/// # Example
255///
256/// ```
257/// use stygian_graph::application::registry::ServiceRegistry;
258/// use stygian_graph::adapters::noop::NoopService;
259/// use std::sync::Arc;
260///
261/// let registry = ServiceRegistry::builder()
262///     .register("noop", Arc::new(NoopService))
263///     .build();
264///
265/// assert_eq!(registry.names().len(), 1);
266/// ```
267pub struct RegistryBuilder {
268    entries: HashMap<String, Arc<dyn ScrapingService>>,
269}
270
271#[allow(clippy::unwrap_used)] // RwLock poisoning is unrecoverable
272impl RegistryBuilder {
273    fn new() -> Self {
274        Self {
275            entries: HashMap::new(),
276        }
277    }
278
279    /// Register a service with the given name.
280    #[must_use]
281    pub fn register(mut self, name: impl Into<String>, service: Arc<dyn ScrapingService>) -> Self {
282        self.entries.insert(name.into(), service);
283        self
284    }
285
286    /// Build the registry from accumulated registrations.
287    pub fn build(self) -> ServiceRegistry {
288        let registry = ServiceRegistry::new();
289        {
290            let mut guard = registry.entries.write().unwrap();
291            for (name, service) in self.entries {
292                guard.insert(
293                    name,
294                    RegistryEntry {
295                        service,
296                        status: ServiceStatus::Unknown,
297                    },
298                );
299            }
300        }
301        registry
302    }
303}
304
305// ─── Global singleton ─────────────────────────────────────────────────────────
306
307/// Process-wide default service registry singleton.
308///
309/// Initialized once via [`LazyLock`]. Use for global lookup of well-known
310/// services without passing the registry through call chains.
311///
312/// Register your services into the global registry at startup:
313///
314/// ```no_run
315/// use stygian_graph::application::registry::global_registry;
316/// use stygian_graph::adapters::noop::NoopService;
317/// use std::sync::Arc;
318///
319/// global_registry().register("noop".to_string(), Arc::new(NoopService));
320/// ```
321pub fn global_registry() -> &'static ServiceRegistry {
322    static INSTANCE: LazyLock<ServiceRegistry> = LazyLock::new(ServiceRegistry::new);
323    &INSTANCE
324}
325
326// ─── Tests ────────────────────────────────────────────────────────────────────
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::adapters::noop::NoopService as NoopScraper;
332
333    fn noop() -> Arc<dyn ScrapingService> {
334        Arc::new(NoopScraper)
335    }
336
337    #[test]
338    fn register_and_get() {
339        let r = ServiceRegistry::new();
340        r.register("svc".to_string(), noop());
341        assert!(r.get("svc").is_some());
342        assert!(r.get("missing").is_none());
343    }
344
345    #[test]
346    fn deregister() {
347        let r = ServiceRegistry::new();
348        r.register("svc".to_string(), noop());
349        assert!(r.deregister("svc"));
350        assert!(!r.deregister("svc")); // idempotent
351        assert!(r.get("svc").is_none());
352    }
353
354    #[test]
355    fn names_lists_all() {
356        let r = ServiceRegistry::builder()
357            .register("a", noop())
358            .register("b", noop())
359            .build();
360        let mut names = r.names();
361        names.sort();
362        assert_eq!(names, vec!["a", "b"]);
363    }
364
365    #[test]
366    fn builder_pattern() {
367        let r = ServiceRegistry::builder()
368            .register("one", noop())
369            .register("two", noop())
370            .build();
371        assert_eq!(r.names().len(), 2);
372    }
373
374    #[test]
375    fn status_unknown_after_register() {
376        let r = ServiceRegistry::new();
377        r.register("svc".to_string(), noop());
378        assert_eq!(r.status("svc"), Some(ServiceStatus::Unknown));
379    }
380
381    #[test]
382    fn update_status() {
383        let r = ServiceRegistry::new();
384        r.register("svc".to_string(), noop());
385        r.update_status("svc", ServiceStatus::Healthy);
386        assert_eq!(r.status("svc"), Some(ServiceStatus::Healthy));
387    }
388
389    #[test]
390    fn service_status_is_available() {
391        assert!(ServiceStatus::Healthy.is_available());
392        assert!(ServiceStatus::Degraded("x".into()).is_available());
393        assert!(!ServiceStatus::Unavailable("x".into()).is_available());
394        assert!(!ServiceStatus::Unknown.is_available());
395    }
396
397    #[tokio::test]
398    async fn health_check_all_marks_healthy() {
399        let r = ServiceRegistry::builder().register("noop", noop()).build();
400        let results = r.health_check_all().await;
401        assert_eq!(results.get("noop"), Some(&ServiceStatus::Healthy));
402        // Stored status updated
403        assert_eq!(r.status("noop"), Some(ServiceStatus::Healthy));
404    }
405
406    #[test]
407    fn global_registry_singleton_is_same_ref() {
408        use std::ptr;
409        let a = global_registry();
410        let b = global_registry();
411        assert!(ptr::addr_eq(a, b));
412    }
413}