From 7ed4a9b731378390850a26be7db4d86d7f5d42ab Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Fri, 22 Aug 2025 22:08:16 -0500 Subject: [PATCH] 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