use crate::base::{McpError, ToolDescriptor};
use serde_json::{json, Value};
use std::io::{Read, Write};
use crate::library::exec_with_cleanup;
fn no_args() -> Value {
json!({"type": "object", "properties": {}, "additionalProperties": false})
}
pub fn overlay_serialize_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"overlay_serialize",
"Serialize the current overlay state from the Jumperless V5 as structured JSON. \
Returns an array of overlay objects, each with name, row, col, width, height, and \
colors fields. Colors are 6-char hex strings in row-major order; '000000' = off. \
The _DIRECT_PIXELS_ overlay appears here if any pixels were set via overlay_set_pixel. \
NOTE: device uses a 4096-byte static buffer — large overlay sets (approaching 8) \
may be silently truncated.",
no_args(),
2_000,
)
}
pub fn overlay_list_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"overlay_list",
"List all active overlays on the Jumperless V5 with their names and bounding boxes. \
Returns overlay count and a summary array. Uses overlay_count() + overlay_serialize(). \
NOTE: count is accurate but slot layout may be sparse after removals (slots are not \
compacted after overlay_clear). Max 8 overlays; approaching that cap will cause \
overlay_set and overlay_set_pixel to fail silently.",
no_args(),
2_000,
)
}
pub fn overlay_clear_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"overlay_clear",
"Remove a single named overlay from the Jumperless V5. \
Requires the exact name (case-sensitive, max 31 chars). \
Returns {\"removed\": true} if the overlay was found and removed, \
{\"removed\": false} if not found. Triggers markDirty() → autosave within ~2s. \
Passing the special name '_DIRECT_PIXELS_' clears all overlay_set_pixel pixels.",
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Exact overlay name (case-sensitive, max 31 chars)"
}
},
"required": ["name"],
"additionalProperties": false
}),
2_000,
)
}
pub fn overlay_clear_all_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"overlay_clear_all",
"Clear all graphic overlays from the Jumperless V5, including the implicit \
_DIRECT_PIXELS_ overlay created by overlay_set_pixel. Always succeeds. \
Triggers markDirty() → autosave within ~2s even if no overlays existed. \
For guaranteed persistence call jumperless.slot.save after this tool.",
no_args(),
2_000,
)
}
pub fn descriptors() -> Vec<ToolDescriptor> {
vec![
overlay_serialize_descriptor(),
overlay_list_descriptor(),
overlay_clear_descriptor(),
overlay_clear_all_descriptor(),
]
}
pub fn handle_overlay_serialize<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
let code = "print('{' + overlay_serialize() + '}')";
let resp = exec_with_cleanup(port, code, "overlay_serialize")?;
let raw = resp.stdout.trim().to_string();
let parsed = serde_json::from_str::<Value>(&raw).map_err(|e| {
McpError::Protocol(format!(
"overlay_serialize: device returned non-JSON (possible 4096-byte buffer truncation; \
parse error: {e}); raw: '{raw}'"
))
})?;
let overlays = parsed
.get("overlays")
.and_then(|v| v.as_array())
.ok_or_else(|| {
McpError::Protocol(format!(
"overlay_serialize: parsed JSON missing 'overlays' key — possible firmware \
response shape change; raw: '{raw}'"
))
})?;
let count = overlays.len();
let warning = if count >= 7 {
Some(format!(
"overlay count is {count}; approaching MAX_GRAPHIC_OVERLAYS (8) — \
overlay_set and overlay_set_pixel will fail silently when full"
))
} else {
None
};
Ok(json!({
"overlays": overlays,
"warning": warning
}))
}
pub fn handle_overlay_list<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
let count_code = "print(overlay_count())";
let count_resp = exec_with_cleanup(port, count_code, "overlay_list:count")?;
let count: u64 = count_resp.stdout.trim().parse().map_err(|_| {
McpError::Protocol(format!(
"overlay_list: unexpected count response: '{}'",
count_resp.stdout.trim()
))
})?;
let serialize_code = "print('{' + overlay_serialize() + '}')";
let serialize_resp = exec_with_cleanup(port, serialize_code, "overlay_list:serialize")?;
let raw = serialize_resp.stdout.trim().to_string();
let parsed = serde_json::from_str::<Value>(&raw).map_err(|e| {
McpError::Protocol(format!(
"overlay_list: serialize-stage parse failed (possible 4096-byte buffer truncation; \
parse error: {e}); raw: '{raw}'"
))
})?;
let overlays: Vec<Value> = parsed
.get("overlays")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
.into_iter()
.map(|o| {
json!({
"name": o.get("name").cloned().unwrap_or(json!("")),
"row": o.get("row").cloned().unwrap_or(json!(0)),
"col": o.get("col").cloned().unwrap_or(json!(0)),
"width": o.get("width").cloned().unwrap_or(json!(0)),
"height": o.get("height").cloned().unwrap_or(json!(0)),
})
})
.collect();
let at_cap = count >= 8;
Ok(json!({
"count": count,
"at_cap": at_cap,
"cap_warning": if at_cap {
Some("overlay cap reached (8); overlay_set and overlay_set_pixel will fail silently")
} else {
None
},
"overlays": overlays
}))
}
pub fn handle_overlay_clear<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
McpError::Protocol("overlay_clear requires a non-empty 'name' argument".into())
})?;
let safe_name = name.replace('\\', "\\\\").replace('\'', "\\'");
let code = format!("print(overlay_clear('{safe_name}'))");
let resp = exec_with_cleanup(port, &code, "overlay_clear")?;
let trimmed = resp.stdout.trim();
let removed = match trimmed {
"1" | "True" => true,
"0" | "False" => false,
other => {
return Err(McpError::Protocol(format!(
"overlay_clear: unexpected device response: '{other}'"
)));
}
};
Ok(json!({ "removed": removed }))
}
pub fn handle_overlay_clear_all<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
let code = "overlay_clear_all()";
exec_with_cleanup(port, code, "overlay_clear_all")?;
Ok(json!({ "cleared": true }))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::io::{self, Read, Write};
struct MockPort {
read_data: VecDeque<u8>,
pub write_data: Vec<u8>,
}
impl MockPort {
fn with_responses(responses: &[&[u8]]) -> Self {
let mut buf = Vec::new();
for r in responses {
buf.extend_from_slice(r);
}
MockPort {
read_data: VecDeque::from(buf),
write_data: Vec::new(),
}
}
fn ok_frame() -> Vec<u8> {
b"OK\x04\x04>".to_vec()
}
fn ok_with_stdout(line: &str) -> Vec<u8> {
let mut v = b"OK".to_vec();
v.extend_from_slice(line.as_bytes());
v.push(b'\n');
v.extend_from_slice(b"\x04\x04>");
v
}
fn error_frame(msg: &str) -> Vec<u8> {
let mut v = b"OK\x04".to_vec();
v.extend_from_slice(msg.as_bytes());
v.push(b'\n');
v.push(b'\x04');
v.push(b'>');
v
}
}
impl Read for MockPort {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = buf.len().min(self.read_data.len());
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"MockPort: no more scripted bytes",
));
}
for (dst, src) in buf[..n].iter_mut().zip(self.read_data.drain(..n)) {
*dst = src;
}
Ok(n)
}
}
impl Write for MockPort {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.write_data.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn all_descriptors_have_correct_names() {
let descs = descriptors();
let names: Vec<&str> = descs.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"overlay_serialize"));
assert!(names.contains(&"overlay_list"));
assert!(names.contains(&"overlay_clear"));
assert!(names.contains(&"overlay_clear_all"));
assert_eq!(descs.len(), 4);
}
#[test]
fn all_descriptors_have_object_schema_with_additional_properties_false() {
for d in descriptors() {
assert!(
matches!(d.input_schema, Value::Object(_)),
"descriptor '{}' must have object input_schema",
d.name
);
assert_eq!(
d.input_schema.get("additionalProperties"),
Some(&Value::Bool(false)),
"descriptor '{}' must have additionalProperties=false",
d.name
);
}
}
#[test]
fn overlay_clear_descriptor_requires_name_field() {
let d = overlay_clear_descriptor();
let required = d.input_schema.get("required").unwrap();
let arr = required.as_array().unwrap();
assert!(
arr.iter().any(|v| v.as_str() == Some("name")),
"overlay_clear descriptor must require 'name'"
);
}
#[test]
fn overlay_serialize_happy_path_returns_parsed_json() {
let payload = r#"{ "overlays": [{"name":"test","row":0,"col":0,"width":5,"height":3,"colors":["FF0000","000000"]}
]}"#;
let frame = MockPort::ok_with_stdout(payload);
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_overlay_serialize(&mut port).unwrap();
assert!(result.get("overlays").is_some());
let overlays = result["overlays"].as_array().unwrap();
assert_eq!(overlays.len(), 1);
assert_eq!(overlays[0]["name"], "test");
}
#[test]
fn overlay_serialize_empty_returns_empty_array() {
let payload = "{ \"overlays\": [\n ]}";
let frame = MockPort::ok_with_stdout(payload);
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_overlay_serialize(&mut port).unwrap();
let overlays = result["overlays"].as_array().unwrap();
assert!(overlays.is_empty());
}
#[test]
fn overlay_serialize_warns_at_cap() {
let overlay_entries = (0..7)
.map(|i| {
format!(r#"{{"name":"o{i}","row":0,"col":0,"width":1,"height":1,"colors":[]}}"#)
})
.collect::<Vec<_>>()
.join(",");
let payload = format!("{{ \"overlays\": [{overlay_entries}\n ]}}");
let frame = MockPort::ok_with_stdout(&payload);
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_overlay_serialize(&mut port).unwrap();
assert!(
result.get("warning").and_then(|v| v.as_str()).is_some(),
"should warn when count >= 7"
);
}
#[test]
fn overlay_serialize_truncated_returns_err() {
let frame = MockPort::ok_with_stdout("not-valid-json");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_overlay_serialize(&mut port);
assert!(result.is_err(), "truncated/non-JSON output must Err");
match result.unwrap_err() {
McpError::Protocol(msg) => assert!(
msg.contains("non-JSON") || msg.contains("truncation"),
"error must mention non-JSON/truncation; got: {msg}"
),
other => panic!("expected Protocol err, got: {other:?}"),
}
}
#[test]
fn overlay_serialize_firmware_fragment_shape_regression() {
let firmware_fragment = " \"overlays\": [\n {\"name\":\"bracket\",\"row\":0,\"col\":0,\"width\":2,\"height\":2,\"colors\":[\"FF0000\",\"00FF00\",\"0000FF\",\"FFFFFF\"]}\n ]";
let payload = format!("{{{firmware_fragment}}}");
let frame = MockPort::ok_with_stdout(&payload);
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_overlay_serialize(&mut port).unwrap();
let overlays = result["overlays"].as_array().unwrap();
assert_eq!(overlays.len(), 1, "must parse firmware fragment correctly");
assert_eq!(overlays[0]["name"], "bracket");
}
#[test]
fn overlay_serialize_truncated_buffer_regression() {
let truncated = "{ \"overlays\": [{\"name\":\"partial\",\"row\":0,\"col\":0,\"wid";
let frame = MockPort::ok_with_stdout(truncated);
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_overlay_serialize(&mut port);
assert!(
result.is_err(),
"truncated output must Err — not silently degrade"
);
}
#[test]
fn overlay_serialize_missing_overlays_key_returns_err() {
let frame = MockPort::ok_with_stdout("{\"something_else\": []}");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_overlay_serialize(&mut port);
assert!(
result.is_err(),
"JSON missing 'overlays' key must Err — schema violation"
);
match result.unwrap_err() {
McpError::Protocol(msg) => assert!(
msg.contains("missing 'overlays' key") || msg.contains("overlays"),
"error must mention missing key; got: {msg}"
),
other => panic!("expected Protocol err, got: {other:?}"),
}
}
#[test]
fn overlay_serialize_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: overlay_serialize");
let mut port = MockPort::with_responses(&[&err]);
let result = handle_overlay_serialize(&mut port);
assert!(result.is_err(), "device exception must propagate as Err");
assert!(
port.write_data.contains(&0x03),
"Ctrl-C must be sent on error"
);
}
#[test]
fn overlay_list_happy_path_returns_count_and_names() {
let count_frame = MockPort::ok_with_stdout("2");
let payload = "{ \"overlays\": [\n {\"name\":\"alpha\",\"row\":0,\"col\":0,\"width\":3,\"height\":2,\"colors\":[]},\n {\"name\":\"beta\",\"row\":1,\"col\":1,\"width\":2,\"height\":2,\"colors\":[]}\n ]}";
let serialize_frame = MockPort::ok_with_stdout(payload);
let mut port = MockPort::with_responses(&[&count_frame, &serialize_frame]);
let result = handle_overlay_list(&mut port).unwrap();
assert_eq!(result["count"], 2);
let overlays = result["overlays"].as_array().unwrap();
assert_eq!(overlays.len(), 2);
assert_eq!(overlays[0]["name"], "alpha");
}
#[test]
fn overlay_list_at_cap_sets_flag() {
let count_frame = MockPort::ok_with_stdout("8");
let overlay_entries = (0..8)
.map(|i| {
format!(r#"{{"name":"o{i}","row":0,"col":0,"width":1,"height":1,"colors":[]}}"#)
})
.collect::<Vec<_>>()
.join(",");
let payload = format!("{{ \"overlays\": [{overlay_entries}\n ]}}");
let serialize_frame = MockPort::ok_with_stdout(&payload);
let mut port = MockPort::with_responses(&[&count_frame, &serialize_frame]);
let result = handle_overlay_list(&mut port).unwrap();
assert_eq!(result["at_cap"], true);
}
#[test]
fn overlay_clear_happy_path_found() {
let frame = MockPort::ok_with_stdout("1");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"name": "my_overlay"});
let result = handle_overlay_clear(&mut port, &args).unwrap();
assert_eq!(result["removed"], true);
}
#[test]
fn overlay_clear_not_found_returns_false() {
let frame = MockPort::ok_with_stdout("0");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"name": "nonexistent"});
let result = handle_overlay_clear(&mut port, &args).unwrap();
assert_eq!(result["removed"], false);
}
#[test]
fn overlay_clear_unexpected_response_returns_error() {
let frame = MockPort::ok_with_stdout("maybe");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"name": "test"});
let result = handle_overlay_clear(&mut port, &args);
assert!(
result.is_err(),
"unexpected device response must return Err"
);
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("unexpected"),
"error must describe unexpected value"
);
assert!(msg.contains("maybe"), "error must include actual value");
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn overlay_clear_accepts_true_as_found() {
let frame = MockPort::ok_with_stdout("True");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"name": "my_overlay"});
let result = handle_overlay_clear(&mut port, &args).unwrap();
assert_eq!(result["removed"], true);
}
#[test]
fn overlay_clear_accepts_false_as_not_found() {
let frame = MockPort::ok_with_stdout("False");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"name": "nonexistent"});
let result = handle_overlay_clear(&mut port, &args).unwrap();
assert_eq!(result["removed"], false);
}
#[test]
fn overlay_clear_missing_name_returns_error() {
let mut port = MockPort::with_responses(&[]);
let args = json!({});
let result = handle_overlay_clear(&mut port, &args);
assert!(result.is_err());
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(msg.contains("name"), "error must mention 'name' arg");
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn overlay_clear_empty_name_returns_error() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"name": ""});
let result = handle_overlay_clear(&mut port, &args);
assert!(result.is_err());
}
#[test]
fn overlay_clear_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: overlay_clear");
let mut port = MockPort::with_responses(&[&err]);
let args = json!({"name": "test"});
let result = handle_overlay_clear(&mut port, &args);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
#[test]
fn overlay_clear_all_happy_path() {
let frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_overlay_clear_all(&mut port).unwrap();
assert_eq!(result["cleared"], true);
}
#[test]
fn overlay_clear_all_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: overlay_clear_all");
let mut port = MockPort::with_responses(&[&err]);
let result = handle_overlay_clear_all(&mut port);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
}