diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 442cd2b..b4539dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,7 @@ jobs: -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_CXX_STANDARD=${{ matrix.std }} \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DRELAY_BUILD_CLI=ON \ -G Ninja - name: Build diff --git a/CMakeLists.txt b/CMakeLists.txt index dc54e98..abd3f8c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 0.1.0 + VERSION 0.3.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX @@ -53,6 +53,14 @@ if(RCP_BUILD_TESTS) add_subdirectory(tests) endif() +# ── RELAY-conformant CLI (cpp-rcp) ──────────────────────────────────────────── +option(RELAY_BUILD_CLI "Build the cpp-rcp RELAY-conformant CLI (spec §11)" OFF) +if(RELAY_BUILD_CLI) + add_executable(cpp-rcp cli/main.cpp) + target_link_libraries(cpp-rcp PRIVATE rcp::rcp) + set_target_properties(cpp-rcp PROPERTIES OUTPUT_NAME cpp-rcp) +endif() + # ── Install ─────────────────────────────────────────────────────────────────── include(GNUInstallDirs) install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) diff --git a/cli/main.cpp b/cli/main.cpp new file mode 100644 index 0000000..60aed8c --- /dev/null +++ b/cli/main.cpp @@ -0,0 +1,18 @@ +// cpp-rcp — RELAY-conformant CLI entry point (spec §11, §13.2). +// +// Thin wrapper around rcp::cli::run(); all logic lives in so it +// can be unit-tested without spawning a subprocess. +#include + +#include +#include +#include + +int main(int argc, char** argv) { + std::vector args; + args.reserve(static_cast(argc > 1 ? argc - 1 : 0)); + for (int i = 1; i < argc; ++i) { + args.emplace_back(argv[i]); + } + return rcp::cli::run(args, std::cout, std::cerr); +} diff --git a/include/rcp/cli.hpp b/include/rcp/cli.hpp new file mode 100644 index 0000000..094c21e --- /dev/null +++ b/include/rcp/cli.hpp @@ -0,0 +1,168 @@ +// fusa:req REQ-CLI-001 +// fusa:req REQ-CLI-002 +// fusa:req REQ-CLI-003 +// fusa:req REQ-CLI-004 + +// RELAY-conformant CLI for cpp-RCP (spec §11 CLI contract, §12 capability docs). +// +// Implements the three mandatory commands — `version`, `capabilities`, `status` — +// emitting JSON that conforms to the RELAY spec/schemas/cli-*.json schemas so +// `relay conform` / `relay report` can validate the binary. +// +// The logic lives in this header as rcp::cli::run() so it is unit-testable +// without spawning a subprocess; cli/main.cpp is a thin wrapper around it. +// +// Binary name: `cpp-rcp` (§13.2). Build with -DRELAY_BUILD_CLI=ON. +#pragma once + +#include +#include + +#include +#include +#include + +namespace rcp { +namespace cli { + +// Exit codes per §11.3. +enum : int { kOk = 0, kError = 1, kInvalidArgs = 2 }; + +// runtime_string returns the compiler id + major version (§12.1 "runtime"). +inline std::string runtime_string() { +#if defined(__clang__) + return "clang-" + std::to_string(__clang_major__); +#elif defined(__GNUC__) + return "gcc-" + std::to_string(__GNUC__); +#elif defined(_MSC_VER) + return "msvc-" + std::to_string(_MSC_VER); +#else + return "unknown"; +#endif +} + +// ── §12.1 version document ──────────────────────────────────────────────────── + +inline std::string version_json() { + std::string spec(relay::kRelaySpecVersion); + return std::string("{") + + "\"tool\":\"cpp-rcp\"," + + "\"protocol\":\"RCP\"," + + "\"protocol_int\":" + std::to_string(static_cast(relay::Protocol::RCP)) + "," + + "\"version\":\"" + std::string(kVersion) + "\"," + + "\"spec_version\":\"" + spec + "\"," + + "\"language\":\"cpp\"," + + "\"runtime\":\"" + runtime_string() + "\"" + + "}"; +} + +inline std::string version_text() { + return std::string("cpp-rcp ") + std::string(kVersion) + + " (RCP, RELAY spec " + std::string(relay::kRelaySpecVersion) + + ", cpp, " + runtime_string() + ")"; +} + +// ── §12.2 capabilities document (always JSON) ──────────────────────────────── +// Field set is constrained by cli-capabilities.json (additionalProperties:false). + +inline std::string capabilities_json() { + std::string spec(relay::kRelaySpecVersion); + return std::string("{") + + "\"kind\":\"capabilities\"," + + "\"tool\":\"cpp-rcp\"," + + "\"version\":\"" + std::string(kVersion) + "\"," + + "\"spec_version\":\"" + spec + "\"," + + "\"commands\":[\"version\",\"capabilities\",\"status\"]," + + "\"transports\":[\"mock\",\"sim\",\"shmem\"]," + + "\"features\":[\"loaning\"]," + + "\"interfaces\":[\"Node\",\"Caller\"]," + + "\"optional_interfaces\":[\"LoaningController\"]," + + "\"adapt\":true" + + "}"; +} + +// ── §12.3 status document ──────────────────────────────────────────────────── + +inline std::string status_json() { + return std::string("{") + + "\"protocol\":\"RCP\"," + + "\"tool\":\"cpp-rcp\"," + + "\"version\":\"" + std::string(kVersion) + "\"," + + "\"healthy\":true," + + "\"connected\":false," + + "\"endpoint\":\"\"," + + "\"details\":{}" + + "}"; +} + +inline std::string status_text() { + return std::string("cpp-rcp: healthy=true connected=false endpoint="); +} + +// ── usage ───────────────────────────────────────────────────────────────────── + +inline std::string usage() { + return std::string( + "Usage: cpp-rcp [--format text|json]\n" + "Commands:\n" + " version Print tool and spec version\n" + " capabilities Print the RELAY capabilities document (JSON)\n" + " status Print self-assessed health\n"); +} + +// format_is_json returns true if --format json appears in args (from index 1). +// Returns false for text/absent. Sets *bad to true on an unrecognised --format. +inline bool format_is_json(const std::vector& args, bool* bad) { + *bad = false; + for (std::size_t i = 1; i < args.size(); ++i) { + if (args[i] == "--format") { + if (i + 1 >= args.size()) { *bad = true; return false; } + const std::string& v = args[i + 1]; + if (v == "json") return true; + if (v == "text") return false; + *bad = true; + return false; + } + } + return false; // default: text +} + +// run executes the CLI. args[0] is the subcommand (argv without the program name). +// Writes the document to out and diagnostics to err. Returns an exit code (§11.3). +inline int run(const std::vector& args, std::ostream& out, std::ostream& err) { + if (args.empty()) { + err << usage(); + return kInvalidArgs; + } + const std::string& cmd = args[0]; + + if (cmd == "version") { + bool bad = false; + bool json = format_is_json(args, &bad); + if (bad) { err << "error: invalid --format\n"; return kInvalidArgs; } + out << (json ? version_json() : version_text()) << "\n"; + return kOk; + } + if (cmd == "capabilities") { + // Always JSON; no --format flag (§11.1). + out << capabilities_json() << "\n"; + return kOk; + } + if (cmd == "status") { + bool bad = false; + bool json = format_is_json(args, &bad); + if (bad) { err << "error: invalid --format\n"; return kInvalidArgs; } + out << (json ? status_json() : status_text()) << "\n"; + return kOk; // always healthy → 0 (§11.1: 1 if degraded) + } + if (cmd == "--help" || cmd == "-h" || cmd == "help") { + out << usage(); + return kOk; + } + + err << "error: unknown command '" << cmd << "'\n" << usage(); + return kInvalidArgs; +} + +} // namespace cli +} // namespace rcp diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp new file mode 100644 index 0000000..c454f76 --- /dev/null +++ b/include/rcp/version.hpp @@ -0,0 +1,11 @@ +// cpp-RCP binary version — single source of truth for the CLI and capabilities. +#pragma once + +#include + +namespace rcp { + +// Semantic version of the cpp-RCP implementation (matches the latest git tag). +constexpr std::string_view kVersion = "0.3.0"; + +} // namespace rcp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 724b6ae..316533f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -112,3 +112,7 @@ set_tests_properties(rcp_latency PROPERTIES TIMEOUT 60) add_executable(test_relay test_relay.cpp) target_link_libraries(test_relay PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_relay COMMAND test_relay) + +add_executable(test_cli test_cli.cpp) +target_link_libraries(test_cli PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_cli COMMAND test_cli) diff --git a/tests/test_cli.cpp b/tests/test_cli.cpp new file mode 100644 index 0000000..bc0e5e9 --- /dev/null +++ b/tests/test_cli.cpp @@ -0,0 +1,123 @@ +// fusa:req REQ-CLI-001 +// fusa:req REQ-CLI-002 +// fusa:req REQ-CLI-003 +// fusa:req REQ-CLI-004 + +// RELAY CLI conformance tests (spec §11 command surface, §12 JSON documents). +// +// Drives rcp::cli::run() directly (no subprocess) and checks the emitted JSON +// against the required fields of RELAY spec/schemas/cli-*.json, plus the §11.3 +// exit codes. +#include + +#include + +#include +#include +#include + +using rcp::cli::run; + +namespace { + +// has_key returns true if `"key"` appears as a JSON object key in s. +bool has_key(const std::string& s, const std::string& key) { + return s.find("\"" + key + "\"") != std::string::npos; +} + +// braces_balanced is a minimal structural sanity check on emitted JSON. +bool braces_balanced(const std::string& s) { + int depth = 0; + for (char c : s) { + if (c == '{') ++depth; + else if (c == '}') --depth; + if (depth < 0) return false; + } + return depth == 0; +} + +std::string capture(const std::vector& args, int& code) { + std::ostringstream out, err; + code = run(args, out, err); + return out.str(); +} + +} // namespace + +// ── §11.3 exit codes ────────────────────────────────────────────────────────── + +TEST_CASE("cli: no command returns invalid-args (2)", "[cli][conformance]") { + int code = 0; + capture({}, code); + REQUIRE(code == rcp::cli::kInvalidArgs); +} + +TEST_CASE("cli: unknown command returns invalid-args (2)", "[cli][conformance]") { + int code = 0; + capture({"frobnicate"}, code); + REQUIRE(code == rcp::cli::kInvalidArgs); +} + +TEST_CASE("cli: invalid --format returns invalid-args (2)", "[cli][conformance]") { + int code = 0; + capture({"version", "--format", "yaml"}, code); + REQUIRE(code == rcp::cli::kInvalidArgs); +} + +// ── §12.1 version ───────────────────────────────────────────────────────────── + +TEST_CASE("cli: version --format json has all required fields", "[cli][conformance]") { + int code = 0; + auto s = capture({"version", "--format", "json"}, code); + REQUIRE(code == rcp::cli::kOk); + REQUIRE(braces_balanced(s)); + for (auto k : {"tool", "version", "spec_version", "language", "runtime"}) { + REQUIRE(has_key(s, k)); + } + REQUIRE(s.find("\"language\":\"cpp\"") != std::string::npos); + REQUIRE(s.find("\"spec_version\":\"0.3\"") != std::string::npos); + REQUIRE(s.find("\"protocol_int\":5") != std::string::npos); +} + +TEST_CASE("cli: version default format is text", "[cli][conformance]") { + int code = 0; + auto s = capture({"version"}, code); + REQUIRE(code == rcp::cli::kOk); + REQUIRE(s.find("cpp-rcp") != std::string::npos); + REQUIRE(s.find("{") == std::string::npos); // not JSON +} + +// ── §12.2 capabilities ──────────────────────────────────────────────────────── + +TEST_CASE("cli: capabilities has all required fields and kind", "[cli][conformance]") { + int code = 0; + auto s = capture({"capabilities"}, code); + REQUIRE(code == rcp::cli::kOk); + REQUIRE(braces_balanced(s)); + for (auto k : {"kind", "tool", "version", "spec_version", "commands", + "transports", "features", "interfaces", + "optional_interfaces", "adapt"}) { + REQUIRE(has_key(s, k)); + } + REQUIRE(s.find("\"kind\":\"capabilities\"") != std::string::npos); + REQUIRE(s.find("\"adapt\":true") != std::string::npos); + // commands MUST contain the three mandatory subcommands. + REQUIRE(s.find("\"version\"") != std::string::npos); + REQUIRE(s.find("\"capabilities\"") != std::string::npos); + REQUIRE(s.find("\"status\"") != std::string::npos); +} + +// ── §12.3 status ────────────────────────────────────────────────────────────── + +TEST_CASE("cli: status --format json has all required fields", "[cli][conformance]") { + int code = 0; + auto s = capture({"status", "--format", "json"}, code); + REQUIRE(code == rcp::cli::kOk); + REQUIRE(braces_balanced(s)); + for (auto k : {"tool", "version", "healthy", "connected", "endpoint", "details"}) { + REQUIRE(has_key(s, k)); + } + REQUIRE(s.find("\"healthy\":true") != std::string::npos); + REQUIRE(s.find("\"connected\":false") != std::string::npos); + REQUIRE(s.find("\"details\":{}") != std::string::npos); +} diff --git a/tests/test_watchdog.cpp b/tests/test_watchdog.cpp index 5d97a46..354bf69 100644 --- a/tests/test_watchdog.cpp +++ b/tests/test_watchdog.cpp @@ -12,6 +12,8 @@ #include #include +#include +#include using namespace rcp; @@ -47,8 +49,15 @@ TEST_CASE("Watchdog transitions zone to Faulted after misses exceed fault_after" last_state.store(ev.state); }); - // Allow time for fault_after misses to accumulate (3 × 20 ms + margin). - std::this_thread::sleep_for(std::chrono::milliseconds(250)); + // The background keeper thread needs fault_after (3) miss cycles of 20 ms to + // reach Faulted. On loaded/preempted CI runners the thread may be scheduled + // late, so poll up to a generous deadline rather than asserting after one + // fixed sleep (which is flaky — observed on macOS shared runners). + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (last_state.load() != watchdog::HealthState::Faulted && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } REQUIRE(last_state.load() == watchdog::HealthState::Faulted); }