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        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(&current_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    /// Load a config file in environment-aware format.
149    ///
150    /// Config files must have the format:
151    /// ```json
152    /// {
153    ///   "dev": { ... config ... },
154    ///   "production": { ... config ... }
155    /// }
156    /// ```
157    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    /// Mirrors the real shape of a node's own config struct (e.g.
221    /// `mecha10_nodes_behavior_executor::BehaviorExecutorConfig`) - `deny_unknown_fields` plus a
222    /// flattened `crate::relay_topics::RelayTopicsField`, the pattern every node's config now
223    /// uses instead of hand-declaring its own `relay_topics` field.
224    #[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    /// Regression test (LAB-2270): a config declaring `relay_topics` must load into a strict
284    /// (`deny_unknown_fields`) node config that embeds `crate::relay_topics::RelayTopicsField`
285    /// via `#[serde(flatten)]` - and the value must actually come through typed, not be
286    /// discarded. (An earlier design had `Context::load_node_config` silently strip the field
287    /// out of the JSON before deserializing, so no node needed to declare it at all - correct,
288    /// but opaque: a node's own type gave no hint the field existed, and it could never
289    /// meaningfully read the value even if it wanted to. Flattening a shared field in is the
290    /// same "no per-node reimplementation" property, but explicit and typed instead.)
291    #[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}