mecha10-core 0.6.5

Mecha10 core runtime and foundational types
Documentation
//! Node-level configuration loading with environment-aware file fallback.

use super::Context;
use crate::error::{Mecha10Error, Result};
use anyhow::Context as _;
use serde::de::DeserializeOwned;

impl Context {
    /// Load node configuration with environment-aware file-level fallback
    ///
    /// This method implements the V2 configuration system with:
    /// - Environment-specific configs (dev/staging/production), selected *within* a config
    ///   file's `{"dev": {...}, "production": {...}}` envelope (see
    ///   [`Self::load_environment_aware_config`])
    /// - File-level fallback (no key-by-key merging)
    /// - Automatic project-root discovery via [`crate::fs_utils::find_project_root`]
    ///
    /// **Tier order** (mirrors `mecha10_cli_core::paths::config::node_config_candidates`, the
    /// canonical 2-tier resolution order for node configs -- this method can't call that helper
    /// directly since `mecha10-core` is a dependency *of* `mecha10-cli-core`, not the reverse,
    /// but it must stay in sync with it):
    /// 1. `configs/nodes/@mecha10/{node_name}/config.json` - Framework nodes
    /// 2. `configs/nodes/{node_name}/config.json` - Project nodes (preferred)
    /// 3. `T::default()` - Default implementation, if neither tier has a config file (or no
    ///    project root could be found)
    ///
    /// **Environment Detection:**
    /// - Uses `MECHA10_ENV` env var (default: "dev")
    /// - Supported values: "dev", "staging", "production", or custom
    ///
    /// **Semantic Validation:**
    /// - Not run here. This method is generic over `T` with only a
    ///   `DeserializeOwned + Default` bound, and stable Rust has no
    ///   specialization -- a function generic over `T` can't conditionally
    ///   call [`crate::config::Validate::validate`] only when `T` happens to
    ///   implement it. Node types that need semantic validation should call
    ///   `validate()` explicitly on the config returned here.
    ///
    /// # Type Parameters
    ///
    /// * `T` - Configuration type that implements `DeserializeOwned` + `Default`
    ///
    /// # Arguments
    ///
    /// * `node_name` - Name of the node (e.g., "simulator", "camera-streamer")
    ///
    /// # Returns
    ///
    /// The loaded configuration of type `T`, using the first available source
    /// in the fallback chain.
    ///
    /// # Example
    ///
    /// ```rust
    /// use mecha10_core::prelude::*;
    /// use serde::{Deserialize, Serialize};
    ///
    /// #[derive(Debug, Clone, Deserialize, Serialize, Default)]
    /// struct SimBridgeConfig {
    ///     camera_width: u32,
    ///     camera_height: u32,
    ///     camera_fps: u32,
    /// }
    ///
    /// # async fn example(ctx: &Context) -> Result<()> {
    /// let config: SimBridgeConfig = ctx.load_node_config("simulator").await?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn load_node_config<T>(&self, node_name: &str) -> Result<T>
    where
        T: DeserializeOwned + Default,
    {
        Self::resolve_node_config(node_name).await
    }

    /// The actual resolution logic behind [`Self::load_node_config`], split out as a
    /// `self`-free associated function so it's unit-testable without constructing a full
    /// [`Context`] (which requires a live Redis connection).
    async fn resolve_node_config<T>(node_name: &str) -> Result<T>
    where
        T: DeserializeOwned + Default,
    {
        use std::env;

        let environment = env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());

        let current_dir = env::current_dir()
            .map_err(|e| Mecha10Error::Configuration(format!("Failed to get current directory: {}", e)))?;

