mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Environment selection and scoring algorithm

use anyhow::Result;
use std::collections::HashSet;

use super::catalog::{Environment, EnvironmentCatalog};
use super::profile::RobotProfile;

#[derive(Debug, Clone)]
pub struct EnvironmentMatch {
    pub environment: Environment,
    pub score: i32,
    pub reasons: Vec<String>,
}

pub struct EnvironmentSelector {
    catalog: EnvironmentCatalog,
}

impl EnvironmentSelector {
    /// Create new selector with default catalog
    pub fn new() -> Result<Self> {
        let catalog = EnvironmentCatalog::load_default()?;
        Ok(Self { catalog })
    }

    /// Create selector with custom catalog
    pub fn with_catalog(catalog: EnvironmentCatalog) -> Self {
        Self { catalog }
    }

    /// Select best matching environments for robot profile
    pub fn select_environments(&self, profile: &RobotProfile, max_envs: usize) -> Result<Vec<EnvironmentMatch>> {
        let mut matches: Vec<EnvironmentMatch> = self
            .catalog
            .environments
            .iter()
            .map(|env| self.score_environment(env, profile))
            .filter(|m| m.score > 0) // Only positive matches
            .collect();

        // Sort by score (descending)
        matches.sort_by_key(|m| -m.score);

        // Return top N
        Ok(matches.into_iter().take(max_envs).collect())
    }

    /// Score a single environment against robot profile
    fn score_environment(&self, env: &Environment, profile: &RobotProfile) -> EnvironmentMatch {
        let mut score = 0;
        let mut reasons = Vec::new();

        // 1. Task node matching (highest priority: +20 per match)
        for task_node in &profile.task_nodes {
            if env.task_nodes.contains(&task_node.name) {
                score += 20;
                reasons.push(format!("Task node match: {}", task_node.name));
            }
        }

        // 2. Tag matching from task nodes (+10 per tag match)
        let node_tags: HashSet<String> = profile.task_nodes.iter().flat_map(|n| n.tags.iter().cloned()).collect();

        for tag in &node_tags {
            if env.tags.contains(tag) {
                score += 10;
                reasons.push(format!("Tag match: {}", tag));
            }
        }

        // 3. Sensor requirements (+5 if all present, -50 if missing any)
        let has_required_sensors = env.is_compatible_with_sensors(&profile.sensors);

        if has_required_sensors {
            score += 5;
            reasons.push("All required sensors present".to_string());
        } else {
            score -= 50; // Hard penalty for missing sensors
            let missing: Vec<_> = env
                .required_sensors
                .iter()
                .filter(|s| !profile.sensors.contains(s))
                .collect();
            reasons.push(format!(
                "Missing required sensors: {}",
                missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
            ));
        }

        // 4. Platform compatibility (+3)
        if env.is_compatible_with_platform(&profile.platform) {
            score += 3;
            reasons.push(format!("Platform match: {}", profile.platform));
        }

        // 5. Difficulty preference (+2)
        if env.difficulty == profile.difficulty_preference {
            score += 2;
            reasons.push(format!("Difficulty match: {}", env.difficulty));
        }

        // 6. Capability matching (+1 per capability)
        for capability in &profile.capabilities {
            if env.tags.contains(capability) {
                score += 1;
                reasons.push(format!("Capability match: {}", capability));
            }
        }

        EnvironmentMatch {
            environment: env.clone(),
            score,
            reasons,
        }
    }

    /// Get catalog reference
    pub fn catalog(&self) -> &EnvironmentCatalog {
        &self.catalog
    }
}