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