use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Action {
Screenshot,
CursorPosition,
MouseMove { x: i32, y: i32 },
LeftClick,
RightClick,
MiddleClick,
DoubleClick,
LeftClickDrag { x: i32, y: i32 },
Type { text: String },
Key { keys: String },
Scroll {
x: i32,
y: i32,
direction: ScrollDirection,
amount: i32,
},
Wait { ms: u64 },
Exec { command: String },
ExecCapture {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
timeout_ms: Option<u64>,
},
Navigate { url: String },
SetClipboard { text: String },
GetClipboard,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScrollDirection {
Up,
Down,
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResponseHeader {
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default)]
pub payload_len: u32,
}
impl ResponseHeader {
pub fn ok() -> Self {
Self {
ok: true,
error: None,
x: None,
y: None,
exit_code: None,
payload_len: 0,
}
}
pub fn err(msg: impl Into<String>) -> Self {
Self {
ok: false,
error: Some(msg.into()),
x: None,
y: None,
exit_code: None,
payload_len: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_screenshot_serializes_with_tag() {
let j = serde_json::to_string(&Action::Screenshot).unwrap();
assert_eq!(j, r#"{"action":"screenshot"}"#);
}
#[test]
fn action_with_fields_round_trips() {
let a = Action::MouseMove { x: 10, y: 20 };
let j = serde_json::to_string(&a).unwrap();
assert_eq!(j, r#"{"action":"mouse_move","x":10,"y":20}"#);
let back: Action = serde_json::from_str(&j).unwrap();
assert_eq!(back, a);
}
#[test]
fn left_click_drag_round_trips() {
let a = Action::LeftClickDrag { x: 640, y: 400 };
let j = serde_json::to_string(&a).unwrap();
assert_eq!(j, r#"{"action":"left_click_drag","x":640,"y":400}"#);
let back: Action = serde_json::from_str(&j).unwrap();
assert_eq!(back, a);
}
#[test]
fn scroll_direction_is_snake_case() {
let a = Action::Scroll {
x: 1,
y: 2,
direction: ScrollDirection::Down,
amount: 3,
};
let j = serde_json::to_string(&a).unwrap();
assert!(j.contains(r#""direction":"down""#));
let back: Action = serde_json::from_str(&j).unwrap();
assert_eq!(back, a);
}
#[test]
fn clipboard_actions_serialize_snake_case() {
assert_eq!(
serde_json::to_string(&Action::GetClipboard).unwrap(),
r#"{"action":"get_clipboard"}"#
);
let a = Action::SetClipboard { text: "hi".into() };
assert_eq!(
serde_json::to_string(&a).unwrap(),
r#"{"action":"set_clipboard","text":"hi"}"#
);
}
#[test]
fn type_and_key_round_trip() {
for a in [
Action::Type {
text: "hello world".into(),
},
Action::Key {
keys: "ctrl+c".into(),
},
Action::Exec {
command: "chromium &".into(),
},
Action::Navigate {
url: "https://example.com/a?b=c&d=e".into(),
},
Action::ExecCapture {
command: "cat /etc/os-release".into(),
timeout_ms: Some(5000),
},
Action::ExecCapture {
command: "ls".into(),
timeout_ms: None,
},
Action::SetClipboard {
text: "clip".into(),
},
Action::GetClipboard,
Action::Wait { ms: 500 },
] {
let j = serde_json::to_string(&a).unwrap();
let back: Action = serde_json::from_str(&j).unwrap();
assert_eq!(back, a);
}
}
#[test]
fn response_header_ok_omits_optional_fields() {
let j = serde_json::to_string(&ResponseHeader::ok()).unwrap();
assert_eq!(j, r#"{"ok":true,"payload_len":0}"#);
}
#[test]
fn exec_capture_serializes_timeout_when_set() {
let a = Action::ExecCapture {
command: "ls".into(),
timeout_ms: None,
};
assert_eq!(
serde_json::to_string(&a).unwrap(),
r#"{"action":"exec_capture","command":"ls"}"#
);
let a = Action::ExecCapture {
command: "ls".into(),
timeout_ms: Some(2000),
};
assert_eq!(
serde_json::to_string(&a).unwrap(),
r#"{"action":"exec_capture","command":"ls","timeout_ms":2000}"#
);
}
#[test]
fn response_header_carries_exit_code() {
let h = ResponseHeader {
ok: true,
error: None,
x: None,
y: None,
exit_code: Some(0),
payload_len: 12,
};
let j = serde_json::to_string(&h).unwrap();
assert!(j.contains(r#""exit_code":0"#));
let back: ResponseHeader = serde_json::from_str(&j).unwrap();
assert_eq!(back, h);
}
#[test]
fn response_header_err_carries_message() {
let h = ResponseHeader::err("boom");
let j = serde_json::to_string(&h).unwrap();
assert!(j.contains(r#""ok":false"#));
assert!(j.contains(r#""error":"boom""#));
let back: ResponseHeader = serde_json::from_str(&j).unwrap();
assert_eq!(back, h);
}
#[test]
fn cursor_position_response_carries_coords() {
let h = ResponseHeader {
ok: true,
error: None,
x: Some(640),
y: Some(400),
exit_code: None,
payload_len: 0,
};
let j = serde_json::to_string(&h).unwrap();
let back: ResponseHeader = serde_json::from_str(&j).unwrap();
assert_eq!(back.x, Some(640));
assert_eq!(back.y, Some(400));
}
}