diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 04:23:22 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 04:23:22 +0300 |
| commit | fd1e3b8b056b2937fc0a5c90bf482e01d269f6b2 (patch) | |
| tree | 4bd41fc4c4682c4fae42e5ea742ee2b93c7672ff /src/api | |
| parent | 60baa765d942dcc40ee8bf0e58400aa634b6741a (diff) | |
feat: add initial socket api for workspace and pane control
add unix socket request/response api scaffolding
implement initial workspace and pane commands:
- ping
- workspace.list
- workspace.get
- workspace.create
- pane.list
- pane.get
- pane.read
- pane.send_text
- pane.send_keys
- pane.wait_for_output
improve pane read semantics:
- visible trims trailing blank viewport rows
- recent reads from parser scrollback/current screen
- wait_for_output returns matched snapshot plus matched_line
add real subprocess integration tests for socket api
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/mod.rs | 348 | ||||
| -rw-r--r-- | src/api/schema.rs | 537 |
2 files changed, 885 insertions, 0 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 00000000..642dc70d --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,348 @@ +pub mod schema; + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; + +use tracing::{debug, error, info, warn}; + +use regex::Regex; + +use crate::api::schema::{ + ErrorBody, ErrorResponse, Method, Request, ResponseResult, SuccessResponse, +}; + +pub const SOCKET_PATH_ENV_VAR: &str = "HERDR_SOCKET_PATH"; + +pub struct ApiRequestMessage { + pub request: Request, + pub respond_to: std::sync::mpsc::Sender<String>, +} + +pub fn socket_path() -> PathBuf { + if let Ok(path) = std::env::var(SOCKET_PATH_ENV_VAR) { + return PathBuf::from(path); + } + + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(dir).join("herdr.sock"); + } + + if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(dir).join("herdr/herdr.sock"); + } + + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".config/herdr/herdr.sock"); + } + + PathBuf::from("/tmp/herdr.sock") +} + +pub struct ServerHandle { + _thread: std::thread::JoinHandle<()>, + path: PathBuf, +} + +impl Drop for ServerHandle { + fn drop(&mut self) { + if let Err(err) = fs::remove_file(&self.path) { + if err.kind() != std::io::ErrorKind::NotFound { + warn!(path = %self.path.display(), err = %err, "failed to remove api socket on shutdown"); + } + } + } +} + +pub fn start_server( + api_tx: std::sync::mpsc::Sender<ApiRequestMessage>, +) -> std::io::Result<ServerHandle> { + let path = socket_path(); + prepare_socket_path(&path)?; + + let listener = UnixListener::bind(&path)?; + info!(path = %path.display(), "api server listening"); + + let thread = std::thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + if let Err(err) = handle_connection(stream, &api_tx) { + warn!(err = %err, "api connection failed"); + } + } + Err(err) => { + error!(err = %err, "api listener accept failed"); + break; + } + } + } + debug!("api server thread exiting"); + }); + + Ok(ServerHandle { + _thread: thread, + path, + }) +} + +fn prepare_socket_path(path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + if let Err(err) = fs::remove_file(path) { + if err.kind() != std::io::ErrorKind::NotFound { + return Err(err); + } + } + + Ok(()) +} + +fn handle_connection( + mut stream: UnixStream, + api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>, +) -> std::io::Result<()> { + let mut line = String::new(); + { + let mut reader = BufReader::new(&stream); + let read = reader.read_line(&mut line)?; + if read == 0 { + return Ok(()); + } + } + + let line = line.trim(); + if line.is_empty() { + return Ok(()); + } + + let response = match serde_json::from_str::<Request>(line) { + Ok(request) => handle_request(request, api_tx), + Err(err) => serde_json::to_string(&ErrorResponse { + id: String::new(), + error: ErrorBody { + code: "invalid_request".into(), + message: format!("invalid request: {err}"), + }, + })?, + }; + + stream.write_all(response.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush()?; + Ok(()) +} + +fn handle_request(request: Request, api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>) -> String { + let request_id = request.id.clone(); + match request.method { + Method::Ping(_) => serde_json::to_string(&SuccessResponse { + id: request.id, + result: ResponseResult::Pong { + version: env!("CARGO_PKG_VERSION").into(), + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"internal_error","message":"failed to encode response"}}"# + .to_string() + }), + Method::PaneWaitForOutput(params) => wait_for_output(request_id, params, api_tx), + _ => dispatch_to_app(request, api_tx), + } +} + +fn wait_for_output( + request_id: String, + params: crate::api::schema::PaneWaitForOutputParams, + api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>, +) -> String { + let deadline = params + .timeout_ms + .map(|ms| std::time::Instant::now() + std::time::Duration::from_millis(ms)); + + let regex = match ¶ms.r#match { + crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) { + Ok(regex) => Some(regex), + Err(err) => { + return serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "invalid_regex".into(), + message: err.to_string(), + }, + }) + .unwrap(); + } + }, + crate::api::schema::OutputMatch::Substring { .. } => None, + }; + + loop { + let read_request = Request { + id: format!("{request_id}:read"), + method: Method::PaneRead(crate::api::schema::PaneReadParams { + pane_id: params.pane_id.clone(), + source: params.source.clone(), + lines: params.lines, + strip_ansi: params.strip_ansi, + }), + }; + let response = dispatch_to_app(read_request, api_tx); + let Ok(value) = serde_json::from_str::<serde_json::Value>(&response) else { + return response; + }; + if value.get("error").is_some() { + let mut value = value; + value["id"] = serde_json::Value::String(request_id); + return serde_json::to_string(&value).unwrap(); + } + + let read_value = value["result"]["read"].clone(); + let Ok(read) = serde_json::from_value::<crate::api::schema::PaneReadResult>(read_value) + else { + return serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode pane read result".into(), + }, + }) + .unwrap(); + }; + + let matched_line = match ¶ms.r#match { + crate::api::schema::OutputMatch::Substring { value } => read + .text + .lines() + .find(|line| line.contains(value)) + .map(|line| line.to_string()), + crate::api::schema::OutputMatch::Regex { .. } => regex.as_ref().and_then(|re| { + read.text + .lines() + .find(|line| re.is_match(line)) + .map(|line| line.to_string()) + }), + }; + if matched_line.is_some() { + let revision = read.revision; + return serde_json::to_string(&SuccessResponse { + id: request_id, + result: ResponseResult::OutputMatched { + pane_id: params.pane_id, + revision, + matched_line, + read, + }, + }) + .unwrap(); + } + + if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { + return serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "timeout".into(), + message: "timed out waiting for output match".into(), + }, + }) + .unwrap(); + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + +fn dispatch_to_app( + request: Request, + api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>, +) -> String { + let (respond_to, response_rx) = std::sync::mpsc::channel(); + if let Err(err) = api_tx.send(ApiRequestMessage { + request, + respond_to, + }) { + return serde_json::to_string(&ErrorResponse { + id: String::new(), + error: ErrorBody { + code: "server_unavailable".into(), + message: format!("failed to dispatch request: {err}"), + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"internal_error","message":"failed to encode error response"}}"#.to_string() + }); + } + + response_rx.recv().unwrap_or_else(|err| { + serde_json::to_string(&ErrorResponse { + id: String::new(), + error: ErrorBody { + code: "server_unavailable".into(), + message: format!("request handling failed: {err}"), + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"internal_error","message":"failed to encode error response"}}"#.to_string() + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn socket_path_prefers_explicit_env_override() { + let unique = format!("/tmp/herdr-test-{}.sock", std::process::id()); + std::env::set_var(SOCKET_PATH_ENV_VAR, &unique); + assert_eq!(socket_path(), PathBuf::from(&unique)); + std::env::remove_var(SOCKET_PATH_ENV_VAR); + } + + #[test] + fn ping_request_returns_pong() { + let (tx, _rx) = std::sync::mpsc::channel(); + let response = handle_request( + Request { + id: "req_1".into(), + method: Method::Ping(crate::api::schema::PingParams::default()), + }, + &tx, + ); + + let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed.id, "req_1"); + assert!(matches!(parsed.result, ResponseResult::Pong { .. })); + } + + #[test] + fn request_dispatches_to_app_channel() { + let (tx, rx) = std::sync::mpsc::channel(); + let request = Request { + id: "req_2".into(), + method: Method::WorkspaceList(crate::api::schema::EmptyParams::default()), + }; + + let request_for_thread = request.clone(); + let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx)); + + let msg = rx.recv().unwrap(); + assert_eq!(msg.request.id, "req_2"); + msg.respond_to + .send( + serde_json::to_string(&SuccessResponse { + id: "req_2".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(), + ) + .unwrap(); + + let response = thread.join().unwrap(); + let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed.id, "req_2"); + } +} diff --git a/src/api/schema.rs b/src/api/schema.rs new file mode 100644 index 00000000..29509211 --- /dev/null +++ b/src/api/schema.rs @@ -0,0 +1,537 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Request { + pub id: String, + #[serde(flatten)] + pub method: Method, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "method", content = "params")] +pub enum Method { + #[serde(rename = "ping")] + Ping(PingParams), + #[serde(rename = "workspace.create")] + WorkspaceCreate(WorkspaceCreateParams), + #[serde(rename = "workspace.list")] + WorkspaceList(EmptyParams), + #[serde(rename = "workspace.get")] + WorkspaceGet(WorkspaceTarget), + #[serde(rename = "workspace.focus")] + WorkspaceFocus(WorkspaceTarget), + #[serde(rename = "workspace.rename")] + WorkspaceRename(WorkspaceRenameParams), + #[serde(rename = "workspace.close")] + WorkspaceClose(WorkspaceTarget), + #[serde(rename = "pane.split")] + PaneSplit(PaneSplitParams), + #[serde(rename = "pane.list")] + PaneList(PaneListParams), + #[serde(rename = "pane.get")] + PaneGet(PaneTarget), + #[serde(rename = "pane.send_text")] + PaneSendText(PaneSendTextParams), + #[serde(rename = "pane.send_keys")] + PaneSendKeys(PaneSendKeysParams), + #[serde(rename = "pane.read")] + PaneRead(PaneReadParams), + #[serde(rename = "pane.close")] + PaneClose(PaneTarget), + #[serde(rename = "events.subscribe")] + EventsSubscribe(EventsSubscribeParams), + #[serde(rename = "events.wait")] + EventsWait(EventsWaitParams), + #[serde(rename = "pane.wait_for_output")] + PaneWaitForOutput(PaneWaitForOutputParams), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct EmptyParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PingParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceTarget { + pub workspace_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneTarget { + pub pane_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceRenameParams { + pub workspace_id: String, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSplitParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + pub target_pane_id: String, + pub direction: SplitDirection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitDirection { + Right, + Down, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSendTextParams { + pub pane_id: String, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSendKeysParams { + pub pane_id: String, + pub keys: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReadParams { + pub pane_id: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option<u32>, + #[serde(default = "default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReadSource { + Visible, + Recent, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsSubscribeParams { + pub events: Vec<EventKind>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventsWaitParams { + pub match_event: EventMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneWaitForOutputParams { + pub pane_id: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option<u32>, + pub r#match: OutputMatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, + #[serde(default = "default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OutputMatch { + Substring { value: String }, + Regex { value: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum EventMatch { + WorkspaceCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + WorkspaceClosed { + workspace_id: String, + }, + WorkspaceRenamed { + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option<String>, + }, + WorkspaceFocused { + workspace_id: String, + }, + PaneCreated { + #[serde(default, skip_serializing_if = "Option::is_none")] + pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option<String>, + }, + PaneClosed { + pane_id: String, + }, + PaneFocused { + pane_id: String, + }, + PaneOutputChanged { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + min_revision: Option<u64>, + }, + PaneExited { + pane_id: String, + }, + PaneAgentDetected { + pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + }, + PaneAgentStateChanged { + pane_id: String, + state: PaneAgentState, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + WorkspaceCreated, + WorkspaceClosed, + WorkspaceRenamed, + WorkspaceFocused, + PaneCreated, + PaneClosed, + PaneFocused, + PaneOutputChanged, + PaneExited, + PaneAgentDetected, + PaneAgentStateChanged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SuccessResponse { + pub id: String, + pub result: ResponseResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorResponse { + pub id: String, + pub error: ErrorBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseResult { + Pong { + version: String, + }, + WorkspaceInfo { + workspace: WorkspaceInfo, + }, + WorkspaceList { + workspaces: Vec<WorkspaceInfo>, + }, + PaneInfo { + pane: PaneInfo, + }, + PaneList { + panes: Vec<PaneInfo>, + }, + PaneRead { + read: PaneReadResult, + }, + SubscriptionStarted { + events: Vec<EventKind>, + }, + WaitMatched { + event: EventEnvelope, + }, + OutputMatched { + pane_id: String, + revision: u64, + matched_line: Option<String>, + read: PaneReadResult, + }, + Ok {}, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceInfo { + pub workspace_id: String, + pub number: usize, + pub label: String, + pub focused: bool, + pub pane_count: usize, + pub agent_state: PaneAgentState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneInfo { + pub pane_id: String, + pub workspace_id: String, + pub focused: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option<String>, + pub agent_state: PaneAgentState, + pub revision: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReadResult { + pub pane_id: String, + pub workspace_id: String, + pub source: ReadSource, + pub text: String, + pub revision: u64, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventEnvelope { + pub event: EventKind, + pub data: EventData, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EventData { + WorkspaceCreated { + workspace: WorkspaceInfo, + }, + WorkspaceClosed { + workspace_id: String, + }, + WorkspaceRenamed { + workspace_id: String, + label: String, + }, + WorkspaceFocused { + workspace_id: String, + }, + PaneCreated { + pane: PaneInfo, + }, + PaneClosed { + pane_id: String, + workspace_id: String, + }, + PaneFocused { + pane_id: String, + workspace_id: String, + }, + PaneOutputChanged { + pane_id: String, + workspace_id: String, + revision: u64, + }, + PaneExited { + pane_id: String, + workspace_id: String, + }, + PaneAgentDetected { + pane_id: String, + workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option<String>, + }, + PaneAgentStateChanged { + pane_id: String, + workspace_id: String, + state: PaneAgentState, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneAgentState { + Idle, + Busy, + Waiting, + Unknown, +} + +fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_round_trips_for_pane_read() { + let request = Request { + id: "req_1".into(), + method: Method::PaneRead(PaneReadParams { + pane_id: "p_1".into(), + source: ReadSource::Recent, + lines: Some(80), + strip_ansi: true, + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); + } + + #[test] + fn request_uses_dot_method_names() { + let request = Request { + id: "req_1".into(), + method: Method::WorkspaceCreate(WorkspaceCreateParams { + cwd: Some("/tmp".into()), + focus: true, + }), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "workspace.create"); + } + + #[test] + fn unknown_method_is_rejected() { + let json = r#"{"id":"req_1","method":"nope","params":{}}"#; + let err = serde_json::from_str::<Request>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown variant")); + } + + #[test] + fn missing_required_params_are_rejected() { + let json = r#"{"id":"req_1","method":"pane.send_text","params":{"pane_id":"p_1"}}"#; + let err = serde_json::from_str::<Request>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("text")); + } + + #[test] + fn pane_wait_for_output_defaults_strip_ansi_to_true() { + let json = r#" + { + "id": "req_1", + "method": "pane.wait_for_output", + "params": { + "pane_id": "p_1", + "source": "recent", + "match": { "type": "substring", "value": "ready" } + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::PaneWaitForOutput(params) = request.method else { + panic!("wrong method parsed"); + }; + assert!(params.strip_ansi); + } + + #[test] + fn event_envelope_round_trips() { + let event = EventEnvelope { + event: EventKind::PaneOutputChanged, + data: EventData::PaneOutputChanged { + pane_id: "p_1".into(), + workspace_id: "w_1".into(), + revision: 42, + }, + }; + + let json = serde_json::to_string(&event).unwrap(); + let restored: EventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); + } + + #[test] + fn success_response_round_trips() { + let response = SuccessResponse { + id: "req_1".into(), + result: ResponseResult::Pong { + version: "0.1.2".into(), + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); + } + + #[test] + fn error_response_round_trips() { + let response = ErrorResponse { + id: "req_1".into(), + error: ErrorBody { + code: "pane_not_found".into(), + message: "pane p_1 not found".into(), + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + let restored: ErrorResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); + } + + #[test] + fn event_wait_parses_typed_match() { + let json = r#" + { + "id": "req_9", + "method": "events.wait", + "params": { + "match_event": { + "event": "pane_agent_state_changed", + "pane_id": "p_1", + "state": "waiting" + }, + "timeout_ms": 30000 + } + } + "#; + + let request: Request = serde_json::from_str(json).unwrap(); + let Method::EventsWait(params) = request.method else { + panic!("wrong method parsed"); + }; + assert_eq!( + params.match_event, + EventMatch::PaneAgentStateChanged { + pane_id: "p_1".into(), + state: PaneAgentState::Waiting, + } + ); + } +} |
