mecha10-dev 0.6.2

Mecha10 dev services — node discovery, topology analysis, and dev mode support
Documentation
//! Node discovery, config loading, and source resolution

use anyhow::Result;
use std::path::PathBuf;

use crate::paths;
use crate::types::ProjectConfig;

use super::{NodeTopology, TopicRef, TopologyService};

impl TopologyService {
    /// Analyze all nodes
    pub(super) async fn analyze_nodes(&self, config: &ProjectConfig) -> Result<Vec<NodeTopology>> {
        let mut nodes = Vec::new();

        // Analyze all nodes from config
        for spec in config.nodes.get_node_specs() {
            let topology = self.analyze_node(&spec.name, &spec.package_path(), None).await?;
            nodes.push(topology);
        }

        Ok(nodes)
    }

    /// Analyze a single node's configuration for topics
    async fn analyze_node(&self, name: &str, package_path: &str, description: Option<&str>) -> Result<NodeTopology> {
        // Try to load node config first
        let (publishes, subscribes) = self.load_topics_from_config(name).await.unwrap_or_else(|_| {
            // Fallback: try source parsing if config loading fails
            let source_path = match self.resolve_node_source(package_path) {
                Ok(path) if path.exists() => path,
                _ => return (Vec::new(), Vec::new()),
            };

            // Parse source file for topics (blocking operation in async context)
            match tokio::task::block_in_place(|| {
                let rt = tokio::runtime::Handle::current();
                rt.block_on(self.parse_node_source(&source_path))
            }) {
                Ok((pubs, subs)) => (pubs, subs),
                Err(_) => (Vec::new(), Vec::new()),
            }
        });

        Ok(NodeTopology {
            name: name.to_string(),
            package: package_path.to_string(),
            enabled: true,
            description: description.map(String::from),
            publishes,
            subscribes,
        })
    }

    /// Load topic information from node config file
    ///
    /// Supports multiple config formats:
    ///
    /// **Format 1: Array format (recommended)**
    /// ```json
    /// {
    ///   "topics": {
    ///     "publishes": [
    ///       { "output": "/vision/classification" },
    ///       { "status": "/motor/status" }
    ///     ],
    ///     "subscribes": [
    ///       { "input": "/camera/rgb" }
    ///     ]
    ///   }
    /// }
    /// ```
    ///
    /// **Format 2: Flat format (for nodes with many semantic topics)**
    /// ```json
    /// {
    ///   "topics": {
    ///     "command_in": "/ai/command",
    ///     "response_out": "/ai/response",
    ///     "camera_in": "/camera/rgb"
    ///   }
    /// }
    /// ```
    /// Field names ending in `_in` or `_sub` are subscribes, `_out` or `_pub` are publishes.
    ///
    /// **Format 3: Root-level fields (legacy)**
    /// ```json
    /// {
    ///   "input_topic": "/camera/rgb",
    ///   "output_topic": "/inference/detections",
    ///   "control_topic": "/inference/cmd"
    /// }
    /// ```
    async fn load_topics_from_config(&self, node_name: &str) -> Result<(Vec<TopicRef>, Vec<TopicRef>)> {
        // Try multiple config locations (new format with dev/production sections):
        // 1. User project: configs/nodes/@mecha10/{node_name}/config.json
        // 2. User project: configs/nodes/@local/{node_name}/config.json
        // 3. Framework monorepo: packages/nodes/{node_name}/configs/config.json
        let mecha10_config_path = self
            .project_root
            .join("configs/nodes/@mecha10")
            .join(node_name)
            .join("config.json");

        let local_config_path = self
            .project_root
            .join("configs/nodes/@local")
            .join(node_name)
            .join("config.json");

        let framework_config_path = self
            .project_root
            .join(paths::framework::NODES_DIR)
            .join(node_name)
            .join("configs/config.json");

        let config_path = if mecha10_config_path.exists() {
            mecha10_config_path
        } else if local_config_path.exists() {
            local_config_path
        } else if framework_config_path.exists() {
            framework_config_path
        } else {
            anyhow::bail!(
                "Config file not found at {}, {}, or {}",
                mecha10_config_path.display(),
                local_config_path.display(),
                framework_config_path.display()
            );
        };

        // Load config as JSON
        let content = tokio::fs::read_to_string(&config_path).await?;
        let config: serde_json::Value = serde_json::from_str(&content)?;

        // Extract environment-specific config (default to dev)
        let profile = std::env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string());
        let env_config = if config.get("dev").is_some() || config.get("production").is_some() {
            // New format: { "dev": {...}, "production": {...} }
            let section = match profile.as_str() {
                "production" | "prod" => config.get("production"),
                _ => config.get("dev"),
            };
            section.cloned().unwrap_or(config)
        } else {
            // Legacy format: direct config object
            config
        };

