From 0351efa2c69db0a1657a7dc30cf0d8720aaca2b9 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 19:55:29 +0700 Subject: [PATCH] feat(proto): envelope, commands, events, ids with serde round-trip tests Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-proto/src/ids.rs | 19 ++++- crates/spacesh-proto/src/lib.rs | 5 +- crates/spacesh-proto/src/message.rs | 116 +++++++++++++++++++++++++++- 3 files changed, 135 insertions(+), 5 deletions(-) diff --git a/crates/spacesh-proto/src/ids.rs b/crates/spacesh-proto/src/ids.rs index 8bf8d9b..ee733e8 100644 --- a/crates/spacesh-proto/src/ids.rs +++ b/crates/spacesh-proto/src/ids.rs @@ -1 +1,18 @@ -// populated in Task 1 +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SurfaceId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct WorkspaceId(pub String); + +impl std::fmt::Display for SurfaceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +impl std::fmt::Display for WorkspaceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/spacesh-proto/src/lib.rs b/crates/spacesh-proto/src/lib.rs index 385b773..23ec789 100644 --- a/crates/spacesh-proto/src/lib.rs +++ b/crates/spacesh-proto/src/lib.rs @@ -2,6 +2,5 @@ pub mod codec; pub mod ids; pub mod message; -// re-exports added in Task 1 when types are defined -// pub use ids::{SurfaceId, WorkspaceId}; -// pub use message::{Cmd, Envelope, ErrorBody, Evt}; +pub use ids::{SurfaceId, WorkspaceId}; +pub use message::{Cmd, Envelope, ErrorBody, Evt}; diff --git a/crates/spacesh-proto/src/message.rs b/crates/spacesh-proto/src/message.rs index 8bf8d9b..482e5c0 100644 --- a/crates/spacesh-proto/src/message.rs +++ b/crates/spacesh-proto/src/message.rs @@ -1 +1,115 @@ -// populated in Task 1 +use serde::{Deserialize, Serialize}; +use crate::ids::{SurfaceId, WorkspaceId}; + +/// Wire envelope. `kind` is the serde tag. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Envelope { + Req { + id: u64, + cmd: Cmd, + }, + Res { + id: u64, + ok: bool, + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + data: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, + Evt(Evt), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ErrorBody { + pub code: String, + pub msg: String, +} + +/// Client → daemon commands. The active subset for M0+M1. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "cmd", content = "args", rename_all = "snake_case")] +pub enum Cmd { + Open { path: String }, + NewSurface { + workspace_id: WorkspaceId, + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + args: Vec, + cols: u16, + rows: u16, + }, + Input { + surface_id: SurfaceId, + /// base64-encoded keyboard bytes. + bytes: String, + }, + Resize { surface_id: SurfaceId, cols: u16, rows: u16 }, + Attach { surface_id: SurfaceId }, + Detach { surface_id: SurfaceId }, + Focus { surface_id: SurfaceId }, + Close { surface_id: SurfaceId }, + Status, + Shutdown, +} + +/// Daemon → subscribers push events. The active subset for M0+M1. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "evt", content = "data", rename_all = "snake_case")] +pub enum Evt { + Output { surface_id: SurfaceId, bytes: Vec }, + Exit { surface_id: SurfaceId, code: i32 }, + SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId }, + SurfaceClosed { surface_id: SurfaceId }, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ids::{SurfaceId, WorkspaceId}; + + #[test] + fn req_round_trips_through_json() { + let env = Envelope::Req { + id: 42, + cmd: Cmd::Focus { surface_id: SurfaceId("s_8f3".into()) }, + }; + let json = serde_json::to_string(&env).unwrap(); + let back: Envelope = serde_json::from_str(&json).unwrap(); + assert_eq!(env, back); + } + + #[test] + fn res_ok_and_err_serialize_distinctly() { + let ok = Envelope::Res { id: 1, ok: true, data: serde_json::json!({"workspace_id":"w_1"}), error: None }; + let err = Envelope::Res { id: 2, ok: false, data: serde_json::Value::Null, + error: Some(ErrorBody { code: "NOT_FOUND".into(), msg: "no surface".into() }) }; + assert!(serde_json::to_string(&ok).unwrap().contains("\"ok\":true")); + assert!(serde_json::to_string(&err).unwrap().contains("NOT_FOUND")); + } + + #[test] + fn evt_output_carries_workspace_scoped_surface() { + let evt = Envelope::Evt(Evt::Output { + surface_id: SurfaceId("s_1".into()), + bytes: vec![104, 105], + }); + let json = serde_json::to_string(&evt).unwrap(); + let back: Envelope = serde_json::from_str(&json).unwrap(); + assert_eq!(evt, back); + } + + #[test] + fn new_surface_defaults_cmd_to_none() { + let json = r#"{"kind":"req","id":7,"cmd":{"cmd":"new_surface","args":{"workspace_id":"w_1","cols":80,"rows":24}}}"#; + let env: Envelope = serde_json::from_str(json).unwrap(); + match env { + Envelope::Req { cmd: Cmd::NewSurface { command, args, .. }, .. } => { + assert!(command.is_none()); + assert!(args.is_empty()); + } + _ => panic!("wrong variant"), + } + } +}