greentic_config/
lib.rs

1//! Enterprise-ready configuration resolver for Greentic hosts.
2//!
3//! This crate loads `GreenticConfig` (from `greentic-config-types`) from defaults, user config,
4//! project config, environment variables, and CLI overrides with strict precedence:
5//! `CLI > env > project > user > defaults`.
6//!
7//! Use `ConfigResolver::load()` for source-only provenance, or `ConfigResolver::load_detailed()` for
8//! per-leaf provenance that also includes origin (file path / env var name / `cli`).
9
10mod 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}