Files
canon/canon/doctor.test.cpp
T
paulhorn 651fa4d89b Add canon add target, add test, add dep and doctor
- add target lib|exe <name>: another library or executable in a source
  directory named after it (P1204R0's rule applied per target); a new
  executable links the project's library
- add test <name>: tests/<name>/driver.cpp for a library, or a run of an
  executable, with --arg and --expect
- add dep <dependency>: link a library of this project (no cycles, no
  executables) or an installed package's imported target such as
  fmt::fmt, adding its find_package()
- doctor: read-only check of the files against .canon.toml and the
  P1204R0 layout; errors for missing declared files or broken markers,
  warnings for unbuilt sources, undeclared tests, a hand-edited managed
  block, .h/.cc extensions and include/ or src/ directories
- every add command takes --target; the CLI rejects options a command
  does not take
- manifest: packages, test args, and validation of target names, shared
  source directories and dependencies
- templates moved to canon/templates.hpp
- tests: multi-target and exe-tests scenarios; every scenario ends with
  canon doctor; CANON_CHECK accepts expressions containing commas; the
  CLI test no longer reads a destroyed temporary

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:38:59 +02:00

152 lines
6.7 KiB
C++

#include <canon/doctor.hpp>
#include <canon/cmake.hpp>
#include <canon/details/testing.hpp>
#include <canon/executor.hpp>
#include <canon/generate.hpp>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
using namespace canon;
namespace fs = std::filesystem;
namespace {
project make_project() {
const manifest m = new_project_manifest(*parse_project_name(project_kind::library, "libhello"));
return project{
.root = "/unused",
.declared = m,
.manifest_text = to_toml(m),
.cmakelists_text = render_cmakelists(m),
};
}
const std::vector<std::string> fresh_files = {
".canon.toml", ".gitignore", "CMakeLists.txt", "README.md",
"hello/hello.cpp", "hello/hello.hpp", "hello/hello.test.cpp", "tests/basics/driver.cpp",
};
std::vector<std::string> with(std::vector<std::string> files, std::initializer_list<std::string> extra) {
files.insert(files.end(), extra);
return files;
}
std::vector<std::string> without(std::vector<std::string> files, const std::string& gone) {
std::erase(files, gone);
return files;
}
bool reports(const std::vector<finding>& findings, severity level, std::string_view path, std::string_view message) {
const bool found = std::ranges::any_of(findings, [&](const finding& f) {
return f.level == level && f.path == path && f.message.contains(message);
});
if (!found) {
std::println(stderr, " missing {} {}: {}", to_string(level), path, message);
for (const finding& f : findings) {
std::println(stderr, " got {} {}: {}", to_string(f.level), f.path, f.message);
}
}
return found;
}
} // namespace
int main() {
const project p = make_project();
CANON_CHECK(diagnose(p, fresh_files).empty());
// Declared but missing.
{
const auto findings = diagnose(p, without(without(without(fresh_files, "hello/hello.cpp"), "hello/hello.hpp"),
"tests/basics/driver.cpp"));
CANON_CHECK_EQ(findings.size(), 3u);
CANON_CHECK(reports(findings, severity::error, "hello/hello.cpp", "source of libhello is missing"));
CANON_CHECK(reports(findings, severity::error, "hello/hello.hpp", "header of module hello/hello is missing"));
CANON_CHECK(reports(findings, severity::error, "tests/basics/driver.cpp", "driver of test basics is missing"));
}
CANON_CHECK(reports(diagnose(p, without(fresh_files, "hello/hello.test.cpp")), severity::error,
"hello/hello.test.cpp", "declared in .canon.toml but missing"));
// Present but unknown to .canon.toml.
{
const auto findings = diagnose(p, with(fresh_files, {"hello/extra.cpp", "hello/orphan.test.cpp",
"tests/manual/driver.cpp", "hello/old.h",
"hello/impl.cc", "hello/notes.txt", "docs/x.cpp",
"include/hello/hello.hpp", "include/hello/x.hpp",
"src/hello.cpp"}));
CANON_CHECK(reports(findings, severity::warning, "hello/extra.cpp", "not a source of any target"));
CANON_CHECK(reports(findings, severity::warning, "hello/orphan.test.cpp", "module that is not in .canon.toml"));
CANON_CHECK(reports(findings, severity::warning, "tests/manual/driver.cpp", "functional test is not in .canon.toml"));
CANON_CHECK(reports(findings, severity::warning, "hello/old.h", "headers use .hpp"));
CANON_CHECK(reports(findings, severity::warning, "hello/impl.cc", "sources use .cpp"));
CANON_CHECK(reports(findings, severity::warning, "include/", "instead of splitting them into include/ and src/"));
CANON_CHECK(reports(findings, severity::warning, "src/", "include/ and src/"));
CANON_CHECK_EQ(findings.size(), 7u); // notes.txt and docs/ are none of canon's business
}
// A unit test on disk for a module declared without one.
{
project untested = p;
untested.declared.modules.at(0).unit_test = false;
untested.cmakelists_text = render_cmakelists(untested.declared);
const auto findings = diagnose(untested, fresh_files);
CANON_CHECK_EQ(findings.size(), 1u);
CANON_CHECK(reports(findings, severity::warning, "hello/hello.test.cpp", "(canon add unit-test hello)"));
untested.declared.modules.at(0).target = "other";
CANON_CHECK(reports(diagnose(untested, fresh_files), severity::warning, "hello/hello.test.cpp",
"(canon add unit-test hello --target other)"));
}
// The managed block.
{
project edited = p;
edited.cmakelists_text.replace(edited.cmakelists_text.find("hello/hello.cpp"), 15, "hello/other.cpp");
const auto findings = diagnose(edited, fresh_files);
CANON_CHECK_EQ(findings.size(), 1u);
CANON_CHECK(reports(findings, severity::warning, "CMakeLists.txt", "managed block differs from .canon.toml"));
edited.cmakelists_text = "project(x)\n";
CANON_CHECK(reports(diagnose(edited, fresh_files), severity::error, "CMakeLists.txt", "no '# >>> canon:managed >>>'"));
}
// Errors come before warnings, each sorted by path.
{
const auto findings = diagnose(p, with(without(fresh_files, "hello/hello.hpp"), {"hello/a.cpp", "hello/b.h"}));
CANON_CHECK_EQ(findings.size(), 3u);
if (findings.size() == 3) {
CANON_CHECK(findings[0].level == severity::error);
CANON_CHECK_EQ(findings[1].path, "hello/a.cpp");
CANON_CHECK_EQ(findings[2].path, "hello/b.h");
}
}
// Listing a real project skips build output and hidden directories.
{
const details::temp_directory tmp;
CANON_CHECK(apply(generate_new_project(*parse_project_name(project_kind::library, "libhello")), tmp.path()));
const fs::path root = tmp.path() / "libhello";
for (const char* dir : {"build", "build-release", "cmake-build-debug", ".git", ".idea", "hello/build"}) {
fs::create_directories(root / dir);
std::ofstream(root / dir / "junk.cpp") << "\n";
}
const auto files = list_project_files(root);
CANON_CHECK(files == with(fresh_files, {"hello/build/junk.cpp"}) ||
files == [&] {
auto sorted = with(fresh_files, {"hello/build/junk.cpp"});
std::ranges::sort(sorted);
return sorted;
}());
CANON_CHECK(std::ranges::is_sorted(files));
}
return details::exit_status();
}