From 1870db66c0cb81266b7450fb3ea24432baddc4dd Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 9 May 2026 04:28:48 +0800 Subject: [PATCH] refactor(pm): move publish/xpkg_emit.cppm into pm/publisher.cppm (PR-R6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step six of the package-management subsystem refactor (see `.agents/docs/2026-05-08-pm-subsystem-architecture.md`). Strictly zero behavior change. * New module `mcpp.pm.publisher` (`src/pm/publisher.cppm`) carries the full xpkg-emission implementation — `emit_xpkg`, `placeholder_release`, `release_tarball_url`, `sha256_of_file`, `make_release_tarball`, `make_release_info` — under the `mcpp::pm` namespace. * Old `mcpp.publish.xpkg_emit` (`src/publish/xpkg_emit.cppm`) is now a thin shim. Aliases use **using-declarations** (not inline forwarders) because `ReleaseInfo` lives in `mcpp::pm`; ADL on a `ReleaseInfo` argument would otherwise see two distinct candidates (the inline forwarder + the new mcpp::pm function) and fail with "ambiguous overload" inside `tests/unit/test_xpkg_emit`. The using-declaration introduces the same symbol into both namespaces — single overload, ADL stays clean. Verification: * `mcpp build` compiles unchanged. * `mcpp test` — 9/9 unit binaries pass (including the previously- ambiguous `test_xpkg_emit`). * e2e: 02 / 06 (emit_xpkg) / 09 / 12 / 27 all pass. `src/publish/` will be deleted entirely once `cli.cppm` migrates to the `mcpp::pm::` qualified names directly. --- src/pm/publisher.cppm | 269 +++++++++++++++++++++++++++++++++++ src/publish/xpkg_emit.cppm | 277 +++---------------------------------- 2 files changed, 288 insertions(+), 258 deletions(-) create mode 100644 src/pm/publisher.cppm diff --git a/src/pm/publisher.cppm b/src/pm/publisher.cppm new file mode 100644 index 0000000..42209bc --- /dev/null +++ b/src/pm/publisher.cppm @@ -0,0 +1,269 @@ +// mcpp.pm.publisher — generate xpkg Lua entry from mcpp.toml + scanner. +// +// See docs/04-schema-xpkg-extension.md for the produced layout. + +module; +#include // popen / pclose / fgets + +export module mcpp.pm.publisher; + +import std; +import mcpp.manifest; +import mcpp.modgraph.graph; + +export namespace mcpp::pm { + +struct ReleaseInfo { + std::string version; // tag/version, e.g. "0.1.0" + + struct PerPlatform { + std::string url; + std::string sha256; + }; + PerPlatform linux; + PerPlatform macosx; + PerPlatform windows; +}; + +// Generate the xpkg Lua content for a package. +std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, + const mcpp::modgraph::Graph& graph, + const ReleaseInfo& release); + +// Convenience: synthesize a placeholder ReleaseInfo for `mcpp emit xpkg --version V` +// before publish infrastructure exists. Uses {url, sha256} sentinels. +ReleaseInfo placeholder_release(std::string_view version); + +// Compute the convention-based GitHub Release tarball URL for a package: +// "/releases/download/v/-.tar.gz" +// Returns empty string if `repo` is empty or doesn't look like a https URL. +std::string release_tarball_url(std::string_view repo, + std::string_view name, + std::string_view version); + +// Compute SHA-256 of `file` by shelling out to `sha256sum` (universally +// available on Linux). Returns empty string on failure. +std::string sha256_of_file(const std::filesystem::path& file); + +// Pack the package source tree at `root` into a tarball at `output` using +// `git archive` (so .gitignore'd files are excluded automatically). The +// tarball uses prefix "-/" so unpacking yields a clean +// versioned directory. +// +// Requires the project to be in a git repo. +// +// Returns a non-empty error message on failure (empty on success). +std::string make_release_tarball(const std::filesystem::path& root, + std::string_view name, + std::string_view version, + const std::filesystem::path& output); + +// Convenience: build a real ReleaseInfo for v0.0.3-style local publish +// where all three platforms point at the same source tarball. Caller has +// already produced the tarball + sha256 by other means. +ReleaseInfo make_release_info(std::string_view version, + std::string_view url, + std::string_view sha256); + +} // namespace mcpp::pm + +namespace mcpp::pm { + +namespace { + +// Quote `s` as a Lua double-quoted string literal: `"..."`. +// +// We deliberately use `"..."` (not the long-bracket `[[...]]` form) +// so the only meta-characters that need escaping are the standard set +// for `"` strings: +// - `"` and `\\` must be backslash-escaped +// - newline / carriage-return / NUL break the string literal +// - other control bytes are escaped numerically (`\xHH`) for safety +// so the emitted .lua is purely printable ASCII even when the input +// contains exotic bytes +// +// Long-bracket sequences like `]=]` are NOT a vector here because we +// never emit `[[`/`]=]` ourselves — the output is always `"..."`. +std::string lua_escape(std::string_view s) { + std::string out; + out.reserve(s.size() + 2); + out.push_back('"'); + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + case 0: out += "\\0"; break; + default: + if (c < 0x20 || c == 0x7f) { + // Other C0 controls + DEL — emit as \xHH to keep the + // .lua text purely printable. + char buf[5]; + std::snprintf(buf, sizeof(buf), "\\x%02x", c); + out += buf; + } else { + out.push_back(static_cast(c)); + } + break; + } + } + out.push_back('"'); + return out; +} + +std::string platform_block(std::string_view version, const ReleaseInfo::PerPlatform& pp) { + return std::format( + " ['{0}'] = {{ url = {1}, sha256 = {2} }},\n", + version, lua_escape(pp.url), lua_escape(pp.sha256)); +} + +} // namespace + +std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, + const mcpp::modgraph::Graph& graph, + const ReleaseInfo& release) +{ + std::string out; + out += "-- AUTO-GENERATED by `mcpp emit xpkg`. Do not edit by hand.\n"; + out += std::format("-- Source: mcpp.toml @ v{}\n", release.version); + out += "package = {\n"; + out += " spec = \"1\",\n"; + out += std::format(" name = {},\n", lua_escape(manifest.package.name)); + if (!manifest.package.description.empty()) + out += std::format(" description = {},\n", lua_escape(manifest.package.description)); + if (!manifest.package.license.empty()) + out += std::format(" licenses = {{{}}},\n", lua_escape(manifest.package.license)); + if (!manifest.package.repo.empty()) + out += std::format(" repo = {},\n", lua_escape(manifest.package.repo)); + out += " type = \"package\",\n\n"; + + out += " xpm = {\n"; + out += " linux = {\n" + platform_block(release.version, release.linux) + " },\n"; + out += " macosx = {\n" + platform_block(release.version, release.macosx) + " },\n"; + out += " windows = {\n" + platform_block(release.version, release.windows) + " },\n"; + out += " },\n\n"; + + out += " mcpp = {\n"; + out += " schema = \"0.1\",\n"; + out += std::format(" language = {},\n", lua_escape(manifest.language.standard)); + out += std::format(" import_std = {},\n", manifest.language.importStd ? "true" : "false"); + + // Module list (from scanner) + out += " modules = {\n"; + for (auto& u : graph.units) { + if (!u.provides) continue; + // Skip partition-only units: their logical name contains ':' + if (u.provides->logicalName.find(':') != std::string::npos) continue; + out += std::format(" {},\n", lua_escape(u.provides->logicalName)); + } + out += " },\n"; + + // Dependencies (excluding dev-dependencies). Path-based deps are + // local-only and intentionally not exposed in the published xpkg + // descriptor; only version-based deps are emitted. + out += " deps = {\n"; + for (auto& [k, v] : manifest.dependencies) { + if (v.isPath() || v.version.empty()) continue; + out += std::format(" [{}] = {},\n", lua_escape(k), lua_escape(v.version)); + } + out += " },\n"; + + out += " manifest = \"mcpp.toml\",\n"; + out += " },\n"; + out += "}\n"; + return out; +} + +ReleaseInfo placeholder_release(std::string_view version) { + ReleaseInfo r; + r.version = std::string(version); + auto fill = [&](ReleaseInfo::PerPlatform& pp, std::string_view ext) { + pp.url = std::format(".{}", ext); + pp.sha256 = ""; + }; + fill(r.linux, "tar.gz"); + fill(r.macosx, "tar.gz"); + fill(r.windows, "zip"); + return r; +} + +std::string release_tarball_url(std::string_view repo, + std::string_view name, + std::string_view version) +{ + // Strip trailing ".git" if present. + std::string r{repo}; + if (r.ends_with(".git")) r.resize(r.size() - 4); + if (r.empty()) return {}; + if (!r.starts_with("https://") && !r.starts_with("http://")) return {}; + return std::format("{}/releases/download/v{}/{}-{}.tar.gz", + r, version, name, version); +} + +std::string sha256_of_file(const std::filesystem::path& file) { + if (!std::filesystem::exists(file)) return {}; + auto cmd = std::format("sha256sum '{}' 2>/dev/null", file.string()); + std::FILE* fp = ::popen(cmd.c_str(), "r"); + if (!fp) return {}; + std::array buf{}; + std::string out; + while (std::fgets(buf.data(), buf.size(), fp)) + out += buf.data(); + int rc = ::pclose(fp); + if (rc != 0) return {}; + // sha256sum format: "<64-hex> \n" + auto sp = out.find(' '); + if (sp == std::string::npos || sp != 64) return {}; + return out.substr(0, 64); +} + +std::string make_release_tarball(const std::filesystem::path& root, + std::string_view name, + std::string_view version, + const std::filesystem::path& output) +{ + std::error_code ec; + std::filesystem::create_directories(output.parent_path(), ec); + + auto cmd = std::format( + "git -C '{}' archive --format=tar.gz " + "--prefix='{}-{}/' " + "-o '{}' HEAD 2>&1", + root.string(), name, version, output.string()); + std::FILE* fp = ::popen(cmd.c_str(), "r"); + if (!fp) return std::format("popen failed for git archive: {}", cmd); + + std::array buf{}; + std::string err; + while (std::fgets(buf.data(), buf.size(), fp)) + err += buf.data(); + int rc = ::pclose(fp); + if (rc != 0) { + return std::format("git archive failed (rc={}): {}", rc, err); + } + if (!std::filesystem::exists(output)) { + return std::format("git archive exited 0 but no tarball at '{}'", + output.string()); + } + return {}; +} + +ReleaseInfo make_release_info(std::string_view version, + std::string_view url, + std::string_view sha256) +{ + ReleaseInfo r; + r.version = std::string(version); + auto fill = [&](ReleaseInfo::PerPlatform& pp) { + pp.url = std::string(url); + pp.sha256 = std::string(sha256); + }; + fill(r.linux); + fill(r.macosx); + fill(r.windows); + return r; +} + +} // namespace mcpp::pm diff --git a/src/publish/xpkg_emit.cppm b/src/publish/xpkg_emit.cppm index 016adfd..8363106 100644 --- a/src/publish/xpkg_emit.cppm +++ b/src/publish/xpkg_emit.cppm @@ -1,269 +1,30 @@ -// mcpp.publish.xpkg_emit — generate xpkg Lua entry from mcpp.toml + scanner. +// mcpp.publish.xpkg_emit — backward-compat shim. The implementation has +// moved to `mcpp.pm.publisher` as part of the package-management +// subsystem refactor (PR-R6 in +// `.agents/docs/2026-05-08-pm-subsystem-architecture.md`). // -// See docs/04-schema-xpkg-extension.md for the produced layout. - -module; -#include // popen / pclose / fgets +// Existing call sites continue to use `mcpp::publish::ReleaseInfo`, +// `mcpp::publish::emit_xpkg`, etc. — the using-declarations below +// introduce those names into the legacy namespace as alternates for +// the new `mcpp::pm::*` symbols (no new functions — keeps ADL +// unambiguous when callers pass `ReleaseInfo` whose own namespace is +// `mcpp::pm`). The shim disappears once `cli.cppm` migrates to the +// `mcpp::pm::` qualified names. export module mcpp.publish.xpkg_emit; import std; -import mcpp.manifest; -import mcpp.modgraph.graph; +export import mcpp.pm.publisher; export namespace mcpp::publish { -struct ReleaseInfo { - std::string version; // tag/version, e.g. "0.1.0" - - struct PerPlatform { - std::string url; - std::string sha256; - }; - PerPlatform linux; - PerPlatform macosx; - PerPlatform windows; -}; - -// Generate the xpkg Lua content for a package. -std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, - const mcpp::modgraph::Graph& graph, - const ReleaseInfo& release); - -// Convenience: synthesize a placeholder ReleaseInfo for `mcpp emit xpkg --version V` -// before publish infrastructure exists. Uses {url, sha256} sentinels. -ReleaseInfo placeholder_release(std::string_view version); - -// Compute the convention-based GitHub Release tarball URL for a package: -// "/releases/download/v/-.tar.gz" -// Returns empty string if `repo` is empty or doesn't look like a https URL. -std::string release_tarball_url(std::string_view repo, - std::string_view name, - std::string_view version); - -// Compute SHA-256 of `file` by shelling out to `sha256sum` (universally -// available on Linux). Returns empty string on failure. -std::string sha256_of_file(const std::filesystem::path& file); - -// Pack the package source tree at `root` into a tarball at `output` using -// `git archive` (so .gitignore'd files are excluded automatically). The -// tarball uses prefix "-/" so unpacking yields a clean -// versioned directory. -// -// Requires the project to be in a git repo. -// -// Returns a non-empty error message on failure (empty on success). -std::string make_release_tarball(const std::filesystem::path& root, - std::string_view name, - std::string_view version, - const std::filesystem::path& output); - -// Convenience: build a real ReleaseInfo for v0.0.3-style local publish -// where all three platforms point at the same source tarball. Caller has -// already produced the tarball + sha256 by other means. -ReleaseInfo make_release_info(std::string_view version, - std::string_view url, - std::string_view sha256); - -} // namespace mcpp::publish - -namespace mcpp::publish { - -namespace { - -// Quote `s` as a Lua double-quoted string literal: `"..."`. -// -// We deliberately use `"..."` (not the long-bracket `[[...]]` form) -// so the only meta-characters that need escaping are the standard set -// for `"` strings: -// - `"` and `\\` must be backslash-escaped -// - newline / carriage-return / NUL break the string literal -// - other control bytes are escaped numerically (`\xHH`) for safety -// so the emitted .lua is purely printable ASCII even when the input -// contains exotic bytes -// -// Long-bracket sequences like `]=]` are NOT a vector here because we -// never emit `[[`/`]=]` ourselves — the output is always `"..."`. -std::string lua_escape(std::string_view s) { - std::string out; - out.reserve(s.size() + 2); - out.push_back('"'); - for (unsigned char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - case 0: out += "\\0"; break; - default: - if (c < 0x20 || c == 0x7f) { - // Other C0 controls + DEL — emit as \xHH to keep the - // .lua text purely printable. - char buf[5]; - std::snprintf(buf, sizeof(buf), "\\x%02x", c); - out += buf; - } else { - out.push_back(static_cast(c)); - } - break; - } - } - out.push_back('"'); - return out; -} - -std::string platform_block(std::string_view version, const ReleaseInfo::PerPlatform& pp) { - return std::format( - " ['{0}'] = {{ url = {1}, sha256 = {2} }},\n", - version, lua_escape(pp.url), lua_escape(pp.sha256)); -} - -} // namespace - -std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, - const mcpp::modgraph::Graph& graph, - const ReleaseInfo& release) -{ - std::string out; - out += "-- AUTO-GENERATED by `mcpp emit xpkg`. Do not edit by hand.\n"; - out += std::format("-- Source: mcpp.toml @ v{}\n", release.version); - out += "package = {\n"; - out += " spec = \"1\",\n"; - out += std::format(" name = {},\n", lua_escape(manifest.package.name)); - if (!manifest.package.description.empty()) - out += std::format(" description = {},\n", lua_escape(manifest.package.description)); - if (!manifest.package.license.empty()) - out += std::format(" licenses = {{{}}},\n", lua_escape(manifest.package.license)); - if (!manifest.package.repo.empty()) - out += std::format(" repo = {},\n", lua_escape(manifest.package.repo)); - out += " type = \"package\",\n\n"; - - out += " xpm = {\n"; - out += " linux = {\n" + platform_block(release.version, release.linux) + " },\n"; - out += " macosx = {\n" + platform_block(release.version, release.macosx) + " },\n"; - out += " windows = {\n" + platform_block(release.version, release.windows) + " },\n"; - out += " },\n\n"; - - out += " mcpp = {\n"; - out += " schema = \"0.1\",\n"; - out += std::format(" language = {},\n", lua_escape(manifest.language.standard)); - out += std::format(" import_std = {},\n", manifest.language.importStd ? "true" : "false"); - - // Module list (from scanner) - out += " modules = {\n"; - for (auto& u : graph.units) { - if (!u.provides) continue; - // Skip partition-only units: their logical name contains ':' - if (u.provides->logicalName.find(':') != std::string::npos) continue; - out += std::format(" {},\n", lua_escape(u.provides->logicalName)); - } - out += " },\n"; - - // Dependencies (excluding dev-dependencies). Path-based deps are - // local-only and intentionally not exposed in the published xpkg - // descriptor; only version-based deps are emitted. - out += " deps = {\n"; - for (auto& [k, v] : manifest.dependencies) { - if (v.isPath() || v.version.empty()) continue; - out += std::format(" [{}] = {},\n", lua_escape(k), lua_escape(v.version)); - } - out += " },\n"; - - out += " manifest = \"mcpp.toml\",\n"; - out += " },\n"; - out += "}\n"; - return out; -} - -ReleaseInfo placeholder_release(std::string_view version) { - ReleaseInfo r; - r.version = std::string(version); - auto fill = [&](ReleaseInfo::PerPlatform& pp, std::string_view ext) { - pp.url = std::format(".{}", ext); - pp.sha256 = ""; - }; - fill(r.linux, "tar.gz"); - fill(r.macosx, "tar.gz"); - fill(r.windows, "zip"); - return r; -} - -std::string release_tarball_url(std::string_view repo, - std::string_view name, - std::string_view version) -{ - // Strip trailing ".git" if present. - std::string r{repo}; - if (r.ends_with(".git")) r.resize(r.size() - 4); - if (r.empty()) return {}; - if (!r.starts_with("https://") && !r.starts_with("http://")) return {}; - return std::format("{}/releases/download/v{}/{}-{}.tar.gz", - r, version, name, version); -} - -std::string sha256_of_file(const std::filesystem::path& file) { - if (!std::filesystem::exists(file)) return {}; - auto cmd = std::format("sha256sum '{}' 2>/dev/null", file.string()); - std::FILE* fp = ::popen(cmd.c_str(), "r"); - if (!fp) return {}; - std::array buf{}; - std::string out; - while (std::fgets(buf.data(), buf.size(), fp)) - out += buf.data(); - int rc = ::pclose(fp); - if (rc != 0) return {}; - // sha256sum format: "<64-hex> \n" - auto sp = out.find(' '); - if (sp == std::string::npos || sp != 64) return {}; - return out.substr(0, 64); -} - -std::string make_release_tarball(const std::filesystem::path& root, - std::string_view name, - std::string_view version, - const std::filesystem::path& output) -{ - std::error_code ec; - std::filesystem::create_directories(output.parent_path(), ec); - - auto cmd = std::format( - "git -C '{}' archive --format=tar.gz " - "--prefix='{}-{}/' " - "-o '{}' HEAD 2>&1", - root.string(), name, version, output.string()); - std::FILE* fp = ::popen(cmd.c_str(), "r"); - if (!fp) return std::format("popen failed for git archive: {}", cmd); - - std::array buf{}; - std::string err; - while (std::fgets(buf.data(), buf.size(), fp)) - err += buf.data(); - int rc = ::pclose(fp); - if (rc != 0) { - return std::format("git archive failed (rc={}): {}", rc, err); - } - if (!std::filesystem::exists(output)) { - return std::format("git archive exited 0 but no tarball at '{}'", - output.string()); - } - return {}; -} +using ReleaseInfo = mcpp::pm::ReleaseInfo; -ReleaseInfo make_release_info(std::string_view version, - std::string_view url, - std::string_view sha256) -{ - ReleaseInfo r; - r.version = std::string(version); - auto fill = [&](ReleaseInfo::PerPlatform& pp) { - pp.url = std::string(url); - pp.sha256 = std::string(sha256); - }; - fill(r.linux); - fill(r.macosx); - fill(r.windows); - return r; -} +using mcpp::pm::emit_xpkg; +using mcpp::pm::placeholder_release; +using mcpp::pm::release_tarball_url; +using mcpp::pm::sha256_of_file; +using mcpp::pm::make_release_tarball; +using mcpp::pm::make_release_info; } // namespace mcpp::publish