        if let Some(project_root) = crate::fs_utils::find_project_root(&current_dir) {
            let flat_config_path = project_root
                .join("configs")
                .join("nodes")
                .join("@mecha10")
                .join(node_name)
                .join("config.json");

            if flat_config_path.exists() {
                tracing::debug!(
                    "Loading node config for '{}' from flat path: {}",
                    node_name,
                    flat_config_path.display()
                );
                return Self::load_environment_aware_config(&flat_config_path, &environment)
                    .with_context(|| {
                        format!(
                            "Failed to load node config for '{}' from '{}'",
                            node_name,
                            flat_config_path.display()
                        )
                    })
                    .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
            }

            let project_config_path = project_root
                .join("configs")
                .join("nodes")
                .join(node_name)
                .join("config.json");

            if project_config_path.exists() {
                tracing::debug!(
                    "Loading node config for '{}' from project path: {}",
                    node_name,
                    project_config_path.display()
                );
                return Self::load_environment_aware_config(&project_config_path, &environment)
                    .with_context(|| {
                        format!(
                            "Failed to load node config for '{}' from '{}'",
                            node_name,
                            project_config_path.display()
                        )
                    })
                    .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
            }
        }

        tracing::debug!(
            "No config file found for node '{}', using Default implementation",
            node_name
        );
        let config = T::default();
        Ok(config)
    }

    /// Load a config file in environment-aware format.
    ///
    /// Config files must have the format:
    /// ```json
    /// {
    ///   "dev": { ... config ... },
    ///   "production": { ... config ... }
    /// }
    /// ```
    fn load_environment_aware_config<T>(path: &std::path::Path, environment: &str) -> Result<T>
    where
        T: DeserializeOwned + Default,
    {
        let content =
            std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;

        let json_value: serde_json::Value =
            serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;

        let obj = json_value.as_object().ok_or_else(|| {
            Mecha10Error::Configuration(format!(
                "Config must be a JSON object with 'dev'/'production' keys: {}",
                path.display()
            ))
        })?;

        let env_key = match environment {
            "production" | "prod" => "production",
            _ => "dev",
        };

        let env_section = obj
            .get(env_key)
            .or_else(|| {
                let fallback = if env_key == "dev" { "production" } else { "dev" };
                tracing::warn!(
                    "Environment '{}' not found in config, falling back to '{}': {}",
                    env_key,
                    fallback,
                    path.display()
                );
                obj.get(fallback)
            })
            .ok_or_else(|| {
                Mecha10Error::Configuration(format!(
                    "Config must have 'dev' or 'production' section: {}",
                    path.display()
                ))
            })?;

        tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
        let config: T = serde_json::from_value(env_section.clone())
            .with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
            .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;

        Ok(config)
    }
}

#[cfg(test)]
mod tests {
    use super::Context;
    use serde::{Deserialize, Serialize};
    use serial_test::serial;
    use std::fs;

    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
    struct TestNodeConfig {
        #[serde(default)]
        value: String,
    }

    /// Mirrors the real shape of a node's own config struct (e.g.
    /// `mecha10_nodes_behavior_executor::BehaviorExecutorConfig`) - `deny_unknown_fields` plus a
    /// flattened `crate::relay_topics::RelayTopicsField`, the pattern every node's config now
    /// uses instead of hand-declaring its own `relay_topics` field.
    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
    #[serde(deny_unknown_fields)]
    struct StrictTestNodeConfig {
        #[serde(default)]
        value: String,
        #[serde(flatten)]
        relay: crate::relay_topics::RelayTopicsField,
    }

