Crafter.Build/tests/DependencyNotes/main.cpp

219 lines
12 KiB
C++
Raw Normal View History

// 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;
// Dependency notes: every dependency that ships an AGENTS.consumer.md beside
// its project.cpp has the file kept in a marked section of its consumer's
// AGENTS.md, refreshed by each build and test run.
//
// AGENTS.md is the user's own file, committed and edited by hand, so what
// this guards against is everything else moving: a section appended again on
// every build, text outside the section rewritten or lost, a write when
// nothing changed (a dirty tree, an editor reloading), an AGENTS.md created in
// a project that has none, notes from a dependency's own host tooling leaking
// into its consumers, and `lint` or `run`, which promise not to touch the
// project, writing it anyway.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
std::string Read(const fs::path& file) {
std::ifstream in(file, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
void Write(const fs::path& file, std::string_view content) {
std::ofstream(file, std::ios::binary | std::ios::trunc) << content;
}
std::size_t Count(std::string_view text, std::string_view needle) {
std::size_t count = 0;
for (std::size_t pos = text.find(needle); pos != std::string_view::npos; pos = text.find(needle, pos + needle.size())) ++count;
return count;
}
// Expect MergeDependencyNotes to throw; the message, or "" when it didn't.
std::string MergeFails(std::string_view doc, std::span<const DependencyNote> notes) {
try {
(void)MergeDependencyNotes(doc, notes);
} catch (const std::exception& e) {
return e.what();
}
return "";
}
std::int32_t RunWith(std::vector<std::string> args) {
std::vector<char*> argv;
for (std::string& arg : args) argv.push_back(arg.data());
return Run(static_cast<std::int32_t>(argv.size()), argv.data());
}
// LoadProject compiles a project file against the installed module
// sources; without them the end-to-end half cannot run at all.
bool ModuleSourcesInstalled() {
try {
return fs::exists(GetCrafterBuildHome() / "Crafter.Build.cppm");
} catch (const std::exception&) {
return false;
}
}
constexpr std::string_view BeginMarker = "<!-- crafter-build: dependency notes begin -->";
constexpr std::string_view EndMarker = "<!-- crafter-build: dependency notes end -->";
}
int main() {
// The merge on its own: what goes where in a document, with no project.
const std::string original = "# app\n\nThe consumer's own text.\n";
const std::vector<DependencyNote> two = {
{ .source = "https://example.invalid/one.git", .text = "## one\n\nFirst." },
{ .source = "../two/AGENTS.consumer.md", .text = "## two\n\nSecond." },
};
const std::string merged = MergeDependencyNotes(original, two);
Check(merged.starts_with(std::format("{}\n{}\n", original, BeginMarker)), "the section is appended after one blank line, and the text before it is untouched");
Check(merged.ends_with(std::format("{}\n", EndMarker)), "the section ends with its end marker");
const std::size_t first = merged.find("<!-- from https://example.invalid/one.git -->\n## one\n\nFirst.\n");
const std::size_t second = merged.find("<!-- from ../two/AGENTS.consumer.md -->\n## two\n\nSecond.\n");
Check(first != std::string::npos && second != std::string::npos && first < second, "each note follows a line naming its source, in the order given");
Check(MergeDependencyNotes(merged, two) == merged, "merging the same notes again changes nothing");
Check(MergeDependencyNotes(merged, {}) == original, "removing the section gives the file its own bytes back");
Check(MergeDependencyNotes(original, {}) == original, "a file with no section and no notes is left alone");
// A section the user moved is rewritten where it stands.
{
const std::string section = merged.substr(original.size() + 1);
const std::string moved = std::format("# app\n\n{}\n## After\n\nThe user's text after it.\n", section);
const std::vector<DependencyNote> changed = { { .source = "https://example.invalid/one.git", .text = "## one\n\nChanged." } };
const std::string rewritten = MergeDependencyNotes(moved, changed);
Check(rewritten.starts_with(std::format("# app\n\n{}\n", BeginMarker)), "the text before a moved section is kept");
Check(rewritten.ends_with("\n## After\n\nThe user's text after it.\n"), "the text after a moved section is kept");
Check(rewritten.contains("Changed.") && !rewritten.contains("First.") && !rewritten.contains("Second."), "a rewritten section holds exactly the new notes");
}
// Markers quoted in a code block are not the section's, including a block
// that quotes a fenced block of its own.
{
const std::string quoted = std::format("# app\n\n````markdown\n{}\n```\nnested\n```\n{}\n````\n", BeginMarker, EndMarker);
const std::string withNotes = MergeDependencyNotes(quoted, two);
Check(withNotes.starts_with(std::format("{}\n{}\n", quoted, BeginMarker)), "markers quoted in a code block are left alone, and a section is appended after them");
Check(MergeDependencyNotes(withNotes, two) == withNotes, "the real section is found past the quoted markers");
}
// Guessing which part of someone's file is generated would overwrite their
// text, and missing a section would add another on every build.
Check(MergeFails(std::format("# app\n{}\ntext\n", BeginMarker), two).contains("line 2"), "a begin marker with no end marker is refused, naming its line");
Check(MergeFails(std::format("# app\n\n{}\n", EndMarker), two).contains("line 3"), "an end marker with no begin marker is refused");
Check(MergeFails(std::format("{}\n{}\n", EndMarker, BeginMarker), two).contains("line 1"), "an end marker before the begin marker is refused");
Check(MergeFails(std::format("{0}\n{1}\n{0}\n{1}\n", BeginMarker, EndMarker), two).contains("line 3"), "a second section is refused");
Check(MergeFails("# app\n\n```\nnever closed\n", two).contains("line 3"), "a document that ends inside a code block is refused");
const std::vector<DependencyNote> markerNote = { { .source = "marker-note", .text = std::format("## bad\n{}", EndMarker) } };
Check(MergeFails(original, markerNote).contains("marker-note"), "a note with a marker line of its own is refused");
const std::vector<DependencyNote> fenceNote = { { .source = "fence-note", .text = "## bad\n```\nnever closed" } };
Check(MergeFails(original, fenceNote).contains("fence-note"), "a note that leaves a code block open is refused");
if (!ModuleSourcesInstalled()) {
std::println(std::cerr, "SKIP: no module sources to compile the fixture's project files against");
return Failures > 0 ? 1 : 77;
}
// Through LoadProject and the CLI. Staged, because each case rewrites
// AGENTS.md and every load drops a build/ tree beside its project file.
const fs::path fixture = fs::current_path() / "tests" / "DependencyNotes" / "fixture";
const fs::path scratch = fs::temp_directory_path() / "crafter-build-dependency-notes";
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch);
fs::copy(fixture, scratch, fs::copy_options::recursive);
const fs::path app = scratch / "app";
const fs::path agents = app / "AGENTS.md";
const fs::path projectFile = app / "project.cpp";
// Checked in under another name: an AGENTS.md in this repository's tree
// would read as instructions for the agents working on it.
const std::string agentsOriginal = Read(app / "AGENTS.md.in");
Write(agents, agentsOriginal);
// LocalProject resolves against the working directory, and app's paths
// are written for `cd app && crafter-build`.
fs::current_path(app);
std::array<std::string_view, 0> noArgs = {};
Configuration cfg;
try {
cfg = LoadProject(projectFile, noArgs);
} catch (const std::exception& e) {
std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what());
return 1;
}
const std::string toolNote = "<!-- from ../tool/AGENTS.consumer.md -->\n## tool\n\nRun tool before committing.\n";
const std::string libNote = "<!-- from ../lib/AGENTS.consumer.md -->\n## lib\n\nCall lib through its module, never through its headers.\n";
// base's file opens and ends with blank lines, which the section drops.
const std::string baseNote = "<!-- from ../base/AGENTS.consumer.md -->\n## base\n\nbase counts from zero.\n";
Check(UpdateAgentsMd(cfg, projectFile), "the first update writes AGENTS.md");
std::string text = Read(agents);
Check(text.starts_with(std::format("{}\n{}\n", agentsOriginal, BeginMarker)), "the section follows the consumer's own text, which is untouched");
const std::size_t tool = text.find(toolNote);
const std::size_t lib = text.find(libNote);
const std::size_t base = text.find(baseNote);
Check(tool != std::string::npos && lib != std::string::npos && base != std::string::npos, std::format("tool, lib and base each contribute their notes (got:\n{})", text));
Check(tool < lib && lib < base, "host tooling first, then what app names, then what lib builds against");
Check(!text.contains("Notes from app"), "app's own AGENTS.consumer.md stays out of its own AGENTS.md");
Check(!text.contains("libtool"), "lib's host tooling stays out of its consumers' notes");
const fs::file_time_type written = fs::last_write_time(agents);
Check(!UpdateAgentsMd(cfg, projectFile), "an update with nothing changed writes nothing");
Check(Read(agents) == text && fs::last_write_time(agents) == written, "the bytes and the mtime are left alone when nothing changed");
cfg.dependencyNotes = DependencyNotes::Direct;
Check(UpdateAgentsMd(cfg, projectFile), "switching to Direct rewrites the section");
text = Read(agents);
Check(text.contains(toolNote) && text.contains(libNote) && !text.contains("## base"), "Direct keeps what app names and drops what only lib does");
cfg.dependencyNotes = DependencyNotes::Transitive;
Write(scratch / "lib" / "AGENTS.consumer.md", "## lib\n\nlib was rewritten.\n");
Check(UpdateAgentsMd(cfg, projectFile), "a dependency's edited notes are picked up");
text = Read(agents);
Check(text.contains("lib was rewritten.") && !text.contains("never through its headers") && Count(text, BeginMarker) == 1, "edited notes replace the old ones, in the one section");
cfg.dependencyNotes = DependencyNotes::None;
Check(UpdateAgentsMd(cfg, projectFile), "None removes the section");
Check(Read(agents) == agentsOriginal, "removing the section gives AGENTS.md its own bytes back");
cfg.dependencyNotes = DependencyNotes::Transitive;
fs::remove(agents);
Check(!UpdateAgentsMd(cfg, projectFile) && !fs::exists(agents), "a project without an AGENTS.md does not get one");
// A common layout keeps the file elsewhere and links it, which a write
// that replaced the link with a copy would quietly undo.
fs::create_directories(app / "docs");
Write(app / "docs" / "agents.md", agentsOriginal);
fs::create_symlink("docs/agents.md", agents);
Check(UpdateAgentsMd(cfg, projectFile), "an AGENTS.md that is a symlink is updated");
Check(fs::is_symlink(agents) && Read(app / "docs" / "agents.md").contains(toolNote), "the update goes through the link, which stays a link");
fs::remove(agents);
// The CLI: only a build or a test run writes it.
Write(agents, agentsOriginal);
(void)RunWith({"crafter-build", "-q", "lint"});
Check(Read(agents) == agentsOriginal, "lint leaves AGENTS.md alone");
(void)RunWith({"crafter-build", "-q", "run"});
Check(Read(agents) == agentsOriginal, "run leaves AGENTS.md alone");
(void)RunWith({"crafter-build", "-q", "test", "--list"});
Check(Read(agents).contains(toolNote), "a test run refreshes AGENTS.md");
// app has no sources, so this build fails at the link, after the update.
Write(agents, agentsOriginal);
(void)RunWith({"crafter-build", "-q"});
Check(Read(agents).contains(toolNote), "a build refreshes AGENTS.md before compiling, so one that fails still does");
return Failures > 0 ? 1 : 0;
}