use std::fmt;
use serde::de::{self, IgnoredAny, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use crate::NodeId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Action {
Activate,
Focus,
Select,
Toggle,
Scroll,
SetValue,
Dismiss,
Custom(String),
}
impl Action {
fn from_wire(name: &str) -> Self {
match name {
"activate" => Action::Activate,
"focus" => Action::Focus,
"select" => Action::Select,
"toggle" => Action::Toggle,
"scroll" => Action::Scroll,
"set_value" => Action::SetValue,
"dismiss" => Action::Dismiss,
other => Action::Custom(other.to_string()),
}
}
}
impl<'de> Deserialize<'de> for Action {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct ActionVisitor;
impl<'de> Visitor<'de> for ActionVisitor {
type Value = Action;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(r#"an action name or {"custom":"name"}"#)
}
fn visit_str<E>(self, name: &str) -> Result<Action, E>
where
E: de::Error,
{
Ok(Action::from_wire(name))
}
fn visit_map<A>(self, mut map: A) -> Result<Action, A::Error>
where
A: MapAccess<'de>,
{
let Some(name) = map.next_key::<String>()? else {
return Err(de::Error::invalid_length(0, &self));
};
let action = if name == "custom" {
Action::from_wire(&map.next_value::<String>()?)
} else {
map.next_value::<IgnoredAny>()?;
Action::from_wire(&name)
};
while map.next_entry::<IgnoredAny, IgnoredAny>()?.is_some() {}
Ok(action)
}
}
deserializer.deserialize_any(ActionVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
#[non_exhaustive]
pub enum AgentInput {
#[non_exhaustive]
Act {
node: NodeId,
action: Action,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<String>,
},
#[non_exhaustive]
Key { key: String },
#[non_exhaustive]
Text { text: String },
#[serde(other)]
Unknown,
}
impl AgentInput {
pub fn act(node: NodeId, action: Action, value: Option<String>) -> Self {
Self::Act {
node,
action,
value,
}
}
pub fn key(key: impl Into<String>) -> Self {
Self::Key { key: key.into() }
}
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn action_json() -> Vec<(Action, String)> {
let mut table: Vec<(Action, String)> = Vec::new();
let mut action = Some(Action::Activate);
while let Some(current) = action {
if table.iter().any(|(seen, _)| *seen == current) {
break;
}
let (json, next) = match ¤t {
Action::Activate => (r#""activate""#.to_string(), Some(Action::Focus)),
Action::Focus => (r#""focus""#.to_string(), Some(Action::Select)),
Action::Select => (r#""select""#.to_string(), Some(Action::Toggle)),
Action::Toggle => (r#""toggle""#.to_string(), Some(Action::Scroll)),
Action::Scroll => (r#""scroll""#.to_string(), Some(Action::SetValue)),
Action::SetValue => (r#""set_value""#.to_string(), Some(Action::Dismiss)),
Action::Dismiss => (
r#""dismiss""#.to_string(),
Some(Action::Custom("archive".into())),
),
Action::Custom(name) => (format!(r#"{{"custom":"{name}"}}"#), None),
};
table.push((current, json));
action = next;
}
table
}
#[test]
fn the_action_table_walks_the_whole_vocabulary() {
let table = action_json();
assert!(
matches!(table.last(), Some((Action::Custom(_), _))),
"the walk must end at the last action, not partway: {table:?}"
);
}
#[test]
fn every_action_serializes_to_its_frozen_json() {
for (action, expected) in action_json() {
assert_eq!(
serde_json::to_string(&action).unwrap(),
expected,
"action {action:?}"
);
}
}
#[test]
fn every_action_roundtrips() {
for (action, json) in action_json() {
let back: Action = serde_json::from_str(&json).unwrap();
assert_eq!(back, action, "action {action:?} via {json}");
}
}
#[test]
fn unknown_action_name_becomes_custom() {
assert_eq!(
serde_json::from_str::<Action>(r#""set_range""#).unwrap(),
Action::Custom("set_range".into())
);
assert_eq!(
serde_json::from_str::<Action>(r#""custom""#).unwrap(),
Action::Custom("custom".into())
);
}
#[test]
fn unknown_action_object_keeps_its_name_and_drops_its_payload() {
assert_eq!(
serde_json::from_str::<Action>(r#"{"set_range":{"from":1,"to":9}}"#).unwrap(),
Action::Custom("set_range".into())
);
assert_eq!(
serde_json::from_str::<Action>(r#"{"activate":null}"#).unwrap(),
Action::Activate
);
assert_eq!(
serde_json::from_str::<Action>(r#"{"custom":"archive","added_later":true}"#).unwrap(),
Action::Custom("archive".into())
);
}
#[test]
fn unknown_action_degrades_inside_an_agent_input() {
let input: AgentInput =
serde_json::from_str(r#"{"kind":"act","node":"btn","action":"set_range"}"#).unwrap();
assert_eq!(
input,
AgentInput::Act {
node: NodeId("btn".into()),
action: Action::Custom("set_range".into()),
value: None,
}
);
}
#[test]
fn the_custom_envelope_folds_onto_every_built_in() {
for (action, json) in action_json() {
let Some(name) = json.strip_prefix('"').and_then(|j| j.strip_suffix('"')) else {
continue;
};
let enveloped = format!(r#"{{"custom":"{name}"}}"#);
assert_eq!(
serde_json::from_str::<Action>(&enveloped).unwrap(),
action,
"{enveloped} must parse as {action:?}"
);
}
}
#[test]
fn an_echoed_custom_reaches_the_built_in_it_names() {
let echoed = Action::Custom("dismiss".into());
let json = serde_json::to_string(&echoed).unwrap();
assert_eq!(json, r#"{"custom":"dismiss"}"#);
assert_eq!(
serde_json::from_str::<Action>(&json).unwrap(),
Action::Dismiss
);
let input: AgentInput =
serde_json::from_str(r#"{"kind":"act","node":"btn","action":{"custom":"dismiss"}}"#)
.unwrap();
assert_eq!(
input,
AgentInput::Act {
node: NodeId("btn".into()),
action: Action::Dismiss,
value: None,
}
);
}
#[test]
fn malformed_action_is_still_an_error() {
assert!(serde_json::from_str::<Action>("7").is_err());
assert!(serde_json::from_str::<Action>(r#"{"custom":7}"#).is_err());
assert!(serde_json::from_str::<Action>("{}").is_err());
}
#[test]
fn every_agent_input_roundtrips() {
let inputs = [
AgentInput::Act {
node: NodeId("input-1".into()),
action: Action::SetValue,
value: Some("hello".into()),
},
AgentInput::Act {
node: NodeId("btn-1".into()),
action: Action::Custom("archive".into()),
value: None,
},
AgentInput::Key {
key: "ctrl+c".into(),
},
AgentInput::Text {
text: "buy milk".into(),
},
];
for input in inputs {
let json = serde_json::to_string(&input).unwrap();
let back: AgentInput = serde_json::from_str(&json).unwrap();
assert_eq!(back, input, "input {input:?} via {json}");
}
}
#[test]
fn agent_input_uses_kind_tag() {
let cases = [
(
AgentInput::Key { key: "q".into() },
r#"{"kind":"key","key":"q"}"#,
),
(
AgentInput::Text { text: "hi".into() },
r#"{"kind":"text","text":"hi"}"#,
),
(
AgentInput::Act {
node: NodeId("btn-1".into()),
action: Action::Activate,
value: None,
},
r#"{"kind":"act","node":"btn-1","action":"activate"}"#,
),
];
for (input, expected) in cases {
let json = serde_json::to_string(&input).unwrap();
assert_eq!(json, expected, "input: {input:?}");
}
}
#[test]
fn the_constructors_reach_every_field() {
assert_eq!(
AgentInput::act(
NodeId("input-1".into()),
Action::SetValue,
Some("hello".into())
),
AgentInput::Act {
node: NodeId("input-1".into()),
action: Action::SetValue,
value: Some("hello".into()),
}
);
assert_eq!(
AgentInput::act(NodeId("btn-1".into()), Action::Activate, None),
AgentInput::Act {
node: NodeId("btn-1".into()),
action: Action::Activate,
value: None,
}
);
assert_eq!(
AgentInput::key("ctrl+c"),
AgentInput::Key {
key: "ctrl+c".into()
}
);
assert_eq!(
AgentInput::text("buy milk"),
AgentInput::Text {
text: "buy milk".into()
}
);
}
#[test]
fn an_unrecognized_kind_reads_as_unknown() {
for line in [
r#"{"kind":"paste","text":"hello"}"#,
r#"{"kind":"pointer","at":{"x":3,"y":9},"button":"left"}"#,
r#"{"kind":"unknown"}"#,
] {
assert_eq!(
serde_json::from_str::<AgentInput>(line).unwrap(),
AgentInput::Unknown,
"{line}"
);
}
assert_eq!(
serde_json::to_string(&AgentInput::Unknown).unwrap(),
r#"{"kind":"unknown"}"#
);
}
#[test]
fn a_malformed_input_is_still_an_error() {
assert!(serde_json::from_str::<AgentInput>(r#"{"text":"hello"}"#).is_err());
assert!(serde_json::from_str::<AgentInput>(r#"{"kind":"key"}"#).is_err());
assert!(serde_json::from_str::<AgentInput>("7").is_err());
}
}