diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a64430..9371195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,10 @@ All notable changes to cargoxx will be documented in this file. are deferred to the M2 follow-up commit. `tests/linkdb_lookup.cpp` covers 13 cases including a smoke test that resolves all 25 curated packages. +- `cargoxx.lockfile`: `Lockfile`, `LockfilePackage` types and `parse(path)` / + `write(lock, path)` matching the format in `SPEC.md` §5. Also + `Lockfile::nixpkgs_rev()` returns the shared revision (codegen will + consume this in M3). `tests/lockfile_round_trip.cpp` covers 9 cases. - SQLite overlay: `Database::open(overlay_path)` now opens (and creates, if missing) a per-user `linkdb.sqlite` cache, applying the schema from `SPEC.md` §9 idempotently. Default path is diff --git a/CMakeLists.txt b/CMakeLists.txt index ffa5aa1..7a7e3fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,7 @@ target_sources(cargoxx src/manifest/parser.cpp src/manifest/writer.cpp src/layout/layout.cpp + src/lockfile/lockfile.cpp src/linkdb/recipe.cpp src/linkdb/curated.cpp src/linkdb/overlay.cpp diff --git a/src/lockfile/lockfile.cpp b/src/lockfile/lockfile.cpp new file mode 100644 index 0000000..7d68aba --- /dev/null +++ b/src/lockfile/lockfile.cpp @@ -0,0 +1,175 @@ +module; + +#include + +module cargoxx.lockfile; + +import std; +import cargoxx.util; + +namespace cargoxx::lockfile { + +auto Lockfile::nixpkgs_rev() const -> std::optional { + for (const auto& p : packages) { + if (p.nixpkgs_rev && !p.nixpkgs_rev->empty()) { + return p.nixpkgs_rev; + } + } + return std::nullopt; +} + +namespace { + +using util::Error; +using util::ErrorCode; + +auto err(ErrorCode code, std::string msg, std::filesystem::path path) -> Error { + return Error{code, std::move(msg), "", std::move(path), std::nullopt}; +} + +auto extract_string_array(const toml::array& arr, std::string_view field, + const std::filesystem::path& path) + -> util::Result> { + std::vector out; + out.reserve(arr.size()); + for (const auto& el : arr) { + if (auto s = el.value()) { + out.push_back(*s); + } else { + return std::unexpected(err(ErrorCode::ManifestInvalidField, + std::format("'{}' must be an array of strings", field), + path)); + } + } + return out; +} + +auto parse_package(const toml::table& tbl, const std::filesystem::path& path) + -> util::Result { + LockfilePackage pkg; + + if (auto v = tbl["name"].value()) { + pkg.name = *v; + } else { + return std::unexpected( + err(ErrorCode::ManifestInvalidField, "lockfile package missing 'name'", path)); + } + + if (auto v = tbl["version"].value()) { + pkg.version = *v; + } else { + return std::unexpected( + err(ErrorCode::ManifestInvalidField, "lockfile package missing 'version'", path)); + } + + if (const auto* deps = tbl["dependencies"].as_array()) { + auto r = extract_string_array(*deps, "dependencies", path); + if (!r) { + return std::unexpected(r.error()); + } + pkg.dependencies = std::move(*r); + } + + if (auto v = tbl["nixpkgs_attr"].value()) { + pkg.nixpkgs_attr = *v; + } + if (auto v = tbl["nixpkgs_rev"].value()) { + pkg.nixpkgs_rev = *v; + } + if (auto v = tbl["linkdb_source"].value()) { + pkg.linkdb_source = *v; + } + + return pkg; +} + +} // namespace + +auto parse(const std::filesystem::path& path) -> util::Result { + std::error_code ec; + if (!std::filesystem::exists(path, ec) || ec) { + return std::unexpected(err(ErrorCode::ManifestNotFound, + std::format("lockfile not found: {}", path.string()), path)); + } + + toml::table root; + try { + root = toml::parse_file(path.string()); + } catch (const toml::parse_error& e) { + return std::unexpected(err(ErrorCode::ManifestParseError, + std::format("toml parse error: {}", e.description()), path)); + } + + Lockfile lock; + if (auto v = root["version"].value()) { + lock.version = *v; + } + + if (const auto* arr = root["package"].as_array()) { + lock.packages.reserve(arr->size()); + for (const auto& el : *arr) { + const auto* tbl = el.as_table(); + if (!tbl) { + return std::unexpected(err(ErrorCode::ManifestInvalidField, + "[[package]] entries must be tables", path)); + } + auto p = parse_package(*tbl, path); + if (!p) { + return std::unexpected(p.error()); + } + lock.packages.push_back(std::move(*p)); + } + } + + return lock; +} + +auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Result { + toml::table root; + root.insert_or_assign("version", lock.version); + + toml::array packages; + for (const auto& p : lock.packages) { + toml::table tbl; + tbl.insert_or_assign("name", p.name); + tbl.insert_or_assign("version", p.version); + if (!p.dependencies.empty()) { + toml::array deps; + for (const auto& d : p.dependencies) { + deps.push_back(d); + } + tbl.insert_or_assign("dependencies", std::move(deps)); + } + if (p.nixpkgs_attr) { + tbl.insert_or_assign("nixpkgs_attr", *p.nixpkgs_attr); + } + if (p.nixpkgs_rev) { + tbl.insert_or_assign("nixpkgs_rev", *p.nixpkgs_rev); + } + if (p.linkdb_source) { + tbl.insert_or_assign("linkdb_source", *p.linkdb_source); + } + packages.push_back(std::move(tbl)); + } + root.insert_or_assign("package", std::move(packages)); + + std::ofstream out{path}; + if (!out) { + return std::unexpected(util::Error{ + util::ErrorCode::Internal, + std::format("cannot open lockfile for writing: {}", path.string()), + "", path, std::nullopt, + }); + } + out << root << '\n'; + if (!out) { + return std::unexpected(util::Error{ + util::ErrorCode::Internal, + std::format("write failed: {}", path.string()), + "", path, std::nullopt, + }); + } + return {}; +} + +} // namespace cargoxx::lockfile diff --git a/src/lockfile/lockfile.cppm b/src/lockfile/lockfile.cppm index aade254..7345aee 100644 --- a/src/lockfile/lockfile.cppm +++ b/src/lockfile/lockfile.cppm @@ -1,4 +1,34 @@ export module cargoxx.lockfile; +import std; import cargoxx.util; import cargoxx.manifest; + +export namespace cargoxx::lockfile { + +struct LockfilePackage { + std::string name; + std::string version; + std::vector dependencies; // " " entries; non-empty for the root + std::optional nixpkgs_attr; + std::optional nixpkgs_rev; + std::optional linkdb_source; + + bool operator==(const LockfilePackage&) const = default; +}; + +struct Lockfile { + int version = 1; + std::vector packages; + + bool operator==(const Lockfile&) const = default; + + // The nixpkgs revision is shared across every dep package per SPEC §5. + // Returns the first non-empty rev seen, or nullopt if no deps are pinned. + [[nodiscard]] auto nixpkgs_rev() const -> std::optional; +}; + +auto parse(const std::filesystem::path& path) -> util::Result; +auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Result; + +} // namespace cargoxx::lockfile diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ddb86f8..741b17b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,6 +12,7 @@ cargoxx_add_test(semver_satisfies) cargoxx_add_test(manifest_parse) cargoxx_add_test(manifest_write) cargoxx_add_test(layout_discovery) +cargoxx_add_test(lockfile_round_trip) cargoxx_add_test(linkdb_lookup) cargoxx_add_test(linkdb_overlay) cargoxx_add_test(cmd_new) diff --git a/tests/lockfile_round_trip.cpp b/tests/lockfile_round_trip.cpp new file mode 100644 index 0000000..aff64f8 --- /dev/null +++ b/tests/lockfile_round_trip.cpp @@ -0,0 +1,140 @@ +#include + +import cargoxx.lockfile; +import cargoxx.util; +import std; + +using cargoxx::lockfile::Lockfile; +using cargoxx::lockfile::LockfilePackage; +using cargoxx::lockfile::parse; +using cargoxx::lockfile::write; +using cargoxx::util::ErrorCode; + +namespace { + +auto tmp_path() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-lockfile-test-{}", std::random_device{}()); + std::filesystem::create_directories(d); + return d / "Cargoxx.lock"; +} + +auto root_pkg(std::string name, std::string version, + std::vector deps = {}) -> LockfilePackage { + return LockfilePackage{ + .name = std::move(name), + .version = std::move(version), + .dependencies = std::move(deps), + .nixpkgs_attr = std::nullopt, + .nixpkgs_rev = std::nullopt, + .linkdb_source = std::nullopt, + }; +} + +auto dep_pkg(std::string name, std::string version, std::string attr, std::string rev, + std::string source = "curated") -> LockfilePackage { + return LockfilePackage{ + .name = std::move(name), + .version = std::move(version), + .dependencies = {}, + .nixpkgs_attr = std::move(attr), + .nixpkgs_rev = std::move(rev), + .linkdb_source = std::move(source), + }; +} + +auto round_trip(const Lockfile& l) -> Lockfile { + auto path = tmp_path(); + REQUIRE(write(l, path).has_value()); + auto parsed = parse(path); + REQUIRE(parsed.has_value()); + return *parsed; +} + +} // namespace + +TEST_CASE("write round-trips a minimal lockfile", "[lockfile]") { + Lockfile l{1, {root_pkg("my-project", "0.1.0")}}; + REQUIRE(round_trip(l) == l); +} + +TEST_CASE("write round-trips a lockfile with deps", "[lockfile]") { + Lockfile l{ + 1, + { + root_pkg("my-project", "0.1.0", {"fmt 10.2.1", "spdlog 1.13.0"}), + dep_pkg("fmt", "10.2.1", "fmt_10", "8a3f...c2d1"), + dep_pkg("spdlog", "1.13.0", "spdlog", "8a3f...c2d1"), + }, + }; + REQUIRE(round_trip(l) == l); +} + +TEST_CASE("Lockfile::nixpkgs_rev returns the shared rev", "[lockfile]") { + Lockfile l{ + 1, + { + root_pkg("p", "0.1.0", {"fmt 10.2.1"}), + dep_pkg("fmt", "10.2.1", "fmt_10", "abc123"), + }, + }; + REQUIRE(l.nixpkgs_rev() == "abc123"); +} + +TEST_CASE("Lockfile::nixpkgs_rev is nullopt when no deps", "[lockfile]") { + Lockfile l{1, {root_pkg("p", "0.1.0")}}; + REQUIRE_FALSE(l.nixpkgs_rev().has_value()); +} + +TEST_CASE("parse rejects a missing file", "[lockfile]") { + auto r = parse("/nonexistent/Cargoxx.lock"); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ManifestNotFound); +} + +TEST_CASE("parse rejects malformed toml", "[lockfile]") { + auto path = tmp_path(); + std::ofstream{path} << "version = \nname = \"x\"\n"; + auto r = parse(path); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ManifestParseError); +} + +TEST_CASE("parse rejects a package missing name", "[lockfile]") { + auto path = tmp_path(); + std::ofstream{path} << R"( +version = 1 + +[[package]] +version = "0.1.0" +)"; + auto r = parse(path); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ManifestInvalidField); +} + +TEST_CASE("parse rejects a package missing version", "[lockfile]") { + auto path = tmp_path(); + std::ofstream{path} << R"( +version = 1 + +[[package]] +name = "p" +)"; + auto r = parse(path); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ManifestInvalidField); +} + +TEST_CASE("parse defaults version to 1 when omitted", "[lockfile]") { + auto path = tmp_path(); + std::ofstream{path} << R"( +[[package]] +name = "p" +version = "0.1.0" +)"; + auto r = parse(path); + REQUIRE(r.has_value()); + REQUIRE(r->version == 1); + REQUIRE(r->packages.size() == 1); +}