mecha10_core/context/node_config.rs
1//! Node-level configuration loading with environment-aware file fallback.
2
3use super::Context;
4use crate::error::{Mecha10Error, Result};
5use anyhow::Context as _;
6use serde::de::DeserializeOwned;
7
8impl Context {
9 /// Load node configuration with environment-aware file-level fallback
10 ///
11 /// This method implements the V2 configuration system with:
12 /// - Environment-specific configs (dev/staging/production)
13 /// - File-level fallback (no key-by-key merging)
14 /// - Automatic path resolution
15 ///
16 /// **Fallback Priority:**
17 /// 1. `configs/{env}/nodes/{node-name}/config.json` (environment-specific)
18 /// 2. `configs/common/nodes/{node-name}/config.json` (common fallback)
19 /// 3. `T::default()` (code default)
20 ///
21 /// **Environment Detection:**
22 /// - Uses `MECHA10_ENVIRONMENT` env var (default: "dev")
23 /// - Supported values: "dev", "staging", "production", or custom
24 ///
25 /// **Semantic Validation:**
26 /// - If `T` implements [`crate::config::Validate`], `validate()` is called
27 /// automatically on the loaded (or default) config and any error it
28 /// returns is propagated. Types that don't implement `Validate` are
29 /// unaffected -- this is a purely additive, opt-in hook.
30 ///
31 /// # Type Parameters
32 ///
33 /// * `T` - Configuration type that implements `DeserializeOwned` + `Default`
34 ///
35 /// # Arguments
36 ///
37 /// * `node_name` - Name of the node (e.g., "mujoco-sim", "camera-streamer")
38 ///
39 /// # Returns
40 ///
41 /// The loaded configuration of type `T`, using the first available source
42 /// in the fallback chain.
43 ///
44 /// # Fallback Chain
45 ///
46 /// Configs are searched in order:
47 /// 1. `configs/{env}/nodes/{node_name}/config.json` - Environment-specific
48 /// 2. `configs/common/nodes/{node_name}/config.json` - Common config
49 /// 3. `configs/nodes/@mecha10/{node_name}/config.json` - Framework nodes
50 /// 4. `configs/nodes/{node_name}/config.json` - Project nodes (preferred)
51 /// 5. `configs/nodes/@local/{node_name}/config.json` - Legacy format
52 /// 6. `T::default()` - Default implementation
53 ///
54 /// # Example
55 ///
56 /// ```rust
57 /// use mecha10::prelude::*;
58 /// use serde::{Deserialize, Serialize};
59 ///
60 /// #[derive(Debug, Clone, Deserialize, Serialize, Default)]
61 /// struct SimBridgeConfig {
62 /// camera_width: u32,
63 /// camera_height: u32,
64 /// camera_fps: u32,
65 /// }
66 ///
67 /// # async fn example(ctx: &Context) -> Result<()> {
68 /// let config: SimBridgeConfig = ctx.load_node_config("mujoco-sim").await?;
69 /// # Ok(())
70 /// # }
71 /// ```
72 pub async fn load_node_config<T>(&self, node_name: &str) -> Result<T>
73 where
74 T: DeserializeOwned + Default,
75 {
76 use crate::config::load_config;
77 use std::env;
78 use std::path::PathBuf;
79
80 // Get environment from env var (default: "dev")
81 let environment = env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string());
82
83 // Try multiple base paths (for running from different locations)
84 let base_paths = vec![
85 PathBuf::from("."), // Current directory
86 PathBuf::from("../.."), // From target/debug/deps
87 PathBuf::from("../../../.."), // From packages/*/target/debug/deps
88 ];
89
90 // Build fallback chain for each base path
91 for base_path in &base_paths {
92 // 1. Try environment-specific config
93 let env_config_path = base_path
94 .join("configs")
95 .join(&environment)
96 .join("nodes")
97 .join(node_name)
98 .join("config.json");
99
100 if env_config_path.exists() {
101 tracing::debug!(
102 "Loading node config for '{}' from environment-specific path: {}",
103 node_name,
104 env_config_path.display()
105 );
106 return load_config(&env_config_path)
107 .with_context(|| {
108 format!(
109 "Failed to load node config for '{}' from '{}'",
110 node_name,
111 env_config_path.display()
112 )
113 })
114 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
115 }
116
117 // 2. Try common config
118 let common_config_path = base_path
119 .join("configs")
120 .join("common")
121 .join("nodes")
122 .join(node_name)
123 .join("config.json");
124
125 if common_config_path.exists() {
126 tracing::debug!(
127 "Loading node config for '{}' from common path: {}",
128 node_name,
129 common_config_path.display()
130 );
131 return load_config(&common_config_path)
132 .with_context(|| {
133 format!(
134 "Failed to load node config for '{}' from '{}'",
135 node_name,
136 common_config_path.display()
137 )
138 })
139 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
140 }
141
142 // 3. Try new flat structure: configs/nodes/@mecha10/{node_name}/config.json
143 // This format supports environment-aware configs: { "dev": {...}, "production": {...} }
144 let flat_config_path = base_path
145 .join("configs")
146 .join("nodes")
147 .join("@mecha10")
148 .join(node_name)
149 .join("config.json");
150
151 if flat_config_path.exists() {
152 tracing::debug!(
153 "Loading node config for '{}' from flat path: {}",
154 node_name,
155 flat_config_path.display()
156 );
157 return Self::load_environment_aware_config(&flat_config_path, &environment)
158 .with_context(|| {
159 format!(
160 "Failed to load node config for '{}' from '{}'",
161 node_name,
162 flat_config_path.display()
163 )
164 })
165 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
166 }
167
168 // 4. Try plain project nodes: configs/nodes/{node_name}/config.json
169 // This is the preferred format for project-local nodes
170 let project_config_path = base_path
171 .join("configs")
172 .join("nodes")
173 .join(node_name)
174 .join("config.json");
175
176 if project_config_path.exists() {
177 tracing::debug!(
178 "Loading node config for '{}' from project path: {}",
179 node_name,
180 project_config_path.display()
181 );
182 return Self::load_environment_aware_config(&project_config_path, &environment)
183 .with_context(|| {
184 format!(
185 "Failed to load node config for '{}' from '{}'",
186 node_name,
187 project_config_path.display()
188 )
189 })
190 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
191 }
192
193 // 5. Try @local nodes (legacy): configs/nodes/@local/{node_name}/config.json
194 let local_config_path = base_path
195 .join("configs")
196 .join("nodes")
197 .join("@local")
198 .join(node_name)
199 .join("config.json");
200
201 if local_config_path.exists() {
202 tracing::debug!(
203 "Loading node config for '{}' from legacy local path: {}",
204 node_name,
205 local_config_path.display()
206 );
207 return Self::load_environment_aware_config(&local_config_path, &environment)
208 .with_context(|| {
209 format!(
210 "Failed to load node config for '{}' from '{}'",
211 node_name,
212 local_config_path.display()
213 )
214 })
215 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
216 }
217 }
218
219 // 3. Fall back to default
220 tracing::debug!(
221 "No config file found for node '{}', using Default implementation",
222 node_name
223 );
224 let config = T::default();
225 crate::config::maybe_validate(&config)?;
226 Ok(config)
227 }
228
229 /// Load a config file in environment-aware format.
230 ///
231 /// Config files must have the format:
232 /// ```json
233 /// {
234 /// "dev": { ... config ... },
235 /// "production": { ... config ... }
236 /// }
237 /// ```
238 fn load_environment_aware_config<T>(path: &std::path::Path, environment: &str) -> Result<T>
239 where
240 T: DeserializeOwned + Default,
241 {
242 let content =
243 std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
244
245 let json_value: serde_json::Value =
246 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;
247
248 let obj = json_value.as_object().ok_or_else(|| {
249 Mecha10Error::Configuration(format!(
250 "Config must be a JSON object with 'dev'/'production' keys: {}",
251 path.display()
252 ))
253 })?;
254
255 // Determine which environment key to use
256 let env_key = match environment {
257 "production" | "prod" => "production",
258 _ => "dev",
259 };
260
261 // Get the environment section
262 let env_section = obj
263 .get(env_key)
264 .or_else(|| {
265 // Fallback to other environment if requested one doesn't exist
266 let fallback = if env_key == "dev" { "production" } else { "dev" };
267 tracing::warn!(
268 "Environment '{}' not found in config, falling back to '{}': {}",
269 env_key,
270 fallback,
271 path.display()
272 );
273 obj.get(fallback)
274 })
275 .ok_or_else(|| {
276 Mecha10Error::Configuration(format!(
277 "Config must have 'dev' or 'production' section: {}",
278 path.display()
279 ))
280 })?;
281
282 tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
283 let config: T = serde_json::from_value(env_section.clone())
284 .with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
285 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
286
287 // Run semantic validation if `T` implements `Validate` (no-op otherwise).
288 crate::config::maybe_validate(&config)?;
289
290 Ok(config)
291 }
292}