mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Development mode service
//!
//! Service for managing development mode operations including Redis flushing,
//! port checking, and simulation launching.

use anyhow::{Context, Result};
use std::collections::HashMap;
use std::net::TcpListener;
use std::path::{Path, PathBuf};

use crate::paths;
use crate::types::SimulationConfig;

/// Resolved simulation paths from configuration
#[derive(Debug, Clone)]
pub struct SimulationPaths {
    /// Path to the robot model directory
    pub model_path: PathBuf,
    /// Path to the environment directory
    pub environment_path: PathBuf,
    /// Optional path to model configuration override
    pub model_config_path: Option<PathBuf>,
    /// Optional path to environment configuration override
    pub environment_config_path: Option<PathBuf>,
    /// Run Godot in headless mode
    pub headless: bool,
    /// Networking configuration
    pub networking: crate::types::simulation::NetworkingConfig,
}

/// Development mode service
///
/// Provides business logic for development mode operations:
/// - Port availability checking
/// - Redis state management
/// - Simulation path resolution
/// - Environment variable setup
///
/// # Examples
///
/// ```rust,ignore
/// use mecha10_cli::services::DevService;
///
/// # async fn example() -> anyhow::Result<()> {
/// let service = DevService::new();
///
/// // Check if port is available
/// if service.is_port_available(11008)? {
///     println!("Port 11008 is available");
/// }
///
/// // Flush Redis for clean dev session
/// service.flush_redis(&mut redis_conn).await?;
/// # Ok(())
/// # }
/// ```
pub struct DevService {
    #[allow(dead_code)]
    redis_url: String,
}

impl DevService {
    /// Create a new dev service
    ///
    /// # Arguments
    ///
    /// * `redis_url` - Redis connection URL
    pub fn new(redis_url: String) -> Self {
        Self { redis_url }
    }

    /// Load project information from context
    ///
    /// # Returns
    ///
    /// Tuple of (project_name, project_version, project_config)
    pub async fn load_project_info(
        ctx: &mut crate::context::CliContext,
    ) -> Result<(String, String, crate::types::ProjectConfig)> {
        let project = ctx.project()?;
        let name = project.name()?.to_string();
        let version = project.version()?.to_string();
        let config = ctx.load_project_config().await?;
        Ok((name, version, config))
    }

