mecha10_core/context/
node_config.rs1use super::Context;
4use crate::error::{Mecha10Error, Result};
5use anyhow::Context as _;
6use serde::de::DeserializeOwned;
7
8impl Context {
9 pub async fn load_node_config<T>(&self, node_name: &str) -> Result<T>
71 where
72 T: DeserializeOwned + Default,
73 {
74 Self::resolve_node_config(node_name).await
75 }
76
77 async fn resolve_node_config<T>(node_name: &str) -> Result<T>
81 where
82 T: DeserializeOwned + Default,
83 {
84 use std::env;
85
86 let environment = env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
87
88 let current_dir = env::current_dir()
89 .map_err(|e| Mecha10Error::Configuration(format!("Failed to get current directory: {}", e)))?;
90
91 if let Some(project_root) = crate::fs_utils::find_project_root(¤t_dir) {
92 let flat_config_path = project_root
93 .join("configs")
94 .join("nodes")
95 .join("@mecha10")
96 .join(node_name)
97 .join("config.json");
98
99 if flat_config_path.exists() {
100 tracing::debug!(
101 "Loading node config for '{}' from flat path: {}",
102 node_name,
103 flat_config_path.display()
104 );
105 return Self::load_environment_aware_config(&flat_config_path, &environment)
106 .with_context(|| {
107 format!(
108 "Failed to load node config for '{}' from '{}'",
109 node_name,
110 flat_config_path.display()
111 )
112 })
113 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
114 }
115
116 let project_config_path = project_root
117 .join("configs")
118 .join("nodes")
119 .join(node_name)
120 .join("config.json");
121
122 if project_config_path.exists() {
123 tracing::debug!(
124 "Loading node config for '{}' from project path: {}",
125 node_name,
126 project_config_path.display()
127 );
128 return Self::load_environment_aware_config(&project_config_path, &environment)
129 .with_context(|| {
130 format!(
131 "Failed to load node config for '{}' from '{}'",
132 node_name,
133 project_config_path.display()
134 )
135 })
136 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
137 }
138 }
139
140 tracing::debug!(
141 "No config file found for node '{}', using Default implementation",
142 node_name
143 );
144 let config = T::default();
145 Ok(config)
146 }
147
148 fn load_environment_aware_config<T>(path: &std::path::Path, environment: &str) -> Result<T>
158 where
159 T: DeserializeOwned + Default,
160 {
161 let content =
162 std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
163
164 let json_value: serde_json::Value =
165 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;
166
167 let obj = json_value.as_object().ok_or_else(|| {
168 Mecha10Error::Configuration(format!(
169 "Config must be a JSON object with 'dev'/'production' keys: {}",
170 path.display()
171 ))
172 })?;
173
174 let env_key = match environment {
175 "production" | "prod" => "production",
176 _ => "dev",
177 };
178
179 let env_section = obj
180 .get(env_key)
181 .or_else(|| {
182 let fallback = if env_key == "dev" { "production" } else { "dev" };
183 tracing::warn!(
184 "Environment '{}' not found in config, falling back to '{}': {}",
185 env_key,
186 fallback,
187 path.display()
188 );
189 obj.get(fallback)
190 })
191 .ok_or_else(|| {
192 Mecha10Error::Configuration(format!(
193 "Config must have 'dev' or 'production' section: {}",
194 path.display()
195 ))
196 })?;
197
198 tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
199 let config: T = serde_json::from_value(env_section.clone())
200 .with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
201 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
202
203 Ok(config)
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::Context;
210 use serde::{Deserialize, Serialize};
211 use serial_test::serial;
212 use std::fs;
213
214 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
215 struct TestNodeConfig {
216 #[serde(default)]
217 value: String,
218 }
219
220 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
225 #[serde(deny_unknown_fields)]
226 struct StrictTestNodeConfig {
227 #[serde(default)]
228 value: String,
229 #[serde(flatten)]
230 relay: crate::relay_topics::RelayTopicsField,
231 }
232
233 fn envelope(dev_value: &str) -> String {
234 format!(r#"{{"dev": {{"value": "{dev_value}"}}, "production": {{"value": "{dev_value}-prod"}}}}"#)
235 }
236
237 #[tokio::test]
238 #[serial]
239 async fn resolve_node_config_loads_from_project_tier_when_only_project_config_exists() {
240 let original_dir = std::env::current_dir().unwrap();
241
242 let project = tempfile::tempdir().unwrap();
243 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
244 let node_dir = project.path().join("configs").join("nodes").join("my-node");
245 fs::create_dir_all(&node_dir).unwrap();
246 fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
247
248 std::env::set_current_dir(project.path()).unwrap();
249 let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
250 std::env::set_current_dir(original_dir).unwrap();
251
252 assert_eq!(result.unwrap().value, "project");
253 }
254
255 #[tokio::test]
256 #[serial]
257 async fn resolve_node_config_prefers_framework_tier_over_project_tier() {
258 let original_dir = std::env::current_dir().unwrap();
259
260 let project = tempfile::tempdir().unwrap();
261 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
262
263 let framework_dir = project
264 .path()
265 .join("configs")
266 .join("nodes")
267 .join("@mecha10")
268 .join("my-node");
269 fs::create_dir_all(&framework_dir).unwrap();
270 fs::write(framework_dir.join("config.json"), envelope("framework")).unwrap();
271
272 let project_dir = project.path().join("configs").join("nodes").join("my-node");
273 fs::create_dir_all(&project_dir).unwrap();
274 fs::write(project_dir.join("config.json"), envelope("project")).unwrap();
275
276 std::env::set_current_dir(project.path()).unwrap();
277 let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
278 std::env::set_current_dir(original_dir).unwrap();
279
280 assert_eq!(result.unwrap().value, "framework");
281 }
282
283 #[tokio::test]
292 #[serial]
293 async fn resolve_node_config_populates_flattened_relay_topics_field_on_a_strict_node_config() {
294 let original_dir = std::env::current_dir().unwrap();
295
296 let project = tempfile::tempdir().unwrap();
297 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
298 let node_dir = project.path().join("configs").join("nodes").join("my-node");
299 fs::create_dir_all(&node_dir).unwrap();
300 fs::write(
301 node_dir.join("config.json"),
302 r#"{
303 "dev": { "value": "project", "relay_topics": ["/my-node/status"] },
304 "production": { "value": "project-prod", "relay_topics": ["/my-node/status"] }
305 }"#,
306 )
307 .unwrap();
308
309 std::env::set_current_dir(project.path()).unwrap();
310 let result = Context::resolve_node_config::<StrictTestNodeConfig>("my-node").await;
311 std::env::set_current_dir(original_dir).unwrap();
312
313 let config = result.unwrap();
314 assert_eq!(config.value, "project");
315 assert_eq!(config.relay.relay_topics, Some(vec!["/my-node/status".to_string()]));
316 }
317
318 #[tokio::test]
319 #[serial]
320 async fn resolve_node_config_ignores_dead_v1_and_common_tiers_and_falls_back_to_default() {
321 let original_dir = std::env::current_dir().unwrap();
322
323 let project = tempfile::tempdir().unwrap();
324 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
325
326 let env_tier_dir = project.path().join("configs").join("dev").join("nodes").join("my-node");
327 fs::create_dir_all(&env_tier_dir).unwrap();
328 fs::write(env_tier_dir.join("config.json"), r#"{"value": "dead-env-tier"}"#).unwrap();
329
330 let common_tier_dir = project
331 .path()
332 .join("configs")
333 .join("common")
334 .join("nodes")
335 .join("my-node");
336 fs::create_dir_all(&common_tier_dir).unwrap();
337 fs::write(common_tier_dir.join("config.json"), r#"{"value": "dead-common-tier"}"#).unwrap();
338
339 std::env::set_current_dir(project.path()).unwrap();
340 let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
341 std::env::set_current_dir(original_dir).unwrap();
342
343 assert_eq!(result.unwrap(), TestNodeConfig::default());
344 }
345
346 #[tokio::test]
347 #[serial]
348 async fn resolve_node_config_falls_back_to_default_when_no_project_root_is_found() {
349 let original_dir = std::env::current_dir().unwrap();
350
351 let dir = tempfile::tempdir().unwrap();
352
353 std::env::set_current_dir(dir.path()).unwrap();
354 let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
355 std::env::set_current_dir(original_dir).unwrap();
356
357 assert_eq!(result.unwrap(), TestNodeConfig::default());
358 }
359}