diff --git a/docs/sphinx/source/api/index.rst b/docs/sphinx/source/api/index.rst index 7da6b11..4fb3686 100644 --- a/docs/sphinx/source/api/index.rst +++ b/docs/sphinx/source/api/index.rst @@ -53,10 +53,6 @@ Some operations are exceptions to that rule: ``array::operator[]`` and ``array::value_at()`` — throw ``std::out_of_range`` when the key or index is not present. Ask ``dictionary::contains()`` or ``array::length()`` before indexing. -- ``path::expand()``, despite returning ``std::expected``, lets a - ``std::filesystem::filesystem_error`` escape for any canonicalisation - failure on a ``~`` path. ``path::resolve()`` catches those and returns - them. - ``path``'s ``exists()`` calls the throwing form of ``std::filesystem::exists``, so an error while querying the path — as opposed to the path simply being absent — escapes as a diff --git a/docs/sphinx/source/api/platform.rst b/docs/sphinx/source/api/platform.rst index 67bf0bf..dc85488 100644 --- a/docs/sphinx/source/api/platform.rst +++ b/docs/sphinx/source/api/platform.rst @@ -58,12 +58,12 @@ The ``path`` class provides filesystem path operations: dross::path config_path = home->append(".config").append("app"); std::cout << "Config path: " << config_path.string() << std::endl; - // Create the directory, including any missing parents. mkdir() - // reports failure when the directory is already there, so tell that - // case apart by its code(): zero means it was already present. + // Create the directory, including any missing parents. mkdir() is + // idempotent: it succeeds whether it creates the directory or + // finds it already there. if (auto created = dross::path::mkdir(config_path.string())) { std::cout << "Created: " << created->string() << std::endl; - } else if (created.error().code()) { + } else { std::cerr << "mkdir: " << created.error().what() << std::endl; } } @@ -85,12 +85,16 @@ The ``path`` class provides filesystem path operations: } // Expand a leading ~ to the home directory. For a ~ path, expand() - // canonicalises without catching, so any canonicalisation failure -- - // a missing target, a permission problem, a symlink loop -- escapes as - // a filesystem_error instead of being returned. resolve() catches it. + // canonicalises and turns any failure the standard library reports as + // a std::filesystem::filesystem_error -- a missing target, a + // permission problem, a symlink loop -- into the returned + // std::expected instead of letting it escape. A path that does not + // begin with ~ is returned unchanged. dross::path user_config{std::string{"~/.config/app"}}; if (auto expanded = user_config.expand()) { std::cout << "Expanded: " << expanded->string() << std::endl; + } else { + std::cerr << "Expand failed: " << expanded.error().what() << std::endl; } Path Operations @@ -120,20 +124,30 @@ Operations that consult the filesystem: Some caveats apply to the current implementation: -- ``mkdir()`` succeeds only when it actually creates the directory. If the - path already exists it returns an error, but one whose ``code()`` is zero, - so a caller can tell it apart from a real filesystem failure, which - carries a nonzero code. Testing with ``exists()`` beforehand is not a - better answer — see below, and it races with other processes anyway. -- ``expand()`` does not route every failure through its return type. For a - path beginning with ``~`` it canonicalises without catching, so *any* - canonicalisation failure — a missing target, a permission problem, a - symlink loop, an invalid component — escapes as a - ``std::filesystem::filesystem_error`` instead of being returned, which - terminates a program that is not catching it. A path that does not begin - with ``~`` is returned unchanged and never throws. ``resolve()`` catches - the same failures and returns them, so prefer it when the target may not - be reachable. +- ``mkdir()`` is idempotent: it succeeds whether it creates + the directory or finds it already there. It fails only when + ``std::filesystem::create_directories`` reports an actual error, such as + a path component that exists and is not a directory. The operation is + not atomic — directories created before the failure may remain. Some + failures are rejected before anything is created at all. Because an + already-present directory is accepted without inspection, a directory, + or a symbolic link that resolves to one, left there by another party is + accepted too. Checking beforehand does not close that gap — the check + and the use are separate operations, and the entry can be replaced in + between. This call does not check who owns the directories along the + path, what their permissions are, or where any links beneath them point, + and it does not set the permissions of the directories it creates: those + are left to the platform's default for new directories, which can be + group- or world-writable. A caller who needs any of that has to arrange + it separately. +- ``expand()`` routes canonicalisation failures through its return type. + For a path beginning with ``~`` it canonicalises and converts any error + the standard library reports as a ``std::filesystem::filesystem_error`` + — a missing target, a permission problem, a symlink loop, an invalid + component — into the returned ``std::expected`` rather than letting it + escape. A path that does not begin with ``~`` is returned unchanged. + Home directory resolution failing (``path::home()`` returning + ``std::nullopt``) is reported the same way. - ``exists()`` calls the throwing form of ``std::filesystem::exists``. An absent path is simply ``false``, but an error while querying it — an over-long name, or a directory the process may not traverse — escapes as a @@ -197,9 +211,8 @@ the application name passed to the constructor: Every accessor returns ``std::optional`` and yields ``std::nullopt`` when the home directory cannot be determined. The directory -itself is not created for you — pass the result to ``path::mkdir()``, keeping -in mind that ``mkdir()`` reports an already-present directory as an error, -recognisable by its zero ``code()``. +itself is not created for you — pass the result to ``path::mkdir()``, which +accepts an already-present directory as success. Example Usage ~~~~~~~~~~~~~ @@ -216,12 +229,11 @@ Creating application directories: dross::xdg app{"myapp"}; // Create the config directory, then name a file inside it. mkdir() - // reports an already-present directory as a failure too, but with a - // zero code(), so only a nonzero one is a real problem. + // accepts an already-present directory as success, so any failure + // here is a real problem. if (auto config_home = app.config_home()) { const dross::path config_dir{*config_home}; - if (auto created = dross::path::mkdir(*config_home); - !created && created.error().code()) { + if (auto created = dross::path::mkdir(*config_home); !created) { std::cerr << "mkdir: " << created.error().what() << std::endl; } dross::path config_file = config_dir.append("settings.toml"); diff --git a/docs/sphinx/source/changelog.rst b/docs/sphinx/source/changelog.rst index 43e9f10..27605ef 100644 --- a/docs/sphinx/source/changelog.rst +++ b/docs/sphinx/source/changelog.rst @@ -29,6 +29,12 @@ Changed - Enhanced error handling: ``timezone::from_string()`` returns ``std::optional`` - Simplified API: removed redundant timezone methods (``is_local()``, ``has_offset()``) - Updated documentation to reflect timestamp and timezone APIs +- ``path::mkdir()`` now succeeds when the target directory already exists + instead of reporting it as a failure with a zero ``code()``; it fails + only when the underlying filesystem operation reports an actual error +- ``path::expand()`` now reports canonicalisation failures on a ``~`` path + through its ``std::expected`` return value instead of letting a + ``std::filesystem::filesystem_error`` escape v0.1.0 - 2024-01-20 ------------------- diff --git a/docs/sphinx/source/user-guide/index.rst b/docs/sphinx/source/user-guide/index.rst index dfcdbe9..7fca7c2 100644 --- a/docs/sphinx/source/user-guide/index.rst +++ b/docs/sphinx/source/user-guide/index.rst @@ -155,9 +155,8 @@ Platform Utilities xdg app{"myapp"}; const std::string app_data = app.data_home().value_or(config_path.string()); - // Create the directory. mkdir() succeeds only when it actually creates - // one, so an already-present directory comes back as an error too -- - // but with a zero code(), unlike a real failure. - if (auto result = path::mkdir(app_data); !result && result.error().code()) { + // Create the directory. mkdir() is idempotent: it succeeds whether it + // creates the directory or finds it already there. + if (auto result = path::mkdir(app_data); !result) { std::cerr << "mkdir: " << result.error().what() << std::endl; } diff --git a/include/dross/platform.h b/include/dross/platform.h index 2758168..5908415 100644 --- a/include/dross/platform.h +++ b/include/dross/platform.h @@ -26,7 +26,7 @@ * // Filesystem operations * path config_dir = path::home().value_or(path{"/tmp"}) / "myapp"; * if (auto result = path::mkdir(config_dir.string()); result) { - * // Directory created successfully + * // Directory created, or already there * } * * // XDG directories @@ -40,8 +40,8 @@ * - Uses std::optional for operations that may not return a value * - Uses std::expected for operations that may fail with detailed error info * - The platform layer throws nothing of its own, but std::filesystem - * exceptions do propagate: see path::exists(), path::expand() and the - * default path constructor + * exceptions do propagate: see path::exists() and the default path + * constructor * * Platform support: * - Unix-like systems (Linux, macOS, BSD) diff --git a/include/dross/platform/path.h b/include/dross/platform/path.h index 94deb4a..20e3429 100644 --- a/include/dross/platform/path.h +++ b/include/dross/platform/path.h @@ -26,8 +26,8 @@ namespace dross { * - Uses std::expected for fallible operations * - Uses std::optional for operations that may not return a value * - No exceptions thrown directly, but std::filesystem ones propagate: - * exists(), expand() on a ~ path, and the default constructor all call - * throwing std::filesystem functions + * exists() and the default constructor call throwing std::filesystem + * functions * * Performance characteristics: * - Thin wrapper over std::filesystem with minimal overhead @@ -71,10 +71,11 @@ class path { * @return Expected containing the created path on success, or filesystem_error on failure * * Creates the specified directory and any necessary parent directories. - * Succeeds only when a directory is actually created: if dir_path is - * already present the call reports failure. That case is still - * recognisable — the reported error's code() is zero, whereas a real - * filesystem failure carries a nonzero code. + * Succeeds both when it creates the directory and when dir_path is + * already a directory — the call is idempotent. It fails only when the + * underlying std::filesystem::create_directories call reports an + * actual error. See the std::filesystem::path overload for the + * failure and safety notes. * * @code * if (auto result = path::mkdir("/tmp/myapp/data")) { @@ -91,11 +92,26 @@ class path { * @param dir_path The directory path to create as a filesystem::path * @return Expected containing the created path on success, or filesystem_error on failure * - * Creates the specified directory and any necessary parent directories. - * Succeeds only when a directory is actually created: if dir_path is - * already present the call reports failure, with an error whose code() - * is zero; a real filesystem failure carries a nonzero code. This - * overload holds the logic; the std::string one forwards to it. + * Creates the specified directory and any necessary parent + * directories. Succeeds both when it creates the directory and + * when dir_path is already a directory — the call is idempotent, + * closer to "ensure this directory exists" than a strict create. It + * fails only when std::filesystem::create_directories reports an + * actual error, for example when a path component exists and is not + * a directory. The operation is not atomic — directories created + * before the failure may remain. Some failures are rejected before + * anything is created at all. Because an already-present directory is + * accepted without inspection, a directory, or a symbolic link that + * resolves to one, left there by another party is accepted too. + * Checking beforehand does not close that gap — the check and the use + * are separate operations, and the entry can be replaced in between. + * This call does not check who owns the directories along the path, + * what their permissions are, or where any links beneath them point, + * and it does not set the permissions of the directories it creates: + * those are left to the platform's default for new directories, which + * can be group- or world-writable. A caller who needs any of that has + * to arrange it separately. This overload holds the logic; the + * std::string one forwards to it. */ static std::expected mkdir(const std::filesystem::path& dir_path); @@ -200,14 +216,22 @@ class path { /** * @brief Expand user home directory (~) in the path. * @return Expected containing the expanded path on success, or filesystem_error on failure - * - * Expands tilde (~) notation to the actual home directory path. - * Only processes paths that start with "~" or "~/". - * + * + * Expands tilde (~) notation to the actual home directory path, then + * canonicalises the result — the returned path has symbolic links + * resolved. Because canonicalisation requires the target to exist, + * expand() returns unexpected when the expanded path does not (yet) + * exist. A path that does not start with "~" is returned unchanged + * and always succeeds. + * * @code - * path user_config{"~/.config/myapp"}; + * path user_config{std::string{"~/.config/myapp"}}; * if (auto expanded = user_config.expand()) { * // expanded contains something like "/home/user/.config/myapp" + * } else { + * // Suppose the target does not exist yet. mkdir() does not + * // expand ~, so build the path from path::home() before + * // creating it. * } * @endcode */ diff --git a/include/dross/platform/xdg.h b/include/dross/platform/xdg.h index eaa9a01..f7f9921 100644 --- a/include/dross/platform/xdg.h +++ b/include/dross/platform/xdg.h @@ -99,7 +99,8 @@ class xdg { * // Typically returns something like "/home/user/.config/myapp" * path config_path{*config_dir}; * if (auto result = path::mkdir(config_path.string())) { - * // Directory created and ready for config files + * // Directory ready for config files, whether just created or + * // already there * } * } * @endcode diff --git a/src/platform/path.cpp b/src/platform/path.cpp index d94b8eb..39ab4e4 100644 --- a/src/platform/path.cpp +++ b/src/platform/path.cpp @@ -14,13 +14,13 @@ std::expected path::mkdir(const std::st std::expected path::mkdir(const std::filesystem::path& absolute_path) { - try { - std::error_code err; - if (! std::filesystem::create_directories(absolute_path, err)) { - return std::unexpected(std::filesystem::filesystem_error("failed", absolute_path, err)); - } - } catch (const std::filesystem::filesystem_error& e) { - return std::unexpected(e); + std::error_code err; + // create_directories() returns false both when the directory already + // existed and when it failed to create one, so the return value alone + // cannot tell success from failure; check err instead. + std::filesystem::create_directories(absolute_path, err); + if (err) { + return std::unexpected(std::filesystem::filesystem_error("failed", absolute_path, err)); } return path{absolute_path}; @@ -97,7 +97,11 @@ std::expected path::expand() const return std::make_optional(p.append(_path.string().replace(0, 1, "")).string()); }); if (expanded) { - return path { std::filesystem::canonical(std::filesystem::path{expanded.value()}) }; + try { + return path { std::filesystem::canonical(std::filesystem::path{expanded.value()}) }; + } catch (const std::filesystem::filesystem_error& e) { + return std::unexpected(e); + } } else { return std::unexpected(std::filesystem::filesystem_error("fail to expand tilde", _path, std::make_error_code(std::errc::no_such_file_or_directory))); } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 900d8f2..8d2707e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -19,10 +19,13 @@ add_executable(${TEST_NAME}) target_sources(${TEST_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/platform/test_environment.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/platform/test_path.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/platform/test_xdg.cpp ${CMAKE_CURRENT_SOURCE_DIR}/type/test_array.cpp ${CMAKE_CURRENT_SOURCE_DIR}/type/test_boolean.cpp ${CMAKE_CURRENT_SOURCE_DIR}/type/test_data.cpp ${CMAKE_CURRENT_SOURCE_DIR}/type/test_dictionary.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/type/test_error.cpp ${CMAKE_CURRENT_SOURCE_DIR}/type/test_number.cpp ${CMAKE_CURRENT_SOURCE_DIR}/type/test_string.cpp ${CMAKE_CURRENT_SOURCE_DIR}/type/test_timestamp.cpp diff --git a/test/platform/test_path.cpp b/test/platform/test_path.cpp new file mode 100644 index 0000000..787dec1 --- /dev/null +++ b/test/platform/test_path.cpp @@ -0,0 +1,308 @@ +#include + +#include "dross/platform/path.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Process-unique scratch directory (CWE-377: never a predictable fixed +// name). Removes itself and everything created inside it on destruction, +// including when a test exits early via an ASSERT_* failure: ASSERT_* +// returns from the test function rather than throwing, so the stack still +// unwinds normally and the destructor still runs. +class scoped_temp_dir { +public: + scoped_temp_dir() + : _path(std::filesystem::temp_directory_path() + / ("dross_path_test_" + std::to_string(::getpid()) + "_" + std::to_string(_next_id++))) + { + std::filesystem::create_directories(_path); + } + + ~scoped_temp_dir() + { + std::error_code ignored; + std::filesystem::remove_all(_path, ignored); + } + + scoped_temp_dir(const scoped_temp_dir&) = delete; + scoped_temp_dir& operator=(const scoped_temp_dir&) = delete; + + const std::filesystem::path& path() const { return _path; } + +private: + std::filesystem::path _path; + static inline unsigned _next_id = 0; +}; + +// Saves and restores a single environment variable across a test, even +// when the test exits early via an ASSERT_* failure. Restores "unset" as +// unset rather than as an empty string. +class scoped_env_var { +public: + explicit scoped_env_var(std::string name) + : _name(std::move(name)) + { + if (const char* v = std::getenv(_name.c_str())) { + _original = std::string(v); + } + } + + ~scoped_env_var() + { + if (_original) { + setenv(_name.c_str(), _original->c_str(), 1); + } else { + unsetenv(_name.c_str()); + } + } + + scoped_env_var(const scoped_env_var&) = delete; + scoped_env_var& operator=(const scoped_env_var&) = delete; + + void set(const std::string& value) const + { + setenv(_name.c_str(), value.c_str(), 1); + } + + void unset() const + { + unsetenv(_name.c_str()); + } + +private: + std::string _name; + std::optional _original; +}; + +void write_regular_file(const std::filesystem::path& p) +{ + std::ofstream out(p); + out << "content"; +} + +} + +// --- path::mkdir ------------------------------------------------------- + +TEST(path_test, mkdir_creates_a_new_directory) +{ + const scoped_temp_dir base; + const auto target = base.path() / "created"; + + const auto result = dross::path::mkdir(target.string()); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->string(), target.string()); + EXPECT_TRUE(std::filesystem::is_directory(target)); +} + +TEST(path_test, mkdir_creates_nested_parent_directories) +{ + const scoped_temp_dir base; + const auto target = base.path() / "a" / "b" / "c"; + + const auto result = dross::path::mkdir(target.string()); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(std::filesystem::is_directory(target)); +} + +TEST(path_test, mkdir_on_an_existing_directory_succeeds) +{ + const scoped_temp_dir base; + + const auto result = dross::path::mkdir(base.path().string()); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->string(), base.path().string()); +} + +TEST(path_test, mkdir_called_twice_on_the_same_directory_succeeds_both_times) +{ + const scoped_temp_dir base; + const auto target = base.path() / "repeat"; + + const auto first = dross::path::mkdir(target.string()); + const auto second = dross::path::mkdir(target.string()); + + ASSERT_TRUE(first.has_value()); + ASSERT_TRUE(second.has_value()); + EXPECT_EQ(second->string(), target.string()); +} + +TEST(path_test, mkdir_fails_when_a_path_component_is_a_regular_file) +{ + // The true-failure case is exercised through ENOTDIR (a parent path + // component that is a regular file) rather than through a permission + // failure, so the assertion still holds when the suite runs as root, + // where permission checks are bypassed. + const scoped_temp_dir base; + const auto blocking_file = base.path() / "not_a_directory"; + write_regular_file(blocking_file); + const auto target = blocking_file / "child"; + + const auto result = dross::path::mkdir(target.string()); + + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().code().value(), 0); +} + +TEST(path_test, mkdir_target_is_an_existing_regular_file) +{ + // Boundary case: the target itself already exists but is not a + // directory. This must not be folded into the "existing directory" + // success case. + const scoped_temp_dir base; + const auto target = base.path() / "already_a_file"; + write_regular_file(target); + + const auto result = dross::path::mkdir(target.string()); + + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().code().value(), 0); +} + +TEST(path_test, mkdir_with_an_empty_path) +{ + // Boundary case: an empty path must not be silently accepted as an + // "already exists" success. + const auto result = dross::path::mkdir(std::string{}); + + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().code().value(), 0); +} + +TEST(path_test, mkdir_on_a_symlink_that_resolves_to_a_directory_succeeds) +{ + // Documented behavior: an already-present directory is accepted + // without inspection, and that includes a symbolic link that + // resolves to one. + const scoped_temp_dir base; + const auto real_dir = base.path() / "real_dir"; + std::filesystem::create_directories(real_dir); + const auto link = base.path() / "link_to_dir"; + std::filesystem::create_directory_symlink(real_dir, link); + + const auto result = dross::path::mkdir(link.string()); + + ASSERT_TRUE(result.has_value()); +} + +// --- path::expand -------------------------------------------------------- + +TEST(path_test, expand_of_a_tilde_path_matches_a_freshly_computed_canonical_path) +{ + const dross::path p{std::string{"~"}}; + + const auto result = p.expand(); + + ASSERT_TRUE(result.has_value()); + const auto home = dross::path::home(); + ASSERT_TRUE(home.has_value()); + const auto expected = std::filesystem::canonical(std::filesystem::path{home->string()}); + EXPECT_EQ(result->string(), expected.string()); +} + +TEST(path_test, expand_of_a_tilde_path_resolves_through_a_symlinked_home) +{ + const scoped_temp_dir base; + const auto real_home = base.path() / "real_home"; + std::filesystem::create_directories(real_home); + const auto home_link = base.path() / "home_link"; + std::filesystem::create_directory_symlink(real_home, home_link); + + scoped_env_var home("HOME"); + home.set(home_link.string()); + + const dross::path p{std::string{"~"}}; + const auto result = p.expand(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->string(), std::filesystem::canonical(real_home).string()); +} + +TEST(path_test, expand_of_a_tilde_path_to_a_nonexistent_target_does_not_terminate) +{ + // Regression test: std::filesystem::canonical() throws on a missing + // path. expand() must report that as std::unexpected instead of + // letting the exception escape, which previously terminated the + // process. Guard that the regression path is actually exercised: if + // home() were nullopt, expand() would fail through the other branch + // and this test would pass without ever reaching canonical(). + ASSERT_TRUE(dross::path::home().has_value()); + + const std::string tilde_path = "~/dross_test_nonexistent_" + std::to_string(::getpid()); + const dross::path p{tilde_path}; + + const auto result = p.expand(); + + EXPECT_FALSE(result.has_value()); +} + +TEST(path_test, expand_of_a_non_tilde_path_is_returned_unchanged) +{ + const std::string original = "relative/does/not/exist"; + const dross::path p{original}; + + const auto result = p.expand(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->string(), original); +} + +// --- path::resolve --------------------------------------------------------- + +TEST(path_test, resolve_of_an_existing_path_succeeds_in_canonical_form) +{ + const scoped_temp_dir base; + const dross::path p{base.path()}; + + const auto result = p.resolve(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->string(), std::filesystem::canonical(base.path()).string()); +} + +TEST(path_test, resolve_of_a_missing_path_returns_unexpected) +{ + const scoped_temp_dir base; + const dross::path p{base.path() / "does_not_exist"}; + + const auto result = p.resolve(); + + EXPECT_FALSE(result.has_value()); +} + +// --- path::exists / default constructor (pinning current, unmodified behavior) --- + +TEST(path_test, exists_is_false_for_a_missing_path) +{ + const scoped_temp_dir base; + const dross::path p{base.path() / "does_not_exist"}; + + EXPECT_FALSE(p.exists()); +} + +TEST(path_test, exists_is_true_for_a_present_path) +{ + const scoped_temp_dir base; + const dross::path p{base.path()}; + + EXPECT_TRUE(p.exists()); +} + +TEST(path_test, default_constructor_refers_to_the_current_working_directory) +{ + const dross::path p; + + EXPECT_TRUE(std::filesystem::equivalent(std::filesystem::path(p), std::filesystem::current_path())); +} diff --git a/test/platform/test_xdg.cpp b/test/platform/test_xdg.cpp new file mode 100644 index 0000000..f9038f8 --- /dev/null +++ b/test/platform/test_xdg.cpp @@ -0,0 +1,164 @@ +#include + +#include "dross/platform/xdg.h" + +#include +#include +#include +#include + +namespace { + +// Saves and restores a single environment variable across a test, even +// when the test exits early via an ASSERT_* failure. Restores "unset" as +// unset rather than as an empty string. +class scoped_env_var { +public: + explicit scoped_env_var(std::string name) + : _name(std::move(name)) + { + if (const char* v = std::getenv(_name.c_str())) { + _original = std::string(v); + } + } + + ~scoped_env_var() + { + if (_original) { + setenv(_name.c_str(), _original->c_str(), 1); + } else { + unsetenv(_name.c_str()); + } + } + + scoped_env_var(const scoped_env_var&) = delete; + scoped_env_var& operator=(const scoped_env_var&) = delete; + + void set(const std::string& value) const + { + setenv(_name.c_str(), value.c_str(), 1); + } + + void unset() const + { + unsetenv(_name.c_str()); + } + +private: + std::string _name; + std::optional _original; +}; + +} + +// --- config_home ----------------------------------------------------------- + +TEST(xdg_test, config_home_uses_xdg_config_home_when_set) +{ + scoped_env_var xdg_config_home("XDG_CONFIG_HOME"); + xdg_config_home.set("/tmp/dross_xdg_test/xdg_config_home"); + + const dross::xdg app{"myapp"}; + const auto result = app.config_home(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "/tmp/dross_xdg_test/xdg_config_home/myapp"); +} + +TEST(xdg_test, config_home_falls_back_to_home_dot_config_when_unset) +{ + scoped_env_var xdg_config_home("XDG_CONFIG_HOME"); + xdg_config_home.unset(); + scoped_env_var home("HOME"); + home.set("/tmp/dross_xdg_test/home"); + + const dross::xdg app{"myapp"}; + const auto result = app.config_home(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "/tmp/dross_xdg_test/home/.config/myapp"); +} + +// --- data_home --------------------------------------------------------- + +TEST(xdg_test, data_home_uses_xdg_data_home_when_set) +{ + scoped_env_var xdg_data_home("XDG_DATA_HOME"); + xdg_data_home.set("/tmp/dross_xdg_test/xdg_data_home"); + + const dross::xdg app{"myapp"}; + const auto result = app.data_home(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "/tmp/dross_xdg_test/xdg_data_home/myapp"); +} + +TEST(xdg_test, data_home_falls_back_to_home_dot_local_share_when_unset) +{ + scoped_env_var xdg_data_home("XDG_DATA_HOME"); + xdg_data_home.unset(); + scoped_env_var home("HOME"); + home.set("/tmp/dross_xdg_test/home"); + + const dross::xdg app{"myapp"}; + const auto result = app.data_home(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "/tmp/dross_xdg_test/home/.local/share/myapp"); +} + +// --- cache_home -------------------------------------------------------- + +TEST(xdg_test, cache_home_uses_xdg_cache_home_when_set) +{ + scoped_env_var xdg_cache_home("XDG_CACHE_HOME"); + xdg_cache_home.set("/tmp/dross_xdg_test/xdg_cache_home"); + + const dross::xdg app{"myapp"}; + const auto result = app.cache_home(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "/tmp/dross_xdg_test/xdg_cache_home/myapp"); +} + +TEST(xdg_test, cache_home_falls_back_to_home_dot_cache_when_unset) +{ + scoped_env_var xdg_cache_home("XDG_CACHE_HOME"); + xdg_cache_home.unset(); + scoped_env_var home("HOME"); + home.set("/tmp/dross_xdg_test/home"); + + const dross::xdg app{"myapp"}; + const auto result = app.cache_home(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "/tmp/dross_xdg_test/home/.cache/myapp"); +} + +// --- state_home -------------------------------------------------------- + +TEST(xdg_test, state_home_uses_xdg_state_home_when_set) +{ + scoped_env_var xdg_state_home("XDG_STATE_HOME"); + xdg_state_home.set("/tmp/dross_xdg_test/xdg_state_home"); + + const dross::xdg app{"myapp"}; + const auto result = app.state_home(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "/tmp/dross_xdg_test/xdg_state_home/myapp"); +} + +TEST(xdg_test, state_home_falls_back_to_home_dot_local_state_when_unset) +{ + scoped_env_var xdg_state_home("XDG_STATE_HOME"); + xdg_state_home.unset(); + scoped_env_var home("HOME"); + home.set("/tmp/dross_xdg_test/home"); + + const dross::xdg app{"myapp"}; + const auto result = app.state_home(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "/tmp/dross_xdg_test/home/.local/state/myapp"); +} diff --git a/test/type/test_error.cpp b/test/type/test_error.cpp new file mode 100644 index 0000000..352a048 --- /dev/null +++ b/test/type/test_error.cpp @@ -0,0 +1,102 @@ +#include + +#include "dross/type/error.h" + +#include +#include +#include +#include +#include + +TEST(error_test, default_constructed_error_is_falsy) +{ + const dross::error e; + + EXPECT_FALSE(static_cast(e)); +} + +TEST(error_test, constructed_from_an_error_code_enum_is_truthy) +{ + // io_errc is a genuine std::is_error_code_enum type (unlike std::errc, + // which is an error_condition enum), so it exercises the templated + // constructor the way it is actually meant to be used. + const dross::error e(std::io_errc::stream); + + EXPECT_TRUE(static_cast(e)); +} + +TEST(error_test, code_and_message_reflect_the_underlying_error_code) +{ + const auto ec = std::make_error_code(std::io_errc::stream); + const dross::error e(std::io_errc::stream); + + EXPECT_EQ(e.code(), ec.value()); + EXPECT_EQ(e.message(), ec.message()); + EXPECT_EQ(e.domain(), ec.category().name()); +} + +TEST(error_test, category_and_condition_match_the_error_code_enum) +{ + const auto ec = std::make_error_code(std::io_errc::stream); + const dross::error e(std::io_errc::stream); + + EXPECT_EQ(e.category(), std::iostream_category()); + EXPECT_EQ(e.condition(), ec.default_error_condition()); +} + +TEST(error_test, equality_operator_compares_against_the_error_code_enum) +{ + const dross::error e(std::io_errc::stream); + + EXPECT_TRUE(e == std::io_errc::stream); + EXPECT_FALSE(e != std::io_errc::stream); +} + +TEST(error_test, equality_operator_compares_against_the_error_category) +{ + const dross::error e(std::io_errc::stream); + + EXPECT_TRUE(e == std::iostream_category()); + EXPECT_FALSE(e == std::generic_category()); + EXPECT_TRUE(e != std::generic_category()); +} + +TEST(error_test, copy_constructor_preserves_the_underlying_code) +{ + const dross::error original(1, std::generic_category()); + const dross::error copy(original); + + EXPECT_EQ(copy.code(), original.code()); + EXPECT_EQ(copy.domain(), original.domain()); +} + +TEST(error_test, three_way_comparison_orders_by_the_underlying_error_code) +{ + // dross::error has no operator==(const error&) of its own (only the + // category/enum overloads), so equality here is checked through <=>. + const dross::error smaller(0, std::generic_category()); + const dross::error larger(1, std::generic_category()); + + EXPECT_TRUE(smaller < larger); + EXPECT_TRUE(larger > smaller); + EXPECT_TRUE((smaller <=> smaller) == std::strong_ordering::equal); +} + +TEST(error_test, stream_insertion_writes_domain_and_code) +{ + const dross::error e(1, std::generic_category()); + + std::ostringstream out; + out << e; + + const std::string expected = e.domain() + ":" + std::to_string(e.code()); + EXPECT_EQ(out.str(), expected); +} + +TEST(error_test, value_and_category_constructor) +{ + const dross::error e(5, std::generic_category()); + + EXPECT_EQ(e.code(), 5); + EXPECT_EQ(e.category(), std::generic_category()); +}