mecha10-dev 0.6.2

Mecha10 dev services — node discovery, topology analysis, and dev mode support
Documentation
//! Topic-centric view construction and JSON topic-config parsing

use std::collections::HashMap;

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

impl TopologyService {
    /// Build topic-centric view from node-centric data
    pub(super) fn build_topic_view(&self, nodes: &[NodeTopology]) -> Vec<TopicTopology> {
        let mut topic_map: HashMap<String, TopicTopology> = HashMap::new();

        for node in nodes {
            // Add publishers
            for topic_ref in &node.publishes {
                let topic = topic_map
                    .entry(topic_ref.path.clone())
                    .or_insert_with(|| TopicTopology {
                        path: topic_ref.path.clone(),
                        message_type: topic_ref.message_type.clone(),
                        publishers: Vec::new(),
                        subscribers: Vec::new(),
                    });
                if !topic.publishers.contains(&node.name) {
                    topic.publishers.push(node.name.clone());
                }
            }

            // Add subscribers
            for topic_ref in &node.subscribes {
                let topic = topic_map
                    .entry(topic_ref.path.clone())
                    .or_insert_with(|| TopicTopology {
                        path: topic_ref.path.clone(),
                        message_type: topic_ref.message_type.clone(),
                        publishers: Vec::new(),
                        subscribers: Vec::new(),
                    });
                if !topic.subscribers.contains(&node.name) {
                    topic.subscribers.push(node.name.clone());
                }
            }
        }

        let mut topics: Vec<TopicTopology> = topic_map.into_values().collect();
        topics.sort_by(|a, b| a.path.cmp(&b.path));
        topics
    }

    /// Parse topics from JSON config value
    ///
    /// This is a helper method extracted for testing. It contains the core parsing logic
    /// from `load_topics_from_config` but operates on a JSON value instead of a file path.
    pub(super) fn parse_topics_from_json(&self, config: &serde_json::Value) -> (Vec<TopicRef>, Vec<TopicRef>) {
        let mut publishes = Vec::new();
        let mut subscribes = Vec::new();

        // Try Format 1: Array format (topics.publishes/topics.subscribes)
        if let Some(topics) = config.get("topics").and_then(|v| v.as_object()) {
            // Check if using array format
            if topics.contains_key("publishes") || topics.contains_key("subscribes") {
                // Format 1: Array format
                if let Some(pubs) = topics.get("publishes").and_then(|v| v.as_array()) {
                    for topic in pubs {
                        if let Some(topic_obj) = topic.as_object() {
                            for (_semantic_name, topic_path) in topic_obj {
                                if let Some(path_str) = topic_path.as_str() {
                                    publishes.push(TopicRef {
                                        path: path_str.to_string(),
                                        message_type: None,
                                    });
                                }
                            }
                        }
                    }
                }

                if let Some(subs) = topics.get("subscribes").and_then(|v| v.as_array()) {
                    for topic in subs {
                        if let Some(topic_obj) = topic.as_object() {
                            for (_semantic_name, topic_path) in topic_obj {
                                if let Some(path_str) = topic_path.as_str() {
                                    subscribes.push(TopicRef {
                                        path: path_str.to_string(),
                                        message_type: None,
                                    });
                                }
                            }
                        }
                    }
                }
            } else {
                // Format 2: Flat format - infer pub/sub from field names
                for (field_name, topic_path) in topics {
                    if let Some(path_str) = topic_path.as_str() {
                        // Classify based on field name suffix
                        if field_name.ends_with("_in") || field_name.ends_with("_sub") || field_name.contains("input") {
                            subscribes.push(TopicRef {
                                path: path_str.to_string(),
                                message_type: None,
                            });
                        } else if field_name.ends_with("_out")
                            || field_name.ends_with("_pub")
                            || field_name.contains("output")
                        {
                            publishes.push(TopicRef {
                                path: path_str.to_string(),
                                message_type: None,
                            });
                        }
                        // If field name doesn't match patterns, skip it (might be control/status)
                    }
                }
            }
        }

        // Try Format 3: Root-level topic fields (fallback)
        if publishes.is_empty() && subscribes.is_empty() {
            // Check for input_topic, output_topic, control_topic pattern
            if let Some(input) = config.get("input_topic").and_then(|v| v.as_str()) {
                subscribes.push(TopicRef {
                    path: input.to_string(),
                    message_type: None,
                });
            }
            if let Some(output) = config.get("output_topic").and_then(|v| v.as_str()) {
                publishes.push(TopicRef {
                    path: output.to_string(),
                    message_type: None,
                });
            }
            if let Some(control) = config.get("control_topic").and_then(|v| v.as_str()) {
                subscribes.push(TopicRef {
                    path: control.to_string(),
                    message_type: None,
                });
            }
        }

        (publishes, subscribes)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn create_test_service() -> TopologyService {
        TopologyService::new(PathBuf::from("/tmp/test"))
    }

    #[test]
    fn test_parse_array_format() {
        let service = create_test_service();
        let config: serde_json::Value = serde_json::json!({
            "topics": {
                "publishes": [
                    { "output": "/vision/classification" },
                    { "status": "/motor/status" }
                ],
                "subscribes": [
                    { "input": "/camera/rgb" }
                ]
            }
        });

        let (publishes, subscribes) = service.parse_topics_from_json(&config);

        assert_eq!(publishes.len(), 2);
        assert_eq!(subscribes.len(), 1);
        assert_eq!(publishes[0].path, "/vision/classification");
        assert_eq!(publishes[1].path, "/motor/status");
        assert_eq!(subscribes[0].path, "/camera/rgb");
    }

    #[test]
    fn test_parse_flat_format() {
        let service = create_test_service();
        let config: serde_json::Value = serde_json::json!({
            "topics": {
                "command_in": "/ai/command",
                "response_out": "/ai/response",
                "camera_in": "/camera/rgb",
                "nav_goal_out": "/nav/goal",
                "motor_cmd_out": "/motor/cmd_vel",
                "behavior_out": "/behavior/execute"
            }
        });

        let (publishes, subscribes) = service.parse_topics_from_json(&config);

        // Should have 4 publishes (_out suffix)
        assert_eq!(publishes.len(), 4);
        assert!(publishes.iter().any(|t| t.path == "/ai/response"));
        assert!(publishes.iter().any(|t| t.path == "/nav/goal"));
        assert!(publishes.iter().any(|t| t.path == "/motor/cmd_vel"));
        assert!(publishes.iter().any(|t| t.path == "/behavior/execute"));

        // Should have 2 subscribes (_in suffix)
        assert_eq!(subscribes.len(), 2);
        assert!(subscribes.iter().any(|t| t.path == "/ai/command"));
        assert!(subscribes.iter().any(|t| t.path == "/camera/rgb"));
    }

    #[test]
    fn test_parse_root_level_format() {
        let service = create_test_service();
        let config: serde_json::Value = serde_json::json!({
            "input_topic": "/robot/sensors/camera/rgb",
            "output_topic": "/inference/detections",
            "control_topic": "/inference/cmd"
        });

        let (publishes, subscribes) = service.parse_topics_from_json(&config);

        assert_eq!(publishes.len(), 1);
        assert_eq!(subscribes.len(), 2);
        assert_eq!(publishes[0].path, "/inference/detections");
        assert!(subscribes.iter().any(|t| t.path == "/robot/sensors/camera/rgb"));
        assert!(subscribes.iter().any(|t| t.path == "/inference/cmd"));
    }
}