use crate::tools::ax_snapshot::Rect;
use rmcp::model::{CallToolResult, Content};
use serde_json::json;
pub(crate) fn success(dispatched_via: &str, bbox: Option<Rect>) -> CallToolResult {
let body = match bbox {
Some(r) => json!({
"ok": true,
"dispatched_via": dispatched_via,
"bbox": { "x": r.x, "y": r.y, "w": r.w, "h": r.h }
}),
None => json!({ "ok": true, "dispatched_via": dispatched_via }),
};
CallToolResult::success(vec![Content::text(body.to_string())])
}
pub(crate) fn error(code: &str, message: &str, fallback: Option<Rect>) -> CallToolResult {
let fb = match fallback {
Some(r) => json!({ "x": r.x + r.w / 2.0, "y": r.y + r.h / 2.0 }),
None => serde_json::Value::Null,
};
let body = json!({ "error": { "code": code, "message": message, "fallback": fb } });
CallToolResult::error(vec![Content::text(body.to_string())])
}
#[cfg(test)]
mod tests {
use super::*;
fn text_body(r: &CallToolResult) -> String {
r.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect::<Vec<_>>()
.join("")
}
#[test]
fn success_with_bbox_serializes_expected_shape() {
let r = success(
"AXPress",
Some(Rect {
x: 412.0,
y: 285.0,
w: 64.0,
h: 32.0,
}),
);
assert_eq!(r.is_error, Some(false));
let body: serde_json::Value = serde_json::from_str(&text_body(&r)).unwrap();
assert_eq!(body["ok"], true);
assert_eq!(body["dispatched_via"], "AXPress");
assert_eq!(body["bbox"]["x"], 412.0);
assert_eq!(body["bbox"]["y"], 285.0);
assert_eq!(body["bbox"]["w"], 64.0);
assert_eq!(body["bbox"]["h"], 32.0);
}
#[test]
fn success_without_bbox_omits_bbox_field() {
let r = success("AXPress", None);
assert_eq!(r.is_error, Some(false));
let body: serde_json::Value = serde_json::from_str(&text_body(&r)).unwrap();
assert_eq!(body["ok"], true);
assert_eq!(body["dispatched_via"], "AXPress");
assert!(body.get("bbox").is_none());
}
#[test]
fn success_accepts_any_dispatched_via_label() {
let r = success("AXSetAttributeValue", None);
let body: serde_json::Value = serde_json::from_str(&text_body(&r)).unwrap();
assert_eq!(body["dispatched_via"], "AXSetAttributeValue");
}
#[test]
fn error_with_fallback_centers_on_bbox() {
let r = error(
"not_dispatchable",
"element does not support AXPress",
Some(Rect {
x: 100.0,
y: 200.0,
w: 50.0,
h: 40.0,
}),
);
assert_eq!(r.is_error, Some(true));
let body: serde_json::Value = serde_json::from_str(&text_body(&r)).unwrap();
assert_eq!(body["error"]["code"], "not_dispatchable");
assert_eq!(body["error"]["message"], "element does not support AXPress");
assert_eq!(body["error"]["fallback"]["x"], 125.0);
assert_eq!(body["error"]["fallback"]["y"], 220.0);
}
#[test]
fn error_without_fallback_renders_null() {
let r = error("snapshot_expired", "stale uid", None);
assert_eq!(r.is_error, Some(true));
let body: serde_json::Value = serde_json::from_str(&text_body(&r)).unwrap();
assert_eq!(body["error"]["code"], "snapshot_expired");
assert!(body["error"]["fallback"].is_null());
}
}