diff --git a/src/App/Application.cpp b/src/App/Application.cpp index b76a8a3b11..b69d139dc2 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::unique_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"; } +const std::unique_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 ? FileName : std::string())); } 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_unique(mConfig); if (vm.contains("safe-mode")) { SafeMode::StartSafeMode(); diff --git a/src/App/Application.h b/src/App/Application.h index 2a2d9e8f76..b1f482adc9 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 const std::unique_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::unique_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..011b37d8a8 --- /dev/null +++ b/src/App/ApplicationDirectories.cpp @@ -0,0 +1,733 @@ +// 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) +{ + _currentVersion = extractVersionFromConfigMap(config); + 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 { + 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 +{ + 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::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"); + + // 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(); + _usingCustomDirectories = !customHome.empty() || !customData.empty(); + + // 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 + // + auto dataSubdirs = subdirs; + appendVersionIfPossible(dataHome, dataSubdirs); + fs::path data = findPath(dataHome, customData, dataSubdirs, true); + _userAppData = data; + mConfig["UserAppData"] = Base::FileInfo::pathToString(data) + PATHSEP; + + + // User config path + // + auto configSubdirs = subdirs; + appendVersionIfPossible(configHome, configSubdirs); + fs::path config = findPath(configHome, customHome, configSubdirs, true); + _userConfig = config; + mConfig["UserConfigPath"] = Base::FileInfo::pathToString(config) + 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{"Macro"}; + fs::path macro = findPath(_userAppData, 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; +} + +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" +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)}; +} + + + +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 fmt::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 = Base::FileInfo::pathToString(config.filename()); + 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: " + Base::FileInfo::pathToString(newPath)); + } + fs::create_directories(newPath); + migrateConfig(path, newPath); + } +} + +// 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 + +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 new file mode 100644 index 0000000000..87f86df7b7 --- /dev/null +++ b/src/App/ApplicationDirectories.h @@ -0,0 +1,240 @@ +// 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. 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 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. 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 + /// 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(); + + /// 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(); +#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); + + 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, + 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); + + /*! + * 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(); + + /*! + * 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(); + + /// 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; + 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; + + bool _usingCustomDirectories {false}; + }; + +} // App + +#endif //SRC_APP_APPLICATIONDIRECTORIES_H_ diff --git a/src/App/CMakeLists.txt b/src/App/CMakeLists.txt index 943c57fcae..c0b83d7a26 100644 --- a/src/App/CMakeLists.txt +++ b/src/App/CMakeLists.txt @@ -274,6 +274,7 @@ SET(FreeCADApp_CPP_SRCS ${Document_CPP_SRCS} ${Properties_CPP_SRCS} Application.cpp + ApplicationDirectories.cpp ApplicationPy.cpp AutoTransaction.cpp Branding.cpp @@ -304,6 +305,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 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..0dd9551dc6 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,146 @@ 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 + 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()) { + knownVersions = splitCommas(offeredToMigrateToVersionedConfig); + } + + 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; diff --git a/tests/src/App/ApplicationDirectories.cpp b/tests/src/App/ApplicationDirectories.cpp new file mode 100644 index 0000000000..493c1348e1 --- /dev/null +++ b/tests/src/App/ApplicationDirectories.cpp @@ -0,0 +1,752 @@ +#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); + } + + std::tuple + wrapExtractVersionFromConfigMap(const std::map& config) + { + return extractVersionFromConfigMap(config); + } +}; + +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::unique_ptr makeAppDirsForVersion(int major, int minor) + { + auto configuration = generateConfig({{"BuildVersionMajor", std::to_string(major)}, + {"BuildVersionMinor", std::to_string(minor)}}); + return std::make_unique(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_unique(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, appendVecAlreadyVersionedBails) +{ + 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, appendVecBaseExistsAppendsCurrentWhenPresent) +{ + 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, appendVecPicksHighestLowerMinorInSameMajor) +{ + 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, appendVecFallsBackToLowerMajor) +{ + 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, appendVecNoVersionedChildrenLeavesVectorUnchanged) +{ + 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, appendVecBaseMissingAppendsCurrentSuffix) +{ + 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)); +} + +// 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, + 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