1mod explain;
11mod loaders;
12mod merge;
13mod paths;
14mod validate;
15
16use greentic_config_types::{ConfigSource, GreenticConfig, ProvenancePath};
17use std::collections::HashMap;
18use std::path::PathBuf;
19
20pub use explain::{ExplainReport, explain, explain_detailed};
21pub use loaders::{ConfigFileFormat, ConfigLayer};
22pub use paths::{DefaultPaths, discover_project_root};
23pub use validate::{ValidationError, validate_config, validate_config_with_overrides};
24
25pub type ProvenanceMap = HashMap<ProvenancePath, ConfigSource>;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ProvenanceRecord {
29 pub source: ConfigSource,
30 pub origin: Option<String>,
31}
32
33pub type ProvenanceMapDetailed = HashMap<ProvenancePath, ProvenanceRecord>;
34
35#[derive(Debug, Clone)]
36pub struct ResolvedConfig {
37 pub config: GreenticConfig,
38 pub provenance: ProvenanceMap,
39 pub warnings: Vec<String>,
40}
41
42impl ResolvedConfig {
43 pub fn explain(&self) -> ExplainReport {
44 explain(&self.config, &self.provenance, &self.warnings)
45 }
46}
47
48#[derive(Debug, Clone)]
49pub struct ResolvedConfigDetailed {
50 pub config: GreenticConfig,
51 pub provenance: ProvenanceMapDetailed,
52 pub warnings: Vec<String>,
53}
54
55impl ResolvedConfigDetailed {
56 pub fn explain(&self) -> ExplainReport {
57 explain::explain_detailed(&self.config, &self.provenance, &self.warnings)
58 }
59}
60
61#[derive(Debug, Clone, Default)]
62pub struct CliOverrides {
63 layer: ConfigLayer,
64}
65
66impl CliOverrides {
67 pub fn new() -> Self {
68 Self::default()
69 }
70
71 pub fn with_env_id(mut self, env_id: greentic_config_types::EnvId) -> Self {
72 self.layer
73 .environment
74 .get_or_insert_with(Default::default)
75 .env_id = Some(env_id);
76 self
77 }
78
79 pub fn with_connection(mut self, connection: greentic_config_types::ConnectionKind) -> Self {
80 self.layer
81 .environment
82 .get_or_insert_with(Default::default)
83 .connection = Some(connection);
84 self
85 }
86
87 pub fn with_region(mut self, region: impl Into<String>) -> Self {
88 self.layer
89 .environment
90 .get_or_insert_with(Default::default)
91 .region = Some(region.into());
92 self
93 }
94
95 pub fn with_services_events_url(mut self, url: url::Url) -> Self {
96 self.layer
97 .services
98 .get_or_insert_with(Default::default)
99 .events
100 .get_or_insert_with(Default::default)
101 .url = Some(url);
102 self
103 }
104
105 pub fn with_services_runner_transport(
106 mut self,
107 transport: greentic_config_types::ServiceTransportConfig,
108 ) -> Self {
109 set_transport_layer(
110 &mut self
111 .layer
112 .services
113 .get_or_insert_with(Default::default)
114 .runner,
115 transport_to_layer(transport),
116 );
117 self
118 }
119
120 pub fn with_services_deployer_transport(
121 mut self,
122 transport: greentic_config_types::ServiceTransportConfig,
123 ) -> Self {
124 set_transport_layer(
125 &mut self
126 .layer
127 .services
128 .get_or_insert_with(Default::default)
129 .deployer,
130 transport_to_layer(transport),
131 );
132 self
133 }
134
135 pub fn with_services_events_transport(
136 mut self,
137 transport: greentic_config_types::ServiceTransportConfig,
138 ) -> Self {
139 set_transport_layer(
140 &mut self
141 .layer
142 .services
143 .get_or_insert_with(Default::default)
144 .events_transport,
145 transport_to_layer(transport),
146 );
147 self
148 }
149
150 pub fn with_services_source_transport(
151 mut self,
152 transport: greentic_config_types::ServiceTransportConfig,
153 ) -> Self {
154 set_transport_layer(
155 &mut self
156 .layer
157 .services
158 .get_or_insert_with(Default::default)
159 .source,
160 transport_to_layer(transport),
161 );
162 self
163 }
164
165 pub fn with_services_publish_transport(
166 mut self,
167 transport: greentic_config_types::ServiceTransportConfig,
168 ) -> Self {
169 set_transport_layer(
170 &mut self
171 .layer
172 .services
173 .get_or_insert_with(Default::default)
174 .publish,
175 transport_to_layer(transport),
176 );
177 self
178 }
179
180 pub fn with_services_metadata_transport(
181 mut self,
182 transport: greentic_config_types::ServiceTransportConfig,
183 ) -> Self {
184 set_transport_layer(
185 &mut self
186 .layer
187 .services
188 .get_or_insert_with(Default::default)
189 .metadata,
190 transport_to_layer(transport),
191 );
192 self
193 }
194
195 pub fn with_services_oauth_broker_transport(
196 mut self,
197 transport: greentic_config_types::ServiceTransportConfig,
198 ) -> Self {
199 set_transport_layer(
200 &mut self
201 .layer
202 .services
203 .get_or_insert_with(Default::default)
204 .oauth_broker,
205 transport_to_layer(transport),
206 );
207 self
208 }
209
210 pub fn with_services_runner_service(
211 mut self,
212 service: greentic_config_types::ServiceConfig,
213 ) -> Self {
214 set_service_layer(
215 &mut self
216 .layer
217 .services
218 .get_or_insert_with(Default::default)
219 .runner,
220 service_to_layer(service),
221 );
222 self
223 }
224
225 pub fn with_services_deployer_service(
226 mut self,
227 service: greentic_config_types::ServiceConfig,
228 ) -> Self {
229 set_service_layer(
230 &mut self
231 .layer
232 .services
233 .get_or_insert_with(Default::default)
234 .deployer,
235 service_to_layer(service),
236 );
237 self
238 }
239
240 pub fn with_services_events_transport_service(
241 mut self,
242 service: greentic_config_types::ServiceConfig,
243 ) -> Self {
244 set_service_layer(
245 &mut self
246 .layer
247 .services
248 .get_or_insert_with(Default::default)
249 .events_transport,
250 service_to_layer(service),
251 );
252 self
253 }
254
255 pub fn with_services_source_service(
256 mut self,
257 service: greentic_config_types::ServiceConfig,
258 ) -> Self {
259 set_service_layer(
260 &mut self
261 .layer
262 .services
263 .get_or_insert_with(Default::default)
264 .source,
265 service_to_layer(service),
266 );
267 self
268 }
269
270 pub fn with_services_publish_service(
271 mut self,
272 service: greentic_config_types::ServiceConfig,
273 ) -> Self {
274 set_service_layer(
275 &mut self
276 .layer
277 .services
278 .get_or_insert_with(Default::default)
279 .publish,
280 service_to_layer(service),
281 );
282 self
283 }
284
285 pub fn with_services_metadata_service(
286 mut self,
287 service: greentic_config_types::ServiceConfig,
288 ) -> Self {
289 set_service_layer(
290 &mut self
291 .layer
292 .services
293 .get_or_insert_with(Default::default)
294 .metadata,
295 service_to_layer(service),
296 );
297 self
298 }
299
300 pub fn with_services_oauth_broker_service(
301 mut self,
302 service: greentic_config_types::ServiceConfig,
303 ) -> Self {
304 set_service_layer(
305 &mut self
306 .layer
307 .services
308 .get_or_insert_with(Default::default)
309 .oauth_broker,
310 service_to_layer(service),
311 );
312 self
313 }
314
315 pub fn with_runtime_admin_secrets_explain_enabled(mut self, enabled: bool) -> Self {
316 self.layer
317 .runtime
318 .get_or_insert_with(Default::default)
319 .admin_endpoints
320 .get_or_insert_with(Default::default)
321 .secrets_explain_enabled = Some(enabled);
322 self
323 }
324
325 pub fn into_layer(self) -> ConfigLayer {
326 self.layer
327 }
328}
329
330impl From<CliOverrides> for ConfigLayer {
331 fn from(value: CliOverrides) -> Self {
332 value.layer
333 }
334}
335
336fn transport_to_layer(
337 transport: greentic_config_types::ServiceTransportConfig,
338) -> crate::loaders::ServiceTransportLayer {
339 match transport {
340 greentic_config_types::ServiceTransportConfig::Noop => {
341 crate::loaders::ServiceTransportLayer {
342 kind: Some("noop".into()),
343 ..Default::default()
344 }
345 }
346 greentic_config_types::ServiceTransportConfig::Http { url, headers } => {
347 crate::loaders::ServiceTransportLayer {
348 kind: Some("http".into()),
349 url: Some(url),
350 headers,
351 ..Default::default()
352 }
353 }
354 greentic_config_types::ServiceTransportConfig::Nats {
355 url,
356 subject_prefix,
357 } => crate::loaders::ServiceTransportLayer {
358 kind: Some("nats".into()),
359 url: Some(url),
360 subject_prefix,
361 ..Default::default()
362 },
363 }
364}
365
366fn service_to_layer(
367 service: greentic_config_types::ServiceConfig,
368) -> crate::loaders::ServiceConfigLayer {
369 crate::loaders::ServiceConfigLayer {
370 bind_addr: service.bind_addr,
371 port: service.port,
372 public_base_url: service.public_base_url,
373 metrics: service.metrics.map(metrics_to_layer),
374 }
375}
376
377fn metrics_to_layer(metrics: greentic_config_types::MetricsConfig) -> crate::loaders::MetricsLayer {
378 crate::loaders::MetricsLayer {
379 enabled: metrics.enabled,
380 bind_addr: metrics.bind_addr,
381 port: metrics.port,
382 path: metrics.path,
383 }
384}
385
386fn set_transport_layer(
387 target: &mut Option<crate::loaders::ServiceLayer>,
388 transport: crate::loaders::ServiceTransportLayer,
389) {
390 let layer = target.get_or_insert_with(Default::default);
391 layer.transport = transport;
392}
393
394fn set_service_layer(
395 target: &mut Option<crate::loaders::ServiceLayer>,
396 service: crate::loaders::ServiceConfigLayer,
397) {
398 let layer = target.get_or_insert_with(Default::default);
399 layer.service = Some(service);
400}
401
402#[derive(Debug, Clone)]
403pub struct ConfigResolver {
404 project_root: Option<PathBuf>,
405 config_path: Option<PathBuf>,
406 cli_overrides: Option<ConfigLayer>,
407 allow_dev: bool,
408 allow_network: bool,
409}
410
411impl ConfigResolver {
412 pub fn new() -> Self {
413 Self {
414 project_root: None,
415 config_path: None,
416 cli_overrides: None,
417 allow_dev: false,
418 allow_network: false,
419 }
420 }
421
422 pub fn with_project_root(mut self, root: PathBuf) -> Self {
423 self.project_root = Some(root);
424 self
425 }
426
427 pub fn with_project_root_opt(mut self, root: Option<PathBuf>) -> Self {
428 if let Some(r) = root {
429 self.project_root = Some(r);
430 }
431 self
432 }
433
434 pub fn with_config_path(mut self, path: PathBuf) -> Self {
435 self.config_path = Some(path);
436 self
437 }
438
439 pub fn with_cli_overrides(mut self, layer: ConfigLayer) -> Self {
440 self.cli_overrides = Some(layer);
441 self
442 }
443
444 pub fn with_cli_overrides_typed(mut self, overrides: CliOverrides) -> Self {
445 self.cli_overrides = Some(overrides.into_layer());
446 self
447 }
448
449 pub fn with_allow_dev(mut self, allow: bool) -> Self {
450 self.allow_dev = allow;
451 self
452 }
453
454 pub fn allow_dev(self, allow: bool) -> Self {
455 self.with_allow_dev(allow)
456 }
457
458 pub fn with_allow_network(mut self, allow: bool) -> Self {
459 self.allow_network = allow;
460 self
461 }
462
463 pub fn load(&self) -> anyhow::Result<ResolvedConfig> {
464 let project_root = self.resolve_project_root()?;
465
466 let default_paths = DefaultPaths::from_root(&project_root);
467 let mut provenance = ProvenanceMap::new();
468
469 let default_layer = loaders::default_layer(&project_root, &default_paths);
470 let user_layer = loaders::load_user_config()?;
471 let (project_layer, _) = self.load_project_layer(&project_root)?;
472 let (env_layer, mut env_warnings) = loaders::load_env_layer();
473
474 let mut merged = merge::MergeState::new(default_layer, ConfigSource::Default);
475 merged.apply(user_layer, ConfigSource::User);
476 merged.apply(project_layer, ConfigSource::Project);
477 merged.apply(env_layer, ConfigSource::Environment);
478 if let Some(cli) = self.cli_overrides.clone() {
479 merged.apply(cli, ConfigSource::Cli);
480 }
481
482 let (resolved, layer_provenance, mut merge_warnings) = merged.finalize(&default_paths)?;
483 provenance.extend(layer_provenance);
484 merge_warnings.append(&mut env_warnings);
485
486 let mut warnings = validate::validate_config_with_overrides(
487 &resolved,
488 self.allow_dev,
489 self.allow_network,
490 )?;
491 warnings.append(&mut merge_warnings);
492
493 Ok(ResolvedConfig {
494 config: resolved,
495 provenance,
496 warnings,
497 })
498 }
499
500 pub fn load_detailed(&self) -> anyhow::Result<ResolvedConfigDetailed> {
501 let project_root = self.resolve_project_root()?;
502
503 let default_paths = DefaultPaths::from_root(&project_root);
504
505 let default_layer = loaders::default_layer(&project_root, &default_paths);
506 let (user_layer, user_origin) = loaders::load_user_config_with_origin()?;
507 let (project_layer, project_origin) = self.load_project_layer(&project_root)?;
508
509 let env_layers = loaders::load_env_layers_detailed();
510 let mut env_warnings = Vec::new();
511
512 let mut merged = merge::MergeStateDetailed::new(
513 default_layer,
514 merge::ProvenanceCtx::new(ConfigSource::Default, Some("defaults".into())),
515 );
516 if let Some(origin) = user_origin {
517 merged.apply(
518 user_layer,
519 merge::ProvenanceCtx::new(ConfigSource::User, Some(origin.display().to_string())),
520 );
521 } else {
522 merged.apply(
523 user_layer,
524 merge::ProvenanceCtx::new(ConfigSource::User, None),
525 );
526 }
527 merged.apply(
528 project_layer,
529 merge::ProvenanceCtx::new(
530 ConfigSource::Project,
531 Some(project_origin.display().to_string()),
532 ),
533 );
534 for (layer, env_key, mut warnings) in env_layers {
535 env_warnings.append(&mut warnings);
536 merged.apply(
537 layer,
538 merge::ProvenanceCtx::new(ConfigSource::Environment, Some(env_key)),
539 );
540 }
541 if let Some(cli) = self.cli_overrides.clone() {
542 merged.apply(
543 cli,
544 merge::ProvenanceCtx::new(ConfigSource::Cli, Some("cli".into())),
545 );
546 }
547
548 let (resolved, provenance, mut merge_warnings) =
549 merged.finalize_detailed(&default_paths)?;
550 merge_warnings.append(&mut env_warnings);
551 let mut warnings = validate::validate_config_with_overrides(
552 &resolved,
553 self.allow_dev,
554 self.allow_network,
555 )?;
556 warnings.append(&mut merge_warnings);
557
558 Ok(ResolvedConfigDetailed {
559 config: resolved,
560 provenance,
561 warnings,
562 })
563 }
564
565 fn resolve_project_root(&self) -> anyhow::Result<PathBuf> {
566 let cwd = std::env::current_dir()?;
567 Ok(self
568 .project_root
569 .clone()
570 .or_else(|| discover_project_root(&cwd))
571 .unwrap_or(cwd))
572 }
573
574 fn load_project_layer(
575 &self,
576 project_root: &std::path::Path,
577 ) -> anyhow::Result<(ConfigLayer, PathBuf)> {
578 match self.config_path.as_deref() {
579 Some(path) => {
580 let abs = crate::paths::absolute_path(path)?;
581 Ok((loaders::load_config_file_required(&abs)?, abs))
582 }
583 None => loaders::load_project_config_with_origin(project_root),
584 }
585 }
586}
587
588impl Default for ConfigResolver {
589 fn default() -> Self {
590 Self::new()
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::loaders::{
598 BackoffLayer, ConfigLayer, DEFAULT_DEPLOYER_BASE_DOMAIN, DeployerLayer, EnvironmentLayer,
599 EventsLayer, ServiceConfigLayer, ServiceEndpointLayer, ServiceLayer, ServiceTransportLayer,
600 ServicesLayer,
601 };
602 use greentic_config_types::PackSourceConfig;
603 use greentic_types::ConnectionKind;
604 use std::path::PathBuf;
605 use tempfile::tempdir;
606 use url::Url;
607
608 #[test]
609 fn precedence_prefers_cli_over_env() {
610 let tmp = tempdir().unwrap();
611 let root = tmp.path().to_path_buf();
612
613 let env_layer = ConfigLayer {
614 environment: Some(EnvironmentLayer {
615 env_id: Some(serde_json::from_str("\"staging\"").unwrap()),
616 deployment: None,
617 connection: None,
618 region: None,
619 }),
620 ..Default::default()
621 };
622
623 let cli_layer = ConfigLayer {
624 environment: Some(EnvironmentLayer {
625 env_id: Some(serde_json::from_str("\"prod\"").unwrap()),
626 deployment: None,
627 connection: None,
628 region: None,
629 }),
630 ..Default::default()
631 };
632
633 let default_paths = DefaultPaths::from_root(&root);
634 let mut merged = merge::MergeState::new(
635 loaders::default_layer(&root, &default_paths),
636 ConfigSource::Default,
637 );
638 merged.apply(env_layer, ConfigSource::Environment);
639 merged.apply(cli_layer, ConfigSource::Cli);
640 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
641 let env_id_str = serde_json::to_string(&resolved.environment.env_id).unwrap();
642 assert!(env_id_str.contains("prod"));
643 }
644
645 #[test]
646 fn relative_paths_resolve_to_absolute() {
647 let tmp = tempdir().unwrap();
648 let root = tmp.path().to_path_buf();
649 let default_paths = DefaultPaths::from_root(&root);
650
651 let layer = ConfigLayer {
652 paths: Some(crate::loaders::PathsLayer {
653 state_dir: Some(PathBuf::from("relative/state")),
654 cache_dir: Some(PathBuf::from("relative/cache")),
655 logs_dir: Some(PathBuf::from("relative/logs")),
656 greentic_root: Some(PathBuf::from(".")),
657 }),
658 packs: Some(crate::loaders::PacksLayer {
659 cache_dir: Some(PathBuf::from("relative/packs/cache")),
660 source: Some(crate::loaders::PackSourceLayer::LocalIndex {
661 path: Some(PathBuf::from("relative/packs/index.json")),
662 }),
663 ..Default::default()
664 }),
665 ..Default::default()
666 };
667
668 let mut merged = merge::MergeState::new(
669 loaders::default_layer(&root, &default_paths),
670 ConfigSource::Default,
671 );
672 merged.apply(layer, ConfigSource::Project);
673 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
674 assert!(resolved.paths.state_dir.is_absolute());
675 assert!(resolved.paths.cache_dir.is_absolute());
676 assert!(resolved.paths.logs_dir.is_absolute());
677 let packs = resolved.packs.unwrap();
678 assert!(packs.cache_dir.is_absolute());
679 if let PackSourceConfig::LocalIndex { path } = packs.source {
680 assert!(path.is_absolute());
681 }
682 }
683
684 #[test]
685 fn dev_config_requires_dev_env_without_allow() {
686 let tmp = tempdir().unwrap();
687 let root = tmp.path().to_path_buf();
688 let default_paths = DefaultPaths::from_root(&root);
689
690 let layer = ConfigLayer {
691 dev: Some(crate::loaders::DevLayer {
692 default_env: Some(serde_json::from_str("\"dev\"").unwrap()),
693 default_tenant: Some("acme".into()),
694 default_team: None,
695 }),
696 environment: Some(EnvironmentLayer {
697 env_id: Some(serde_json::from_str("\"prod\"").unwrap()),
698 deployment: None,
699 connection: None,
700 region: None,
701 }),
702 ..Default::default()
703 };
704
705 let mut merged = merge::MergeState::new(
706 loaders::default_layer(&root, &default_paths),
707 ConfigSource::Default,
708 );
709 merged.apply(layer, ConfigSource::Cli);
710 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
711 let validation = validate::validate_config(&resolved, false);
712 assert!(validation.is_err());
713 }
714
715 #[test]
716 fn packs_default_to_paths_based_locations() {
717 let tmp = tempdir().unwrap();
718 let root = tmp.path().to_path_buf();
719 let default_paths = DefaultPaths::from_root(&root);
720 let cache_dir = root.join("custom_cache");
721 let state_dir = root.join(".state_dir");
722
723 let layer = ConfigLayer {
724 paths: Some(crate::loaders::PathsLayer {
725 cache_dir: Some(cache_dir.clone()),
726 state_dir: Some(state_dir.clone()),
727 greentic_root: Some(root.clone()),
728 logs_dir: None,
729 }),
730 ..Default::default()
731 };
732
733 let mut merged = merge::MergeState::new(
734 loaders::default_layer(&root, &default_paths),
735 ConfigSource::Default,
736 );
737 merged.apply(layer, ConfigSource::Project);
738 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
739 let packs = resolved.packs.unwrap();
740 assert_eq!(packs.cache_dir, cache_dir.join("packs"));
741 if let PackSourceConfig::LocalIndex { path } = packs.source {
742 assert_eq!(path, state_dir.join("packs").join("index.json"));
743 } else {
744 panic!("expected local index default");
745 }
746 }
747
748 #[test]
749 fn offline_env_forbids_remote_packs() {
750 let tmp = tempdir().unwrap();
751 let root = tmp.path().to_path_buf();
752 let default_paths = DefaultPaths::from_root(&root);
753
754 let layer = ConfigLayer {
755 environment: Some(EnvironmentLayer {
756 env_id: Some(serde_json::from_str("\"dev\"").unwrap()),
757 deployment: None,
758 connection: Some(ConnectionKind::Offline),
759 region: None,
760 }),
761 packs: Some(crate::loaders::PacksLayer {
762 source: Some(crate::loaders::PackSourceLayer::HttpIndex {
763 url: Some("https://example.com/index.json".into()),
764 }),
765 ..Default::default()
766 }),
767 ..Default::default()
768 };
769
770 let mut merged = merge::MergeState::new(
771 loaders::default_layer(&root, &default_paths),
772 ConfigSource::Default,
773 );
774 merged.apply(layer, ConfigSource::Project);
775 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
776 let validation = validate::validate_config(&resolved, false);
777 assert!(matches!(
778 validation,
779 Err(validate::ValidationError::PacksSourceOffline)
780 ));
781 }
782
783 #[test]
784 fn events_endpoint_precedence_and_provenance() {
785 let tmp = tempdir().unwrap();
786 let root = tmp.path().to_path_buf();
787 let default_paths = DefaultPaths::from_root(&root);
788 let user_layer = ConfigLayer {
789 services: Some(ServicesLayer {
790 events: Some(ServiceEndpointLayer {
791 url: Some(Url::parse("https://user.example.com").unwrap()),
792 headers: None,
793 }),
794 ..Default::default()
795 }),
796 ..Default::default()
797 };
798 let project_layer = ConfigLayer {
799 services: Some(ServicesLayer {
800 events: Some(ServiceEndpointLayer {
801 url: Some(Url::parse("https://project.example.com").unwrap()),
802 headers: None,
803 }),
804 ..Default::default()
805 }),
806 ..Default::default()
807 };
808 let env_layer = ConfigLayer {
809 services: Some(ServicesLayer {
810 events: Some(ServiceEndpointLayer {
811 url: Some(Url::parse("https://env.example.com").unwrap()),
812 headers: None,
813 }),
814 ..Default::default()
815 }),
816 ..Default::default()
817 };
818 let cli_layer = ConfigLayer {
819 services: Some(ServicesLayer {
820 events: Some(ServiceEndpointLayer {
821 url: Some(Url::parse("https://cli.example.com").unwrap()),
822 headers: None,
823 }),
824 ..Default::default()
825 }),
826 ..Default::default()
827 };
828
829 let mut merged = merge::MergeState::new(
830 loaders::default_layer(&root, &default_paths),
831 ConfigSource::Default,
832 );
833 merged.apply(user_layer, ConfigSource::User);
834 merged.apply(project_layer, ConfigSource::Project);
835 merged.apply(env_layer, ConfigSource::Environment);
836 merged.apply(cli_layer, ConfigSource::Cli);
837 let (resolved, provenance, _) = merged.finalize(&default_paths).unwrap();
838
839 let url = resolved.services.unwrap().events.unwrap().url.to_string();
840 assert_eq!(url, "https://cli.example.com/");
841 assert_eq!(
842 provenance
843 .get(&greentic_config_types::ProvenancePath(
844 "services.events.url".into()
845 ))
846 .cloned(),
847 Some(ConfigSource::Cli)
848 );
849 }
850
851 #[test]
852 fn runner_transport_precedence_and_provenance() {
853 let tmp = tempdir().unwrap();
854 let root = tmp.path().to_path_buf();
855 let default_paths = DefaultPaths::from_root(&root);
856
857 let user_layer = ConfigLayer {
858 services: Some(ServicesLayer {
859 runner: Some(ServiceLayer {
860 transport: ServiceTransportLayer {
861 kind: Some("http".into()),
862 url: Some(Url::parse("https://user-runner.example.com").unwrap()),
863 ..Default::default()
864 },
865 ..Default::default()
866 }),
867 ..Default::default()
868 }),
869 ..Default::default()
870 };
871 let project_layer = ConfigLayer {
872 services: Some(ServicesLayer {
873 runner: Some(ServiceLayer {
874 transport: ServiceTransportLayer {
875 kind: Some("http".into()),
876 url: Some(Url::parse("https://project-runner.example.com").unwrap()),
877 ..Default::default()
878 },
879 ..Default::default()
880 }),
881 ..Default::default()
882 }),
883 ..Default::default()
884 };
885 let env_layer = ConfigLayer {
886 services: Some(ServicesLayer {
887 runner: Some(ServiceLayer {
888 transport: ServiceTransportLayer {
889 kind: Some("http".into()),
890 url: Some(Url::parse("https://env-runner.example.com").unwrap()),
891 ..Default::default()
892 },
893 ..Default::default()
894 }),
895 ..Default::default()
896 }),
897 ..Default::default()
898 };
899 let cli_layer = ConfigLayer {
900 services: Some(ServicesLayer {
901 runner: Some(ServiceLayer {
902 transport: ServiceTransportLayer {
903 kind: Some("http".into()),
904 url: Some(Url::parse("https://cli-runner.example.com").unwrap()),
905 ..Default::default()
906 },
907 ..Default::default()
908 }),
909 ..Default::default()
910 }),
911 ..Default::default()
912 };
913
914 let mut merged = merge::MergeState::new(
915 loaders::default_layer(&root, &default_paths),
916 ConfigSource::Default,
917 );
918 merged.apply(user_layer, ConfigSource::User);
919 merged.apply(project_layer, ConfigSource::Project);
920 merged.apply(env_layer, ConfigSource::Environment);
921 merged.apply(cli_layer, ConfigSource::Cli);
922 let (resolved, provenance, _) = merged.finalize(&default_paths).unwrap();
923
924 let runner_url = match resolved
925 .services
926 .unwrap()
927 .runner
928 .as_ref()
929 .unwrap()
930 .transport
931 .as_ref()
932 .unwrap()
933 {
934 greentic_config_types::ServiceTransportConfig::Http { url, .. }
935 | greentic_config_types::ServiceTransportConfig::Nats { url, .. } => url.to_string(),
936 greentic_config_types::ServiceTransportConfig::Noop => {
937 panic!("expected http or nats transport")
938 }
939 };
940 assert_eq!(runner_url, "https://cli-runner.example.com/");
941 assert_eq!(
942 provenance
943 .get(&greentic_config_types::ProvenancePath(
944 "services.runner.url".into()
945 ))
946 .cloned(),
947 Some(ConfigSource::Cli)
948 );
949 }
950
951 #[test]
952 fn service_binding_precedence_and_provenance() {
953 let tmp = tempdir().unwrap();
954 let root = tmp.path().to_path_buf();
955 let default_paths = DefaultPaths::from_root(&root);
956
957 let user_layer = ConfigLayer {
958 services: Some(ServicesLayer {
959 runner: Some(ServiceLayer {
960 service: Some(ServiceConfigLayer {
961 port: Some(1000),
962 ..Default::default()
963 }),
964 ..Default::default()
965 }),
966 ..Default::default()
967 }),
968 ..Default::default()
969 };
970 let project_layer = ConfigLayer {
971 services: Some(ServicesLayer {
972 runner: Some(ServiceLayer {
973 service: Some(ServiceConfigLayer {
974 port: Some(2000),
975 ..Default::default()
976 }),
977 ..Default::default()
978 }),
979 ..Default::default()
980 }),
981 ..Default::default()
982 };
983 let env_layer = ConfigLayer {
984 services: Some(ServicesLayer {
985 runner: Some(ServiceLayer {
986 service: Some(ServiceConfigLayer {
987 port: Some(3000),
988 ..Default::default()
989 }),
990 ..Default::default()
991 }),
992 ..Default::default()
993 }),
994 ..Default::default()
995 };
996 let cli_layer = ConfigLayer {
997 services: Some(ServicesLayer {
998 runner: Some(ServiceLayer {
999 service: Some(ServiceConfigLayer {
1000 port: Some(4000),
1001 ..Default::default()
1002 }),
1003 ..Default::default()
1004 }),
1005 ..Default::default()
1006 }),
1007 ..Default::default()
1008 };
1009
1010 let mut merged = merge::MergeState::new(
1011 loaders::default_layer(&root, &default_paths),
1012 ConfigSource::Default,
1013 );
1014 merged.apply(user_layer, ConfigSource::User);
1015 merged.apply(project_layer, ConfigSource::Project);
1016 merged.apply(env_layer, ConfigSource::Environment);
1017 merged.apply(cli_layer, ConfigSource::Cli);
1018 let (resolved, provenance, _) = merged.finalize(&default_paths).unwrap();
1019
1020 let port = resolved
1021 .services
1022 .unwrap()
1023 .runner
1024 .and_then(|svc| svc.service)
1025 .and_then(|svc| svc.port);
1026 assert_eq!(port, Some(4000));
1027 assert_eq!(
1028 provenance
1029 .get(&greentic_config_types::ProvenancePath(
1030 "services.runner.service.port".into()
1031 ))
1032 .cloned(),
1033 Some(ConfigSource::Cli)
1034 );
1035 }
1036
1037 #[test]
1038 fn service_metrics_precedence_and_provenance() {
1039 let tmp = tempdir().unwrap();
1040 let root = tmp.path().to_path_buf();
1041 let default_paths = DefaultPaths::from_root(&root);
1042
1043 let user_layer = ConfigLayer {
1044 services: Some(ServicesLayer {
1045 runner: Some(ServiceLayer {
1046 service: Some(ServiceConfigLayer {
1047 metrics: Some(crate::loaders::MetricsLayer {
1048 port: Some(9100),
1049 ..Default::default()
1050 }),
1051 ..Default::default()
1052 }),
1053 ..Default::default()
1054 }),
1055 ..Default::default()
1056 }),
1057 ..Default::default()
1058 };
1059 let project_layer = ConfigLayer {
1060 services: Some(ServicesLayer {
1061 runner: Some(ServiceLayer {
1062 service: Some(ServiceConfigLayer {
1063 metrics: Some(crate::loaders::MetricsLayer {
1064 port: Some(9200),
1065 ..Default::default()
1066 }),
1067 ..Default::default()
1068 }),
1069 ..Default::default()
1070 }),
1071 ..Default::default()
1072 }),
1073 ..Default::default()
1074 };
1075 let env_layer = ConfigLayer {
1076 services: Some(ServicesLayer {
1077 runner: Some(ServiceLayer {
1078 service: Some(ServiceConfigLayer {
1079 metrics: Some(crate::loaders::MetricsLayer {
1080 port: Some(9300),
1081 ..Default::default()
1082 }),
1083 ..Default::default()
1084 }),
1085 ..Default::default()
1086 }),
1087 ..Default::default()
1088 }),
1089 ..Default::default()
1090 };
1091 let cli_layer = ConfigLayer {
1092 services: Some(ServicesLayer {
1093 runner: Some(ServiceLayer {
1094 service: Some(ServiceConfigLayer {
1095 metrics: Some(crate::loaders::MetricsLayer {
1096 port: Some(9400),
1097 ..Default::default()
1098 }),
1099 ..Default::default()
1100 }),
1101 ..Default::default()
1102 }),
1103 ..Default::default()
1104 }),
1105 ..Default::default()
1106 };
1107
1108 let mut merged = merge::MergeState::new(
1109 loaders::default_layer(&root, &default_paths),
1110 ConfigSource::Default,
1111 );
1112 merged.apply(user_layer, ConfigSource::User);
1113 merged.apply(project_layer, ConfigSource::Project);
1114 merged.apply(env_layer, ConfigSource::Environment);
1115 merged.apply(cli_layer, ConfigSource::Cli);
1116 let (resolved, provenance, _) = merged.finalize(&default_paths).unwrap();
1117
1118 let port = resolved
1119 .services
1120 .unwrap()
1121 .runner
1122 .and_then(|svc| svc.service)
1123 .and_then(|svc| svc.metrics)
1124 .and_then(|m| m.port);
1125 assert_eq!(port, Some(9400));
1126 assert_eq!(
1127 provenance
1128 .get(&greentic_config_types::ProvenancePath(
1129 "services.runner.service.metrics.port".into()
1130 ))
1131 .cloned(),
1132 Some(ConfigSource::Cli)
1133 );
1134 }
1135
1136 #[test]
1137 fn service_public_base_url_validation_warns() {
1138 let tmp = tempdir().unwrap();
1139 let root = tmp.path().to_path_buf();
1140 let default_paths = DefaultPaths::from_root(&root);
1141
1142 let layer = ConfigLayer {
1143 services: Some(ServicesLayer {
1144 runner: Some(ServiceLayer {
1145 service: Some(ServiceConfigLayer {
1146 public_base_url: Some("not a url".into()),
1147 ..Default::default()
1148 }),
1149 ..Default::default()
1150 }),
1151 ..Default::default()
1152 }),
1153 ..Default::default()
1154 };
1155
1156 let mut merged = merge::MergeState::new(
1157 loaders::default_layer(&root, &default_paths),
1158 ConfigSource::Default,
1159 );
1160 merged.apply(layer, ConfigSource::Project);
1161 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
1162 let warnings =
1163 validate::validate_config_with_overrides(&resolved, true, true).expect("validate");
1164 assert!(
1165 warnings
1166 .iter()
1167 .any(|w| w.contains("public_base_url") && w.contains("services.runner"))
1168 );
1169 }
1170
1171 #[test]
1172 fn invalid_env_service_port_warns() {
1173 let layers = crate::loaders::load_env_layers_detailed_from([(
1174 "GREENTIC_SERVICES_RUNNER_PORT".to_string(),
1175 "not-a-number".to_string(),
1176 )]);
1177 assert_eq!(layers.len(), 1);
1178 let (layer, key, warnings) = &layers[0];
1179 assert_eq!(key, "GREENTIC_SERVICES_RUNNER_PORT");
1180 assert!(
1181 warnings
1182 .iter()
1183 .any(|w| w.contains("expected u16") && w.contains(key))
1184 );
1185 let port = layer
1186 .services
1187 .as_ref()
1188 .and_then(|svc| svc.runner.as_ref())
1189 .and_then(|svc| svc.service.as_ref())
1190 .and_then(|svc| svc.port);
1191 assert!(port.is_none());
1192 }
1193
1194 #[test]
1195 fn runtime_admin_endpoints_precedence_and_provenance() {
1196 let tmp = tempdir().unwrap();
1197 let root = tmp.path().to_path_buf();
1198 let default_paths = DefaultPaths::from_root(&root);
1199
1200 let user_layer = ConfigLayer {
1201 runtime: Some(crate::loaders::RuntimeLayer {
1202 admin_endpoints: Some(crate::loaders::AdminEndpointsLayer {
1203 secrets_explain_enabled: Some(false),
1204 }),
1205 ..Default::default()
1206 }),
1207 ..Default::default()
1208 };
1209 let project_layer = ConfigLayer {
1210 runtime: Some(crate::loaders::RuntimeLayer {
1211 admin_endpoints: Some(crate::loaders::AdminEndpointsLayer {
1212 secrets_explain_enabled: Some(true),
1213 }),
1214 ..Default::default()
1215 }),
1216 ..Default::default()
1217 };
1218 let env_layer = ConfigLayer {
1219 runtime: Some(crate::loaders::RuntimeLayer {
1220 admin_endpoints: Some(crate::loaders::AdminEndpointsLayer {
1221 secrets_explain_enabled: Some(false),
1222 }),
1223 ..Default::default()
1224 }),
1225 ..Default::default()
1226 };
1227 let cli_layer = ConfigLayer {
1228 runtime: Some(crate::loaders::RuntimeLayer {
1229 admin_endpoints: Some(crate::loaders::AdminEndpointsLayer {
1230 secrets_explain_enabled: Some(true),
1231 }),
1232 ..Default::default()
1233 }),
1234 ..Default::default()
1235 };
1236
1237 let mut merged = merge::MergeState::new(
1238 loaders::default_layer(&root, &default_paths),
1239 ConfigSource::Default,
1240 );
1241 merged.apply(user_layer, ConfigSource::User);
1242 merged.apply(project_layer, ConfigSource::Project);
1243 merged.apply(env_layer, ConfigSource::Environment);
1244 merged.apply(cli_layer, ConfigSource::Cli);
1245 let (resolved, provenance, _) = merged.finalize(&default_paths).unwrap();
1246
1247 let enabled = resolved
1248 .runtime
1249 .admin_endpoints
1250 .as_ref()
1251 .map(|a| a.secrets_explain_enabled)
1252 .unwrap();
1253 assert!(enabled);
1254 assert_eq!(
1255 provenance
1256 .get(&greentic_config_types::ProvenancePath(
1257 "runtime.admin_endpoints.secrets_explain_enabled".into()
1258 ))
1259 .cloned(),
1260 Some(ConfigSource::Cli)
1261 );
1262 }
1263
1264 #[test]
1265 fn offline_env_blocks_remote_events_endpoint() {
1266 let tmp = tempdir().unwrap();
1267 let root = tmp.path().to_path_buf();
1268 let default_paths = DefaultPaths::from_root(&root);
1269
1270 let layer = ConfigLayer {
1271 environment: Some(EnvironmentLayer {
1272 env_id: Some(serde_json::from_str("\"dev\"").unwrap()),
1273 deployment: None,
1274 connection: Some(ConnectionKind::Offline),
1275 region: None,
1276 }),
1277 services: Some(ServicesLayer {
1278 events: Some(ServiceEndpointLayer {
1279 url: Some(Url::parse("https://events.example.com").unwrap()),
1280 headers: None,
1281 }),
1282 ..Default::default()
1283 }),
1284 ..Default::default()
1285 };
1286
1287 let mut merged = merge::MergeState::new(
1288 loaders::default_layer(&root, &default_paths),
1289 ConfigSource::Default,
1290 );
1291 merged.apply(layer, ConfigSource::Project);
1292 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
1293 let validation = validate::validate_config(&resolved, false);
1294 assert!(matches!(
1295 validation,
1296 Err(validate::ValidationError::EventsEndpointOffline(_))
1297 ));
1298 }
1299
1300 #[test]
1301 fn backoff_validation_catches_invalid_values() {
1302 let tmp = tempdir().unwrap();
1303 let root = tmp.path().to_path_buf();
1304 let default_paths = DefaultPaths::from_root(&root);
1305
1306 let invalid_backoff = ConfigLayer {
1307 events: Some(EventsLayer {
1308 backoff: Some(BackoffLayer {
1309 initial_ms: Some(0),
1310 max_ms: Some(10),
1311 multiplier: Some(0.5),
1312 jitter: None,
1313 }),
1314 ..Default::default()
1315 }),
1316 ..Default::default()
1317 };
1318
1319 let mut merged = merge::MergeState::new(
1320 loaders::default_layer(&root, &default_paths),
1321 ConfigSource::Default,
1322 );
1323 merged.apply(invalid_backoff, ConfigSource::Project);
1324 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
1325
1326 let validation = validate::validate_config(&resolved, true);
1327 assert!(matches!(
1328 validation,
1329 Err(validate::ValidationError::EventsBackoffInitial(0))
1330 ));
1331
1332 let invalid_max = ConfigLayer {
1333 events: Some(EventsLayer {
1334 backoff: Some(BackoffLayer {
1335 initial_ms: Some(200),
1336 max_ms: Some(100),
1337 multiplier: Some(2.0),
1338 jitter: None,
1339 }),
1340 ..Default::default()
1341 }),
1342 ..Default::default()
1343 };
1344
1345 let mut merged = merge::MergeState::new(
1346 loaders::default_layer(&root, &default_paths),
1347 ConfigSource::Default,
1348 );
1349 merged.apply(invalid_max, ConfigSource::Project);
1350 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
1351 let validation = validate::validate_config(&resolved, true);
1352 assert!(matches!(
1353 validation,
1354 Err(validate::ValidationError::EventsBackoffMax { .. })
1355 ));
1356 }
1357
1358 #[test]
1359 fn deployer_base_domain_defaults_and_validation() {
1360 let tmp = tempdir().unwrap();
1361 let root = tmp.path().to_path_buf();
1362 let default_paths = DefaultPaths::from_root(&root);
1363
1364 let merged = merge::MergeState::new(
1365 loaders::default_layer(&root, &default_paths),
1366 ConfigSource::Default,
1367 );
1368 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
1369 assert_eq!(
1370 resolved
1371 .deployer
1372 .as_ref()
1373 .and_then(|d| d.base_domain.as_deref()),
1374 Some(DEFAULT_DEPLOYER_BASE_DOMAIN)
1375 );
1376
1377 let invalid_layer = ConfigLayer {
1378 deployer: Some(DeployerLayer {
1379 base_domain: Some("https://bad-domain".into()),
1380 provider: None,
1381 }),
1382 ..Default::default()
1383 };
1384
1385 let mut merged = merge::MergeState::new(
1386 loaders::default_layer(&root, &default_paths),
1387 ConfigSource::Default,
1388 );
1389 merged.apply(invalid_layer, ConfigSource::Project);
1390 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
1391 let validation = validate::validate_config(&resolved, true);
1392 assert!(matches!(
1393 validation,
1394 Err(validate::ValidationError::DeployerBaseDomain(_))
1395 ));
1396 }
1397
1398 #[test]
1399 fn deployer_base_domain_precedence_and_provenance() {
1400 let tmp = tempdir().unwrap();
1401 let root = tmp.path().to_path_buf();
1402 let default_paths = DefaultPaths::from_root(&root);
1403
1404 let user_layer = ConfigLayer {
1405 deployer: Some(DeployerLayer {
1406 base_domain: Some("user.greentic.test".into()),
1407 provider: None,
1408 }),
1409 ..Default::default()
1410 };
1411 let project_layer = ConfigLayer {
1412 deployer: Some(DeployerLayer {
1413 base_domain: Some("project.greentic.test".into()),
1414 provider: None,
1415 }),
1416 ..Default::default()
1417 };
1418 let env_layer = ConfigLayer {
1419 deployer: Some(DeployerLayer {
1420 base_domain: Some("env.greentic.test".into()),
1421 provider: None,
1422 }),
1423 ..Default::default()
1424 };
1425 let cli_layer = ConfigLayer {
1426 deployer: Some(DeployerLayer {
1427 base_domain: Some("cli.greentic.test".into()),
1428 provider: None,
1429 }),
1430 ..Default::default()
1431 };
1432
1433 let mut merged = merge::MergeState::new(
1434 loaders::default_layer(&root, &default_paths),
1435 ConfigSource::Default,
1436 );
1437 merged.apply(user_layer, ConfigSource::User);
1438 merged.apply(project_layer, ConfigSource::Project);
1439 merged.apply(env_layer, ConfigSource::Environment);
1440 merged.apply(cli_layer, ConfigSource::Cli);
1441
1442 let (resolved, provenance, _) = merged.finalize(&default_paths).unwrap();
1443 let base_domain = resolved
1444 .deployer
1445 .as_ref()
1446 .and_then(|d| d.base_domain.as_deref())
1447 .unwrap();
1448 assert_eq!(base_domain, "cli.greentic.test");
1449 assert_eq!(
1450 provenance
1451 .get(&greentic_config_types::ProvenancePath(
1452 "deployer.base_domain".into()
1453 ))
1454 .cloned(),
1455 Some(ConfigSource::Cli)
1456 );
1457 }
1458
1459 #[test]
1460 fn explicit_config_path_replaces_project_discovery() {
1461 let tmp = tempdir().unwrap();
1462 let root = tmp.path().to_path_buf();
1463 std::fs::create_dir_all(root.join(".greentic")).unwrap();
1464
1465 std::fs::write(
1466 root.join(".greentic").join("config.toml"),
1467 r#"
1468[environment]
1469env_id = "staging"
1470"#,
1471 )
1472 .unwrap();
1473
1474 let explicit_path = root.join("explicit.toml");
1475 std::fs::write(
1476 &explicit_path,
1477 r#"
1478[environment]
1479env_id = "prod"
1480"#,
1481 )
1482 .unwrap();
1483
1484 let resolver = ConfigResolver::new()
1485 .with_project_root(root.clone())
1486 .with_config_path(explicit_path.clone());
1487 let (layer, origin) = resolver.load_project_layer(&root).unwrap();
1488 assert_eq!(origin, crate::paths::absolute_path(&explicit_path).unwrap());
1489
1490 let env_id = layer.environment.unwrap().env_id.unwrap();
1491 let env_json = serde_json::to_string(&env_id).unwrap();
1492 assert!(env_json.contains("prod"));
1493 }
1494
1495 #[test]
1496 fn explicit_config_path_missing_is_error() {
1497 let tmp = tempdir().unwrap();
1498 let root = tmp.path().to_path_buf();
1499 let missing = root.join("missing.toml");
1500
1501 let resolver = ConfigResolver::new()
1502 .with_project_root(root.clone())
1503 .with_config_path(missing.clone());
1504 let err = resolver.load_project_layer(&root).unwrap_err();
1505 let msg = err.to_string();
1506 assert!(msg.contains("explicit config file not found"));
1507 assert!(
1508 msg.contains(
1509 &crate::paths::absolute_path(&missing)
1510 .unwrap()
1511 .display()
1512 .to_string()
1513 )
1514 );
1515 }
1516
1517 #[test]
1518 fn detailed_precedence_prefers_cli_and_tracks_origin() {
1519 let tmp = tempdir().unwrap();
1520 let root = tmp.path().to_path_buf();
1521 let default_paths = DefaultPaths::from_root(&root);
1522
1523 let base = crate::loaders::default_layer(&root, &default_paths);
1524 let mut merged = crate::merge::MergeStateDetailed::new(
1525 base,
1526 crate::merge::ProvenanceCtx::new(ConfigSource::Default, Some("defaults".into())),
1527 );
1528
1529 let user_layer = ConfigLayer {
1530 paths: Some(crate::loaders::PathsLayer {
1531 state_dir: Some(PathBuf::from("/tmp/user-state")),
1532 ..Default::default()
1533 }),
1534 ..Default::default()
1535 };
1536 merged.apply(
1537 user_layer,
1538 crate::merge::ProvenanceCtx::new(ConfigSource::User, Some("/user/config.toml".into())),
1539 );
1540
1541 let project_layer = ConfigLayer {
1542 paths: Some(crate::loaders::PathsLayer {
1543 state_dir: Some(PathBuf::from("/tmp/project-state")),
1544 ..Default::default()
1545 }),
1546 ..Default::default()
1547 };
1548 merged.apply(
1549 project_layer,
1550 crate::merge::ProvenanceCtx::new(
1551 ConfigSource::Project,
1552 Some("/repo/.greentic/config.toml".into()),
1553 ),
1554 );
1555
1556 let env_layers = crate::loaders::load_env_layers_detailed_from([(
1557 "GREENTIC_PATHS_STATE_DIR".to_string(),
1558 "/tmp/env-state".to_string(),
1559 )]);
1560 for (layer, key, _) in env_layers {
1561 merged.apply(
1562 layer,
1563 crate::merge::ProvenanceCtx::new(ConfigSource::Environment, Some(key)),
1564 );
1565 }
1566
1567 let cli_layer = ConfigLayer {
1568 paths: Some(crate::loaders::PathsLayer {
1569 state_dir: Some(PathBuf::from("/tmp/cli-state")),
1570 ..Default::default()
1571 }),
1572 ..Default::default()
1573 };
1574 merged.apply(
1575 cli_layer,
1576 crate::merge::ProvenanceCtx::new(ConfigSource::Cli, Some("cli".into())),
1577 );
1578
1579 let (resolved, provenance, _) = merged.finalize_detailed(&default_paths).unwrap();
1580 assert_eq!(resolved.paths.state_dir, PathBuf::from("/tmp/cli-state"));
1581 let rec = provenance
1582 .get(&greentic_config_types::ProvenancePath(
1583 "paths.state_dir".into(),
1584 ))
1585 .unwrap();
1586 assert_eq!(rec.source, ConfigSource::Cli);
1587 assert_eq!(rec.origin.as_deref(), Some("cli"));
1588 }
1589
1590 #[test]
1591 fn offline_telemetry_endpoint_warns_unless_allow_network() {
1592 let tmp = tempdir().unwrap();
1593 let root = tmp.path().to_path_buf();
1594 let default_paths = DefaultPaths::from_root(&root);
1595 let merged = crate::merge::MergeState::new(
1596 crate::loaders::default_layer(&root, &default_paths),
1597 ConfigSource::Default,
1598 );
1599 let (mut config, _, _) = merged.finalize(&default_paths).unwrap();
1600 config.environment.connection = Some(ConnectionKind::Offline);
1601 config.telemetry.enabled = true;
1602 config.telemetry.endpoint = Some("https://otlp.example.com:4317".into());
1603 config.telemetry.exporter = greentic_config_types::TelemetryExporterKind::Otlp;
1604
1605 let warnings =
1606 crate::validate::validate_config_with_overrides(&config, true, false).unwrap();
1607 assert!(warnings.iter().any(|w| w.contains("telemetry.endpoint")));
1608
1609 let warnings =
1610 crate::validate::validate_config_with_overrides(&config, true, true).unwrap();
1611 assert!(!warnings.iter().any(|w| w.contains("telemetry.endpoint")));
1612 }
1613
1614 #[test]
1615 fn offline_events_endpoint_error_is_suppressed_with_allow_network() {
1616 let tmp = tempdir().unwrap();
1617 let root = tmp.path().to_path_buf();
1618 let default_paths = DefaultPaths::from_root(&root);
1619 let merged = crate::merge::MergeState::new(
1620 crate::loaders::default_layer(&root, &default_paths),
1621 ConfigSource::Default,
1622 );
1623 let (mut config, _, _) = merged.finalize(&default_paths).unwrap();
1624 config.environment.connection = Some(ConnectionKind::Offline);
1625 config.services = Some(greentic_config_types::ServicesConfig {
1626 events: Some(greentic_config_types::ServiceEndpointConfig {
1627 url: Url::parse("https://events.example.com").unwrap(),
1628 headers: None,
1629 }),
1630 ..Default::default()
1631 });
1632
1633 let err =
1634 crate::validate::validate_config_with_overrides(&config, true, false).unwrap_err();
1635 assert!(matches!(
1636 err,
1637 crate::validate::ValidationError::EventsEndpointOffline(_)
1638 ));
1639
1640 let warnings =
1641 crate::validate::validate_config_with_overrides(&config, true, true).unwrap();
1642 assert!(
1643 !warnings
1644 .iter()
1645 .any(|w| w.contains("events endpoint") || w.contains("EventsEndpointOffline"))
1646 );
1647 }
1648
1649 #[test]
1650 fn offline_service_transport_emits_warning() {
1651 let tmp = tempdir().unwrap();
1652 let root = tmp.path().to_path_buf();
1653 let default_paths = DefaultPaths::from_root(&root);
1654 let mut merged = crate::merge::MergeState::new(
1655 crate::loaders::default_layer(&root, &default_paths),
1656 ConfigSource::Default,
1657 );
1658
1659 let layer = ConfigLayer {
1660 environment: Some(EnvironmentLayer {
1661 env_id: Some(serde_json::from_str("\"dev\"").unwrap()),
1662 deployment: None,
1663 connection: Some(ConnectionKind::Offline),
1664 region: None,
1665 }),
1666 services: Some(ServicesLayer {
1667 runner: Some(ServiceLayer {
1668 transport: ServiceTransportLayer {
1669 kind: Some("http".into()),
1670 url: Some(Url::parse("https://runner.example.com").unwrap()),
1671 ..Default::default()
1672 },
1673 ..Default::default()
1674 }),
1675 ..Default::default()
1676 }),
1677 ..Default::default()
1678 };
1679
1680 merged.apply(layer, ConfigSource::Project);
1681 let (resolved, _, _) = merged.finalize(&default_paths).unwrap();
1682 let warnings = crate::validate::validate_config_with_overrides(&resolved, true, false)
1683 .expect("validation should warn, not error");
1684 assert!(
1685 warnings
1686 .iter()
1687 .any(|w| w.contains("services.runner") && w.contains("offline"))
1688 );
1689 }
1690}