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), selected *within* a config
13    ///   file's `{"dev": {...}, "production": {...}}` envelope (see
14    ///   [`Self::load_environment_aware_config`])
15    /// - File-level fallback (no key-by-key merging)
16    /// - Automatic project-root discovery via [`crate::fs_utils::find_project_root`]
17    ///
18    /// **Tier order** (mirrors `mecha10_cli_core::paths::config::node_config_candidates`, the
19    /// canonical 2-tier resolution order for node configs -- this method can't call that helper
20    /// directly since `mecha10-core` is a dependency *of* `mecha10-cli-core`, not the reverse,
21    /// but it must stay in sync with it):
22    /// 1. `configs/nodes/@mecha10/{node_name}/config.json` - Framework nodes
23    /// 2. `configs/nodes/{node_name}/config.json` - Project nodes (preferred)
24    /// 3. `T::default()` - Default implementation, if neither tier has a config file (or no
25    ///    project root could be found)
26    ///
27    /// **Environment Detection:**
28    /// - Uses `MECHA10_ENV` env var (default: "dev")
29    /// - Supported values: "dev", "staging", "production", or custom
30    ///
31    /// **Semantic Validation:**
32    /// - Not run here. This method is generic over `T` with only a
33    ///   `DeserializeOwned + Default` bound, and stable Rust has no
34    ///   specialization -- a function generic over `T` can't conditionally
35    ///   call [`crate::config::Validate::validate`] only when `T` happens to
36    ///   implement it. Node types that need semantic validation should call
37    ///   `validate()` explicitly on the config returned here.
38    ///
39    /// # Type Parameters
40    ///
41    /// * `T` - Configuration type that implements `DeserializeOwned` + `Default`
42    ///
43    /// # Arguments
44    ///
45    /// * `node_name` - Name of the node (e.g., "simulator", "camera-streamer")
46    ///
47    /// # Returns
48    ///
49    /// The loaded configuration of type `T`, using the first available source
50    /// in the fallback chain.
51    ///
52    /// # Example
53    ///
54    /// ```rust
55    /// use mecha10_core::prelude::*;
56    /// use serde::{Deserialize, Serialize};
57    ///
58    /// #[derive(Debug, Clone, Deserialize, Serialize, Default)]
59    /// struct SimBridgeConfig {
60    ///     camera_width: u32,
61    ///     camera_height: u32,
62    ///     camera_fps: u32,
63    /// }
64    ///
65    /// # async fn example(ctx: &Context) -> Result<()> {
66    /// let config: SimBridgeConfig = ctx.load_node_config("simulator").await?;
67    /// # Ok(())
68    /// # }
69    /// ```
70    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    /// The actual resolution logic behind [`Self::load_node_config`], split out as a
78    /// `self`-free associated function so it's unit-testable without constructing a full
79    /// [`Context`] (which requires a live Redis connection).
80    async fn resolve_node_config<T>(node_name: &str) -> Result<T>
81    where
82        T: DeserializeOwned + Default,
83    {
84        use std::env;
85
86        // Get environment from env var (default: "dev")
87        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        // Resolve the project root the same way the rest of Context does (see
93        // `try_load_redis_from_mecha10_json`) rather than hand-rolling a relative-base-path
94        // guessing loop. If no project root can be found, there's nowhere to look for a node
95        // config, so fall straight through to `T::default()`.
96        if let Some(project_root) = crate::fs_utils::find_project_root(&current_dir) {
97            // 1. Try new flat structure: configs/nodes/@mecha10/{node_name}/config.json
98            // This format supports environment-aware configs: { "dev": {...}, "production": {...} }
99            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            // 2. Try plain project nodes: configs/nodes/{node_name}/config.json
124            // This is the preferred format for project-local nodes
125            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        // Fall back to default
150        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    /// Load a config file in environment-aware format.
159    ///
160    /// Config files must have the format:
161    /// ```json
162    /// {
163    ///   "dev": { ... config ... },
164    ///   "production": { ... config ... }
165    /// }
166    /// ```
167    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        // Determine which environment key to use
185        let env_key = match environment {
186            "production" | "prod" => "production",
187            _ => "dev",
188        };
189
190        // Get the environment section
191        let env_section = obj
192            .get(env_key)
193            .or_else(|| {
194                // Fallback to other environment if requested one doesn't exist
195                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    // These tests mutate process-wide state (current directory, `MECHA10_ENV`), so
228    // they must run serially (`#[serial]`) to avoid racing with other tests in this binary --
229    // same pattern as `context::config::tests`.
230
231    #[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        // Dead tier 1 (V1 env-tier layout): configs/{env}/nodes/{node}/config.json
296        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        // Dead tier 2 (undocumented "common" tier): configs/common/nodes/{node}/config.json
301        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        // Neither dead tier should be consulted -- with no tier-3/tier-4 config present, this
315        // must fall through to `T::default()`, not read either legacy file.
316        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        // A standalone tempdir with no mecha10.json anywhere in its ancestry.
325        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}