1use serde::de::DeserializeOwned;
2use std::sync::Arc;
3use tokio_util::sync::CancellationToken;
4use toolkit_contract::runtime::config::InternalTokenProvider;
5use uuid::Uuid;
6
7use 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#[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>>, }
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 pub fn with_internal_token_provider(mut self, provider: Option<InternalTokenProvider>) -> Self {
67 self.internal_token_provider = provider;
68 self
69 }
70
71 #[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 #[must_use]
81 pub fn instance_id(&self) -> Uuid {
82 self.instance_id
83 }
84
85 #[must_use]
89 pub(crate) fn internal_token_provider(&self) -> Option<&InternalTokenProvider> {
90 self.internal_token_provider.as_ref()
91 }
92
93 #[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 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 #[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 #[doc(hidden)]
175 #[must_use]
176 pub fn internal_token_provider(&self) -> Option<InternalTokenProvider> {
177 self.internal_token_provider.clone()
178 }
179
180 #[cfg(feature = "db")]
182 pub fn with_db(mut self, db: DbProvider) -> Self {
183 self.db = Some(db);
184 self
185 }
186
187 #[inline]
190 #[must_use]
191 pub fn gear_name(&self) -> &str {
192 &self.gear_name
193 }
194
195 #[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 #[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 #[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 #[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 pub fn config<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
279 gear_config_required(self.config_provider.as_ref(), &self.gear_name)
280 }
281
282 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 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 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 #[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 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 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 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 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 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 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 #[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 #[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}