    /// Resolve simulation paths from configuration
    ///
    /// Loads simulation config from configs/simulation/config.json (with dev/production sections)
    /// based on mecha10.json simulation settings.
    ///
    /// # Arguments
    ///
    /// * `ctx` - CLI context
    /// * `config` - Project configuration
    ///
    /// # Returns
    ///
    /// Optional SimulationPaths struct with model and environment paths
    pub fn resolve_simulation_paths(
        ctx: &mut crate::context::CliContext,
        config: &crate::types::ProjectConfig,
    ) -> Result<Option<SimulationPaths>> {
        if let Some(sim_config) = &config.simulation {
            let sim = ctx.simulation();

            // Try loading simulation config (defaults to "dev" profile)
            if let Ok(runtime_config) =
                SimulationConfig::load_with_profile_and_scenario(Some("dev"), sim_config.scenario.as_deref())
            {
                // Load model and environment from runtime config
                let model_path = sim.resolve_model_path(&runtime_config.model)?;
                let env_path = sim.resolve_environment_path(&runtime_config.environment)?;

                // Get config paths if specified and convert to absolute paths
                let model_config_path = sim_config.model_config.as_ref().map(|p| {
                    let path = PathBuf::from(p);
                    if path.is_absolute() {
                        path
                    } else {
                        std::env::current_dir().unwrap_or_default().join(path)
                    }
                });
                let env_config_path = sim_config.environment_config.as_ref().map(|p| {
                    let path = PathBuf::from(p);
                    if path.is_absolute() {
                        path
                    } else {
                        std::env::current_dir().unwrap_or_default().join(path)
                    }
                });

                return Ok(Some(SimulationPaths {
                    model_path,
                    environment_path: env_path,
                    model_config_path,
                    environment_config_path: env_config_path,
                    headless: runtime_config.godot.headless,
                    networking: runtime_config.networking.clone(),
                }));
            }

            // Fall back to legacy schema for backwards compatibility
            if let Some(model) = &sim_config.model {
                let model_path = sim.resolve_model_path(model)?;
                let env_name = sim_config
                    .environment
                    .as_deref()
                    .unwrap_or("@mecha10/simulation-environments/basic_arena");
                let env_path = sim.resolve_environment_path(env_name)?;

                // Get config paths if specified and convert to absolute paths
                let model_config_path = sim_config.model_config.as_ref().map(|p| {
                    let path = PathBuf::from(p);
                    if path.is_absolute() {
                        path
                    } else {
                        std::env::current_dir().unwrap_or_default().join(path)
                    }
                });
                let env_config_path = sim_config.environment_config.as_ref().map(|p| {
                    let path = PathBuf::from(p);
                    if path.is_absolute() {
                        path
                    } else {
                        std::env::current_dir().unwrap_or_default().join(path)
                    }
                });

                return Ok(Some(SimulationPaths {
                    model_path,
                    environment_path: env_path,
                    model_config_path,
                    environment_config_path: env_config_path,
                    headless: false, // Legacy mode defaults to non-headless
                    networking: crate::types::simulation::NetworkingConfig::default(),
                }));
            }

            // No valid config found
            Err(anyhow::anyhow!(
                "Simulation enabled but no valid configuration found.\n\
                 Either:\n\
                 1. Create configs/simulation/config.json with dev/production sections\n\
                 2. Use legacy format with model/environment fields in mecha10.json"
            ))
        } else {
            Ok(None)
        }
    }

    /// Check if a port is available
    ///
    /// # Arguments
    ///
    /// * `port` - Port number to check
    ///
    /// # Returns
    ///
    /// `Ok(true)` if port is available, `Ok(false)` if in use
    pub fn is_port_available(&self, port: u16) -> Result<bool> {
        Ok(TcpListener::bind(("127.0.0.1", port)).is_ok())
    }

    /// Check if a port is in use
    ///
    /// # Arguments
    ///
    /// * `port` - Port number to check
    ///
    /// # Returns
    ///
    /// `Ok(true)` if port is in use, `Ok(false)` if available
    pub fn is_port_in_use(&self, port: u16) -> Result<bool> {
        Ok(!self.is_port_available(port)?)
    }

    /// Flush Redis database for clean dev session
    ///
    /// # Arguments
    ///
    /// * `conn` - Mutable reference to Redis connection (generic type)
    ///
    /// # Errors
    ///
    /// Returns error if Redis FLUSHALL command fails
    pub async fn flush_redis<C>(&self, conn: &mut C) -> Result<()>
    where
        C: redis::aio::ConnectionLike,
    {
        redis::cmd("FLUSHALL")
            .query_async::<()>(conn)
            .await
            .context("Failed to flush Redis")?;
        Ok(())
    }

    /// Get default environment variables for dev mode
    ///
    /// # Returns
    ///
    /// HashMap of environment variable key-value pairs
    ///
    /// Note: This method is no longer used by CLI (replaced by node-runner in Phase 2).
    /// Kept for testing purposes.
    #[allow(dead_code)]
    pub fn get_default_env_vars(&self) -> HashMap<String, String> {
        let mut env = HashMap::new();
        env.insert("REDIS_URL".to_string(), self.redis_url.clone());
        env.insert("RUST_LOG".to_string(), "info".to_string());
        env
    }

