Skip to main content

mecha10_core/context/
node_config.rs

1//! Node-level configuration loading with environment-aware file fallback.
2//!
3//! Every load path here (see [`split_base_and_custom`]) resolves a node's config JSON into the
4//! base+`custom` split documented on [`crate::node_config_base::NodeConfigBase`]
5//! (`docs/adr/0001-node-config-base-schema.md`): `T`'s reserved/base fields (e.g.
6//! `relay_topics`, via `#[serde(flatten)] pub base: NodeConfigBase` on `T`) are read from the
7//! top level, `T`'s own node-specific fields are read exclusively from a nested `custom` object
8//! (absent `custom` means no node-specific fields, not a fallback to reading them flat off the
9//! top level - there is deliberately no dual-read support for that old shape).
10
11use super::Context;
12use crate::error::{Mecha10Error, Result};
13use crate::node_config_base::NodeConfigBase;
14use anyhow::Context as _;
15use schemars::JsonSchema;
16use serde::de::DeserializeOwned;
17use serde_json::Value;
18
19impl Context {
20    /// Load node configuration with environment-aware file-level fallback
21    ///
22    /// This method implements the V2 configuration system with:
23    /// - Environment-specific configs (dev/staging/production), selected *within* a config
24    ///   file's `{"dev": {...}, "production": {...}}` envelope (see
25    ///   [`Self::load_environment_aware_config`])
26    /// - File-level fallback (no key-by-key merging)
27    /// - Automatic project-root discovery via [`crate::fs_utils::find_project_root`]
28    ///
29    /// **Tier order** (mirrors `mecha10_cli_core::paths::config::node_config_candidates`, the
30    /// canonical 2-tier resolution order for node configs -- this method can't call that helper
31    /// directly since `mecha10-core` is a dependency *of* `mecha10-cli-core`, not the reverse,
32    /// but it must stay in sync with it):
33    /// 1. `configs/nodes/@mecha10/{node_name}/config.json` - Framework nodes
34    /// 2. `configs/nodes/{node_name}/config.json` - Project nodes (preferred)
35    /// 3. `T::default()` - Default implementation, if neither tier has a config file (or no
36    ///    project root could be found)
37    ///
38    /// **Environment Detection:**
39    /// - Uses `MECHA10_ENV` env var (default: "dev")
40    /// - Supported values: "dev", "staging", "production", or custom
41    ///
42    /// **Semantic Validation:**
43    /// - Not run here. This method is generic over `T` with only a
44    ///   `DeserializeOwned + Default` bound, and stable Rust has no
45    ///   specialization -- a function generic over `T` can't conditionally
46    ///   call [`crate::config::Validate::validate`] only when `T` happens to
47    ///   implement it. Node types that need semantic validation should call
48    ///   `validate()` explicitly on the config returned here.
49    ///
50    /// # Type Parameters
51    ///
52    /// * `T` - Configuration type that implements `DeserializeOwned` + `Default`
53    ///
54    /// # Arguments
55    ///
56    /// * `node_name` - Name of the node (e.g., "simulator", "camera-streamer")
57    ///
58    /// # Returns
59    ///
60    /// The loaded configuration of type `T`, using the first available source
61    /// in the fallback chain.
62    ///
63    /// # Example
64    ///
65    /// ```rust
66    /// use mecha10_core::prelude::*;
67    /// use serde::{Deserialize, Serialize};
68    ///
69    /// #[derive(Debug, Clone, Deserialize, Serialize, Default)]
70    /// struct SimBridgeConfig {
71    ///     camera_width: u32,
72    ///     camera_height: u32,
73    ///     camera_fps: u32,
74    /// }
75    ///
76    /// # async fn example(ctx: &Context) -> Result<()> {
77    /// let config: SimBridgeConfig = ctx.load_node_config("simulator").await?;
78    /// # Ok(())
79    /// # }
80    /// ```
81    pub async fn load_node_config<T>(&self, node_name: &str) -> Result<T>
82    where
83        T: DeserializeOwned + Default,
84    {
85        self.load_node_config_for_instance(node_name, self.instance()).await
86    }
87
88    /// Like [`Self::load_node_config`], but with an explicit `instance_id` instead of this
89    /// context's own [`Self::instance`].
90    ///
91    /// Node-runner (the supervisor process) is the main reason this is split out: it loads a
92    /// node's `config.json` on that node's behalf, through a context that represents
93    /// node-runner itself (not the node), so it can't rely on `self.instance()` - it passes
94    /// the target node's `--instance-id` through explicitly instead. Every actual node binary
95    /// (via `node_main!`/`#[derive(Node)]`) should just call [`Self::load_node_config`], which
96    /// already does the right thing from its own `instance()`.
97    ///
98    /// # Instance-Keyed Config Files
99    ///
100    /// For a multi-instance node (two `camera` nodes - one real, one simulated - both declared
101    /// in `mecha10.json` via the `{"name": "@mecha10/camera", "instance_id": "real"}` node-list
102    /// entry shape, LAB-4131), the resolved config file's per-environment section may itself be
103    /// a flat config object (today's behavior, unchanged, used when `instance_id` is `None`) or
104    /// a JSON object keyed by instance id:
105    /// ```json
106    /// {
107    ///   "dev": {
108    ///     "real": { "source": { "type": "v4l2", "device_path": "/dev/video0" }, "id": "real" },
109    ///     "sim":  { "source": { "type": "redis", "topic": "/camera/sim/raw" }, "id": "sim" }
110    ///   }
111    /// }
112    /// ```
113    /// When `instance_id` is `Some(id)`, the sub-object at that key is used as the node's
114    /// config (same shape/validation as today's flat config). An `instance_id` that isn't
115    /// present as a key is an error, rather than silently falling back to `T::default()` - a
116    /// multi-instance node whose config is missing an instance's entry has a real
117    /// misconfiguration, not an absent config file.
118    pub async fn load_node_config_for_instance<T>(&self, node_name: &str, instance_id: Option<&str>) -> Result<T>
119    where
120        T: DeserializeOwned + Default,
121    {
122        Self::resolve_node_config(node_name, instance_id).await
123    }
124
125    /// Same as [`Self::load_node_config`], but also validates the loaded JSON against `T`'s own
126    /// `schemars`-derived JSON Schema before deserializing into it (LAB-4066).
127    ///
128    /// This is the load-at-startup counterpart to `mecha10_core::schema_validation`'s preflight
129    /// validation (`validate_project_config`/`mecha10 config validate`), using the same "every
130    /// node supplies its own schema" principle that validation's declared-schema lookup is
131    /// built on -- except here there is no lookup and no "missing schema" case to hard-error on:
132    /// `T: JsonSchema` means `T`'s own derived schema always exists, enforced by the type system
133    /// at compile time rather than by a runtime registry/convention check. `node_main!` and
134    /// `#[derive(Node)]`'s generated `run()` call this (not [`Self::load_node_config`]) so every
135    /// node -- built-in or custom -- gets this check for free the moment its `Config` struct
136    /// derives `JsonSchema` (already required to export a schema for it at all, see
137    /// `mecha10-schema-registry`).
138    ///
139    /// Not used for [`Self::load_node_config`] callers that read another node's config file
140    /// generically through a deliberately-partial/lenient struct (e.g.
141    /// `mecha10-node-runner`'s launcher-level `NodeConfig`) -- validating a partial struct's
142    /// derived schema against another node's *full* config would reject fields the reader
143    /// never claimed to model, which isn't this node's schema to enforce.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the config file exists but fails to parse, fails schema validation,
148    /// or fails to deserialize into `T`.
149    pub async fn load_and_validate_node_config<T>(&self, node_name: &str) -> Result<T>
150    where
151        T: DeserializeOwned + Default + JsonSchema,
152    {
153        Self::resolve_node_config_validated(node_name).await
154    }
155
156    /// Find the config file [`Self::resolve_node_config`]/[`Self::resolve_node_config_validated`]
157    /// should load for `node_name`, per the 2-tier resolution order documented on
158    /// [`Self::load_node_config`]. Returns `None` when neither tier has a file (or no project
159    /// root could be found), meaning the caller should fall back to `T::default()`.
160    fn find_node_config_path(node_name: &str) -> Result<Option<std::path::PathBuf>> {
161        use std::env;
162
163        let current_dir = env::current_dir()
164            .map_err(|e| Mecha10Error::Configuration(format!("Failed to get current directory: {}", e)))?;
165
166        let Some(project_root) = crate::fs_utils::find_project_root(&current_dir) else {
167            return Ok(None);
168        };
169
170        let flat_config_path = project_root
171            .join("configs")
172            .join("nodes")
173            .join("@mecha10")
174            .join(node_name)
175            .join("config.json");
176        if flat_config_path.exists() {
177            return Ok(Some(flat_config_path));
178        }
179
180        let project_config_path = project_root
181            .join("configs")
182            .join("nodes")
183            .join(node_name)
184            .join("config.json");
185        if project_config_path.exists() {
186            return Ok(Some(project_config_path));
187        }
188
189        Ok(None)
190    }
191
192    /// The actual resolution logic behind [`Self::load_and_validate_node_config`] -- mirrors
193    /// [`Self::resolve_node_config`] but additionally validates the environment section against
194    /// `T`'s derived schema (via [`Self::load_environment_aware_config_validated`]) before
195    /// deserializing it.
196    async fn resolve_node_config_validated<T>(node_name: &str) -> Result<T>
197    where
198        T: DeserializeOwned + Default + JsonSchema,
199    {
200        use std::env;
201
202        let environment = env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
203
204        let Some(config_path) = Self::find_node_config_path(node_name)? else {
205            tracing::debug!(
206                "No config file found for node '{}', using Default implementation",
207                node_name
208            );
209            return Ok(T::default());
210        };
211
212        tracing::debug!(
213            "Loading node config for '{}' from: {}",
214            node_name,
215            config_path.display()
216        );
217        Self::load_environment_aware_config_validated(&config_path, &environment)
218            .with_context(|| {
219                format!(
220                    "Failed to load node config for '{}' from '{}'",
221                    node_name,
222                    config_path.display()
223                )
224            })
225            .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))
226    }
227
228    /// Same as [`Self::load_environment_aware_config`], but validates the selected environment
229    /// section against `T::schema_value()`-equivalent (`schemars::SchemaGenerator`-derived)
230    /// before deserializing it, so a config that's structurally wrong (wrong types, violates a
231    /// `#[schemars(...)]` constraint, unknown fields when `T` denies them) fails with a clear
232    /// schema error instead of either a less-specific serde error or silently loading anyway.
233    fn load_environment_aware_config_validated<T>(path: &std::path::Path, environment: &str) -> Result<T>
234    where
235        T: DeserializeOwned + Default + JsonSchema,
236    {
237        let content =
238            std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
239
240        let json_value: Value =
241            serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;
242
243        let obj = json_value.as_object().ok_or_else(|| {
244            Mecha10Error::Configuration(format!(
245                "Config must be a JSON object with 'dev'/'production' keys: {}",
246                path.display()
247            ))
248        })?;
249
250        let env_key = match environment {
251            "production" | "prod" => "production",
252            _ => "dev",
253        };
254
255        let env_section = obj.get(env_key).ok_or_else(|| {
256            Mecha10Error::Configuration(format!(
257                "Config is missing its '{}' environment section (MECHA10_ENV='{}'): {}",
258                env_key,
259                environment,
260                path.display()
261            ))
262        })?;
263
264        let config_value = split_base_and_custom(env_section)?;
265        validate_against_declared_schema::<T>(&config_value, path, env_key)?;
266
267        tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
268        let config: T = serde_json::from_value(config_value)
269            .with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
270            .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
271
272        Ok(config)
273    }
274
275    /// The actual resolution logic behind [`Self::load_node_config`], split out as a
276    /// `self`-free associated function so it's unit-testable without constructing a full
277    /// [`Context`] (which requires a live Redis connection).
278    async fn resolve_node_config<T>(node_name: &str, instance_id: Option<&str>) -> Result<T>
279    where
280        T: DeserializeOwned + Default,
281    {
282        use std::env;
283
284        let environment = env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
285
286        let current_dir = env::current_dir()
287            .map_err(|e| Mecha10Error::Configuration(format!("Failed to get current directory: {}", e)))?;
288
289        if let Some(project_root) = crate::fs_utils::find_project_root(&current_dir) {
290            let flat_config_path = project_root
291                .join("configs")
292                .join("nodes")
293                .join("@mecha10")
294                .join(node_name)
295                .join("config.json");
296
297            if flat_config_path.exists() {
298                tracing::debug!(
299                    "Loading node config for '{}' from flat path: {}",
300                    node_name,
301                    flat_config_path.display()
302                );
303                return Self::load_environment_aware_config(&flat_config_path, &environment, instance_id)
304                    .with_context(|| {
305                        format!(
306                            "Failed to load node config for '{}' from '{}'",
307                            node_name,
308                            flat_config_path.display()
309                        )
310                    })
311                    .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
312            }
313
314            let project_config_path = project_root
315                .join("configs")
316                .join("nodes")
317                .join(node_name)
318                .join("config.json");
319
320            if project_config_path.exists() {
321                tracing::debug!(
322                    "Loading node config for '{}' from project path: {}",
323                    node_name,
324                    project_config_path.display()
325                );
326                return Self::load_environment_aware_config(&project_config_path, &environment, instance_id)
327                    .with_context(|| {
328                        format!(
329                            "Failed to load node config for '{}' from '{}'",
330                            node_name,
331                            project_config_path.display()
332                        )
333                    })
334                    .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
335            }
336        }
337
338        tracing::debug!(
339            "No config file found for node '{}', using Default implementation",
340            node_name
341        );
342        let config = T::default();
343        Ok(config)
344    }
345
346    /// Load a config file in environment-aware format.
347    ///
348    /// Config files must have the format:
349    /// ```json
350    /// {
351    ///   "dev": { ... config ... },
352    ///   "production": { ... config ... }
353    /// }
354    /// ```
355    fn load_environment_aware_config<T>(
356        path: &std::path::Path,
357        environment: &str,
358        instance_id: Option<&str>,
359    ) -> Result<T>
360    where
361        T: DeserializeOwned + Default,
362    {
363        let content =
364            std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
365
366        let json_value: serde_json::Value =
367            serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;
368
369        let obj = json_value.as_object().ok_or_else(|| {
370            Mecha10Error::Configuration(format!(
371                "Config must be a JSON object with 'dev'/'production' keys: {}",
372                path.display()
373            ))
374        })?;
375
376        let env_key = match environment {
377            "production" | "prod" => "production",
378            _ => "dev",
379        };
380
381        let env_section = obj.get(env_key).ok_or_else(|| {
382            Mecha10Error::Configuration(format!(
383                "Config is missing its '{}' environment section (MECHA10_ENV='{}'): {}",
384                env_key,
385                environment,
386                path.display()
387            ))
388        })?;
389
390        tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
391
392        // Multi-instance support (LAB-4131): when loading on behalf of a specific
393        // `instance_id`, the environment section is a JSON object keyed by instance id rather
394        // than the flat config itself - look up that key and deserialize its value instead of
395        // the section directly. No `instance_id` (the overwhelming majority of nodes, today's
396        // only shape) means the environment section *is* the flat config, exactly as before.
397        let config_value = match instance_id {
398            Some(id) => env_section
399                .get(id)
400                .ok_or_else(|| {
401                    Mecha10Error::Configuration(format!(
402                        "Config for instance '{}' not found - expected a top-level '{}' key inside the '{}' \
403                         environment section of: {}",
404                        id,
405                        id,
406                        env_key,
407                        path.display()
408                    ))
409                })?
410                .clone(),
411            None => env_section.clone(),
412        };
413
414        let config_value = split_base_and_custom(&config_value)?;
415        let config: T = serde_json::from_value(config_value)
416            .with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
417            .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
418
419        Ok(config)
420    }
421}
422
423/// Reshape a node's resolved config object into the base+`custom` split documented on
424/// [`NodeConfigBase`] - the one place both [`Context::load_environment_aware_config`] and
425/// [`Context::load_environment_aware_config_validated`] go through before deserializing into a
426/// node's own `T`, so neither has to duplicate this logic.
427///
428/// `value` is expected to be the already environment-/instance-resolved config object (e.g. the
429/// `"dev"` section, or the per-instance sub-object inside it) - a JSON object with the node's
430/// reserved/base fields (`NodeConfigBase`, e.g. `relay_topics`) at the top level and its own
431/// node-specific fields nested under a `custom` key, per LAB-4384's ADR
432/// (`docs/adr/0001-node-config-base-schema.md`).
433///
434/// The returned object merges `NodeConfigBase`'s own fields (re-serialized, so only its
435/// *recognized* reserved fields survive, never arbitrary top-level noise) with whatever is
436/// nested under `custom` (defaulting to no fields at all when `custom` is absent - never a
437/// fallback to reading node-specific fields flat off the top level). This is a hard breaking
438/// change for a `config.json` still written in the old flat shape: a node-specific field left at
439/// the top level (outside `custom`) is silently dropped here, not promoted - there is
440/// deliberately no dual-read support for that old shape.
441///
442/// `pub` (re-exported as [`crate::context::split_base_and_custom`]) so that out-of-`Context`
443/// callers which read a node's `config.json` straight off disk instead of through a live
444/// `Context` (e.g. `mecha10-cli`'s standalone-mode model/Ollama preflight, which can't depend on
445/// a live `Context` the way `mecha10-node-runner` does) can resolve the same base+`custom` split
446/// by calling into this one real implementation, instead of each hand-rolling its own partial
447/// reimplementation of this same logic that silently drifts out of sync whenever a field moves
448/// between `NodeConfigBase` and `custom`.
449pub fn split_base_and_custom(value: &Value) -> Result<Value> {
450    let base: NodeConfigBase = serde_json::from_value(value.clone())
451        .with_context(|| "Failed to deserialize reserved/base config fields")
452        .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
453    let base_fields = serde_json::to_value(&base)
454        .expect("NodeConfigBase always serializes to a JSON object")
455        .as_object()
456        .expect("NodeConfigBase always serializes to a JSON object")
457        .clone();
458
459    // Only carry a reserved/base field into `merged` when `value` actually declared it -
460    // `NodeConfigBase`'s own `Serialize` fills in every field it owns (even ones left at their
461    // default, e.g. `topics` when the config never mentioned it), which is correct for
462    // `NodeConfigBase` considered on its own, but would otherwise inject fields a node's own
463    // config type (`T`) never asked for into every resolved config - breaking any `T` with
464    // `#[serde(deny_unknown_fields)]` that only embeds a subset of `NodeConfigBase`'s fields
465    // (e.g. just `crate::relay_topics::RelayTopicsField`, not the full struct) with an "unknown
466    // field" error for a field it was never given in the first place.
467    let mut merged = match value.as_object() {
468        Some(input_obj) => base_fields
469            .into_iter()
470            .filter(|(key, _)| input_obj.contains_key(key))
471            .collect::<serde_json::Map<String, Value>>(),
472        None => base_fields,
473    };
474
475    let custom = value
476        .get("custom")
477        .cloned()
478        .unwrap_or_else(|| Value::Object(Default::default()));
479    let custom_obj = custom
480        .as_object()
481        .ok_or_else(|| Mecha10Error::Configuration("Node config's 'custom' field must be a JSON object".to_string()))?;
482    merged.extend(custom_obj.clone());
483
484    Ok(Value::Object(merged))
485}
486
487/// Validate `value` (an environment section, e.g. the `"dev"` object) against `T`'s own
488/// `schemars`-derived JSON Schema, used by [`Context::load_environment_aware_config_validated`].
489///
490/// `env_label` (e.g. `"dev"`/`"production"`) is only used to make the error message point at
491/// which section failed, since `path` alone doesn't say that.
492fn validate_against_declared_schema<T: JsonSchema>(
493    value: &Value,
494    path: &std::path::Path,
495    env_label: &str,
496) -> Result<()> {
497    let schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
498    let schema_value = serde_json::to_value(schema).expect("a schemars-derived root schema always serializes to JSON");
499
500    let validator = jsonschema::options()
501        .with_draft(jsonschema::Draft::Draft7)
502        .build(&schema_value)
503        .map_err(|e| Mecha10Error::Configuration(format!("Invalid schema derived for node config type: {}", e)))?;
504
505    if validator.is_valid(value) {
506        return Ok(());
507    }
508
509    let messages: Vec<String> = validator
510        .iter_errors(value)
511        .map(|e| format!("  - {}: {} ({})", e.instance_path(), e, e.instance()))
512        .collect();
513
514    Err(Mecha10Error::Configuration(format!(
515        "Node config validation failed for '{}' [{}]:\n{}",
516        path.display(),
517        env_label,
518        messages.join("\n")
519    )))
520}
521
522#[cfg(test)]
523mod tests {
524    use super::Context;
525    use schemars::JsonSchema;
526    use serde::{Deserialize, Serialize};
527    use serial_test::serial;
528    use std::fs;
529
530    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
531    struct TestNodeConfig {
532        #[serde(default)]
533        value: String,
534    }
535
536    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
537    #[serde(deny_unknown_fields)]
538    struct StrictTestNodeConfig {
539        #[serde(default)]
540        value: String,
541        #[serde(flatten)]
542        base: crate::node_config_base::NodeConfigBase,
543    }
544
545    /// A configuration with a positive-rate constraint enforced by schema validation.
546    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
547    struct StrictPositiveConfig {
548        #[serde(default)]
549        #[schemars(range(min = 1))]
550        rate_hz: i32,
551    }
552
553    /// `value` (every test type's lone node-specific field) now has to be nested under `custom`
554    /// per the base+`custom` split (`crate::node_config_base::NodeConfigBase`) -
555    /// `split_base_and_custom` only ever reads node-specific fields from there.
556    fn envelope(dev_value: &str) -> String {
557        format!(
558            r#"{{"dev": {{"custom": {{"value": "{dev_value}"}}}}, "production": {{"custom": {{"value": "{dev_value}-prod"}}}}}}"#
559        )
560    }
561
562    #[tokio::test]
563    #[serial]
564    async fn resolve_node_config_loads_from_project_tier_when_only_project_config_exists() {
565        let original_dir = std::env::current_dir().unwrap();
566
567        let project = tempfile::tempdir().unwrap();
568        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
569        let node_dir = project.path().join("configs").join("nodes").join("my-node");
570        fs::create_dir_all(&node_dir).unwrap();
571        fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
572
573        std::env::set_current_dir(project.path()).unwrap();
574        let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
575        std::env::set_current_dir(original_dir).unwrap();
576
577        assert_eq!(result.unwrap().value, "project");
578    }
579
580    #[tokio::test]
581    #[serial]
582    async fn resolve_node_config_prefers_framework_tier_over_project_tier() {
583        let original_dir = std::env::current_dir().unwrap();
584
585        let project = tempfile::tempdir().unwrap();
586        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
587
588        let framework_dir = project
589            .path()
590            .join("configs")
591            .join("nodes")
592            .join("@mecha10")
593            .join("my-node");
594        fs::create_dir_all(&framework_dir).unwrap();
595        fs::write(framework_dir.join("config.json"), envelope("framework")).unwrap();
596
597        let project_dir = project.path().join("configs").join("nodes").join("my-node");
598        fs::create_dir_all(&project_dir).unwrap();
599        fs::write(project_dir.join("config.json"), envelope("project")).unwrap();
600
601        std::env::set_current_dir(project.path()).unwrap();
602        let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
603        std::env::set_current_dir(original_dir).unwrap();
604
605        assert_eq!(result.unwrap().value, "framework");
606    }
607
608    /// `LAB-4131`: a multi-instance node's config file nests a flat config object per
609    /// instance id inside its environment section, instead of being the flat config itself.
610    #[tokio::test]
611    #[serial]
612    async fn resolve_node_config_with_instance_id_loads_the_keyed_sub_object() {
613        let original_dir = std::env::current_dir().unwrap();
614
615        let project = tempfile::tempdir().unwrap();
616        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
617        let node_dir = project.path().join("configs").join("nodes").join("camera");
618        fs::create_dir_all(&node_dir).unwrap();
619        fs::write(
620            node_dir.join("config.json"),
621            r#"{
622                "dev": {
623                    "real": {"custom": {"value": "real"}},
624                    "sim": {"custom": {"value": "sim"}}
625                },
626                "production": {
627                    "real": {"custom": {"value": "real-prod"}},
628                    "sim": {"custom": {"value": "sim-prod"}}
629                }
630            }"#,
631        )
632        .unwrap();
633
634        std::env::set_current_dir(project.path()).unwrap();
635        let real = Context::resolve_node_config::<TestNodeConfig>("camera", Some("real")).await;
636        let sim = Context::resolve_node_config::<TestNodeConfig>("camera", Some("sim")).await;
637        std::env::set_current_dir(original_dir).unwrap();
638
639        assert_eq!(real.unwrap().value, "real");
640        assert_eq!(sim.unwrap().value, "sim");
641    }
642
643    /// An `instance_id` that isn't a key in the environment section is a real
644    /// misconfiguration and must error, not silently fall back to `T::default()`.
645    #[tokio::test]
646    #[serial]
647    async fn resolve_node_config_errors_when_instance_id_key_is_missing() {
648        let original_dir = std::env::current_dir().unwrap();
649
650        let project = tempfile::tempdir().unwrap();
651        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
652        let node_dir = project.path().join("configs").join("nodes").join("camera");
653        fs::create_dir_all(&node_dir).unwrap();
654        fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
655
656        std::env::set_current_dir(project.path()).unwrap();
657        let result = Context::resolve_node_config::<TestNodeConfig>("camera", Some("missing")).await;
658        std::env::set_current_dir(original_dir).unwrap();
659
660        let err = result.unwrap_err();
661        assert!(err.to_string().contains("missing"));
662    }
663
664    /// A flat (single-instance, unkeyed) config file must keep loading exactly as before when
665    /// no `instance_id` is passed, even though this module now supports keyed configs too.
666    #[tokio::test]
667    #[serial]
668    async fn resolve_node_config_without_instance_id_is_unaffected_by_keyed_config_support() {
669        let original_dir = std::env::current_dir().unwrap();
670
671        let project = tempfile::tempdir().unwrap();
672        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
673        let node_dir = project.path().join("configs").join("nodes").join("my-node");
674        fs::create_dir_all(&node_dir).unwrap();
675        fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
676
677        std::env::set_current_dir(project.path()).unwrap();
678        let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
679        std::env::set_current_dir(original_dir).unwrap();
680
681        assert_eq!(result.unwrap().value, "project");
682    }
683
684    /// Regression test (LAB-2270): a config declaring `relay_topics` must load into a strict
685    /// (`deny_unknown_fields`) node config that embeds `crate::relay_topics::RelayTopicsField`
686    /// via `#[serde(flatten)]` - and the value must actually come through typed, not be
687    /// discarded. (An earlier design had `Context::load_node_config` silently strip the field
688    /// out of the JSON before deserializing, so no node needed to declare it at all - correct,
689    /// but opaque: a node's own type gave no hint the field existed, and it could never
690    /// meaningfully read the value even if it wanted to. Flattening a shared field in is the
691    /// same "no per-node reimplementation" property, but explicit and typed instead.)
692    #[tokio::test]
693    #[serial]
694    async fn resolve_node_config_populates_flattened_relay_topics_field_on_a_strict_node_config() {
695        let original_dir = std::env::current_dir().unwrap();
696
697        let project = tempfile::tempdir().unwrap();
698        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
699        let node_dir = project.path().join("configs").join("nodes").join("my-node");
700        fs::create_dir_all(&node_dir).unwrap();
701        fs::write(
702            node_dir.join("config.json"),
703            r#"{
704                "dev": { "custom": { "value": "project" }, "relay_topics": ["/my-node/status"] },
705                "production": { "custom": { "value": "project-prod" }, "relay_topics": ["/my-node/status"] }
706            }"#,
707        )
708        .unwrap();
709
710        std::env::set_current_dir(project.path()).unwrap();
711        let result = Context::resolve_node_config::<StrictTestNodeConfig>("my-node", None).await;
712        std::env::set_current_dir(original_dir).unwrap();
713
714        let config = result.unwrap();
715        assert_eq!(config.value, "project");
716        assert_eq!(
717            config.base.relay.relay_topics,
718            Some(vec!["/my-node/status".to_string()])
719        );
720    }
721
722    #[tokio::test]
723    #[serial]
724    async fn resolve_node_config_ignores_dead_v1_and_common_tiers_and_falls_back_to_default() {
725        let original_dir = std::env::current_dir().unwrap();
726
727        let project = tempfile::tempdir().unwrap();
728        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
729
730        let env_tier_dir = project.path().join("configs").join("dev").join("nodes").join("my-node");
731        fs::create_dir_all(&env_tier_dir).unwrap();
732        fs::write(env_tier_dir.join("config.json"), r#"{"value": "dead-env-tier"}"#).unwrap();
733
734        let common_tier_dir = project
735            .path()
736            .join("configs")
737            .join("common")
738            .join("nodes")
739            .join("my-node");
740        fs::create_dir_all(&common_tier_dir).unwrap();
741        fs::write(common_tier_dir.join("config.json"), r#"{"value": "dead-common-tier"}"#).unwrap();
742
743        std::env::set_current_dir(project.path()).unwrap();
744        let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
745        std::env::set_current_dir(original_dir).unwrap();
746
747        assert_eq!(result.unwrap(), TestNodeConfig::default());
748    }
749
750    /// `LAB-4129`: a config missing its active environment's section must fail to load instead
751    /// of silently resolving to the other environment's values.
752    #[tokio::test]
753    #[serial]
754    async fn resolve_node_config_errors_when_active_environment_section_is_missing() {
755        let original_dir = std::env::current_dir().unwrap();
756        let original_env = std::env::var("MECHA10_ENV").ok();
757
758        let project = tempfile::tempdir().unwrap();
759        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
760        let node_dir = project.path().join("configs").join("nodes").join("my-node");
761        fs::create_dir_all(&node_dir).unwrap();
762        fs::write(node_dir.join("config.json"), r#"{"dev": {"value": "project"}}"#).unwrap();
763
764        std::env::set_var("MECHA10_ENV", "production");
765        std::env::set_current_dir(project.path()).unwrap();
766        let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
767        std::env::set_current_dir(original_dir).unwrap();
768        match original_env {
769            Some(value) => std::env::set_var("MECHA10_ENV", value),
770            None => std::env::remove_var("MECHA10_ENV"),
771        }
772
773        let err = result.unwrap_err();
774        assert!(err.to_string().contains("production"));
775    }
776
777    #[tokio::test]
778    #[serial]
779    async fn resolve_node_config_falls_back_to_default_when_no_project_root_is_found() {
780        let original_dir = std::env::current_dir().unwrap();
781
782        let dir = tempfile::tempdir().unwrap();
783
784        std::env::set_current_dir(dir.path()).unwrap();
785        let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
786        std::env::set_current_dir(original_dir).unwrap();
787
788        assert_eq!(result.unwrap(), TestNodeConfig::default());
789    }
790
791    #[tokio::test]
792    #[serial]
793    async fn resolve_node_config_validated_passes_for_a_schema_satisfying_config() {
794        let original_dir = std::env::current_dir().unwrap();
795
796        let project = tempfile::tempdir().unwrap();
797        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
798        let node_dir = project.path().join("configs").join("nodes").join("my-node");
799        fs::create_dir_all(&node_dir).unwrap();
800        fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
801
802        std::env::set_current_dir(project.path()).unwrap();
803        let result = Context::resolve_node_config_validated::<TestNodeConfig>("my-node").await;
804        std::env::set_current_dir(original_dir).unwrap();
805
806        assert_eq!(result.unwrap().value, "project");
807    }
808
809    /// LAB-4066: a config that satisfies `serde` deserialization (every field is the right
810    /// Rust type) but violates a `#[schemars(...)]` constraint the struct declares -- something
811    /// plain deserialization can never catch -- must fail under
812    /// `resolve_node_config_validated`, proving it actually runs schema validation and isn't
813    /// just a thin wrapper around deserialization.
814    #[tokio::test]
815    #[serial]
816    async fn resolve_node_config_validated_rejects_a_value_violating_a_schemars_constraint() {
817        let original_dir = std::env::current_dir().unwrap();
818
819        let project = tempfile::tempdir().unwrap();
820        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
821        let node_dir = project.path().join("configs").join("nodes").join("my-node");
822        fs::create_dir_all(&node_dir).unwrap();
823        fs::write(
824            node_dir.join("config.json"),
825            r#"{"dev": {"custom": {"rate_hz": 0}}, "production": {"custom": {"rate_hz": 1}}}"#,
826        )
827        .unwrap();
828
829        std::env::set_current_dir(project.path()).unwrap();
830        let result = Context::resolve_node_config_validated::<StrictPositiveConfig>("my-node").await;
831        std::env::set_current_dir(original_dir).unwrap();
832
833        let err = result.unwrap_err().to_string();
834        assert!(err.contains("rate_hz"), "error should name the failing field: {}", err);
835    }
836
837    #[tokio::test]
838    #[serial]
839    async fn resolve_node_config_validated_falls_back_to_default_when_no_config_file_exists() {
840        let original_dir = std::env::current_dir().unwrap();
841
842        let dir = tempfile::tempdir().unwrap();
843
844        std::env::set_current_dir(dir.path()).unwrap();
845        let result = Context::resolve_node_config_validated::<TestNodeConfig>("my-node").await;
846        std::env::set_current_dir(original_dir).unwrap();
847
848        assert_eq!(result.unwrap(), TestNodeConfig::default());
849    }
850
851    /// Mirrors `resolve_node_config_populates_flattened_relay_topics_field_on_a_strict_node_config`
852    /// (the unvalidated path) -- a `deny_unknown_fields` config with a flattened shared field
853    /// must still validate and deserialize correctly under the schema-validated path too.
854    #[tokio::test]
855    #[serial]
856    async fn resolve_node_config_validated_populates_flattened_relay_topics_field() {
857        let original_dir = std::env::current_dir().unwrap();
858
859        let project = tempfile::tempdir().unwrap();
860        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
861        let node_dir = project.path().join("configs").join("nodes").join("my-node");
862        fs::create_dir_all(&node_dir).unwrap();
863        fs::write(
864            node_dir.join("config.json"),
865            r#"{
866                "dev": { "custom": { "value": "project" }, "relay_topics": ["/my-node/status"] },
867                "production": { "custom": { "value": "project-prod" }, "relay_topics": ["/my-node/status"] }
868            }"#,
869        )
870        .unwrap();
871
872        std::env::set_current_dir(project.path()).unwrap();
873        let result = Context::resolve_node_config_validated::<StrictTestNodeConfig>("my-node").await;
874        std::env::set_current_dir(original_dir).unwrap();
875
876        let config = result.unwrap();
877        assert_eq!(config.value, "project");
878        assert_eq!(
879            config.base.relay.relay_topics,
880            Some(vec!["/my-node/status".to_string()])
881        );
882    }
883
884    /// LAB-4385: a node-specific field left at the top level, sibling to (not nested inside)
885    /// `custom`, must not be picked up - there is no dual-read fallback to the old flat shape
886    /// (`crate::node_config_base::NodeConfigBase`'s module docs, `docs/adr/0001-node-config-base-schema.md`).
887    #[tokio::test]
888    #[serial]
889    async fn resolve_node_config_ignores_a_node_specific_field_left_flat_at_the_top_level() {
890        let original_dir = std::env::current_dir().unwrap();
891
892        let project = tempfile::tempdir().unwrap();
893        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
894        let node_dir = project.path().join("configs").join("nodes").join("my-node");
895        fs::create_dir_all(&node_dir).unwrap();
896        fs::write(
897            node_dir.join("config.json"),
898            r#"{
899                "dev": { "value": "flat-and-ignored" },
900                "production": { "value": "flat-and-ignored-prod" }
901            }"#,
902        )
903        .unwrap();
904
905        std::env::set_current_dir(project.path()).unwrap();
906        let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
907        std::env::set_current_dir(original_dir).unwrap();
908
909        assert_eq!(result.unwrap().value, "");
910    }
911
912    /// LAB-4385: a config section that omits `custom` entirely just means "no node-specific
913    /// fields" - the base/reserved fields still load normally.
914    #[tokio::test]
915    #[serial]
916    async fn resolve_node_config_treats_missing_custom_as_no_custom_fields() {
917        let original_dir = std::env::current_dir().unwrap();
918
919        let project = tempfile::tempdir().unwrap();
920        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
921        let node_dir = project.path().join("configs").join("nodes").join("my-node");
922        fs::create_dir_all(&node_dir).unwrap();
923        fs::write(
924            node_dir.join("config.json"),
925            r#"{
926                "dev": { "relay_topics": ["/my-node/status"] },
927                "production": { "relay_topics": ["/my-node/status"] }
928            }"#,
929        )
930        .unwrap();
931
932        std::env::set_current_dir(project.path()).unwrap();
933        let result = Context::resolve_node_config::<StrictTestNodeConfig>("my-node", None).await;
934        std::env::set_current_dir(original_dir).unwrap();
935
936        let config = result.unwrap();
937        assert_eq!(config.value, "");
938        assert_eq!(
939            config.base.relay.relay_topics,
940            Some(vec!["/my-node/status".to_string()])
941        );
942    }
943
944    /// Regression test (LAB-4536) for [`super::split_base_and_custom`] directly: it must not
945    /// inject a `NodeConfigBase`-recognized field (`topics`, `model`, `provider`) that the
946    /// original section never mentioned, even though `NodeConfigBase`'s own `Serialize` fills
947    /// those fields in with their defaults. Doing so used to leak `"topics": {...}` into every
948    /// merged config regardless of whether the caller's section ever declared it, breaking any
949    /// `#[serde(deny_unknown_fields)]` config type that doesn't itself declare a `topics` field
950    /// (e.g. one that only embeds `crate::relay_topics::RelayTopicsField`, not the full
951    /// `NodeConfigBase`).
952    #[test]
953    fn split_base_and_custom_does_not_inject_an_unmentioned_base_field() {
954        let section = serde_json::json!({
955            "relay_topics": ["/my-node/status"],
956            "custom": { "value": "project" }
957        });
958
959        let merged = super::split_base_and_custom(&section).unwrap();
960        let merged = merged.as_object().unwrap();
961
962        assert!(merged.contains_key("relay_topics"), "mentioned field must survive");
963        assert!(
964            merged.contains_key("value"),
965            "custom field must be promoted to the top level"
966        );
967        assert!(
968            !merged.contains_key("topics"),
969            "a base field never mentioned by the section must not be injected: {:?}",
970            merged
971        );
972        assert!(
973            !merged.contains_key("model") && !merged.contains_key("provider"),
974            "unmentioned Option base fields must not be injected either: {:?}",
975            merged
976        );
977    }
978
979    /// Mirrors `split_base_and_custom_does_not_inject_an_unmentioned_base_field`, but proves the
980    /// positive case still works: a base field the section *does* mention must still come
981    /// through, re-serialized via `NodeConfigBase` (not read verbatim off the input).
982    #[test]
983    fn split_base_and_custom_still_carries_through_a_mentioned_base_field() {
984        let section = serde_json::json!({
985            "topics": { "publishes": [{"output": "/a/b"}], "subscribes": [] },
986            "custom": {}
987        });
988
989        let merged = super::split_base_and_custom(&section).unwrap();
990        assert_eq!(
991            merged["topics"],
992            serde_json::json!({ "publishes": [{"output": "/a/b"}], "subscribes": [] })
993        );
994    }
995}