mecha10-dev 0.6.5

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();

        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> {
        let (publishes, subscribes) = self.load_topics_from_config(name).await.unwrap_or_else(|_| {
            let source_path = match self.resolve_node_source(package_path) {
                Ok(path) if path.exists() => path,
                _ => return (Vec::new(), Vec::new()),
            };

            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>)> {
        let mecha10_config_path = self.project_root.join(paths::config::framework_node(node_name));

        let project_config_path = self.project_root.join(paths::config::project_node(node_name));

        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 project_config_path.exists() {
            project_config_path
        } else if framework_config_path.exists() {
            framework_config_path
        } else {
            anyhow::bail!(
                "Config file not found at {}, {}, or {}",
                mecha10_config_path.display(),
                project_config_path.display(),
                framework_config_path.display()
            );
        };

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

        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() {
            let section = match profile.as_str() {
                "production" | "prod" => config.get("production"),
                _ => config.get("dev"),
            };
            section.cloned().unwrap_or(config)
        } else {
            config
        };

        Ok(self.parse_topics_from_json(&env_config))
    }

    /// Resolve node source file path
    fn resolve_node_source(&self, package_path: &str) -> Result<PathBuf> {
        if package_path.starts_with("./") || package_path.starts_with("../") {
            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-") {
            let package_name = package_path
                .strip_prefix("@mecha10/")
                .or_else(|| package_path.strip_prefix("mecha10-"))
                .unwrap_or(package_path);

            let package_name = package_name
                .strip_prefix("nodes-")
                .or_else(|| package_name.strip_prefix("drivers-"))
                .unwrap_or(package_name);

            let framework_root = self.find_framework_root()?;

            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);
            }

            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);
            }

            Ok(nodes_path)
        } else {
            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> {
        if self.project_root.join(paths::framework::NODES_DIR).exists() {
            return Ok(self.project_root.clone());
        }

        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,
            }
        }

        Ok(self.project_root.clone())
    }
}