From 3426c9ff21ae44bd9ab8e8a995e136ca7db2591f Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Fri, 22 Aug 2025 21:49:08 -0500 Subject: [PATCH 1/6] App: Migrate directory handling to helper class Use std::filesystem wherever possible, replacing most uses of std::string when the object is actually a path. This is the first stage of refactoring, and does not make any changes to Application that affect client code. Access to the new directory-handling class is implemented, but is unused by any external code. --- src/App/Application.cpp | 59 +-- src/App/Application.h | 7 + src/App/ApplicationDirectories.cpp | 581 +++++++++++++++++++++++++++++ src/App/ApplicationDirectories.h | 179 +++++++++ src/App/CMakeLists.txt | 2 + src/App/PreCompiled.h | 9 + 6 files changed, 796 insertions(+), 41 deletions(-) create mode 100644 src/App/ApplicationDirectories.cpp create mode 100644 src/App/ApplicationDirectories.h diff --git a/src/App/Application.cpp b/src/App/Application.cpp index b76a8a3b11..760f52de0e 100644 --- a/src/App/Application.cpp +++ b/src/App/Application.cpp @@ -102,6 +102,7 @@ #include "Annotation.h" #include "Application.h" +#include "ApplicationDirectories.h" #include "CleanupProcess.h" #include "ComplexGeoData.h" #include "Services.h" @@ -185,6 +186,7 @@ Base::ConsoleObserverStd *Application::_pConsoleObserverStd = nullptr; Base::ConsoleObserverFile *Application::_pConsoleObserverFile = nullptr; AppExport std::map Application::mConfig; +std::shared_ptr Application::_appDirs; //************************************************************************** @@ -1113,7 +1115,7 @@ int64_t Application::applicationPid() std::string Application::getHomePath() { - return mConfig["AppHomePath"]; + return Base::FileInfo::pathToString(Application::directories()->getHomePath()) + PATHSEP; } std::string Application::getExecutableName() @@ -1140,78 +1142,53 @@ bool Application::isDevelopmentVersion() return suffix == "dev"; } +std::shared_ptr Application::directories() { + return _appDirs; +} + std::string Application::getTempPath() { - return mConfig["AppTempPath"]; + return Base::FileInfo::pathToString(_appDirs->getTempPath()) + PATHSEP; } std::string Application::getTempFileName(const char* FileName) { - return Base::FileInfo::getTempFileName(FileName, getTempPath().c_str()); + return Base::FileInfo::pathToString(_appDirs->getTempFileName(FileName)) + PATHSEP; } std::string Application::getUserCachePath() { - return mConfig["UserCachePath"]; + return Base::FileInfo::pathToString(_appDirs->getUserCachePath()) + PATHSEP; } std::string Application::getUserConfigPath() { - return mConfig["UserConfigPath"]; + return Base::FileInfo::pathToString(_appDirs->getUserConfigPath()) + PATHSEP; } std::string Application::getUserAppDataDir() { - return mConfig["UserAppData"]; + return Base::FileInfo::pathToString(_appDirs->getUserAppDataDir()) + PATHSEP; } std::string Application::getUserMacroDir() { - return mConfig["UserMacroPath"]; + return Base::FileInfo::pathToString(_appDirs->getUserMacroDir()) + PATHSEP; } std::string Application::getResourceDir() { -#ifdef RESOURCEDIR - // #6892: Conda may inject null characters => remove them using c_str() - std::string path = std::string(RESOURCEDIR).c_str(); - path += PATHSEP; - const QDir dir(QString::fromStdString(path)); - if (dir.isAbsolute()) - return path; - return mConfig["AppHomePath"] + path; -#else - return mConfig["AppHomePath"]; -#endif + return Base::FileInfo::pathToString(_appDirs->getResourceDir()) + PATHSEP; } std::string Application::getLibraryDir() { -#ifdef LIBRARYDIR - // #6892: Conda may inject null characters => remove them using c_str() - std::string path = std::string(LIBRARYDIR).c_str(); - const QDir dir(QString::fromStdString(path)); - if (dir.isAbsolute()) - return path; - return mConfig["AppHomePath"] + path; -#else - return mConfig["AppHomePath"] + "lib"; -#endif + return Base::FileInfo::pathToString(_appDirs->getLibraryDir()) + PATHSEP; } std::string Application::getHelpDir() { -#ifdef DOCDIR - // #6892: Conda may inject null characters => remove them using c_str() - std::string path = std::string(DOCDIR).c_str(); - path += PATHSEP; - const QDir dir(QString::fromStdString(path)); - if (dir.isAbsolute()) - return path; - return mConfig["AppHomePath"] + path; -#else - return mConfig["DocPath"]; -#endif + return Base::FileInfo::pathToString(_appDirs->getHelpDir()) + PATHSEP; } int Application::checkLinkDepth(int depth, MessageOption option) @@ -2535,7 +2512,7 @@ void processProgramOptions(const boost::program_options::variables_map& vm, std: void Application::initConfig(int argc, char ** argv) { // find the home path.... - mConfig["AppHomePath"] = FindHomePath(argv[0]); + mConfig["AppHomePath"] = ApplicationDirectories::findHomePath(argv[0]).string(); // Version of the application extracted from SubWCRef into src/Build/Version.h // We only set these keys if not yet defined. Therefore it suffices to search @@ -2593,7 +2570,7 @@ void Application::initConfig(int argc, char ** argv) } // extract home paths - ExtractUserPath(); + _appDirs = std::make_shared(mConfig); if (vm.contains("safe-mode")) { SafeMode::StartSafeMode(); diff --git a/src/App/Application.h b/src/App/Application.h index 2a2d9e8f76..6abc36aa4a 100644 --- a/src/App/Application.h +++ b/src/App/Application.h @@ -53,6 +53,7 @@ namespace App class Document; class DocumentObject; +class ApplicationDirectories; class ApplicationObserver; class Property; class AutoTransaction; @@ -423,6 +424,10 @@ public: static std::string getExecutableName(); static std::string getNameWithVersion(); static bool isDevelopmentVersion(); + + /// Access to the various directories for the program a replacement for the get*Path methods below + static std::shared_ptr directories(); + /*! Returns the temporary directory. By default, this is set to the system's temporary directory but can be customized by the user. @@ -626,6 +631,8 @@ private: static void SaveEnv(const char *); /// startup configuration container static std::map mConfig; + /// Management of and access to applications directories + static std::shared_ptr _appDirs; static int _argc; static char ** _argv; //@} diff --git a/src/App/ApplicationDirectories.cpp b/src/App/ApplicationDirectories.cpp new file mode 100644 index 0000000000..e66765e31f --- /dev/null +++ b/src/App/ApplicationDirectories.cpp @@ -0,0 +1,581 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/*************************************************************************************************** + * * + * Copyright (c) 2002 Jürgen Riegel * + * Copyright (c) 2025 The FreeCAD project association AISBL * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it under the terms of the * + * GNU Lesser General Public License as published by the Free Software Foundation, either * + * version 2.1 of the License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public License along with FreeCAD. * + * If not, see . * + * * + **************************************************************************************************/ + +#include "PreCompiled.h" +#ifndef _PreComp_ +#include +#include +#include +#include +#include +#include +#endif + +#include "ApplicationDirectories.h" + +#if defined(FC_OS_LINUX) || defined(FC_OS_MACOSX) || defined(FC_OS_BSD) +#include +#endif + +#include +#include + +#include +#include + + +using namespace App; +namespace fs = std::filesystem; + +fs::path qstringToPath(const QString& path) +{ +#if defined(FC_OS_WIN32) + return {path.toStdWString()}; +#else + return {path.toStdString()}; +#endif +} + +ApplicationDirectories::ApplicationDirectories(std::map &config) +{ + try { + int major = std::stoi(config.at("BuildVersionMajor")); + int minor = std::stoi(config.at("BuildVersionMinor")); + _currentVersion = std::make_tuple(major, minor); + } catch (const std::exception& e) { + throw Base::RuntimeError("Failed to parse version from config: " + std::string(e.what())); + } + configurePaths(config); + configureResourceDirectory(config); + configureLibraryDirectory(config); + configureHelpDirectory(config); +} + +const fs::path& ApplicationDirectories::getHomePath() const +{ + return this->_home; +} + +const fs::path& ApplicationDirectories::getTempPath() const { + return this->_temp; +} + +fs::path ApplicationDirectories::getTempFileName(const std::string & filename) const { + return Base::FileInfo::getTempFileName(filename.c_str(), getTempPath().string().c_str()); +} + +const fs::path& ApplicationDirectories::getUserCachePath() const +{ + return this->_userCache; +} + +const fs::path& ApplicationDirectories::getUserAppDataDir() const +{ + return this->_userAppData; +} + +const fs::path& ApplicationDirectories::getUserMacroDir() const +{ + return this->_userMacro; +} + +const fs::path& ApplicationDirectories::getResourceDir() const +{ + return this->_resource; +} + +const fs::path& ApplicationDirectories::getHelpDir() const +{ + return this->_help; +} + +const fs::path& ApplicationDirectories::getUserConfigPath() const { + return this->_userConfig; +} + +const fs::path& ApplicationDirectories::getLibraryDir() const { + return this->_library; +} + + +/*! + * \brief findPath + * Returns the path where to store application files to. + * If \a customHome is not empty, it will be used, otherwise a path starting from \a stdHome will be used. + */ +fs::path ApplicationDirectories::findPath(const fs::path& stdHome, const fs::path& customHome, + const std::vector& subdirs, bool create) { + fs::path appData = customHome; + if (appData.empty()) { + appData = stdHome; + } + + // If a custom user home path is given, then don't modify it + if (customHome.empty()) { + for (const auto& it : subdirs) { + appData = appData / it; + } + } + + // To write to our data path, we must create some directories, first. + if (create && !fs::exists(appData) && !Py_IsInitialized()) { + try { + fs::create_directories(appData); + } catch (const fs::filesystem_error& e) { + throw Base::FileSystemError("Could not create directories. Failed with: " + e.code().message()); + } + } + + return appData; +} + +void ApplicationDirectories::configurePaths(std::map& mConfig) +{ + bool keepDeprecatedPaths = mConfig.contains("KeepDeprecatedPaths"); + + // std paths + _home = fs::path(mConfig.at("AppHomePath")); + mConfig["BinPath"] = mConfig.at("AppHomePath") + "bin" + PATHSEP; + mConfig["DocPath"] = mConfig.at("AppHomePath") + "doc" + PATHSEP; + + // this is to support a portable version of FreeCAD + auto [customHome, customData, customTemp] = getCustomPaths(); + + // get the system standard paths + auto [configHome, dataHome, cacheHome, tempPath] = getStandardPaths(); + + // User home path + // + fs::path homePath = findUserHomePath(customHome); + mConfig["UserHomePath"] = Base::FileInfo::pathToString(homePath); + + // the old path name to save config and data files + std::vector subdirs; + if (keepDeprecatedPaths) { + configHome = homePath; + dataHome = homePath; + cacheHome = homePath; + getOldDataLocation(mConfig, subdirs); + } + else { + getSubDirectories(mConfig, subdirs); + } + + // User data path + // + fs::path data = findPath(dataHome, customData, subdirs, true); + _userAppData = data; + mConfig["UserAppData"] = Base::FileInfo::pathToString(_userAppData) + PATHSEP; + + + // User config path + // + fs::path config = findPath(configHome, customHome, subdirs, true); + _userConfig = config; + mConfig["UserConfigPath"] = Base::FileInfo::pathToString(_userConfig) + PATHSEP; + + + // User cache path + // + std::vector cachedirs = subdirs; + cachedirs.emplace_back("Cache"); + fs::path cache = findPath(cacheHome, customTemp, cachedirs, true); + _userCache = cache; + mConfig["UserCachePath"] = Base::FileInfo::pathToString(cache) + PATHSEP; + + + // Set application temporary directory + // + std::vector empty; + fs::path tmp = findPath(tempPath, customTemp, empty, true); + _temp = tmp; + mConfig["AppTempPath"] = Base::FileInfo::pathToString(tmp) + PATHSEP; + + + // Set the default macro directory + // + std::vector macrodirs = std::move(subdirs); // Last use in this method, just move + macrodirs.emplace_back("Macro"); + fs::path macro = findPath(dataHome, customData, macrodirs, true); + _userMacro = macro; + mConfig["UserMacroPath"] = Base::FileInfo::pathToString(macro) + PATHSEP; +} + +void ApplicationDirectories::configureResourceDirectory(const std::map& mConfig) { +#ifdef RESOURCEDIR + // #6892: Conda may inject null characters => remove them using c_str() + fs::path path {std::string(RESOURCEDIR).c_str()}; + if (path.is_absolute()) { + _resource = path; + } else { + _resource = Base::FileInfo::stringToPath(mConfig.at("AppHomePath")) / path; + } +#else + _resource = fs::path(mConfig.at("AppHomePath")); +#endif +} + +void ApplicationDirectories::configureLibraryDirectory(const std::map& mConfig) { +#ifdef LIBRARYDIR + // #6892: Conda may inject null characters => remove them using c_str() + fs::path path {std::string(LIBRARYDIR).c_str()}; + if (path.is_absolute()) { + _library = path; + } else { + _library = Base::FileInfo::stringToPath(mConfig.at("AppHomePath")) / path; + } +#else + _library = Base::FileInfo::stringToPath(mConfig.at("AppHomePath")) / "lib"; +#endif +} + + +void ApplicationDirectories::configureHelpDirectory(const std::map& mConfig) +{ +#ifdef DOCDIR + // #6892: Conda may inject null characters => remove them using c_str() + fs::path path {std::string(DOCDIR).c_str()}; + if (path.is_absolute()) { + _help = path; + } else { + _help = Base::FileInfo::stringToPath(mConfig.at("AppHomePath")) / path; + } +#else + _help = Base::FileInfo::stringToPath(mConfig.at("DocPath")); +#endif +} + + +fs::path ApplicationDirectories::getUserHome() +{ + fs::path path; +#if defined(FC_OS_LINUX) || defined(FC_OS_CYGWIN) || defined(FC_OS_BSD) || defined(FC_OS_MACOSX) + // Default paths for the user-specific stuff + struct passwd pwd {}; + struct passwd *result {}; + constexpr std::size_t bufferLength = 16384; + std::vector buffer(bufferLength); + const int error = getpwuid_r(getuid(), &pwd, buffer.data(), buffer.size(), &result); + if (!result || error != 0) { + throw Base::RuntimeError("Getting HOME path from system failed!"); + } + path = Base::FileInfo::stringToPath(result->pw_dir); +#else + path = Base::FileInfo::stringToPath(QStandardPaths::writableLocation(QStandardPaths::HomeLocation).toStdString()); +#endif + return path; +} + +#if defined(FC_OS_WIN32) // This is ONLY used on Windows now, so don't even compile it elsewhere +#include +#include "ShlObj.h" +QString ApplicationDirectories::getOldGenericDataLocation() +{ + WCHAR szPath[MAX_PATH]; + std::wstring_convert> converter; + if (SUCCEEDED(SHGetFolderPathW(NULL, CSIDL_APPDATA, NULL, 0, szPath))) { + return QString::fromStdString(converter.to_bytes(szPath)); + } + return {}; +} +#endif + +void ApplicationDirectories::getSubDirectories(const std::map& mConfig, + std::vector& appData) +{ + // If 'AppDataSkipVendor' is defined, the value of 'ExeVendor' must not be part of + // the path. + if (!mConfig.contains("AppDataSkipVendor") && mConfig.contains("ExeVendor")) { + appData.push_back(mConfig.at("ExeVendor")); + } + appData.push_back(mConfig.at("ExeName")); +} + +void ApplicationDirectories::getOldDataLocation(const std::map& mConfig, + std::vector& appData) +{ + // The name of the directory where the parameters are stored should be the name of + // the application (for branding reasons). +#if defined(FC_OS_LINUX) || defined(FC_OS_CYGWIN) || defined(FC_OS_BSD) + // If 'AppDataSkipVendor' is defined, the value of 'ExeVendor' must not be part of + // the path. + if (!mConfig.contains("AppDataSkipVendor")) { + appData.push_back(std::string(".") + mConfig.at("ExeVendor")); + appData.push_back(mConfig.at("ExeName")); + } else { + appData.push_back(std::string(".") + mConfig.at("ExeName")); + } + +#elif defined(FC_OS_MACOSX) || defined(FC_OS_WIN32) + getSubDirectories(mConfig, appData); +#endif +} + +fs::path ApplicationDirectories::findUserHomePath(const fs::path& userHome) +{ + return userHome.empty() ? getUserHome() : userHome; +} + +std::tuple ApplicationDirectories::getCustomPaths() +{ + const QProcessEnvironment env(QProcessEnvironment::systemEnvironment()); + QString userHome = env.value(QStringLiteral("FREECAD_USER_HOME")); + QString userData = env.value(QStringLiteral("FREECAD_USER_DATA")); + QString userTemp = env.value(QStringLiteral("FREECAD_USER_TEMP")); + + auto toNativePath = [](QString& path) { + if (!path.isEmpty()) { + if (const QDir dir(path); dir.exists()) { + path = QDir::toNativeSeparators(dir.canonicalPath()); + } + else { + path.clear(); + } + } + }; + + // verify env. variables + toNativePath(userHome); + toNativePath(userData); + toNativePath(userTemp); + + // if FREECAD_USER_HOME is set but not FREECAD_USER_DATA + if (!userHome.isEmpty() && userData.isEmpty()) { + userData = userHome; + } + + // if FREECAD_USER_HOME is set but not FREECAD_USER_TEMP + if (!userHome.isEmpty() && userTemp.isEmpty()) { + const QDir dir(userHome); + dir.mkdir(QStringLiteral("temp")); + const QFileInfo fi(dir, QStringLiteral("temp")); + userTemp = fi.absoluteFilePath(); + } + + return {qstringToPath(userHome), + qstringToPath(userData), + qstringToPath(userTemp)}; +} + +std::tuple ApplicationDirectories::getStandardPaths() +{ + QString configHome = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation); + QString dataHome = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + QString cacheHome = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation); + QString tempPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + + // Keep the old behaviour +#if defined(FC_OS_WIN32) + configHome = getOldGenericDataLocation(); + dataHome = configHome; + + // On systems with non-7-bit-ASCII application data directories, + // GetTempPathW will return a path in DOS format. This path will be + // accepted by boost's file_lock class. + // Since boost 1.76, there is now a version that accepts a wide string. +#if (BOOST_VERSION < 107600) + tempPath = QString::fromStdString(Base::FileInfo::getTempPath()); + cacheHome = tempPath; +#endif +#endif + + return {qstringToPath(configHome), + qstringToPath(dataHome), + qstringToPath(cacheHome), + qstringToPath(tempPath)}; +} + +// TODO: Consider using this for all UNIX-like OSes +#if defined(__OpenBSD__) +#include +#include +#include +#include + +fs::path ApplicationDirectories::findHomePath(const char* sCall) +{ + // We have three ways to start this application either use one of the two executables or + // import the FreeCAD.so module from a running Python session. In the latter case the + // Python interpreter is already initialized. + std::string absPath; + std::string homePath; + if (Py_IsInitialized()) { + // Note: `realpath` is known to cause a buffer overflow because it + // expands the given path to an absolute path of unknown length. + // Even setting PATH_MAX does not necessarily solve the problem + // for sure, but the risk of overflow is rather small. + char resolved[PATH_MAX]; + char* path = realpath(sCall, resolved); + if (path) + absPath = path; + } + else { + int argc = 1; + QCoreApplication app(argc, (char**)(&sCall)); + absPath = QCoreApplication::applicationFilePath().toStdString(); + } + + // should be an absolute path now + std::string::size_type pos = absPath.find_last_of("/"); + homePath.assign(absPath,0,pos); + pos = homePath.find_last_of("/"); + homePath.assign(homePath,0,pos+1); + + return Base::FileInfo::stringToPath(homePath); +} + +#elif defined (FC_OS_LINUX) || defined(FC_OS_CYGWIN) || defined(FC_OS_BSD) +#include +#include +#include + +fs::path ApplicationDirectories::findHomePath(const char* sCall) +{ + // We have three ways to start this application either use one of the two executables or + // import the FreeCAD.so module from a running Python session. In the latter case the + // Python interpreter is already initialized. + std::string absPath; + std::string homePath; + if (Py_IsInitialized()) { + // Note: `realpath` is known to cause a buffer overflow because it + // expands the given path to an absolute path of unknown length. + // Even setting PATH_MAX does not necessarily solve the problem + // for sure, but the risk of overflow is rather small. + char resolved[PATH_MAX]; + char* path = realpath(sCall, resolved); + if (path) + absPath = path; + } + else { + // Find the path of the executable. Theoretically, there could occur a + // race condition when using readlink, but we only use this method to + // get the absolute path of the executable to compute the actual home + // path. In the worst case we simply get q wrong path, and FreeCAD is not + // able to load its modules. + char resolved[PATH_MAX]; +#if defined(FC_OS_BSD) + int mib[4]; + mib[0] = CTL_KERN; + mib[1] = KERN_PROC; + mib[2] = KERN_PROC_PATHNAME; + mib[3] = -1; + size_t cb = sizeof(resolved); + sysctl(mib, 4, resolved, &cb, NULL, 0); + int nchars = strlen(resolved); +#else + int nchars = readlink("/proc/self/exe", resolved, PATH_MAX); +#endif + if (nchars < 0 || nchars >= PATH_MAX) + throw Base::FileSystemError("Cannot determine the absolute path of the executable"); + resolved[nchars] = '\0'; // enforce null termination + absPath = resolved; + } + + // should be an absolute path now + std::string::size_type pos = absPath.find_last_of("/"); + homePath.assign(absPath,0,pos); + pos = homePath.find_last_of("/"); + homePath.assign(homePath,0,pos+1); + + return Base::FileInfo::stringToPath(homePath); +} + +#elif defined(FC_OS_MACOSX) +#include +#include +#include +#include + +fs::path ApplicationDirectories::findHomePath(const char* sCall) +{ + // If Python is initialized at this point, then we're being run from + // MainPy.cpp, which hopefully rewrote argv[0] to point at the + // FreeCAD shared library. + if (!Py_IsInitialized()) { + uint32_t sz = 0; + + // function only returns "sz" if the first arg is too small to hold value + _NSGetExecutablePath(nullptr, &sz); + + if (const auto buf = new char[++sz]; _NSGetExecutablePath(buf, &sz) == 0) { + std::array resolved{}; + const char* path = realpath(buf, resolved.data()); + delete [] buf; + + if (path) { + const std::string Call(resolved.data()); + std::string TempHomePath; + std::string::size_type pos = Call.find_last_of(fs::path::preferred_separator); + TempHomePath.assign(Call,0,pos); + pos = TempHomePath.find_last_of(fs::path::preferred_separator); + TempHomePath.assign(TempHomePath,0,pos+1); + return Base::FileInfo::stringToPath(TempHomePath); + } + } else { + delete [] buf; + } + } + + return Base::FileInfo::stringToPath(sCall); +} + +#elif defined (FC_OS_WIN32) +fs::path ApplicationDirectories::findHomePath(const char* sCall) +{ + // We have several ways to start this application: + // * use one of the two executables + // * import the FreeCAD.pyd module from a running Python session. In this case the + // Python interpreter is already initialized. + // * use a custom dll that links FreeCAD core dlls and that is loaded by its host application + // In this case the calling name should be set to FreeCADBase.dll or FreeCADApp.dll in order + // to locate the correct home directory + wchar_t szFileName [MAX_PATH]; + QString dll(QString::fromUtf8(sCall)); + if (Py_IsInitialized() || dll.endsWith(QLatin1String(".dll"))) { + GetModuleFileNameW(GetModuleHandleA(sCall),szFileName, MAX_PATH-1); + } + else { + GetModuleFileNameW(0, szFileName, MAX_PATH-1); + } + + std::wstring Call(szFileName), homePath; + std::wstring::size_type pos = Call.find_last_of(fs::path::preferred_separator); + homePath.assign(Call,0,pos); + pos = homePath.find_last_of(fs::path::preferred_separator); + homePath.assign(homePath,0,pos+1); + + // fixes #0001638 to avoid loading DLLs from Windows' system directories before FreeCAD's bin folder + std::wstring binPath = homePath; + binPath += L"bin"; + SetDllDirectoryW(binPath.c_str()); + + // https://stackoverflow.com/questions/5625884/conversion-of-stdwstring-to-qstring-throws-linker-error +#ifdef _MSC_VER + QString str = QString::fromUtf16(reinterpret_cast(homePath.c_str())); +#else + QString str = QString::fromStdWString(homePath); +#endif + return qstringToPath(str); +} + +#else +# error "std::string ApplicationDirectories::FindHomePath(const char*) not implemented" +#endif diff --git a/src/App/ApplicationDirectories.h b/src/App/ApplicationDirectories.h new file mode 100644 index 0000000000..a9e91aac8c --- /dev/null +++ b/src/App/ApplicationDirectories.h @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/*************************************************************************************************** + * * + * Copyright (c) 2002 Jürgen Riegel * + * Copyright (c) 2025 The FreeCAD project association AISBL * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it under the terms of the * + * GNU Lesser General Public License as published by the Free Software Foundation, either * + * version 2.1 of the License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public License along with FreeCAD. * + * If not, see . * + * * + **************************************************************************************************/ + +#ifndef SRC_APP_APPLICATIONDIRECTORIES_H_ +#define SRC_APP_APPLICATIONDIRECTORIES_H_ + +#include "FCGlobal.h" + +#include +#include +#include +#include + +namespace App { + + /// A helper class to handle application-wide directory management on behalf of the main + /// App::Application class + class AppExport ApplicationDirectories { + + public: + + /// Constructor + /// \param currentVersion A tuple {major, minor} used to create/manage the versioned + /// directories, if any + explicit ApplicationDirectories(std::map &config); + + /// Given argv[0], figure out the home path + static std::filesystem::path findHomePath(const char* sCall); + + /// "Home" here is the parent directory of the actual executable file being run. It is + /// exposed here primarily for historical compatibility reasons, and new code should almost + /// certainly NOT use this path for anything. See alternatives in the + /// `App::ApplicationDirectories` class. + const std::filesystem::path& getHomePath() const; + + /// Temp path is the location of all temporary files: it is not guaranteed to preserve + /// information between runs of the program, but *is* guaranteed to exist for the duration + /// of a single program execution (that is, files are not deleted from it *during* the run). + const std::filesystem::path& getTempPath() const; + + /// Get a file in the temp directory. WARNING: NOT THREAD-SAFE! Currently just forwards to + /// the FileInfo class. TODO: Rewrite to be thread safe + std::filesystem::path getTempFileName(const std::string & filename = "") const; + + /// The user cache path can be used to store files that the program *prefers* not be deleted + /// between runs, but that will be recreated or otherwise handled if they do not exist due + /// to the cache being cleared. There is no guarantee that the files will exist from + /// run-to-run, but an effort is made to preserve them (unlike the temp directory, which + /// should never be used to save data between runs). + const std::filesystem::path& getUserCachePath() const; + + /// The primary directory used to store per-user application data. This is the parent + /// directory of all installed addons, per-user configuration files, etc. Developers looking + /// for a place to put per-user data should begin here. Common subdirectories include "Mod", + /// "Macros", "Materials" and many others that don't begin with the letter "M". This is + /// typically a versioned directory, though users may choose to use a single path for + /// multiple versions of the software. + const std::filesystem::path& getUserAppDataDir() const; + + /// Historically, a single directory was used to store user-created (or user-installed) + /// macro files. This is the path to that directory. Note that going forward it should *not* + /// be assumed that all installed macros are located in this directory. This is typically a + /// versioned directory, though users may choose to use a single path for multiple versions + /// of the software. + const std::filesystem::path& getUserMacroDir() const; + + /// The "resource" directory should be used to store non-ephemeral resources such as icons, + /// templates, hardware setup, etc. -- items that should be preserved from run-to-run of the + /// program, and between versions. This is *not* a versioned directory, and multiple + /// versions of the software may access the same data. + const std::filesystem::path& getResourceDir() const; + + /// Nominally, this is the directory where "help" files are stored, though for historical + /// reasons several other informational files are stored here as well. It should only be + /// used for user-facing informational files. + const std::filesystem::path& getHelpDir() const; + + /// The root path of user config files `user.cfg` and `system.cfg`. + const std::filesystem::path& getUserConfigPath() const; + + /// The directory of all extension modules. Added to `sys.path` during Python + /// initialization. + const std::filesystem::path& getLibraryDir() const; + + /// Get the user's home directory + static std::filesystem::path getUserHome(); + +#ifdef FC_OS_WIN32 + /// On Windows, gets the location of the user's "AppData" directory. Invalid on other OSes. + QString getOldGenericDataLocation(); +#endif + /// Adds subdirectory information to the appData vector for use in constructing full paths to config files, etc. + static void getSubDirectories(const std::map& mConfig, + std::vector& appData); + /// To a given path it adds the subdirectories where to store application-specific files. + /// On Linux or BSD a hidden directory (i.e. starting with a dot) is added. + static void getOldDataLocation(const std::map& mConfig, + std::vector& appData); + /// If the passed path name is not empty, it will be returned, otherwise the user home path of the system will + /// be returned. + static std::filesystem::path findUserHomePath(const std::filesystem::path& userHome); + + bool isVersionedPath(const std::filesystem::path &startingPath) const; + std::string mostRecentAvailableConfigVersion(const std::filesystem::path &startingPath) const; + std::filesystem::path mostRecentConfigFromBase(const std::filesystem::path &startingPath) const; + + static void migrateConfig(const std::filesystem::path &oldPath, const std::filesystem::path &newPath); + + + protected: + static std::filesystem::path findPath( + const std::filesystem::path& stdHome, + const std::filesystem::path& customHome, + const std::vector& paths, + bool create); + + void configurePaths(std::map &config); + void configureResourceDirectory(const std::map& mConfig); + void configureLibraryDirectory(const std::map& mConfig); + void configureHelpDirectory(const std::map& mConfig); + + /*! + * \brief getCustomPaths + * Returns a tuple of path names where to store config, data, and temp. files. + * The method therefore reads the environment variables: + * \list + * \li FREECAD_USER_HOME + * \li FREECAD_USER_DATA + * \li FREECAD_USER_TEMP + * \endlist + */ + static std::tuple getCustomPaths(); + + /*! + * \brief getStandardPaths + * Returns a tuple of XDG-compliant standard paths names where to store config, data and cached files. + * The method therefore reads the environment variables: + * \list + * \li XDG_CONFIG_HOME + * \li XDG_DATA_HOME + * \li XDG_CACHE_HOME + * \endlist + */ + std::tuple getStandardPaths(); + + private: + std::tuple _currentVersion; + std::filesystem::path _home; + std::filesystem::path _temp; + std::filesystem::path _userCache; + std::filesystem::path _userConfig; + std::filesystem::path _userAppData; + std::filesystem::path _userMacro; + std::filesystem::path _resource; + std::filesystem::path _library; + std::filesystem::path _help; + }; + +} // App + +#endif //SRC_APP_APPLICATIONDIRECTORIES_H_ diff --git a/src/App/CMakeLists.txt b/src/App/CMakeLists.txt index f274bb674b..cf2f2415bb 100644 --- a/src/App/CMakeLists.txt +++ b/src/App/CMakeLists.txt @@ -273,6 +273,7 @@ SET(FreeCADApp_CPP_SRCS ${Document_CPP_SRCS} ${Properties_CPP_SRCS} Application.cpp + ApplicationDirectories.cpp ApplicationPy.cpp AutoTransaction.cpp Branding.cpp @@ -303,6 +304,7 @@ SET(FreeCADApp_HPP_SRCS ${Document_HPP_SRCS} ${Properties_HPP_SRCS} Application.h + ApplicationDirectories.h AutoTransaction.h Branding.h CleanupProcess.h diff --git a/src/App/PreCompiled.h b/src/App/PreCompiled.h index 982e2a8125..b531b93ef9 100644 --- a/src/App/PreCompiled.h +++ b/src/App/PreCompiled.h @@ -70,6 +70,9 @@ // STL #include #include +#if defined(FC_OS_WIN32) +#include +#endif #include #include #include @@ -101,6 +104,12 @@ #include +// Qt -- only QtCore +#include +#include +#include +#include + #endif //_PreComp_ #endif // APP_PRECOMPILED_H From bce7ff87088f92d8faded18035757d76be6f4d10 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Fri, 22 Aug 2025 22:08:16 -0500 Subject: [PATCH 2/6] App: Add directory versioning for data and config At the application level, support the existence of subdirectories inside the original config and data paths for a specific version of the software. For a new installation, create them, but for an existing installation use the most recent available version, even if it's not the current one (and even if it's not versioned at all). Any migration must be done at the GUI level due to the amount of data that is potentially being copied during that process. --- src/App/ApplicationDirectories.cpp | 159 +++++- src/App/ApplicationDirectories.h | 79 ++- tests/src/App/ApplicationDirectories.cpp | 682 +++++++++++++++++++++++ tests/src/App/CMakeLists.txt | 1 + 4 files changed, 902 insertions(+), 19 deletions(-) create mode 100644 tests/src/App/ApplicationDirectories.cpp diff --git a/src/App/ApplicationDirectories.cpp b/src/App/ApplicationDirectories.cpp index e66765e31f..c3c72083b7 100644 --- a/src/App/ApplicationDirectories.cpp +++ b/src/App/ApplicationDirectories.cpp @@ -119,7 +119,8 @@ const fs::path& ApplicationDirectories::getLibraryDir() const { /*! * \brief findPath * Returns the path where to store application files to. - * If \a customHome is not empty, it will be used, otherwise a path starting from \a stdHome will be used. + * If \a customHome is not empty, it will be used, otherwise a path starting from \a stdHome will be + * used. */ fs::path ApplicationDirectories::findPath(const fs::path& stdHome, const fs::path& customHome, const std::vector& subdirs, bool create) { @@ -147,6 +148,26 @@ fs::path ApplicationDirectories::findPath(const fs::path& stdHome, const fs::pat return appData; } +void ApplicationDirectories::appendVersionIfPossible(const fs::path& basePath, std::vector &subdirs) const +{ + fs::path pathToCheck = basePath; + for (const auto& it : subdirs) { + pathToCheck = pathToCheck / it; + } + if (isVersionedPath(pathToCheck)) { + return; // Bail out if it's already versioned + } + if (fs::exists(pathToCheck)) { + std::string version = mostRecentAvailableConfigVersion(pathToCheck); + if (!version.empty()) { + subdirs.emplace_back(std::move(version)); + } + } else { + auto [major, minor] = _currentVersion; + subdirs.emplace_back(versionStringForPath(major, minor)); + } +} + void ApplicationDirectories::configurePaths(std::map& mConfig) { bool keepDeprecatedPaths = mConfig.contains("KeepDeprecatedPaths"); @@ -158,6 +179,7 @@ void ApplicationDirectories::configurePaths(std::map& m // this is to support a portable version of FreeCAD auto [customHome, customData, customTemp] = getCustomPaths(); + _usingCustomDirectories = !customHome.empty() || !customData.empty(); // get the system standard paths auto [configHome, dataHome, cacheHome, tempPath] = getStandardPaths(); @@ -179,18 +201,23 @@ void ApplicationDirectories::configurePaths(std::map& m getSubDirectories(mConfig, subdirs); } + // User data path // - fs::path data = findPath(dataHome, customData, subdirs, true); + auto dataSubdirs = subdirs; + appendVersionIfPossible(dataHome, dataSubdirs); + fs::path data = findPath(dataHome, customData, dataSubdirs, true); _userAppData = data; - mConfig["UserAppData"] = Base::FileInfo::pathToString(_userAppData) + PATHSEP; + mConfig["UserAppData"] = Base::FileInfo::pathToString(data) + PATHSEP; // User config path // - fs::path config = findPath(configHome, customHome, subdirs, true); + auto configSubdirs = subdirs; + appendVersionIfPossible(configHome, configSubdirs); + fs::path config = findPath(configHome, customHome, configSubdirs, true); _userConfig = config; - mConfig["UserConfigPath"] = Base::FileInfo::pathToString(_userConfig) + PATHSEP; + mConfig["UserConfigPath"] = Base::FileInfo::pathToString(config) + PATHSEP; // User cache path @@ -212,9 +239,8 @@ void ApplicationDirectories::configurePaths(std::map& m // Set the default macro directory // - std::vector macrodirs = std::move(subdirs); // Last use in this method, just move - macrodirs.emplace_back("Macro"); - fs::path macro = findPath(dataHome, customData, macrodirs, true); + std::vector macrodirs{"Macro"}; + fs::path macro = findPath(_userAppData, customData, macrodirs, true); _userMacro = macro; mConfig["UserMacroPath"] = Base::FileInfo::pathToString(macro) + PATHSEP; } @@ -284,6 +310,11 @@ fs::path ApplicationDirectories::getUserHome() return path; } +bool ApplicationDirectories::usingCustomDirectories() const +{ + return _usingCustomDirectories; +} + #if defined(FC_OS_WIN32) // This is ONLY used on Windows now, so don't even compile it elsewhere #include #include "ShlObj.h" @@ -403,6 +434,118 @@ std::tuple ApplicationDirectories::getSt qstringToPath(tempPath)}; } + + +std::string ApplicationDirectories::versionStringForPath(int major, int minor) +{ + // NOTE: This is intended to be stable over time, so if the format changes, a condition should be added to check for + // older versions and return this format for them, even if the new format differs. + return std::format("v{}-{}", major, minor); +} + +bool ApplicationDirectories::isVersionedPath(const fs::path &startingPath) const { + for (int major = std::get<0>(_currentVersion); major >= 1; --major) { + constexpr int largestPossibleMinor = 99; // We have to start someplace + int startingMinor = largestPossibleMinor; + if (major == std::get<0>(_currentVersion)) { + startingMinor = std::get<1>(_currentVersion); + } + for (int minor = startingMinor; minor >= 0; --minor) { + if (startingPath.filename() == versionStringForPath(major, minor)) { + return true; + } + } + } + return false; +} + +std::string ApplicationDirectories::mostRecentAvailableConfigVersion(const fs::path &startingPath) const { + for (int major = std::get<0>(_currentVersion); major >= 1; --major) { + constexpr int largestPossibleMinor = 99; // We have to start someplace + int startingMinor = largestPossibleMinor; + if (major == std::get<0>(_currentVersion)) { + startingMinor = std::get<1>(_currentVersion); + } + for (int minor = startingMinor; minor >= 0; --minor) { + auto version = startingPath / versionStringForPath(major, minor); + if (fs::is_directory(version)) { + return versionStringForPath(major, minor); + } + } + } + return ""; +} + +fs::path ApplicationDirectories::mostRecentConfigFromBase(const fs::path &startingPath) const { + // Starting in FreeCAD v1.1, we switched to using a versioned config path for the three configuration + // directories: + // UserAppData + // UserConfigPath + // UserMacroPath + // + // Migration to the versioned structured is NOT automatic: at the App level, we just find the most + // recent directory and use it, regardless of which version of the program is currently running. + // It is up to user-facing code in Gui to determine whether to ask a user if they want to migrate + // and to call the App-level functions that do that work. + + // The simplest and most common case is if the current version subfolder already exists + auto current = startingPath / versionStringForPath(std::get<0>(_currentVersion), std::get<1>(_currentVersion)); + if (fs::is_directory(current)) { + return current; + } + + // If the current version doesn't exist, see if a previous version does + std::string bestVersion = mostRecentAvailableConfigVersion(startingPath); + if (!bestVersion.empty()) { + return startingPath / bestVersion; + } + return startingPath; // No versioned config found +} + +bool ApplicationDirectories::usingCurrentVersionConfig(fs::path config) const { + if (config.filename().empty()) { + config = config.parent_path(); + } + auto version = config.filename().string(); + return version == versionStringForPath(std::get<0>(_currentVersion), std::get<1>(_currentVersion)); +} + +void ApplicationDirectories::migrateConfig(const fs::path &oldPath, const fs::path &newPath) +{ + fs::create_directories(newPath); + for (auto& file : fs::directory_iterator(oldPath)) { + if (file == newPath) { + // Handle the case where newPath is a subdirectory of oldPath + continue; + } + fs::copy(file.path(), + newPath / file.path().filename(), + fs::copy_options::recursive | fs::copy_options::copy_symlinks); + } +} + +void ApplicationDirectories::migrateAllPaths(const std::vector &paths) const { + auto [major, minor] = _currentVersion; + std::set uniquePaths (paths.begin(), paths.end()); + for (auto path : uniquePaths) { + if (path.filename().empty()) { + // Handle the case where the path was constructed from a std::string with a trailing / + path = path.parent_path(); + } + fs::path newPath; + if (isVersionedPath(path)) { + newPath = path.parent_path() / versionStringForPath(major, minor); + } else { + newPath = path / versionStringForPath(major, minor); + } + if (fs::exists(newPath)) { + throw Base::RuntimeError("Cannot migrate config - path already exists: " + newPath.string()); + } + fs::create_directories(newPath); + migrateConfig(path, newPath); + } +} + // TODO: Consider using this for all UNIX-like OSes #if defined(__OpenBSD__) #include diff --git a/src/App/ApplicationDirectories.h b/src/App/ApplicationDirectories.h index a9e91aac8c..55ff7197b5 100644 --- a/src/App/ApplicationDirectories.h +++ b/src/App/ApplicationDirectories.h @@ -32,17 +32,22 @@ namespace App { /// A helper class to handle application-wide directory management on behalf of the main - /// App::Application class + /// App::Application class. Most of this class's methods were originally in Application, and + /// were extracted here to be more easily testable (and to better follow the single- + /// responsibility principle, though further work is required in that area). class AppExport ApplicationDirectories { public: /// Constructor - /// \param currentVersion A tuple {major, minor} used to create/manage the versioned - /// directories, if any + /// \param config The base configuration dictionary. Used to create the appropriate + /// directories, and updated to reflect their locations. New code should not directly access + /// elements of this dictionary to determine directory locations, but should instead use + /// the public methods of this class to determine the locations needed. explicit ApplicationDirectories(std::map &config); - /// Given argv[0], figure out the home path + /// Given argv[0], figure out the home path. Used to initialize the AppHomePath element of + /// the configuration map prior to instantiating this class. static std::filesystem::path findHomePath(const char* sCall); /// "Home" here is the parent directory of the actual executable file being run. It is @@ -103,6 +108,55 @@ namespace App { /// Get the user's home directory static std::filesystem::path getUserHome(); + /// Returns true if the current active directory set is custom, and false if it's the + /// standard system default. + bool usingCustomDirectories() const; + + + // Versioned-Path Management Methods: + + /// Determine if a given config path is for the current version of the program + /// \param config The path to check + bool usingCurrentVersionConfig(std::filesystem::path config) const; + + /// Migrate a set of versionable configuration directories from the given path to a new + /// version. The new version's directories cannot exist yet, and the old ones *must* exist. + /// If the old paths are themselves versioned, then the new paths will be placed at the same + /// level in the directory structure (e.g., they will be siblings of each entry in paths). + /// If paths are NOT versioned, the new (versioned) copies will be placed *inside* the + /// original paths. + void migrateAllPaths(const std::vector &paths) const; + + /// A utility method to generate the versioned directory name for a given version. This only + /// returns the version string, not an entire path. As of FreeCAD 1.1, the string is of the + /// form "vX-Y" where X is the major version and Y is the minor, but external code should + /// not assume that is always the form, and should instead use this method, which is + /// guaranteed stable (that is, given "1" and "1" as the major and minor, it will always + /// return the string "v1-1", even if in later versions the format changes. + static std::string versionStringForPath(int major, int minor); + + /// Given an arbitrary path, determine if it has the correct form to be a versioned path + /// (e.g. is the last component a recognized version of the code). DOES NOT recognize + /// versions *newer* than the current version, even if the directory name matches the path + /// naming convention used by the previous version. + bool isVersionedPath(const std::filesystem::path &startingPath) const; + + /// Given a base path that is expected to contained versioned subdirectories, locate the + /// directory name (*not* the path, only the final component, the version string itself) + /// corresponding to the most recent version of the software, up to and including the + /// current version, but NOT exceeding it. + std::string mostRecentAvailableConfigVersion(const std::filesystem::path &startingPath) const; + + /// Given a base path that is expected to contained versioned subdirectories, locate the + /// directory corresponding to the most recent version of the software, up to and including + /// the current version, but NOT exceeding it. + std::filesystem::path mostRecentConfigFromBase(const std::filesystem::path &startingPath) const; + + /// A utility method to copy all files and directories from oldPath to newPath, handling the + /// case where newPath might itself be a subdirectory of oldPath (and *not* attempting that + /// otherwise-recursive copy). + static void migrateConfig(const std::filesystem::path &oldPath, const std::filesystem::path &newPath); + #ifdef FC_OS_WIN32 /// On Windows, gets the location of the user's "AppData" directory. Invalid on other OSes. QString getOldGenericDataLocation(); @@ -118,14 +172,15 @@ namespace App { /// be returned. static std::filesystem::path findUserHomePath(const std::filesystem::path& userHome); - bool isVersionedPath(const std::filesystem::path &startingPath) const; - std::string mostRecentAvailableConfigVersion(const std::filesystem::path &startingPath) const; - std::filesystem::path mostRecentConfigFromBase(const std::filesystem::path &startingPath) const; - - static void migrateConfig(const std::filesystem::path &oldPath, const std::filesystem::path &newPath); - - protected: + + /// Take a path and add a version to it, if it's possible to do so. A version can be + /// appended only if a) the versioned subdirectory already exists, or b) pathToCheck/subdirs + /// does NOT yet exist. This does not actually create any directories, just determines + /// if we can append the versioned directory name to subdirs. + void appendVersionIfPossible(const std::filesystem::path& basePath, + std::vector &subdirs) const; + static std::filesystem::path findPath( const std::filesystem::path& stdHome, const std::filesystem::path& customHome, @@ -172,6 +227,8 @@ namespace App { std::filesystem::path _resource; std::filesystem::path _library; std::filesystem::path _help; + + bool _usingCustomDirectories {false}; }; } // App diff --git a/tests/src/App/ApplicationDirectories.cpp b/tests/src/App/ApplicationDirectories.cpp new file mode 100644 index 0000000000..dff98381a0 --- /dev/null +++ b/tests/src/App/ApplicationDirectories.cpp @@ -0,0 +1,682 @@ +#include +#include + +#include +#include +#include "Base/Exception.h" + +/* NOLINTBEGIN( + readability-magic-numbers, + cppcoreguidelines-avoid-magic-numbers, + readability-function-cognitive-complexity +) */ + +namespace fs = std::filesystem; + +static fs::path MakeUniqueTempDir() +{ + constexpr int maxTries = 128; + const fs::path base = fs::temp_directory_path(); + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dist; + + for (int i = 0; i < maxTries; ++i) { + auto name = "app_directories_test-" + std::to_string(dist(gen)); + fs::path candidate = base / name; + std::error_code ec; + if (fs::create_directory(candidate, ec)) { + return candidate; + } + if (ec && ec != std::make_error_code(std::errc::file_exists)) { + continue; + } + } + throw std::runtime_error("Failed to create unique temp directory"); +} + +/// A subclass to expose protected members for unit testing +class ApplicationDirectoriesTestClass: public App::ApplicationDirectories +{ + using App::ApplicationDirectories::ApplicationDirectories; + +public: + void wrapAppendVersionIfPossible(const fs::path& basePath, + std::vector& subdirs) const + { + appendVersionIfPossible(basePath, subdirs); + } +}; + +class ApplicationDirectoriesTest: public ::testing::Test +{ +protected: + void SetUp() override + { + _tempDir = MakeUniqueTempDir(); + } + + std::map + generateConfig(const std::map& overrides) const + { + std::map config {{"AppHomePath", _tempDir.string()}, + {"ExeVendor", "Vendor"}, + {"ExeName", "Test"}, + {"BuildVersionMajor", "4"}, + {"BuildVersionMinor", "2"}}; + for (const auto& override : overrides) { + config[override.first] = override.second; + } + return config; + } + + std::shared_ptr makeAppDirsForVersion(int major, int minor) + { + auto configuration = generateConfig({{"BuildVersionMajor", std::to_string(major)}, + {"BuildVersionMinor", std::to_string(minor)}}); + return std::make_shared(configuration); + } + + fs::path makePathForVersion(const fs::path& base, int major, int minor) + { + return base / App::ApplicationDirectories::versionStringForPath(major, minor); + } + + void TearDown() override + { + fs::remove_all(_tempDir); + } + + fs::path tempDir() + { + return _tempDir; + } + +private: + fs::path _tempDir; +}; + +namespace fs = std::filesystem; + + +TEST_F(ApplicationDirectoriesTest, usingCurrentVersionConfigTrueWhenDirMatchesVersion) +{ + // Arrange + constexpr int major = 3; + constexpr int minor = 7; + const fs::path testPath = fs::path("some_kind_of_config") + / App::ApplicationDirectories::versionStringForPath(major, minor); + + // Act: generate a directory structure with the same version + auto appDirs = makeAppDirsForVersion(major, minor); + + // Assert + EXPECT_TRUE(appDirs->usingCurrentVersionConfig(testPath)); +} + +TEST_F(ApplicationDirectoriesTest, usingCurrentVersionConfigFalseWhenDirDoesntMatchVersion) +{ + // Arrange + constexpr int major = 3; + constexpr int minor = 7; + const fs::path testPath = fs::path("some_kind_of_config") + / App::ApplicationDirectories::versionStringForPath(major, minor); + + // Act: generate a directory structure with the same version + auto configuration = generateConfig({{"BuildVersionMajor", std::to_string(major + 1)}, + {"BuildVersionMinor", std::to_string(minor)}}); + auto appDirs = std::make_shared(configuration); + + // Assert + EXPECT_FALSE(appDirs->usingCurrentVersionConfig(testPath)); +} + +// Exact current version (hits: major==currentMajor path; inner if true) +TEST_F(ApplicationDirectoriesTest, isVersionedPathMatchesCurrentMajorAndMinor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::path p = makePathForVersion(tempDir(), 5, 4); + EXPECT_TRUE(appDirs->isVersionedPath(p)); +} + +// Lower minor within the same major (major==currentMajor path; iterates down to a smaller minor) +TEST_F(ApplicationDirectoriesTest, isVersionedPathMatchesLowerMinorSameMajor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::path p = makePathForVersion(tempDir(), 5, 2); + EXPECT_TRUE(appDirs->isVersionedPath(p)); +} + +// Lower major (major!=currentMajor path) +TEST_F(ApplicationDirectoriesTest, isVersionedPathMatchesLowerMajorAnyMinor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::path p = makePathForVersion(tempDir(), 4, 7); + EXPECT_TRUE(appDirs->isVersionedPath(p)); +} + +// Boundary: minor==0 within the same major +TEST_F(ApplicationDirectoriesTest, isVersionedPathMatchesZeroMinorSameMajor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::path p = makePathForVersion(tempDir(), 5, 0); + EXPECT_TRUE(appDirs->isVersionedPath(p)); +} + +// Negative: higher minor than current for the same major is never iterated +TEST_F(ApplicationDirectoriesTest, isVersionedPathDoesNotMatchHigherMinorSameMajor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::path p = makePathForVersion(tempDir(), 5, 5); + EXPECT_FALSE(appDirs->isVersionedPath(p)); +} + +// Negative: higher major than current is never iterated; also covers "non-version" style +TEST_F(ApplicationDirectoriesTest, isVersionedPathDoesNotMatchHigherMajorOrRandomName) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path higherMajor = makePathForVersion(tempDir(), 6, 1); + EXPECT_FALSE(appDirs->isVersionedPath(higherMajor)); + + fs::path randomName = tempDir() / "not-a-version"; + EXPECT_FALSE(appDirs->isVersionedPath(randomName)); +} + +// Convenience: path under base for a version folder name. +fs::path versionedPath(const fs::path& base, int major, int minor) +{ + return base / App::ApplicationDirectories::versionStringForPath(major, minor); +} + +// Create a regular file (used to prove non-directories are ignored). +void touchFile(const fs::path& p) +{ + fs::create_directories(p.parent_path()); + std::ofstream ofs(p.string()); + ofs << "x"; +} + +// The exact current version exists -> returned immediately (current-major branch). +TEST_F(ApplicationDirectoriesTest, mostRecentAvailReturnsExactCurrentVersionIfDirectoryExists) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories(versionedPath(tempDir(), 5, 4)); + + EXPECT_EQ(appDirs->mostRecentAvailableConfigVersion(tempDir()), + App::ApplicationDirectories::versionStringForPath(5, 4)); +} + +// No exact match in the current major: choose the highest available minor <= current +// and prefer the current major over lower majors. +TEST_F(ApplicationDirectoriesTest, mostRecentAvailPrefersSameMajorAndPicksHighestLowerMinor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories(versionedPath(tempDir(), 5, 1)); + fs::create_directories(versionedPath(tempDir(), 5, 3)); + fs::create_directories(versionedPath(tempDir(), 4, 99)); // distractor in lower major + + EXPECT_EQ(appDirs->mostRecentAvailableConfigVersion(tempDir()), + App::ApplicationDirectories::versionStringForPath(5, 3)); +} + +// No directories in current major: scan next lower major from 99 downward, +// returning the highest available minor present (demonstrates descending search). +TEST_F(ApplicationDirectoriesTest, mostRecentAvailForLowerMajorPicksHighestAvailableMinor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories(versionedPath(tempDir(), 4, 3)); + fs::create_directories(versionedPath(tempDir(), 4, 42)); + + EXPECT_EQ(appDirs->mostRecentAvailableConfigVersion(tempDir()), + App::ApplicationDirectories::versionStringForPath(4, 42)); +} + +// If the candidate path exists but is a regular file, it must be ignored and +// the search must fall back to the next available directory. +TEST_F(ApplicationDirectoriesTest, mostRecentAvailSkipsFilesAndFallsBackToNextDirectory) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + touchFile(versionedPath(tempDir(), 5, 4)); // file at the current version + fs::create_directories(versionedPath(tempDir(), 5, 3)); // directory at next lower minor + + EXPECT_EQ(appDirs->mostRecentAvailableConfigVersion(tempDir()), + App::ApplicationDirectories::versionStringForPath(5, 3)); +} + +// Higher minor in the current major is not considered (loop starts at the current minor); +// should fall through to the lower major when nothing <= the current minor exists. +TEST_F(ApplicationDirectoriesTest, mostRecentAvailIgnoresHigherMinorThanCurrentInSameMajor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories( + versionedPath(tempDir(), 5, 7)); // higher than the current minor; ignored + fs::create_directories(versionedPath(tempDir(), 4, 1)); // fallback target + + EXPECT_EQ(appDirs->mostRecentAvailableConfigVersion(tempDir()), + App::ApplicationDirectories::versionStringForPath(4, 1)); +} + +// No candidates anywhere -> empty string returned. +TEST_F(ApplicationDirectoriesTest, mostRecentAvailReturnsEmptyStringWhenNoVersionsPresent) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + EXPECT_EQ(appDirs->mostRecentAvailableConfigVersion(tempDir()), ""); +} + + +// The current version directory exists -> returned immediately +TEST_F(ApplicationDirectoriesTest, mostRecentConfigReturnsCurrentVersionDirectoryIfPresent) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories(versionedPath(tempDir(), 5, 4)); + + fs::path got = appDirs->mostRecentConfigFromBase(tempDir()); + EXPECT_EQ(got, versionedPath(tempDir(), 5, 4)); +} + +// The current version missing -> falls back to most recent available in same major +TEST_F(ApplicationDirectoriesTest, + mostRecentConfigFallsBackToMostRecentInSameMajorWhenCurrentMissing) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + // There is no directory called "5.4"; provide candidates 5.3 and 5.1; also a distractor in a + // lower major. + fs::create_directories(versionedPath(tempDir(), 5, 1)); + fs::create_directories(versionedPath(tempDir(), 5, 3)); + fs::create_directories(versionedPath(tempDir(), 4, 99)); + + fs::path got = appDirs->mostRecentConfigFromBase(tempDir()); + EXPECT_EQ(got, versionedPath(tempDir(), 5, 3)); +} + +// The current version path exists as a file (not directory) -> ignored, fallback used +TEST_F(ApplicationDirectoriesTest, mostRecentConfigSkipsFileAtCurrentVersionAndFallsBack) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + touchFile(versionedPath(tempDir(), 5, 4)); // file, not dir + fs::create_directories(versionedPath(tempDir(), 5, 2)); + + fs::path got = appDirs->mostRecentConfigFromBase(tempDir()); + EXPECT_EQ(got, versionedPath(tempDir(), 5, 2)); +} + +// There are no eligible versions in the current major -> choose the highest available in lower +// majors +TEST_F(ApplicationDirectoriesTest, mostRecentConfigFallsBackToHighestVersionInLowerMajors) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + // No 5.x minor <= 4 exists. (Optionally add a higher minor to prove it's ignored.) + fs::create_directories(versionedPath(tempDir(), 5, 7)); // ignored (higher than current minor) + + // Provide multiple lower-major candidates; should pick 4.90 over 3.99. + fs::create_directories(versionedPath(tempDir(), 4, 3)); + fs::create_directories(versionedPath(tempDir(), 4, 90)); + fs::create_directories(versionedPath(tempDir(), 3, 99)); + + fs::path got = appDirs->mostRecentConfigFromBase(tempDir()); + EXPECT_EQ(got, versionedPath(tempDir(), 4, 90)); +} + +// There is nothing available anywhere -> returns startingPath +TEST_F(ApplicationDirectoriesTest, mostRecentConfigReturnsStartingPathWhenNoVersionedConfigExists) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::path got = appDirs->mostRecentConfigFromBase(tempDir()); + EXPECT_EQ(got, tempDir()); +} + +// True: exact current version directory +TEST_F(ApplicationDirectoriesTest, usingCurrentVersionExactVersionDir) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories(versionedPath(tempDir(), 5, 4)); + + EXPECT_TRUE(appDirs->usingCurrentVersionConfig(versionedPath(tempDir(), 5, 4))); +} + +// True: current version directory with trailing separator (exercises filename().empty() branch) +TEST_F(ApplicationDirectoriesTest, usingCurrentVersionVersionDirWithTrailingSlash) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories(versionedPath(tempDir(), 5, 4)); + fs::path p = versionedPath(tempDir(), 5, 4) / ""; // ensure trailing separator + + EXPECT_TRUE(appDirs->usingCurrentVersionConfig(p)); +} + +// False: a file inside the current version directory (filename != version string) +TEST_F(ApplicationDirectoriesTest, usingCurrentVersionFileInsideVersionDirIsFalse) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::path filePath = versionedPath(tempDir(), 5, 4) / "config.yaml"; + touchFile(filePath); + + EXPECT_FALSE(appDirs->usingCurrentVersionConfig(filePath)); +} + +// False: lower version directory +TEST_F(ApplicationDirectoriesTest, usingCurrentVersionLowerVersionDirIsFalse) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories(versionedPath(tempDir(), 5, 3)); + + EXPECT_FALSE(appDirs->usingCurrentVersionConfig(versionedPath(tempDir(), 5, 3))); +} + +// False: higher version directory +TEST_F(ApplicationDirectoriesTest, usingCurrentVersionHigherVersionDirIsFalse) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories(versionedPath(tempDir(), 6, 0)); + + EXPECT_FALSE(appDirs->usingCurrentVersionConfig(versionedPath(tempDir(), 6, 0))); +} + +// False: non-version directory (e.g., base dir) +TEST_F(ApplicationDirectoriesTest, usingCurrentVersionNonVersionDirIsFalse) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + fs::create_directories(tempDir()); + + EXPECT_FALSE(appDirs->usingCurrentVersionConfig(tempDir())); +} + +void writeFile(const fs::path& p, std::string_view contents) +{ + fs::create_directories(p.parent_path()); + std::ofstream ofs(p.string(), std::ios::binary); + ofs << contents; +} + +std::string readFile(const fs::path& p) +{ + std::ifstream ifs(p.string(), std::ios::binary); + return {std::istreambuf_iterator(ifs), {}}; +} + +// Creates destination and copies flat files over +TEST_F(ApplicationDirectoriesTest, migrateConfigCreatesDestinationAndCopiesFiles) +{ + // Arrange + fs::path oldPath = tempDir() / "old"; + fs::path newPath = tempDir() / "new"; + writeFile(oldPath / "a.txt", "alpha"); + writeFile(oldPath / "b.ini", "bravo"); + + // Act + App::ApplicationDirectories::migrateConfig(oldPath, newPath); + + // Assert + ASSERT_TRUE(fs::exists(newPath)); + ASSERT_TRUE(fs::is_directory(newPath)); + + EXPECT_TRUE(fs::exists(newPath / "a.txt")); + EXPECT_TRUE(fs::exists(newPath / "b.ini")); + EXPECT_EQ(readFile(newPath / "a.txt"), "alpha"); + EXPECT_EQ(readFile(newPath / "b.ini"), "bravo"); + + EXPECT_TRUE(fs::exists(oldPath / "a.txt")); + EXPECT_TRUE(fs::exists(oldPath / "b.ini")); +} + +// newPath is a subdirectory of oldPath -> skip copying newPath into itself +TEST_F(ApplicationDirectoriesTest, migrateConfigSkipsSelfWhenNewIsSubdirectoryOfOld) +{ + // Arrange + fs::path oldPath = tempDir() / "container"; + fs::path newPath = oldPath / "migrated"; + writeFile(oldPath / "c.yaml", "charlie"); + writeFile(oldPath / "d.cfg", "delta"); + + // Act + App::ApplicationDirectories::migrateConfig(oldPath, newPath); + + // Assert + ASSERT_TRUE(fs::exists(newPath)); + ASSERT_TRUE(fs::is_directory(newPath)); + EXPECT_TRUE(fs::exists(newPath / "c.yaml")); + EXPECT_TRUE(fs::exists(newPath / "d.cfg")); + + // Do not copy the destination back into itself (no nested 'migrated/migrated') + EXPECT_FALSE(fs::exists(newPath / newPath.filename())); +} + +// oldPath empty -> still creates the destination and does nothing else +TEST_F(ApplicationDirectoriesTest, migrateConfigEmptyOldPathJustCreatesDestination) +{ + fs::path oldPath = tempDir() / "empty_old"; + fs::path newPath = tempDir() / "dest_only"; + + fs::create_directories(oldPath); + App::ApplicationDirectories::migrateConfig(oldPath, newPath); + + ASSERT_TRUE(fs::exists(newPath)); + ASSERT_TRUE(fs::is_directory(newPath)); + + // No files expected + bool hasEntries = (fs::directory_iterator(newPath) != fs::directory_iterator {}); + EXPECT_FALSE(hasEntries); +} + +// Case: recursively copy nested directories and files +TEST_F(ApplicationDirectoriesTest, migrateConfigRecursivelyCopiesDirectoriesAndFiles) +{ + fs::path oldPath = tempDir() / "old_tree"; + fs::path newPath = tempDir() / "new_tree"; + + // old_tree/ + // config/ + // env/ + // a.txt + // empty_dir/ + writeFile(oldPath / "config" / "env" / "a.txt", "alpha"); + fs::create_directories(oldPath / "empty_dir"); + + App::ApplicationDirectories::migrateConfig(oldPath, newPath); + + // Expect structure replicated under newPath + ASSERT_TRUE(fs::exists(newPath)); + EXPECT_TRUE(fs::exists(newPath / "config")); + EXPECT_TRUE(fs::exists(newPath / "config" / "env")); + EXPECT_TRUE(fs::exists(newPath / "config" / "env" / "a.txt")); + EXPECT_EQ(readFile(newPath / "config" / "env" / "a.txt"), "alpha"); + + // Empty directories should be created as well + EXPECT_TRUE(fs::exists(newPath / "empty_dir")); + EXPECT_TRUE(fs::is_directory(newPath / "empty_dir")); +} + +// Case: newPath is subdir of oldPath -> recursively copy, but do NOT copy newPath into itself +TEST_F(ApplicationDirectoriesTest, migrateConfigNewPathSubdirRecursivelyCopiesAndSkipsSelf) +{ + fs::path oldPath = tempDir() / "src_tree"; + fs::path newPath = oldPath / "migrated"; // destination under source + + // src_tree/ + // folderA/ + // child/ + // f.txt + writeFile(oldPath / "folderA" / "child" / "f.txt", "filedata"); + + App::ApplicationDirectories::migrateConfig(oldPath, newPath); + + // Copied recursively into destination + EXPECT_TRUE(fs::exists(newPath / "folderA" / "child" / "f.txt")); + EXPECT_EQ(readFile(newPath / "folderA" / "child" / "f.txt"), "filedata"); + + // Do not copy the destination back into itself (no migrated/migrated) + EXPECT_FALSE(fs::exists(newPath / newPath.filename())); +} + +// Versioned input: `path` == base/` -> newPath == base/ +TEST_F(ApplicationDirectoriesTest, migrateAllPathsVersionedInputChoosesParentPlusCurrent) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path base = tempDir() / "v_branch"; + fs::path older = versionedPath(base, 5, 1); // versioned input (isVersionedPath == true) + fs::create_directories(older); + writeFile(older / "sentinel.txt", "s"); + + std::vector inputs {older}; + appDirs->migrateAllPaths(inputs); + + fs::path expectedDest = versionedPath(base, 5, 4); + EXPECT_TRUE(fs::exists(expectedDest)); + EXPECT_TRUE(fs::is_directory(expectedDest)); +} + +// Non-versioned input: `path` == base -> newPath == base/ +TEST_F(ApplicationDirectoriesTest, migrateAllPathsNonVersionedInputAppendsCurrent) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path base = tempDir() / "plain_base"; + fs::create_directories(base); + writeFile(base / "config.yaml", "x"); + + std::vector inputs {base}; + appDirs->migrateAllPaths(inputs); + + fs::path expectedDest = versionedPath(base, 5, 4); + EXPECT_TRUE(fs::exists(expectedDest)); + EXPECT_TRUE(fs::is_directory(expectedDest)); +} + +// Pre-existing destination -> throws Base::RuntimeError +TEST_F(ApplicationDirectoriesTest, migrateAllPathsThrowsIfDestinationAlreadyExists_NonVersioned) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path base = tempDir() / "exists_case"; + fs::create_directories(base); + fs::create_directories(versionedPath(base, 5, 4)); // destination already exists + + std::vector inputs {base}; + EXPECT_THROW(appDirs->migrateAllPaths(inputs), Base::RuntimeError); +} + +// Multiple inputs: one versioned, one non-versioned -> both destinations created +TEST_F(ApplicationDirectoriesTest, migrateAllPathsProcessesMultipleInputs) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + // Versioned input A: baseA/5.2 + fs::path baseA = tempDir() / "multiA"; + fs::path olderA = versionedPath(baseA, 5, 2); + fs::create_directories(olderA); + writeFile(olderA / "a.txt", "a"); + + // Non-versioned input B: baseB + fs::path baseB = tempDir() / "multiB"; + fs::create_directories(baseB); + writeFile(baseB / "b.txt", "b"); + + std::vector inputs {olderA, baseB}; + appDirs->migrateAllPaths(inputs); + + EXPECT_TRUE(fs::exists(versionedPath(baseA, 5, 4))); // parent_path() / current + EXPECT_TRUE(fs::exists(versionedPath(baseB, 5, 4))); // base / current +} + +// Already versioned (final component is a version) -> no change +TEST_F(ApplicationDirectoriesTest, AppendVec_AlreadyVersioned_Bails) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path base = tempDir() / "bail_vec"; + std::vector sub { + "configs", + App::ApplicationDirectories::versionStringForPath(5, 2)}; // versioned tail + fs::create_directories(base / sub[0] / sub[1]); + + auto before = sub; + appDirs->wrapAppendVersionIfPossible(base, sub); + EXPECT_EQ(sub, before); // unchanged +} + +// Base exists & current version dir present -> append current +TEST_F(ApplicationDirectoriesTest, AppendVec_BaseExists_AppendsCurrentWhenPresent) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path base = tempDir() / "vec_current"; + std::vector sub {"configs"}; + fs::create_directories(base / "configs"); + fs::create_directories(versionedPath(base / "configs", 5, 4)); + + appDirs->wrapAppendVersionIfPossible(base, sub); + + ASSERT_EQ(sub.size(), 2u); + EXPECT_EQ(sub.back(), App::ApplicationDirectories::versionStringForPath(5, 4)); +} + +// Base exists, no current; lower minors exist -> append highest ≤ current in same major +TEST_F(ApplicationDirectoriesTest, AppendVec_PicksHighestLowerMinorInSameMajor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path base = tempDir() / "vec_lower_minor"; + std::vector sub {"configs"}; + fs::create_directories(versionedPath(base / "configs", 5, 1)); + fs::create_directories(versionedPath(base / "configs", 5, 3)); + // distractor in lower major + fs::create_directories(versionedPath(base / "configs", 4, 99)); + + appDirs->wrapAppendVersionIfPossible(base, sub); + + ASSERT_EQ(sub.size(), 2u); + EXPECT_EQ(sub.back(), App::ApplicationDirectories::versionStringForPath(5, 3)); +} + +// Base exists, nothing in current major; lower major exists -> append highest available lower major +TEST_F(ApplicationDirectoriesTest, AppendVec_FallsBackToLowerMajor) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path base = tempDir() / "vec_lower_major"; + std::vector sub {"configs"}; + fs::create_directories(versionedPath(base / "configs", 4, 90)); + fs::create_directories(versionedPath(base / "configs", 3, 99)); + + appDirs->wrapAppendVersionIfPossible(base, sub); + + ASSERT_EQ(sub.size(), 2u); + EXPECT_EQ(sub.back(), App::ApplicationDirectories::versionStringForPath(4, 90)); +} + +// Base exists but contains no versioned subdirs -> append nothing (vector unchanged) +TEST_F(ApplicationDirectoriesTest, AppendVec_NoVersionedChildren_LeavesVectorUnchanged) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path base = tempDir() / "vec_noversions"; + std::vector sub {"configs"}; + fs::create_directories(base / "configs"); // but no versioned children + + auto before = sub; + appDirs->wrapAppendVersionIfPossible(base, sub); + EXPECT_EQ(sub, before); +} + +// Base does not exist -> append current version string +TEST_F(ApplicationDirectoriesTest, AppendVec_BaseMissing_AppendsCurrentSuffix) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + + fs::path base = tempDir() / "vec_missing"; + std::vector sub {"configs"}; // base/configs doesn't exist + + appDirs->wrapAppendVersionIfPossible(base, sub); + + ASSERT_EQ(sub.size(), 2u); + EXPECT_EQ(sub.back(), App::ApplicationDirectories::versionStringForPath(5, 4)); +} + +/* NOLINTEND( + readability-magic-numbers, + cppcoreguidelines-avoid-magic-numbers, + readability-function-cognitive-complexity +) */ diff --git a/tests/src/App/CMakeLists.txt b/tests/src/App/CMakeLists.txt index 182c9152ef..c99750e30c 100644 --- a/tests/src/App/CMakeLists.txt +++ b/tests/src/App/CMakeLists.txt @@ -1,5 +1,6 @@ add_executable(App_tests_run Application.cpp + ApplicationDirectories.cpp BackupPolicy.cpp Branding.cpp ComplexGeoData.cpp From ade698e52d6f9bf98afa9d8ed84b5a1161326dec Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sat, 23 Aug 2025 18:12:20 -0500 Subject: [PATCH 3/6] Gui: Detect new versions and offer to migrate At the very end of the startup process, check to see if we are using a versioned directory from a previous version, or we are not versioned at all but are using the system's default storage location (that is, not a custom config path set on the command line, etc.). If so, offer to migrate the old configration folders into new ones for the current version. --- src/Gui/PreCompiled.h | 1 + src/Gui/StartupProcess.cpp | 144 ++++++++++++++++++++++++++++++++++++- src/Gui/StartupProcess.h | 3 + 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/src/Gui/PreCompiled.h b/src/Gui/PreCompiled.h index f1b3ce8192..a5e5327814 100644 --- a/src/Gui/PreCompiled.h +++ b/src/Gui/PreCompiled.h @@ -77,6 +77,7 @@ #include #include #include +#include #include #include #include diff --git a/src/Gui/StartupProcess.cpp b/src/Gui/StartupProcess.cpp index c26bf72a24..e7613e2073 100644 --- a/src/Gui/StartupProcess.cpp +++ b/src/Gui/StartupProcess.cpp @@ -24,14 +24,21 @@ #include "PreCompiled.h" #ifndef _PreComp_ #include -#include #include #include +#include #include #include +#include #include +#include +#include #include #include + +#include +#include +#include #endif #include "StartupProcess.h" @@ -43,6 +50,7 @@ #include "MainWindow.h" #include "Language/Translator.h" #include +#include #include @@ -220,6 +228,7 @@ void StartupPostProcess::execute() showMainWindow(); activateWorkbench(); checkParameters(); + runWelcomeScreen(); } void StartupPostProcess::setWindowTitle() @@ -544,3 +553,136 @@ void StartupPostProcess::checkParameters() "Continue with an empty configuration that won't be saved.\n"); } } + +void StartupPostProcess::runWelcomeScreen() +{ + // If the user is running a custom directory set, there is no migration to versioned directories + if (App::Application::directories()->usingCustomDirectories()) { + return; + } + + auto prefGroup = App::GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/Migration"); + + // Split our comma-separated list of already-migrated-to version directories into a set for easy + // searching + std::string offeredToMigrateToVersionedConfig = + prefGroup->GetASCII("OfferedToMigrateToVersionedConfig", ""); + std::set knownVersions; + if (!offeredToMigrateToVersionedConfig.empty()) { + for (auto&& part : offeredToMigrateToVersionedConfig | std::views::split(',')) { + knownVersions.emplace(part.begin(), part.end()); + } + } + + auto joinCommas = [](const std::set& s) { + std::ostringstream oss; + for (auto it = s.begin(); it != s.end(); ++it) { + if (it != s.begin()) { + oss << ','; + } + oss << *it; + } + return oss.str(); + }; + + int major = std::stoi(App::Application::Config()["BuildVersionMajor"]); + int minor = std::stoi(App::Application::Config()["BuildVersionMinor"]); + std::string currentVersionedDirName = App::ApplicationDirectories::versionStringForPath(major, minor); + if (!knownVersions.contains(currentVersionedDirName) + && !App::Application::directories()->usingCurrentVersionConfig( + App::Application::directories()->getUserAppDataDir())) { + auto programName = QString::fromStdString(App::Application::getExecutableName()); + auto result = QMessageBox::question( + mainWindow, + QObject::tr("Welcome to %1 v%2.%3").arg(programName, QString::number(major), QString::number(minor)), + QObject::tr("Welcome to %1 v%2.%3\n\n").arg(programName, QString::number(major), QString::number(minor)) + + QObject::tr("Configuration data and addons from previous program version found. " + "Migrate the old configuration to this version?"), + QMessageBox::Yes | QMessageBox::No); + knownVersions.insert(currentVersionedDirName); + prefGroup->SetASCII("OfferedToMigrateToVersionedConfig", joinCommas(knownVersions)); + if (result == QMessageBox::Yes) { + migrateToCurrentVersion(); + } + } +} + +class PathMigrationWorker : public QObject +{ + Q_OBJECT + +public: + void run () { + try { + App::GetApplication().GetUserParameter().SaveDocument(); + App::Application::directories()->migrateAllPaths( + {App::Application::getUserAppDataDir(), App::Application::getUserConfigPath()}); + Q_EMIT(complete()); + } catch (const Base::Exception& e) { + Base::Console().error("Error migrating configuration data: %s\n", e.what()); + Q_EMIT(failed()); + } catch (const std::exception& e) { + Base::Console().error("Unrecognized error migrating configuration data: %s\n", e.what()); + Q_EMIT(failed()); + } catch (...) { + Base::Console().error("Error migrating configuration data\n"); + Q_EMIT(failed()); + } + } + +Q_SIGNALS: + void complete(); + void failed(); +}; + +void StartupPostProcess::migrateToCurrentVersion() +{ + auto *workerThread = new QThread(mainWindow); + auto *worker = new PathMigrationWorker(); + worker->moveToThread(workerThread); + QObject::connect(workerThread, &QThread::started, worker, &PathMigrationWorker::run); + + auto migrationRunning = new QMessageBox(mainWindow); + migrationRunning->setWindowTitle(QObject::tr("Migrating")); + migrationRunning->setText(QObject::tr("Migrating configuration data and addons...")); + migrationRunning->setStandardButtons(QMessageBox::NoButton); + QObject::connect(worker, &PathMigrationWorker::complete, migrationRunning, &QMessageBox::accept); + QObject::connect(worker, &PathMigrationWorker::failed, migrationRunning, &QMessageBox::reject); + + workerThread->start(); + migrationRunning->exec(); + + if (migrationRunning->result() == QDialog::Accepted) { + auto* restarting = new QMessageBox(mainWindow); + restarting->setText( + QObject::tr("Migration complete. Restarting...")); + restarting->setWindowTitle(QObject::tr("Restarting")); + restarting->setStandardButtons(QMessageBox::NoButton); + auto closeNotice = [restarting]() { + restarting->reject(); + }; + + // Insert a short delay before restart so the user can see the success message, and + // knows it's a restart and not a crash... + const int delayRestartMillis {2000}; + QTimer::singleShot(delayRestartMillis, closeNotice); + restarting->exec(); + + QObject::connect(qApp, &QCoreApplication::aboutToQuit, [=] { + if (getMainWindow()->close()) { + auto args = QApplication::arguments(); + args.removeFirst(); + QProcess::startDetached(QApplication::applicationFilePath(), + args, + QApplication::applicationDirPath()); + } + }); + QCoreApplication::exit(0); + _exit(0); // No really. Die. + } else { + QMessageBox::critical(mainWindow, QObject::tr("Migration failed"),QObject::tr("Migration failed. See the Report View for details.")); + } +} + +#include "StartupProcess.moc" diff --git a/src/Gui/StartupProcess.h b/src/Gui/StartupProcess.h index 0ec4ad0e03..f3d045d098 100644 --- a/src/Gui/StartupProcess.h +++ b/src/Gui/StartupProcess.h @@ -28,6 +28,7 @@ #include class QApplication; +class QMessageBox; namespace Gui { @@ -75,6 +76,8 @@ private: void showMainWindow(); void activateWorkbench(); void checkParameters(); + void runWelcomeScreen(); + void migrateToCurrentVersion(); private: bool loadFromPythonModule = false; From 5c7a44d7d8073d49c9c9a29eaca68f16e6ca8589 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sun, 24 Aug 2025 17:18:59 -0500 Subject: [PATCH 4/6] App: Fix problems with temp dir creation --- src/App/Application.cpp | 5 ++++- src/App/ApplicationDirectories.cpp | 14 +++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/App/Application.cpp b/src/App/Application.cpp index 760f52de0e..b25138a854 100644 --- a/src/App/Application.cpp +++ b/src/App/Application.cpp @@ -1153,7 +1153,10 @@ std::string Application::getTempPath() std::string Application::getTempFileName(const char* FileName) { - return Base::FileInfo::pathToString(_appDirs->getTempFileName(FileName)) + PATHSEP; + if (FileName) { + return Base::FileInfo::pathToString(_appDirs->getTempFileName(FileName)); + } + return Base::FileInfo::pathToString(_appDirs->getTempFileName(std::string())); } std::string Application::getUserCachePath() diff --git a/src/App/ApplicationDirectories.cpp b/src/App/ApplicationDirectories.cpp index c3c72083b7..eb5305a529 100644 --- a/src/App/ApplicationDirectories.cpp +++ b/src/App/ApplicationDirectories.cpp @@ -21,7 +21,7 @@ #include "PreCompiled.h" #ifndef _PreComp_ -#include +#include #include #include #include @@ -79,7 +79,11 @@ const fs::path& ApplicationDirectories::getTempPath() const { } fs::path ApplicationDirectories::getTempFileName(const std::string & filename) const { - return Base::FileInfo::getTempFileName(filename.c_str(), getTempPath().string().c_str()); + auto tempPath = Base::FileInfo::pathToString(getTempPath()); + if (filename.empty()) { + return Base::FileInfo::getTempFileName(nullptr, tempPath.c_str()); + } + return Base::FileInfo::getTempFileName(filename.c_str(), tempPath.c_str()); } const fs::path& ApplicationDirectories::getUserCachePath() const @@ -440,7 +444,7 @@ std::string ApplicationDirectories::versionStringForPath(int major, int minor) { // NOTE: This is intended to be stable over time, so if the format changes, a condition should be added to check for // older versions and return this format for them, even if the new format differs. - return std::format("v{}-{}", major, minor); + return fmt::format("v{}-{}", major, minor); } bool ApplicationDirectories::isVersionedPath(const fs::path &startingPath) const { @@ -506,7 +510,7 @@ bool ApplicationDirectories::usingCurrentVersionConfig(fs::path config) const { if (config.filename().empty()) { config = config.parent_path(); } - auto version = config.filename().string(); + auto version = Base::FileInfo::pathToString(config.filename()); return version == versionStringForPath(std::get<0>(_currentVersion), std::get<1>(_currentVersion)); } @@ -539,7 +543,7 @@ void ApplicationDirectories::migrateAllPaths(const std::vector &paths) newPath = path / versionStringForPath(major, minor); } if (fs::exists(newPath)) { - throw Base::RuntimeError("Cannot migrate config - path already exists: " + newPath.string()); + throw Base::RuntimeError("Cannot migrate config - path already exists: " + Base::FileInfo::pathToString(newPath)); } fs::create_directories(newPath); migrateConfig(path, newPath); From c1805413e021f51f140147b6ce6d2bff8ba1ca53 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sun, 24 Aug 2025 19:33:25 -0500 Subject: [PATCH 5/6] Gui: Refactor to not use std::ranges Older Ubuntu doesn't like the mechanism used to split on commas --- src/Gui/StartupProcess.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Gui/StartupProcess.cpp b/src/Gui/StartupProcess.cpp index e7613e2073..0dd9551dc6 100644 --- a/src/Gui/StartupProcess.cpp +++ b/src/Gui/StartupProcess.cpp @@ -566,13 +566,23 @@ void StartupPostProcess::runWelcomeScreen() // Split our comma-separated list of already-migrated-to version directories into a set for easy // searching + auto splitCommas = [](const std::string &input) { + std::set result; + std::stringstream ss(input); + std::string token; + + while (std::getline(ss, token, ',')) { + result.insert(token); + } + + return result; + }; + std::string offeredToMigrateToVersionedConfig = prefGroup->GetASCII("OfferedToMigrateToVersionedConfig", ""); std::set knownVersions; if (!offeredToMigrateToVersionedConfig.empty()) { - for (auto&& part : offeredToMigrateToVersionedConfig | std::views::split(',')) { - knownVersions.emplace(part.begin(), part.end()); - } + knownVersions = splitCommas(offeredToMigrateToVersionedConfig); } auto joinCommas = [](const std::set& s) { From fa98beb2212e3e4e0dfa939dc301615ee9258650 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Tue, 26 Aug 2025 19:09:33 -0500 Subject: [PATCH 6/6] App: refactor to use unique_ptr Also address other reviewer comments. --- src/App/Application.cpp | 11 ++- src/App/Application.h | 4 +- src/App/ApplicationDirectories.cpp | 21 +++--- src/App/ApplicationDirectories.h | 8 ++- tests/src/App/ApplicationDirectories.cpp | 88 +++++++++++++++++++++--- 5 files changed, 104 insertions(+), 28 deletions(-) diff --git a/src/App/Application.cpp b/src/App/Application.cpp index b25138a854..b69d139dc2 100644 --- a/src/App/Application.cpp +++ b/src/App/Application.cpp @@ -186,7 +186,7 @@ Base::ConsoleObserverStd *Application::_pConsoleObserverStd = nullptr; Base::ConsoleObserverFile *Application::_pConsoleObserverFile = nullptr; AppExport std::map Application::mConfig; -std::shared_ptr Application::_appDirs; +std::unique_ptr Application::_appDirs; //************************************************************************** @@ -1142,7 +1142,7 @@ bool Application::isDevelopmentVersion() return suffix == "dev"; } -std::shared_ptr Application::directories() { +const std::unique_ptr& Application::directories() { return _appDirs; } @@ -1153,10 +1153,7 @@ std::string Application::getTempPath() std::string Application::getTempFileName(const char* FileName) { - if (FileName) { - return Base::FileInfo::pathToString(_appDirs->getTempFileName(FileName)); - } - return Base::FileInfo::pathToString(_appDirs->getTempFileName(std::string())); + return Base::FileInfo::pathToString(_appDirs->getTempFileName(FileName ? FileName : std::string())); } std::string Application::getUserCachePath() @@ -2573,7 +2570,7 @@ void Application::initConfig(int argc, char ** argv) } // extract home paths - _appDirs = std::make_shared(mConfig); + _appDirs = std::make_unique(mConfig); if (vm.contains("safe-mode")) { SafeMode::StartSafeMode(); diff --git a/src/App/Application.h b/src/App/Application.h index 6abc36aa4a..b1f482adc9 100644 --- a/src/App/Application.h +++ b/src/App/Application.h @@ -426,7 +426,7 @@ public: static bool isDevelopmentVersion(); /// Access to the various directories for the program a replacement for the get*Path methods below - static std::shared_ptr directories(); + static const std::unique_ptr& directories(); /*! Returns the temporary directory. By default, this is set to the @@ -632,7 +632,7 @@ private: /// startup configuration container static std::map mConfig; /// Management of and access to applications directories - static std::shared_ptr _appDirs; + static std::unique_ptr _appDirs; static int _argc; static char ** _argv; //@} diff --git a/src/App/ApplicationDirectories.cpp b/src/App/ApplicationDirectories.cpp index eb5305a529..011b37d8a8 100644 --- a/src/App/ApplicationDirectories.cpp +++ b/src/App/ApplicationDirectories.cpp @@ -56,13 +56,7 @@ fs::path qstringToPath(const QString& path) ApplicationDirectories::ApplicationDirectories(std::map &config) { - try { - int major = std::stoi(config.at("BuildVersionMajor")); - int minor = std::stoi(config.at("BuildVersionMinor")); - _currentVersion = std::make_tuple(major, minor); - } catch (const std::exception& e) { - throw Base::RuntimeError("Failed to parse version from config: " + std::string(e.what())); - } + _currentVersion = extractVersionFromConfigMap(config); configurePaths(config); configureResourceDirectory(config); configureLibraryDirectory(config); @@ -724,5 +718,16 @@ fs::path ApplicationDirectories::findHomePath(const char* sCall) } #else -# error "std::string ApplicationDirectories::FindHomePath(const char*) not implemented" +# error "std::string ApplicationDirectories::findHomePath(const char*) not implemented" #endif + +std::tuple ApplicationDirectories::extractVersionFromConfigMap(const std::map &config) +{ + try { + int major = std::stoi(config.at("BuildVersionMajor")); + int minor = std::stoi(config.at("BuildVersionMinor")); + return std::make_tuple(major, minor); + } catch (const std::exception& e) { + throw Base::RuntimeError("Failed to parse version from config: " + std::string(e.what())); + } +} diff --git a/src/App/ApplicationDirectories.h b/src/App/ApplicationDirectories.h index 55ff7197b5..87f86df7b7 100644 --- a/src/App/ApplicationDirectories.h +++ b/src/App/ApplicationDirectories.h @@ -193,7 +193,6 @@ namespace App { void configureHelpDirectory(const std::map& mConfig); /*! - * \brief getCustomPaths * Returns a tuple of path names where to store config, data, and temp. files. * The method therefore reads the environment variables: * \list @@ -205,7 +204,6 @@ namespace App { static std::tuple getCustomPaths(); /*! - * \brief getStandardPaths * Returns a tuple of XDG-compliant standard paths names where to store config, data and cached files. * The method therefore reads the environment variables: * \list @@ -216,6 +214,12 @@ namespace App { */ std::tuple getStandardPaths(); + /// Find the BuildVersionMajor, BuildVersionMinor pair in the config map, convert them to an int tuple, and + /// return it. If the pair is not found, or cannot be converted to integers, a RuntimeError is raised. + /// \param config The config map to search. + /// \return The version tuple. + static std::tuple extractVersionFromConfigMap(const std::map &config); + private: std::tuple _currentVersion; std::filesystem::path _home; diff --git a/tests/src/App/ApplicationDirectories.cpp b/tests/src/App/ApplicationDirectories.cpp index dff98381a0..493c1348e1 100644 --- a/tests/src/App/ApplicationDirectories.cpp +++ b/tests/src/App/ApplicationDirectories.cpp @@ -46,6 +46,12 @@ public: { appendVersionIfPossible(basePath, subdirs); } + + std::tuple + wrapExtractVersionFromConfigMap(const std::map& config) + { + return extractVersionFromConfigMap(config); + } }; class ApplicationDirectoriesTest: public ::testing::Test @@ -70,11 +76,11 @@ protected: return config; } - std::shared_ptr makeAppDirsForVersion(int major, int minor) + std::unique_ptr makeAppDirsForVersion(int major, int minor) { auto configuration = generateConfig({{"BuildVersionMajor", std::to_string(major)}, {"BuildVersionMinor", std::to_string(minor)}}); - return std::make_shared(configuration); + return std::make_unique(configuration); } fs::path makePathForVersion(const fs::path& base, int major, int minor) @@ -125,7 +131,7 @@ TEST_F(ApplicationDirectoriesTest, usingCurrentVersionConfigFalseWhenDirDoesntMa // Act: generate a directory structure with the same version auto configuration = generateConfig({{"BuildVersionMajor", std::to_string(major + 1)}, {"BuildVersionMinor", std::to_string(minor)}}); - auto appDirs = std::make_shared(configuration); + auto appDirs = std::make_unique(configuration); // Assert EXPECT_FALSE(appDirs->usingCurrentVersionConfig(testPath)); @@ -582,7 +588,7 @@ TEST_F(ApplicationDirectoriesTest, migrateAllPathsProcessesMultipleInputs) } // Already versioned (final component is a version) -> no change -TEST_F(ApplicationDirectoriesTest, AppendVec_AlreadyVersioned_Bails) +TEST_F(ApplicationDirectoriesTest, appendVecAlreadyVersionedBails) { auto appDirs = makeAppDirsForVersion(5, 4); @@ -598,7 +604,7 @@ TEST_F(ApplicationDirectoriesTest, AppendVec_AlreadyVersioned_Bails) } // Base exists & current version dir present -> append current -TEST_F(ApplicationDirectoriesTest, AppendVec_BaseExists_AppendsCurrentWhenPresent) +TEST_F(ApplicationDirectoriesTest, appendVecBaseExistsAppendsCurrentWhenPresent) { auto appDirs = makeAppDirsForVersion(5, 4); @@ -614,7 +620,7 @@ TEST_F(ApplicationDirectoriesTest, AppendVec_BaseExists_AppendsCurrentWhenPresen } // Base exists, no current; lower minors exist -> append highest ≤ current in same major -TEST_F(ApplicationDirectoriesTest, AppendVec_PicksHighestLowerMinorInSameMajor) +TEST_F(ApplicationDirectoriesTest, appendVecPicksHighestLowerMinorInSameMajor) { auto appDirs = makeAppDirsForVersion(5, 4); @@ -632,7 +638,7 @@ TEST_F(ApplicationDirectoriesTest, AppendVec_PicksHighestLowerMinorInSameMajor) } // Base exists, nothing in current major; lower major exists -> append highest available lower major -TEST_F(ApplicationDirectoriesTest, AppendVec_FallsBackToLowerMajor) +TEST_F(ApplicationDirectoriesTest, appendVecFallsBackToLowerMajor) { auto appDirs = makeAppDirsForVersion(5, 4); @@ -648,7 +654,7 @@ TEST_F(ApplicationDirectoriesTest, AppendVec_FallsBackToLowerMajor) } // Base exists but contains no versioned subdirs -> append nothing (vector unchanged) -TEST_F(ApplicationDirectoriesTest, AppendVec_NoVersionedChildren_LeavesVectorUnchanged) +TEST_F(ApplicationDirectoriesTest, appendVecNoVersionedChildrenLeavesVectorUnchanged) { auto appDirs = makeAppDirsForVersion(5, 4); @@ -662,7 +668,7 @@ TEST_F(ApplicationDirectoriesTest, AppendVec_NoVersionedChildren_LeavesVectorUnc } // Base does not exist -> append current version string -TEST_F(ApplicationDirectoriesTest, AppendVec_BaseMissing_AppendsCurrentSuffix) +TEST_F(ApplicationDirectoriesTest, appendVecBaseMissingAppendsCurrentSuffix) { auto appDirs = makeAppDirsForVersion(5, 4); @@ -675,6 +681,70 @@ TEST_F(ApplicationDirectoriesTest, AppendVec_BaseMissing_AppendsCurrentSuffix) EXPECT_EQ(sub.back(), App::ApplicationDirectories::versionStringForPath(5, 4)); } +// Happy path: exact integers +TEST_F(ApplicationDirectoriesTest, extractVersionSucceedsWithPlainIntegers) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + std::map m {{"BuildVersionMajor", "7"}, {"BuildVersionMinor", "2"}}; + auto [maj, min] = appDirs->wrapExtractVersionFromConfigMap(m); + EXPECT_EQ(maj, 7); + EXPECT_EQ(min, 2); +} + +// Whitespace tolerated by std::stoi +TEST_F(ApplicationDirectoriesTest, extractVersionSucceedsWithWhitespace) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + std::map m {{"BuildVersionMajor", " 10 "}, + {"BuildVersionMinor", "\t3\n"}}; + auto [maj, min] = appDirs->wrapExtractVersionFromConfigMap(m); + EXPECT_EQ(maj, 10); + EXPECT_EQ(min, 3); +} + +// Missing major key -> rethrows as Base::RuntimeError +TEST_F(ApplicationDirectoriesTest, extractVersionMissingMajorThrowsRuntimeError) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + std::map m {{"BuildVersionMinor", "1"}}; + EXPECT_THROW(appDirs->wrapExtractVersionFromConfigMap(m), Base::RuntimeError); +} + +// Missing minor key -> rethrows as Base::RuntimeError +TEST_F(ApplicationDirectoriesTest, extractVersionMissingMinorThrowsRuntimeError) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + std::map m {{"BuildVersionMajor", "1"}}; + EXPECT_THROW(appDirs->wrapExtractVersionFromConfigMap(m), Base::RuntimeError); +} + +// Non-numeric -> std::stoi throws invalid_argument, rethrown as Base::RuntimeError +TEST_F(ApplicationDirectoriesTest, extractVersionNonNumericThrowsRuntimeError) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + std::map m {{"BuildVersionMajor", "abc"}, {"BuildVersionMinor", "2"}}; + EXPECT_THROW(appDirs->wrapExtractVersionFromConfigMap(m), Base::RuntimeError); +} + +// Overflow -> std::stoi throws out_of_range, rethrown as Base::RuntimeError +TEST_F(ApplicationDirectoriesTest, extractVersionOverflowThrowsRuntimeError) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + std::map m {{"BuildVersionMajor", "9999999999999999999999999"}, + {"BuildVersionMinor", "1"}}; + EXPECT_THROW(appDirs->wrapExtractVersionFromConfigMap(m), Base::RuntimeError); +} + +// Document current behavior: negative numbers are accepted and returned as-is +TEST_F(ApplicationDirectoriesTest, extractVersionNegativeNumbersPassThrough) +{ + auto appDirs = makeAppDirsForVersion(5, 4); + std::map m {{"BuildVersionMajor", "-2"}, {"BuildVersionMinor", "-7"}}; + auto [maj, min] = appDirs->wrapExtractVersionFromConfigMap(m); + EXPECT_EQ(maj, -2); + EXPECT_EQ(min, -7); +} + /* NOLINTEND( readability-magic-numbers, cppcoreguidelines-avoid-magic-numbers,