Skip to main content

toolkit/
context.rs

1use serde::de::DeserializeOwned;
2use std::sync::Arc;
3use tokio_util::sync::CancellationToken;
4use toolkit_contract::runtime::config::InternalTokenProvider;
5use uuid::Uuid;
6
7// Import configuration types from the config gear
8use crate::{
9    config::{ConfigError, ConfigProvider, gear_config_or_default},
10    gear_config_required,
11};
12
13/// Database manager type, available with the `db` feature.
14#[cfg(feature = "db")]
15pub(crate) type DbManager = toolkit_db::DbManager;
16/// Database provider type, available with the `db` feature.
17#[cfg(feature = "db")]
18pub(crate) type DbProvider = toolkit_db::DBProvider<toolkit_db::DbError>;
19
20#[derive(Clone)]
21#[must_use]
22pub struct GearCtx {
23    gear_name: Arc<str>,
24    instance_id: Uuid,
25    config_provider: Arc<dyn ConfigProvider>,
26    client_hub: Arc<crate::client_hub::ClientHub>,
27    cancellation_token: CancellationToken,
28    internal_token_provider: Option<InternalTokenProvider>,
29    #[cfg(feature = "db")]
30    db: Option<DbProvider>,
31}
32
33/// Builder for creating gear-scoped contexts with resolved database handles.
34///
35/// Use [`GearContextBuilder::with_db_manager`] (feature `db`) to attach a
36/// `DbManager` so `for_gear` can resolve a per-gear `DbHandle`.
37#[must_use]
38pub struct GearContextBuilder {
39    instance_id: Uuid,
40    config_provider: Arc<dyn ConfigProvider>,
41    client_hub: Arc<crate::client_hub::ClientHub>,
42    root_token: CancellationToken,
43    internal_token_provider: Option<InternalTokenProvider>,
44    #[cfg(feature = "db")]
45    db_manager: Option<Arc<DbManager>>, // internal only, never exposed to gears
46}
47
48impl GearContextBuilder {
49    pub fn new(
50        instance_id: Uuid,
51        config_provider: Arc<dyn ConfigProvider>,
52        client_hub: Arc<crate::client_hub::ClientHub>,
53        root_token: CancellationToken,
54    ) -> Self {
55        Self {
56            instance_id,
57            config_provider,
58            client_hub,
59            root_token,
60            internal_token_provider: None,
61            #[cfg(feature = "db")]
62            db_manager: None,
63        }
64    }
65
66    /// Set the process-wide platform-plane credential source applied to every
67    /// built [`GearCtx`]. See [`GearCtx::internal_token_provider`].
68    pub fn with_internal_token_provider(mut self, provider: Option<InternalTokenProvider>) -> Self {
69        self.internal_token_provider = provider;
70        self
71    }
72
73    /// Attach a `DbManager` used by [`for_gear`](Self::for_gear) to resolve
74    /// per-gear database handles.
75    #[cfg(feature = "db")]
76    pub fn with_db_manager(mut self, db_manager: Arc<DbManager>) -> Self {
77        self.db_manager = Some(db_manager);
78        self
79    }
80
81    /// Returns the process-level instance ID.
82    #[must_use]
83    pub fn instance_id(&self) -> Uuid {
84        self.instance_id
85    }
86
87    /// The process-wide platform-plane credential source, if any. Used by the
88    /// runtime proxy-wiring phase to thread the credential into
89    /// directory-resolving (`#[toolkit::consumes]`) clients.
90    #[must_use]
91    pub(crate) fn internal_token_provider(&self) -> Option<&InternalTokenProvider> {
92        self.internal_token_provider.as_ref()
93    }
94
95    /// Build a gear-scoped context, resolving the `DbHandle` for the given
96    /// gear when the `db` feature is enabled.
97    ///
98    /// Kept `async` in both configurations so callers don't need cfg branches
99    /// around `.await`; under `not(feature = "db")` the future is ready on
100    /// first poll.
101    ///
102    /// # Errors
103    /// Returns an error if database resolution fails.
104    #[cfg_attr(not(feature = "db"), allow(clippy::unused_async))]
105    pub async fn for_gear(&self, gear_name: &str) -> anyhow::Result<GearCtx> {
106        let ctx = GearCtx::new(
107            Arc::<str>::from(gear_name),
108            self.instance_id,
109            self.config_provider.clone(),
110            self.client_hub.clone(),
111            self.root_token.child_token(),
112        )
113        .with_internal_token_provider(self.internal_token_provider.clone());
114        #[cfg(feature = "db")]
115        let ctx = if let Some(mgr) = &self.db_manager
116            && let Some(handle) = mgr.get(gear_name).await?
117        {
118            ctx.with_db(toolkit_db::DBProvider::new(handle))
119        } else {
120            ctx
121        };
122        Ok(ctx)
123    }
124}
125
126impl GearCtx {
127    /// Create a new gear-scoped context with all required fields.
128    ///
129    /// Attach a database entrypoint with [`with_db`](Self::with_db) (feature `db`).
130    pub fn new(
131        gear_name: impl Into<Arc<str>>,
132        instance_id: Uuid,
133        config_provider: Arc<dyn ConfigProvider>,
134        client_hub: Arc<crate::client_hub::ClientHub>,
135        cancellation_token: CancellationToken,
136    ) -> Self {
137        Self {
138            gear_name: gear_name.into(),
139            instance_id,
140            config_provider,
141            client_hub,
142            cancellation_token,
143            internal_token_provider: None,
144            #[cfg(feature = "db")]
145            db: None,
146        }
147    }
148
149    /// Attach the process-wide platform-plane credential source.
150    ///
151    /// **Wiring-only** (`#[doc(hidden)]`): set by [`GearContextBuilder::for_gear`]
152    /// from the bootstrap-selected `InternalCredential`, not part of the public
153    /// gear-facing API. See [`GearCtx::internal_token_provider`] for why the
154    /// credential is kept off the gear API.
155    #[doc(hidden)]
156    pub fn with_internal_token_provider(mut self, provider: Option<InternalTokenProvider>) -> Self {
157        self.internal_token_provider = provider;
158        self
159    }
160
161    /// The process-wide platform-plane credential source, if any.
162    ///
163    /// **Wiring-only.** This is `#[doc(hidden)]` and intended solely for the
164    /// `#[toolkit::provides]`-generated wiring, which moves it onto a generated
165    /// client's [`ClientConfig`](toolkit_contract::runtime::config::ClientConfig)
166    /// so platform-plane methods attach `X-ToolKit-Internal-Token`. It is **not**
167    /// part of the public gear-facing API: the two-plane design injects the
168    /// platform credential *below* the contract layer, and gear/plugin code must
169    /// not read it — `InternalTokenProvider::current()` yields the rotating
170    /// process credential that grants access to every system service, so exposing
171    /// it to arbitrary co-hosted (incl. third-party) gears would let them forge
172    /// platform-plane requests (`cpt-cf-adr-two-plane-auth`).
173    ///
174    /// `None` (Profile 1 / in-process) means platform-plane calls attach no
175    /// credential.
176    #[doc(hidden)]
177    #[must_use]
178    pub fn internal_token_provider(&self) -> Option<InternalTokenProvider> {
179        self.internal_token_provider.clone()
180    }
181
182    /// Attach the per-gear database entrypoint.
183    #[cfg(feature = "db")]
184    pub fn with_db(mut self, db: DbProvider) -> Self {
185        self.db = Some(db);
186        self
187    }
188
189    // ---- public read-only API for gears ----
190
191    #[inline]
192    #[must_use]
193    pub fn gear_name(&self) -> &str {
194        &self.gear_name
195    }
196
197    /// Returns the process-level instance ID.
198    ///
199    /// This is a unique identifier for this process instance, shared by all gears
200    /// in the same process. It is generated once at bootstrap.
201    #[inline]
202    #[must_use]
203    pub fn instance_id(&self) -> Uuid {
204        self.instance_id
205    }
206
207    #[inline]
208    #[must_use]
209    pub fn config_provider(&self) -> &dyn ConfigProvider {
210        &*self.config_provider
211    }
212
213    /// Get the `ClientHub` for dependency resolution.
214    #[inline]
215    #[must_use]
216    pub fn client_hub(&self) -> Arc<crate::client_hub::ClientHub> {
217        self.client_hub.clone()
218    }
219
220    #[inline]
221    #[must_use]
222    pub fn cancellation_token(&self) -> &CancellationToken {
223        &self.cancellation_token
224    }
225
226    /// Get a gear-scoped DB entrypoint for secure database operations.
227    ///
228    /// Returns `None` if no database is configured for this gear.
229    ///
230    /// # Security
231    ///
232    /// The returned `DBProvider<toolkit_db::DbError>`:
233    /// - Is cheap to clone (shares an internal `Db`)
234    /// - Provides `conn()` for non-transactional access (fails inside tx via guard)
235    /// - Provides `transaction(..)` for transactional operations
236    ///
237    /// # Example
238    ///
239    /// ```ignore
240    /// let db = ctx.db().ok_or_else(|| anyhow!("no db"))?;
241    /// let conn = db.conn()?;
242    /// let user = svc.get_user(&conn, &scope, id).await?;
243    /// ```
244    #[must_use]
245    #[cfg(feature = "db")]
246    pub fn db(&self) -> Option<toolkit_db::DBProvider<toolkit_db::DbError>> {
247        self.db.clone()
248    }
249
250    /// Get a database handle, returning an error if not configured.
251    ///
252    /// This is a convenience method that combines `db()` with an error for
253    /// gears that require database access.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the database is not configured for this gear.
258    ///
259    /// # Example
260    ///
261    /// ```ignore
262    /// let db = ctx.db_required()?;
263    /// let conn = db.conn()?;
264    /// let user = svc.get_user(&conn, &scope, id).await?;
265    /// ```
266    #[cfg(feature = "db")]
267    pub fn db_required(&self) -> anyhow::Result<toolkit_db::DBProvider<toolkit_db::DbError>> {
268        self.db().ok_or_else(|| {
269            anyhow::anyhow!("Database is not configured for gear '{}'", self.gear_name)
270        })
271    }
272
273    /// Deserialize the gear's config section into `T`.
274    ///
275    /// This reads the `gears.<name>.config` object for the current gear and
276    /// deserializes it into the requested type.
277    ///
278    /// # Errors
279    /// Returns `ConfigError` if the gear config is missing or deserialization fails.
280    pub fn config<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
281        gear_config_required(self.config_provider.as_ref(), &self.gear_name)
282    }
283
284    /// Deserialize the gear's config section into T, or use defaults if missing.
285    ///
286    /// This method uses lenient configuration loading: if the gear is not present in config,
287    /// has no config section, or the gear entry is not an object, it returns `T::default()`.
288    /// This allows gears to exist without configuration sections in the main config file.
289    ///
290    /// It extracts the 'config' field from: `gears.<name> = { database: ..., config: ... }`
291    ///
292    /// # Example
293    ///
294    /// ```rust,ignore
295    /// #[derive(serde::Deserialize, Default)]
296    /// struct MyConfig {
297    ///     api_key: String,
298    ///     timeout_ms: u64,
299    /// }
300    ///
301    /// let config: MyConfig = ctx.config_or_default()?;
302    /// ```
303    ///
304    /// # Errors
305    /// Returns `ConfigError` if deserialization fails.
306    pub fn config_or_default<T: DeserializeOwned + Default>(&self) -> Result<T, ConfigError> {
307        gear_config_or_default(self.config_provider.as_ref(), &self.gear_name)
308    }
309
310    /// Like [`config()`](Self::config), but additionally expands `${VAR}` placeholders
311    /// in fields marked with `#[expand_vars]`.
312    ///
313    /// # Errors
314    /// Returns `ConfigError` if the gear config is missing, deserialization fails,
315    /// or environment variable expansion fails.
316    pub fn config_expanded<T>(&self) -> Result<T, ConfigError>
317    where
318        T: DeserializeOwned + crate::var_expand::ExpandVars,
319    {
320        let mut cfg: T = self.config()?;
321        cfg.expand_vars().map_err(|e| ConfigError::VarExpand {
322            gear: self.gear_name.to_string(),
323            cause: e,
324        })?;
325        Ok(cfg)
326    }
327
328    /// Like [`config_or_default()`](Self::config_or_default), but additionally expands `${VAR}`
329    /// placeholders
330    /// in fields marked with `#[expand_vars]` (requires `#[derive(ExpandVars)]` on the config
331    /// struct).
332    ///
333    /// Gears that do not need environment variable expansion should use
334    /// [`config_or_default()`](Self::config_or_default).
335    ///
336    /// # Example
337    ///
338    /// ```rust,ignore
339    /// #[derive(serde::Deserialize, Default, ExpandVars)]
340    /// struct MyConfig {
341    ///     #[expand_vars]
342    ///     api_key: String,
343    ///     timeout_ms: u64,
344    /// }
345    ///
346    /// let config: MyConfig = ctx.config_expanded_or_default()?;
347    /// ```
348    ///
349    /// # Errors
350    /// Returns `ConfigError` if deserialization fails or if environment variable expansion fails.
351    pub fn config_expanded_or_default<T>(&self) -> Result<T, ConfigError>
352    where
353        T: DeserializeOwned + Default + crate::var_expand::ExpandVars,
354    {
355        let mut cfg: T = self.config_or_default()?;
356        cfg.expand_vars().map_err(|e| ConfigError::VarExpand {
357            gear: self.gear_name.to_string(),
358            cause: e,
359        })?;
360        Ok(cfg)
361    }
362
363    /// Get the raw JSON value of the gear's config section.
364    /// Returns the 'config' field from: gears.<name> = { database: ..., config: ... }
365    #[must_use]
366    pub fn raw_config(&self) -> &serde_json::Value {
367        use std::sync::LazyLock;
368
369        static EMPTY: LazyLock<serde_json::Value> =
370            LazyLock::new(|| serde_json::Value::Object(serde_json::Map::new()));
371
372        if let Some(gear_raw) = self.config_provider.get_gear_config(&self.gear_name) {
373            // Try new structure first: gears.<name> = { database: ..., config: ... }
374            if let Some(obj) = gear_raw.as_object()
375                && let Some(config_section) = obj.get("config")
376            {
377                return config_section;
378            }
379        }
380        &EMPTY
381    }
382
383    /// Create a derivative context with the same references but no DB handle.
384    /// Useful for gears that don't require database access.
385    pub fn without_db(&self) -> GearCtx {
386        GearCtx {
387            gear_name: self.gear_name.clone(),
388            instance_id: self.instance_id,
389            config_provider: self.config_provider.clone(),
390            client_hub: self.client_hub.clone(),
391            cancellation_token: self.cancellation_token.clone(),
392            internal_token_provider: self.internal_token_provider.clone(),
393            #[cfg(feature = "db")]
394            db: None,
395        }
396    }
397}
398
399#[cfg(test)]
400#[cfg_attr(coverage_nightly, coverage(off))]
401mod tests {
402    use super::*;
403    use serde::Deserialize;
404    use serde_json::json;
405    use std::collections::HashMap;
406
407    #[derive(Debug, PartialEq, Deserialize, Default)]
408    struct TestConfig {
409        #[serde(default)]
410        api_key: String,
411        #[serde(default)]
412        timeout_ms: u64,
413        #[serde(default)]
414        enabled: bool,
415    }
416
417    struct MockConfigProvider {
418        gears: HashMap<String, serde_json::Value>,
419    }
420
421    impl MockConfigProvider {
422        fn new() -> Self {
423            let mut gears = HashMap::new();
424
425            // Valid gear config
426            gears.insert(
427                "test_gear".to_owned(),
428                json!({
429                    "database": {
430                        "url": "postgres://localhost/test"
431                    },
432                    "config": {
433                        "api_key": "secret123",
434                        "timeout_ms": 5000,
435                        "enabled": true
436                    }
437                }),
438            );
439
440            Self { gears }
441        }
442    }
443
444    impl ConfigProvider for MockConfigProvider {
445        fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value> {
446            self.gears.get(gear_name)
447        }
448    }
449
450    #[test]
451    fn test_gear_ctx_config_with_valid_config() {
452        let provider = Arc::new(MockConfigProvider::new());
453        let ctx = GearCtx::new(
454            "test_gear",
455            Uuid::new_v4(),
456            provider,
457            Arc::new(crate::client_hub::ClientHub::default()),
458            CancellationToken::new(),
459        );
460
461        let result: Result<TestConfig, ConfigError> = ctx.config();
462        assert!(result.is_ok());
463
464        let config = result.unwrap();
465        assert_eq!(config.api_key, "secret123");
466        assert_eq!(config.timeout_ms, 5000);
467        assert!(config.enabled);
468    }
469
470    #[test]
471    fn test_gear_ctx_config_returns_error_for_missing_gear() {
472        let provider = Arc::new(MockConfigProvider::new());
473        let ctx = GearCtx::new(
474            "nonexistent_gear",
475            Uuid::new_v4(),
476            provider,
477            Arc::new(crate::client_hub::ClientHub::default()),
478            CancellationToken::new(),
479        );
480
481        let result: Result<TestConfig, ConfigError> = ctx.config();
482        assert!(matches!(
483            result,
484            Err(ConfigError::GearNotFound { ref gear }) if gear == "nonexistent_gear"
485        ));
486    }
487
488    #[test]
489    fn test_gear_ctx_config_or_default_returns_default_for_missing_gear() {
490        let provider = Arc::new(MockConfigProvider::new());
491        let ctx = GearCtx::new(
492            "nonexistent_gear",
493            Uuid::new_v4(),
494            provider,
495            Arc::new(crate::client_hub::ClientHub::default()),
496            CancellationToken::new(),
497        );
498
499        let result: Result<TestConfig, ConfigError> = ctx.config_or_default();
500        assert!(result.is_ok());
501
502        let config = result.unwrap();
503        assert_eq!(config, TestConfig::default());
504    }
505
506    #[tokio::test]
507    async fn internal_token_provider_flows_from_builder_to_ctx() {
508        use std::sync::atomic::{AtomicBool, Ordering};
509
510        // The builder's provider must be threaded onto every `GearCtx` it
511        // builds, and `GearCtx::internal_token_provider()` must return the SAME
512        // provider (proven by observing the closure fire on `.current()`).
513        let invoked = Arc::new(AtomicBool::new(false));
514        let invoked_in_closure = Arc::clone(&invoked);
515        let provider = InternalTokenProvider::new(move || {
516            invoked_in_closure.store(true, Ordering::SeqCst);
517            toolkit_contract::runtime::config::CredentialState::NotConfigured
518        });
519
520        let builder = GearContextBuilder::new(
521            Uuid::new_v4(),
522            Arc::new(MockConfigProvider::new()),
523            Arc::new(crate::client_hub::ClientHub::default()),
524            CancellationToken::new(),
525        )
526        .with_internal_token_provider(Some(provider));
527
528        let ctx = builder.for_gear("test_gear").await.unwrap();
529        let threaded = ctx.internal_token_provider();
530        assert!(threaded.is_some(), "provider must be threaded onto GearCtx");
531
532        // Invoking it runs the original closure — confirming identity, not a
533        // freshly-defaulted provider. The configured closure yields no token.
534        assert!(matches!(
535            threaded.unwrap().current(),
536            toolkit_contract::runtime::config::CredentialState::NotConfigured
537        ));
538        assert!(
539            invoked.load(Ordering::SeqCst),
540            "the threaded provider must be the one configured on the builder"
541        );
542
543        // A builder without a provider yields a context with none (Profile 1).
544        let bare = GearContextBuilder::new(
545            Uuid::new_v4(),
546            Arc::new(MockConfigProvider::new()),
547            Arc::new(crate::client_hub::ClientHub::default()),
548            CancellationToken::new(),
549        )
550        .for_gear("test_gear")
551        .await
552        .unwrap();
553        assert!(bare.internal_token_provider().is_none());
554    }
555
556    #[test]
557    fn test_gear_ctx_instance_id() {
558        let provider = Arc::new(MockConfigProvider::new());
559        let instance_id = Uuid::new_v4();
560        let ctx = GearCtx::new(
561            "test_gear",
562            instance_id,
563            provider,
564            Arc::new(crate::client_hub::ClientHub::default()),
565            CancellationToken::new(),
566        );
567
568        assert_eq!(ctx.instance_id(), instance_id);
569    }
570
571    // --- config_expanded tests ---
572
573    #[derive(Debug, PartialEq, Deserialize, Default, toolkit_macros::ExpandVars)]
574    struct ExpandableConfig {
575        #[expand_vars]
576        #[serde(default)]
577        api_key: String,
578        #[expand_vars]
579        #[serde(default)]
580        endpoint: Option<String>,
581        #[serde(default)]
582        retries: u32,
583    }
584
585    fn make_ctx(gear_name: &str, config_json: serde_json::Value) -> GearCtx {
586        let mut gears = HashMap::new();
587        gears.insert(gear_name.to_owned(), config_json);
588        let provider = Arc::new(MockConfigProvider { gears });
589        GearCtx::new(
590            gear_name,
591            Uuid::new_v4(),
592            provider,
593            Arc::new(crate::client_hub::ClientHub::default()),
594            CancellationToken::new(),
595        )
596    }
597
598    #[test]
599    fn config_expanded_resolves_env_vars() {
600        let ctx = make_ctx(
601            "expand_mod",
602            json!({
603                "config": {
604                    "api_key": "${TOOLKIT_TEST_KEY}",
605                    "endpoint": "https://${TOOLKIT_TEST_HOST}/api",
606                    "retries": 3
607                }
608            }),
609        );
610
611        temp_env::with_vars(
612            [
613                ("TOOLKIT_TEST_KEY", Some("secret-42")),
614                ("TOOLKIT_TEST_HOST", Some("example.com")),
615            ],
616            || {
617                let cfg: ExpandableConfig = ctx.config_expanded().unwrap();
618                assert_eq!(cfg.api_key, "secret-42");
619                assert_eq!(cfg.endpoint.as_deref(), Some("https://example.com/api"));
620                assert_eq!(cfg.retries, 3);
621            },
622        );
623    }
624
625    #[test]
626    fn config_expanded_returns_error_on_missing_var() {
627        let ctx = make_ctx(
628            "expand_mod",
629            json!({
630                "config": {
631                    "api_key": "${TOOLKIT_TEST_MISSING_VAR_XYZ}"
632                }
633            }),
634        );
635
636        temp_env::with_vars([("TOOLKIT_TEST_MISSING_VAR_XYZ", None::<&str>)], || {
637            let err = ctx.config_expanded::<ExpandableConfig>().unwrap_err();
638            assert!(
639                matches!(err, ConfigError::VarExpand { ref gear, .. } if gear == "expand_mod"),
640                "expected EnvExpand error, got: {err:?}"
641            );
642        });
643    }
644
645    #[test]
646    fn config_expanded_skips_none_option_fields() {
647        let ctx = make_ctx(
648            "expand_mod",
649            json!({
650                "config": {
651                    "api_key": "literal-key",
652                    "retries": 5
653                }
654            }),
655        );
656
657        let cfg: ExpandableConfig = ctx.config_expanded().unwrap();
658        assert_eq!(cfg.api_key, "literal-key");
659        assert_eq!(cfg.endpoint, None);
660        assert_eq!(cfg.retries, 5);
661    }
662
663    #[test]
664    fn config_expanded_returns_error_when_missing() {
665        let ctx = make_ctx("missing_mod", json!({}));
666        let err = ctx.config_expanded::<ExpandableConfig>().unwrap_err();
667        assert!(matches!(
668            err,
669            ConfigError::MissingConfigSection { ref gear } if gear == "missing_mod"
670        ));
671    }
672
673    #[test]
674    fn config_expanded_or_default_falls_back_to_default_when_missing() {
675        let ctx = make_ctx("missing_mod", json!({}));
676        let cfg: ExpandableConfig = ctx.config_expanded_or_default().unwrap();
677        assert_eq!(cfg, ExpandableConfig::default());
678    }
679
680    // --- nested struct expansion ---
681
682    #[derive(Debug, PartialEq, Deserialize, Default, toolkit_macros::ExpandVars)]
683    struct NestedProvider {
684        #[expand_vars]
685        #[serde(default)]
686        host: String,
687        #[expand_vars]
688        #[serde(default)]
689        token: Option<String>,
690        #[expand_vars]
691        #[serde(default)]
692        auth_config: Option<HashMap<String, String>>,
693        #[serde(default)]
694        port: u16,
695    }
696
697    #[derive(Debug, PartialEq, Deserialize, Default, toolkit_macros::ExpandVars)]
698    struct NestedConfig {
699        #[expand_vars]
700        #[serde(default)]
701        name: String,
702        #[expand_vars]
703        #[serde(default)]
704        providers: HashMap<String, NestedProvider>,
705        #[expand_vars]
706        #[serde(default)]
707        tags: Vec<String>,
708    }
709
710    #[test]
711    fn config_expanded_resolves_nested_structs() {
712        let ctx = make_ctx(
713            "nested_mod",
714            json!({
715                "config": {
716                    "name": "${TOOLKIT_NESTED_NAME}",
717                    "providers": {
718                        "primary": {
719                            "host": "${TOOLKIT_NESTED_HOST}",
720                            "token": "${TOOLKIT_NESTED_TOKEN}",
721                            "auth_config": {
722                                "header": "X-Api-Key",
723                                "secret_ref": "${TOOLKIT_NESTED_SECRET}"
724                            },
725                            "port": 443
726                        }
727                    },
728                    "tags": ["${TOOLKIT_NESTED_TAG}", "literal"]
729                }
730            }),
731        );
732
733        temp_env::with_vars(
734            [
735                ("TOOLKIT_NESTED_NAME", Some("my-service")),
736                ("TOOLKIT_NESTED_HOST", Some("api.example.com")),
737                ("TOOLKIT_NESTED_TOKEN", Some("sk-secret")),
738                ("TOOLKIT_NESTED_SECRET", Some("key-12345")),
739                ("TOOLKIT_NESTED_TAG", Some("production")),
740            ],
741            || {
742                let cfg: NestedConfig = ctx.config_expanded().unwrap();
743                assert_eq!(cfg.name, "my-service");
744                assert_eq!(cfg.tags, vec!["production", "literal"]);
745
746                let primary = cfg.providers.get("primary").expect("primary provider");
747                assert_eq!(primary.host, "api.example.com");
748                assert_eq!(primary.token.as_deref(), Some("sk-secret"));
749                assert_eq!(primary.port, 443);
750
751                let auth = primary.auth_config.as_ref().expect("auth_config present");
752                assert_eq!(auth.get("header").map(String::as_str), Some("X-Api-Key"));
753                assert_eq!(
754                    auth.get("secret_ref").map(String::as_str),
755                    Some("key-12345")
756                );
757            },
758        );
759    }
760
761    #[test]
762    fn config_expanded_nested_missing_var_returns_error() {
763        let ctx = make_ctx(
764            "nested_mod",
765            json!({
766                "config": {
767                    "name": "ok",
768                    "providers": {
769                        "bad": { "host": "${TOOLKIT_NESTED_GONE}", "port": 80 }
770                    }
771                }
772            }),
773        );
774
775        temp_env::with_vars([("TOOLKIT_NESTED_GONE", None::<&str>)], || {
776            let err = ctx.config_expanded::<NestedConfig>().unwrap_err();
777            assert!(
778                matches!(err, ConfigError::VarExpand { ref gear, .. } if gear == "nested_mod"),
779                "expected EnvExpand, got: {err:?}"
780            );
781        });
782    }
783}