Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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})
Expand Down
18 changes: 18 additions & 0 deletions cli/main.cpp
Original file line number Diff line number Diff line change
@@ -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 <rcp/cli.hpp> so it
// can be unit-tested without spawning a subprocess.
#include <rcp/cli.hpp>

#include <iostream>
#include <string>
#include <vector>

int main(int argc, char** argv) {
std::vector<std::string> args;
args.reserve(static_cast<std::size_t>(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);
}
168 changes: 168 additions & 0 deletions include/rcp/cli.hpp
Original file line number Diff line number Diff line change
@@ -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 <relay/relay.hpp>
#include <rcp/version.hpp>

#include <ostream>
#include <string>
#include <vector>

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<int>(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 <command> [--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<std::string>& 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<std::string>& 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
11 changes: 11 additions & 0 deletions include/rcp/version.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// cpp-RCP binary version — single source of truth for the CLI and capabilities.
#pragma once

#include <string_view>

namespace rcp {

// Semantic version of the cpp-RCP implementation (matches the latest git tag).
constexpr std::string_view kVersion = "0.3.0";

} // namespace rcp
4 changes: 4 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
123 changes: 123 additions & 0 deletions tests/test_cli.cpp
Original file line number Diff line number Diff line change
@@ -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 <catch2/catch_test_macros.hpp>

#include <rcp/cli.hpp>

#include <sstream>
#include <string>
#include <vector>

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<std::string>& 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);
}
13 changes: 11 additions & 2 deletions tests/test_watchdog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
#include <rcp/watchdog.hpp>

#include <atomic>
#include <chrono>
#include <thread>

using namespace rcp;

Expand Down Expand Up @@ -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);
}
Expand Down
Loading