bevy_brp_mcp 0.22.2

MCP server for Bevy Remote Protocol (BRP) integration
//! Simple cargo detector based on `bevy_brp_tool`

use std::path::Path;
use std::path::PathBuf;
use std::process::Command;

use anyhow::Context;
use anyhow::Error;
use anyhow::Result;
use cargo_metadata::Metadata;
use cargo_metadata::MetadataCommand;
use cargo_metadata::Package;
use serde::Serialize;
use strum::AsRefStr;
use strum::Display;
use strum::EnumString;

use super::constants::BEVY_CRATE_NAME;
use super::constants::BEVY_REMOTE_FEATURE;
use super::constants::BEVY_REMOTE_GLOB_IMPORT_PREFIX;
use super::constants::BEVY_REMOTE_PLUGIN_IMPORT;
use super::constants::BEVY_REMOTE_REMOTE_GLOB_IMPORT_PREFIX;
use super::constants::BEVY_REMOTE_REMOTE_PLUGIN_IMPORT;
use super::constants::BRP_EXTRAS_GLOB_IMPORT_PREFIX;
use super::constants::BRP_EXTRAS_PLUGIN_IMPORT;
use super::constants::BRP_EXTRAS_PLUGIN_NAME;
use super::constants::CARGO_EXAMPLES_DIRECTORY;
use super::constants::CARGO_SRC_DIRECTORY;
use super::constants::MCP_CRATE_NAME;
use super::constants::REMOTE_PLUGIN_NAME;
use super::constants::RUST_SOURCE_EXTENSION;
use super::constants::TARGET_DIRECTORY_NAME;
use crate::app_tools::constants::CARGO_BIN_FLAG;
use crate::app_tools::constants::CARGO_EXAMPLE_FLAG;

/// Type of Bevy target
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, AsRefStr, EnumString, Serialize)]
#[strum(serialize_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum TargetType {
    /// A binary application target
    App,
    /// An example target
    Example,
}

/// Level of BRP (Bevy Remote Protocol) support detected in a target
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum BrpLevel {
    /// No BRP support detected
    None,
    /// Uses `RemotePlugin` only (core BRP, no extras)
    Core,
    /// Uses `BrpExtrasPlugin` (full extras support: screenshots, input, shutdown, etc.)
    Extras,
}

impl BrpLevel {
    /// Returns the string representation used in JSON output
    pub(super) const fn as_str(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::Core => "brp_only",
            Self::Extras => "extras",
        }
    }
}

impl TargetType {
    /// Add cargo-specific arguments for this target type
    pub fn add_cargo_args(self, command: &mut Command, target_name: &str) {
        match self {
            Self::App => {
                command.arg(CARGO_BIN_FLAG).arg(target_name);
            },
            Self::Example => {
                command.arg(CARGO_EXAMPLE_FLAG).arg(target_name);
            },
        }
    }
}

/// Unified information about a Bevy target (app or example)
#[derive(Debug, Clone)]
pub struct BevyTarget {
    /// Name of the target
    pub name:           String,
    /// Type of target (`App` or `Example`)
    pub target_type:    TargetType,
    /// Package name (for examples, this is the package containing the example)
    pub package_name:   String,
    /// Workspace root (for apps)
    pub workspace_root: PathBuf,
    /// Path to the package's Cargo.toml
    pub manifest:       PathBuf,
    /// Relative path from scan root to this item
    pub relative:       PathBuf,
    /// Path to the target's source file (from `cargo metadata`)
    pub source:         PathBuf,
}

impl BevyTarget {
    /// Get the path to the binary for a given profile
    pub fn get_binary_path(&self, profile: &str) -> PathBuf {
        match self.target_type {
            TargetType::App => self
                .workspace_root
                .join(TARGET_DIRECTORY_NAME)
                .join(profile)
                .join(&self.name),
            TargetType::Example => self
                .workspace_root
                .join(TARGET_DIRECTORY_NAME)
                .join(profile)
                .join(CARGO_EXAMPLES_DIRECTORY)
                .join(&self.name),
        }
    }

