diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml new file mode 100644 index 0000000..c050297 --- /dev/null +++ b/.github/workflows/windows-smoke.yml @@ -0,0 +1,43 @@ +name: Windows Smoke + +on: + pull_request: + push: + branches: + - main + +jobs: + metatrader-file: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Configure + run: > + cmake -S . -B build-windows + -DOPTIONX_BUILD_DEPS=ON + -DOPTIONX_BUILD_TESTS=ON + -DOPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS=ON + + - name: Build MetaTrader path discovery test + run: cmake --build build-windows --config Debug --target metatrader_paths_test + + - name: Build MetaTrader file transport tests + run: > + cmake --build build-windows --config Debug --target + metatrader_file_config_include_test + metatrader_file_bridge_test + bridge_umbrella_include_test + + - name: Run MetaTrader file tests + shell: pwsh + run: | + $env:PATH = "$PWD\build-windows\bin;$PWD\build-windows\Debug;$env:PATH" + .\build-windows\Debug\metatrader_paths_test.exe --gtest_brief=1 + .\build-windows\Debug\metatrader_file_config_include_test.exe --gtest_brief=1 + .\build-windows\Debug\metatrader_file_bridge_test.exe --gtest_brief=1 + .\build-windows\Debug\bridge_umbrella_include_test.exe --gtest_brief=1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 70f1fa0..df8dd79 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,6 +3,14 @@ cmake_minimum_required(VERSION 3.18) project(optionx_cpp LANGUAGES CXX) +if(MSVC) + add_compile_options(/Zc:__cplusplus) +endif() + +if(WIN32) + add_compile_definitions(NOMINMAX) +endif() + set(OPTIONX_TOP_LEVEL OFF) if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(OPTIONX_TOP_LEVEL ON) @@ -45,6 +53,10 @@ endif() option(OPTIONX_BUILD_DEPS "Build bundled optionx dependencies" OFF) option(OPTIONX_BUILD_EXAMPLES "Build optionx examples" OFF) option(OPTIONX_BUILD_TESTS "Build optionx tests" OFF) +option( + OPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS + "Link selected bridge smoke tests without the full optionx_cpp dependency graph" + OFF) set(OPTIONX_DEPS_BUILD_DIR "" CACHE PATH "Path to prebuilt optionx dependencies") if(OPTIONX_TOP_LEVEL) @@ -118,6 +130,8 @@ set(OPTIONX_WINDOWS_SYSTEM_LIBS ws2_32 wsock32 crypt32 + shell32 + ole32 ntdll bcrypt ) @@ -461,6 +475,17 @@ if(OPTIONX_BUILD_TESTS) gtest ) + set(OPTIONX_LIGHTWEIGHT_TESTS + metatrader_paths_test + ) + if(OPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS) + list(APPEND OPTIONX_LIGHTWEIGHT_TESTS + bridge_umbrella_include_test + metatrader_file_bridge_test + metatrader_file_config_include_test + ) + endif() + file(GLOB_RECURSE PROJECT_HEADERS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} include/*.hpp @@ -514,6 +539,7 @@ if(OPTIONX_BUILD_TESTS) list(APPEND TEST_TARGET_SOURCES "${test_src}") add_executable(${test_name} ${test_src}) + target_compile_features(${test_name} PRIVATE cxx_std_17) target_include_directories(${test_name} PRIVATE ${PROJECT_INCLUDE_DIRS} @@ -526,10 +552,23 @@ if(OPTIONX_BUILD_TESTS) ${PROJECT_DEFINES} LOGIT_BASE_PATH="${LOGIT_BASE_PATH_FWD}" ) - target_link_libraries(${test_name} PRIVATE ${PROJECT_LIBS} optionx_cpp) + list(FIND OPTIONX_LIGHTWEIGHT_TESTS "${test_name}" lightweight_test_index) + if(NOT lightweight_test_index EQUAL -1) + target_link_libraries(${test_name} PRIVATE ${PROJECT_LIBS}) + if(WIN32) + target_link_libraries(${test_name} PRIVATE ${OPTIONX_WINDOWS_SYSTEM_LIBS}) + endif() + else() + target_link_libraries(${test_name} PRIVATE ${PROJECT_LIBS} optionx_cpp) + endif() if(OPTIONX_BUILD_DEPS) - foreach(test_dep mdbx-static AES gtest) + if(NOT lightweight_test_index EQUAL -1) + set(test_deps gtest) + else() + set(test_deps mdbx-static AES gtest) + endif() + foreach(test_dep ${test_deps}) if(TARGET ${test_dep}) add_dependencies(${test_name} ${test_dep}) endif() diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.md b/guides/bridge-protocol-v1/file-transport-and-adapters.md index a0c78e0..e89312a 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.md @@ -69,20 +69,21 @@ file-transport building blocks: - `metatrader_file::detail` helpers for path-safe IDs, NDJSON append/read, owner-side log cleanup, JSON-RPC request/response/notification documents, atomic state snapshots and bounded JSON reads; +- `utils::metatrader` helpers for resolving the default MetaQuotes roaming + location, Common Files root and terminal data directories; - small helpers for `balance.updated` and `trade.updated` notification payloads. This slice does not yet define a long-running `BaseBridge` polling loop, MQL advisor code or a broker execution adapter. Those pieces should use these helpers in follow-up implementation PRs. -### Future MetaTrader Discovery Utility +### MetaTrader Discovery Utility -MetaTrader path discovery should be implemented as a reusable utility in a -follow-up PR, not as ad-hoc logic inside the file bridge. The utility should be -usable by the file transport, quote translators, MQL sample tooling and future -MT4/MT5 adapters. +MetaTrader path discovery is a reusable utility, not ad-hoc logic inside the +file bridge. The utility is intended for the file transport, quote translators, +MQL sample tooling and future MT4/MT5 adapters. -Expected responsibilities: +Responsibilities: - resolve the default MetaQuotes roaming directory on Windows through the OS known-folder API, with `%APPDATA%` only as a fallback; @@ -97,6 +98,9 @@ Expected responsibilities: - keep path confinement and reserved-name checks separate from discovery so the bridge can validate configured and discovered roots the same way. +C++ entry points live under `optionx::utils::metatrader` and are exposed through +`optionx_cpp/utils.hpp` and `optionx_cpp/bridges/metatrader_file.hpp`. + The older `mega-connector` code has useful prior art in `tools/mt/common/utils.hpp` (`SHGetKnownFolderPath`, terminal enumeration and history-folder discovery), but the OptionX utility should be redesigned around @@ -287,7 +291,6 @@ append, checkpoint and clear. Deferred implementation work: -- MetaTrader terminal discovery utilities for locating `Common\Files`. - A concrete polling bridge class built on this protocol helper layer. - A runtime writer object or owner queue that serializes append, repair and owner-side clear operations per log file. diff --git a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md index 9b5cb8d..8b3bf7a 100644 --- a/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md +++ b/guides/bridge-protocol-v1/file-transport-and-adapters.ru.md @@ -63,19 +63,21 @@ Reader хранит свой checkpoint и не переписывает append- - helpers в `metatrader_file::detail` для path-safe IDs, NDJSON append/read, owner-side cleanup, JSON-RPC request/response/notification documents, atomic state snapshots и bounded JSON reads; +- helpers `utils::metatrader` для default MetaQuotes roaming location, + Common Files root и terminal data directories; - helpers для `balance.updated`, `trade.updated` и `state.json` payloads. Этот slice еще не задает long-running `BaseBridge` polling loop, MQL advisor code или broker execution adapter. Эти части должны использовать helpers в следующем implementation PR. -### Future MetaTrader Discovery Utility +### MetaTrader Discovery Utility -Поиск путей MetaTrader стоит реализовать отдельной reusable utility в будущем -PR, а не ad-hoc логикой внутри file bridge. Utility должна быть полезна file -transport, quote translators, MQL sample tooling и будущим MT4/MT5 adapters. +Поиск путей MetaTrader реализован отдельной reusable utility, а не ad-hoc +логикой внутри file bridge. Utility предназначена для file transport, quote +translators, MQL sample tooling и будущих MT4/MT5 adapters. -Expected responsibilities: +Responsibilities: - находить default MetaQuotes roaming directory на Windows через OS known-folder API, используя `%APPDATA%` только как fallback; @@ -88,6 +90,9 @@ Expected responsibilities: - принимать явно настроенные terminal или Common Files roots без guesswork; - держать path confinement и reserved-name checks отдельно от discovery. +C++ entry points находятся в `optionx::utils::metatrader` и доступны через +`optionx_cpp/utils.hpp` и `optionx_cpp/bridges/metatrader_file.hpp`. + В старом `mega-connector` есть useful prior art в `tools/mt/common/utils.hpp`, но OptionX utility лучше спроектировать как набор маленьких testable helpers. @@ -262,7 +267,6 @@ append, checkpoint and clear. Deferred implementation work: -- MetaTrader terminal discovery utilities for locating `Common\Files`. - Concrete polling bridge class built on this protocol helper layer. - Runtime writer object or owner queue that serializes append, repair and owner-side clear operations per log file. diff --git a/guides/build-and-test.md b/guides/build-and-test.md index 5124920..4e6ea3a 100644 --- a/guides/build-and-test.md +++ b/guides/build-and-test.md @@ -90,7 +90,7 @@ Target naming: - `tradeup_ws_invalid_token_probe` - TradeUp WebSocket probe. Линкуемые libs для tests в `CMakeLists.txt`: `ws2_32`, `wsock32`, `crypt32`, -`ssl`, `crypto`, `curl`, `mdbx`, `ntdll`, `bcrypt`, `AES`, `gtest`. +`ssl`, `crypto`, `curl`, `mdbx`, `shell32`, `ole32`, `ntdll`, `bcrypt`, `AES`, `gtest`. Compile definition: `ASIO_STANDALONE`. Для tests также задается `LOGIT_BASE_PATH`. @@ -130,6 +130,12 @@ $env:OPTIONX_INTRADE_BAR_CONFIG_FILE="tests\intrade_bar_api\intrade_bar_api.loca ## Include-Contract Checks +GitHub CI also runs a focused Windows smoke job for MetaTrader file transport +helpers. It builds and runs `metatrader_paths_test`, +`metatrader_file_config_include_test`, `metatrader_file_bridge_test` and +`bridge_umbrella_include_test` on `windows-latest` so Windows filesystem API +paths such as atomic replacement and exclusive temp creation are covered by CI. + При изменении публичных aggregate headers или include policy добавляй или обновляй тест, который подключает intended public entry point: diff --git a/include/optionx_cpp/bridges/metatrader_file.hpp b/include/optionx_cpp/bridges/metatrader_file.hpp index 4aa190b..c24f58b 100644 --- a/include/optionx_cpp/bridges/metatrader_file.hpp +++ b/include/optionx_cpp/bridges/metatrader_file.hpp @@ -6,15 +6,20 @@ /// \brief Includes MetaTrader file-transport bridge headers. #include +#include +#include #include #include #include #include +#include #include #include #include #include +#include #include +#include #include #include #include @@ -22,8 +27,9 @@ #include #include -#include "data/bridge.hpp" -#include "data/trading.hpp" +#include +#include +#include #if defined(_WIN32) #ifndef NOMINMAX @@ -33,6 +39,7 @@ #endif #include "BaseBridge.hpp" +#include "metatrader_file/MetaTraderFilePathUtils.hpp" #include "metatrader_file/MetaTraderFileBridgeConfig.hpp" #include "metatrader_file/detail/MetaTraderFileProtocol.hpp" diff --git a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileBridgeConfig.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileBridgeConfig.hpp index 7154ded..8f551a7 100644 --- a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileBridgeConfig.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFileBridgeConfig.hpp @@ -5,137 +5,8 @@ /// \file MetaTraderFileBridgeConfig.hpp /// \brief Defines configuration for the MetaTrader common-files bridge transport. -#include "data/bridge.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - namespace optionx::bridges::metatrader_file { - /// \brief Returns the default MetaQuotes common files directory on Windows. - /// \return `%APPDATA%\MetaQuotes\Terminal\Common\Files` when APPDATA is set; otherwise empty. - inline std::string default_common_files_root() { -#if defined(_WIN32) - const char* appdata = std::getenv("APPDATA"); - if (!appdata || !*appdata) { - return {}; - } - return (std::filesystem::u8path(appdata) / - "MetaQuotes" / - "Terminal" / - "Common" / - "Files").u8string(); -#else - return {}; -#endif - } - - /// \brief Returns an ASCII-uppercase copy for protocol/path identifiers. - inline std::string ascii_upper_copy(std::string value) { - std::transform( - value.begin(), - value.end(), - value.begin(), - [](const unsigned char ch) { - return static_cast(std::toupper(ch)); - }); - return value; - } - - /// \brief Returns true when a path component is a Windows reserved device name. - /// \details Windows treats names such as `CON` and `COM1` as devices even - /// when an extension is appended, so the base name before the first dot is checked. - inline bool is_windows_reserved_device_name(const std::string& value) { - if (value.empty()) return false; - - const auto dot = value.find('.'); - const auto base = ascii_upper_copy(value.substr(0, dot)); - if (base == "CON" || - base == "PRN" || - base == "AUX" || - base == "NUL") { - return true; - } - if (base.size() == 4 && - (base.substr(0, 3) == "COM" || base.substr(0, 3) == "LPT") && - base[3] >= '1' && - base[3] <= '9') { - return true; - } - return false; - } - - /// \brief Returns true when a path segment is safe for protocol filenames. - /// \details The file transport uses `_` as a separator, so stable IDs must - /// not contain it. Conservative IDs keep parsing portable across MQL and C++. - inline bool is_safe_file_transport_id(const std::string& value) { - if (value.empty() || - value == "." || - value == ".." || - value.back() == '.' || - is_windows_reserved_device_name(value)) { - return false; - } - for (const unsigned char ch : value) { - const bool ok = - (ch >= 'A' && ch <= 'Z') || - (ch >= 'a' && ch <= 'z') || - (ch >= '0' && ch <= '9') || - ch == '-' || - ch == '.'; - if (!ok) return false; - } - return true; - } - - /// \brief Returns a normalized absolute path without touching the filesystem. - inline std::filesystem::path lexically_absolute_normal( - const std::filesystem::path& path) { - return std::filesystem::absolute(path).lexically_normal(); - } - - /// \brief Returns true when `child` is inside or equal to `parent`. - inline bool path_is_within_or_equal( - const std::filesystem::path& parent, - const std::filesystem::path& child) { - const auto normalized_parent = lexically_absolute_normal(parent); - const auto normalized_child = lexically_absolute_normal(child); - - if (normalized_parent == normalized_child) { - return true; - } - - const auto relative = normalized_child.lexically_relative(normalized_parent); - if (relative.empty() || relative.is_absolute()) { - return false; - } - - const auto first = relative.begin(); - return first != relative.end() && *first != ".."; - } - - /// \brief Returns true when a namespace subdirectory is a safe relative path. - inline bool is_safe_namespace_subdir(const std::string& value) { - if (value.empty()) return false; - const auto path = std::filesystem::u8path(value); - if (path.is_absolute() || path.has_root_name() || path.has_root_directory()) { - return false; - } - for (const auto& part : path) { - const auto text = part.u8string(); - if (!is_safe_file_transport_id(text)) { - return false; - } - } - return true; - } - /// \class MetaTraderFileBridgeConfig /// \brief Configuration for the OptionX MetaTrader NDJSON file transport. class MetaTraderFileBridgeConfig final : public IBridgeConfig { diff --git a/include/optionx_cpp/bridges/metatrader_file/MetaTraderFilePathUtils.hpp b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFilePathUtils.hpp new file mode 100644 index 0000000..173a056 --- /dev/null +++ b/include/optionx_cpp/bridges/metatrader_file/MetaTraderFilePathUtils.hpp @@ -0,0 +1,118 @@ +#pragma once +#ifndef OPTIONX_HEADER_BRIDGES_METATRADER_FILE_META_TRADER_FILE_PATH_UTILS_HPP_INCLUDED +#define OPTIONX_HEADER_BRIDGES_METATRADER_FILE_META_TRADER_FILE_PATH_UTILS_HPP_INCLUDED + +/// \file MetaTraderFilePathUtils.hpp +/// \brief Path and identifier helpers for the MetaTrader file transport. + +namespace optionx::bridges::metatrader_file { + + /// \brief Returns the default MetaQuotes common files directory. + /// \return `%APPDATA%\MetaQuotes\Terminal\Common\Files` when it can be resolved; otherwise empty. + inline std::string default_common_files_root() { + return optionx::utils::metatrader::default_common_files_root().u8string(); + } + + /// \brief Returns an ASCII-uppercase copy for protocol/path identifiers. + inline std::string ascii_upper_copy(std::string value) { + std::transform( + value.begin(), + value.end(), + value.begin(), + [](const unsigned char ch) { + return static_cast(std::toupper(ch)); + }); + return value; + } + + /// \brief Returns true when a path component is a Windows reserved device name. + /// \details Windows treats names such as `CON` and `COM1` as devices even + /// when an extension is appended, so the base name before the first dot is checked. + inline bool is_windows_reserved_device_name(const std::string& value) { + if (value.empty()) return false; + + const auto dot = value.find('.'); + const auto base = ascii_upper_copy(value.substr(0, dot)); + if (base == "CON" || + base == "PRN" || + base == "AUX" || + base == "NUL") { + return true; + } + if (base.size() == 4 && + (base.substr(0, 3) == "COM" || base.substr(0, 3) == "LPT") && + base[3] >= '1' && + base[3] <= '9') { + return true; + } + return false; + } + + /// \brief Returns true when a path segment is safe for protocol filenames. + /// \details The file transport uses `_` as a separator, so stable IDs must + /// not contain it. Conservative IDs keep parsing portable across MQL and C++. + inline bool is_safe_file_transport_id(const std::string& value) { + if (value.empty() || + value == "." || + value == ".." || + value.back() == '.' || + is_windows_reserved_device_name(value)) { + return false; + } + for (const unsigned char ch : value) { + const bool ok = + (ch >= 'A' && ch <= 'Z') || + (ch >= 'a' && ch <= 'z') || + (ch >= '0' && ch <= '9') || + ch == '-' || + ch == '.'; + if (!ok) return false; + } + return true; + } + + /// \brief Returns a normalized absolute path without touching the filesystem. + inline std::filesystem::path lexically_absolute_normal( + const std::filesystem::path& path) { + return std::filesystem::absolute(path).lexically_normal(); + } + + /// \brief Returns true when `child` is inside or equal to `parent`. + inline bool path_is_within_or_equal( + const std::filesystem::path& parent, + const std::filesystem::path& child) { + const auto normalized_parent = lexically_absolute_normal(parent); + const auto normalized_child = lexically_absolute_normal(child); + + if (normalized_parent == normalized_child) { + return true; + } + + const auto relative = normalized_child.lexically_relative(normalized_parent); + if (relative.empty() || relative.is_absolute()) { + return false; + } + + const auto first = relative.begin(); + return first != relative.end() && *first != ".."; + } + + /// \brief Returns true when a namespace subdirectory is a safe relative path. + inline bool is_safe_namespace_subdir(const std::string& value) { + if (value.empty()) return false; + const auto path = std::filesystem::u8path(value); + if (path.is_absolute() || path.has_root_name() || path.has_root_directory()) { + return false; + } + for (const auto& part : path) { + const auto text = part.u8string(); + if (!is_safe_file_transport_id(text)) { + return false; + } + } + return true; + } + +} // namespace optionx::bridges::metatrader_file + +#endif // OPTIONX_HEADER_BRIDGES_METATRADER_FILE_META_TRADER_FILE_PATH_UTILS_HPP_INCLUDED diff --git a/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp b/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp index 072d1e5..2740e4e 100644 --- a/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp +++ b/include/optionx_cpp/bridges/metatrader_file/detail/MetaTraderFileProtocol.hpp @@ -5,27 +5,6 @@ /// \file MetaTraderFileProtocol.hpp /// \brief NDJSON file-transport helpers for MetaTrader common-files integrations. -#include "../MetaTraderFileBridgeConfig.hpp" -#include "data/trading.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - #if defined(_WIN32) #ifndef NOMINMAX #define NOMINMAX diff --git a/include/optionx_cpp/bridges/named_pipe.hpp b/include/optionx_cpp/bridges/named_pipe.hpp index 55b46dd..8cc337b 100644 --- a/include/optionx_cpp/bridges/named_pipe.hpp +++ b/include/optionx_cpp/bridges/named_pipe.hpp @@ -24,7 +24,7 @@ #include #include "BaseBridge.hpp" -#include "utils/tasks.hpp" +#include #if defined(_WIN32) #include diff --git a/include/optionx_cpp/bridges/trading_view.hpp b/include/optionx_cpp/bridges/trading_view.hpp index 4def7f2..05298b4 100644 --- a/include/optionx_cpp/bridges/trading_view.hpp +++ b/include/optionx_cpp/bridges/trading_view.hpp @@ -26,7 +26,7 @@ #include #include "BaseBridge.hpp" -#include "utils/unicode_case.hpp" +#include #include diff --git a/include/optionx_cpp/utils.hpp b/include/optionx_cpp/utils.hpp index a1e1f0d..0a3161f 100644 --- a/include/optionx_cpp/utils.hpp +++ b/include/optionx_cpp/utils.hpp @@ -26,6 +26,7 @@ #include "utils/log_redaction.hpp" ///< Secret redaction helpers for diagnostics. #include "utils/json_comments.hpp" ///< JSONC-style comment stripping helpers. #include "utils/unicode_case.hpp" ///< Unicode-aware caseless matching helpers. +#include "utils/metatrader_paths.hpp" ///< MetaTrader directory discovery helpers. // Data encoding #include "utils/Base36.hpp" ///< Base36 encoding/decoding implementation diff --git a/include/optionx_cpp/utils/metatrader_paths.hpp b/include/optionx_cpp/utils/metatrader_paths.hpp new file mode 100644 index 0000000..99608f6 --- /dev/null +++ b/include/optionx_cpp/utils/metatrader_paths.hpp @@ -0,0 +1,613 @@ +#pragma once +#ifndef OPTIONX_HEADER_UTILS_METATRADER_PATHS_HPP_INCLUDED +#define OPTIONX_HEADER_UTILS_METATRADER_PATHS_HPP_INCLUDED + +/// \file metatrader_paths.hpp +/// \brief Utilities for locating MetaTrader data and common-files directories. + +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#ifdef min +#undef min +#endif +#ifdef max +#undef max +#endif +#endif + +namespace optionx::utils::metatrader { + +#if defined(_WIN32) + /// \brief Releases memory returned by Windows known-folder APIs. + struct CoTaskMemDeleter { + void operator()(wchar_t* ptr) const noexcept { + CoTaskMemFree(ptr); + } + }; + + /// \brief Error category used when an HRESULT is not a wrapped Win32 error. + class HResultErrorCategory final : public std::error_category { + public: + const char* name() const noexcept override { + return "hresult"; + } + + std::string message(int value) const override { + return "HRESULT " + std::to_string(value); + } + }; + + /// \brief Returns the process-wide HRESULT error category. + inline const std::error_category& hresult_error_category() { + static HResultErrorCategory category; + return category; + } + + /// \brief Converts an HRESULT without pretending every value is Win32. + inline std::error_code make_error_code_from_hresult(const HRESULT hr) { + if (HRESULT_FACILITY(hr) == FACILITY_WIN32) { + return std::error_code( + static_cast(HRESULT_CODE(hr)), + std::system_category()); + } + return std::error_code( + static_cast(hr), + hresult_error_category()); + } +#endif + + /// \enum MetaTraderTerminalKind + /// \brief Identifies which MQL runtime directories exist in a terminal data directory. + enum class MetaTraderTerminalKind { + UNKNOWN = 0, ///< No recognizable MQL runtime directory was found. + MT4, ///< The terminal data directory contains MQL4. + MT5, ///< The terminal data directory contains MQL5. + MIXED ///< Both MQL4 and MQL5 directories are present. + }; + + /// \class MetaTraderTerminalDirectory + /// \brief Describes one MetaTrader terminal data directory under MetaQuotes\Terminal. + struct MetaTraderTerminalDirectory { + std::filesystem::path data_root; ///< Terminal data directory. + bool has_mql4 = false; ///< True when MQL4 directory exists. + bool has_mql5 = false; ///< True when MQL5 directory exists. + + /// \brief Returns the detected terminal kind. + MetaTraderTerminalKind kind() const noexcept { + if (has_mql4 && has_mql5) return MetaTraderTerminalKind::MIXED; + if (has_mql4) return MetaTraderTerminalKind::MT4; + if (has_mql5) return MetaTraderTerminalKind::MT5; + return MetaTraderTerminalKind::UNKNOWN; + } + + /// \brief Returns `MQL4\Files` inside this terminal data directory. + std::filesystem::path mql4_files_dir() const { + return data_root / "MQL4" / "Files"; + } + + /// \brief Returns `MQL5\Files` inside this terminal data directory. + std::filesystem::path mql5_files_dir() const { + return data_root / "MQL5" / "Files"; + } + }; + + /// \class MetaTraderDiscoveryResult + /// \brief Result of a terminal-directory discovery scan. + struct MetaTraderDiscoveryResult { + std::vector terminals; ///< Discovered terminal directories. + std::error_code error; ///< Filesystem error when the scan could not complete. + std::filesystem::path error_path; ///< Path that produced the first reported error. + bool complete = true; ///< False when discovery stopped because of an error. + }; + + /// \class MetaTraderPathResolutionResult + /// \brief Result of resolving a default MetaTrader-related root path. + struct MetaTraderPathResolutionResult { + std::filesystem::path path; ///< Resolved path. + std::error_code error; ///< Resolution error when no path was resolved. + bool resolved = false; ///< True when `path` contains a usable value. + }; + + /// \class MetaTraderMqlFilesDirectoriesResult + /// \brief Result of locating existing MQL `Files` directories for a terminal. + struct MetaTraderMqlFilesDirectoriesResult { + std::vector files_directories; ///< Existing MQL Files directories. + std::error_code error; ///< First filesystem error observed while checking directories. + std::filesystem::path error_path; ///< Path that produced the first reported error. + bool complete = true; ///< False when one or more checks failed. + }; + + /// \brief Records the first discovery error while allowing scanning to continue. + inline void record_discovery_error( + MetaTraderDiscoveryResult& result, + const std::error_code& ec, + const std::filesystem::path& path) { + if (!ec) { + return; + } + result.complete = false; + if (!result.error) { + result.error = ec; + result.error_path = path; + } + } + + /// \brief Records the first MQL Files lookup error. + inline void record_mql_files_error( + MetaTraderMqlFilesDirectoriesResult& result, + const std::error_code& ec, + const std::filesystem::path& path) { + if (!ec) { + return; + } + result.complete = false; + if (!result.error) { + result.error = ec; + result.error_path = path; + } + } + + /// \brief Returns true when the path is a symlink or Windows reparse point. + inline bool is_reparse_or_symlink( + const std::filesystem::path& path, + std::error_code& ec) { + ec.clear(); +#if defined(_WIN32) + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) { + const DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND || + error == ERROR_PATH_NOT_FOUND || + error == ERROR_NOT_READY) { + return false; + } + ec = std::error_code( + static_cast(error), + std::system_category()); + return false; + } + return (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; +#else + const auto status = std::filesystem::symlink_status(path, ec); + if (ec) { + if (ec == std::errc::no_such_file_or_directory || + ec == std::errc::not_a_directory) { + ec.clear(); + } + return false; + } + return std::filesystem::is_symlink(status); +#endif + } + + /// \brief Returns true when the path exists and is a directory. + inline bool directory_exists( + const std::filesystem::path& path, + std::error_code& ec) { + ec.clear(); + const bool exists = std::filesystem::is_directory(path, ec); + if (ec == std::errc::no_such_file_or_directory || + ec == std::errc::not_a_directory) { + ec.clear(); + } + return !ec && exists; + } + + /// \brief Returns true when the path is a directory and not a symlink/reparse point. + inline bool trusted_directory_exists( + const std::filesystem::path& path, + std::error_code& ec) { + if (is_reparse_or_symlink(path, ec)) { + ec = std::make_error_code(std::errc::operation_not_permitted); + return false; + } + if (ec) { + return false; + } + return directory_exists(path, ec); + } + + /// \brief Returns true when the path exists and is a directory. + inline bool directory_exists(const std::filesystem::path& path) { + std::error_code ec; + return directory_exists(path, ec); + } + + /// \brief Resolves the Windows roaming AppData directory. + /// \details On Windows, the known-folder API is preferred and APPDATA is a + /// fallback. On other platforms, APPDATA is used only as an explicit test or + /// compatibility fallback because MetaTrader common files are Windows-specific. + inline MetaTraderPathResolutionResult default_roaming_app_data_root_result() { + MetaTraderPathResolutionResult result; +#if defined(_WIN32) + PWSTR raw_path = nullptr; + const HRESULT hr = SHGetKnownFolderPath( + FOLDERID_RoamingAppData, + KF_FLAG_DEFAULT, + nullptr, + &raw_path); + std::unique_ptr known_path(raw_path); + if (SUCCEEDED(hr) && known_path) { + result.path = std::filesystem::path(known_path.get()); + result.resolved = true; + return result; + } + if (FAILED(hr)) { + result.error = make_error_code_from_hresult(hr); + } +#endif + +#if defined(_WIN32) + DWORD required = GetEnvironmentVariableW(L"APPDATA", nullptr, 0); + for (int attempt = 0; attempt < 2 && required > 1; ++attempt) { + std::wstring value(required, L'\0'); + const DWORD written = GetEnvironmentVariableW( + L"APPDATA", + value.data(), + required); + if (written > 0 && written < required) { + value.resize(written); + result.path = std::filesystem::path(value); + result.resolved = true; + result.error.clear(); + return result; + } + required = written; + } + if (!result.error) { + const DWORD error = GetLastError(); + result.error = std::error_code( + static_cast(error ? error : ERROR_ENVVAR_NOT_FOUND), + std::system_category()); + } +#else + const char* appdata = std::getenv("APPDATA"); + if (!appdata || !*appdata) { + result.error = + std::make_error_code(std::errc::no_such_file_or_directory); + return result; + } + result.path = std::filesystem::u8path(appdata); + result.resolved = true; + return result; +#endif + return result; + } + + /// \brief Resolves the Windows roaming AppData directory. + inline std::filesystem::path default_roaming_app_data_root() { + return default_roaming_app_data_root_result().path; + } + + /// \brief Builds `%APPDATA%\MetaQuotes\Terminal` from a roaming AppData root. + inline std::filesystem::path metaquotes_terminal_root_from_roaming( + const std::filesystem::path& roaming_root) { + if (roaming_root.empty()) { + return {}; + } + return roaming_root / "MetaQuotes" / "Terminal"; + } + + /// \brief Returns the default MetaQuotes terminal data root. + inline MetaTraderPathResolutionResult default_metaquotes_terminal_root_result() { + auto result = default_roaming_app_data_root_result(); + if (!result.resolved) { + return result; + } + result.path = metaquotes_terminal_root_from_roaming(result.path); + return result; + } + + /// \brief Returns the default MetaQuotes terminal data root. + inline std::filesystem::path default_metaquotes_terminal_root() { + return default_metaquotes_terminal_root_result().path; + } + + /// \brief Builds a MetaQuotes Common\Files directory from a terminal root. + inline std::filesystem::path common_files_root_from_terminal_root( + const std::filesystem::path& terminal_root) { + if (terminal_root.empty()) { + return {}; + } + return terminal_root / "Common" / "Files"; + } + + /// \brief Returns the default MetaQuotes Common\Files root. + inline MetaTraderPathResolutionResult default_common_files_root_result() { + auto result = default_metaquotes_terminal_root_result(); + if (!result.resolved) { + return result; + } + result.path = common_files_root_from_terminal_root(result.path); + return result; + } + + /// \brief Returns the default MetaQuotes Common\Files root. + inline std::filesystem::path default_common_files_root() { + return default_common_files_root_result().path; + } + + /// \brief Inspects a possible terminal data directory. + /// \details Classification is based on `MQL4` and `MQL5` directory presence. + inline MetaTraderTerminalDirectory inspect_terminal_data_directory( + const std::filesystem::path& data_root, + std::error_code& ec) { + MetaTraderTerminalDirectory result; + result.data_root = data_root; + ec.clear(); + + std::error_code root_ec; + if (!trusted_directory_exists(data_root, root_ec)) { + if (root_ec) { + ec = root_ec; + } + return result; + } + + std::error_code mql_ec; + result.has_mql4 = trusted_directory_exists(data_root / "MQL4", mql_ec); + if (mql_ec) { + ec = mql_ec; + return result; + } + + result.has_mql5 = trusted_directory_exists(data_root / "MQL5", mql_ec); + if (mql_ec) { + ec = mql_ec; + } + return result; + } + + /// \brief Inspects a possible terminal data directory. + inline MetaTraderTerminalDirectory inspect_terminal_data_directory( + const std::filesystem::path& data_root) { + std::error_code ec; + return inspect_terminal_data_directory(data_root, ec); + } + + /// \brief Returns true when a directory looks like a MetaTrader data directory. + inline bool looks_like_terminal_data_directory( + const std::filesystem::path& data_root) { + return inspect_terminal_data_directory(data_root).kind() != + MetaTraderTerminalKind::UNKNOWN; + } + + /// \brief Enumerates known terminal data directories under a MetaQuotes terminal root. + /// \details The `Common` directory is ignored unless it unexpectedly contains + /// MQL runtime directories. Returned entries are sorted by path for stable tests. + inline MetaTraderDiscoveryResult discover_terminal_data_directories_result( + const std::filesystem::path& terminal_root) { + MetaTraderDiscoveryResult result; + if (terminal_root.empty()) { + return result; + } + + std::error_code root_reparse_ec; + if (is_reparse_or_symlink(terminal_root, root_reparse_ec)) { + record_discovery_error( + result, + std::make_error_code(std::errc::operation_not_permitted), + terminal_root); + return result; + } + if (root_reparse_ec) { + record_discovery_error(result, root_reparse_ec, terminal_root); + return result; + } + + std::error_code root_status_ec; + const auto root_status = + std::filesystem::symlink_status(terminal_root, root_status_ec); + if (root_status_ec) { + if (root_status_ec == std::errc::no_such_file_or_directory || + root_status_ec == std::errc::not_a_directory) { + root_status_ec.clear(); + return result; + } + record_discovery_error(result, root_status_ec, terminal_root); + return result; + } + if (!std::filesystem::is_directory(root_status)) { + record_discovery_error( + result, + std::make_error_code(std::errc::not_a_directory), + terminal_root); + return result; + } + + std::error_code ec; + std::filesystem::directory_iterator it(terminal_root, ec); + if (ec) { + if (ec == std::errc::no_such_file_or_directory) { + ec.clear(); + return result; + } + record_discovery_error(result, ec, terminal_root); + return result; + } + + const std::filesystem::directory_iterator end; + while (it != end) { + const auto path = it->path(); + std::error_code reparse_ec; + if (is_reparse_or_symlink(path, reparse_ec)) { + record_discovery_error( + result, + std::make_error_code(std::errc::operation_not_permitted), + path); + it.increment(ec); + if (ec) { + record_discovery_error(result, ec, path); + break; + } + continue; + } + if (reparse_ec) { + record_discovery_error(result, reparse_ec, path); + it.increment(ec); + if (ec) { + record_discovery_error(result, ec, path); + break; + } + continue; + } + + std::error_code type_ec; + if (std::filesystem::is_directory(path, type_ec) && !type_ec) { + std::error_code inspect_ec; + const auto inspected = + inspect_terminal_data_directory(path, inspect_ec); + if (inspect_ec) { + record_discovery_error(result, inspect_ec, path); + it.increment(ec); + if (ec) { + record_discovery_error(result, ec, path); + break; + } + continue; + } + if (inspected.kind() != MetaTraderTerminalKind::UNKNOWN) { + result.terminals.push_back(inspected); + } + } else if (type_ec) { + record_discovery_error(result, type_ec, path); + } + + it.increment(ec); + if (ec) { + record_discovery_error(result, ec, path); + break; + } + } + + std::sort( + result.terminals.begin(), + result.terminals.end(), + [](const MetaTraderTerminalDirectory& lhs, + const MetaTraderTerminalDirectory& rhs) { + return lhs.data_root.u8string() < rhs.data_root.u8string(); + }); + return result; + } + + /// \brief Enumerates known terminal data directories under the default root. + inline MetaTraderDiscoveryResult discover_terminal_data_directories_result() { + const auto root = default_metaquotes_terminal_root_result(); + if (root.resolved) { + return discover_terminal_data_directories_result(root.path); + } + + MetaTraderDiscoveryResult result; + result.complete = false; + result.error = root.error; + result.error_path = root.path; + return result; + } + + /// \brief Enumerates known terminal data directories under a MetaQuotes terminal root. + /// \details Best-effort convenience overload. Use + /// `discover_terminal_data_directories_result()` when partial scans and + /// filesystem errors must be visible to the caller. + inline std::vector discover_terminal_data_directories( + const std::filesystem::path& terminal_root) { + return discover_terminal_data_directories_result(terminal_root).terminals; + } + + /// \brief Enumerates known terminal data directories under the default root. + /// \details Best-effort convenience overload. Use + /// `discover_terminal_data_directories_result()` when partial scans and + /// filesystem errors must be visible to the caller. + inline std::vector discover_terminal_data_directories() { + return discover_terminal_data_directories_result().terminals; + } + + /// \brief Enumerates terminal data directories and reports scan errors. + inline std::vector discover_terminal_data_directories( + const std::filesystem::path& terminal_root, + std::error_code& ec) { + const auto result = discover_terminal_data_directories_result(terminal_root); + ec = result.error; + return result.terminals; + } + + /// \brief Returns existing `MQL4\Files` and `MQL5\Files` directories for one terminal. + inline MetaTraderMqlFilesDirectoriesResult existing_mql_files_directories_result( + const MetaTraderTerminalDirectory& terminal) { + MetaTraderMqlFilesDirectoriesResult result; + std::error_code ec; + if (!trusted_directory_exists(terminal.data_root, ec)) { + if (ec) { + record_mql_files_error(result, ec, terminal.data_root); + } + return result; + } + if (terminal.has_mql4) { + const auto mql_root = terminal.data_root / "MQL4"; + const bool mql_root_exists = trusted_directory_exists(mql_root, ec); + if (ec) { + record_mql_files_error(result, ec, mql_root); + return result; + } + if (mql_root_exists) { + const auto dir = terminal.mql4_files_dir(); + if (trusted_directory_exists(dir, ec)) { + result.files_directories.push_back(dir); + } else if (ec) { + record_mql_files_error(result, ec, dir); + } + } + } + if (terminal.has_mql5) { + const auto mql_root = terminal.data_root / "MQL5"; + const bool mql_root_exists = trusted_directory_exists(mql_root, ec); + if (ec) { + record_mql_files_error(result, ec, mql_root); + return result; + } + if (mql_root_exists) { + const auto dir = terminal.mql5_files_dir(); + if (trusted_directory_exists(dir, ec)) { + result.files_directories.push_back(dir); + } else if (ec) { + record_mql_files_error(result, ec, dir); + } + } + } + return result; + } + + /// \brief Returns existing `MQL4\Files` and `MQL5\Files` directories for one terminal. + /// \details Best-effort convenience overload. Use + /// `existing_mql_files_directories_result()` when partial results and + /// filesystem errors must be visible to the caller. + inline std::vector existing_mql_files_directories( + const MetaTraderTerminalDirectory& terminal) { + return existing_mql_files_directories_result(terminal).files_directories; + } + + /// \brief Returns existing MQL Files directories and reports scan errors. + inline std::vector existing_mql_files_directories( + const MetaTraderTerminalDirectory& terminal, + std::error_code& ec) { + const auto result = existing_mql_files_directories_result(terminal); + ec = result.error; + return result.files_directories; + } + +} // namespace optionx::utils::metatrader + +#endif // OPTIONX_HEADER_UTILS_METATRADER_PATHS_HPP_INCLUDED diff --git a/tests/metatrader_paths_test.cpp b/tests/metatrader_paths_test.cpp new file mode 100644 index 0000000..2e3e192 --- /dev/null +++ b/tests/metatrader_paths_test.cpp @@ -0,0 +1,266 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +class ScopedPathCleanup { +public: + explicit ScopedPathCleanup(std::filesystem::path cleanup_path) + : path(std::move(cleanup_path)) {} + + ~ScopedPathCleanup() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } + + std::filesystem::path path; +}; + +std::filesystem::path make_temp_root() { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + return std::filesystem::temp_directory_path() / + ("optionx_metatrader_paths_test_" + std::to_string(stamp)); +} + +} // namespace + +TEST(MetaTraderPaths, BuildsMetaQuotesRootsFromRoamingRoot) { + namespace mt = optionx::utils::metatrader; + + const auto roaming = std::filesystem::u8path("C:/Users/User/AppData/Roaming"); + const auto terminal_root = mt::metaquotes_terminal_root_from_roaming(roaming); + EXPECT_EQ( + terminal_root.generic_u8string(), + "C:/Users/User/AppData/Roaming/MetaQuotes/Terminal"); + + const auto common_files = mt::common_files_root_from_terminal_root(terminal_root); + EXPECT_EQ( + common_files.generic_u8string(), + "C:/Users/User/AppData/Roaming/MetaQuotes/Terminal/Common/Files"); + + EXPECT_TRUE(mt::metaquotes_terminal_root_from_roaming({}).empty()); + EXPECT_TRUE(mt::common_files_root_from_terminal_root({}).empty()); +} + +TEST(MetaTraderPaths, DiscoversTerminalDataDirectoriesByMqlFolders) { + namespace mt = optionx::utils::metatrader; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + const auto terminal_root = root / "MetaQuotes" / "Terminal"; + + std::filesystem::create_directories(terminal_root / "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" / "MQL4" / "Files"); + std::filesystem::create_directories(terminal_root / "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" / "MQL5" / "Files"); + std::filesystem::create_directories(terminal_root / "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" / "MQL4" / "Files"); + std::filesystem::create_directories(terminal_root / "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" / "MQL5" / "Files"); + std::filesystem::create_directories(terminal_root / "Common" / "Files"); + std::filesystem::create_directories(terminal_root / "not-a-terminal"); + + const auto discovered = mt::discover_terminal_data_directories(terminal_root); + ASSERT_EQ(discovered.size(), 3u); + + EXPECT_EQ(discovered[0].kind(), mt::MetaTraderTerminalKind::MT4); + EXPECT_TRUE(discovered[0].has_mql4); + EXPECT_FALSE(discovered[0].has_mql5); + const auto first_files = mt::existing_mql_files_directories(discovered[0]); + ASSERT_EQ(first_files.size(), 1u); + EXPECT_EQ(first_files[0].filename().u8string(), "Files"); + EXPECT_EQ(first_files[0].parent_path().filename().u8string(), "MQL4"); + + EXPECT_EQ(discovered[1].kind(), mt::MetaTraderTerminalKind::MT5); + EXPECT_FALSE(discovered[1].has_mql4); + EXPECT_TRUE(discovered[1].has_mql5); + + EXPECT_EQ(discovered[2].kind(), mt::MetaTraderTerminalKind::MIXED); + EXPECT_TRUE(discovered[2].has_mql4); + EXPECT_TRUE(discovered[2].has_mql5); + const auto mixed_files = mt::existing_mql_files_directories(discovered[2]); + ASSERT_EQ(mixed_files.size(), 2u); + EXPECT_EQ(mixed_files[0].parent_path().filename().u8string(), "MQL4"); + EXPECT_EQ(mixed_files[1].parent_path().filename().u8string(), "MQL5"); + + std::error_code missing_ec; + EXPECT_TRUE(mt::discover_terminal_data_directories(root / "missing", missing_ec).empty()); + EXPECT_FALSE(missing_ec); +} + +TEST(MetaTraderPaths, InspectsSingleTerminalDirectory) { + namespace mt = optionx::utils::metatrader; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + const auto terminal = root / "terminal"; + + EXPECT_FALSE(mt::looks_like_terminal_data_directory(terminal)); + + std::filesystem::create_directories(terminal / "MQL5"); + const auto inspected = mt::inspect_terminal_data_directory(terminal); + EXPECT_EQ(inspected.data_root, terminal); + EXPECT_EQ(inspected.kind(), mt::MetaTraderTerminalKind::MT5); + EXPECT_TRUE(mt::looks_like_terminal_data_directory(terminal)); + EXPECT_EQ(inspected.mql5_files_dir(), terminal / "MQL5" / "Files"); +} + +TEST(MetaTraderPaths, ReportsDiscoveryErrors) { + namespace mt = optionx::utils::metatrader; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + std::filesystem::create_directories(root); + const auto not_directory = root / "not-a-directory"; + { + std::ofstream out(not_directory, std::ios::binary); + out << "not a directory"; + } + + const auto result = mt::discover_terminal_data_directories_result(not_directory); + EXPECT_TRUE(result.terminals.empty()); + EXPECT_FALSE(result.complete); + EXPECT_TRUE(static_cast(result.error)); + EXPECT_EQ(result.error_path, not_directory); + + std::error_code ec; + EXPECT_TRUE(mt::discover_terminal_data_directories(not_directory, ec).empty()); + EXPECT_TRUE(static_cast(ec)); +} + +TEST(MetaTraderPaths, RejectsSymlinkTerminalAndContinuesDiscovery) { + namespace mt = optionx::utils::metatrader; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + const auto terminal_root = root / "MetaQuotes" / "Terminal"; + const auto outside = root / "outside-terminal"; + const auto symlink_terminal = + terminal_root / "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const auto valid_terminal = + terminal_root / "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + + std::filesystem::create_directories(outside / "MQL5" / "Files"); + std::filesystem::create_directories(valid_terminal / "MQL4" / "Files"); + + std::error_code link_ec; + std::filesystem::create_directory_symlink(outside, symlink_terminal, link_ec); + if (link_ec) { + GTEST_SKIP() << "Directory symlinks are not available: " + << link_ec.message(); + } + + const auto result = mt::discover_terminal_data_directories_result(terminal_root); + EXPECT_FALSE(result.complete); + EXPECT_TRUE(static_cast(result.error)); + EXPECT_EQ(result.error_path, symlink_terminal); + ASSERT_EQ(result.terminals.size(), 1u); + EXPECT_EQ(result.terminals[0].data_root, valid_terminal); + EXPECT_EQ(result.terminals[0].kind(), mt::MetaTraderTerminalKind::MT4); +} + +TEST(MetaTraderPaths, RejectsSymlinkTerminalRoot) { + namespace mt = optionx::utils::metatrader; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + const auto metaquotes = root / "MetaQuotes"; + const auto terminal_root = metaquotes / "Terminal"; + const auto outside_root = root / "outside-terminal-root"; + + std::filesystem::create_directories(metaquotes); + std::filesystem::create_directories( + outside_root / "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" / "MQL5" / "Files"); + + std::error_code link_ec; + std::filesystem::create_directory_symlink( + outside_root, + terminal_root, + link_ec); + if (link_ec) { + GTEST_SKIP() << "Directory symlinks are not available: " + << link_ec.message(); + } + + const auto result = mt::discover_terminal_data_directories_result(terminal_root); + EXPECT_TRUE(result.terminals.empty()); + EXPECT_FALSE(result.complete); + EXPECT_TRUE(static_cast(result.error)); + EXPECT_EQ(result.error_path, terminal_root); +} + +TEST(MetaTraderPaths, RejectsSymlinkMqlParentDirectory) { + namespace mt = optionx::utils::metatrader; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + const auto terminal = root / "terminal"; + const auto outside_mql = root / "outside-mql"; + + std::filesystem::create_directories(terminal); + std::filesystem::create_directories(outside_mql / "Files"); + + std::error_code link_ec; + std::filesystem::create_directory_symlink( + outside_mql, + terminal / "MQL4", + link_ec); + if (link_ec) { + GTEST_SKIP() << "Directory symlinks are not available: " + << link_ec.message(); + } + + mt::MetaTraderTerminalDirectory inspected; + inspected.data_root = terminal; + inspected.has_mql4 = true; + + const auto result = mt::existing_mql_files_directories_result(inspected); + EXPECT_TRUE(result.files_directories.empty()); + EXPECT_FALSE(result.complete); + EXPECT_TRUE(static_cast(result.error)); + EXPECT_EQ(result.error_path, terminal / "MQL4"); +} + +TEST(MetaTraderPaths, ReportsMqlFilesDirectoryErrors) { + namespace mt = optionx::utils::metatrader; + + const auto root = make_temp_root(); + ScopedPathCleanup cleanup(root); + const auto terminal = root / "terminal"; + const auto outside_files = root / "outside-files"; + + std::filesystem::create_directories(terminal / "MQL4"); + std::filesystem::create_directories(outside_files); + + std::error_code link_ec; + std::filesystem::create_directory_symlink( + outside_files, + terminal / "MQL4" / "Files", + link_ec); + if (link_ec) { + GTEST_SKIP() << "Directory symlinks are not available: " + << link_ec.message(); + } + + const auto inspected = mt::inspect_terminal_data_directory(terminal); + ASSERT_TRUE(inspected.has_mql4); + + const auto result = mt::existing_mql_files_directories_result(inspected); + EXPECT_TRUE(result.files_directories.empty()); + EXPECT_FALSE(result.complete); + EXPECT_TRUE(static_cast(result.error)); + EXPECT_EQ(result.error_path, terminal / "MQL4" / "Files"); + + std::error_code ec; + EXPECT_TRUE(mt::existing_mql_files_directories(inspected, ec).empty()); + EXPECT_TRUE(static_cast(ec)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}