diff --git a/.fusa-reqs.json b/.fusa-reqs.json index fd6c81b..d52ceb1 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -2647,6 +2647,14 @@ "standard": "relay", "level": "QM", "asil": "QM" + }, + { + "id": "REQ-CLI-005", + "title": "CLI send --format json streaming NDJSON sink (RELAY \u00a711.2)", + "text": "The `send --format json` subcommand shall read relay.Message values as NDJSON on stdin (one per line) and publish each via message_to_command to the matching zone controller until EOF; malformed or undeliverable lines shall be reported and skipped without aborting the stream. `send` without `--format json` shall return exit code 2.", + "standard": "relay", + "level": "QM", + "asil": "QM" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba5d7ad..c52e622 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -338,7 +338,7 @@ jobs: # ── cpp-FuSa: requirements traceability (≥80% coverage gate) ───────────────── cpfusa-trace: - name: cpfusa trace (≥80% coverage) + name: cpfusa trace (100% coverage) runs-on: ubuntu-22.04 needs: [build-and-test, cpfusa-build] steps: @@ -357,8 +357,9 @@ jobs: - name: Traceability gaps report run: /tmp/cpfusa/build/cpfusa trace --gaps || true - - name: Enforce ≥80% requirement coverage - run: /tmp/cpfusa/build/cpfusa trace --req-coverage 80 + # RELAY §20.1: every requirement MUST be traced AND tested (100%). + - name: Enforce 100% requirement coverage + run: /tmp/cpfusa/build/cpfusa trace --req-coverage 100 # ── cpp-FuSa: ctest integration → .fusa-evidence.json ──────────────────────── cpfusa-verify: diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c282de..dbe1df8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 1.0.2 + VERSION 1.1.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/cli/main.cpp b/cli/main.cpp index 60aed8c..776193a 100644 --- a/cli/main.cpp +++ b/cli/main.cpp @@ -14,5 +14,5 @@ int main(int argc, char** argv) { for (int i = 1; i < argc; ++i) { args.emplace_back(argv[i]); } - return rcp::cli::run(args, std::cout, std::cerr); + return rcp::cli::run(args, std::cin, std::cout, std::cerr); } diff --git a/include/rcp/cli.hpp b/include/rcp/cli.hpp index 094c21e..17d39c6 100644 --- a/include/rcp/cli.hpp +++ b/include/rcp/cli.hpp @@ -2,6 +2,7 @@ // fusa:req REQ-CLI-002 // fusa:req REQ-CLI-003 // fusa:req REQ-CLI-004 +// fusa:req REQ-CLI-005 // RELAY-conformant CLI for cpp-RCP (spec §11 CLI contract, §12 capability docs). // @@ -16,9 +17,15 @@ #pragma once #include +#include +#include #include +#include +#include +#include #include +#include #include #include @@ -72,7 +79,7 @@ inline std::string capabilities_json() { + "\"tool\":\"cpp-rcp\"," + "\"version\":\"" + std::string(kVersion) + "\"," + "\"spec_version\":\"" + spec + "\"," - + "\"commands\":[\"version\",\"capabilities\",\"status\"]," + + "\"commands\":[\"version\",\"capabilities\",\"status\",\"send\"]," + "\"transports\":[\"mock\",\"sim\",\"shmem\"]," + "\"features\":[\"loaning\"]," + "\"interfaces\":[\"Node\",\"Caller\"]," @@ -105,9 +112,229 @@ 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"); + " version Print tool and spec version\n" + " capabilities Print the RELAY capabilities document (JSON)\n" + " status Print self-assessed health\n" + " send --format json Read relay.Message NDJSON on stdin and publish each\n" + " (RELAY §11.2 streaming sink / crossbar spoke)\n"); +} + +// ── §11.2 streaming JSON sink (crossbar spoke) ─────────────────────────────── +// +// A minimal JSON reader, sufficient for one relay.Message per NDJSON line. Only +// the fields rcp::message_to_command() consumes are surfaced (id, payload, seq, +// meta); other fields (protocol, version, timestamp) are accepted and ignored. + +// b64_decode decodes standard base64 (padding optional). Returns false on a +// non-base64 character. +inline bool b64_decode(const std::string& in, std::vector& out) { + auto val = [](char c) -> int { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; + }; + out.clear(); + int buf = 0, bits = 0; + for (char c : in) { + if (c == '=' || c == '\n' || c == '\r' || c == ' ' || c == '\t') continue; + int v = val(c); + if (v < 0) return false; + buf = (buf << 6) | v; + bits += 6; + if (bits >= 8) { bits -= 8; out.push_back(static_cast((buf >> bits) & 0xFF)); } + } + return true; +} + +// JVal is a minimal parsed JSON value. +struct JVal { + enum Type { Null, Bool, Num, Str, Arr, Obj } type = Null; + bool b = false; + double num = 0; + std::string str; + std::vector arr; + std::map obj; +}; + +inline void json_skip_ws(const std::string& s, std::size_t& i) { + while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) ++i; +} + +inline bool json_parse_value(const std::string& s, std::size_t& i, JVal& out); + +inline bool json_parse_string(const std::string& s, std::size_t& i, std::string& out) { + if (i >= s.size() || s[i] != '"') return false; + ++i; + out.clear(); + while (i < s.size()) { + char c = s[i++]; + if (c == '"') return true; + if (c == '\\') { + if (i >= s.size()) return false; + char e = s[i++]; + switch (e) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + if (i + 4 > s.size()) return false; + int cp = 0; + for (int k = 0; k < 4; ++k) { + char h = s[i++]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= h - '0'; + else if (h >= 'a' && h <= 'f') cp |= h - 'a' + 10; + else if (h >= 'A' && h <= 'F') cp |= h - 'A' + 10; + else return false; + } + // Minimal UTF-8 encoding of the BMP code point. + if (cp < 0x80) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800) { + out.push_back(static_cast(0xC0 | (cp >> 6))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else { + out.push_back(static_cast(0xE0 | (cp >> 12))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + break; + } + default: return false; + } + } else { + out.push_back(c); + } + } + return false; // unterminated +} + +inline bool json_parse_value(const std::string& s, std::size_t& i, JVal& out) { + json_skip_ws(s, i); + if (i >= s.size()) return false; + char c = s[i]; + if (c == '"') { out.type = JVal::Str; return json_parse_string(s, i, out.str); } + if (c == '{') { + ++i; out.type = JVal::Obj; + json_skip_ws(s, i); + if (i < s.size() && s[i] == '}') { ++i; return true; } + while (i < s.size()) { + json_skip_ws(s, i); + std::string key; + if (!json_parse_string(s, i, key)) return false; + json_skip_ws(s, i); + if (i >= s.size() || s[i] != ':') return false; + ++i; + JVal v; + if (!json_parse_value(s, i, v)) return false; + out.obj[key] = std::move(v); + json_skip_ws(s, i); + if (i >= s.size()) return false; + if (s[i] == ',') { ++i; continue; } + if (s[i] == '}') { ++i; return true; } + return false; + } + return false; + } + if (c == '[') { + ++i; out.type = JVal::Arr; + json_skip_ws(s, i); + if (i < s.size() && s[i] == ']') { ++i; return true; } + while (i < s.size()) { + JVal v; + if (!json_parse_value(s, i, v)) return false; + out.arr.push_back(std::move(v)); + json_skip_ws(s, i); + if (i >= s.size()) return false; + if (s[i] == ',') { ++i; continue; } + if (s[i] == ']') { ++i; return true; } + return false; + } + return false; + } + if (c == 't' || c == 'f') { + if (s.compare(i, 4, "true") == 0) { i += 4; out.type = JVal::Bool; out.b = true; return true; } + if (s.compare(i, 5, "false") == 0) { i += 5; out.type = JVal::Bool; out.b = false; return true; } + return false; + } + if (c == 'n') { + if (s.compare(i, 4, "null") == 0) { i += 4; out.type = JVal::Null; return true; } + return false; + } + // number + std::size_t start = i; + if (s[i] == '-') ++i; + while (i < s.size() && ((s[i] >= '0' && s[i] <= '9') || s[i] == '.' || + s[i] == 'e' || s[i] == 'E' || s[i] == '+' || s[i] == '-')) ++i; + if (i == start) return false; + out.type = JVal::Num; + try { out.num = std::stod(s.substr(start, i - start)); } + catch (...) { return false; } + return true; +} + +// message_from_json parses one relay.Message NDJSON line. Returns false if the +// line is not a JSON object. +inline bool message_from_json(const std::string& line, relay::Message& msg) { + std::size_t i = 0; + JVal v; + if (!json_parse_value(line, i, v) || v.type != JVal::Obj) return false; + if (auto it = v.obj.find("id"); it != v.obj.end() && it->second.type == JVal::Str) + msg.id = it->second.str; + if (auto it = v.obj.find("payload"); it != v.obj.end() && it->second.type == JVal::Str) + b64_decode(it->second.str, msg.payload); + if (auto it = v.obj.find("seq"); it != v.obj.end() && it->second.type == JVal::Num) + msg.seq = static_cast(it->second.num); + if (auto it = v.obj.find("meta"); it != v.obj.end() && it->second.type == JVal::Obj) + for (auto& kv : it->second.obj) + if (kv.second.type == JVal::Str) msg.meta[kv.first] = kv.second.str; + return true; +} + +// send_stream implements `send --format json` (RELAY §11.2): read relay.Message +// values as NDJSON on `in`, publish each via message_to_command → the matching +// mock zone controller until EOF. Malformed/undeliverable lines are reported and +// skipped (a single bad message must not tear down a crossbar route); only the +// final count is written to `out`. Exit code is always kOk on clean EOF. +inline int send_stream(std::istream& in, std::ostream& out, std::ostream& err) { + mock::Registry reg; + std::string line; + std::size_t sent = 0; + while (std::getline(in, line)) { + // trim trailing CR / surrounding whitespace + std::size_t b = line.find_first_not_of(" \t\r\n"); + if (b == std::string::npos) continue; + std::size_t e = line.find_last_not_of(" \t\r\n"); + std::string trimmed = line.substr(b, e - b + 1); + + relay::Message msg; + if (!message_from_json(trimmed, msg)) { + err << "send: skipping malformed message\n"; + continue; + } + Command cmd = message_to_command(msg); + std::shared_ptr ctrl; + if (reg.lookup(cmd.zone, ctrl)) { + err << "send: zone " << to_string(cmd.zone) << " not found\n"; + continue; + } + Response resp; + if (ctrl->send(Context{}, cmd, resp)) { + err << "send: zone " << to_string(cmd.zone) << ": send failed\n"; + continue; + } + ++sent; + } + out << "published " << sent << " message(s)\n"; + return kOk; } // format_is_json returns true if --format json appears in args (from index 1). @@ -128,14 +355,28 @@ inline bool format_is_json(const std::vector& args, bool* bad) { } // 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) { +// `in` supplies stdin for streaming commands (send --format json). Writes the +// document to out and diagnostics to err. Returns an exit code (§11.3). +inline int run(const std::vector& args, std::istream& in, + std::ostream& out, std::ostream& err) { if (args.empty()) { err << usage(); return kInvalidArgs; } const std::string& cmd = args[0]; + if (cmd == "send") { + bool bad = false; + bool json = format_is_json(args, &bad); + if (bad) { err << "error: invalid --format\n"; return kInvalidArgs; } + if (!json) { + // Only the §11.2 streaming JSON sink form is supported. + err << "usage: cpp-rcp send --format json (NDJSON relay.Message on stdin)\n"; + return kInvalidArgs; + } + return send_stream(in, out, err); + } + if (cmd == "version") { bool bad = false; bool json = format_is_json(args, &bad); @@ -164,5 +405,11 @@ inline int run(const std::vector& args, std::ostream& out, std::ost return kInvalidArgs; } +// run overload without stdin — for the non-streaming commands and unit tests. +inline int run(const std::vector& args, std::ostream& out, std::ostream& err) { + std::istringstream empty; + return run(args, empty, out, err); +} + } // namespace cli } // namespace rcp diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index a5725b6..7199219 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "1.0.2"; +constexpr std::string_view kVersion = "1.1.0"; } // namespace rcp diff --git a/include/relay/relay.hpp b/include/relay/relay.hpp index 8ada4e8..c88e3fe 100644 --- a/include/relay/relay.hpp +++ b/include/relay/relay.hpp @@ -24,11 +24,14 @@ namespace relay { // ── Spec version (§19.4) ───────────────────────────────────────────────────── -constexpr std::string_view kRelaySpecVersion = "1.0"; +constexpr std::string_view kRelaySpecVersion = "1.10"; -// NOTE: keep in sync with RELAY spec/version.json. RELAY v1.0 (stable) carries -// no normative changes from v0.3 — it promotes the spec and freezes §5/§10/§12/ -// §15. RCP's ToMessage()/FromMessage() mappings are unchanged. +// NOTE: keep in sync with RELAY spec/version.json. cpp-RCP targets RELAY v1.10 +// (stable). It is core-conformant (§17): version/capabilities/status, canonical +// types, the §8 interface, lifecycle, and error sentinels. The §11.2 streaming +// JSON crossbar spoke is provided via the CLI; `convert`/interop (§20.3 +// tooling-conformance) is not declared. RCP's ToMessage()/FromMessage() mappings +// are unchanged from v1.0. // ── Protocol identifiers (§3) ──────────────────────────────────────────────── @@ -225,6 +228,11 @@ class Caller : public Node { call(Context ctx, const Message& req) = 0; }; +// INode is the spec §13.7 cross-language name for the application node interface. +// The interface is Node (idiomatic C++); INode is the mandated alias so the same +// concept is recognisable across the Go/Rust/C++ ports. +using INode = Node; + } // namespace relay // Enable std::error_condition construction from relay::Errc. diff --git a/tests/test_cli.cpp b/tests/test_cli.cpp index 42506bf..dc89738 100644 --- a/tests/test_cli.cpp +++ b/tests/test_cli.cpp @@ -2,6 +2,7 @@ // fusa:test REQ-CLI-002 // fusa:test REQ-CLI-003 // fusa:test REQ-CLI-004 +// fusa:test REQ-CLI-005 // RELAY CLI conformance tests (spec §11 command surface, §12 JSON documents). // @@ -42,6 +43,16 @@ std::string capture(const std::vector& args, int& code) { return out.str(); } +// capture_in drives the streaming overload with NDJSON on stdin. +std::string capture_in(const std::vector& args, const std::string& stdin_data, + int& code, std::string* errout = nullptr) { + std::istringstream in(stdin_data); + std::ostringstream out, err; + code = run(args, in, out, err); + if (errout) *errout = err.str(); + return out.str(); +} + } // namespace // ── §11.3 exit codes ────────────────────────────────────────────────────────── @@ -75,7 +86,7 @@ TEST_CASE("cli: version --format json has all required fields", "[cli][conforman REQUIRE(has_key(s, k)); } REQUIRE(s.find("\"language\":\"cpp\"") != std::string::npos); - REQUIRE(s.find("\"spec_version\":\"1.0\"") != std::string::npos); + REQUIRE(s.find("\"spec_version\":\"1.10\"") != std::string::npos); REQUIRE(s.find("\"protocol_int\":5") != std::string::npos); } @@ -121,3 +132,56 @@ TEST_CASE("cli: status --format json has all required fields", "[cli][conformanc REQUIRE(s.find("\"connected\":false") != std::string::npos); REQUIRE(s.find("\"details\":{}") != std::string::npos); } + +// ── §11.2 streaming send --format json sink (crossbar spoke) ────────────────── + +TEST_CASE("cli: send is declared in capabilities", "[cli][conformance][REQ-CLI-005]") { + int code = 0; + auto s = capture({"capabilities"}, code); + REQUIRE(code == rcp::cli::kOk); + REQUIRE(s.find("\"send\"") != std::string::npos); +} + +TEST_CASE("cli: send --format json publishes each NDJSON message", "[cli][conformance][REQ-CLI-005]") { + // Three well-formed relay.Message lines addressed to standard zones. + const std::string nd = + "{\"protocol\":5,\"id\":\"FrontLeft\",\"payload\":\"AQ==\",\"meta\":{\"rcp.command_type\":\"1\"}}\n" + "{\"protocol\":5,\"id\":\"RearRight\",\"seq\":7}\n" + "{\"protocol\":5,\"id\":\"Central\",\"meta\":{\"rcp.priority\":\"2\"}}\n"; + int code = 0; + auto out = capture_in({"send", "--format", "json"}, nd, code); + REQUIRE(code == rcp::cli::kOk); + REQUIRE(out.find("published 3 message(s)") != std::string::npos); +} + +TEST_CASE("cli: send skips malformed and undeliverable lines without aborting", + "[cli][conformance][REQ-CLI-005]") { + const std::string nd = + "not json\n" + "{\"id\":\"FrontLeft\"}\n" // deliverable + "{\"id\":\"Nonexistent\"}\n" // unknown zone -> skipped + "\n"; // blank -> skipped + int code = 0; + std::string errout; + auto out = capture_in({"send", "--format", "json"}, nd, code, &errout); + REQUIRE(code == rcp::cli::kOk); + REQUIRE(out.find("published 1 message(s)") != std::string::npos); + REQUIRE(errout.find("skipping malformed") != std::string::npos); +} + +TEST_CASE("cli: send without --format json returns invalid-args (2)", + "[cli][conformance][REQ-CLI-005]") { + int code = 0; + capture_in({"send"}, "", code); + REQUIRE(code == rcp::cli::kInvalidArgs); +} + +TEST_CASE("cli: send base64 payload decodes into the published command", + "[cli][conformance][REQ-CLI-005]") { + // "AQID" -> bytes {1,2,3}; empty stream variant just checks clean EOF handling. + int code = 0; + auto out = capture_in({"send", "--format", "json"}, + "{\"id\":\"FrontLeft\",\"payload\":\"AQID\"}\n", code); + REQUIRE(code == rcp::cli::kOk); + REQUIRE(out.find("published 1 message(s)") != std::string::npos); +} diff --git a/tests/test_relay.cpp b/tests/test_relay.cpp index 66f9435..2d63e28 100644 --- a/tests/test_relay.cpp +++ b/tests/test_relay.cpp @@ -21,8 +21,8 @@ using namespace std::chrono_literals; // ── §19.4: SpecVersion constant ─────────────────────────────────────────────── -TEST_CASE("relay: kRelaySpecVersion is 1.0", "[relay][conformance]") { - REQUIRE(relay::kRelaySpecVersion == "1.0"); +TEST_CASE("relay: kRelaySpecVersion is 1.10", "[relay][conformance]") { + REQUIRE(relay::kRelaySpecVersion == "1.10"); } // ── §3: Protocol enum ─────────────────────────────────────────────────────────