diff --git a/src/lang/io/include/sourcemeta/core/io.h b/src/lang/io/include/sourcemeta/core/io.h index b7c0c2533..ac607c8e8 100644 --- a/src/lang/io/include/sourcemeta/core/io.h +++ b/src/lang/io/include/sourcemeta/core/io.h @@ -100,6 +100,22 @@ auto read_file(const std::filesystem::path &path) return stream; } +/// @ingroup io +/// +/// Recursively mirror a directory tree using hard links for regular files. +/// Directories are created, regular files are hard-linked. Both paths must +/// reside on the same filesystem. The destination must not be inside the +/// source tree, as that would cause infinite recursion. +/// +/// ```cpp +/// #include +/// +/// sourcemeta::core::hardlink_directory("/source", "/destination"); +/// ``` +SOURCEMETA_CORE_IO_EXPORT +auto hardlink_directory(const std::filesystem::path &source, + const std::filesystem::path &destination) -> void; + /// @ingroup io /// /// Flush an existing file to disk, beyond just to the operating system. For diff --git a/src/lang/io/io.cc b/src/lang/io/io.cc index 0c09bce83..745ddb56e 100644 --- a/src/lang/io/io.cc +++ b/src/lang/io/io.cc @@ -47,6 +47,25 @@ auto starts_with(const std::filesystem::path &path, return true; } +auto hardlink_directory(const std::filesystem::path &source, + const std::filesystem::path &destination) -> void { + assert(std::filesystem::is_directory(source)); + assert(!std::filesystem::exists(destination) || + std::filesystem::is_directory(destination)); + assert(!starts_with(destination, source)); + std::filesystem::create_directories(destination); + for (const auto &entry : + std::filesystem::recursive_directory_iterator{source}) { + const auto target{destination / + std::filesystem::relative(entry.path(), source)}; + if (entry.is_directory()) { + std::filesystem::create_directories(target); + } else if (entry.is_regular_file()) { + std::filesystem::create_hard_link(entry.path(), target); + } + } +} + auto flush(const std::filesystem::path &path) -> void { #if defined(_WIN32) HANDLE hFile = diff --git a/test/io/CMakeLists.txt b/test/io/CMakeLists.txt index 33d202a26..55500c59b 100644 --- a/test/io/CMakeLists.txt +++ b/test/io/CMakeLists.txt @@ -6,9 +6,11 @@ sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME io io_starts_with_test.cc io_read_file_test.cc io_fileview_test.cc - io_temporary_test.cc) + io_temporary_test.cc + io_hardlink_directory_test.cc) target_link_libraries(sourcemeta_core_io_unit PRIVATE sourcemeta::core::io) target_compile_definitions(sourcemeta_core_io_unit - PRIVATE STUBS_DIRECTORY="${CMAKE_CURRENT_SOURCE_DIR}/stubs") + PRIVATE STUBS_DIRECTORY="${CMAKE_CURRENT_SOURCE_DIR}/stubs" + PRIVATE BUILD_DIRECTORY="${CMAKE_CURRENT_BINARY_DIR}") diff --git a/test/io/io_hardlink_directory_test.cc b/test/io/io_hardlink_directory_test.cc new file mode 100644 index 000000000..95cc2adf9 --- /dev/null +++ b/test/io/io_hardlink_directory_test.cc @@ -0,0 +1,101 @@ +#include + +#include + +#include // std::filesystem +#include // std::ofstream, std::ifstream +#include // std::string + +TEST(IO_hardlink_directory, mirrors_structure) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"}; + const auto source{workspace.path() / "source"}; + const auto destination{workspace.path() / "destination"}; + + std::filesystem::create_directories(source / "subdir"); + std::ofstream{source / "a.txt"} << "alpha"; + std::ofstream{source / "subdir" / "b.txt"} << "beta"; + + sourcemeta::core::hardlink_directory(source, destination); + + EXPECT_TRUE(std::filesystem::exists(destination / "a.txt")); + EXPECT_TRUE(std::filesystem::exists(destination / "subdir" / "b.txt")); + EXPECT_TRUE(std::filesystem::is_directory(destination / "subdir")); +} + +TEST(IO_hardlink_directory, files_share_inodes) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"}; + const auto source{workspace.path() / "source"}; + const auto destination{workspace.path() / "destination"}; + + std::filesystem::create_directory(source); + std::ofstream{source / "file.txt"} << "content"; + + sourcemeta::core::hardlink_directory(source, destination); + + EXPECT_EQ(std::filesystem::hard_link_count(source / "file.txt"), 2); + EXPECT_EQ(std::filesystem::hard_link_count(destination / "file.txt"), 2); + EXPECT_TRUE(std::filesystem::equivalent(source / "file.txt", + destination / "file.txt")); +} + +TEST(IO_hardlink_directory, unlink_then_write_independence) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"}; + const auto source{workspace.path() / "source"}; + const auto destination{workspace.path() / "destination"}; + + std::filesystem::create_directory(source); + std::ofstream{source / "file.txt"} << "original"; + + sourcemeta::core::hardlink_directory(source, destination); + + std::filesystem::remove(destination / "file.txt"); + std::ofstream{destination / "file.txt"} << "modified"; + + std::ifstream source_stream{source / "file.txt"}; + std::string source_content; + std::getline(source_stream, source_content); + EXPECT_EQ(source_content, "original"); + + std::ifstream destination_stream{destination / "file.txt"}; + std::string destination_content; + std::getline(destination_stream, destination_content); + EXPECT_EQ(destination_content, "modified"); +} + +TEST(IO_hardlink_directory, empty_source) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"}; + const auto source{workspace.path() / "source"}; + const auto destination{workspace.path() / "destination"}; + + std::filesystem::create_directory(source); + + sourcemeta::core::hardlink_directory(source, destination); + + EXPECT_TRUE(std::filesystem::exists(destination)); + EXPECT_TRUE(std::filesystem::is_directory(destination)); + EXPECT_TRUE(std::filesystem::is_empty(destination)); +} + +TEST(IO_hardlink_directory, deeply_nested) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"}; + const auto source{workspace.path() / "source"}; + const auto destination{workspace.path() / "destination"}; + + std::filesystem::create_directories(source / "a" / "b" / "c"); + std::ofstream{source / "a" / "b" / "c" / "deep.txt"} << "deep"; + + sourcemeta::core::hardlink_directory(source, destination); + + EXPECT_TRUE( + std::filesystem::exists(destination / "a" / "b" / "c" / "deep.txt")); + + std::ifstream stream{destination / "a" / "b" / "c" / "deep.txt"}; + std::string content; + std::getline(stream, content); + EXPECT_EQ(content, "deep"); +} diff --git a/test/io/io_temporary_test.cc b/test/io/io_temporary_test.cc index 1d7917ede..46e8626b7 100644 --- a/test/io/io_temporary_test.cc +++ b/test/io/io_temporary_test.cc @@ -7,21 +7,21 @@ #include // std::string TEST(IO_TemporaryDirectory, creates_directory) { - const auto parent{std::filesystem::temp_directory_path()}; + const auto parent{std::filesystem::path{BUILD_DIRECTORY}}; const sourcemeta::core::TemporaryDirectory temporary{parent, ".test-"}; EXPECT_TRUE(std::filesystem::exists(temporary.path())); EXPECT_TRUE(std::filesystem::is_directory(temporary.path())); } TEST(IO_TemporaryDirectory, unique_names) { - const auto parent{std::filesystem::temp_directory_path()}; + const auto parent{std::filesystem::path{BUILD_DIRECTORY}}; const sourcemeta::core::TemporaryDirectory first{parent, ".test-"}; const sourcemeta::core::TemporaryDirectory second{parent, ".test-"}; EXPECT_NE(first.path(), second.path()); } TEST(IO_TemporaryDirectory, custom_prefix) { - const auto parent{std::filesystem::temp_directory_path()}; + const auto parent{std::filesystem::path{BUILD_DIRECTORY}}; const sourcemeta::core::TemporaryDirectory temporary{parent, ".my-test-prefix-"}; const auto filename{temporary.path().filename().string()}; @@ -32,7 +32,7 @@ TEST(IO_TemporaryDirectory, removed_on_destruction) { std::filesystem::path path_copy; { const sourcemeta::core::TemporaryDirectory temporary{ - std::filesystem::temp_directory_path(), ".test-"}; + std::filesystem::path{BUILD_DIRECTORY}, ".test-"}; path_copy = temporary.path(); EXPECT_TRUE(std::filesystem::exists(path_copy)); } @@ -43,7 +43,7 @@ TEST(IO_TemporaryDirectory, removed_on_destruction_non_empty) { std::filesystem::path path_copy; { const sourcemeta::core::TemporaryDirectory temporary{ - std::filesystem::temp_directory_path(), ".test-"}; + std::filesystem::path{BUILD_DIRECTORY}, ".test-"}; path_copy = temporary.path(); std::filesystem::create_directory(path_copy / "subdir"); std::ofstream{path_copy / "file.txt"} << "hello"; @@ -55,14 +55,14 @@ TEST(IO_TemporaryDirectory, removed_on_destruction_non_empty) { TEST(IO_TemporaryDirectory, inside_parent) { const auto parent{ - std::filesystem::canonical(std::filesystem::temp_directory_path())}; + std::filesystem::canonical(std::filesystem::path{BUILD_DIRECTORY})}; const sourcemeta::core::TemporaryDirectory temporary{parent, ".test-"}; EXPECT_EQ(temporary.path().parent_path(), parent); } TEST(IO_TemporaryDirectory, creates_nonexistent_parent) { const sourcemeta::core::TemporaryDirectory unique_parent{ - std::filesystem::temp_directory_path(), ".test-parent-"}; + std::filesystem::path{BUILD_DIRECTORY}, ".test-parent-"}; const auto parent{unique_parent.path() / "nonexistent"}; EXPECT_FALSE(std::filesystem::exists(parent)); const sourcemeta::core::TemporaryDirectory temporary{parent, ".test-"}; @@ -72,7 +72,7 @@ TEST(IO_TemporaryDirectory, creates_nonexistent_parent) { TEST(IO_TemporaryDirectory, creates_nested_nonexistent_parents) { const sourcemeta::core::TemporaryDirectory unique_parent{ - std::filesystem::temp_directory_path(), ".test-parent-"}; + std::filesystem::path{BUILD_DIRECTORY}, ".test-parent-"}; const auto parent{unique_parent.path() / "nested_a" / "nested_b"}; EXPECT_FALSE(std::filesystem::exists(parent)); const sourcemeta::core::TemporaryDirectory temporary{parent, ".test-"}; @@ -82,7 +82,7 @@ TEST(IO_TemporaryDirectory, creates_nested_nonexistent_parents) { TEST(IO_TemporaryDirectory, parent_is_a_file) { const sourcemeta::core::TemporaryDirectory unique_parent{ - std::filesystem::temp_directory_path(), ".test-parent-"}; + std::filesystem::path{BUILD_DIRECTORY}, ".test-parent-"}; const auto file_path{unique_parent.path() / "test_parent_file"}; std::ofstream{file_path}; EXPECT_TRUE(std::filesystem::exists(file_path));