Skip to main content

gallo_mcp/
encoding.rs

1//! Byte-payload encoding and argument validation for MCP tools.
2//!
3//! Mirrors the `gallo` CLI conventions: bytes are accepted as hex strings
4//! (`"0x48,0x00"`, `"4800"`) or comma-separated `0x`/`0b`/decimal tokens, and
5//! returned as a [`Bytes`] struct carrying both a hex string and the decoded
6//! array.
7
8use serde::Serialize;
9
10/// A byte payload rendered both as a hex string and a decoded array.
11///
12/// Returned by every tool that reads bytes so an agent can reason about
13/// register values in hex while still having the raw integers.
14#[derive(Debug, Clone, Serialize, schemars::JsonSchema, PartialEq, Eq)]
15pub struct Bytes {
16    /// Comma-separated `0xNN` tokens, e.g. `"0x48,0x00"`. Empty string for no bytes.
17    pub hex: String,
18    /// Decoded byte values.
19    pub bytes: Vec<u8>,
20}
21
22impl Bytes {
23    /// Build a [`Bytes`] from a raw slice.
24    pub fn from_slice(data: &[u8]) -> Self {
25        let hex = data
26            .iter()
27            .map(|b| format!("0x{b:02X}"))
28            .collect::<Vec<_>>()
29            .join(",");
30        Self {
31            hex,
32            bytes: data.to_vec(),
33        }
34    }
35}
36
37/// Parse a single byte token: `0x`-hex, `0b`-binary, or decimal.
38pub fn parse_byte(s: &str) -> Result<u8, String> {
39    let s = s.trim();
40    let parsed = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
41        u8::from_str_radix(hex, 16)
42    } else if let Some(bin) = s.strip_prefix("0b").or_else(|| s.strip_prefix("0B")) {
43        u8::from_str_radix(bin, 2)
44    } else {
45        s.parse::<u8>()
46    };
47    parsed.map_err(|e| format!("invalid byte '{s}': {e}"))
48}
49
50/// Parse a byte payload.
51///
52/// Accepts either a comma-separated list of `parse_byte` tokens
53/// (`"0x0A,20,0xFF"`) or a bare even-length hex string (`"cc44"` / `"0xCC44"`).
54/// An empty string yields an empty vector.
55pub fn parse_bytes(s: &str) -> Result<Vec<u8>, String> {
56    let s = s.trim();
57    if s.is_empty() {
58        return Ok(Vec::new());
59    }
60    if s.contains(',') {
61        return s.split(',').map(|tok| parse_byte(tok.trim())).collect();
62    }
63    // Single token with a radix prefix -> one byte, if it is a valid single byte.
64    if (s.starts_with("0x") || s.starts_with("0X") || s.starts_with("0b") || s.starts_with("0B"))
65        && let Ok(b) = parse_byte(s)
66    {
67        return Ok(vec![b]);
68    }
69    // Bare hex string: strip an optional 0x/0X, require even length.
70    let hex = s
71        .strip_prefix("0x")
72        .or_else(|| s.strip_prefix("0X"))
73        .unwrap_or(s);
74    if !hex.len().is_multiple_of(2) {
75        return Err(format!("hex string '{s}' has an odd number of digits"));
76    }
77    (0..hex.len())
78        .step_by(2)
79        .map(|i| {
80            u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| format!("invalid hex at {i}: {e}"))
81        })
82        .collect()
83}
84
85/// Validate a 7-bit I2C address (`0x00..=0x7F`).
86pub fn validate_i2c_address(addr: u8) -> Result<u8, String> {
87    if addr > 0x7F {
88        return Err(format!(
89            "I2C address 0x{addr:02X} exceeds 7-bit range (max 0x7F)"
90        ));
91    }
92    Ok(addr)
93}
94
95/// Validate an ADC channel index (`0..=3`).
96pub fn validate_adc_channel(ch: u8) -> Result<u8, String> {
97    if ch > 3 {
98        return Err(format!("ADC channel {ch} out of range (0..=3)"));
99    }
100    Ok(ch)
101}
102
103/// Validate a non-zero timeout in milliseconds.
104pub fn validate_timeout_ms(ms: u32) -> Result<u32, String> {
105    if ms == 0 {
106        return Err("timeout_ms must be non-zero".to_string());
107    }
108    Ok(ms)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn parse_byte_accepts_hex_bin_decimal() {
117        assert_eq!(parse_byte("0x48").unwrap(), 0x48);
118        assert_eq!(parse_byte("0xab").unwrap(), 0xAB);
119        assert_eq!(parse_byte("0b10101010").unwrap(), 0xAA);
120        assert_eq!(parse_byte("20").unwrap(), 20);
121    }
122
123    #[test]
124    fn parse_byte_rejects_garbage_and_overflow() {
125        assert!(parse_byte("0xGG").is_err());
126        assert!(parse_byte("0x100").is_err());
127        assert!(parse_byte("").is_err());
128    }
129
130    #[test]
131    fn parse_bytes_accepts_comma_list_and_bare_hex() {
132        assert_eq!(parse_bytes("0x0A,20,0xFF").unwrap(), vec![0x0A, 20, 0xFF]);
133        assert_eq!(parse_bytes("cc44").unwrap(), vec![0xCC, 0x44]);
134        assert_eq!(parse_bytes("0xCC44").unwrap(), vec![0xCC, 0x44]);
135        assert_eq!(parse_bytes("").unwrap(), Vec::<u8>::new());
136    }
137
138    #[test]
139    fn parse_bytes_rejects_odd_bare_hex() {
140        assert!(parse_bytes("ccc").is_err());
141    }
142
143    #[test]
144    fn bytes_from_slice_roundtrip() {
145        let b = Bytes::from_slice(&[0x48, 0x00]);
146        assert_eq!(b.hex, "0x48,0x00");
147        assert_eq!(b.bytes, vec![0x48, 0x00]);
148    }
149
150    #[test]
151    fn bytes_empty_formats_cleanly() {
152        let b = Bytes::from_slice(&[]);
153        assert_eq!(b.hex, "");
154        assert!(b.bytes.is_empty());
155    }
156
157    #[test]
158    fn validate_i2c_address_bounds() {
159        assert_eq!(validate_i2c_address(0x48).unwrap(), 0x48);
160        assert!(validate_i2c_address(0x80).is_err());
161    }
162
163    #[test]
164    fn validate_adc_channel_bounds() {
165        for c in 0..=3u8 {
166            assert!(validate_adc_channel(c).is_ok());
167        }
168        assert!(validate_adc_channel(4).is_err());
169    }
170
171    #[test]
172    fn validate_timeout_rejects_zero() {
173        assert!(validate_timeout_ms(0).is_err());
174        assert_eq!(validate_timeout_ms(1000).unwrap(), 1000);
175    }
176}