diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b6d00..45912b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,3 +29,10 @@ All notable changes to cargoxx will be documented in this file. Dependencies are emitted alphabetically (matches Cargo). Round-trip property is exercised by `tests/manifest_write.cpp` (9 cases). Defaulted `operator==` on the manifest structs supports comparison. +- `cargoxx.layout` public types (`Target`, `TargetKind`, `DiscoveredLayout`) + and `discover(project_root, package_name)`. Walks `src/` recursively + for the library (excluding the `src/bin/` subtree), enumerates + `src/main.cpp`, `src/bin/*.cpp`, `tests/*.cpp`, `examples/*.cpp` flat, + sorts results for deterministic output, and returns `LayoutNoTarget` + when neither a library nor any binary is present. + `tests/layout_discovery.cpp` covers 12 cases. diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e726c9..5bdf777 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,7 @@ target_sources(cargoxx src/util/error.cpp src/manifest/parser.cpp src/manifest/writer.cpp + src/layout/layout.cpp PUBLIC FILE_SET CXX_MODULES FILES src/lib.cppm diff --git a/src/layout/layout.cpp b/src/layout/layout.cpp new file mode 100644 index 0000000..a31e019 --- /dev/null +++ b/src/layout/layout.cpp @@ -0,0 +1,142 @@ +module cargoxx.layout; + +import std; +import cargoxx.util; + +namespace cargoxx::layout { + +namespace { + +namespace fs = std::filesystem; + +auto top_level_cpp(const fs::path& dir) -> std::vector { + std::vector out; + std::error_code ec; + if (!fs::exists(dir, ec) || ec) { + return out; + } + for (const auto& entry : fs::directory_iterator{dir}) { + if (entry.is_regular_file() && entry.path().extension() == ".cpp") { + out.push_back(entry.path()); + } + } + std::ranges::sort(out); + return out; +} + +void walk_library_sources(const fs::path& src_dir, const fs::path& lib_path, + const fs::path& main_path, std::vector& cppm_out, + std::vector& cpp_out) { + std::error_code ec; + if (!fs::exists(src_dir, ec) || ec) { + return; + } + for (const auto& entry : fs::recursive_directory_iterator{src_dir}) { + if (!entry.is_regular_file()) { + continue; + } + const auto& p = entry.path(); + // Exclude the bin/ subtree — it holds extra binary roots, not library sources. + auto rel = fs::relative(p, src_dir); + if (!rel.empty() && rel.begin()->string() == "bin") { + continue; + } + if (p == lib_path || p == main_path) { + continue; + } + const auto ext = p.extension(); + if (ext == ".cppm") { + cppm_out.push_back(p); + } else if (ext == ".cpp") { + cpp_out.push_back(p); + } + } +} + +auto by_name(const Target& a, const Target& b) -> bool { + return a.name < b.name; +} + +} // namespace + +auto discover(const fs::path& project_root, const std::string& package_name) + -> util::Result { + const auto src_dir = project_root / "src"; + const auto lib_path = src_dir / "lib.cppm"; + const auto main_path = src_dir / "main.cpp"; + const auto bin_dir = src_dir / "bin"; + const auto tests_dir = project_root / "tests"; + const auto examples_dir = project_root / "examples"; + + DiscoveredLayout out; + + std::error_code ec; + if (fs::exists(lib_path, ec) && !ec) { + std::vector cppm{lib_path}; + std::vector cpp; + walk_library_sources(src_dir, lib_path, main_path, cppm, cpp); + std::ranges::sort(cppm); + std::ranges::sort(cpp); + out.library = Target{ + .kind = TargetKind::Library, + .name = package_name, + .entry = lib_path, + .additional_sources = std::move(cpp), + .module_units = std::move(cppm), + }; + } + + if (fs::exists(main_path, ec) && !ec) { + out.binaries.push_back(Target{ + .kind = TargetKind::Binary, + .name = package_name, + .entry = main_path, + .additional_sources = {}, + .module_units = {}, + }); + } + for (const auto& f : top_level_cpp(bin_dir)) { + out.binaries.push_back(Target{ + .kind = TargetKind::Binary, + .name = f.stem().string(), + .entry = f, + .additional_sources = {}, + .module_units = {}, + }); + } + std::ranges::sort(out.binaries, by_name); + + for (const auto& f : top_level_cpp(tests_dir)) { + out.tests.push_back(Target{ + .kind = TargetKind::Test, + .name = f.stem().string(), + .entry = f, + .additional_sources = {}, + .module_units = {}, + }); + } + + for (const auto& f : top_level_cpp(examples_dir)) { + out.examples.push_back(Target{ + .kind = TargetKind::Example, + .name = f.stem().string(), + .entry = f, + .additional_sources = {}, + .module_units = {}, + }); + } + + if (!out.library && out.binaries.empty()) { + return std::unexpected(util::Error{ + util::ErrorCode::LayoutNoTarget, + "no target found", + "expected one of: src/main.cpp, src/lib.cppm", + project_root, + std::nullopt, + }); + } + + return out; +} + +} // namespace cargoxx::layout diff --git a/src/layout/layout.cppm b/src/layout/layout.cppm index 49ab611..83ab3e9 100644 --- a/src/layout/layout.cppm +++ b/src/layout/layout.cppm @@ -1,3 +1,32 @@ export module cargoxx.layout; +import std; import cargoxx.util; + +export namespace cargoxx::layout { + +enum class TargetKind { Library, Binary, Test, Example }; + +struct Target { + TargetKind kind; + std::string name; + std::filesystem::path entry; + std::vector additional_sources; + std::vector module_units; + + bool operator==(const Target&) const = default; +}; + +struct DiscoveredLayout { + std::optional library; + std::vector binaries; + std::vector tests; + std::vector examples; + + bool operator==(const DiscoveredLayout&) const = default; +}; + +auto discover(const std::filesystem::path& project_root, const std::string& package_name) + -> util::Result; + +} // namespace cargoxx::layout diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b26d29d..8b3c9d2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,3 +10,4 @@ endfunction() cargoxx_add_test(util_error) cargoxx_add_test(manifest_parse) cargoxx_add_test(manifest_write) +cargoxx_add_test(layout_discovery) diff --git a/tests/layout_discovery.cpp b/tests/layout_discovery.cpp new file mode 100644 index 0000000..e3dc4c2 --- /dev/null +++ b/tests/layout_discovery.cpp @@ -0,0 +1,193 @@ +#include + +import cargoxx.layout; +import cargoxx.util; +import std; + +using cargoxx::layout::DiscoveredLayout; +using cargoxx::layout::discover; +using cargoxx::layout::Target; +using cargoxx::layout::TargetKind; +using cargoxx::util::ErrorCode; + +namespace { + +class TempProject { + public: + TempProject() { + root_ = std::filesystem::temp_directory_path() / + std::format("cargoxx-layout-test-{}", std::random_device{}()); + std::filesystem::create_directories(root_); + } + + ~TempProject() { + std::error_code ec; + std::filesystem::remove_all(root_, ec); + } + + TempProject(const TempProject&) = delete; + TempProject& operator=(const TempProject&) = delete; + TempProject(TempProject&&) = delete; + TempProject& operator=(TempProject&&) = delete; + + auto root() const -> const std::filesystem::path& { return root_; } + + auto touch(std::string_view rel) -> std::filesystem::path { + auto p = root_ / rel; + std::filesystem::create_directories(p.parent_path()); + std::ofstream{p} << "// stub\n"; + return p; + } + + private: + std::filesystem::path root_; +}; + +} // namespace + +TEST_CASE("discover returns LayoutNoTarget for an empty src", "[layout]") { + TempProject p; + std::filesystem::create_directories(p.root() / "src"); + auto r = discover(p.root(), "foo"); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::LayoutNoTarget); +} + +TEST_CASE("discover finds main.cpp as a binary", "[layout]") { + TempProject p; + auto main = p.touch("src/main.cpp"); + auto r = discover(p.root(), "foo"); + REQUIRE(r.has_value()); + REQUIRE_FALSE(r->library.has_value()); + REQUIRE(r->binaries.size() == 1); + REQUIRE(r->binaries[0].kind == TargetKind::Binary); + REQUIRE(r->binaries[0].name == "foo"); + REQUIRE(r->binaries[0].entry == main); +} + +TEST_CASE("discover finds lib.cppm as a library", "[layout]") { + TempProject p; + auto lib = p.touch("src/lib.cppm"); + auto r = discover(p.root(), "foo"); + REQUIRE(r.has_value()); + REQUIRE(r->library.has_value()); + REQUIRE(r->library->kind == TargetKind::Library); + REQUIRE(r->library->name == "foo"); + REQUIRE(r->library->entry == lib); + REQUIRE(r->library->module_units == std::vector{lib}); + REQUIRE(r->library->additional_sources.empty()); + REQUIRE(r->binaries.empty()); +} + +TEST_CASE("discover supports lib + main together", "[layout]") { + TempProject p; + p.touch("src/lib.cppm"); + p.touch("src/main.cpp"); + auto r = discover(p.root(), "foo"); + REQUIRE(r.has_value()); + REQUIRE(r->library.has_value()); + REQUIRE(r->binaries.size() == 1); + REQUIRE(r->binaries[0].entry.filename() == "main.cpp"); +} + +TEST_CASE("discover collects nested .cppm and .cpp into the library", + "[layout]") { + TempProject p; + auto lib = p.touch("src/lib.cppm"); + auto inner_cppm = p.touch("src/internal/foo.cppm"); + auto inner_cpp = p.touch("src/internal/foo.cpp"); + auto r = discover(p.root(), "foo"); + REQUIRE(r.has_value()); + REQUIRE(r->library->module_units.size() == 2); + REQUIRE(std::ranges::find(r->library->module_units, lib) != + r->library->module_units.end()); + REQUIRE(std::ranges::find(r->library->module_units, inner_cppm) != + r->library->module_units.end()); + REQUIRE(r->library->additional_sources == std::vector{inner_cpp}); +} + +TEST_CASE("discover excludes src/bin/* from the library walk", "[layout]") { + TempProject p; + p.touch("src/lib.cppm"); + p.touch("src/bin/tool.cpp"); + auto r = discover(p.root(), "foo"); + REQUIRE(r.has_value()); + REQUIRE(r->library->additional_sources.empty()); + REQUIRE(r->binaries.size() == 1); + REQUIRE(r->binaries[0].name == "tool"); +} + +TEST_CASE("discover lists src/bin/*.cpp as additional binaries", "[layout]") { + TempProject p; + p.touch("src/main.cpp"); + p.touch("src/bin/foo.cpp"); + p.touch("src/bin/bar.cpp"); + auto r = discover(p.root(), "pkg"); + REQUIRE(r.has_value()); + REQUIRE(r->binaries.size() == 3); + // sorted alphabetically by target name + REQUIRE(r->binaries[0].name == "bar"); + REQUIRE(r->binaries[1].name == "foo"); + REQUIRE(r->binaries[2].name == "pkg"); +} + +TEST_CASE("discover does not recurse into src/bin/", "[layout]") { + TempProject p; + p.touch("src/main.cpp"); + p.touch("src/bin/foo.cpp"); + p.touch("src/bin/sub/nested.cpp"); // should be ignored + auto r = discover(p.root(), "pkg"); + REQUIRE(r.has_value()); + REQUIRE(r->binaries.size() == 2); +} + +TEST_CASE("discover lists tests/*.cpp", "[layout]") { + TempProject p; + p.touch("src/main.cpp"); + p.touch("tests/alpha.cpp"); + p.touch("tests/beta.cpp"); + p.touch("tests/sub/nested.cpp"); // ignored, no recursion + auto r = discover(p.root(), "pkg"); + REQUIRE(r.has_value()); + REQUIRE(r->tests.size() == 2); + REQUIRE(r->tests[0].name == "alpha"); + REQUIRE(r->tests[0].kind == TargetKind::Test); + REQUIRE(r->tests[1].name == "beta"); +} + +TEST_CASE("discover lists examples/*.cpp", "[layout]") { + TempProject p; + p.touch("src/main.cpp"); + p.touch("examples/demo.cpp"); + auto r = discover(p.root(), "pkg"); + REQUIRE(r.has_value()); + REQUIRE(r->examples.size() == 1); + REQUIRE(r->examples[0].name == "demo"); + REQUIRE(r->examples[0].kind == TargetKind::Example); +} + +TEST_CASE("discover ignores non .cpp/.cppm files", "[layout]") { + TempProject p; + p.touch("src/main.cpp"); + p.touch("src/notes.txt"); + p.touch("src/lib.cppm"); + p.touch("src/internal/header.h"); // headers in src/ are ignored + auto r = discover(p.root(), "pkg"); + REQUIRE(r.has_value()); + REQUIRE(r->library->module_units.size() == 1); + REQUIRE(r->library->additional_sources.empty()); +} + +TEST_CASE("discover output is deterministic across runs", "[layout]") { + TempProject p; + p.touch("src/lib.cppm"); + p.touch("src/zeta.cppm"); + p.touch("src/alpha.cppm"); + p.touch("src/middle.cppm"); + auto r1 = discover(p.root(), "pkg"); + auto r2 = discover(p.root(), "pkg"); + REQUIRE(r1.has_value()); + REQUIRE(r2.has_value()); + REQUIRE(*r1 == *r2); + REQUIRE(std::ranges::is_sorted(r1->library->module_units)); +}