diff --git a/src/manifest/manifest.cppm b/src/manifest/manifest.cppm index 1ffd752..9a44885 100644 --- a/src/manifest/manifest.cppm +++ b/src/manifest/manifest.cppm @@ -16,6 +16,7 @@ struct Dependency { struct BuildSettings { bool warnings_as_errors = false; std::vector sanitizers; + std::vector include_dirs; bool operator==(const BuildSettings&) const = default; }; @@ -35,6 +36,7 @@ struct Package { struct Manifest { Package package; std::vector dependencies; + std::vector dev_dependencies; BuildSettings build; bool operator==(const Manifest&) const = default; diff --git a/src/manifest/parser.cpp b/src/manifest/parser.cpp index 92cb439..83d5ee4 100644 --- a/src/manifest/parser.cpp +++ b/src/manifest/parser.cpp @@ -76,13 +76,12 @@ constexpr std::array PACKAGE_KNOWN_KEYS = { }; constexpr std::array BUILD_KNOWN_KEYS = { - "warnings_as_errors", "sanitizers", + "warnings_as_errors", "sanitizers", "include_dirs", }; constexpr std::array TOPLEVEL_KNOWN_KEYS = { - "package", "dependencies", "build", - // reserved (accepted, ignored for v0.1) - "dev-dependencies", "features", "workspace", + "package", "dependencies", "dev-dependencies", "build", + "features", "workspace", }; auto parse_package(const toml::table& tbl, const std::filesystem::path& path) @@ -218,6 +217,24 @@ auto parse_build(const toml::table& tbl, const std::filesystem::path& path) b.sanitizers = std::move(*r); } + if (const auto* inc = tbl["include_dirs"].as_array()) { + auto r = extract_string_array(*inc, "[build].include_dirs", path); + if (!r) { + return std::unexpected(r.error()); + } + for (const auto& p : *r) { + if (p.empty() || p.starts_with('/') || p.find("..") != std::string::npos) { + return std::unexpected(err( + ErrorCode::ManifestInvalidField, + std::format("[build].include_dirs entry '{}' must be a " + "relative path that does not contain '..'", + p), + path)); + } + } + b.include_dirs = std::move(*r); + } + return b; } @@ -271,6 +288,14 @@ auto parse(const std::filesystem::path& path) -> util::Result { m.dependencies = std::move(*deps); } + if (const auto* dev_tbl = root["dev-dependencies"].as_table()) { + auto deps = parse_dependencies(*dev_tbl, path); + if (!deps) { + return std::unexpected(deps.error()); + } + m.dev_dependencies = std::move(*deps); + } + if (const auto* build_tbl = root["build"].as_table()) { auto build = parse_build(*build_tbl, path); if (!build) { diff --git a/src/manifest/writer.cpp b/src/manifest/writer.cpp index e7e934c..8fef503 100644 --- a/src/manifest/writer.cpp +++ b/src/manifest/writer.cpp @@ -46,23 +46,32 @@ auto build_table(const Manifest& m) -> toml::table { } root.insert_or_assign("package", std::move(package)); - if (!m.dependencies.empty()) { - toml::table deps; - for (const auto& dep : m.dependencies) { + auto deps_to_table = [](const std::vector& deps) { + toml::table out; + for (const auto& dep : deps) { if (dep.components.empty()) { - deps.insert_or_assign(dep.name, dep.version_spec); + out.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)); + out.insert_or_assign(dep.name, std::move(dep_tbl)); } } - root.insert_or_assign("dependencies", std::move(deps)); + return out; + }; + + if (!m.dependencies.empty()) { + root.insert_or_assign("dependencies", deps_to_table(m.dependencies)); } - if (m.build.warnings_as_errors || !m.build.sanitizers.empty()) { + if (!m.dev_dependencies.empty()) { + root.insert_or_assign("dev-dependencies", deps_to_table(m.dev_dependencies)); + } + + if (m.build.warnings_as_errors || !m.build.sanitizers.empty() + || !m.build.include_dirs.empty()) { toml::table build; if (m.build.warnings_as_errors) { build.insert_or_assign("warnings_as_errors", true); @@ -70,6 +79,9 @@ auto build_table(const Manifest& m) -> toml::table { if (!m.build.sanitizers.empty()) { build.insert_or_assign("sanitizers", to_array(m.build.sanitizers)); } + if (!m.build.include_dirs.empty()) { + build.insert_or_assign("include_dirs", to_array(m.build.include_dirs)); + } root.insert_or_assign("build", std::move(build)); } diff --git a/tests/codegen_cmake.cpp b/tests/codegen_cmake.cpp index 64a840f..b64992f 100644 --- a/tests/codegen_cmake.cpp +++ b/tests/codegen_cmake.cpp @@ -206,9 +206,8 @@ TEST_CASE("cmake_lists emits find_package per dep", "[codegen][cmake]") { TEST_CASE("cmake_lists honors warnings_as_errors", "[codegen][cmake]") { Manifest m{ - pkg("app"), - {}, - BuildSettings{.warnings_as_errors = true, .sanitizers = {}}, + .package = pkg("app"), + .build = BuildSettings{.warnings_as_errors = true, .sanitizers = {}}, }; DiscoveredLayout layout{ .library = std::nullopt, @@ -226,10 +225,9 @@ TEST_CASE("cmake_lists honors warnings_as_errors", "[codegen][cmake]") { TEST_CASE("cmake_lists honors sanitizers", "[codegen][cmake]") { Manifest m{ - pkg("app"), - {}, - BuildSettings{.warnings_as_errors = false, - .sanitizers = {"address", "undefined"}}, + .package = pkg("app"), + .build = BuildSettings{.warnings_as_errors = false, + .sanitizers = {"address", "undefined"}}, }; DiscoveredLayout layout{ .library = std::nullopt, diff --git a/tests/manifest_parse.cpp b/tests/manifest_parse.cpp index b56dc37..8a13286 100644 --- a/tests/manifest_parse.cpp +++ b/tests/manifest_parse.cpp @@ -199,9 +199,6 @@ TEST_CASE("parse accepts reserved top-level tables", "[manifest][parse]") { name = "foo" version = "0.1.0" -[dev-dependencies] -gtest = "1.14" - [features] default = [] @@ -212,6 +209,63 @@ members = ["a"] REQUIRE(r.has_value()); } +TEST_CASE("parse extracts dev-dependencies", "[manifest][parse]") { + auto p = write_manifest(R"( +[package] +name = "foo" +version = "0.1.0" + +[dev-dependencies] +catch2 = "3.5.0" +gtest = { version = "1.14", components = ["gmock"] } +)"); + auto r = parse(p); + REQUIRE(r.has_value()); + REQUIRE(r->dev_dependencies.size() == 2); + auto find = [&](std::string_view n) { + return std::ranges::find_if(r->dev_dependencies, + [&](const auto& d) { return d.name == n; }); + }; + auto catch2 = find("catch2"); + REQUIRE(catch2 != r->dev_dependencies.end()); + REQUIRE(catch2->version_spec == "3.5.0"); + REQUIRE(catch2->components.empty()); + auto gtest = find("gtest"); + REQUIRE(gtest != r->dev_dependencies.end()); + REQUIRE(gtest->version_spec == "1.14"); + REQUIRE(gtest->components == std::vector{"gmock"}); +} + +TEST_CASE("parse extracts [build].include_dirs", "[manifest][parse]") { + auto p = write_manifest(R"( +[package] +name = "foo" +version = "0.1.0" + +[build] +include_dirs = ["third_party", "vendor/spdlog"] +)"); + auto r = parse(p); + REQUIRE(r.has_value()); + REQUIRE(r->build.include_dirs == + std::vector{"third_party", "vendor/spdlog"}); +} + +TEST_CASE("parse rejects [build].include_dirs that escape the project", + "[manifest][parse]") { + auto p = write_manifest(R"( +[package] +name = "foo" +version = "0.1.0" + +[build] +include_dirs = ["../escape"] +)"); + auto r = parse(p); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == cargoxx::util::ErrorCode::ManifestInvalidField); +} + TEST_CASE("parse rejects invalid name with spaces", "[manifest][parse]") { auto p = write_manifest(R"( [package] diff --git a/tests/manifest_write.cpp b/tests/manifest_write.cpp index eb0fa5f..5b5ca08 100644 --- a/tests/manifest_write.cpp +++ b/tests/manifest_write.cpp @@ -93,10 +93,18 @@ TEST_CASE("write sorts dependencies alphabetically (matches Cargo)", TEST_CASE("write round-trips build settings", "[manifest][write]") { Manifest m{ - pkg("foo", "0.1.0"), - {}, - BuildSettings{.warnings_as_errors = true, - .sanitizers = {"address", "undefined"}}, + .package = pkg("foo", "0.1.0"), + .build = BuildSettings{.warnings_as_errors = true, + .sanitizers = {"address", "undefined"}, + .include_dirs = {"third_party"}}, + }; + REQUIRE(round_trip(m) == m); +} + +TEST_CASE("write round-trips dev-dependencies", "[manifest][write]") { + Manifest m{ + .package = pkg("foo", "0.1.0"), + .dev_dependencies = {Dependency{.name = "catch2", .version_spec = "3.5.0"}}, }; REQUIRE(round_trip(m) == m); }