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());
88
89 let current_dir = env::current_dir()
90 .map_err(|e| Mecha10Error::Configuration(format!("Failed to get current directory: {}", e)))?;
91
92 if let Some(project_root) = crate::fs_utils::find_project_root(¤t_dir) {
97 let flat_config_path = project_root
100 .join("configs")
101 .join("nodes")
102 .join("@mecha10")
103 .join(node_name)
104 .join("config.json");
105
106 if flat_config_path.exists() {
107 tracing::debug!(
108 "Loading node config for '{}' from flat path: {}",
109 node_name,
110 flat_config_path.display()
111 );
112 return Self::load_environment_aware_config(&flat_config_path, &environment)
113 .with_context(|| {
114 format!(
115 "Failed to load node config for '{}' from '{}'",
116 node_name,
117 flat_config_path.display()
118 )
119 })
120 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
121 }
122
123 let project_config_path = project_root
126 .join("configs")
127 .join("nodes")
128 .join(node_name)
129 .join("config.json");
130
131 if project_config_path.exists() {
132 tracing::debug!(
133 "Loading node config for '{}' from project path: {}",
134 node_name,
135 project_config_path.display()
136 );
137 return Self::load_environment_aware_config(&project_config_path, &environment)
138 .with_context(|| {
139 format!(
140 "Failed to load node config for '{}' from '{}'",
141 node_name,
142 project_config_path.display()
143 )
144 })
145 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
146 }
147 }
148
149 tracing::debug!(
151 "No config file found for node '{}', using Default implementation",
152 node_name
153 );
154 let config = T::default();
155 Ok(config)
156 }
157
158 fn load_environment_aware_config<T>(path: &std::path::Path, environment: &str) -> Result<T>
168 where
169 T: DeserializeOwned + Default,
170 {
171 let content =
172 std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
173
174 let json_value: serde_json::Value =
175 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;
176
177 let obj = json_value.as_object().ok_or_else(|| {
178 Mecha10Error::Configuration(format!(
179 "Config must be a JSON object with 'dev'/'production' keys: {}",
180 path.display()
181 ))
182 })?;
183
184 let env_key = match environment {
186 "production" | "prod" => "production",
187 _ => "dev",
188 };
189
190 let env_section = obj
192 .get(env_key)
193 .or_else(|| {
194 let fallback = if env_key == "dev" { "production" } else { "dev" };
196 tracing::warn!(
197 "Environment '{}' not found in config, falling back to '{}': {}",
198 env_key,
199 fallback,
200 path.display()
201 );
202 obj.get(fallback)
203 })
204 .ok_or_else(|| {
205 Mecha10Error::Configuration(format!(
206 "Config must have 'dev' or 'production' section: {}",
207 path.display()
208 ))
209 })?;
210
211 tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
212 let config: T = serde_json::from_value(env_section.clone())
213 .with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
214 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
215
216 Ok(config)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::Context;
223 use serde::{Deserialize, Serialize};
224 use serial_test::serial;
225 use std::fs;
226
227 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
232 struct TestNodeConfig {
233 #[serde(default)]
234 value: String,
235 }
236
237 fn envelope(dev_value: &str) -> String {
238 format!(r#"{{"dev": {{"value": "{dev_value}"}}, "production": {{"value": "{dev_value}-prod"}}}}"#)
239 }
240
241 #[tokio::test]
242 #[serial]
243 async fn resolve_node_config_loads_from_project_tier_when_only_project_config_exists() {
244 let original_dir = std::env::current_dir().unwrap();
245
246 let project = tempfile::tempdir().unwrap();
247 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
248 let node_dir = project.path().join("configs").join("nodes").join("my-node");
249 fs::create_dir_all(&node_dir).unwrap();
250 fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
251
252 std::env::set_current_dir(project.path()).unwrap();
253 let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
254 std::env::set_current_dir(original_dir).unwrap();
255
256 assert_eq!(result.unwrap().value, "project");
257 }
258
259 #[tokio::test]
260 #[serial]
261 async fn resolve_node_config_prefers_framework_tier_over_project_tier() {
262 let original_dir = std::env::current_dir().unwrap();
263
264 let project = tempfile::tempdir().unwrap();
265 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
266
267 let framework_dir = project
268 .path()
269 .join("configs")
270 .join("nodes")
271 .join("@mecha10")
272 .join("my-node");
273 fs::create_dir_all(&framework_dir).unwrap();
274 fs::write(framework_dir.join("config.json"), envelope("framework")).unwrap();
275
276 let project_dir = project.path().join("configs").join("nodes").join("my-node");
277 fs::create_dir_all(&project_dir).unwrap();
278 fs::write(project_dir.join("config.json"), envelope("project")).unwrap();
279
280 std::env::set_current_dir(project.path()).unwrap();
281 let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
282 std::env::set_current_dir(original_dir).unwrap();
283
284 assert_eq!(result.unwrap().value, "framework");
285 }
286
287 #[tokio::test]
288 #[serial]
289 async fn resolve_node_config_ignores_dead_v1_and_common_tiers_and_falls_back_to_default() {
290 let original_dir = std::env::current_dir().unwrap();
291
292 let project = tempfile::tempdir().unwrap();
293 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
294
295 let env_tier_dir = project.path().join("configs").join("dev").join("nodes").join("my-node");
297 fs::create_dir_all(&env_tier_dir).unwrap();
298 fs::write(env_tier_dir.join("config.json"), r#"{"value": "dead-env-tier"}"#).unwrap();
299
300 let common_tier_dir = project
302 .path()
303 .join("configs")
304 .join("common")
305 .join("nodes")
306 .join("my-node");
307 fs::create_dir_all(&common_tier_dir).unwrap();
308 fs::write(common_tier_dir.join("config.json"), r#"{"value": "dead-common-tier"}"#).unwrap();
309
310 std::env::set_current_dir(project.path()).unwrap();
311 let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
312 std::env::set_current_dir(original_dir).unwrap();
313
314 assert_eq!(result.unwrap(), TestNodeConfig::default());
317 }
318
319 #[tokio::test]
320 #[serial]
321 async fn resolve_node_config_falls_back_to_default_when_no_project_root_is_found() {
322 let original_dir = std::env::current_dir().unwrap();
323
324 let dir = tempfile::tempdir().unwrap();
326
327 std::env::set_current_dir(dir.path()).unwrap();
328 let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
329 std::env::set_current_dir(original_dir).unwrap();
330
331 assert_eq!(result.unwrap(), TestNodeConfig::default());
332 }
333}