aboutsummaryrefslogtreecommitdiff
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/mod.rs348
-rw-r--r--src/api/schema.rs537
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 &params.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 &params.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,
+ }
+ );
+ }
+}