Skip to main content

modkit/
context.rs

1use serde::de::DeserializeOwned;
2use std::sync::Arc;
3use tokio_util::sync::CancellationToken;
4use uuid::Uuid;
5
6// Import configuration types from the config module
7use crate::config::{ConfigError, ConfigProvider, module_config_or_default};
8
9// Note: runtime-dependent features are conditionally compiled
10
11#[derive(Clone)]
12#[must_use]
13pub struct ModuleCtx {
14    module_name: Arc<str>,
15    instance_id: Uuid,
16    config_provider: Arc<dyn ConfigProvider>,
17    client_hub: Arc<crate::client_hub::ClientHub>,
18    cancellation_token: CancellationToken,
19    db_handle: Option<Arc<modkit_db::DbHandle>>,
20}
21
22/// Builder for creating module-scoped contexts with resolved database handles.
23///
24/// This builder internally uses `DbManager` to resolve per-module `DbHandle` instances
25/// at build time, ensuring `ModuleCtx` contains only the final, ready-to-use handle.
26pub struct ModuleContextBuilder {
27    instance_id: Uuid,
28    config_provider: Arc<dyn ConfigProvider>,
29    client_hub: Arc<crate::client_hub::ClientHub>,
30    root_token: CancellationToken,
31    db_manager: Option<Arc<modkit_db::DbManager>>, // internal only, never exposed to modules
32}
33
34impl ModuleContextBuilder {
35    pub fn new(
36        instance_id: Uuid,
37        config_provider: Arc<dyn ConfigProvider>,
38        client_hub: Arc<crate::client_hub::ClientHub>,
39        root_token: CancellationToken,
40        db_manager: Option<Arc<modkit_db::DbManager>>,
41    ) -> Self {
42        Self {
43            instance_id,
44            config_provider,
45            client_hub,
46            root_token,
47            db_manager,
48        }
49    }
50
51    /// Returns the process-level instance ID.
52    #[must_use]
53    pub fn instance_id(&self) -> Uuid {
54        self.instance_id
55    }
56
57    /// Build a module-scoped context, resolving the `DbHandle` for the given module.
58    ///
59    /// # Errors
60    /// Returns an error if database resolution fails.
61    pub async fn for_module(&self, module_name: &str) -> anyhow::Result<ModuleCtx> {
62        let db_handle = if let Some(mgr) = &self.db_manager {
63            mgr.get(module_name).await?
64        } else {
65            None
66        };
67
68        Ok(ModuleCtx::new(
69            Arc::<str>::from(module_name),
70            self.instance_id,
71            self.config_provider.clone(),
72            self.client_hub.clone(),
73            self.root_token.child_token(),
74            db_handle,
75        ))
76    }
77}
78
79impl ModuleCtx {
80    /// Create a new module-scoped context with all required fields.
81    pub fn new(
82        module_name: impl Into<Arc<str>>,
83        instance_id: Uuid,
84        config_provider: Arc<dyn ConfigProvider>,
85        client_hub: Arc<crate::client_hub::ClientHub>,
86        cancellation_token: CancellationToken,
87        db_handle: Option<Arc<modkit_db::DbHandle>>,
88    ) -> Self {
89        Self {
90            module_name: module_name.into(),
91            instance_id,
92            config_provider,
93            client_hub,
94            cancellation_token,
95            db_handle,
96        }
97    }
98
99    // ---- public read-only API for modules ----
100
101    #[inline]
102    #[must_use]
103    pub fn module_name(&self) -> &str {
104        &self.module_name
105    }
106
107    /// Returns the process-level instance ID.
108    ///
109    /// This is a unique identifier for this process instance, shared by all modules
110    /// in the same process. It is generated once at bootstrap.
111    #[inline]
112    #[must_use]
113    pub fn instance_id(&self) -> Uuid {
114        self.instance_id
115    }
116
117    #[inline]
118    #[must_use]
119    pub fn config_provider(&self) -> &dyn ConfigProvider {
120        &*self.config_provider
121    }
122
123    /// Get the `ClientHub` for dependency resolution.
124    #[inline]
125    #[must_use]
126    pub fn client_hub(&self) -> Arc<crate::client_hub::ClientHub> {
127        self.client_hub.clone()
128    }
129
130    #[inline]
131    #[must_use]
132    pub fn cancellation_token(&self) -> &CancellationToken {
133        &self.cancellation_token
134    }
135
136    #[must_use]
137    pub fn db_optional(&self) -> Option<Arc<modkit_db::DbHandle>> {
138        self.db_handle.clone()
139    }
140
141    /// Get the database handle, returning an error if not configured.
142    ///
143    /// # Errors
144    /// Returns an error if the database is not configured for this module.
145    pub fn db_required(&self) -> anyhow::Result<Arc<modkit_db::DbHandle>> {
146        self.db_handle.clone().ok_or_else(|| {
147            anyhow::anyhow!(
148                "Database is not configured for module '{}'",
149                self.module_name
150            )
151        })
152    }
153
154    #[must_use]
155    pub fn current_module(&self) -> Option<&str> {
156        Some(&self.module_name)
157    }
158
159    /// Deserialize the module's config section into T, or use defaults if missing.
160    ///
161    /// This method uses lenient configuration loading: if the module is not present in config,
162    /// has no config section, or the module entry is not an object, it returns `T::default()`.
163    /// This allows modules to exist without configuration sections in the main config file.
164    ///
165    /// It extracts the 'config' field from: `modules.<name> = { database: ..., config: ... }`
166    ///
167    /// # Example
168    ///
169    /// ```rust,ignore
170    /// #[derive(serde::Deserialize, Default)]
171    /// struct MyConfig {
172    ///     api_key: String,
173    ///     timeout_ms: u64,
174    /// }
175    ///
176    /// let config: MyConfig = ctx.config()?;
177    /// ```
178    ///
179    /// # Errors
180    /// Returns `ConfigError` if deserialization fails.
181    pub fn config<T: DeserializeOwned + Default>(&self) -> Result<T, ConfigError> {
182        module_config_or_default(self.config_provider.as_ref(), &self.module_name)
183    }
184
185    /// Get the raw JSON value of the module's config section.
186    /// Returns the 'config' field from: modules.<name> = { database: ..., config: ... }
187    #[must_use]
188    pub fn raw_config(&self) -> &serde_json::Value {
189        use std::sync::LazyLock;
190
191        static EMPTY: LazyLock<serde_json::Value> =
192            LazyLock::new(|| serde_json::Value::Object(serde_json::Map::new()));
193
194        if let Some(module_raw) = self.config_provider.get_module_config(&self.module_name) {
195            // Try new structure first: modules.<name> = { database: ..., config: ... }
196            if let Some(obj) = module_raw.as_object()
197                && let Some(config_section) = obj.get("config")
198            {
199                return config_section;
200            }
201        }
202        &EMPTY
203    }
204
205    /// Create a derivative context with the same references but a different DB handle.
206    /// This allows reusing the stable base context while providing per-module DB access.
207    pub fn with_db(&self, db: Arc<modkit_db::DbHandle>) -> ModuleCtx {
208        ModuleCtx {
209            module_name: self.module_name.clone(),
210            instance_id: self.instance_id,
211            config_provider: self.config_provider.clone(),
212            client_hub: self.client_hub.clone(),
213            cancellation_token: self.cancellation_token.clone(),
214            db_handle: Some(db),
215        }
216    }
217
218    /// Create a derivative context with the same references but no DB handle.
219    /// Useful for modules that don't require database access.
220    pub fn without_db(&self) -> ModuleCtx {
221        ModuleCtx {
222            module_name: self.module_name.clone(),
223            instance_id: self.instance_id,
224            config_provider: self.config_provider.clone(),
225            client_hub: self.client_hub.clone(),
226            cancellation_token: self.cancellation_token.clone(),
227            db_handle: None,
228        }
229    }
230}
231
232#[cfg(test)]
233#[cfg_attr(coverage_nightly, coverage(off))]
234mod tests {
235    use super::*;
236    use serde::Deserialize;
237    use serde_json::json;
238    use std::collections::HashMap;
239
240    #[derive(Debug, PartialEq, Deserialize, Default)]
241    struct TestConfig {
242        #[serde(default)]
243        api_key: String,
244        #[serde(default)]
245        timeout_ms: u64,
246        #[serde(default)]
247        enabled: bool,
248    }
249
250    struct MockConfigProvider {
251        modules: HashMap<String, serde_json::Value>,
252    }
253
254    impl MockConfigProvider {
255        fn new() -> Self {
256            let mut modules = HashMap::new();
257
258            // Valid module config
259            modules.insert(
260                "test_module".to_owned(),
261                json!({
262                    "database": {
263                        "url": "postgres://localhost/test"
264                    },
265                    "config": {
266                        "api_key": "secret123",
267                        "timeout_ms": 5000,
268                        "enabled": true
269                    }
270                }),
271            );
272
273            Self { modules }
274        }
275    }
276
277    impl ConfigProvider for MockConfigProvider {
278        fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value> {
279            self.modules.get(module_name)
280        }
281    }
282
283    #[test]
284    fn test_module_ctx_config_with_valid_config() {
285        let provider = Arc::new(MockConfigProvider::new());
286        let ctx = ModuleCtx::new(
287            "test_module",
288            Uuid::new_v4(),
289            provider,
290            Arc::new(crate::client_hub::ClientHub::default()),
291            CancellationToken::new(),
292            None,
293        );
294
295        let result: Result<TestConfig, ConfigError> = ctx.config();
296        assert!(result.is_ok());
297
298        let config = result.unwrap();
299        assert_eq!(config.api_key, "secret123");
300        assert_eq!(config.timeout_ms, 5000);
301        assert!(config.enabled);
302    }
303
304    #[test]
305    fn test_module_ctx_config_returns_default_for_missing_module() {
306        let provider = Arc::new(MockConfigProvider::new());
307        let ctx = ModuleCtx::new(
308            "nonexistent_module",
309            Uuid::new_v4(),
310            provider,
311            Arc::new(crate::client_hub::ClientHub::default()),
312            CancellationToken::new(),
313            None,
314        );
315
316        let result: Result<TestConfig, ConfigError> = ctx.config();
317        assert!(result.is_ok());
318
319        let config = result.unwrap();
320        assert_eq!(config, TestConfig::default());
321    }
322
323    #[test]
324    fn test_module_ctx_instance_id() {
325        let provider = Arc::new(MockConfigProvider::new());
326        let instance_id = Uuid::new_v4();
327        let ctx = ModuleCtx::new(
328            "test_module",
329            instance_id,
330            provider,
331            Arc::new(crate::client_hub::ClientHub::default()),
332            CancellationToken::new(),
333            None,
334        );
335
336        assert_eq!(ctx.instance_id(), instance_id);
337    }
338}