1use serde::{Deserialize, Serialize};
2use std::{path::Path, 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
86pub fn env_for_workdir(workdir: &Path, runtime_root: &Path) -> anyhow::Result<RuntimeEnv> {
87 let root = runtime_root
88 .canonicalize()
89 .unwrap_or_else(|_| runtime_root.to_path_buf());
90 let resolved = workdir
91 .canonicalize()
92 .unwrap_or_else(|_| workdir.to_path_buf());
93 if let Some(name) = workdir.file_name().filter(|n| *n == "dev" || *n == "e2e") {
94 if workdir
95 .parent()
96 .map(|p| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()))
97 .as_ref()
98 == Some(&root)
99 {
100 anyhow::ensure!(
101 resolved == root.join(name),
102 "Dev/E2E workdir must not redirect to another environment"
103 );
104 }
105 }
106 if resolved == root.join("dev") {
107 Ok(RuntimeEnv::Dev)
108 } else if resolved == root.join("e2e") {
109 Ok(RuntimeEnv::E2e)
110 } else {
111 Ok(RuntimeEnv::Prod)
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 #[test]
119 fn only_absence_defaults_to_prod() {
120 assert_eq!(RuntimeEnv::from_optional(None).unwrap(), RuntimeEnv::Prod);
121 for invalid in ["", "DEV", " dev", "test"] {
122 assert!(RuntimeEnv::from_optional(Some(invalid)).is_err());
123 }
124 assert_eq!(
125 RuntimeEnv::from_optional(Some("dev")).unwrap(),
126 RuntimeEnv::Dev
127 );
128 assert!(serde_json::from_str::<RuntimeEnv>("null").is_err());
129 assert_eq!(RuntimeEnv::from_txt(None).unwrap(), RuntimeEnv::Prod);
130 assert_eq!(
131 RuntimeEnv::from_txt(Some(Some(b"dev"))).unwrap(),
132 RuntimeEnv::Dev
133 );
134 for value in [
135 None,
136 Some(b"".as_slice()),
137 Some(b"DEV".as_slice()),
138 Some(&[255][..]),
139 ] {
140 assert!(RuntimeEnv::from_txt(Some(value)).is_err());
141 }
142 }
143 #[test]
144 fn standard_roots_and_custom_directories() {
145 let root = Path::new("/tmp/env-test/.meow");
146 for env in [RuntimeEnv::Prod, RuntimeEnv::Dev, RuntimeEnv::E2e] {
147 let dir = if env.is_prod() {
148 root.to_path_buf()
149 } else {
150 root.join(env.as_str())
151 };
152 assert_eq!(env_for_workdir(&dir, root).unwrap(), env);
153 }
154 assert_eq!(
155 env_for_workdir(Path::new("/tmp/custom/dev"), root).unwrap(),
156 RuntimeEnv::Prod
157 );
158 }
159 #[test]
160 fn production_routes_and_discovery_are_compatible() {
161 for component in ["host", "hub", "node"] {
162 assert_ne!(
163 RuntimeEnv::Prod.mdns_service_type(component),
164 RuntimeEnv::Dev.mdns_service_type(component)
165 );
166 assert_ne!(
167 RuntimeEnv::Dev.mdns_service_type(component),
168 RuntimeEnv::E2e.mdns_service_type(component)
169 );
170 }
171 assert!(!RuntimeEnv::Prod.requires_direct_management("/app/system/hosts/runtime"));
172 assert!(RuntimeEnv::Dev.requires_direct_management("/app/system/hosts/runtime"));
173 assert!(RuntimeEnv::E2e.requires_direct_management("/app/plugin/enabled/list"));
174 assert!(!RuntimeEnv::Dev.requires_direct_management("/app/plugin/catalog/list"));
175 assert!(!RuntimeEnv::Dev.requires_direct_management("/app/person/list"));
176 }
177}