    fn envelope(dev_value: &str) -> String {
        format!(r#"{{"dev": {{"value": "{dev_value}"}}, "production": {{"value": "{dev_value}-prod"}}}}"#)
    }

    #[tokio::test]
    #[serial]
    async fn resolve_node_config_loads_from_project_tier_when_only_project_config_exists() {
        let original_dir = std::env::current_dir().unwrap();

        let project = tempfile::tempdir().unwrap();
        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
        let node_dir = project.path().join("configs").join("nodes").join("my-node");
        fs::create_dir_all(&node_dir).unwrap();
        fs::write(node_dir.join("config.json"), envelope("project")).unwrap();

        std::env::set_current_dir(project.path()).unwrap();
        let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
        std::env::set_current_dir(original_dir).unwrap();

        assert_eq!(result.unwrap().value, "project");
    }

    #[tokio::test]
    #[serial]
    async fn resolve_node_config_prefers_framework_tier_over_project_tier() {
        let original_dir = std::env::current_dir().unwrap();

        let project = tempfile::tempdir().unwrap();
        fs::write(project.path().join("mecha10.json"), "{}").unwrap();

        let framework_dir = project
            .path()
            .join("configs")
            .join("nodes")
            .join("@mecha10")
            .join("my-node");
        fs::create_dir_all(&framework_dir).unwrap();
        fs::write(framework_dir.join("config.json"), envelope("framework")).unwrap();

        let project_dir = project.path().join("configs").join("nodes").join("my-node");
        fs::create_dir_all(&project_dir).unwrap();
        fs::write(project_dir.join("config.json"), envelope("project")).unwrap();

        std::env::set_current_dir(project.path()).unwrap();
        let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
        std::env::set_current_dir(original_dir).unwrap();

        assert_eq!(result.unwrap().value, "framework");
    }

    /// Regression test (LAB-2270): a config declaring `relay_topics` must load into a strict
    /// (`deny_unknown_fields`) node config that embeds `crate::relay_topics::RelayTopicsField`
    /// via `#[serde(flatten)]` - and the value must actually come through typed, not be
    /// discarded. (An earlier design had `Context::load_node_config` silently strip the field
    /// out of the JSON before deserializing, so no node needed to declare it at all - correct,
    /// but opaque: a node's own type gave no hint the field existed, and it could never
    /// meaningfully read the value even if it wanted to. Flattening a shared field in is the
    /// same "no per-node reimplementation" property, but explicit and typed instead.)
    #[tokio::test]
    #[serial]
    async fn resolve_node_config_populates_flattened_relay_topics_field_on_a_strict_node_config() {
        let original_dir = std::env::current_dir().unwrap();

        let project = tempfile::tempdir().unwrap();
        fs::write(project.path().join("mecha10.json"), "{}").unwrap();
        let node_dir = project.path().join("configs").join("nodes").join("my-node");
        fs::create_dir_all(&node_dir).unwrap();
        fs::write(
            node_dir.join("config.json"),
            r#"{
                "dev": { "value": "project", "relay_topics": ["/my-node/status"] },
                "production": { "value": "project-prod", "relay_topics": ["/my-node/status"] }
            }"#,
        )
        .unwrap();

        std::env::set_current_dir(project.path()).unwrap();
        let result = Context::resolve_node_config::<StrictTestNodeConfig>("my-node").await;
        std::env::set_current_dir(original_dir).unwrap();

        let config = result.unwrap();
        assert_eq!(config.value, "project");
        assert_eq!(config.relay.relay_topics, Some(vec!["/my-node/status".to_string()]));
    }

    #[tokio::test]
    #[serial]
    async fn resolve_node_config_ignores_dead_v1_and_common_tiers_and_falls_back_to_default() {
        let original_dir = std::env::current_dir().unwrap();

        let project = tempfile::tempdir().unwrap();
        fs::write(project.path().join("mecha10.json"), "{}").unwrap();

        let env_tier_dir = project.path().join("configs").join("dev").join("nodes").join("my-node");
        fs::create_dir_all(&env_tier_dir).unwrap();
        fs::write(env_tier_dir.join("config.json"), r#"{"value": "dead-env-tier"}"#).unwrap();

        let common_tier_dir = project
            .path()
            .join("configs")
            .join("common")
            .join("nodes")
            .join("my-node");
        fs::create_dir_all(&common_tier_dir).unwrap();
        fs::write(common_tier_dir.join("config.json"), r#"{"value": "dead-common-tier"}"#).unwrap();

        std::env::set_current_dir(project.path()).unwrap();
        let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
        std::env::set_current_dir(original_dir).unwrap();

        assert_eq!(result.unwrap(), TestNodeConfig::default());
    }

    #[tokio::test]
    #[serial]
    async fn resolve_node_config_falls_back_to_default_when_no_project_root_is_found() {
        let original_dir = std::env::current_dir().unwrap();

        let dir = tempfile::tempdir().unwrap();

        std::env::set_current_dir(dir.path()).unwrap();
        let result = Context::resolve_node_config::<TestNodeConfig>("my-node").await;
        std::env::set_current_dir(original_dir).unwrap();

        assert_eq!(result.unwrap(), TestNodeConfig::default());
    }
}