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 slot_has_changes_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"slot_has_changes",
"Check whether the current Jumperless V5 state differs from the Python-session-entry \
backup. NOTE: always returns false if no Python session backup exists \
(e.g., called outside a Python session or at boot). \
Returns {\"has_changes\": true|false}.",
no_args(),
2_000,
)
}
pub fn slot_save_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"slot_save",
"Save the complete Jumperless V5 state (connections, nets, power, config, AND overlays) \
to a slot file. Slot -1 (default) saves to the currently active slot. Valid explicit \
slots: 0-7. IMPORTANT: this tool pauses Core2 internally during the filesystem write \
(~50-200ms); do NOT wrap it in jumperless.core.pause — that would double-pause. \
Bypasses the 2-second autosave timer for guaranteed immediate persistence. \
Returns {\"saved_to_slot\": int, \"pause_was_applied_by_firmware\": true, \"do_not_wrap_in_core_pause\": true}.",
json!({
"type": "object",
"properties": {
"slot": {
"type": "integer",
"minimum": -1,
"maximum": 7,
"description": "Slot to save to. -1 = current active slot (default). Valid: -1 or 0-7."
}
},
"additionalProperties": false
}),
3_000,
)
}
pub fn slot_load_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"slot_load",
"Load the Jumperless V5 state from a slot file (connections, nets, power, config, and \
overlays). Slot must be 0-7. The device will immediately switch to the specified slot, \
replacing the current live state. NOTE: this is the same underlying primitive as manually \
rotating slots on the device — it does NOT create a backup first. If the target slot \
is empty, the board will clear to a default state. \
Returns {\"loaded_from_slot\": int}.",
json!({
"type": "object",
"properties": {
"slot": {
"type": "integer",
"minimum": 0,
"maximum": 7,
"description": "Slot to load from. Required. Valid: 0-7."
}
},
"required": ["slot"],
"additionalProperties": false
}),
2_000,
)
}
pub fn slot_get_current_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"slot_get_current",
"Return the currently active slot number on the Jumperless V5 (0-7). Reads the \
CURRENT_SLOT firmware constant — this is the slot whose state is presently loaded in \
memory. Useful before a slot_load call to record where you are, or after a slot_save \
call to confirm the active slot. Returns {\"current_slot\": int}.",
no_args(),
1_000,
)
}
pub fn slot_discard_descriptor() -> ToolDescriptor {
ToolDescriptor::with_timeout(
"slot_discard",
"DESTRUCTIVE: Revert the Jumperless V5 state to the Python-session-entry backup AND \
immediately save the reverted state to disk. This is NOT a soft undo — it overwrites \
the slot file with the pre-session state. If no backup exists (boot-time script, or \
never entered Python normally), returns {\"discarded\": false, \"reason\": str} instead \
of performing a silent no-op. After discard, slot_has_changes() will return false. \
Returns {\"discarded\": true} on success.",
no_args(),
3_000,
)
}
pub fn descriptors() -> Vec<ToolDescriptor> {
vec![
slot_has_changes_descriptor(),
slot_save_descriptor(),
slot_discard_descriptor(),
slot_load_descriptor(),
slot_get_current_descriptor(),
]
}
pub fn handle_slot_has_changes<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
let code = "print(nodes_has_changes())";
let resp = exec_with_cleanup(port, code, "slot_has_changes")?;
let trimmed = resp.stdout.trim();
let has_changes = match trimmed {
"True" => true,
"False" => false,
other => {
return Err(McpError::Protocol(format!(
"slot_has_changes: unexpected device response: '{other}'"
)));
}
};
Ok(json!({ "has_changes": has_changes }))
}
pub fn handle_slot_save<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let slot = match args.get("slot") {
Some(v) => {
let n = v
.as_i64()
.ok_or_else(|| McpError::Protocol("slot must be an integer".into()))?;
if n != -1 && !(0..=7).contains(&n) {
return Err(McpError::Protocol(format!(
"slot must be -1 (current) or 0-7; got {n}"
)));
}
n
}
None => -1,
};
let code = format!("print(nodes_save({slot}))");
let resp = exec_with_cleanup(port, &code, "slot_save")?;
let saved_to: i64 = resp.stdout.trim().parse().map_err(|_| {
McpError::Protocol(format!(
"slot_save: unexpected response: '{}'",
resp.stdout.trim()
))
})?;
Ok(json!({
"saved_to_slot": saved_to,
"pause_was_applied_by_firmware": true,
"do_not_wrap_in_core_pause": true
}))
}
pub fn handle_slot_discard<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
let check_code = "print(nodes_has_changes())";
let check_resp = exec_with_cleanup(port, check_code, "slot_discard:has_changes")?;
let trimmed = check_resp.stdout.trim();
let has_backup = match trimmed {
"True" => true,
"False" => false,
other => {
return Err(McpError::Protocol(format!(
"slot_discard: unexpected response from nodes_has_changes(): '{other}'"
)));
}
};
if !has_backup {
return Ok(json!({
"discarded": false,
"reason": "no backup available; not entered via Python session (boot-time script or called outside a Python session)"
}));
}
let code = "nodes_discard()";
exec_with_cleanup(port, code, "slot_discard")?;
Ok(json!({ "discarded": true }))
}
pub fn handle_slot_load<P: Read + Write + ?Sized>(
port: &mut P,
args: &Value,
) -> Result<Value, McpError> {
let slot = match args.get("slot") {
Some(v) => {
let n = v
.as_i64()
.ok_or_else(|| McpError::Protocol("slot must be an integer".into()))?;
if !(0..=7).contains(&n) {
return Err(McpError::Protocol(format!("slot must be 0-7; got {n}")));
}
n
}
None => {
return Err(McpError::Protocol(
"slot_load: 'slot' argument is required".into(),
));
}
};
let code = format!("print(switch_slot({slot}))");
let resp = exec_with_cleanup(port, &code, "slot_load")?;
let _prev_slot: i64 = resp.stdout.trim().parse().map_err(|_| {
McpError::Protocol(format!(
"slot_load: unexpected device response: '{}'",
resp.stdout.trim()
))
})?;
Ok(json!({ "loaded_from_slot": slot }))
}
pub fn handle_slot_get_current<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
let code = "print(CURRENT_SLOT())";
let resp = exec_with_cleanup(port, code, "slot_get_current")?;
let current: i64 = resp.stdout.trim().parse().map_err(|_| {
McpError::Protocol(format!(
"slot_get_current: unexpected device response: '{}'",
resp.stdout.trim()
))
})?;
Ok(json!({ "current_slot": current }))
}
#[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(&"slot_has_changes"));
assert!(names.contains(&"slot_save"));
assert!(names.contains(&"slot_discard"));
assert!(names.contains(&"slot_load"));
assert!(names.contains(&"slot_get_current"));
assert_eq!(descs.len(), 5);
}
#[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 slot_discard_description_mentions_destructive() {
let d = slot_discard_descriptor();
assert!(
d.description.to_uppercase().contains("DESTRUCTIVE"),
"slot_discard description must warn DESTRUCTIVE"
);
}
#[test]
fn slot_save_description_warns_about_double_pause() {
let d = slot_save_descriptor();
assert!(
d.description.contains("do NOT wrap") || d.description.contains("double-pause"),
"slot_save description must warn against double-pause"
);
}
#[test]
fn slot_has_changes_returns_true_when_device_says_true() {
let frame = MockPort::ok_with_stdout("True");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_slot_has_changes(&mut port).unwrap();
assert_eq!(result["has_changes"], true);
}
#[test]
fn slot_has_changes_returns_false_when_device_says_false() {
let frame = MockPort::ok_with_stdout("False");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_slot_has_changes(&mut port).unwrap();
assert_eq!(result["has_changes"], false);
}
#[test]
fn slot_has_changes_unexpected_value_returns_error() {
let frame = MockPort::ok_with_stdout("maybe");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_slot_has_changes(&mut port);
assert!(result.is_err(), "unexpected device output must return Err");
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("unexpected"),
"error must describe the unexpected value; got: {msg}"
);
assert!(
msg.contains("maybe"),
"error must include the actual value; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn slot_has_changes_empty_response_returns_error() {
let frame = MockPort::ok_with_stdout("");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_slot_has_changes(&mut port);
assert!(
result.is_err(),
"empty device output must return Err, not silent false"
);
}
#[test]
fn slot_has_changes_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: nodes_has_changes");
let mut port = MockPort::with_responses(&[&err]);
let result = handle_slot_has_changes(&mut port);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
#[test]
fn slot_save_default_slot_returns_saved_slot_with_pause_fields() {
let frame = MockPort::ok_with_stdout("3");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({});
let result = handle_slot_save(&mut port, &args).unwrap();
assert_eq!(result["saved_to_slot"], 3);
assert_eq!(
result["pause_was_applied_by_firmware"], true,
"must indicate firmware applied pause internally"
);
assert_eq!(
result["do_not_wrap_in_core_pause"], true,
"must warn against wrapping in core_pause"
);
}
#[test]
fn slot_save_explicit_slot_0() {
let frame = MockPort::ok_with_stdout("0");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"slot": 0});
let result = handle_slot_save(&mut port, &args).unwrap();
assert_eq!(result["saved_to_slot"], 0);
assert_eq!(result["pause_was_applied_by_firmware"], true);
assert_eq!(result["do_not_wrap_in_core_pause"], true);
}
#[test]
fn slot_save_rejects_invalid_slot() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"slot": 99});
let result = handle_slot_save(&mut port, &args);
assert!(result.is_err());
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(msg.contains("slot"), "error must mention 'slot'");
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn slot_save_rejects_invalid_slot_8() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"slot": 8});
let result = handle_slot_save(&mut port, &args);
assert!(result.is_err());
}
#[test]
fn slot_save_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("IOError: flash write failed");
let mut port = MockPort::with_responses(&[&err]);
let args = json!({});
let result = handle_slot_save(&mut port, &args);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
#[test]
fn slot_discard_happy_path_with_backup() {
let has_changes_frame = MockPort::ok_with_stdout("True");
let discard_frame = MockPort::ok_frame();
let mut port = MockPort::with_responses(&[&has_changes_frame, &discard_frame]);
let result = handle_slot_discard(&mut port).unwrap();
assert_eq!(result["discarded"], true);
}
#[test]
fn slot_discard_no_backup_returns_discarded_false() {
let has_changes_frame = MockPort::ok_with_stdout("False");
let mut port = MockPort::with_responses(&[&has_changes_frame]);
let result = handle_slot_discard(&mut port).unwrap();
assert_eq!(
result["discarded"], false,
"no-backup path must return discarded:false"
);
assert!(
result.get("reason").and_then(|v| v.as_str()).is_some(),
"no-backup response must include a 'reason' field"
);
}
#[test]
fn slot_discard_has_changes_unexpected_value_returns_error() {
let has_changes_frame = MockPort::ok_with_stdout("unexpected");
let mut port = MockPort::with_responses(&[&has_changes_frame]);
let result = handle_slot_discard(&mut port);
assert!(
result.is_err(),
"unexpected has_changes value must return Err"
);
}
#[test]
fn slot_discard_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: nodes_has_changes");
let mut port = MockPort::with_responses(&[&err]);
let result = handle_slot_discard(&mut port);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
#[test]
fn slot_discard_discard_call_error_sends_ctrl_c() {
let has_changes_frame = MockPort::ok_with_stdout("True");
let err = MockPort::error_frame("NameError: nodes_discard");
let mut port = MockPort::with_responses(&[&has_changes_frame, &err]);
let result = handle_slot_discard(&mut port);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
#[test]
fn descriptors_includes_slot_load_and_slot_get_current() {
let descs = descriptors();
let names: Vec<&str> = descs.iter().map(|d| d.name.as_str()).collect();
assert!(
names.contains(&"slot_load"),
"descriptors() must include slot_load; got: {names:?}"
);
assert!(
names.contains(&"slot_get_current"),
"descriptors() must include slot_get_current; got: {names:?}"
);
assert_eq!(
descs.len(),
5,
"descriptors() must return 5 entries (slot_has_changes, slot_save, slot_discard, slot_load, slot_get_current)"
);
}
#[test]
fn slot_load_happy_path_returns_loaded_from_slot() {
let frame = MockPort::ok_with_stdout("3");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"slot": 2});
let result = handle_slot_load(&mut port, &args).unwrap();
assert_eq!(
result["loaded_from_slot"], 2,
"loaded_from_slot must reflect the requested slot, not the previous one"
);
}
#[test]
fn slot_load_slot_zero_accepted() {
let frame = MockPort::ok_with_stdout("5");
let mut port = MockPort::with_responses(&[&frame]);
let args = json!({"slot": 0});
let result = handle_slot_load(&mut port, &args).unwrap();
assert_eq!(result["loaded_from_slot"], 0);
}
#[test]
fn slot_load_rejects_slot_minus_one() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"slot": -1});
let result = handle_slot_load(&mut port, &args);
assert!(result.is_err(), "slot -1 must be rejected by slot_load");
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(msg.contains("slot"), "error must mention 'slot'");
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn slot_load_rejects_slot_8() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"slot": 8});
let result = handle_slot_load(&mut port, &args);
assert!(result.is_err(), "slot 8 must be rejected");
}
#[test]
fn slot_load_rejects_non_integer_slot() {
let mut port = MockPort::with_responses(&[]);
let args = json!({"slot": "two"});
let result = handle_slot_load(&mut port, &args);
assert!(result.is_err(), "non-integer slot must be rejected");
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("integer") || msg.contains("slot"),
"error must be descriptive; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn slot_load_missing_arg_returns_error() {
let mut port = MockPort::with_responses(&[]);
let args = json!({});
let result = handle_slot_load(&mut port, &args);
assert!(result.is_err(), "missing 'slot' arg must return Err");
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("required") || msg.contains("slot"),
"error must describe the missing field; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn slot_load_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("RuntimeError: switch_slot failed");
let mut port = MockPort::with_responses(&[&err]);
let args = json!({"slot": 1});
let result = handle_slot_load(&mut port, &args);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
#[test]
fn slot_get_current_happy_path_returns_current_slot() {
let frame = MockPort::ok_with_stdout("4");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_slot_get_current(&mut port).unwrap();
assert_eq!(result["current_slot"], 4);
}
#[test]
fn slot_get_current_slot_zero() {
let frame = MockPort::ok_with_stdout("0");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_slot_get_current(&mut port).unwrap();
assert_eq!(result["current_slot"], 0);
}
#[test]
fn slot_get_current_malformed_response_returns_error() {
let frame = MockPort::ok_with_stdout("???");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_slot_get_current(&mut port);
assert!(
result.is_err(),
"non-integer CURRENT_SLOT response must return Err"
);
match result.unwrap_err() {
McpError::Protocol(msg) => {
assert!(
msg.contains("unexpected"),
"error must mention 'unexpected'; got: {msg}"
);
}
other => panic!("expected McpError::Protocol, got: {other:?}"),
}
}
#[test]
fn slot_get_current_empty_response_returns_error() {
let frame = MockPort::ok_with_stdout("");
let mut port = MockPort::with_responses(&[&frame]);
let result = handle_slot_get_current(&mut port);
assert!(
result.is_err(),
"empty CURRENT_SLOT response must return Err"
);
}
#[test]
fn slot_get_current_device_error_sends_ctrl_c() {
let err = MockPort::error_frame("NameError: CURRENT_SLOT");
let mut port = MockPort::with_responses(&[&err]);
let result = handle_slot_get_current(&mut port);
assert!(result.is_err());
assert!(port.write_data.contains(&0x03));
}
}