bevy_brp_mcp 0.22.2

MCP server for Bevy Remote Protocol (BRP) integration
use bevy_brp_mcp_macros::ParamStruct;
use bevy_brp_mcp_macros::ResultStruct;
use bevy_brp_mcp_macros::ToolFn;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;

use super::support;
use super::support::LogFileEntry;
use crate::error::Error;
use crate::error::Result;
use crate::tool::HandlerContext;
use crate::tool::HandlerResult;
use crate::tool::ToolFn;
use crate::tool::ToolResult;

#[derive(Clone, Deserialize, Serialize, JsonSchema, ParamStruct)]
pub struct ListLogsParams {
    /// Optional filter to list logs for a specific app only
    #[to_metadata(skip_if_none)]
    pub app_name: Option<String>,
    /// Include full details (path, timestamps, size in bytes). Default is false for minimal output
    #[to_metadata(skip_if_none)]
    pub verbose:  Option<bool>,
}

/// Result from listing log files
#[derive(Debug, Clone, Serialize, Deserialize, ResultStruct)]
pub struct ListLogResult {
    /// List of log files found
    #[to_result]
    logs:             Vec<LogFileInfo>,
    /// Path to the temp directory containing logs
    #[to_metadata]
    temp_directory:   String,
    /// Log file count
    #[to_metadata]
    log_count:        usize,
    /// Message template for formatting responses
    #[to_message(message_template = "Found {log_count} log files")]
    message_template: String,
}

#[derive(ToolFn)]
#[tool_fn(params = "ListLogsParams", output = "ListLogResult")]
pub struct ListLogs;

#[allow(
    clippy::unused_async,
    reason = "ToolFn trait requires async handler signature"
)]
async fn handle_impl(params: ListLogsParams) -> Result<ListLogResult> {
    let logs = list_log_files(
        params.app_name.as_deref(),
        LogDetail::from(params.verbose.unwrap_or(false)),
    )?;
    Ok(ListLogResult::new(
        logs.clone(),
        support::get_log_directory().display().to_string(),
        logs.len(),
    ))
}

fn list_log_files(
    app_name_filter: Option<&str>,
    log_detail: LogDetail,
) -> Result<Vec<LogFileInfo>> {
    // Use the iterator to get all log files with optional filter
    let filter = |entry: &LogFileEntry| -> bool {
        // Apply app name filter if provided
        app_name_filter.map_or_else(|| true, |app_filter| entry.app_name == app_filter)
    };

    let mut log_entries =
        support::iterate_log_files(filter).map_err(|e| Error::tool_call_failed(e.to_string()))?;

    // Sort by timestamp (newest first)
    log_entries.sort_by(|a, b| {
        let timestamp_a = a.timestamp.parse::<u128>().unwrap_or(0);
        let timestamp_b = b.timestamp.parse::<u128>().unwrap_or(0);
        timestamp_b.cmp(&timestamp_a)
    });

    // Convert to LogFileInfo structs
    let log_infos: Vec<LogFileInfo> = log_entries
        .into_iter()
        .map(|entry| {
            if log_detail.is_verbose() {
                let size_bytes = entry.metadata.len();
                let modified = entry.metadata.modified().ok().map(|t| {
                    chrono::DateTime::<chrono::Local>::from(t)
                        .format("%Y-%m-%d %H:%M:%S")
                        .to_string()
                });
                let created = entry.metadata.created().ok().map(|t| {
                    chrono::DateTime::<chrono::Local>::from(t)
                        .format("%Y-%m-%d %H:%M:%S")
                        .to_string()
                });

                LogFileInfo {
                    filename: entry.filename,
                    app_name: entry.app_name,
                    path: Some(entry.path.display().to_string()),
                    size: Some(support::format_bytes(size_bytes)),
                    size_bytes: Some(size_bytes),
                    created,
                    modified,
                }
            } else {
                LogFileInfo {
                    filename:   entry.filename,
                    app_name:   entry.app_name,
                    path:       None,
                    size:       None,
                    size_bytes: None,
                    created:    None,
                    modified:   None,
                }
            }
        })
        .collect();

    Ok(log_infos)
}

#[derive(Clone, Copy)]
enum LogDetail {
    Minimal,
    Verbose,
}

impl LogDetail {
    const fn is_verbose(self) -> bool { matches!(self, Self::Verbose) }
}

impl From<bool> for LogDetail {
    fn from(value: bool) -> Self { if value { Self::Verbose } else { Self::Minimal } }
}

/// Individual log file entry
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LogFileInfo {
    /// The filename
    filename:   String,
    /// The app name extracted from the filename
    app_name:   String,
    /// Full path to the file (included in verbose mode)
    #[serde(skip_serializing_if = "Option::is_none")]
    path:       Option<String>,
    /// Human-readable file size (included in verbose mode)
    #[serde(skip_serializing_if = "Option::is_none")]
    size:       Option<String>,
    /// File size in bytes (included in verbose mode)
    #[serde(skip_serializing_if = "Option::is_none")]
    size_bytes: Option<u64>,
    /// Creation time as ISO string (included in verbose mode)
    #[serde(skip_serializing_if = "Option::is_none")]
    created:    Option<String>,
    /// Modification time as ISO string (included in verbose mode)
    #[serde(skip_serializing_if = "Option::is_none")]
    modified:   Option<String>,
}