tripley-rpc-gen-cpp 0.1.1

C++ client and server generator for Tripley RPC IDL.
Documentation
use std::io::Write;
use std::process::{Command, Stdio};

use camino::{Utf8Path, Utf8PathBuf};
use rpc_gen_cpp::{GenerateOptions, GenerateSide, generate, generate_to_dir};
use rpc_idl_ir::{CanonicalIr, build_ir, to_pretty_json};
use rpc_idl_loader::{LoadOptions, load_graph};
use rpc_idl_semantic::analyze;
use tempfile::TempDir;

#[test]
fn generate_kiosk_cpp_sdk_files() {
    let ir = compile_kiosk();
    let files = generate(&ir, &GenerateOptions::default()).expect("generate");

    assert!(files.contains_key("CMakeLists.txt"));
    assert!(files.contains_key("include/tripley_rpc_generated/types.h"));
    assert!(files.contains_key("include/tripley_rpc_generated/services.h"));
    assert!(files.contains_key("include/tripley_rpc_generated/client.h"));
    assert!(files.contains_key("include/tripley_rpc_generated/server.h"));
    assert!(files.contains_key("include/tripley_rpc_generated/activation.h"));

    let types = &files["include/tripley_rpc_generated/types.h"];
    assert!(types.contains("struct CardInfo"));
    assert!(types.contains("std::optional<std::vector<std::uint8_t>> tracks;"));
    assert!(types.contains("enum class DeviceStatus : std::int32_t"));
    assert!(types.contains("MSGPACK_DEFINE_ARRAY"));

    let client = &files["include/tripley_rpc_generated/client.h"];
    assert!(client.contains("class CardReaderClient"));
    assert!(client.contains("ResolveNamed"));
    assert!(client.contains("Create"));
    assert!(client.contains("SubscribeMediaInserted"));
    assert!(!client.contains("std::optional<std::optional"));
    assert!(files["src/client.cc"].contains("CardReaderClient::ResolveNamed"));

    let server = &files["include/tripley_rpc_generated/server.h"];
    assert!(server.contains("class CardReaderService"));
    assert!(server.contains("RegisterCardReaderNamed"));
}

#[test]
fn generate_side_filtering_works() {
    let ir = compile_kiosk();
    let client = generate(
        &ir,
        &GenerateOptions {
            side: GenerateSide::Client,
            ..GenerateOptions::default()
        },
    )
    .expect("client generate");
    assert!(client.contains_key("include/tripley_rpc_generated/client.h"));
    assert!(!client.contains_key("include/tripley_rpc_generated/server.h"));

    let server = generate(
        &ir,
        &GenerateOptions {
            side: GenerateSide::Server,
            ..GenerateOptions::default()
        },
    )
    .expect("server generate");
    assert!(server.contains_key("include/tripley_rpc_generated/server.h"));
    assert!(!server.contains_key("include/tripley_rpc_generated/client.h"));
}

#[test]
fn generate_typed_constructor_cpp_sdk_files() {
    let temp = TempDir::new().expect("tempdir");
    let idl = temp_path(&temp, "session.rpc.yaml");
    std::fs::write(&idl, CONSTRUCTOR_IDL).expect("write idl");
    let ir = compile(&idl, Vec::new());

    let files = generate(&ir, &GenerateOptions::default()).expect("generate");

    let client_h = &files["include/tripley_rpc_generated/client.h"];
    assert!(client_h.contains(
        "Create(tripley::rpc::RpcClient& client, const demo::session::SessionReaderCreateArgs& request"
    ));

    let client_cc = &files["src/client.cc"];
    assert!(client_cc.contains("tripley::rpc::EncodePayload(request, zone)"));
    assert!(client_cc.contains("client.CreateInstance"));

    let server_h = &files["include/tripley_rpc_generated/server.h"];
    assert!(server_h.contains("class SessionReaderFactory"));
    assert!(server_h.contains(
        "Create(tripley::rpc::RpcCallContext& ctx, const demo::session::SessionReaderCreateArgs& request"
    ));
    assert!(server_h.contains("missing constructor payload"));
    assert!(server_h.contains("DecodePayload<demo::session::SessionReaderCreateArgs>"));
}

#[test]
fn generate_to_dir_writes_expected_files() {
    let temp = TempDir::new().expect("tempdir");
    let out = temp_path(&temp, "cpp-sdk");

    generate_to_dir(&compile_kiosk(), &out, GenerateOptions::default()).expect("generate to dir");

    assert!(out.join("CMakeLists.txt").exists());
    assert!(out.join("include/tripley_rpc_generated/types.h").exists());
    assert!(out.join("src/types.cc").exists());
}

#[test]
fn plugin_describe_reports_cpp_capabilities() {
    let output = Command::new(env!("CARGO_BIN_EXE_rpc-gen-cpp"))
        .arg("--describe")
        .output()
        .expect("run describe");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("\"plugin_name\":\"cpp\""));
    assert!(stdout.contains("\"cpp.client\""));
    assert!(stdout.contains("\"tcp\""));
}

#[test]
fn plugin_generates_from_stdin_ir() {
    let temp = TempDir::new().expect("tempdir");
    let out = temp_path(&temp, "plugin-sdk");
    let ir = to_pretty_json(&compile_kiosk()).expect("ir json");
    let mut child = Command::new(env!("CARGO_BIN_EXE_rpc-gen-cpp"))
        .arg("--out-dir")
        .arg(out.as_str())
        .arg("--opt")
        .arg("include_prefix=acme/generated")
        .stdin(Stdio::piped())
        .spawn()
        .expect("spawn plugin");

    child
        .stdin
        .as_mut()
        .expect("stdin")
        .write_all(ir.as_bytes())
        .expect("write ir");
    let output = child.wait_with_output().expect("wait plugin");

    assert!(output.status.success());
    assert!(out.join("include/acme/generated/client.h").exists());
}

fn compile_kiosk() -> CanonicalIr {
    compile(&kiosk_idl(), vec![kiosk_root()])
}

fn compile(input: &Utf8Path, import_roots: Vec<Utf8PathBuf>) -> CanonicalIr {
    let graph = load_graph(input, LoadOptions { import_roots }).expect("load idl");
    let model = analyze(&graph).expect("analyze idl");
    build_ir(&model)
}

fn temp_path(temp: &TempDir, relative: &str) -> Utf8PathBuf {
    Utf8PathBuf::from_path_buf(temp.path().join(relative)).expect("utf8 temp path")
}

fn kiosk_idl() -> Utf8PathBuf {
    kiosk_root().join("card_reader.rpc.yaml")
}

fn kiosk_root() -> Utf8PathBuf {
    repo_root().join("idl/examples/kiosk_demo")
}

fn repo_root() -> Utf8PathBuf {
    Utf8Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Utf8Path::parent)
        .expect("repo root")
        .to_owned()
}

const CONSTRUCTOR_IDL: &str = r#"
idl_version: 1
package: demo.session
types:
  Empty:
    kind: struct
    fields: []
  SessionReaderCreateArgs:
    kind: struct
    fields:
      - id: 0
        name: token
        type: string
  SessionInfo:
    kind: struct
    fields:
      - id: 0
        name: token
        type: string
services:
  SessionReader:
    guid: 4edfea20-9929-448d-94c9-4b28f5f8a2c5
    options:
      rpc.activation_modes: [instantiable]
    create:
      request: SessionReaderCreateArgs
    methods:
      whoami:
        id: 1
        request: Empty
        response: SessionInfo
"#;