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")]
15pub(crate) type DbManager = toolkit_db::DbManager;
16#[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#[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>>, }
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 pub fn with_internal_token_provider(mut self, provider: Option<InternalTokenProvider>) -> Self {
69 self.internal_token_provider = provider;
70 self
71 }
72
73 #[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 #[must_use]
83 pub fn instance_id(&self) -> Uuid {
84 self.instance_id
85 }
86
87 #[must_use]
91 pub(crate) fn internal_token_provider(&self) -> Option<&InternalTokenProvider> {
92 self.internal_token_provider.as_ref()
93 }
94
95 #[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 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 #[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 #[doc(hidden)]
177 #[must_use]
178 pub fn internal_token_provider(&self) -> Option<InternalTokenProvider> {
179 self.internal_token_provider.clone()
180 }
181
182 #[cfg(feature = "db")]
184 pub fn with_db(mut self, db: DbProvider) -> Self {
185 self.db = Some(db);
186 self
187 }
188
189 #[inline]
192 #[must_use]
193 pub fn gear_name(&self) -> &str {
194 &self.gear_name
195 }
196
197 #[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 #[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 #[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 #[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 pub fn config<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
281 gear_config_required(self.config_provider.as_ref(), &self.gear_name)
282 }
283
284 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 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 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 #[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 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 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 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 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 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 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 #[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 #[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}