greentic_config_types/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use url::Url;
5
6pub use greentic_types::{ConnectionKind, DeploymentCtx, EnvId};
7
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(transparent)]
10pub struct ConfigVersion(pub String);
11
12impl ConfigVersion {
13    pub fn v1() -> Self {
14        Self("1".to_string())
15    }
16}
17
18impl Default for ConfigVersion {
19    fn default() -> Self {
20        Self::v1()
21    }
22}
23
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25pub struct GreenticConfig {
26    #[serde(default = "ConfigVersion::v1")]
27    pub schema_version: ConfigVersion,
28    pub environment: EnvironmentConfig,
29    pub paths: PathsConfig,
30    #[serde(default)]
31    pub packs: Option<PacksConfig>,
32    #[serde(default)]
33    pub services: Option<ServicesConfig>,
34    #[serde(default)]
35    pub events: Option<EventsConfig>,
36    pub runtime: RuntimeConfig,
37    pub telemetry: TelemetryConfig,
38    pub network: NetworkConfig,
39    #[serde(default)]
40    pub deployer: Option<DeployerConfig>,
41    pub secrets: SecretsBackendRefConfig,
42    pub dev: Option<DevConfig>,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum ConfigSource {
47    Default,
48    User,
49    Project,
50    Environment,
51    Cli,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(transparent)]
56pub struct ProvenancePath(pub String);
57
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59pub struct EnvironmentConfig {
60    pub env_id: EnvId,
61    pub deployment: Option<DeploymentCtx>,
62    pub connection: Option<ConnectionKind>,
63    pub region: Option<String>,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
67pub struct PathsConfig {
68    pub greentic_root: PathBuf,
69    pub state_dir: PathBuf,
70    pub cache_dir: PathBuf,
71    pub logs_dir: PathBuf,
72}
73
74#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
75#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ServicesConfig {
77    #[serde(default)]
78    pub events: Option<ServiceEndpointConfig>,
79
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub runner: Option<ServiceDefinitionConfig>,
82
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub deployer: Option<ServiceDefinitionConfig>,
85
86    /// Transport selector for events.
87    ///
88    /// This exists alongside `services.events` for backward compatibility:
89    /// - `services.events` preserves the legacy HTTP-only endpoint shape.
90    /// - New consumers should prefer `services.events_transport`.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub events_transport: Option<ServiceDefinitionConfig>,
93
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub source: Option<ServiceDefinitionConfig>,
96
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub publish: Option<ServiceDefinitionConfig>,
99
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub store: Option<ServiceDefinitionConfig>,
102
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub metadata: Option<ServiceDefinitionConfig>,
105
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub oauth_broker: Option<ServiceDefinitionConfig>,
108}
109
110#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ServiceEndpointConfig {
113    pub url: Url,
114    #[serde(default)]
115    pub headers: Option<BTreeMap<String, String>>,
116}
117
118// --- Services transport (non-secret) ---
119
120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
121#[derive(Clone, Debug, Default, PartialEq, Eq)]
122pub struct ServiceDefinitionConfig {
123    pub transport: Option<ServiceTransportConfig>,
124    pub service: Option<ServiceConfig>,
125}
126
127impl Serialize for ServiceDefinitionConfig {
128    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
129    where
130        S: serde::Serializer,
131    {
132        if self.service.is_none() && self.transport.is_some() {
133            return self
134                .transport
135                .as_ref()
136                .expect("checked is_some")
137                .serialize(serializer);
138        }
139
140        let mut map = serde_json::Map::new();
141        if let Some(transport) = &self.transport {
142            let value = serde_json::to_value(transport).map_err(serde::ser::Error::custom)?;
143            let obj = value
144                .as_object()
145                .ok_or_else(|| serde::ser::Error::custom("expected map for transport"))?;
146            for (k, v) in obj {
147                map.insert(k.clone(), v.clone());
148            }
149        }
150        if let Some(service) = &self.service {
151            map.insert(
152                "service".to_string(),
153                serde_json::to_value(service).map_err(serde::ser::Error::custom)?,
154            );
155        }
156        map.serialize(serializer)
157    }
158}
159
160impl<'de> Deserialize<'de> for ServiceDefinitionConfig {
161    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
162    where
163        D: serde::Deserializer<'de>,
164    {
165        let value = serde_json::Value::deserialize(deserializer)?;
166
167        if let serde_json::Value::Object(mut map) = value {
168            let service = if let Some(service_value) = map.remove("service") {
169                Some(ServiceConfig::deserialize(service_value).map_err(serde::de::Error::custom)?)
170            } else {
171                None
172            };
173
174            let transport = if map.is_empty() {
175                None
176            } else {
177                let transport_value = serde_json::Value::Object(map);
178                ServiceTransportConfig::deserialize(transport_value).ok()
179            };
180
181            return Ok(ServiceDefinitionConfig { transport, service });
182        }
183
184        // Fallback to transport-only shape
185        let transport = ServiceTransportConfig::deserialize(value).ok();
186
187        Ok(ServiceDefinitionConfig {
188            transport,
189            service: None,
190        })
191    }
192}
193
194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
195#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
196#[serde(tag = "kind", rename_all = "kebab-case")]
197pub enum ServiceTransportConfig {
198    /// Explicitly disables this service integration.
199    Noop,
200
201    /// HTTP transport with a base URL and optional headers.
202    ///
203    /// Headers are strictly for non-sensitive routing/metadata only and MUST NOT include secrets
204    /// (e.g. `Authorization`, `Cookie`, `Set-Cookie`).
205    Http {
206        url: url::Url,
207        #[serde(default, skip_serializing_if = "Option::is_none")]
208        headers: Option<std::collections::BTreeMap<String, String>>,
209    },
210
211    /// NATS transport (non-secret). Auth is handled elsewhere (secrets-store), not here.
212    Nats {
213        url: url::Url,
214        #[serde(default, skip_serializing_if = "Option::is_none")]
215        subject_prefix: Option<String>,
216    },
217}
218
219#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
220#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
221pub struct ServiceConfig {
222    #[serde(default)]
223    pub bind_addr: Option<String>,
224    #[serde(default)]
225    pub port: Option<u16>,
226    #[serde(default)]
227    pub public_base_url: Option<String>,
228    #[serde(default)]
229    pub metrics: Option<MetricsConfig>,
230}
231
232#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
233#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
234pub struct MetricsConfig {
235    #[serde(default)]
236    pub enabled: Option<bool>,
237    #[serde(default)]
238    pub bind_addr: Option<String>,
239    #[serde(default)]
240    pub port: Option<u16>,
241    #[serde(default)]
242    pub path: Option<String>,
243}
244
245#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
246pub struct EventsConfig {
247    #[serde(default)]
248    pub reconnect: Option<ReconnectConfig>,
249    #[serde(default)]
250    pub backoff: Option<BackoffConfig>,
251}
252
253#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
254pub struct ReconnectConfig {
255    #[serde(default)]
256    pub enabled: Option<bool>,
257    #[serde(default)]
258    pub max_retries: Option<u32>,
259}
260
261#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
262pub struct BackoffConfig {
263    #[serde(default)]
264    pub initial_ms: Option<u64>,
265    #[serde(default)]
266    pub max_ms: Option<u64>,
267    #[serde(default)]
268    pub multiplier: Option<f64>,
269    #[serde(default)]
270    pub jitter: Option<bool>,
271}
272
273#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
274pub struct PacksConfig {
275    pub source: PackSourceConfig,
276    pub cache_dir: PathBuf,
277    #[serde(default)]
278    pub index_cache_ttl_secs: Option<u64>,
279    #[serde(default)]
280    pub trust: Option<PackTrustConfig>,
281}
282
283#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(tag = "type", rename_all = "snake_case")]
285pub enum PackSourceConfig {
286    LocalIndex { path: PathBuf },
287    HttpIndex { url: String },
288    OciRegistry { reference: String },
289}
290
291#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
292pub struct PackTrustConfig {
293    #[serde(default)]
294    pub public_keys: Vec<String>,
295    #[serde(default)]
296    pub require_signatures: bool,
297}
298
299#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
300pub struct RuntimeConfig {
301    #[serde(default)]
302    pub max_concurrency: Option<u32>,
303    #[serde(default)]
304    pub task_timeout_ms: Option<u64>,
305    #[serde(default)]
306    pub shutdown_grace_ms: Option<u64>,
307
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub admin_endpoints: Option<AdminEndpointsConfig>,
310}
311
312#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
313pub struct AdminEndpointsConfig {
314    /// Enables sensitive admin endpoints (default: false).
315    #[serde(default)]
316    pub secrets_explain_enabled: bool,
317}
318
319#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
320pub struct TelemetryConfig {
321    #[serde(default = "default_enabled")]
322    pub enabled: bool,
323    #[serde(default)]
324    pub exporter: TelemetryExporterKind,
325    #[serde(default)]
326    pub endpoint: Option<String>,
327    #[serde(default = "default_sampling")]
328    pub sampling: f32,
329}
330
331impl Default for TelemetryConfig {
332    fn default() -> Self {
333        Self {
334            enabled: default_enabled(),
335            exporter: TelemetryExporterKind::None,
336            endpoint: None,
337            sampling: default_sampling(),
338        }
339    }
340}
341
342fn default_enabled() -> bool {
343    true
344}
345
346fn default_sampling() -> f32 {
347    1.0
348}
349
350#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
351#[serde(rename_all = "snake_case")]
352pub enum TelemetryExporterKind {
353    Otlp,
354    Stdout,
355    #[default]
356    None,
357}
358
359#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
360pub struct NetworkConfig {
361    #[serde(default)]
362    pub proxy_url: Option<String>,
363    #[serde(default)]
364    pub tls_mode: TlsMode,
365    #[serde(default)]
366    pub connect_timeout_ms: Option<u64>,
367    #[serde(default)]
368    pub read_timeout_ms: Option<u64>,
369}
370
371impl Default for NetworkConfig {
372    fn default() -> Self {
373        Self {
374            proxy_url: None,
375            tls_mode: TlsMode::System,
376            connect_timeout_ms: None,
377            read_timeout_ms: None,
378        }
379    }
380}
381
382#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
383#[serde(rename_all = "snake_case")]
384pub enum TlsMode {
385    Disabled,
386    #[default]
387    System,
388    Strict,
389}
390
391#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
392pub struct DeployerConfig {
393    /// Default domain used when generating deployment URLs / routing domains.
394    #[serde(default)]
395    pub base_domain: Option<String>,
396    #[serde(default)]
397    pub provider: Option<DeployerProviderDefaults>,
398}
399
400#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
401pub struct DeployerProviderDefaults {
402    #[serde(default)]
403    pub provider_kind: Option<String>,
404    #[serde(default)]
405    pub region: Option<String>,
406}
407
408#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
409pub struct SecretsBackendRefConfig {
410    #[serde(default = "default_backend_kind")]
411    pub kind: String,
412    #[serde(default)]
413    pub reference: Option<String>,
414}
415
416impl Default for SecretsBackendRefConfig {
417    fn default() -> Self {
418        Self {
419            kind: default_backend_kind(),
420            reference: None,
421        }
422    }
423}
424
425fn default_backend_kind() -> String {
426    "none".to_string()
427}
428
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
430pub struct DevConfig {
431    pub default_env: EnvId,
432    pub default_tenant: String,
433    #[serde(default)]
434    pub default_team: Option<String>,
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use url::Url;
441
442    fn sample_toml() -> &'static str {
443        r#"
444schema_version = "1"
445
446[environment]
447env_id = "dev"
448region = "us-east-1"
449
450[paths]
451greentic_root = "/workspace"
452state_dir = "/workspace/.greentic"
453cache_dir = "/workspace/.greentic/cache"
454logs_dir = "/workspace/.greentic/logs"
455
456[services.runner]
457kind = "http"
458url = "https://runner.greentic.local"
459headers = { "x-routing-key" = "tenant-1" }
460
461[services.runner.service]
462bind_addr = "0.0.0.0"
463port = 8080
464public_base_url = "https://runner-public.greentic.local"
465
466[services.runner.service.metrics]
467enabled = true
468bind_addr = "127.0.0.1"
469port = 9090
470path = "/metrics"
471
472[services.deployer]
473kind = "nats"
474url = "nats://nats.greentic.local:4222"
475subject_prefix = "greentic"
476
477[services.metadata]
478kind = "noop"
479
480[services.events]
481url = "https://events.greentic.local"
482headers = { "x-routing-key" = "tenant-1" }
483
484[events.reconnect]
485enabled = true
486max_retries = 25
487
488[events.backoff]
489initial_ms = 250
490max_ms = 30000
491multiplier = 2.0
492jitter = true
493
494[packs]
495cache_dir = "/workspace/.greentic/cache/packs"
496index_cache_ttl_secs = 3600
497
498[packs.source]
499type = "local_index"
500path = "/workspace/.greentic/packs/index.json"
501
502[packs.trust]
503public_keys = ["/keys/key1.pem", "/keys/key2.pem"]
504require_signatures = true
505
506[runtime]
507max_concurrency = 8
508task_timeout_ms = 30000
509shutdown_grace_ms = 5000
510
511[runtime.admin_endpoints]
512secrets_explain_enabled = true
513
514[telemetry]
515enabled = true
516exporter = "otlp"
517endpoint = "http://localhost:4317"
518sampling = 0.5
519
520[network]
521proxy_url = "http://proxy"
522tls_mode = "system"
523connect_timeout_ms = 1000
524read_timeout_ms = 2000
525
526[deployer]
527base_domain = "deploy.greentic.ai"
528
529[deployer.provider]
530provider_kind = "aws"
531region = "us-west-2"
532
533[secrets]
534kind = "vault"
535reference = "ops"
536
537[dev]
538default_env = "dev"
539default_tenant = "acme"
540default_team = "devex"
541"#
542    }
543
544    #[test]
545    fn toml_round_trip() {
546        let config: GreenticConfig = toml::from_str(sample_toml()).expect("deserialize");
547        let serialized = toml::to_string(&config).expect("serialize");
548        let config_back: GreenticConfig = toml::from_str(&serialized).expect("deserialize again");
549        assert_eq!(config, config_back);
550
551        let runner = config_back
552            .services
553            .as_ref()
554            .and_then(|s| s.runner.as_ref())
555            .expect("runner present");
556        let service = runner.service.as_ref().expect("service binding present");
557        assert_eq!(service.bind_addr.as_deref(), Some("0.0.0.0"));
558        assert_eq!(service.port, Some(8080));
559        assert_eq!(
560            service.public_base_url.as_deref(),
561            Some("https://runner-public.greentic.local")
562        );
563        let metrics = service.metrics.as_ref().expect("metrics present");
564        assert_eq!(metrics.enabled, Some(true));
565        assert_eq!(metrics.bind_addr.as_deref(), Some("127.0.0.1"));
566        assert_eq!(metrics.port, Some(9090));
567        assert_eq!(metrics.path.as_deref(), Some("/metrics"));
568        assert!(
569            config_back
570                .services
571                .as_ref()
572                .and_then(|s| s.store.as_ref())
573                .is_none(),
574            "store should remain absent when omitted"
575        );
576    }
577
578    #[test]
579    fn json_round_trip() {
580        let config: GreenticConfig = serde_json::from_str(
581            r#"{
582            "schema_version": "1",
583            "environment": {"env_id": "dev", "region": "us-west-2"},
584            "paths": {
585                "greentic_root": "/workspace",
586                "state_dir": "/workspace/.greentic",
587                "cache_dir": "/workspace/.greentic/cache",
588                "logs_dir": "/workspace/.greentic/logs"
589            },
590            "services": {
591                "events": {
592                    "url": "https://events.greentic.local",
593                    "headers": {"x-routing-key": "tenant-1"}
594                },
595                "runner": {
596                    "kind": "http",
597                    "url": "https://runner.greentic.local",
598                    "headers": {"x-routing-key": "tenant-1"},
599                    "service": {
600                        "bind_addr": "0.0.0.0",
601                        "port": 8080,
602                        "public_base_url": "https://runner-public.greentic.local",
603                        "metrics": {
604                            "enabled": true,
605                            "bind_addr": "127.0.0.1",
606                            "port": 9090,
607                            "path": "/metrics"
608                        }
609                    }
610                },
611                "deployer": {"kind": "nats", "url": "nats://nats.greentic.local:4222", "subject_prefix": "greentic"},
612                "store": {
613                    "kind": "http",
614                    "url": "https://store.greentic.local",
615                    "service": {
616                        "bind_addr": "0.0.0.0",
617                        "port": 7070,
618                        "metrics": {"enabled": true, "port": 9191}
619                    }
620                },
621                "metadata": {"kind": "noop"}
622            },
623            "events": {
624                "reconnect": {"enabled": true, "max_retries": 10},
625                "backoff": {"initial_ms": 100, "max_ms": 5000, "multiplier": 1.5, "jitter": false}
626            },
627            "packs": {
628                "cache_dir": "/workspace/.greentic/cache/packs",
629                "index_cache_ttl_secs": 3600,
630                "source": {"type": "http_index", "url": "https://example.com/index.json"},
631                "trust": {"public_keys": ["inline-key", "/keys/key.pem"], "require_signatures": true}
632            },
633            "runtime": {"max_concurrency": 4, "task_timeout_ms": 120000, "shutdown_grace_ms": 1000, "admin_endpoints": {"secrets_explain_enabled": true}},
634            "telemetry": {"enabled": true, "exporter": "stdout", "sampling": 1.0},
635            "network": {"tls_mode": "system"},
636            "deployer": {"base_domain": "deploy.greentic.ai", "provider": {"provider_kind": "gcp", "region": "europe-west1"}},
637            "secrets": {"kind": "none"},
638            "dev": {"default_env": "dev", "default_tenant": "acme"}
639        }"#,
640        )
641        .expect("json decode");
642
643        let serialized = serde_json::to_string(&config).expect("json encode");
644        let round: GreenticConfig = serde_json::from_str(&serialized).expect("json decode round");
645        assert_eq!(config, round);
646
647        let runner = round
648            .services
649            .as_ref()
650            .and_then(|s| s.runner.as_ref())
651            .expect("runner present");
652        let service = runner.service.as_ref().expect("service binding present");
653        assert_eq!(service.port, Some(8080));
654
655        let store = round
656            .services
657            .as_ref()
658            .and_then(|s| s.store.as_ref())
659            .expect("store present");
660        let store_transport = store.transport.as_ref().expect("store transport present");
661        match store_transport {
662            ServiceTransportConfig::Http { url, .. } => {
663                assert_eq!(url.as_str(), "https://store.greentic.local/")
664            }
665            other => panic!("unexpected transport {other:?}"),
666        }
667        let store_service = store.service.as_ref().expect("store binding present");
668        assert_eq!(store_service.port, Some(7070));
669        let store_metrics = store_service
670            .metrics
671            .as_ref()
672            .expect("store metrics present");
673        assert_eq!(store_metrics.port, Some(9191));
674    }
675
676    #[test]
677    fn store_service_round_trips_with_bindings() {
678        let services: ServicesConfig = toml::from_str(
679            r#"
680[events]
681url = "https://events.greentic.local"
682
683[store]
684kind = "http"
685url = "https://store.greentic.local"
686
687[store.service]
688bind_addr = "0.0.0.0"
689port = 7070
690public_base_url = "https://store-public.greentic.local"
691
692[store.service.metrics]
693enabled = true
694bind_addr = "127.0.0.1"
695port = 9191
696path = "/metrics"
697"#,
698        )
699        .expect("deserialize store service");
700
701        let serialized = toml::to_string(&services).expect("serialize store");
702        let round: ServicesConfig = toml::from_str(&serialized).expect("deserialize store");
703
704        let store = round.store.expect("store config present");
705        let transport = store.transport.expect("transport present");
706        match transport {
707            ServiceTransportConfig::Http { url, .. } => {
708                assert_eq!(url.as_str(), "https://store.greentic.local/")
709            }
710            other => panic!("unexpected transport {other:?}"),
711        }
712
713        let service = store.service.expect("service present");
714        assert_eq!(service.bind_addr.as_deref(), Some("0.0.0.0"));
715        assert_eq!(service.port, Some(7070));
716        assert_eq!(
717            service.public_base_url.as_deref(),
718            Some("https://store-public.greentic.local")
719        );
720
721        let metrics = service.metrics.expect("metrics present");
722        assert_eq!(metrics.enabled, Some(true));
723        assert_eq!(metrics.bind_addr.as_deref(), Some("127.0.0.1"));
724        assert_eq!(metrics.port, Some(9191));
725        assert_eq!(metrics.path.as_deref(), Some("/metrics"));
726    }
727
728    #[test]
729    fn service_definition_accepts_transport_only_shape() {
730        let services: ServicesConfig = toml::from_str(
731            r#"
732[runner]
733kind = "http"
734url = "https://runner.greentic.local"
735            "#,
736        )
737        .expect("deserialize runner");
738
739        let runner = services.runner.expect("runner");
740        assert!(runner.service.is_none());
741        let transport = runner.transport.expect("transport");
742        match transport {
743            ServiceTransportConfig::Http { url, .. } => {
744                assert_eq!(url.as_str(), "https://runner.greentic.local/")
745            }
746            other => panic!("unexpected variant {other:?}"),
747        }
748    }
749
750    #[test]
751    fn services_and_events_are_schema_only() {
752        let endpoint = ServiceEndpointConfig {
753            url: Url::parse("https://events.greentic.local").unwrap(),
754            headers: None,
755        };
756        let services = ServicesConfig {
757            events: Some(endpoint),
758            ..Default::default()
759        };
760        let events = EventsConfig {
761            reconnect: Some(ReconnectConfig {
762                enabled: Some(true),
763                max_retries: Some(5),
764            }),
765            backoff: Some(BackoffConfig {
766                initial_ms: Some(100),
767                max_ms: Some(1000),
768                multiplier: Some(2.0),
769                jitter: Some(true),
770            }),
771        };
772
773        let serialized = toml::to_string(&services).expect("serialize services");
774        let services_back: ServicesConfig =
775            toml::from_str(&serialized).expect("deserialize services");
776        assert_eq!(
777            services_back.events.unwrap().url.as_str(),
778            "https://events.greentic.local/"
779        );
780
781        let serialized_events = serde_json::to_string(&events).expect("serialize events");
782        let events_back: EventsConfig =
783            serde_json::from_str(&serialized_events).expect("deserialize events");
784        assert_eq!(events_back.backoff.unwrap().initial_ms, Some(100));
785    }
786
787    #[test]
788    fn backward_compat_services_events_still_deserializes() {
789        let legacy_services: ServicesConfig = toml::from_str(
790            r#"
791[events]
792url = "https://events.greentic.local"
793headers = { "x-routing-key" = "tenant-1" }
794"#,
795        )
796        .expect("deserialize legacy services.events shape");
797        assert_eq!(
798            legacy_services.events.unwrap().url.as_str(),
799            "https://events.greentic.local/"
800        );
801        assert!(legacy_services.events_transport.is_none());
802        assert!(legacy_services.runner.is_none());
803        assert!(legacy_services.deployer.is_none());
804        assert!(legacy_services.metadata.is_none());
805    }
806
807    #[test]
808    fn service_transport_config_serializes_with_kind_tags() {
809        let noop = ServiceTransportConfig::Noop;
810        let http = ServiceTransportConfig::Http {
811            url: Url::parse("https://runner.greentic.local").unwrap(),
812            headers: None,
813        };
814        let nats = ServiceTransportConfig::Nats {
815            url: Url::parse("nats://nats.greentic.local:4222").unwrap(),
816            subject_prefix: Some("greentic".to_string()),
817        };
818
819        let noop_v = serde_json::to_value(&noop).expect("json");
820        assert_eq!(noop_v, serde_json::json!({"kind": "noop"}));
821
822        let http_v = serde_json::to_value(&http).expect("json");
823        assert_eq!(
824            http_v,
825            serde_json::json!({"kind": "http", "url": "https://runner.greentic.local/"})
826        );
827
828        let nats_v = serde_json::to_value(&nats).expect("json");
829        assert_eq!(
830            nats_v,
831            serde_json::json!({"kind": "nats", "url": "nats://nats.greentic.local:4222", "subject_prefix": "greentic"})
832        );
833    }
834}