// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; namespace fs = std::filesystem; using namespace Crafter; // `crafter-build run [args...]` runs a script project.cpp put in // cfg.scripts and exits with what the script returned. // // Two things are easy to get wrong and expensive when they are. The words // after the script's name belong to the script: read by crafter-build // instead, `run package clean` deletes the build tree, `run package --help` // prints the wrong help, and anything else lands in the project's args, // where it changes the variant hash and so the directory the project builds // into. And the exit code is the script's whole result: collapsed to 0 or 1 // it loses what a caller branches on, and passed through unchecked a value // such as 256 wraps to success. namespace { std::int32_t Failures = 0; void Check(bool cond, std::string_view msg) { if (!cond) { std::println(std::cerr, "FAIL: {}", msg); ++Failures; } } std::int32_t RunWith(std::vector args) { std::vector argv; for (std::string& arg : args) argv.push_back(arg.data()); return Run(static_cast(argv.size()), argv.data()); } std::vector ReadLines(const fs::path& file) { std::vector lines; std::ifstream in(file); for (std::string line; std::getline(in, line);) lines.push_back(line); return lines; } // One script that returns `code`, run by name. std::int32_t RunReturning(std::int32_t code) { Configuration cfg; cfg.scripts.push_back({"it", [code](ScriptContext&) { return code; }}); return RunScript(cfg, {.name = "it", .named = true}); } // LoadProject compiles a project file against the installed module // sources; without them the command-line cases cannot run at all. bool ModuleSourcesInstalled() { try { return fs::exists(GetCrafterBuildHome() / "Crafter.Build.cppm"); } catch (const std::exception&) { return false; } } } int main() { const fs::path fixture = fs::current_path() / "tests" / "Script" / "fixture"; const fs::path scratch = fs::temp_directory_path() / "crafter-build-script"; std::error_code ec; fs::remove_all(scratch, ec); fs::create_directories(scratch); fs::copy(fixture, scratch, fs::copy_options::recursive); // The script gets the project, the project's directory and its // arguments exactly as given, and its return value comes back. { Configuration cfg; std::int32_t calls = 0; std::vector seen; const Configuration* seenProject = nullptr; fs::path seenDir; cfg.scripts.push_back({"other", [&](ScriptContext&) { ++calls; return 9; }}); cfg.scripts.push_back({"pass", [&](ScriptContext& ctx) { ++calls; seen.assign(ctx.args.begin(), ctx.args.end()); seenProject = &ctx.project; seenDir = ctx.projectDir; return 3; }}); const std::vector given = {"a", "--help", "", "two words", "it's"}; const std::int32_t code = RunScript(cfg, {.name = "pass", .named = true, .args = given, .projectFile = scratch / "project.cpp"}); Check(code == 3, std::format("the script's return value is the result (got {})", code)); Check(calls == 1, std::format("only the named script ran (ran {})", calls)); Check(seen == given, "the arguments arrive verbatim, empty ones and option-like ones included"); Check(seenProject == &cfg, "ctx.project is the configuration the script was declared on"); Check(seenDir == scratch, std::format("ctx.projectDir is the project file's directory (got '{}')", seenDir.string())); // Asking for the list runs nothing, with or without a name. calls = 0; Check(RunScript(cfg, {}) == 0, "a run without a name lists and succeeds"); Check(RunScript(cfg, {.name = "pass", .named = true, .listOnly = true}) == 0, "--list succeeds"); Check(calls == 0, "listing runs no script"); // A name nobody declared. The empty string is one: it is a name // that was given, not the absence of a name. Check(RunScript(cfg, {.name = "nope", .named = true}) == 1, "an unknown name fails"); Check(RunScript(cfg, {.name = "", .named = true}) == 1, "an empty name is looked up and fails instead of listing"); Check(RunScript(cfg, {.name = "pas", .named = true}) == 1, "names match exactly, not by prefix"); Check(calls == 0, "an unknown name runs no script"); } // The exit status passes through whole, and only an exit status does. Check(RunReturning(0) == 0, "0 stays 0"); Check(RunReturning(1) == 1, "1 stays 1"); Check(RunReturning(77) == 77, "77 stays 77"); Check(RunReturning(255) == 255, "255 stays 255"); Check(RunReturning(256) == 1, "256 would wrap to success as an exit status and becomes 1"); Check(RunReturning(-1) == 1, "a negative value becomes 1"); // A script that throws is that script's failure, not a crash. { Configuration cfg; cfg.scripts.push_back({"throws", [](ScriptContext&) -> std::int32_t { throw std::runtime_error("no such tool"); }}); Check(RunScript(cfg, {.name = "throws", .named = true}) == 1, "a throwing script exits 1"); cfg.scripts.push_back({"throwsInt", [](ScriptContext&) -> std::int32_t { throw 7; }}); Check(RunScript(cfg, {.name = "throwsInt", .named = true}) == 1, "and so does one throwing something that isn't a std::exception"); } // Declarations that cannot work are refused before anything runs, also // when a different, valid script was asked for. { Configuration none; Check(RunScript(none, {.name = "it", .named = true}) == 1, "a project without scripts fails"); Check(RunScript(none, {}) == 1, "listing a project without scripts fails too"); std::int32_t calls = 0; const auto count = [&](ScriptContext&) { ++calls; return 0; }; Configuration twice; twice.scripts.push_back({"a", count}); twice.scripts.push_back({"b", count}); twice.scripts.push_back({"a", count}); Check(RunScript(twice, {.name = "b", .named = true}) == 1, "a name declared twice fails the run"); Check(RunScript(twice, {}) == 1, "a name declared twice fails the list"); Configuration dashed; dashed.scripts.push_back({"ok", count}); dashed.scripts.push_back({"-x", count}); Check(RunScript(dashed, {.name = "ok", .named = true}) == 1, "a name starting with '-' could never be typed and is refused"); Configuration unnamed; unnamed.scripts.push_back({"", count}); Check(RunScript(unnamed, {.name = "", .named = true}) == 1, "an empty name is refused"); Configuration hollow; hollow.scripts.push_back({"hollow", nullptr}); Check(RunScript(hollow, {.name = "hollow", .named = true}) == 1, "a script without a callback is refused"); Check(calls == 0, "none of these ran a script"); } // ctx.Run: the command's real exit code, a signal death the way a shell // reports it, and ShellQuote carrying awkward text through sh intact. { const std::string awkward = "it's a \"test\" $HOME `id` \\ *"; const fs::path echoed = scratch / "echoed file.txt"; Configuration cfg; cfg.scripts.push_back({"exit", [](ScriptContext& ctx) { return ctx.Run("exit 7"); }}); cfg.scripts.push_back({"signal", [](ScriptContext& ctx) { return ctx.Run("kill -TERM $$"); }}); cfg.scripts.push_back({"quote", [&](ScriptContext& ctx) { return ctx.Run(std::format("printf %s {} > {}", ShellQuote(awkward), ShellQuote(echoed.string()))); }}); // A script is usually several commands: stop at the first failure. cfg.scripts.push_back({"steps", [&](ScriptContext& ctx) { if (const std::int32_t code = ctx.Run("exit 5"); code != 0) return code; return ctx.Run(std::format("touch {}", ShellQuote((scratch / "not-reached").string()))); }}); Check(RunScript(cfg, {.name = "exit", .named = true}) == 7, "ctx.Run returns the command's exit code"); Check(RunScript(cfg, {.name = "signal", .named = true}) == 143, "a command killed by SIGTERM reads as 128+15"); Check(RunScript(cfg, {.name = "quote", .named = true}) == 0, "a quoted command line runs"); const std::vector lines = ReadLines(echoed); Check(lines.size() == 1 && lines[0] == awkward, "ShellQuote passes quotes, spaces and expansions through sh untouched"); Check(RunScript(cfg, {.name = "steps", .named = true}) == 5, "a failed step's code can be returned as the script's"); Check(!fs::exists(scratch / "not-reached"), "and the steps after it do not run"); } if (!ModuleSourcesInstalled()) { // A skip must never cover for a failure above. if (Failures > 0) return 1; std::println(std::cerr, "SKIP: no module sources to load the fixture project with"); return 77; } // The same through the command line, where the parsing lives. Sentinels // stand in for a built project: a word after `run` that is read as the // clean verb takes them with it. const std::string project = std::format("--project={}", (scratch / "project.cpp").string()); const fs::path binSentinel = scratch / "bin" / "sentinel"; const fs::path buildSentinel = scratch / "build" / "sentinel"; fs::create_directories(binSentinel.parent_path()); fs::create_directories(buildSentinel.parent_path()); std::ofstream(binSentinel) << "binary"; std::ofstream(buildSentinel) << "object"; // Everything after the name is the script's, including this tool's own // options and verbs; what stands before `run` is still the project's. { const std::int32_t code = RunWith({"crafter-build", project, "--flavour=x", "run", "record", "a", "--help", "-v", "clean", "--debug", "--version", "run"}); Check(code == 42, std::format("the script ran and its return value is the exit code (got {})", code)); const std::vector expected = { "arg:a", "arg:--help", "arg:-v", "arg:clean", "arg:--debug", "arg:--version", "arg:run", "projectArg:--flavour=x", "debug:false", "project:script-fixture", }; const std::vector record = ReadLines(scratch / "record.txt"); Check(record == expected, "the script got every word after its name and the project args got none of them"); if (record != expected) { for (const std::string& line : record) std::println(std::cerr, " recorded: {}", line); } Check(fs::exists(binSentinel) && fs::exists(buildSentinel), "`clean` as a script argument deleted nothing"); } // The first word after `run` is the name even when it is a verb. { fs::remove(scratch / "record.txt"); Check(RunWith({"crafter-build", project, "run", "clean"}) == 1, "`run clean` looks for a script called clean"); Check(fs::exists(binSentinel) && fs::exists(buildSentinel), "`run clean` deleted nothing"); Check(RunWith({"crafter-build", project, "run", "help"}) == 1, "`run help` looks for a script called help"); } Check(RunWith({"crafter-build", project, "run", "shell"}) == 7, "a command's exit code reaches the process exit code"); Check(RunWith({"crafter-build", project, "run", "-q", "shell"}) == 7, "an option between `run` and the name is still this tool's"); Check(RunWith({"crafter-build", project, "run"}) == 0, "a bare `run` lists"); Check(RunWith({"crafter-build", project, "run", "--list"}) == 0, "`run --list` lists"); Check(!fs::exists(scratch / "record.txt"), "listing ran no script"); Check(RunWith({"crafter-build", project, "run", "nope"}) == 1, "an unknown script fails"); if (Failures > 0) { std::println(std::cerr, "{} assertions failed", Failures); return 1; } return 0; }