        // Use the helper method to parse topics from JSON
        Ok(self.parse_topics_from_json(&env_config))
    }

    /// Resolve node source file path
    fn resolve_node_source(&self, package_path: &str) -> Result<PathBuf> {
        // Handle different path formats:
        // 1. Relative path: "./nodes/camera" or "./drivers/motor"
        // 2. Package name: "mecha10-nodes-camera"
        // 3. Framework package: "@mecha10/camera-node"

        if package_path.starts_with("./") || package_path.starts_with("../") {
            // Relative path
            let path = self.project_root.join(package_path).join("src/lib.rs");
            Ok(path)
        } else if package_path.starts_with("@mecha10/") || package_path.starts_with("mecha10-") {
            // Framework package - look in workspace packages
            let package_name = package_path
                .strip_prefix("@mecha10/")
                .or_else(|| package_path.strip_prefix("mecha10-"))
                .unwrap_or(package_path);

            // Strip "nodes-" or "drivers-" prefix if present (e.g., "mecha10-nodes-speaker" -> "speaker")
            let package_name = package_name
                .strip_prefix("nodes-")
                .or_else(|| package_name.strip_prefix("drivers-"))
                .unwrap_or(package_name);

            // Try to find framework root (MECHA10_FRAMEWORK_PATH or walk up to find workspace)
            let framework_root = self.find_framework_root()?;

            // Try packages/nodes/{name}/src/lib.rs
            let nodes_path = framework_root
                .join(paths::framework::NODES_DIR)
                .join(package_name)
                .join("src/lib.rs");
            if nodes_path.exists() {
                return Ok(nodes_path);
            }

            // Try packages/drivers/{name}/src/lib.rs
            let drivers_path = framework_root
                .join(paths::framework::DRIVERS_DIR)
                .join(package_name)
                .join("src/lib.rs");
            if drivers_path.exists() {
                return Ok(drivers_path);
            }

            // Try packages/services/{name}/src/lib.rs
            let services_path = framework_root
                .join(paths::framework::SERVICES_DIR)
                .join(package_name)
                .join("src/lib.rs");
            if services_path.exists() {
                return Ok(services_path);
            }

            // Not found, return the first attempt
            Ok(nodes_path)
        } else {
            // Assume it's a package name
            Ok(self
                .project_root
                .join(paths::project::NODES_DIR)
                .join(package_path)
                .join("src/lib.rs"))
        }
    }

    /// Find the mecha10 framework root directory
    fn find_framework_root(&self) -> Result<PathBuf> {
        // First try MECHA10_FRAMEWORK_PATH environment variable
        if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
            let path = PathBuf::from(framework_path);
            if path.exists() {
                return Ok(path);
            }
        }

        // Check if we're already in the framework root (has packages/nodes directory)
        if self.project_root.join(paths::framework::NODES_DIR).exists() {
            return Ok(self.project_root.clone());
        }

        // Walk up from project root to find workspace with packages/nodes
        let mut current = self.project_root.clone();
        loop {
            if current.join(paths::framework::NODES_DIR).exists() {
                return Ok(current);
            }

            match current.parent() {
                Some(parent) => current = parent.to_path_buf(),
                None => break,
            }
        }

        // Fallback to project root
        Ok(self.project_root.clone())
    }
}