Skip to content

Commit 44e3626

Browse files
committed
refactor(pm): move publish/xpkg_emit.cppm into pm/publisher.cppm (PR-R6)
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.
1 parent d97ab2f commit 44e3626

2 files changed

Lines changed: 288 additions & 258 deletions

File tree

src/pm/publisher.cppm

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// mcpp.pm.publisher — generate xpkg Lua entry from mcpp.toml + scanner.
2+
//
3+
// See docs/04-schema-xpkg-extension.md for the produced layout.
4+
5+
module;
6+
#include <cstdio> // popen / pclose / fgets
7+
8+
export module mcpp.pm.publisher;
9+
10+
import std;
11+
import mcpp.manifest;
12+
import mcpp.modgraph.graph;
13+
14+
export namespace mcpp::pm {
15+
16+
struct ReleaseInfo {
17+
std::string version; // tag/version, e.g. "0.1.0"
18+
19+
struct PerPlatform {
20+
std::string url;
21+
std::string sha256;
22+
};
23+
PerPlatform linux;
24+
PerPlatform macosx;
25+
PerPlatform windows;
26+
};
27+
28+
// Generate the xpkg Lua content for a package.
29+
std::string emit_xpkg(const mcpp::manifest::Manifest& manifest,
30+
const mcpp::modgraph::Graph& graph,
31+
const ReleaseInfo& release);
32+
33+
// Convenience: synthesize a placeholder ReleaseInfo for `mcpp emit xpkg --version V`
34+
// before publish infrastructure exists. Uses {url, sha256} sentinels.
35+
ReleaseInfo placeholder_release(std::string_view version);
36+
37+
// Compute the convention-based GitHub Release tarball URL for a package:
38+
// "<repo>/releases/download/v<version>/<name>-<version>.tar.gz"
39+
// Returns empty string if `repo` is empty or doesn't look like a https URL.
40+
std::string release_tarball_url(std::string_view repo,
41+
std::string_view name,
42+
std::string_view version);
43+
44+
// Compute SHA-256 of `file` by shelling out to `sha256sum` (universally
45+
// available on Linux). Returns empty string on failure.
46+
std::string sha256_of_file(const std::filesystem::path& file);
47+
48+
// Pack the package source tree at `root` into a tarball at `output` using
49+
// `git archive` (so .gitignore'd files are excluded automatically). The
50+
// tarball uses prefix "<name>-<version>/" so unpacking yields a clean
51+
// versioned directory.
52+
//
53+
// Requires the project to be in a git repo.
54+
//
55+
// Returns a non-empty error message on failure (empty on success).
56+
std::string make_release_tarball(const std::filesystem::path& root,
57+
std::string_view name,
58+
std::string_view version,
59+
const std::filesystem::path& output);
60+
61+
// Convenience: build a real ReleaseInfo for v0.0.3-style local publish
62+
// where all three platforms point at the same source tarball. Caller has
63+
// already produced the tarball + sha256 by other means.
64+
ReleaseInfo make_release_info(std::string_view version,
65+
std::string_view url,
66+
std::string_view sha256);
67+
68+
} // namespace mcpp::pm
69+
70+
namespace mcpp::pm {
71+
72+
namespace {
73+
74+
// Quote `s` as a Lua double-quoted string literal: `"..."`.
75+
//
76+
// We deliberately use `"..."` (not the long-bracket `[[...]]` form)
77+
// so the only meta-characters that need escaping are the standard set
78+
// for `"` strings:
79+
// - `"` and `\\` must be backslash-escaped
80+
// - newline / carriage-return / NUL break the string literal
81+
// - other control bytes are escaped numerically (`\xHH`) for safety
82+
// so the emitted .lua is purely printable ASCII even when the input
83+
// contains exotic bytes
84+
//
85+
// Long-bracket sequences like `]=]` are NOT a vector here because we
86+
// never emit `[[`/`]=]` ourselves — the output is always `"..."`.
87+
std::string lua_escape(std::string_view s) {
88+
std::string out;
89+
out.reserve(s.size() + 2);
90+
out.push_back('"');
91+
for (unsigned char c : s) {
92+
switch (c) {
93+
case '"': out += "\\\""; break;
94+
case '\\': out += "\\\\"; break;
95+
case '\n': out += "\\n"; break;
96+
case '\r': out += "\\r"; break;
97+
case '\t': out += "\\t"; break;
98+
case 0: out += "\\0"; break;
99+
default:
100+
if (c < 0x20 || c == 0x7f) {
101+
// Other C0 controls + DEL — emit as \xHH to keep the
102+
// .lua text purely printable.
103+
char buf[5];
104+
std::snprintf(buf, sizeof(buf), "\\x%02x", c);
105+
out += buf;
106+
} else {
107+
out.push_back(static_cast<char>(c));
108+
}
109+
break;
110+
}
111+
}
112+
out.push_back('"');
113+
return out;
114+
}
115+
116+
std::string platform_block(std::string_view version, const ReleaseInfo::PerPlatform& pp) {
117+
return std::format(
118+
" ['{0}'] = {{ url = {1}, sha256 = {2} }},\n",
119+
version, lua_escape(pp.url), lua_escape(pp.sha256));
120+
}
121+
122+
} // namespace
123+
124+
std::string emit_xpkg(const mcpp::manifest::Manifest& manifest,
125+
const mcpp::modgraph::Graph& graph,
126+
const ReleaseInfo& release)
127+
{
128+
std::string out;
129+
out += "-- AUTO-GENERATED by `mcpp emit xpkg`. Do not edit by hand.\n";
130+
out += std::format("-- Source: mcpp.toml @ v{}\n", release.version);
131+
out += "package = {\n";
132+
out += " spec = \"1\",\n";
133+
out += std::format(" name = {},\n", lua_escape(manifest.package.name));
134+
if (!manifest.package.description.empty())
135+
out += std::format(" description = {},\n", lua_escape(manifest.package.description));
136+
if (!manifest.package.license.empty())
137+
out += std::format(" licenses = {{{}}},\n", lua_escape(manifest.package.license));
138+
if (!manifest.package.repo.empty())
139+
out += std::format(" repo = {},\n", lua_escape(manifest.package.repo));
140+
out += " type = \"package\",\n\n";
141+
142+
out += " xpm = {\n";
143+
out += " linux = {\n" + platform_block(release.version, release.linux) + " },\n";
144+
out += " macosx = {\n" + platform_block(release.version, release.macosx) + " },\n";
145+
out += " windows = {\n" + platform_block(release.version, release.windows) + " },\n";
146+
out += " },\n\n";
147+
148+
out += " mcpp = {\n";
149+
out += " schema = \"0.1\",\n";
150+
out += std::format(" language = {},\n", lua_escape(manifest.language.standard));
151+
out += std::format(" import_std = {},\n", manifest.language.importStd ? "true" : "false");
152+
153+
// Module list (from scanner)
154+
out += " modules = {\n";
155+
for (auto& u : graph.units) {
156+
if (!u.provides) continue;
157+
// Skip partition-only units: their logical name contains ':'
158+
if (u.provides->logicalName.find(':') != std::string::npos) continue;
159+
out += std::format(" {},\n", lua_escape(u.provides->logicalName));
160+
}
161+
out += " },\n";
162+
163+
// Dependencies (excluding dev-dependencies). Path-based deps are
164+
// local-only and intentionally not exposed in the published xpkg
165+
// descriptor; only version-based deps are emitted.
166+
out += " deps = {\n";
167+
for (auto& [k, v] : manifest.dependencies) {
168+
if (v.isPath() || v.version.empty()) continue;
169+
out += std::format(" [{}] = {},\n", lua_escape(k), lua_escape(v.version));
170+
}
171+
out += " },\n";
172+
173+
out += " manifest = \"mcpp.toml\",\n";
174+
out += " },\n";
175+
out += "}\n";
176+
return out;
177+
}
178+
179+
ReleaseInfo placeholder_release(std::string_view version) {
180+
ReleaseInfo r;
181+
r.version = std::string(version);
182+
auto fill = [&](ReleaseInfo::PerPlatform& pp, std::string_view ext) {
183+
pp.url = std::format("<TBD: release tarball URL>.{}", ext);
184+
pp.sha256 = "<TBD: sha256>";
185+
};
186+
fill(r.linux, "tar.gz");
187+
fill(r.macosx, "tar.gz");
188+
fill(r.windows, "zip");
189+
return r;
190+
}
191+
192+
std::string release_tarball_url(std::string_view repo,
193+
std::string_view name,
194+
std::string_view version)
195+
{
196+
// Strip trailing ".git" if present.
197+
std::string r{repo};
198+
if (r.ends_with(".git")) r.resize(r.size() - 4);
199+
if (r.empty()) return {};
200+
if (!r.starts_with("https://") && !r.starts_with("http://")) return {};
201+
return std::format("{}/releases/download/v{}/{}-{}.tar.gz",
202+
r, version, name, version);
203+
}
204+
205+
std::string sha256_of_file(const std::filesystem::path& file) {
206+
if (!std::filesystem::exists(file)) return {};
207+
auto cmd = std::format("sha256sum '{}' 2>/dev/null", file.string());
208+
std::FILE* fp = ::popen(cmd.c_str(), "r");
209+
if (!fp) return {};
210+
std::array<char, 256> buf{};
211+
std::string out;
212+
while (std::fgets(buf.data(), buf.size(), fp))
213+
out += buf.data();
214+
int rc = ::pclose(fp);
215+
if (rc != 0) return {};
216+
// sha256sum format: "<64-hex> <filename>\n"
217+
auto sp = out.find(' ');
218+
if (sp == std::string::npos || sp != 64) return {};
219+
return out.substr(0, 64);
220+
}
221+
222+
std::string make_release_tarball(const std::filesystem::path& root,
223+
std::string_view name,
224+
std::string_view version,
225+
const std::filesystem::path& output)
226+
{
227+
std::error_code ec;
228+
std::filesystem::create_directories(output.parent_path(), ec);
229+
230+
auto cmd = std::format(
231+
"git -C '{}' archive --format=tar.gz "
232+
"--prefix='{}-{}/' "
233+
"-o '{}' HEAD 2>&1",
234+
root.string(), name, version, output.string());
235+
std::FILE* fp = ::popen(cmd.c_str(), "r");
236+
if (!fp) return std::format("popen failed for git archive: {}", cmd);
237+
238+
std::array<char, 4096> buf{};
239+
std::string err;
240+
while (std::fgets(buf.data(), buf.size(), fp))
241+
err += buf.data();
242+
int rc = ::pclose(fp);
243+
if (rc != 0) {
244+
return std::format("git archive failed (rc={}): {}", rc, err);
245+
}
246+
if (!std::filesystem::exists(output)) {
247+
return std::format("git archive exited 0 but no tarball at '{}'",
248+
output.string());
249+
}
250+
return {};
251+
}
252+
253+
ReleaseInfo make_release_info(std::string_view version,
254+
std::string_view url,
255+
std::string_view sha256)
256+
{
257+
ReleaseInfo r;
258+
r.version = std::string(version);
259+
auto fill = [&](ReleaseInfo::PerPlatform& pp) {
260+
pp.url = std::string(url);
261+
pp.sha256 = std::string(sha256);
262+
};
263+
fill(r.linux);
264+
fill(r.macosx);
265+
fill(r.windows);
266+
return r;
267+
}
268+
269+
} // namespace mcpp::pm

0 commit comments

Comments
 (0)