// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® #include import std; import Crafter.Build; namespace fs = std::filesystem; using namespace Crafter; // `#pragma crafter dependency`: a project.cpp that imports a module from a // sibling crafter-build project. LoadProject has to fetch and build that // project before project.cpp compiles, put its BMI and archive on the // project.so command line, and treat the archive as an input to the // staleness check. The fixture is copied to a scratch dir because one case // edits a source and every build drops bin/ and build/ trees next to it. // // A recompiled project.so can't be observed from the process that already // dlopened the old one — the loader hands back the mapping it has for that // path — so the "dependency changed" cases load through a child process: // this binary re-run with `--load ` prints the name the project // file produced. namespace { std::int32_t Failures = 0; void Check(bool cond, std::string_view msg) { if (!cond) { std::println(std::cerr, "FAIL: {}", msg); ++Failures; } } // Expect LoadProject to throw; the message, or "" when it didn't. std::string LoadFails(const fs::path& projectFile) { std::array noArgs = {}; try { (void)LoadProject(projectFile, noArgs); } catch (const std::exception& e) { return e.what(); } return ""; } // LoadProject in a fresh process; returns what it printed, trimmed. std::string LoadInChild(const fs::path& projectFile) { const fs::path self = fs::read_symlink("/proc/self/exe"); const auto r = RunCommandWithTimeout(std::format("{} --load {}", self.string(), projectFile.string()), std::chrono::seconds(300)); std::string out = r.output; while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back(); if (r.exitCode != 0) return std::format(" {}", r.exitCode, out); // The dependency build's progress lines share stdout; the name is the // last line. const std::size_t lastBreak = out.rfind('\n'); return lastBreak == std::string::npos ? out : out.substr(lastBreak + 1); } void ChangeGreeting(const fs::path& scratch) { const fs::path iface = scratch / "greeter" / "interfaces" / "Greeter.cppm"; fs::copy_file(scratch / "greeter" / "interfaces" / "Greeter-changed.cppm.in", iface, fs::copy_options::overwrite_existing); // copy_file carries the source's mtime across, which would leave the // rewritten interface looking older than the BMI built from it. fs::last_write_time(iface, fs::file_time_type::clock::now()); } } int main(int argc, char** argv) { if (argc == 3 && std::string_view(argv[1]) == "--load") { std::array noArgs = {}; Configuration cfg = LoadProject(argv[2], noArgs); std::println("{}", cfg.name); return 0; } const fs::path installedHome = GetCrafterBuildHome(); if (!fs::exists(installedHome / "Crafter.Build.cppm")) { std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string()); return 77; } const fs::path fixture = fs::current_path() / "tests" / "ProjectDependency" / "fixture"; const fs::path scratch = fs::temp_directory_path() / "crafter-build-project-dependency"; std::error_code ec; fs::remove_all(scratch, ec); fs::create_directories(scratch); fs::copy(fixture, scratch, fs::copy_options::recursive); // The dependency's project.cpp applies the standard args, which read the // march from the environment. project.so is compiled for the host, so // the pre-pass must override it or the dependency's BMIs won't load. setenv("CRAFTER_BUILD_MARCH", "x86-64-v2", 1); const fs::path app = scratch / "app" / "project.cpp"; const fs::path appSo = scratch / "app" / "build" / "project.so"; std::array noArgs = {}; // The dependency is fetched, built and linked before project.cpp runs, // and ProjectDependency() hands it back by either name. try { Configuration cfg = LoadProject(app, noArgs); Check(cfg.name == "hello from greeter", std::format("project.cpp imported and called the dependency's module (got '{}')", cfg.name)); Check(cfg.dependencies.size() == 2, "ProjectDependency resolved by module name and by configuration name"); if (cfg.dependencies.size() == 2) { Check(cfg.dependencies[0] == cfg.dependencies[1], "both names resolve to the same dependency"); const Configuration& dep = *cfg.dependencies[0]; Check(dep.name == "greeter", "the dependency is the fixture's greeter project"); Check(dep.target == "x86_64-pc-linux-gnu", "the dependency was built for the host triple LoadProject uses"); Check(dep.march == "native", "the environment's march was overridden for host tooling"); Check(std::ranges::contains(dep.compileFlags, "-fPIC"), "the dependency archive was built position-independent"); Check(fs::exists(ExpectedOutput(dep)), "the dependency archive exists"); } } catch (const std::exception& e) { std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what()); return 1; } Check(fs::exists(appSo), "project.so was produced"); if (!fs::exists(appSo)) return 1; const fs::file_time_type firstSo = fs::last_write_time(appSo); // Editing the dependency rebuilds it and recompiles project.so against // it — without touching project.cpp. ChangeGreeting(scratch); const std::string changed = LoadInChild(app); Check(changed == "hello again", std::format("a changed dependency reaches project.cpp (got '{}')", changed)); Check(fs::last_write_time(appSo) > firstSo, "project.so was recompiled after the dependency changed"); const fs::file_time_type secondSo = fs::last_write_time(appSo); // And an unchanged dependency doesn't: "always stale" would be invisible // to every other case here. const std::string unchanged = LoadInChild(app); Check(unchanged == "hello again", std::format("an unchanged dependency still loads (got '{}')", unchanged)); Check(fs::last_write_time(appSo) == secondSo, "project.so is left alone when nothing changed"); // Directive errors name the file and line. const std::string noSource = LoadFails(scratch / "bad-nosource" / "project.cpp"); Check(noSource.contains("project.cpp:3:") && noSource.contains("expected a quoted source"), std::format("a directive without a source is rejected with its location (got '{}')", noSource)); const std::string both = LoadFails(scratch / "bad-both" / "project.cpp"); Check(both.contains("project.cpp:3:") && both.contains("mutually exclusive"), std::format("branch and commit together are rejected (got '{}')", both)); // Two project files that depend on each other stop instead of recursing. const std::string cycle = LoadFails(scratch / "cycle-a" / "project.cpp"); Check(cycle.contains("circular project dependency"), std::format("a dependency cycle is reported (got '{}')", cycle)); // The accessor is only meaningful while a project file is running. try { (void)ProjectDependency("Greeter"); Check(false, "ProjectDependency outside a project file throws"); } catch (const std::exception& e) { Check(std::string_view(e.what()).contains("outside a project file"), std::format("ProjectDependency outside a project file explains itself (got '{}')", e.what())); } return Failures > 0 ? 1 : 0; }