Skip to main content

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}