use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentAction {
pub name: String,
pub description: String,
pub params: Vec<ActionParam>,
pub returns: Option<String>,
pub mutates: bool,
pub idempotent: bool,
pub shortcut: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionParam {
pub name: String,
pub description: String,
pub param_type: ActionParamType,
pub required: bool,
pub default_value: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ActionParamType {
String,
Integer,
Float,
Boolean,
Index,
Position { x: bool, y: bool },
Size { width: bool, height: bool },
Color,
Enum(Vec<String>),
Any,
}
impl AgentAction {
#[must_use]
pub fn simple(name: impl Into<String>, description: impl Into<String>, mutates: bool) -> Self {
Self {
name: name.into(),
description: description.into(),
params: vec![],
returns: None,
mutates,
idempotent: !mutates,
shortcut: None,
}
}
#[must_use]
pub fn with_params(
name: impl Into<String>,
description: impl Into<String>,
params: Vec<ActionParam>,
mutates: bool,
) -> Self {
Self {
name: name.into(),
description: description.into(),
params,
returns: None,
mutates,
idempotent: !mutates,
shortcut: None,
}
}
pub fn validate_params(&self, params: &serde_json::Value) -> Result<(), String> {
for param in &self.params {
let val = params.get(¶m.name);
match val {
None | Some(serde_json::Value::Null) => {
if param.required {
return Err(format!("Missing required parameter '{}'", param.name));
}
}
Some(v) => {
param
.param_type
.check(v)
.map_err(|e| format!("Parameter '{}': {}", param.name, e))?;
}
}
}
Ok(())
}
}
impl ActionParamType {
fn check(&self, value: &serde_json::Value) -> Result<(), String> {
match self {
ActionParamType::String => {
if !value.is_string() {
return Err(format!("expected string, got {}", json_type_name(value)));
}
}
ActionParamType::Integer => {
if !value.is_i64() && !value.is_u64() {
return Err(format!("expected integer, got {}", json_type_name(value)));
}
}
ActionParamType::Float => {
if !value.is_number() {
return Err(format!("expected number, got {}", json_type_name(value)));
}
}
ActionParamType::Boolean => {
if !value.is_boolean() {
return Err(format!("expected boolean, got {}", json_type_name(value)));
}
}
ActionParamType::Index => {
if !value.is_u64() {
return Err(format!(
"expected index (uint), got {}",
json_type_name(value)
));
}
}
ActionParamType::Position { .. } | ActionParamType::Size { .. } => {
if !value.is_object() {
return Err(format!("expected object, got {}", json_type_name(value)));
}
}
ActionParamType::Color => {
if !value.is_string() && !value.is_object() {
return Err(format!(
"expected color string or object, got {}",
json_type_name(value)
));
}
}
ActionParamType::Enum(variants) => {
if let Some(s) = value.as_str() {
if !variants.iter().any(|v| v == s) {
return Err(format!("expected one of {:?}, got {:?}", variants, s));
}
} else {
return Err(format!(
"expected string enum, got {}",
json_type_name(value)
));
}
}
ActionParamType::Any => {}
}
Ok(())
}
}
impl ActionParam {
#[must_use]
pub fn required(
name: impl Into<String>,
description: impl Into<String>,
param_type: ActionParamType,
) -> Self {
Self {
name: name.into(),
description: description.into(),
param_type,
required: true,
default_value: None,
}
}
#[must_use]
pub fn optional(
name: impl Into<String>,
description: impl Into<String>,
param_type: ActionParamType,
default: serde_json::Value,
) -> Self {
Self {
name: name.into(),
description: description.into(),
param_type,
required: false,
default_value: Some(default),
}
}
}
fn json_type_name(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_action_defaults() {
let a = AgentAction::simple("click", "Click the button", true);
assert_eq!(a.name, "click");
assert!(a.mutates);
assert!(!a.idempotent);
assert!(a.params.is_empty());
assert!(a.shortcut.is_none());
}
#[test]
fn simple_action_readonly_is_idempotent() {
let a = AgentAction::simple("read", "Read value", false);
assert!(!a.mutates);
assert!(a.idempotent);
}
#[test]
fn validate_params_missing_required() {
let action = AgentAction::with_params(
"set_text",
"Set text content",
vec![ActionParam::required(
"text",
"The text",
ActionParamType::String,
)],
true,
);
let result = action.validate_params(&serde_json::json!({}));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Missing required"));
}
#[test]
fn validate_params_wrong_type() {
let action = AgentAction::with_params(
"set_value",
"Set value",
vec![ActionParam::required(
"count",
"Count",
ActionParamType::Integer,
)],
true,
);
let result = action.validate_params(&serde_json::json!({"count": "not_a_number"}));
assert!(result.is_err());
}
#[test]
fn validate_params_correct() {
let action = AgentAction::with_params(
"set_value",
"Set value",
vec![ActionParam::required(
"count",
"Count",
ActionParamType::Integer,
)],
true,
);
assert!(
action
.validate_params(&serde_json::json!({"count": 42}))
.is_ok()
);
}
#[test]
fn validate_params_optional_missing_ok() {
let action = AgentAction::with_params(
"set",
"Set",
vec![ActionParam::optional(
"label",
"Label",
ActionParamType::String,
serde_json::json!("default"),
)],
true,
);
assert!(action.validate_params(&serde_json::json!({})).is_ok());
}
#[test]
fn validate_boolean_type() {
let action = AgentAction::with_params(
"toggle",
"Toggle",
vec![ActionParam::required(
"state",
"State",
ActionParamType::Boolean,
)],
true,
);
assert!(
action
.validate_params(&serde_json::json!({"state": true}))
.is_ok()
);
assert!(
action
.validate_params(&serde_json::json!({"state": "yes"}))
.is_err()
);
}
#[test]
fn validate_enum_type() {
let action = AgentAction::with_params(
"set_mode",
"Set mode",
vec![ActionParam::required(
"mode",
"Mode",
ActionParamType::Enum(vec!["light".into(), "dark".into()]),
)],
true,
);
assert!(
action
.validate_params(&serde_json::json!({"mode": "dark"}))
.is_ok()
);
assert!(
action
.validate_params(&serde_json::json!({"mode": "blue"}))
.is_err()
);
}
#[test]
fn validate_any_type_accepts_anything() {
let action = AgentAction::with_params(
"exec",
"Execute",
vec![ActionParam::required("data", "Data", ActionParamType::Any)],
true,
);
assert!(
action
.validate_params(&serde_json::json!({"data": [1,2,3]}))
.is_ok()
);
assert!(
action
.validate_params(&serde_json::json!({"data": "text"}))
.is_ok()
);
assert!(
action
.validate_params(&serde_json::json!({"data": 42}))
.is_ok()
);
}
#[test]
fn action_param_required_constructor() {
let p = ActionParam::required("name", "The name", ActionParamType::String);
assert!(p.required);
assert!(p.default_value.is_none());
}
#[test]
fn action_param_optional_constructor() {
let p = ActionParam::optional(
"name",
"The name",
ActionParamType::String,
serde_json::json!(""),
);
assert!(!p.required);
assert!(p.default_value.is_some());
}
}