    /// Check if this target is an app
    pub fn is_app(&self) -> bool { self.target_type == TargetType::App }
}

/// Detects binary targets in a project or workspace
pub(super) struct CargoDetector {
    metadata: Metadata,
}

impl TryFrom<&Path> for CargoDetector {
    type Error = Error;

    fn try_from(path: &Path) -> Result<Self> {
        let metadata = MetadataCommand::new()
            .current_dir(path)
            .exec()
            .context("Failed to execute cargo metadata")?;

        Ok(Self { metadata })
    }
}

impl CargoDetector {
    /// Check if a package is a workspace member
    fn is_workspace_member(&self, package: &Package) -> bool {
        self.metadata.workspace_members.contains(&package.id)
    }

    /// Find packages that match the given filter criteria
    fn find_packages_with_filter<'a, F>(&'a self, filter: F) -> impl Iterator<Item = &'a Package>
    where
        F: Fn(&Package) -> bool + 'a,
    {
        self.metadata
            .packages
            .iter()
            .filter(|p| self.is_workspace_member(p))
            .filter(move |p| filter(p))
    }

    /// Extract all targets (apps and examples) from a package as `BevyTarget`s
    fn extract_all_targets(&self, package: &Package) -> Vec<BevyTarget> {
        let workspace_root: PathBuf = self.metadata.workspace_root.clone().into();
        let package_name = package.name.to_string();
        let manifest_path: PathBuf = package.manifest_path.clone().into();

        let mut targets = Vec::new();

        // Extract apps
        for target in package.targets.iter().filter(|t| t.is_bin()) {
            targets.push(BevyTarget {
                name:           target.name.clone(),
                target_type:    TargetType::App,
                package_name:   package_name.clone(),
                workspace_root: workspace_root.clone(),
                manifest:       manifest_path.clone(),
                relative:       PathBuf::new(), // Will be set by scanning logic
                source:         target.src_path.clone().into(),
            });
        }

        // Extract examples
        for target in package.targets.iter().filter(|t| t.is_example()) {
            targets.push(BevyTarget {
                name:           target.name.clone(),
                target_type:    TargetType::Example,
                package_name:   package_name.clone(),
                workspace_root: workspace_root.clone(),
                manifest:       manifest_path.clone(),
                relative:       PathBuf::new(), // Will be set by scanning logic
                source:         target.src_path.clone().into(),
            });
        }

        targets
    }

    /// Filter for packages that depend on Bevy (excluding `bevy_brp_mcp` itself)
    /// Also includes the `bevy` crate itself since it contains examples
    fn bevy_app_filter(package: &Package) -> bool {
        package.name.as_str() != MCP_CRATE_NAME
            && (package.name.as_str() == BEVY_CRATE_NAME || Self::package_depends_on_bevy(package))
    }

    /// Filter for packages that have BRP support and are not `bevy_brp_mcp` itself
    fn brp_app_filter(package: &Package) -> bool {
        package.name.as_str() != MCP_CRATE_NAME && Self::package_has_brp_support(package)
    }

    /// Find all Bevy targets (apps and examples) in the workspace/project
    pub(super) fn find_bevy_targets(&self) -> Vec<BevyTarget> {
        self.find_packages_with_filter(Self::bevy_app_filter)
            .flat_map(|p| self.extract_all_targets(p))
            .collect()
    }

    /// Find all BRP-enabled Bevy targets (apps and examples) in the workspace/project
    pub(super) fn find_brp_targets(&self) -> Vec<BevyTarget> {
        self.find_packages_with_filter(Self::brp_app_filter)
            .flat_map(|p| self.extract_all_targets(p))
            .collect()
    }

    fn package_depends_on_bevy(package: &Package) -> bool {
        // Check direct dependencies (including workspace dependencies)
        package
            .dependencies
            .iter()
            .any(|dependency| dependency.name == BEVY_CRATE_NAME)
    }

    /// Check if a package has BRP (Bevy Remote Protocol) support enabled
    fn package_has_brp_support(package: &Package) -> bool {
        // First check: Must have bevy dependency with bevy_remote feature available
        if !Self::package_has_bevy_remote_feature(package) {
            return false;
        }

        // Second check: Must actually use BRP plugins in source code
        Self::package_uses_brp_plugins(package)
    }

    /// Check if a package has `bevy_remote` feature available (either explicit or workspace
    /// inherited)
    fn package_has_bevy_remote_feature(package: &Package) -> bool {
        // Check if bevy dependency includes bevy_remote feature or uses workspace inheritance
        package.dependencies.iter().any(|dependency| {
            if dependency.name == BEVY_CRATE_NAME {
                // If it has explicit features, check for bevy_remote
                if dependency.features.is_empty() {
                    // If no explicit features, assume workspace inheritance
                    // (we'll verify actual usage in the code scanning step)
                    true
                } else {
                    dependency
                        .features
                        .iter()
                        .any(|feature| feature == BEVY_REMOTE_FEATURE)
                }
            } else {
                false
            }
        })
    }

    /// Check if a package uses `RemotePlugin` or `BrpExtrasPlugin` in its source code
    fn package_uses_brp_plugins(package: &Package) -> bool {
        // Get the package directory
        let Some(package_dir) = package.manifest_path.parent() else {
            return false;
        };

        // Check all .rs files in src/ directory
        let src_dir = package_dir.join(CARGO_SRC_DIRECTORY);
        if !src_dir.exists() {
            return false;
        }

        Self::check_directory_for_brp_plugins(src_dir.as_std_path())
    }

    /// Recursively check directory for BRP plugin usage
    fn check_directory_for_brp_plugins(dir: &Path) -> bool {
        if let Ok(entries) = std::fs::read_dir(dir) {
            for entry in entries.flatten() {
                let path = entry.path();

                if path.is_dir() {
                    if Self::check_directory_for_brp_plugins(&path) {
                        return true;
                    }
                } else if path
                    .extension()
                    .is_some_and(|extension| extension == RUST_SOURCE_EXTENSION)
                    && Self::file_uses_brp_plugins(&path)
                {
                    return true;
                }
            }
        }

        false
    }

    /// Determine the BRP support level of a specific file.
    ///
    /// Returns `"extras"` if the file imports `BrpExtrasPlugin`,
    /// `"brp_only"` if it imports `RemotePlugin` without extras,
    /// or `"none"` if neither is found.
    pub(super) fn file_brp_level(file_path: &Path) -> BrpLevel {
        let Ok(content) = std::fs::read_to_string(file_path) else {
            return BrpLevel::None;
        };

        let has_extras = Self::content_has_extras_plugin(&content);
        let has_remote = Self::content_has_remote_plugin(&content);

        if has_extras {
            BrpLevel::Extras
        } else if has_remote {
            BrpLevel::Core
        } else {
            BrpLevel::None
        }
    }

    /// Check if file content imports `BrpExtrasPlugin`
    fn content_has_extras_plugin(content: &str) -> bool {
        content.contains(BRP_EXTRAS_PLUGIN_IMPORT)
            || (content.contains(BRP_EXTRAS_GLOB_IMPORT_PREFIX)
                && content.contains(BRP_EXTRAS_PLUGIN_NAME))
    }

    /// Check if file content imports `RemotePlugin` (via `bevy::remote` or `bevy_remote`)
    fn content_has_remote_plugin(content: &str) -> bool {
        content.contains(BEVY_REMOTE_PLUGIN_IMPORT)
            || (content.contains(BEVY_REMOTE_GLOB_IMPORT_PREFIX)
                && content.contains(REMOTE_PLUGIN_NAME))
            || content.contains(BEVY_REMOTE_REMOTE_PLUGIN_IMPORT)
            || (content.contains(BEVY_REMOTE_REMOTE_GLOB_IMPORT_PREFIX)
                && content.contains(REMOTE_PLUGIN_NAME))
    }

    /// Check if a specific file uses `RemotePlugin` or `BrpExtrasPlugin` (any BRP support)
    pub(super) fn file_uses_brp_plugins(file_path: &Path) -> bool {
        !matches!(Self::file_brp_level(file_path), BrpLevel::None)
    }
}