From 17bb7545ba972f72dc2f004ed70591b91d751930 Mon Sep 17 00:00:00 2001 From: Devid HW Date: Thu, 25 Jun 2026 16:03:59 +0700 Subject: [PATCH] feat: overhaul optimizer launch, rewrite set file parsing, add OS detection and utils --- src/models/config.rs | 16 +- src/optimization/optimizer.rs | 332 ++++++++++++++++++++++------- src/tools/handlers/optimization.rs | 1 - src/tools/handlers/setfiles.rs | 41 ++-- src/tools/handlers/utility.rs | 250 ++++++++++++++++------ src/utils/mod.rs | 20 ++ 6 files changed, 501 insertions(+), 159 deletions(-) create mode 100644 src/utils/mod.rs diff --git a/src/models/config.rs b/src/models/config.rs index f1d751c..da629fb 100644 --- a/src/models/config.rs +++ b/src/models/config.rs @@ -247,19 +247,21 @@ impl Config { fn find_wine(home: &Path) -> Option { let candidates: &[PathBuf] = &[ - // macOS: bundled with the official MT5 app + // macOS: bundled with the official MT5 app (binary is just named 'wine' on recent builds) + PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine"), PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"), - // macOS: CrossOver + // macOS: CrossOver (new versions may use 'wine', older ones 'wine64') + home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine"), home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine64"), - // macOS: Homebrew (Apple Silicon) - PathBuf::from("/opt/homebrew/bin/wine64"), + // macOS: Homebrew Apple Silicon (prefer 'wine', fall back to 'wine64') PathBuf::from("/opt/homebrew/bin/wine"), - // macOS: Homebrew (Intel) - PathBuf::from("/usr/local/bin/wine64"), + PathBuf::from("/opt/homebrew/bin/wine64"), + // macOS: Homebrew Intel PathBuf::from("/usr/local/bin/wine"), + PathBuf::from("/usr/local/bin/wine64"), // Linux - PathBuf::from("/usr/bin/wine64"), PathBuf::from("/usr/bin/wine"), + PathBuf::from("/usr/bin/wine64"), ]; candidates.iter() .find(|p| p.exists()) diff --git a/src/optimization/optimizer.rs b/src/optimization/optimizer.rs index 8122b25..b5ef154 100644 --- a/src/optimization/optimizer.rs +++ b/src/optimization/optimizer.rs @@ -6,6 +6,27 @@ use std::process::{Command, Stdio}; use crate::models::Config; +/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String. +/// MT5 .set and .ini files are typically UTF-16LE with BOM (0xFF 0xFE). +fn read_file_as_utf8(path: &Path) -> Result { + let bytes = fs::read(path)?; + + // Check for UTF-16LE BOM (0xFF 0xFE) + if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { + // UTF-16LE with BOM - skip the 2-byte BOM and decode + let utf16_data: Vec = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16(&utf16_data) + .map_err(|e| anyhow!("Failed to decode UTF-16LE: {}", e)) + } else { + // Try UTF-8 + String::from_utf8(bytes) + .map_err(|e| anyhow!("Failed to decode as UTF-8: {}", e)) + } +} + pub struct OptimizationParams { pub expert: String, pub set_file: String, @@ -13,7 +34,6 @@ pub struct OptimizationParams { pub from_date: String, pub to_date: String, pub deposit: u32, - pub model: u8, pub leverage: u32, pub currency: String, } @@ -27,7 +47,6 @@ impl Default for OptimizationParams { from_date: String::new(), to_date: String::new(), deposit: 10000, - model: 0, leverage: 500, currency: "USD".to_string(), } @@ -78,7 +97,8 @@ impl OptimizationRunner { let log_file = PathBuf::from(format!("/tmp/mt5opt_{}.log", timestamp)); // Count combinations - let combinations = self.count_combinations(¶ms.set_file)?; + let combinations = self.count_combinations(¶ms.set_file) + .map_err(|e| anyhow!("count_combinations failed: {}", e))?; // Get paths let mt5_dir = self.config.terminal_dir.as_ref() @@ -89,50 +109,167 @@ impl OptimizationRunner { // Write .set file as UTF-16LE with BOM directly to MT5 tester directory let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?; let tester_dir = wine_prefix_dir.join("drive_c/Program Files/MetaTrader 5/MQL5/Profiles/Tester"); - fs::create_dir_all(&tester_dir)?; + fs::create_dir_all(&tester_dir).map_err(|e| anyhow!("create_dir_all({}) failed: {}", tester_dir.display(), e))?; let dst_set_file = tester_dir.join(format!("{}.set", params.expert)); - self.write_utf16le_set(¶ms.set_file, &dst_set_file)?; + self.write_utf16le_set(¶ms.set_file, &dst_set_file) + .map_err(|e| anyhow!("write_utf16le_set({}) failed: {}", dst_set_file.display(), e))?; // Reset OptMode in terminal.ini - self.reset_optmode(mt5_dir)?; + // Patch terminal.ini [Tester] section with optimization params (primary mechanism) + let terminal_ini = if Path::new(mt5_dir).join("config").exists() { + Path::new(mt5_dir).join("config").join("terminal.ini") + } else { + Path::new(mt5_dir).join("terminal.ini") + }; + let mt5_ini_text = if terminal_ini.exists() { + read_file_as_utf8(&terminal_ini).unwrap_or_default() + } else { + String::new() + }; + let expert_path = if let Some(experts_dir) = &self.config.experts_dir { + let nested = Path::new(experts_dir).join(¶ms.expert).join(format!("{}.mq5", params.expert)); + if nested.exists() { + format!("Experts\\{}\\{}.ex5", params.expert, params.expert) + } else { + format!("Experts\\{}.ex5", params.expert) + } + } else { + format!("Experts\\{}.ex5", params.expert) + }; + let tester_section = format!( + "[Tester]\n\ + Expert={}\n\ + ExpertParameters={}.set\n\ + Symbol={}\n\ + Period=M1\n\ + Model=4\n\ + FromDate={}\n\ + ToDate={}\n\ + ForwardMode=0\n\ + Deposit={}\n\ + Currency={}\n\ + ProfitInPips=0\n\ + Leverage={}\n\ + Execution=10\n\ + Optimization=2\n\ + Agents=10\n\ + Visual=0\n\ + Report=reports\\opt_report.htm\n\ + ReplaceReport=1\n\ + ShutdownTerminal=1", + expert_path, params.expert, params.symbol, + params.from_date, params.to_date, params.deposit, params.currency, params.leverage, + ); + let agents_section = "\ + [Agents]\n\ + Agent0000=11111111-1111-1111-1111-111111111111\n\ + AgentStatus0000=3\n\ + AgentState0000=0\n\ + Enabled0000=1\n\ + IP0000=127.0.0.1\n\ + Port0000=3000\n\ + Agent0001=22222222-2222-2222-2222-222222222222\n\ + AgentStatus0001=3\n\ + AgentState0001=0\n\ + Enabled0001=1\n\ + IP0001=127.0.0.1\n\ + Port0001=3001\n\ + Agent0002=33333333-3333-3333-3333-333333333333\n\ + AgentStatus0002=3\n\ + AgentState0002=0\n\ + Enabled0002=1\n\ + IP0002=127.0.0.1\n\ + Port0002=3002\n\ + Agent0003=44444444-4444-4444-4444-444444444444\n\ + AgentStatus0003=3\n\ + AgentState0003=0\n\ + Enabled0003=1\n\ + IP0003=127.0.0.1\n\ + Port0003=3003"; + let updated_ini = Self::patch_ini_section(&mt5_ini_text, "Tester", &tester_section); + // Strip any stale [Agents] sections from previous runs, then append fresh one + let cleaned = Self::strip_ini_section(&updated_ini, "Agents"); + let final_ini = format!("{}\n{}", cleaned.trim_end(), agents_section); + let mut utf16_out: Vec = vec![0xFF, 0xFE]; + utf16_out.extend(final_ini.encode_utf16().flat_map(|c| c.to_le_bytes())); + fs::write(&terminal_ini, utf16_out)?; - // Get Wine prefix directory - let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?; + // Write /config: INI to trigger tester/optimizer mode + // For /config: format, Expert path is relative to MQL5/Experts/ (no Experts\ prefix) + let opt_config_win = r"C:\mt5opt_config.ini"; + let opt_config_host = wine_prefix_dir.join("drive_c").join("mt5opt_config.ini"); + let mut opt_ini = String::new(); + if let Some(login) = &self.config.backtest_login { + if let Some(server) = &self.config.backtest_server { + opt_ini.push_str("[Common]\n"); + opt_ini.push_str(&format!("Login={}\n", login)); + opt_ini.push_str(&format!("Server={}\n", server)); + if let Some(password) = &self.config.backtest_password { + opt_ini.push_str(&format!("Password={}\n", password)); + } + opt_ini.push_str("\n"); + } + } + opt_ini.push_str("[Tester]\n"); + opt_ini.push_str(&format!("Expert={}.ex5\n", params.expert)); + opt_ini.push_str(&format!("ExpertParameters={}.set\n", params.expert)); + opt_ini.push_str(&format!("Symbol={}\n", params.symbol)); + opt_ini.push_str("Period=M1\n"); + opt_ini.push_str("Optimization=2\n"); + opt_ini.push_str(&format!("FromDate={}\n", params.from_date)); + opt_ini.push_str(&format!("ToDate={}\n", params.to_date)); + opt_ini.push_str("ForwardMode=0\n"); + opt_ini.push_str(&format!("Deposit={}\n", params.deposit)); + opt_ini.push_str(&format!("Currency={}\n", params.currency)); + opt_ini.push_str("ProfitInPips=0\n"); + opt_ini.push_str(&format!("Leverage={}\n", params.leverage)); + opt_ini.push_str("Execution=10\n"); + opt_ini.push_str("Visual=0\n"); + opt_ini.push_str("Report=reports\\opt_report.htm\n"); + opt_ini.push_str("ReplaceReport=1\n"); + opt_ini.push_str("ShutdownTerminal=1\n"); + fs::write(&opt_config_host, opt_ini.as_bytes())?; - // Build optimization INI - let ini_path = wine_prefix_dir.join("drive_c/mt5mcp_backtest.ini"); - let ini_content = format!(r#"[Tester] -Expert={} -Symbol={} -Period=M5 -Deposit={} -Currency={} -Leverage={} -Model={} -FromDate={} -ToDate={} -Report=C:\mt5mcp_opt_report -Optimization=2 -ExpertParameters={}.set -ShutdownTerminal=1 -"#, params.expert, params.symbol, params.deposit, params.currency, - params.leverage, params.model, params.from_date, params.to_date, params.expert); - fs::write(&ini_path, ini_content)?; + // Build launch script (macOS-compatible with /config: to trigger tester mode) + let wine_bin = Path::new(wine_exe); + let wine_root = wine_bin + .parent() + .and_then(|p| p.parent()) + .ok_or_else(|| anyhow!("Cannot derive Wine root from wine_exe"))?; + let ext_libs = wine_root.join("lib").join("external"); + let wine_libs = wine_root.join("lib"); + let dyld = format!("{}:{}:/usr/lib:/usr/local/lib", + ext_libs.display(), wine_libs.display()); + let terminal_host = wine_prefix_dir.join("drive_c") + .join("Program Files").join("MetaTrader 5").join("terminal64.exe"); - // Build batch file - let batch_path = wine_prefix_dir.join("drive_c/mt5mcp_run.bat"); - let batch_content = format!(r#"@echo off -"C:\Program Files\MetaTrader 5\terminal64.exe" /config:C:\mt5mcp_backtest.ini -"#); - fs::write(&batch_path, batch_content)?; + let script = format!( + "#!/bin/sh\n\ + export DYLD_FALLBACK_LIBRARY_PATH='{dyld}'\n\ + export WINEPREFIX='{prefix}'\n\ + export WINEDEBUG='-all'\n\ + nohup '{wine}' '{terminal}' '/config:{config}' >/dev/null 2>&1 &\n", + dyld = dyld, + prefix = wine_prefix_dir.display(), + wine = wine_exe, + terminal = terminal_host.display(), + config = opt_config_win, + ); - // Launch detached process - let cmd = format!("cmd.exe /c 'C:\\mt5mcp_run.bat'"); - let child = Command::new(wine_exe) - .arg(&cmd) + let script_path = std::env::temp_dir().join("mt5opt_launch.sh"); + fs::write(&script_path, &script)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?; + } + + let child = Command::new("/bin/sh") + .arg(&script_path) .stdout(Stdio::null()) .stderr(Stdio::null()) - .spawn()?; + .spawn() + .map_err(|e| anyhow!("spawn /bin/sh {} failed: {}", script_path.display(), e))?; let pid = child.id(); @@ -150,7 +287,7 @@ ShutdownTerminal=1 } fn count_combinations(&self, set_file: &str) -> Result { - let content = fs::read_to_string(set_file)?; + let content = read_file_as_utf8(Path::new(set_file))?; let mut total: u64 = 1; for line in content.lines() { @@ -179,13 +316,23 @@ ShutdownTerminal=1 } fn write_utf16le_set(&self, src: &str, dst: &Path) -> Result<()> { - let content = fs::read_to_string(src)?; + let content = read_file_as_utf8(Path::new(src))?; // Create parent directory if needed if let Some(parent) = dst.parent() { fs::create_dir_all(parent)?; } + // Remove existing file if read-only from previous run + if dst.exists() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(dst, fs::Permissions::from_mode(0o644)); + } + let _ = fs::remove_file(dst); + } + // Write UTF-16LE with BOM let mut utf16_content: Vec = vec![0xFEFF]; // BOM utf16_content.extend(content.encode_utf16()); @@ -195,50 +342,18 @@ ShutdownTerminal=1 .collect(); fs::write(dst, bytes)?; - - // Make read-only - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(dst, fs::Permissions::from_mode(0o444))?; - } Ok(()) } - fn reset_optmode(&self, mt5_dir: &str) -> Result<()> { - let terminal_ini = Path::new(mt5_dir).join("terminal.ini"); - - if !terminal_ini.exists() { - return Ok(()); - } - - let content = fs::read_to_string(&terminal_ini)?; - let updated = content - .lines() - .map(|line| { - if line.starts_with("OptMode=") { - "OptMode=0".to_string() - } else if line.starts_with("LastOptimization=") { - String::new() - } else { - line.to_string() - } - }) - .filter(|l| !l.is_empty()) - .collect::>() - .join("\n"); - - fs::write(&terminal_ini, updated)?; - Ok(()) - } - fn get_wine_prefix_dir(&self, mt5_dir: &str) -> Result { let path = Path::new(mt5_dir); - // Go up two levels: .../drive_c/Program Files/MetaTrader 5 -> .../drive_c + // Go up three levels: .../drive_c/Program Files/MetaTrader 5 -> .../net.metaquotes.wine.metatrader5 + // (same as backtest pipeline) let prefix_dir = path .parent() .and_then(|p| p.parent()) + .and_then(|p| p.parent()) .ok_or_else(|| anyhow!("Cannot determine Wine prefix from terminal_dir"))?; Ok(prefix_dir.to_path_buf()) } @@ -276,6 +391,73 @@ ShutdownTerminal=1 Ok(()) } + /// Replace a [section] in an INI string — removes old content and inserts new. + fn patch_ini_section(text: &str, section: &str, new_content: &str) -> String { + let section_header = format!("[{}]", section); + let mut result = String::new(); + let mut in_section = false; + let mut section_found = false; + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed == section_header { + in_section = true; + section_found = true; + continue; + } + if in_section { + if trimmed.starts_with('[') { + in_section = false; + result.push_str(new_content); + if !new_content.ends_with('\n') { + result.push('\n'); + } + result.push_str(line); + result.push('\n'); + continue; + } + continue; + } + result.push_str(line); + result.push('\n'); + } + + if !section_found { + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(new_content); + result.push('\n'); + } else if in_section { + result.push_str(new_content); + result.push('\n'); + } + + result + } + + /// Remove all lines belonging to a [section] from the INI text. + fn strip_ini_section(text: &str, section: &str) -> String { + let header = format!("[{}]", section); + let mut result = String::new(); + let mut skipping = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed == header { + skipping = true; + continue; + } + if skipping && trimmed.starts_with('[') { + skipping = false; + } + if !skipping { + result.push_str(line); + result.push('\n'); + } + } + result + } + pub fn get_job_status(&self, job_id: &str) -> Result { let jobs_dir = Path::new(".mt5mcp_jobs"); let meta_path = jobs_dir.join(format!("{}.json", job_id)); diff --git a/src/tools/handlers/optimization.rs b/src/tools/handlers/optimization.rs index f7d5499..3975a7c 100644 --- a/src/tools/handlers/optimization.rs +++ b/src/tools/handlers/optimization.rs @@ -27,7 +27,6 @@ pub async fn handle_run_optimization(config: &Config, args: &Value) -> Result Result { + let bytes = fs::read(path)?; + if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { + let utf16_data: Vec = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16(&utf16_data) + .map_err(|e| anyhow::anyhow!("Failed to decode UTF-16LE: {}", e)) + } else { + String::from_utf8(bytes) + .map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e)) + } +} + pub async fn handle_read_set_file(args: &Value) -> Result { let path = args.get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("path is required"))?; - let content = fs::read_to_string(path)?; + let content = read_file_as_utf8(path)?; let mut params = serde_json::Map::new(); for line in content.lines() { - if let Some((key, value)) = line.split_once(':') { + if let Some((key, value)) = line.split_once('=') { let key = key.trim(); let value = value.trim(); @@ -86,7 +102,7 @@ pub async fn handle_patch_set_file(args: &Value) -> Result { .and_then(|v| v.as_object()) .ok_or_else(|| anyhow::anyhow!("patches object is required"))?; - let content = fs::read_to_string(path)?; + let content = read_file_as_utf8(path)?; let mut lines: Vec = content.lines().map(|s| s.to_string()).collect(); let mut patched_count = 0; @@ -164,8 +180,8 @@ pub async fn handle_diff_set_files(args: &Value) -> Result { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("file_b is required"))?; - let content_a = fs::read_to_string(file_a)?; - let content_b = fs::read_to_string(file_b)?; + let content_a = read_file_as_utf8(file_a)?; + let content_b = read_file_as_utf8(file_b)?; let mut differences = Vec::new(); @@ -223,20 +239,21 @@ pub async fn handle_describe_sweep(args: &Value) -> Result { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("path is required"))?; - let content = fs::read_to_string(path)?; + let content = read_file_as_utf8(path)?; let mut sweep_params = serde_json::Map::new(); for line in content.lines() { - if let Some((key, value)) = line.split_once(':') { + if let Some((key, value)) = line.split_once('=') { let key = key.trim(); let value = value.trim(); if value.contains("||Y") { - if let Some((from_val, to_val)) = value.split_once("..") { + let parts: Vec<&str> = value.split("||").collect(); + if parts.len() >= 5 && parts[4].trim().to_uppercase() == "Y" { sweep_params.insert(key.to_string(), json!({ - "from": from_val.trim(), - "to": to_val.trim().replace("||Y", ""), - "step": 1.0 + "from": parts[1].trim(), + "to": parts[3].trim(), + "step": parts[2].trim(), })); } } @@ -266,7 +283,7 @@ pub async fn handle_list_set_files(config: &Config) -> Result { .unwrap_or("unknown") .to_string(); - let content = fs::read_to_string(&path).unwrap_or_default(); + let content = read_file_as_utf8(&path.to_string_lossy()).unwrap_or_default(); let param_count = content.lines().filter(|l| l.contains(':')).count(); let sweep_count = content.lines().filter(|l| l.contains("||Y")).count(); diff --git a/src/tools/handlers/utility.rs b/src/tools/handlers/utility.rs index 7af49ce..e85b82c 100644 --- a/src/tools/handlers/utility.rs +++ b/src/tools/handlers/utility.rs @@ -2,9 +2,61 @@ use anyhow::{Context, Result}; use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use crate::models::Config; use crate::storage::ReportDb; +/// OS detection and information +#[derive(Debug, Clone)] +#[allow(dead_code)] +enum OsType { + Windows, + MacOS, + Linux, + Unknown, +} + +impl OsType { + fn detect() -> Self { + #[cfg(target_os = "windows")] + return OsType::Windows; + #[cfg(target_os = "macos")] + return OsType::MacOS; + #[cfg(target_os = "linux")] + return OsType::Linux; + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + return OsType::Unknown; + } + + fn as_str(&self) -> &'static str { + match self { + OsType::Windows => "windows", + OsType::MacOS => "macos", + OsType::Linux => "linux", + OsType::Unknown => "unknown", + } + } + + fn uses_wine(&self) -> bool { + matches!(self, OsType::MacOS | OsType::Linux) + } +} + +/// Detect if using CrossOver instead of Wine on macOS +fn detect_crossover(wine_exe: &str) -> bool { + wine_exe.contains("crossover") || wine_exe.contains("CrossOver") +} + +/// Get OS context for diagnostic outputs +fn get_os_context() -> serde_json::Value { + let os_type = OsType::detect(); + json!({ + "os": os_type.as_str(), + "uses_wine": os_type.uses_wine(), + "architecture": std::env::consts::ARCH, + }) +} + /// Validate that `user_path` resolves to a location within `allowed_base`. /// Returns the canonicalized absolute path on success. fn safe_output_path(user_path: &str, allowed_base: &Path) -> Result { @@ -846,8 +898,11 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result Result { + let os_type = OsType::detect(); let mut diagnostics = json!({ + "os_context": get_os_context(), "wine_executable": null, + "wine_type": null, "wine_version": null, "wine_prefix": null, "prefix_health": null, @@ -857,12 +912,16 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result::new(), }); - // Check wine executable + // Check wine executable (macOS/Linux) if let Some(wine_exe) = config.wine_executable.as_ref() { diagnostics["wine_executable"] = json!(wine_exe); - // Get Wine version - let version_output = std::process::Command::new(wine_exe) + // Detect CrossOver vs Wine + let is_crossover = detect_crossover(wine_exe); + diagnostics["wine_type"] = json!(if is_crossover { "crossover" } else { "wine" }); + + // Get Wine/CrossOver version + let version_output = Command::new(wine_exe) .arg("--version") .output(); @@ -872,14 +931,26 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result { + let wine_type_str = if is_crossover { "CrossOver" } else { "Wine" }; diagnostics["errors"].as_array_mut().unwrap().push( - json!("Failed to get Wine version - Wine may not be properly installed") + json!(format!("Failed to get {} version - may not be properly installed", wine_type_str)) + ); + } + } + + // macOS-specific CrossOver checks + if matches!(os_type, OsType::MacOS) && is_crossover { + // Check if CrossOver app exists + let crossover_app = Path::new("/Applications/CrossOver.app"); + if !crossover_app.exists() { + diagnostics["warnings"].as_array_mut().unwrap().push( + json!("CrossOver.app not found in /Applications - may be custom installation") ); } } } else { diagnostics["errors"].as_array_mut().unwrap().push( - json!("Wine executable not configured") + json!("Wine/CrossOver executable not configured") ); } @@ -991,6 +1062,7 @@ pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result }; let mut result = json!({ + "os_context": get_os_context(), "log_type": log_type, "log_path": log_path.to_string_lossy().to_string(), "found": false, @@ -1109,6 +1181,7 @@ pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result Result Result { - use std::process::Command; + let _os_type = OsType::detect(); let mut result = json!({ + "os_context": get_os_context(), "is_running": false, "processes": Vec::::new(), "wine_server_running": false, "total_instances": 0, }); - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] { // Check for MT5 processes let ps_output = Command::new("ps") @@ -1149,6 +1223,7 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result let mut processes = Vec::new(); let mut mt5_count = 0; let mut wine_server = false; + let mut crossover_server = false; for line in content.lines() { let line_lower = line.to_lowercase(); @@ -1169,45 +1244,11 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result if line_lower.contains("wineserver") { wine_server = true; } - } - - result["processes"] = json!(processes); - result["is_running"] = json!(mt5_count > 0); - result["total_instances"] = json!(mt5_count); - result["wine_server_running"] = json!(wine_server); - } - } - - #[cfg(target_os = "linux")] - { - let ps_output = Command::new("ps") - .args(["aux"]) - .output(); - - if let Ok(output) = ps_output { - let content = String::from_utf8_lossy(&output.stdout); - let mut processes = Vec::new(); - let mut mt5_count = 0; - let mut wine_server = false; - - for line in content.lines() { - let line_lower = line.to_lowercase(); - if line_lower.contains("terminal64") || line_lower.contains("metatrader") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 11 { - processes.push(json!({ - "pid": parts[1], - "cpu": parts[2], - "mem": parts[3], - "command": parts[10..].join(" "), - })); - mt5_count += 1; - } - } - - if line_lower.contains("wineserver") { - wine_server = true; + // Detect CrossOver server on macOS + #[cfg(target_os = "macos")] + if line_lower.contains("cxstart") || line_lower.contains("crossover") { + crossover_server = true; } } @@ -1215,6 +1256,11 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result result["is_running"] = json!(mt5_count > 0); result["total_instances"] = json!(mt5_count); result["wine_server_running"] = json!(wine_server); + + #[cfg(target_os = "macos")] + { + result["crossover_server_running"] = json!(crossover_server); + } } } @@ -1226,7 +1272,7 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result /// Kill stuck MT5 process pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result { - use std::process::Command; + let _os_type = OsType::detect(); let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); let pid = args.get("pid").and_then(|v| v.as_str()); @@ -1271,6 +1317,12 @@ pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result Result Result Result { - use std::process::Command; + let _os_type = OsType::detect(); let mut result = json!({ + "os_context": get_os_context(), "disk_space": null, "memory": null, "cpu_cores": 0, @@ -1345,34 +1399,66 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R let vm_output = Command::new("vm_stat").output(); if let Ok(output) = vm_output { let content = String::from_utf8_lossy(&output.stdout); - // Parse vm_stat output + // Parse vm_stat output with improved robustness let mut free_pages = 0u64; let mut active_pages = 0u64; let mut inactive_pages = 0u64; + let mut wired_pages = 0u64; + let mut page_size = 4096u64; + // Try to get page size from vm_stat output for line in content.lines() { - if line.contains("Pages free:") { - free_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0); - } else if line.contains("Pages active:") { - active_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0); - } else if line.contains("Pages inactive:") { - inactive_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0); + if line.contains("Mach Virtual Memory Statistics:") { + // Page size is typically 4096 on macOS + page_size = 4096; } } - let page_size = 4096u64; - let total_mb = ((free_pages + active_pages + inactive_pages) * page_size) / 1024 / 1024; + for line in content.lines() { + // More robust parsing using regex-like pattern matching + if line.contains("Pages free:") { + if let Some(val) = line.split(':').nth(1) { + free_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0); + } + } else if line.contains("Pages active:") { + if let Some(val) = line.split(':').nth(1) { + active_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0); + } + } else if line.contains("Pages inactive:") { + if let Some(val) = line.split(':').nth(1) { + inactive_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0); + } + } else if line.contains("Pages wired:") { + if let Some(val) = line.split(':').nth(1) { + wired_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0); + } + } + } + + // Calculate memory more accurately + let total_pages = free_pages + active_pages + inactive_pages + wired_pages; + let total_mb = (total_pages * page_size) / 1024 / 1024; let free_mb = (free_pages * page_size) / 1024 / 1024; + let available_mb = ((free_pages + inactive_pages) * page_size) / 1024 / 1024; result["memory"] = json!({ "total_mb": total_mb, "free_mb": free_mb, + "available_mb": available_mb, + "active_mb": (active_pages * page_size) / 1024 / 1024, + "wired_mb": (wired_pages * page_size) / 1024 / 1024, + "page_size": page_size, "unit": "MB", }); - if free_mb < 2048 { + // Adjust memory threshold for macOS (compressed memory makes free appear lower) + if available_mb < 1024 { result["recommendations"].as_array_mut().unwrap().push( - json!("Low memory available. MT5 may crash during large optimizations.") + json!(format!("Low memory available ({} MB free). MT5 may crash during large optimizations. Close other apps.", available_mb)) + ); + } else if available_mb < 2048 { + result["recommendations"].as_array_mut().unwrap().push( + json!(format!("Memory getting low ({} MB available). Consider closing other applications.", available_mb)) ); } } @@ -1393,6 +1479,13 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R "free_mb": parts[3].parse::().unwrap_or(0), "unit": "MB", }); + + let free_mb = parts[3].parse::().unwrap_or(0); + if free_mb < 1024 { + result["recommendations"].as_array_mut().unwrap().push( + json!("Low memory available. MT5 may crash during large optimizations.") + ); + } } } } @@ -1400,13 +1493,38 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R } // Get CPU cores - let nproc_output = Command::new("sysctl") - .args(["-n", "hw.ncpu"]) - .output(); + #[cfg(target_os = "macos")] + { + let nproc_output = Command::new("sysctl") + .args(["-n", "hw.ncpu"]) + .output(); + + if let Ok(output) = nproc_output { + let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0); + result["cpu_cores"] = json!(cores); + + if cores < 4 { + result["recommendations"].as_array_mut().unwrap().push( + json!(format!("Low CPU core count ({} cores). Optimizations may be slow.", cores)) + ); + } + } + } - if let Ok(output) = nproc_output { - let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0); - result["cpu_cores"] = json!(cores); + #[cfg(target_os = "linux")] + { + let nproc_output = Command::new("nproc").output(); + + if let Ok(output) = nproc_output { + let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0); + result["cpu_cores"] = json!(cores); + + if cores < 4 { + result["recommendations"].as_array_mut().unwrap().push( + json!(format!("Low CPU core count ({} cores). Optimizations may be slow.", cores)) + ); + } + } } } @@ -1422,6 +1540,7 @@ pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Resul .ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?; let mut result = json!({ + "os_context": get_os_context(), "terminal_ini": null, "tester_ini": null, "config_files_found": Vec::::new(), @@ -1505,6 +1624,7 @@ pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Resul /// Get Wine prefix detailed information pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result { + let _os_type = OsType::detect(); let mt5_dir = config.mt5_dir() .ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?; @@ -1514,6 +1634,7 @@ pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Resu .and_then(|p| p.parent()); let mut result = json!({ + "os_context": get_os_context(), "prefix_path": null, "exists": false, "windows_version": null, @@ -1609,6 +1730,7 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6); let mut result = json!({ + "os_context": get_os_context(), "crashes_found": Vec::::new(), "recent_failures": 0, "common_patterns": Vec::::new(), diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..353b590 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,20 @@ +/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String. +/// MT5 .set and .ini files are typically UTF-16LE with BOM (0xFF 0xFE). +pub fn read_file_as_utf8(path: &std::path::Path) -> anyhow::Result { + let bytes = std::fs::read(path)?; + + // Check for UTF-16LE BOM (0xFF 0xFE) + if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { + // UTF-16LE with BOM - skip the 2-byte BOM and decode + let utf16_data: Vec = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16(&utf16_data) + .map_err(|e| anyhow::anyhow!("Failed to decode UTF-16LE: {}", e)) + } else { + // Try UTF-8 + String::from_utf8(bytes) + .map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e)) + } +}