bevy_brp_mcp 0.22.2

MCP server for Bevy Remote Protocol (BRP) integration
use std::collections::HashMap;
use std::fs::File;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;

use error_stack::Report;
use serde_json::Value;
use tracing::debug;
use tracing::info;

use super::constants::BUILD_OUTPUT_FRESH_FIELD;
use super::constants::BUILD_OUTPUT_NAME_FIELD;
use super::constants::BUILD_OUTPUT_TARGET_FIELD;
use super::constants::CARGO_RELEASE_FLAG;
use super::logging;
use crate::app_tools::constants::CARGO_BUILD_SUBCOMMAND;
use crate::app_tools::constants::CARGO_COMMAND_NAME;
use crate::app_tools::constants::CARGO_EXAMPLE_FLAG;
use crate::app_tools::constants::CARGO_MESSAGE_FORMAT_JSON_FLAG;
use crate::app_tools::constants::CARGO_RUN_SUBCOMMAND;
use crate::app_tools::constants::PROFILE_RELEASE;
use crate::app_tools::constants::USER_ARGUMENT_SEPARATOR;
use crate::app_tools::targets::TargetType;
use crate::brp_tools::BRP_EXTRAS_PORT_ENV_VAR;
use crate::brp_tools::Port;
use crate::error::Error;
use crate::error::Result;

#[derive(Debug, Clone, Copy)]
pub(super) enum BuildState {
    NotFound,
    Fresh,
    Rebuilt,
}

pub(super) fn validate_manifest_directory(manifest_path: &Path) -> Result<&Path> {
    manifest_path.parent().ok_or_else(|| {
        Report::new(Error::FileOrPathNotFound(
            "Invalid manifest path".to_string(),
        ))
        .attach("No parent directory found")
        .attach(format!("Path: {}", manifest_path.display()))
    })
}

fn set_brp_env_vars(command: &mut Command, port: Option<Port>) {
    if let Some(port) = port {
        command.env(BRP_EXTRAS_PORT_ENV_VAR, port.to_string());
    }
}

fn set_user_env_vars(command: &mut Command, env: Option<&HashMap<String, String>>) {
    if let Some(env_vars) = env {
        for (key, value) in env_vars {
            command.env(key, value);
        }
    }
}

pub(super) fn setup_launch_logging(
    name: &str,
    target_type: TargetType,
    profile: &str,
    binary_path: &Path,
    manifest_dir: &Path,
    port: Port,
    extra_log_info: Option<&str>,
) -> Result<(PathBuf, File)> {
    let (log_file_path, _) =
        logging::create_log_file(name, target_type, profile, binary_path, manifest_dir, port)
            .map_err(|e| Error::tool_call_failed(format!("Failed to create log file: {e}")))?;

    if let Some(extra_info) = extra_log_info {
        logging::append_to_log_file(&log_file_path, &format!("{extra_info}\n"))
            .map_err(|e| Error::tool_call_failed(format!("Failed to append to log file: {e}")))?;
    }

    let log_file_for_redirect =
        logging::open_log_file_for_redirect(&log_file_path).map_err(|e| {
            Error::tool_call_failed(format!("Failed to open log file for redirect: {e}"))
        })?;

    Ok((log_file_path, log_file_for_redirect))
}

pub(super) fn build_cargo_example_command(
    example_name: &str,
    profile: &str,
    port: Option<Port>,
    env: Option<&HashMap<String, String>>,
    command_line_arguments: Option<&[String]>,
) -> Command {
    let mut command = Command::new(CARGO_COMMAND_NAME);
    command
        .arg(CARGO_RUN_SUBCOMMAND)
        .arg(CARGO_EXAMPLE_FLAG)
        .arg(example_name);

    if profile == PROFILE_RELEASE {
        command.arg(CARGO_RELEASE_FLAG);
    }

    if let Some(user_arguments) = command_line_arguments {
        command.arg(USER_ARGUMENT_SEPARATOR).args(user_arguments);
    }

    set_brp_env_vars(&mut command, port);
    set_user_env_vars(&mut command, env);

    command
}

pub(super) fn build_app_command(
    binary_path: &Path,
    port: Option<Port>,
    env: Option<&HashMap<String, String>>,
    command_line_arguments: Option<&[String]>,
) -> Command {
    let mut command = Command::new(binary_path);
    if let Some(user_arguments) = command_line_arguments {
        command.args(user_arguments);
    }
    set_brp_env_vars(&mut command, port);
    set_user_env_vars(&mut command, env);
    command
}

fn build_cargo_command(
    target_name: &str,
    target_type: TargetType,
    profile: &str,
    manifest_dir: &Path,
) -> Command {
    let mut command = Command::new(CARGO_COMMAND_NAME);
    command.current_dir(manifest_dir);
    command.arg(CARGO_BUILD_SUBCOMMAND);

    target_type.add_cargo_args(&mut command, target_name);

    if profile == PROFILE_RELEASE {
        command.arg(CARGO_RELEASE_FLAG);
    }

    command.arg(CARGO_MESSAGE_FORMAT_JSON_FLAG);

    command
}

fn execute_build_command(
    command: &mut Command,
    target_name: &str,
    target_type: TargetType,
    profile: &str,
    manifest_dir: &Path,
) -> Result<Output> {
    debug!("Running cargo build for {target_type} '{target_name}' with command: {command:?}");

    let output = command.output().map_err(|e| {
        Error::ProcessManagement(format!(
            "Failed to run cargo build for {target_type} '{target_name}' (profile: {profile}, dir: {}): {e}",
            manifest_dir.display()
        ))
    })?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(Error::ProcessManagement(format!(
            "Cargo build failed for {target_type} '{target_name}' (profile: {profile}, dir: {}): {stderr}",
            manifest_dir.display()
        ))
        .into());
    }

    Ok(output)
}

fn parse_build_output(stdout: &[u8], target_name: &str) -> BuildState {
    let stdout_str = String::from_utf8_lossy(stdout);

    for line in stdout_str.lines() {
        if let Ok(json) = serde_json::from_str::<Value>(line)
            && let Some(target) = json.get(BUILD_OUTPUT_TARGET_FIELD)
            && let Some(name) = target.get(BUILD_OUTPUT_NAME_FIELD)
            && name.as_str() == Some(target_name)
        {
            return json
                .get(BUILD_OUTPUT_FRESH_FIELD)
                .and_then(serde_json::Value::as_bool)
                .map_or(BuildState::Rebuilt, |is_fresh| {
                    if is_fresh {
                        BuildState::Fresh
                    } else {
                        BuildState::Rebuilt
                    }
                });
        }
    }

    BuildState::NotFound
}

fn log_build_result(build_state: BuildState, target_name: &str, target_type: TargetType) {
    match build_state {
        BuildState::NotFound => {
            debug!("Target '{target_name}' not found in build output, assuming it was built");
        },
        BuildState::Fresh => {
            debug!("{target_type} '{target_name}' was already up to date");
        },
        BuildState::Rebuilt => {
            info!("{target_type} '{target_name}' was built successfully");
        },
    }
}

pub(super) fn run_cargo_build(
    target_name: &str,
    target_type: TargetType,
    profile: &str,
    manifest_dir: &Path,
) -> Result<BuildState> {
    let mut command = build_cargo_command(target_name, target_type, profile, manifest_dir);
    let output = execute_build_command(
        &mut command,
        target_name,
        target_type,
        profile,
        manifest_dir,
    )?;
    let build_state = parse_build_output(&output.stdout, target_name);
    log_build_result(build_state, target_name, target_type);

    Ok(build_state)
}