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
8 changes: 8 additions & 0 deletions .fusa-reqs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 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 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
Expand Down
2 changes: 1 addition & 1 deletion cli/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
259 changes: 253 additions & 6 deletions include/rcp/cli.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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).
//
Expand All @@ -16,9 +17,15 @@
#pragma once

#include <relay/relay.hpp>
#include <rcp/adapt.hpp>
#include <rcp/mock.hpp>
#include <rcp/version.hpp>

#include <cstdint>
#include <istream>
#include <map>
#include <ostream>
#include <sstream>
#include <string>
#include <vector>

Expand Down Expand Up @@ -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\"],"
Expand Down Expand Up @@ -105,9 +112,229 @@ 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");
" 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<std::uint8_t>& 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<std::uint8_t>((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<JVal> arr;
std::map<std::string, JVal> 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<char>(cp));
} else if (cp < 0x800) {
out.push_back(static_cast<char>(0xC0 | (cp >> 6)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
} else {
out.push_back(static_cast<char>(0xE0 | (cp >> 12)));
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
out.push_back(static_cast<char>(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<std::uint64_t>(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<Controller> 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).
Expand All @@ -128,14 +355,28 @@ inline bool format_is_json(const std::vector<std::string>& 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<std::string>& 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<std::string>& 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);
Expand Down Expand Up @@ -164,5 +405,11 @@ inline int run(const std::vector<std::string>& 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<std::string>& args, std::ostream& out, std::ostream& err) {
std::istringstream empty;
return run(args, empty, out, err);
}

} // namespace cli
} // namespace rcp
2 changes: 1 addition & 1 deletion include/rcp/version.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 12 additions & 4 deletions include/relay/relay.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) ────────────────────────────────────────────────

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading