Skip to main content

runtime_paths/
env.rs

1use serde::{Deserialize, Serialize};
2use std::{fmt, str::FromStr};
3
4/// A local runtime environment, independent of build profile and cloud destination.
5/// Only an absent wire field defaults to production; invalid values fail decoding.
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum RuntimeEnv {
9    #[default]
10    Prod,
11    Dev,
12    E2e,
13}
14
15impl RuntimeEnv {
16    pub fn current() -> Self {
17        crate::runtime_env()
18    }
19    pub fn is_prod(&self) -> bool {
20        *self == Self::Prod
21    }
22
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Self::Prod => "prod",
26            Self::Dev => "dev",
27            Self::E2e => "e2e",
28        }
29    }
30
31    pub fn from_optional(value: Option<&str>) -> anyhow::Result<Self> {
32        value.map(Self::from_str).unwrap_or(Ok(Self::Prod))
33    }
34
35    pub fn from_txt(value: Option<Option<&[u8]>>) -> anyhow::Result<Self> {
36        match value {
37            None => Ok(Self::Prod),
38            Some(Some(value)) => Self::from_str(std::str::from_utf8(value)?),
39            Some(None) => anyhow::bail!("runtime environment TXT property has no value"),
40        }
41    }
42
43    /// Production names stay unchanged so existing peers continue to interoperate.
44    pub fn mdns_service_type(self, component: &str) -> &'static str {
45        match (self, component) {
46            (Self::Prod, "host") => "_mhome-host._tcp.local.",
47            (Self::Prod, "hub") => "_mhome-hub._tcp.local.",
48            (Self::Prod, "node") => "_mhome-node._tcp.local.",
49            (Self::Dev, "host") => "_mhome-host-dev._tcp.local.",
50            (Self::Dev, "hub") => "_mhome-hub-dev._tcp.local.",
51            (Self::Dev, "node") => "_mhome-node-dev._tcp.local.",
52            (Self::E2e, "host") => "_mhome-host-e2e._tcp.local.",
53            (Self::E2e, "hub") => "_mhome-hub-e2e._tcp.local.",
54            (Self::E2e, "node") => "_mhome-node-e2e._tcp.local.",
55            _ => panic!("unsupported runtime discovery component"),
56        }
57    }
58
59    pub fn insert_mdns_property(self, properties: &mut impl Extend<(String, String)>) {
60        if !self.is_prod() {
61            properties.extend([("env".into(), self.as_str().into())]);
62        }
63    }
64
65    /// Runtime management must not fall back to a cloud route lacking an env proof.
66    /// Cloud-owned plugin catalogs and ordinary business requests are unaffected.
67    pub fn requires_direct_management(self, target: &str) -> bool {
68        !self.is_prod()
69            && (target.starts_with("/app/system/hosts/")
70                || (target.starts_with("/app/plugin/") && target != "/app/plugin/catalog/list"))
71    }
72}
73
74impl FromStr for RuntimeEnv {
75    type Err = anyhow::Error;
76    fn from_str(value: &str) -> Result<Self, Self::Err> {
77        match value {
78            "prod" => Ok(Self::Prod),
79            "dev" => Ok(Self::Dev),
80            "e2e" => Ok(Self::E2e),
81            _ => anyhow::bail!("invalid runtime environment"),
82        }
83    }
84}
85
86impl fmt::Display for RuntimeEnv {
87    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88        formatter.write_str(self.as_str())
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    #[test]
96    fn only_absence_defaults_to_prod() {
97        assert_eq!(RuntimeEnv::from_optional(None).unwrap(), RuntimeEnv::Prod);
98        for invalid in ["", "DEV", " dev", "test"] {
99            assert!(RuntimeEnv::from_optional(Some(invalid)).is_err());
100        }
101        assert_eq!(
102            RuntimeEnv::from_optional(Some("dev")).unwrap(),
103            RuntimeEnv::Dev
104        );
105        assert!(serde_json::from_str::<RuntimeEnv>("null").is_err());
106        assert_eq!(RuntimeEnv::from_txt(None).unwrap(), RuntimeEnv::Prod);
107        assert_eq!(
108            RuntimeEnv::from_txt(Some(Some(b"dev"))).unwrap(),
109            RuntimeEnv::Dev
110        );
111        for value in [
112            None,
113            Some(b"".as_slice()),
114            Some(b"DEV".as_slice()),
115            Some(&[255][..]),
116        ] {
117            assert!(RuntimeEnv::from_txt(Some(value)).is_err());
118        }
119    }
120    #[test]
121    fn production_routes_and_discovery_are_compatible() {
122        for component in ["host", "hub", "node"] {
123            assert_ne!(
124                RuntimeEnv::Prod.mdns_service_type(component),
125                RuntimeEnv::Dev.mdns_service_type(component)
126            );
127            assert_ne!(
128                RuntimeEnv::Dev.mdns_service_type(component),
129                RuntimeEnv::E2e.mdns_service_type(component)
130            );
131        }
132        assert!(!RuntimeEnv::Prod.requires_direct_management("/app/system/hosts/runtime"));
133        assert!(RuntimeEnv::Dev.requires_direct_management("/app/system/hosts/runtime"));
134        assert!(RuntimeEnv::E2e.requires_direct_management("/app/plugin/enabled/list"));
135        assert!(!RuntimeEnv::Dev.requires_direct_management("/app/plugin/catalog/list"));
136        assert!(!RuntimeEnv::Dev.requires_direct_management("/app/person/list"));
137    }
138}