kcode-jsonrpc-stdio 0.2.1

Asynchronous header-omitted JSON-RPC 2.0 transport over child-process JSONL stdio.
Documentation
use kcode_jsonrpc_stdio::{
    Error, IncomingMessage, MAX_INBOUND_LINE_BYTES, MAX_STDERR_CAPTURE_BYTES, PeerId, RequestId,
    RpcError, StdioRpc,
};
use serde_json::{Number, json};
use tokio::process::Command;

fn shell(script: &str) -> Command {
    let mut command = Command::new("sh");
    command.arg("-c").arg(script);
    command
}

#[tokio::test]
async fn frames_requests_and_notifications_without_version_member() {
    let mut rpc = StdioRpc::spawn(shell(
        "IFS= read -r first; printf '%s\n' \"$first\"; IFS= read -r second; printf '%s\n' \"$second\"",
    ))
    .unwrap();
    assert_eq!(
        rpc.send_request("alpha", json!({"value": 3}))
            .await
            .unwrap(),
        RequestId(1)
    );
    rpc.send_notification("beta", json!([1, 2])).await.unwrap();
    assert_eq!(
        rpc.next().await.unwrap(),
        IncomingMessage::Request {
            id: PeerId::Number(Number::from(1)),
            method: "alpha".into(),
            params: json!({"value": 3}),
        }
    );
    assert_eq!(
        rpc.next().await.unwrap(),
        IncomingMessage::Notification {
            method: "beta".into(),
            params: json!([1, 2]),
        }
    );
    assert!(rpc.shutdown().await.unwrap().status.success());
}

#[tokio::test]
async fn reads_success_and_error_responses() {
    let mut rpc = StdioRpc::spawn(shell(
        r#"printf '%s\n' '{"id":1,"result":{"ok":true}}' '{"id":"two","error":{"code":-7,"message":"no","data":{"why":1}}}'"#,
    ))
    .unwrap();
    assert_eq!(
        rpc.next().await.unwrap(),
        IncomingMessage::Response {
            id: PeerId::Number(Number::from(1)),
            result: Ok(json!({"ok": true})),
        }
    );
    assert_eq!(
        rpc.next().await.unwrap(),
        IncomingMessage::Response {
            id: PeerId::String("two".into()),
            result: Err(RpcError {
                code: -7,
                message: "no".into(),
                data: Some(json!({"why": 1})),
            }),
        }
    );
}

#[tokio::test]
async fn responds_to_peer_requests_without_version_member() {
    let mut rpc = StdioRpc::spawn(shell(
        r#"printf '%s\n' '{"id":"a","method":"first"}'; IFS= read -r first; printf '%s\n' "$first"; printf '%s\n' '{"id":9,"method":"second","params":null}'; IFS= read -r second; printf '%s\n' "$second""#,
    ))
    .unwrap();
    assert_eq!(
        rpc.next().await.unwrap(),
        IncomingMessage::Request {
            id: PeerId::String("a".into()),
            method: "first".into(),
            params: serde_json::Value::Null,
        }
    );
    rpc.respond(PeerId::String("a".into()), json!({"done": true}))
        .await
        .unwrap();
    assert_eq!(
        rpc.next().await.unwrap(),
        IncomingMessage::Response {
            id: PeerId::String("a".into()),
            result: Ok(json!({"done": true})),
        }
    );
    assert!(matches!(
        rpc.next().await.unwrap(),
        IncomingMessage::Request {
            id: PeerId::Number(_),
            method,
            ..
        } if method == "second"
    ));
    rpc.respond_error(
        PeerId::Number(Number::from(9)),
        -32_001,
        "rejected",
        Some(json!({"retry": false})),
    )
    .await
    .unwrap();
    assert_eq!(
        rpc.next().await.unwrap(),
        IncomingMessage::Response {
            id: PeerId::Number(Number::from(9)),
            result: Err(RpcError {
                code: -32_001,
                message: "rejected".into(),
                data: Some(json!({"retry": false})),
            }),
        }
    );
}

#[tokio::test]
async fn rejects_malformed_and_versioned_protocol_lines() {
    let mut malformed = StdioRpc::spawn(shell("printf '%s\n' '{not-json}'")).unwrap();
    assert!(matches!(malformed.next().await, Err(Error::Json(_))));
    let _ = malformed.shutdown().await.unwrap();

    let mut invalid = StdioRpc::spawn(shell(
        r#"printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":null}'"#,
    ))
    .unwrap();
    assert!(matches!(
        invalid.next().await,
        Err(Error::InvalidMessage(_))
    ));
    let _ = invalid.shutdown().await.unwrap();
}

#[tokio::test]
async fn reports_exit_and_bounds_stderr() {
    let script = format!(
        "yes e | tr -d '\\n' | head -c {} >&2; exit 7",
        MAX_STDERR_CAPTURE_BYTES + 100
    );
    let mut rpc = StdioRpc::spawn(shell(&script)).unwrap();
    let Err(Error::ProcessExited(exit)) = rpc.next().await else {
        panic!("expected process exit");
    };
    assert_eq!(exit.status.code(), Some(7));
    assert_eq!(exit.stderr.len(), MAX_STDERR_CAPTURE_BYTES);
    assert!(exit.stderr.bytes().all(|byte| byte == b'e'));
}

#[tokio::test]
async fn shutdown_kills_and_reaps_child() {
    let rpc = StdioRpc::spawn(shell("exec sleep 30")).unwrap();
    let exit = rpc.shutdown().await.unwrap();
    assert!(!exit.status.success());
}

#[tokio::test]
async fn rejects_and_drains_an_oversized_line() {
    let script = format!(
        "yes x | tr -d '\\n' | head -c {}; printf '\n{}\n'",
        MAX_INBOUND_LINE_BYTES + 1,
        r#"{"method":"after"}"#
    );
    let mut rpc = StdioRpc::spawn(shell(&script)).unwrap();
    assert!(matches!(rpc.next().await, Err(Error::InboundLineTooLong)));
    assert_eq!(
        rpc.next().await.unwrap(),
        IncomingMessage::Notification {
            method: "after".into(),
            params: serde_json::Value::Null,
        }
    );
    let _ = rpc.shutdown().await.unwrap();
}