    /// Build Godot launch command arguments
    ///
    /// # Arguments
    ///
    /// * `godot_project_path` - Path to Godot project directory
    /// * `model_path` - Path to robot model directory
    /// * `env_path` - Path to environment directory
    /// * `headless` - Run Godot in headless mode (no GUI)
    ///
    /// # Returns
    ///
    /// Vector of command-line arguments for Godot
    #[allow(clippy::too_many_arguments)]
    pub fn build_godot_args(
        &self,
        godot_project_path: &Path,
        model_path: &Path,
        env_path: &Path,
        model_config_path: Option<&PathBuf>,
        env_config_path: Option<&PathBuf>,
        headless: bool,
        networking: Option<&crate::types::simulation::NetworkingConfig>,
    ) -> Vec<String> {
        // Extract environment name from path (e.g., "basic_arena")
        let env_name = env_path.file_name().and_then(|n| n.to_str()).unwrap_or("environment");

        // Extract model name from path (e.g., "rover")
        let model_name = model_path.file_name().and_then(|n| n.to_str()).unwrap_or("robot");

        // Build res:// paths for resource packs
        // Resource packs export with full paths: res://packages/simulation/
        let env_scene_path = format!("res://packages/simulation/environments/{}/{}.tscn", env_name, env_name);
        let model_scene_path = format!("res://packages/simulation/models/{}/robot.tscn", model_name);

        // Don't pass scene path - let Godot use run/main_scene from project.godot
        // This ensures consistent scene loading behavior
        let mut args = vec!["--path".to_string(), godot_project_path.to_string_lossy().to_string()];

        // Add headless flag before -- separator if enabled
        if headless {
            args.push("--headless".to_string());
        }

        args.push("--".to_string());
        args.push(format!("--env-path={}", env_scene_path));
        args.push(format!("--model-path={}", model_scene_path));

        // Use provided config paths if available
        if let Some(model_config) = model_config_path {
            args.push(format!("--model-config={}", model_config.display()));
        }

        if let Some(env_config) = env_config_path {
            args.push(format!("--env-config={}", env_config.display()));
        }

        // Add networking config if provided
        if let Some(net) = networking {
            args.push(format!("--protocol-port={}", net.protocol_port));
            args.push(format!("--protocol-bind={}", net.protocol_bind));
            args.push(format!("--camera-port={}", net.camera_port));
            args.push(format!("--camera-bind={}", net.camera_bind));
            args.push(format!("--signaling-port={}", net.signaling_port));
            args.push(format!("--signaling-bind={}", net.signaling_bind));
        }

        args
    }

    /// Determine Godot project path based on environment
    ///
    /// Resolution order:
    /// 1. MECHA10_FRAMEWORK_PATH environment variable (if set)
    /// 2. Compile-time path from CLI package location (../simulation/godot-project)
    /// 3. Downloaded assets cache (~/.mecha10/simulation/current/godot-project)
    /// 4. Project-local path (simulation/godot) as fallback
    ///
    /// # Returns
    ///
    /// PathBuf to Godot project directory
    pub fn get_godot_project_path(&self) -> PathBuf {
        // 1. Check MECHA10_FRAMEWORK_PATH environment variable first
        if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
            return PathBuf::from(framework_path).join(paths::framework::SIMULATION_GODOT_DIR);
        }

        // 2. Use compile-time path from CLI package (packages/cli -> packages/simulation)
        // This works for installed CLI from the monorepo
        let cli_manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let godot_project_path = cli_manifest_dir
            .parent() // packages/
            .map(|p| p.join("simulation/godot-project"))
            .unwrap_or_else(|| PathBuf::from(paths::project::SIMULATION_GODOT_DIR));

        if godot_project_path.exists() {
            return godot_project_path;
        }

        // 3. Check downloaded assets cache
        let assets_service = crate::services::SimulationAssetsService::new();
        if let Some(godot_path) = assets_service.godot_project_path() {
            return godot_path;
        }

        // 4. Fall back to project-local path
        PathBuf::from(paths::project::SIMULATION_GODOT_DIR)
    }
}

impl Default for DevService {
    fn default() -> Self {
        Self::new("redis://localhost:6379".to_string())
    }
}