1use serde::{Deserialize, Serialize};
2use std::{fmt, str::FromStr};
3
4#[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 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 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}