Skip to main content

greentic_deploy_spec/
environment.rs

1//! `greentic.environment.v1` (`§5.1`).
2//!
3//! Top-level Environment compose-view. Decomposes into three persistence units
4//! on disk (`environment.json`, `env-packs/<slot>/answers.json`, `runtime.json`)
5//! — the in-memory `Environment` is the union of those, owned by A2's
6//! `EnvironmentStore`.
7
8use crate::bundle_deployment::BundleDeployment;
9use crate::capability_slot::{CapabilitySlot, PackDescriptor};
10use crate::error::SpecError;
11use crate::ids::PackId;
12use crate::messaging_endpoint::MessagingEndpoint;
13use crate::refs::{ExtensionRef, SecretRef};
14use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
15use crate::revision::Revision;
16use crate::traffic_split::TrafficSplit;
17use crate::version::SchemaVersion;
18use greentic_types::EnvId;
19use serde::{Deserialize, Serialize};
20use std::collections::HashSet;
21use std::net::{IpAddr, Ipv4Addr, SocketAddr};
22use std::path::PathBuf;
23
24/// Default bind address for the runtime's local HTTP listener when
25/// [`EnvironmentHostConfig::listen_addr`] is unset and no runtime-level
26/// override applies. Loopback by design — exposing externally is an explicit
27/// opt-in via `op config set listen_addr 0.0.0.0:<port>`.
28pub const DEFAULT_LISTEN_ADDR: SocketAddr =
29    SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080);
30
31/// Host-level config moved out of `greentic-config-types::EnvironmentConfig`
32/// (`§5.1`). Identity-only — connectivity, region, and deployment ctx; nothing
33/// secret, nothing tenant-scoped.
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub struct EnvironmentHostConfig {
36    pub env_id: EnvId,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub region: Option<String>,
39    /// Tenant organization the env belongs to. `None` for `local`.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub tenant_org_id: Option<String>,
42    /// Bind address for the runtime's local HTTP listener. Set at `op env init`
43    /// to [`DEFAULT_LISTEN_ADDR`] so a freshly-initialized env can be started
44    /// with no bundles attached. The runtime (`gtc start`) may layer its own
45    /// env-var override on top — see the `greentic-start` docs for the
46    /// concrete name and precedence; this crate stays implementation-agnostic.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub listen_addr: Option<SocketAddr>,
49    /// Persistent public base URL the runtime exposes (e.g. via a static
50    /// tunnel or load balancer). Stored as origin only — `https://host[:port]`,
51    /// no path, no query, no fragment. Validated by [`Environment::validate`]
52    /// (so save AND load both reject invalid values via [`LocalFsStore`]).
53    /// Runtime precedence (env var override vs. tunnel-discovered vs. persisted)
54    /// is `greentic-start`'s concern; this crate persists the configured value
55    /// only.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub public_base_url: Option<String>,
58    /// Whether the runtime serves the built-in webchat GUI for this env.
59    /// `None` = unset → resolved by [`EnvironmentHostConfig::resolved_gui_enabled`]
60    /// (on for `local`, off elsewhere — the chat path is loopback-only and
61    /// unauthenticated, so it stays off public envs unless explicitly enabled).
62    /// `Some(b)` is an explicit operator/wizard choice that overrides the
63    /// env-id default either way.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub gui_enabled: Option<bool>,
66}
67
68/// Env id whose runtime serves the built-in webchat GUI by default. Other
69/// envs default off because the chat path is loopback-only and unauthenticated
70/// — exposing it on a public env is an explicit opt-in.
71pub const GUI_DEFAULT_ENV_ID: &str = "local";
72
73impl EnvironmentHostConfig {
74    /// Resolves the bind address using `self.listen_addr` falling back to
75    /// [`DEFAULT_LISTEN_ADDR`]. Runtime-level env-var precedence (if any) is
76    /// the caller's responsibility — this helper is the persisted-state
77    /// resolution only.
78    pub fn resolved_listen_addr(&self) -> SocketAddr {
79        self.listen_addr.unwrap_or(DEFAULT_LISTEN_ADDR)
80    }
81
82    /// Resolves whether the runtime should serve the built-in webchat GUI.
83    /// Explicit [`gui_enabled`](Self::gui_enabled) wins; when unset the GUI is
84    /// on only for [`GUI_DEFAULT_ENV_ID`] (`local`). Both the deployer's apply
85    /// engine and `greentic-start`'s boot path read through this helper so the
86    /// default lives in exactly one place.
87    pub fn resolved_gui_enabled(&self) -> bool {
88        self.gui_enabled
89            .unwrap_or(self.env_id.as_str() == GUI_DEFAULT_ENV_ID)
90    }
91}
92
93/// Normalize and validate a candidate `public_base_url`. Returns the canonical
94/// form (trimmed, trailing `/` removed) on success. Rules mirror
95/// `greentic-start::startup_contract::normalize_public_base_url` so a value
96/// accepted here passes the runtime's gate without reformatting.
97///
98/// - Scheme MUST be `http://` or `https://`.
99/// - MUST include a non-empty host.
100/// - MUST NOT contain whitespace.
101/// - MUST NOT include userinfo (`user:pass@`).
102/// - MUST NOT include a query string (`?...`).
103/// - MUST NOT include a fragment (`#...`).
104/// - Path MUST be empty or exactly `/`.
105pub fn validate_public_base_url(value: &str) -> Result<String, crate::error::SpecError> {
106    let trimmed = value.trim();
107    let invalid = |reason: &'static str| crate::error::SpecError::InvalidPublicBaseUrl {
108        value: trimmed.to_string(),
109        reason,
110    };
111    if trimmed.is_empty() {
112        return Err(invalid("must not be empty"));
113    }
114    if trimmed.chars().any(char::is_whitespace) {
115        return Err(invalid("must not contain whitespace"));
116    }
117    // Parse via http::Uri for robust authority/port/host validation.
118    let uri: http::Uri = trimmed.parse().map_err(|_| invalid("is not a valid URI"))?;
119    // Require http or https scheme.
120    match uri.scheme_str() {
121        Some("http") | Some("https") => {}
122        _ => return Err(invalid("must start with http:// or https://")),
123    }
124    // Require authority (host[:port]).
125    let authority = uri
126        .authority()
127        .ok_or_else(|| invalid("must include a host"))?;
128    // Reject userinfo.
129    if authority.as_str().contains('@') {
130        return Err(invalid("must not include userinfo"));
131    }
132    // Reject empty host.
133    if authority.host().is_empty() {
134        return Err(invalid("must include a host"));
135    }
136    // Reject non-numeric port: http::Uri accepts `host:bad` (port() → None)
137    // but we require a valid numeric port if `:` follows the host.
138    if authority.as_str().len() > authority.host().len() && authority.port_u16().is_none() {
139        // There's text after the host (a `:something`) but it's not a valid port.
140        return Err(invalid("port is not a valid number"));
141    }
142    // Reject query.
143    if uri.query().is_some() {
144        return Err(invalid("must not include a query string"));
145    }
146    // Reject fragment (http::Uri does not parse fragments, but guard anyway).
147    if trimmed.contains('#') {
148        return Err(invalid("must not include a fragment"));
149    }
150    // Path must be empty or exactly "/".
151    let path = uri.path();
152    if !path.is_empty() && path != "/" {
153        return Err(invalid("must be an origin without a path"));
154    }
155    Ok(trimmed.trim_end_matches('/').to_string())
156}
157
158/// Binding from a [`CapabilitySlot`] to a concrete pack (`§5.1`).
159#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
160pub struct EnvPackBinding {
161    pub slot: CapabilitySlot,
162    pub kind: PackDescriptor,
163    pub pack_ref: PackId,
164    /// `env-packs/<slot>/answers.json` (env-relative path).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub answers_ref: Option<PathBuf>,
167    /// Bumped on attach/update/remove/rollback.
168    #[serde(default)]
169    pub generation: u64,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub previous_binding_ref: Option<PathBuf>,
172}
173
174/// An open-namespace capability binding (`§5.1`, Path 3).
175///
176/// Unlike [`EnvPackBinding`] it carries no `slot` field — its slot is always
177/// [`CapabilitySlot::Extension`](crate::CapabilitySlot::Extension). Its identity
178/// is `(kind.path(), instance_id)`: the descriptor path plus an optional
179/// instance selector distinguishing N instances of the same extension type.
180/// Bindings live in [`Environment::extensions`], never in
181/// [`Environment::packs`], so the 1-per-slot rule does not apply; a workload
182/// resolves one by name via `ext://<path>[/<instance>]`
183/// ([`ExtensionRef`](crate::ExtensionRef)) — no typed host interface is wired.
184#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
185pub struct ExtensionBinding {
186    pub kind: PackDescriptor,
187    pub pack_ref: PackId,
188    /// Distinguishes N instances of the SAME extension type. `None` ⇒ the
189    /// descriptor path is the whole key (the single default instance). A
190    /// `None` binding and a `Some(..)` binding on the same path coexist; two
191    /// `None` bindings on the same path collide.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub instance_id: Option<String>,
194    /// `extensions/<path>[-<instance>]/answers.json` (env-relative path).
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub answers_ref: Option<PathBuf>,
197    /// Bumped on attach/update/remove/rollback.
198    #[serde(default)]
199    pub generation: u64,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub previous_binding_ref: Option<PathBuf>,
202}
203
204impl ExtensionBinding {
205    /// Per-document invariants. The `(path, instance_id)` uniqueness check is a
206    /// cross-document invariant on [`Environment::validate`] where the sibling
207    /// bindings are in scope.
208    pub fn validate(&self) -> Result<(), SpecError> {
209        if let Some(inst) = &self.instance_id {
210            crate::refs::validate_instance_id(inst).map_err(|e| {
211                SpecError::InvalidExtensionInstanceId {
212                    path: self.kind.path().to_string(),
213                    reason: e.to_string(),
214                }
215            })?;
216        }
217        Ok(())
218    }
219}
220
221/// `greentic.environment.v1` compose-view (`§5.1`).
222#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
223pub struct Environment {
224    pub schema: SchemaVersion,
225    pub environment_id: EnvId,
226    pub name: String,
227    pub host_config: EnvironmentHostConfig,
228    /// One entry per [`CapabilitySlot`]. Use [`Environment::validate`] to enforce.
229    pub packs: Vec<EnvPackBinding>,
230    /// `secret://<env>/credentials/...` reference into `packs[secrets]` (P5).
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub credentials_ref: Option<SecretRef>,
233    #[serde(default)]
234    pub bundles: Vec<BundleDeployment>,
235    #[serde(default)]
236    pub revisions: Vec<Revision>,
237    #[serde(default)]
238    pub traffic_splits: Vec<TrafficSplit>,
239    /// Per-environment messaging provider instances (`Phase M1`). N-per-env;
240    /// unique on `endpoint_id` and on `(provider_type, provider_id)`.
241    #[serde(default)]
242    pub messaging_endpoints: Vec<MessagingEndpoint>,
243    /// Open-namespace extension bindings (`Path 3`). N-per-env; unique on
244    /// `(kind.path(), instance_id)`. Resolved by workloads via
245    /// `ext://<path>[/<instance>]`, never linked as a typed host interface and
246    /// never reported in `doctor`'s `missing_slots` (the namespace is open).
247    #[serde(default)]
248    pub extensions: Vec<ExtensionBinding>,
249    #[serde(default)]
250    pub revocation: RevocationConfig,
251    #[serde(default)]
252    pub retention: RetentionPolicy,
253    #[serde(default)]
254    pub health: HealthStatus,
255}
256
257impl Environment {
258    pub fn schema_str() -> &'static str {
259        SchemaVersion::ENVIRONMENT_V1
260    }
261
262    /// Returns the binding for a slot, if any.
263    pub fn pack_for_slot(&self, slot: CapabilitySlot) -> Option<&EnvPackBinding> {
264        self.packs.iter().find(|b| b.slot == slot)
265    }
266
267    /// Resolve an [`ExtensionRef`] to its binding by `(path, instance_id)` —
268    /// the same key [`Environment::validate`] enforces uniqueness on. Returns
269    /// `None` when no extension matches both the path and the (absence of an)
270    /// instance selector.
271    pub fn extension_for_ref(&self, r: &ExtensionRef) -> Option<&ExtensionBinding> {
272        self.extensions
273            .iter()
274            .find(|b| b.kind.path() == r.path() && b.instance_id.as_deref() == r.instance_id())
275    }
276
277    /// Validates spec-level invariants:
278    /// - schema discriminator matches `greentic.environment.v1`,
279    /// - slot uniqueness across `packs`,
280    /// - extension binding uniqueness on `(kind.path(), instance_id)`,
281    /// - basis-points sums on contained `TrafficSplit` / `BundleDeployment`,
282    /// - `env_id` ownership across `host_config`, `revisions`, `bundles`, and
283    ///   `traffic_splits` (every nested doc carries the same env identifier),
284    /// - referential integrity: split entries reference a `Revision` in this
285    ///   env whose `deployment_id` + `bundle_id` match the split's, and every
286    ///   bundle's `current_revisions` references a `Revision` whose
287    ///   `deployment_id` matches the bundle's. Lifecycle-state checks (e.g.
288    ///   `lifecycle == Ready` for split entries per `§5.3`) stay at apply
289    ///   time — pure data invariants only here.
290    pub fn validate(&self) -> Result<(), SpecError> {
291        if self.schema.as_str() != SchemaVersion::ENVIRONMENT_V1 {
292            return Err(SpecError::SchemaMismatch {
293                expected: SchemaVersion::ENVIRONMENT_V1,
294                actual: self.schema.as_str().to_string(),
295            });
296        }
297
298        if self.host_config.env_id != self.environment_id {
299            return Err(SpecError::EnvIdMismatch {
300                context: "host_config",
301                expected: self.environment_id.clone(),
302                actual: self.host_config.env_id.clone(),
303            });
304        }
305
306        if let Some(url) = self.host_config.public_base_url.as_deref() {
307            validate_public_base_url(url)?;
308        }
309
310        // Sized to the `CapabilitySlot` enum cardinality. Bump in lock-step
311        // when the enum grows.
312        let mut seen = [false; CapabilitySlot::ALL.len()];
313        for binding in &self.packs {
314            let idx = binding.slot as usize;
315            if seen[idx] {
316                return Err(SpecError::DuplicateCapabilitySlot(binding.slot));
317            }
318            seen[idx] = true;
319        }
320
321        // `credentials_ref` is documented as `secret://<env>/credentials/...`.
322        // Without this scope check, a saved Environment could persist a
323        // pointer into a different env's secrets backend and bypass tenant
324        // isolation at resolve time.
325        if let Some(cred_ref) = &self.credentials_ref {
326            let actual = cred_ref.env_segment();
327            if actual != self.environment_id.as_str() {
328                return Err(SpecError::CrossEnvRef {
329                    context: "credentials_ref",
330                    uri: cred_ref.as_str().to_string(),
331                    expected_env: self.environment_id.clone(),
332                    actual_env: actual.to_string(),
333                });
334            }
335        }
336
337        for revision in &self.revisions {
338            revision.validate()?;
339            if revision.env_id != self.environment_id {
340                return Err(SpecError::EnvIdMismatch {
341                    context: "revision",
342                    expected: self.environment_id.clone(),
343                    actual: revision.env_id.clone(),
344                });
345            }
346        }
347
348        for bundle in &self.bundles {
349            if bundle.env_id != self.environment_id {
350                return Err(SpecError::EnvIdMismatch {
351                    context: "bundle_deployment",
352                    expected: self.environment_id.clone(),
353                    actual: bundle.env_id.clone(),
354                });
355            }
356            bundle.validate()?;
357            let mut revision_pack_ids: HashSet<&str> = HashSet::new();
358            for rev_id in &bundle.current_revisions {
359                let referenced = self
360                    .revisions
361                    .iter()
362                    .find(|r| r.revision_id == *rev_id)
363                    .ok_or(SpecError::UnknownRevision(*rev_id))?;
364                if referenced.deployment_id != bundle.deployment_id {
365                    return Err(SpecError::BundleRevisionWrongDeployment {
366                        deployment: bundle.deployment_id,
367                        revision: *rev_id,
368                        actual_deployment: referenced.deployment_id,
369                    });
370                }
371                // A `BundleDeployment` is `(deployment_id, bundle_id)`-shaped;
372                // a revision whose `bundle_id` does not match the deployment's
373                // would let the deployment route or bill a different bundle's
374                // revisions. Reject statically.
375                if referenced.bundle_id != bundle.bundle_id {
376                    return Err(SpecError::BundleRevisionWrongBundle {
377                        deployment: bundle.deployment_id,
378                        revision: *rev_id,
379                        expected_bundle: bundle.bundle_id.clone(),
380                        actual_bundle: referenced.bundle_id.clone(),
381                    });
382                }
383                revision_pack_ids.extend(referenced.pack_list.iter().map(|e| e.pack_id.as_str()));
384            }
385
386            // Cross-ref: every config_overrides pack_id must appear in a
387            // non-archived revision's pack_list for this deployment.
388            // Forward-accept when no such revisions yet exist OR when their
389            // pack_list is empty (the in-memory data the validator can see —
390            // disk lock is the source of truth). The override gets
391            // re-validated on the next env.validate() call once a revision
392            // lands with populated pack_list.
393            if !bundle.config_overrides.is_empty() {
394                let mut deployment_pack_ids: HashSet<&str> = HashSet::new();
395                for rev in self.revisions.iter().filter(|r| {
396                    r.deployment_id == bundle.deployment_id
397                        && r.lifecycle != crate::RevisionLifecycle::Archived
398                }) {
399                    deployment_pack_ids.extend(rev.pack_list.iter().map(|e| e.pack_id.as_str()));
400                }
401                if !deployment_pack_ids.is_empty() {
402                    for override_pack_id in bundle.config_overrides.keys() {
403                        if !deployment_pack_ids.contains(override_pack_id.as_str()) {
404                            return Err(SpecError::ConfigOverridePackNotInRevisions {
405                                deployment: bundle.deployment_id,
406                                pack_id: override_pack_id.clone(),
407                            });
408                        }
409                    }
410                }
411            }
412        }
413
414        for split in &self.traffic_splits {
415            if split.env_id != self.environment_id {
416                return Err(SpecError::EnvIdMismatch {
417                    context: "traffic_split",
418                    expected: self.environment_id.clone(),
419                    actual: split.env_id.clone(),
420                });
421            }
422            split.validate()?;
423            // Resolve the referenced BundleDeployment and assert that its
424            // bundle_id matches the split's. Without this, a split's
425            // (deployment_id, bundle_id) pair can diverge from the
426            // deployment's recorded bundle and cross-route traffic.
427            let referenced_bundle = self
428                .bundles
429                .iter()
430                .find(|b| b.deployment_id == split.deployment_id)
431                .ok_or(SpecError::UnknownDeployment(split.deployment_id))?;
432            if referenced_bundle.bundle_id != split.bundle_id {
433                return Err(SpecError::SplitDeploymentBundleMismatch {
434                    deployment: split.deployment_id,
435                    split_bundle: split.bundle_id.clone(),
436                    deployment_bundle: referenced_bundle.bundle_id.clone(),
437                });
438            }
439            for entry in &split.entries {
440                let referenced = self
441                    .revisions
442                    .iter()
443                    .find(|r| r.revision_id == entry.revision_id)
444                    .ok_or(SpecError::UnknownRevision(entry.revision_id))?;
445                if referenced.deployment_id != split.deployment_id {
446                    return Err(SpecError::SplitRevisionWrongDeployment {
447                        revision: entry.revision_id,
448                        expected_deployment: split.deployment_id,
449                        actual_deployment: referenced.deployment_id,
450                    });
451                }
452                if referenced.bundle_id != split.bundle_id {
453                    return Err(SpecError::SplitRevisionWrongBundle {
454                        revision: entry.revision_id,
455                        expected_bundle: split.bundle_id.clone(),
456                        actual_bundle: referenced.bundle_id.clone(),
457                    });
458                }
459            }
460        }
461
462        // Phase M1: messaging endpoint cross-document invariants. Per-document
463        // checks (schema discriminator, non-empty ids, secret-ref env scope)
464        // live on `MessagingEndpoint::validate`.
465        let mut seen_endpoint_ids = HashSet::with_capacity(self.messaging_endpoints.len());
466        let mut seen_provider_instances = HashSet::with_capacity(self.messaging_endpoints.len());
467        for endpoint in &self.messaging_endpoints {
468            endpoint.validate()?;
469            if endpoint.env_id != self.environment_id {
470                return Err(SpecError::EnvIdMismatch {
471                    context: "messaging_endpoint",
472                    expected: self.environment_id.clone(),
473                    actual: endpoint.env_id.clone(),
474                });
475            }
476            if !seen_endpoint_ids.insert(endpoint.endpoint_id) {
477                return Err(SpecError::DuplicateMessagingEndpoint(endpoint.endpoint_id));
478            }
479            let instance_key = (
480                endpoint.provider_type.as_str(),
481                endpoint.provider_id.as_str(),
482            );
483            if !seen_provider_instances.insert(instance_key) {
484                return Err(SpecError::DuplicateProviderInstance {
485                    provider_type: endpoint.provider_type.clone(),
486                    provider_id: endpoint.provider_id.clone(),
487                });
488            }
489            for bundle_id in &endpoint.linked_bundles {
490                if !self.bundles.iter().any(|b| b.bundle_id == *bundle_id) {
491                    return Err(SpecError::MessagingEndpointBundleNotLinked {
492                        endpoint: endpoint.endpoint_id,
493                        bundle: bundle_id.clone(),
494                    });
495                }
496            }
497            if let Some(welcome) = &endpoint.welcome_flow
498                && !endpoint.linked_bundles.contains(&welcome.bundle_id)
499            {
500                return Err(SpecError::WelcomeFlowBundleNotLinked {
501                    endpoint: endpoint.endpoint_id,
502                    bundle: welcome.bundle_id.clone(),
503                });
504            }
505        }
506
507        // Extension bindings (`Path 3`): open N-per-env namespace, unique on
508        // `(kind.path(), instance_id)`. A `None` instance and a `Some(..)`
509        // instance on the same path coexist; two identical keys collide.
510        let mut seen_extensions = HashSet::with_capacity(self.extensions.len());
511        for ext in &self.extensions {
512            ext.validate()?;
513            let key = (ext.kind.path(), ext.instance_id.as_deref());
514            if !seen_extensions.insert(key) {
515                return Err(SpecError::DuplicateExtension {
516                    path: ext.kind.path().to_string(),
517                    instance_id: ext.instance_id.clone(),
518                });
519            }
520        }
521
522        Ok(())
523    }
524}
525
526#[cfg(test)]
527mod public_base_url_tests {
528    use super::validate_public_base_url;
529
530    #[test]
531    fn accepts_https_origin() {
532        assert_eq!(
533            validate_public_base_url("https://chat.example.com").unwrap(),
534            "https://chat.example.com"
535        );
536    }
537
538    #[test]
539    fn accepts_http_origin() {
540        assert_eq!(
541            validate_public_base_url("http://localhost:8080").unwrap(),
542            "http://localhost:8080"
543        );
544    }
545
546    #[test]
547    fn trims_trailing_slash() {
548        // Match `greentic-start::startup_contract::normalize_public_base_url`
549        // so a value persisted here passes the runtime's gate unchanged.
550        assert_eq!(
551            validate_public_base_url("https://chat.example.com/").unwrap(),
552            "https://chat.example.com"
553        );
554    }
555
556    #[test]
557    fn rejects_path() {
558        let err = validate_public_base_url("https://chat.example.com/api").unwrap_err();
559        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
560    }
561
562    #[test]
563    fn rejects_query() {
564        let err = validate_public_base_url("https://chat.example.com?x=1").unwrap_err();
565        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
566    }
567
568    #[test]
569    fn rejects_fragment() {
570        let err = validate_public_base_url("https://chat.example.com#frag").unwrap_err();
571        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
572    }
573
574    #[test]
575    fn rejects_non_http_scheme() {
576        let err = validate_public_base_url("ftp://chat.example.com").unwrap_err();
577        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
578    }
579
580    #[test]
581    fn rejects_missing_scheme() {
582        let err = validate_public_base_url("chat.example.com").unwrap_err();
583        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
584    }
585
586    #[test]
587    fn rejects_empty_host() {
588        let err = validate_public_base_url("https:///path").unwrap_err();
589        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
590    }
591
592    #[test]
593    fn rejects_whitespace() {
594        let err = validate_public_base_url("https://chat .example.com").unwrap_err();
595        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
596    }
597
598    #[test]
599    fn trims_surrounding_whitespace_before_validation() {
600        // Mirrors `normalize_public_base_url`: trim outer whitespace, reject
601        // inner whitespace.
602        assert_eq!(
603            validate_public_base_url("  https://chat.example.com  ").unwrap(),
604            "https://chat.example.com"
605        );
606    }
607
608    #[test]
609    fn rejects_userinfo() {
610        let err = validate_public_base_url("https://user:pass@example.com").unwrap_err();
611        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
612    }
613
614    #[test]
615    fn rejects_empty_host_in_authority() {
616        // `https://:443` has an empty host but non-empty authority.
617        let err = validate_public_base_url("https://:443").unwrap_err();
618        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
619    }
620
621    #[test]
622    fn rejects_authority_with_bad_port() {
623        // `http::Uri` rejects a non-numeric port at parse time.
624        let err = validate_public_base_url("https://example.com:bad").unwrap_err();
625        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
626    }
627
628    #[test]
629    fn accepts_ipv6_origin() {
630        // Parity with `greentic-start::normalize_public_base_url`.
631        assert_eq!(
632            validate_public_base_url("https://[::1]:8080").unwrap(),
633            "https://[::1]:8080"
634        );
635    }
636}
637
638#[cfg(test)]
639mod gui_enabled_tests {
640    use super::{EnvironmentHostConfig, GUI_DEFAULT_ENV_ID};
641    use greentic_types::EnvId;
642
643    fn host_config(env_id: &str, gui_enabled: Option<bool>) -> EnvironmentHostConfig {
644        EnvironmentHostConfig {
645            env_id: EnvId::try_from(env_id).unwrap(),
646            region: None,
647            tenant_org_id: None,
648            listen_addr: None,
649            public_base_url: None,
650            gui_enabled,
651        }
652    }
653
654    #[test]
655    fn unset_defaults_on_for_local() {
656        assert_eq!(GUI_DEFAULT_ENV_ID, "local");
657        assert!(host_config("local", None).resolved_gui_enabled());
658    }
659
660    #[test]
661    fn unset_defaults_off_for_non_local() {
662        assert!(!host_config("staging", None).resolved_gui_enabled());
663    }
664
665    #[test]
666    fn explicit_value_overrides_env_id_default() {
667        // Off on local (the wizard "no" case) ...
668        assert!(!host_config("local", Some(false)).resolved_gui_enabled());
669        // ... and on for a non-local env (explicit opt-in).
670        assert!(host_config("staging", Some(true)).resolved_gui_enabled());
671    }
672}