mecha10-core 0.6.2

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)
    /// - File-level fallback (no key-by-key merging)
    /// - Automatic path resolution
    ///
    /// **Fallback Priority:**
    /// 1. `configs/{env}/nodes/{node-name}/config.json` (environment-specific)
    /// 2. `configs/common/nodes/{node-name}/config.json` (common fallback)
    /// 3. `T::default()` (code default)
    ///
    /// **Environment Detection:**
    /// - Uses `MECHA10_ENVIRONMENT` env var (default: "dev")
    /// - Supported values: "dev", "staging", "production", or custom
    ///
    /// **Semantic Validation:**
    /// - If `T` implements [`crate::config::Validate`], `validate()` is called
    ///   automatically on the loaded (or default) config and any error it
    ///   returns is propagated. Types that don't implement `Validate` are
    ///   unaffected -- this is a purely additive, opt-in hook.
    ///
    /// # Type Parameters
    ///
    /// * `T` - Configuration type that implements `DeserializeOwned` + `Default`
    ///
    /// # Arguments
    ///
    /// * `node_name` - Name of the node (e.g., "mujoco-sim", "camera-streamer")
    ///
    /// # Returns
    ///
    /// The loaded configuration of type `T`, using the first available source
    /// in the fallback chain.
    ///
    /// # Fallback Chain
    ///
    /// Configs are searched in order:
    /// 1. `configs/{env}/nodes/{node_name}/config.json` - Environment-specific
    /// 2. `configs/common/nodes/{node_name}/config.json` - Common config
    /// 3. `configs/nodes/@mecha10/{node_name}/config.json` - Framework nodes
    /// 4. `configs/nodes/{node_name}/config.json` - Project nodes (preferred)
    /// 5. `configs/nodes/@local/{node_name}/config.json` - Legacy format
    /// 6. `T::default()` - Default implementation
    ///
    /// # Example
    ///
    /// ```rust
    /// use mecha10::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("mujoco-sim").await?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn load_node_config<T>(&self, node_name: &str) -> Result<T>
    where
        T: DeserializeOwned + Default,
    {
        use crate::config::load_config;
        use std::env;
        use std::path::PathBuf;

        // Get environment from env var (default: "dev")
        let environment = env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string());

        // Try multiple base paths (for running from different locations)
        let base_paths = vec![
            PathBuf::from("."),           // Current directory
            PathBuf::from("../.."),       // From target/debug/deps
            PathBuf::from("../../../.."), // From packages/*/target/debug/deps
        ];

        // Build fallback chain for each base path
        for base_path in &base_paths {
            // 1. Try environment-specific config
            let env_config_path = base_path
                .join("configs")
                .join(&environment)
                .join("nodes")
                .join(node_name)
                .join("config.json");

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

            // 2. Try common config
            let common_config_path = base_path
                .join("configs")
                .join("common")
                .join("nodes")
                .join(node_name)
                .join("config.json");

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

            // 3. Try new flat structure: configs/nodes/@mecha10/{node_name}/config.json
            // This format supports environment-aware configs: { "dev": {...}, "production": {...} }
            let flat_config_path = base_path
                .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)));
            }

            // 4. Try plain project nodes: configs/nodes/{node_name}/config.json
            // This is the preferred format for project-local nodes
            let project_config_path = base_path
                .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)));
            }

            // 5. Try @local nodes (legacy): configs/nodes/@local/{node_name}/config.json
            let local_config_path = base_path
                .join("configs")
                .join("nodes")
                .join("@local")
                .join(node_name)
                .join("config.json");

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

        // 3. Fall back to default
        tracing::debug!(
            "No config file found for node '{}', using Default implementation",
            node_name
        );
        let config = T::default();
        crate::config::maybe_validate(&config)?;
        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()
            ))
        })?;

        // Determine which environment key to use
        let env_key = match environment {
            "production" | "prod" => "production",
            _ => "dev",
        };

        // Get the environment section
        let env_section = obj
            .get(env_key)
            .or_else(|| {
                // Fallback to other environment if requested one doesn't exist
                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)))?;

        // Run semantic validation if `T` implements `Validate` (no-op otherwise).
        crate::config::maybe_validate(&config)?;

        Ok(config)
    }
}