// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; // Pinned: the Flatpak builds with the base app's crafter-build, which can be // older than the rules' master. Move the pin together with the base. #pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "7d932892017800b4e1f1a15fe7aa49b3daee9ceb" import Crafter.LintRules; namespace fs = std::filesystem; using namespace Crafter; namespace { // The umbrella, then the partitions in import order: each after the ones // it imports, which is also the order the module map in AGENTS.md uses. constexpr std::array Interfaces = { "interfaces/Crafter.Review", "interfaces/Crafter.Review-Unicode", "interfaces/Crafter.Review-Json", "interfaces/Crafter.Review-Text", "interfaces/Crafter.Review-Crypto", "interfaces/Crafter.Review-Platform", "interfaces/Crafter.Review-Process", "interfaces/Crafter.Review-Core", "interfaces/Crafter.Review-Schema", "interfaces/Crafter.Review-Tools", "interfaces/Crafter.Review-Plan", "interfaces/Crafter.Review-Artifact", "interfaces/Crafter.Review-Inventory", "interfaces/Crafter.Review-Dispositions", "interfaces/Crafter.Review-State", "interfaces/Crafter.Review-Normalize", "interfaces/Crafter.Review-PgWire", "interfaces/Crafter.Review-Totp", "interfaces/Crafter.Review-Stream", "interfaces/Crafter.Review-Prompt", "interfaces/Crafter.Review-Config", "interfaces/Crafter.Review-Memory", "interfaces/Crafter.Review-Mcp", "interfaces/Crafter.Review-Seats", "interfaces/Crafter.Review-Freeze", "interfaces/Crafter.Review-Driver", }; constexpr std::array Implementations = { "implementations/Crafter.Review-Text", "implementations/Crafter.Review-Crypto", "implementations/Crafter.Review-Platform", "implementations/Crafter.Review-Process", "implementations/Crafter.Review-Core", "implementations/Crafter.Review-Schema", "implementations/Crafter.Review-Tools", "implementations/Crafter.Review-Plan", "implementations/Crafter.Review-Artifact", "implementations/Crafter.Review-Inventory", "implementations/Crafter.Review-Dispositions", "implementations/Crafter.Review-State", "implementations/Crafter.Review-Normalize", "implementations/Crafter.Review-PgWire", "implementations/Crafter.Review-Totp", "implementations/Crafter.Review-Stream", "implementations/Crafter.Review-Prompt", "implementations/Crafter.Review-Config", "implementations/Crafter.Review-Memory", "implementations/Crafter.Review-Mcp", "implementations/Crafter.Review-Seats", "implementations/Crafter.Review-Freeze", "implementations/Crafter.Review-Driver", }; // With -march=native clang would fuse a multiply and an add: a cost // estimate, a usage total or a memory threshold must come out to the // same last bit on every machine, as the goldens record it. constexpr std::string_view NoContraction = "-ffp-contract=off"; // One per directory under tests/, each an ordinary main() that links the // library: exit 0 passes, nonzero fails, 77 skips. constexpr std::array Tests = { "Crypto", "Text", "Platform", "Process", "Goldens", "Stream", "Normalize", "Merge", "Dispositions", "Config", "CommitsMode", "CodeKeys", "Freeze", "PgWire", "Seats", }; // The version the binary reports: CI sets it from the tag, a checkout // describes itself, anything else is a development build. A define on // the library, so each version is its own library variant. std::string Version() { if (const char* env = std::getenv("CRAFTER_REVIEW_VERSION"); env && *env) return env; auto described = RunCommandWithTimeout("git describe --tags --always --dirty --exclude=latest 2>/dev/null", std::chrono::seconds(10)); std::string version = described.output; while (!version.empty() && (version.back() == '\n' || version.back() == '\r')) version.pop_back(); if (described.exitCode != 0 || version.empty()) return "0.0.0-dev"; return version; } // The local Flatpak: `crafter-build run flatpak` builds it from this // checkout and `crafter-build run install` installs that build, as // Crafter.Build's own scripts do. --base= builds on another branch // of the Crafter.Build base app than stable: until a Crafter.Build release // with scripts is on stable, `--base=local` (the branch Crafter.Build's own // `run install` makes) is what builds this project. struct FlatpakPaths { fs::path manifest; // flatpak-builder's downloads, ccache and per-module cache. fs::path state; // The OSTree repo the build is exported to; `install` reads it. fs::path repo; fs::path buildDir; }; // Everything under /build/flatpak, a fixed name rather than a // BuildDir(): the Flatpak is compiled by the toolchain inside the sandbox, // not by the host variant. flatpak-builder keys its cache on the // manifest's absolute path, so a branch other than stable gets a manifest // copy of its own next to the original (gitignored), and with it a cache // of its own. FlatpakPaths FlatpakLayout(const fs::path& projectDir, std::string_view base) { const fs::path root = projectDir / "build" / "flatpak"; const fs::path packaging = projectDir / "packaging" / "flatpak"; return { .manifest = base == "stable" ? packaging / "net.catcrafts.CrafterReview.yaml" : packaging / std::format("{}-base.yaml", base), .state = root / "state", .repo = root / "repo", .buildDir = root / "build-dir", }; } // Both scripts drive the host's flatpak, which the sandbox of a Flatpak // crafter-build does not have. bool RefusedInSandbox(std::string_view script) { if (!fs::exists("/.flatpak-info")) return false; std::println(std::cerr, "run {0}: needs the host's flatpak, which the Flatpak build of crafter-build cannot reach. Use a native crafter-build", script); return true; } // flatpak-builder as a host package, else the org.flatpak.Builder app. // Empty when neither is installed. std::string FlatpakBuilder() { if (RunCommandWithTimeout("flatpak-builder --version", std::chrono::seconds(10)).exitCode == 0) return "flatpak-builder"; if (RunCommandWithTimeout("flatpak info org.flatpak.Builder", std::chrono::seconds(10)).exitCode == 0) return "flatpak run org.flatpak.Builder"; return ""; } // A repo path as the file:// URL of a remote. Percent-encoded, because // `remote-modify --url=` stores what it is given as it is. std::string FileUrl(const fs::path& path) { std::string url = "file://"; for (const char c : path.string()) { const bool plain = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || std::string_view("/-._~").contains(c); if (plain) { url.push_back(c); } else { url += std::format("%{:02X}", static_cast(c)); } } return url; } std::int32_t BuildFlatpak(ScriptContext& ctx) { if (RefusedInSandbox("flatpak")) return 1; std::string jobs; std::string base = "stable"; for (const std::string& arg : ctx.args) { if (arg.starts_with("--base=") && arg.size() > 7) { base = arg.substr(7); continue; } const std::string_view count = arg.starts_with("--jobs=") ? std::string_view(arg).substr(7) : std::string_view(); if (count.empty() || !std::ranges::all_of(count, [](char c) { return c >= '0' && c <= '9'; })) { std::println(std::cerr, "run flatpak: unknown argument '{}' (only --jobs= and --base= are accepted)", arg); return 1; } jobs = std::format(" --jobs={}", count); } if (!std::ranges::all_of(base, [](char c) { return std::isalnum(static_cast(c)) || c == '-' || c == '_' || c == '.'; })) { std::println(std::cerr, "run flatpak: '{}' is not a branch name", base); return 1; } if (RunCommandWithTimeout(std::format("flatpak info net.catcrafts.CrafterBuild//{}", base), std::chrono::seconds(10)).exitCode != 0) { std::println(std::cerr, "run flatpak: the base app is not installed: flatpak install catcrafts net.catcrafts.CrafterBuild//{}", base); return 1; } const std::string builder = FlatpakBuilder(); if (builder.empty()) { std::println(std::cerr, "run flatpak: neither flatpak-builder nor the org.flatpak.Builder app is installed (flatpak install flathub org.flatpak.Builder)"); return 1; } const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, base); if (base != "stable") { // The manifest with only base-version changed. std::ifstream in(FlatpakLayout(ctx.projectDir, "stable").manifest); const std::string manifest((std::istreambuf_iterator(in)), std::istreambuf_iterator()); constexpr std::string_view Line = "base-version: stable\n"; const std::size_t at = manifest.find(Line); if (at == std::string::npos) { std::println(std::cerr, "run flatpak: the manifest has no `{}` line to replace", Line.substr(0, Line.size() - 1)); return 1; } std::ofstream(paths.manifest) << manifest.substr(0, at) << std::format("base-version: {}\n", base) << manifest.substr(at + Line.size()); } const std::string buildDir = ShellQuote(paths.buildDir.string()); if (const std::int32_t code = ctx.Run(std::format("{}{} --user --force-clean --ccache --state-dir={} --repo={} --default-branch=local {} {}", builder, jobs, ShellQuote(paths.state.string()), ShellQuote(paths.repo.string()), buildDir, ShellQuote(paths.manifest.string()))); code != 0) return code; // The smoke test, through `flatpak build` and not ` --run`: // the org.flatpak.Builder app reports --run's exit status inverted. if (const std::int32_t code = ctx.Run(std::format("flatpak build {} crafter-review --version", buildDir)); code != 0) return code; return ctx.Run(std::format("flatpak build {} sh -c 'ldd /app/bin/crafter-review | grep \"not found\" && exit 1 || exit 0'", buildDir)); } std::int32_t InstallFlatpak(ScriptContext& ctx) { if (RefusedInSandbox("install")) return 1; if (!ctx.args.empty()) { std::println(std::cerr, "run install: takes no arguments (got '{}')", ctx.args.front()); return 1; } const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, "stable"); if (!fs::exists(paths.repo / "config")) { std::println(std::cerr, "run install: nothing built yet, run: crafter-build run flatpak"); return 1; } const std::string url = ShellQuote(FileUrl(paths.repo)); if (const std::int32_t code = ctx.Run(std::format("flatpak remote-add --user --if-not-exists --no-gpg-verify crafter-review-local {}", url)); code != 0) return code; if (const std::int32_t code = ctx.Run(std::format("flatpak remote-modify --user --url={} crafter-review-local", url)); code != 0) return code; if (const std::int32_t code = ctx.Run("flatpak install --user --noninteractive --or-update crafter-review-local net.catcrafts.CrafterReview//local"); code != 0) return code; if (const std::int32_t code = ctx.Run("flatpak make-current --user net.catcrafts.CrafterReview local"); code != 0) return code; std::println( R"( `flatpak run net.catcrafts.CrafterReview` now starts this build. Back to a published branch, keeping this one installed: flatpak make-current --user net.catcrafts.CrafterReview Remove it (do this before deleting build/, which holds the repo the remote points at): flatpak uninstall --user net.catcrafts.CrafterReview//local flatpak remote-delete --user crafter-review-local)"); return 0; } } extern "C" Configuration CrafterBuildProject(std::span args) { // Crafter.Network for its TCP and HTTP/1.1 clients and listener, without // QUIC: msquic is most of a fresh build and nothing here speaks HTTP/3. // From forgejo at a pinned commit, or with --local from the checkout next // to this one (which is also how Crafter.Network then finds // Crafter.Thread). std::vector depArgs(args.begin(), args.end()); depArgs.emplace_back("--no-quic"); const bool useLocal = std::ranges::find(args, std::string_view("--local")) != args.end(); Configuration* network = useLocal ? LocalProject({ .projectFile = "../Crafter.Network/project.cpp", .args = depArgs }) : GitProject({ .source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git", .commit = "f68321685006be65f6bc0436fd0175611f1e0a55" }, .args = depArgs, }); static auto ReviewLib = std::make_unique(); ReviewLib->path = "./"; ReviewLib->name = "crafter.review-lib"; ReviewLib->outputName = "crafter-review"; ApplyStandardArgs(*ReviewLib, args); ReviewLib->type = ConfigurationType::LibraryStatic; ReviewLib->dependencies = { network }; ReviewLib->compileFlags.emplace_back(NoContraction); // Backslash-escaped quotes: the define travels through the shell before // it reaches clang, which must still see a string literal. ReviewLib->defines.push_back({"CRAFTER_REVIEW_VERSION", std::format("\\\"{}\\\"", Version())}); { std::vector interfaces(Interfaces.begin(), Interfaces.end()); std::vector implementations(Implementations.begin(), Implementations.end()); ReviewLib->GetInterfacesAndImplementations(interfaces, implementations); } Configuration cfg; cfg.path = "./"; cfg.name = "crafter.review-exe"; cfg.outputName = "crafter-review"; ApplyStandardArgs(cfg, args); cfg.type = ConfigurationType::Executable; cfg.dependencies = { ReviewLib.get() }; cfg.compileFlags.emplace_back(NoContraction); { std::array interfaces = {}; std::array implementations = { "implementations/main" }; cfg.GetInterfacesAndImplementations(interfaces, implementations); } for (const std::string_view test : Tests) { cfg.AddTest(test).Dependencies({ ReviewLib.get() }).CompileFlag(std::string(NoContraction)); } LintRules::AddLiteRules(cfg); cfg.scripts.push_back({"flatpak", BuildFlatpak}); cfg.scripts.push_back({"install", InstallFlatpak}); return cfg; }