From b0c54b8f5aa561f65b11bc4879f0b5edcaeb65e5 Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Fri, 8 May 2026 10:50:38 +0000 Subject: [PATCH] [M1] add manifest::write --- CHANGELOG.md | 4 ++ CMakeLists.txt | 1 + src/manifest/manifest.cppm | 9 +++ src/manifest/writer.cpp | 103 +++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/manifest_write.cpp | 132 +++++++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+) create mode 100644 src/manifest/writer.cpp create mode 100644 tests/manifest_write.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 51677f9..a2b6d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,3 +25,7 @@ All notable changes to cargoxx will be documented in this file. `[package]` / `[build]` and unknown top-level keys are rejected; reserved fields (`description`, `repository`, `[dev-dependencies]`, `[features]`, `[workspace]`) are accepted. `tests/manifest_parse.cpp` covers 17 cases. +- `manifest::write(m, path)` serializes a `Manifest` as TOML using toml++. + 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index c5e9a6d..2e726c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,7 @@ target_sources(cargoxx PRIVATE src/util/error.cpp src/manifest/parser.cpp + src/manifest/writer.cpp PUBLIC FILE_SET CXX_MODULES FILES src/lib.cppm diff --git a/src/manifest/manifest.cppm b/src/manifest/manifest.cppm index 1de53a8..1ffd752 100644 --- a/src/manifest/manifest.cppm +++ b/src/manifest/manifest.cppm @@ -9,11 +9,15 @@ struct Dependency { std::string name; std::string version_spec; std::vector components; + + bool operator==(const Dependency&) const = default; }; struct BuildSettings { bool warnings_as_errors = false; std::vector sanitizers; + + bool operator==(const BuildSettings&) const = default; }; enum class Edition { Cpp20, Cpp23, Cpp26 }; @@ -24,14 +28,19 @@ struct Package { Edition edition = Edition::Cpp23; std::vector authors; std::optional license; + + bool operator==(const Package&) const = default; }; struct Manifest { Package package; std::vector dependencies; BuildSettings build; + + bool operator==(const Manifest&) const = default; }; auto parse(const std::filesystem::path& path) -> util::Result; +auto write(const Manifest& m, const std::filesystem::path& path) -> util::Result; } // namespace cargoxx::manifest diff --git a/src/manifest/writer.cpp b/src/manifest/writer.cpp new file mode 100644 index 0000000..e7e934c --- /dev/null +++ b/src/manifest/writer.cpp @@ -0,0 +1,103 @@ +module; + +#include + +module cargoxx.manifest; + +import std; +import cargoxx.util; + +namespace cargoxx::manifest { + +namespace { + +auto edition_to_string(Edition e) -> std::string_view { + switch (e) { + case Edition::Cpp20: + return "cpp20"; + case Edition::Cpp23: + return "cpp23"; + case Edition::Cpp26: + return "cpp26"; + } + return "cpp23"; +} + +auto to_array(const std::vector& xs) -> toml::array { + toml::array out; + for (const auto& x : xs) { + out.push_back(x); + } + return out; +} + +auto build_table(const Manifest& m) -> toml::table { + toml::table root; + + toml::table package; + package.insert_or_assign("name", m.package.name); + package.insert_or_assign("version", m.package.version); + package.insert_or_assign("edition", std::string{edition_to_string(m.package.edition)}); + if (!m.package.authors.empty()) { + package.insert_or_assign("authors", to_array(m.package.authors)); + } + if (m.package.license) { + package.insert_or_assign("license", *m.package.license); + } + root.insert_or_assign("package", std::move(package)); + + if (!m.dependencies.empty()) { + toml::table deps; + for (const auto& dep : m.dependencies) { + if (dep.components.empty()) { + deps.insert_or_assign(dep.name, dep.version_spec); + } else { + toml::table dep_tbl; + dep_tbl.insert_or_assign("version", dep.version_spec); + dep_tbl.insert_or_assign("components", to_array(dep.components)); + dep_tbl.is_inline(true); + deps.insert_or_assign(dep.name, std::move(dep_tbl)); + } + } + root.insert_or_assign("dependencies", std::move(deps)); + } + + if (m.build.warnings_as_errors || !m.build.sanitizers.empty()) { + toml::table build; + if (m.build.warnings_as_errors) { + build.insert_or_assign("warnings_as_errors", true); + } + if (!m.build.sanitizers.empty()) { + build.insert_or_assign("sanitizers", to_array(m.build.sanitizers)); + } + root.insert_or_assign("build", std::move(build)); + } + + return root; +} + +} // namespace + +auto write(const Manifest& m, const std::filesystem::path& path) -> util::Result { + auto tbl = build_table(m); + + std::ofstream out{path}; + if (!out) { + return std::unexpected(util::Error{ + util::ErrorCode::Internal, + std::format("cannot open manifest for writing: {}", path.string()), + "", path, std::nullopt}); + } + + out << tbl << '\n'; + if (!out) { + return std::unexpected(util::Error{ + util::ErrorCode::Internal, + std::format("write failed: {}", path.string()), + "", path, std::nullopt}); + } + + return {}; +} + +} // namespace cargoxx::manifest diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 71869a9..b26d29d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,3 +9,4 @@ endfunction() cargoxx_add_test(util_error) cargoxx_add_test(manifest_parse) +cargoxx_add_test(manifest_write) diff --git a/tests/manifest_write.cpp b/tests/manifest_write.cpp new file mode 100644 index 0000000..eb0fa5f --- /dev/null +++ b/tests/manifest_write.cpp @@ -0,0 +1,132 @@ +#include + +import cargoxx.manifest; +import cargoxx.util; +import std; + +using cargoxx::manifest::BuildSettings; +using cargoxx::manifest::Dependency; +using cargoxx::manifest::Edition; +using cargoxx::manifest::Manifest; +using cargoxx::manifest::Package; +using cargoxx::manifest::parse; +using cargoxx::manifest::write; + +namespace { + +auto tmp_path() -> std::filesystem::path { + auto dir = std::filesystem::temp_directory_path() / + std::format("cargoxx-write-test-{}", std::random_device{}()); + std::filesystem::create_directories(dir); + return dir / "Cargoxx.toml"; +} + +auto pkg(std::string name, std::string version, Edition ed = Edition::Cpp23, + std::vector authors = {}, + std::optional license = std::nullopt) -> Package { + return Package{ + .name = std::move(name), + .version = std::move(version), + .edition = ed, + .authors = std::move(authors), + .license = std::move(license), + }; +} + +auto dep(std::string name, std::string version, + std::vector components = {}) -> Dependency { + return Dependency{ + .name = std::move(name), + .version_spec = std::move(version), + .components = std::move(components), + }; +} + +auto round_trip(const Manifest& m) -> Manifest { + auto path = tmp_path(); + REQUIRE(write(m, path).has_value()); + auto parsed = parse(path); + REQUIRE(parsed.has_value()); + return *parsed; +} + +} // namespace + +TEST_CASE("write round-trips a minimal manifest", "[manifest][write]") { + Manifest m{pkg("foo", "0.1.0"), {}, {}}; + REQUIRE(round_trip(m) == m); +} + +TEST_CASE("write round-trips authors and license", "[manifest][write]") { + Manifest m{pkg("foo", "0.1.0", Edition::Cpp20, {"Ada", "Grace"}, "MIT"), {}, {}}; + REQUIRE(round_trip(m) == m); +} + +TEST_CASE("write round-trips a string-form dependency", "[manifest][write]") { + Manifest m{pkg("foo", "0.1.0"), {dep("fmt", "10.2")}, {}}; + REQUIRE(round_trip(m) == m); +} + +TEST_CASE("write round-trips a table-form dependency with components", + "[manifest][write]") { + Manifest m{pkg("foo", "0.1.0"), {dep("boost", "1.84", {"filesystem", "system"})}, {}}; + REQUIRE(round_trip(m) == m); +} + +TEST_CASE("write sorts dependencies alphabetically (matches Cargo)", + "[manifest][write]") { + Manifest m{ + pkg("foo", "0.1.0"), + { + dep("fmt", "10.2"), + dep("spdlog", "1.13"), + dep("boost", "1.84", {"filesystem"}), + }, + {}, + }; + auto rt = round_trip(m); + REQUIRE(rt.dependencies.size() == 3); + REQUIRE(rt.dependencies[0].name == "boost"); + REQUIRE(rt.dependencies[1].name == "fmt"); + REQUIRE(rt.dependencies[2].name == "spdlog"); +} + +TEST_CASE("write round-trips build settings", "[manifest][write]") { + Manifest m{ + pkg("foo", "0.1.0"), + {}, + BuildSettings{.warnings_as_errors = true, + .sanitizers = {"address", "undefined"}}, + }; + REQUIRE(round_trip(m) == m); +} + +TEST_CASE("write omits empty optional sections", "[manifest][write]") { + Manifest m{pkg("foo", "0.1.0"), {}, {}}; + auto path = tmp_path(); + REQUIRE(write(m, path).has_value()); + + std::ifstream in{path}; + std::string content{std::istreambuf_iterator(in), {}}; + REQUIRE(content.find("[dependencies]") == std::string::npos); + REQUIRE(content.find("[build]") == std::string::npos); +} + +TEST_CASE("write overwrites an existing file", "[manifest][write]") { + auto path = tmp_path(); + std::ofstream{path} << "# stale content\n[package]\nname = \"old\"\n"; + + Manifest m{pkg("new", "1.0.0"), {}, {}}; + REQUIRE(write(m, path).has_value()); + + auto parsed = parse(path); + REQUIRE(parsed.has_value()); + REQUIRE(parsed->package.name == "new"); +} + +TEST_CASE("write fails when the target directory does not exist", + "[manifest][write]") { + Manifest m{pkg("foo", "0.1.0"), {}, {}}; + auto r = write(m, "/nonexistent/dir/Cargoxx.toml"); + REQUIRE_FALSE(r.has_value()); +}