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