[M2/M4] mxaccess-rpc: Guid::parse_str + dedupe examples (resolves F17)

Adds `Guid::parse_str(&str) -> Result<Guid, RpcError>` to
`crates/mxaccess-rpc/src/guid.rs` as the inverse of the existing
`Display` impl. Accepts the canonical dashed-hex form, optionally
braced (.NET `B` format), case-insensitive, and tolerant of bare
32-char hex without dashes. Single-pass char-by-char nibble accumulator
avoids per-byte string allocation; applies the same byte-swap of
groups 1-3 that the `Display` impl reads.

Eight new tests cover round-trip against the existing `Display`
fixture (`crates/mxaccess-rpc/src/guid.rs:111-119`,
`b49f92f7-c748-4169-8eca-a0670b012746`), braces, uppercase, no-dashes,
zero-GUID, too-short, too-long, and non-hex rejection.

The five live-NMX examples (`connect-write-read`, `subscribe`,
`recovery`, `multi-tag`, `secured-write`) lose their per-file 15-line
`parse_guid` helpers in favour of the canonical implementation.
`asb-subscribe` and `subscribe-buffered` are unaffected — they don't
parse GUIDs.

Test count delta: 524 → 532 (+8)
Open followups touched: F17 resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 10:18:21 -04:00
parent af939730b1
commit 48d3a9d6da
7 changed files with 149 additions and 111 deletions
+141
View File
@@ -46,6 +46,71 @@ impl Guid {
Ok(Self(out))
}
/// Parse a `12345678-1234-1234-1234-123456789012` style GUID string
/// into wire-byte form. Inverse of the [`std::fmt::Display`] impl.
///
/// Accepts the canonical dashed-hex form, optionally wrapped in
/// `{...}` braces (the .NET `B` format). Case-insensitive. The
/// first three hex groups are stored little-endian on the wire (per
/// the module docstring) so the parser byte-swaps them after the
/// raw hex pass.
///
/// There is no .NET reference to mirror here — the Display impl is
/// the spec, this is its inverse.
///
/// # Errors
/// Returns [`crate::error::RpcError::Decode`] if the input is not
/// 32 hex chars (with 4 optional dashes and optional outer braces),
/// or contains a non-hex character.
pub fn parse_str(s: &str) -> Result<Self, crate::error::RpcError> {
let trimmed = s.trim_start_matches('{').trim_end_matches('}');
// Strip dashes; everything else must be a hex digit.
let mut bytes = [0u8; 16];
let mut nibble_count = 0usize;
let mut acc: u8 = 0;
for c in trimmed.chars() {
if c == '-' {
continue;
}
let digit = match c.to_digit(16) {
Some(d) => d as u8,
None => {
return Err(crate::error::RpcError::Decode {
offset: nibble_count / 2,
reason: "non-hex character in guid",
buffer_len: trimmed.len(),
});
}
};
if nibble_count >= 32 {
return Err(crate::error::RpcError::Decode {
offset: 16,
reason: "guid hex too long",
buffer_len: trimmed.len(),
});
}
if nibble_count % 2 == 0 {
acc = digit << 4;
} else {
bytes[nibble_count / 2] = acc | digit;
}
nibble_count += 1;
}
if nibble_count != 32 {
return Err(crate::error::RpcError::Decode {
offset: nibble_count / 2,
reason: "guid hex too short",
buffer_len: trimmed.len(),
});
}
// Byte-swap the first three groups so the resulting bytes match
// the wire layout the Display impl reads.
bytes[0..4].reverse();
bytes[4..6].reverse();
bytes[6..8].reverse();
Ok(Self(bytes))
}
/// Write the 16 wire bytes into `dst[..16]`. Mirrors .NET
/// `Guid.TryWriteBytes(span)`.
///
@@ -142,4 +207,80 @@ mod tests {
"00000000-0000-0000-0000-000000000000"
);
}
#[test]
fn parse_str_round_trips_display() {
// The dashed-hex form from the display fixture above.
let g = Guid::parse_str("b49f92f7-c748-4169-8eca-a0670b012746").unwrap();
assert_eq!(
g.0,
[
0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01,
0x27, 0x46,
]
);
// Round-trip back via Display.
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
}
#[test]
fn parse_str_accepts_braces() {
// .NET "B" format wraps the dashed-hex form in `{}`.
let g = Guid::parse_str("{b49f92f7-c748-4169-8eca-a0670b012746}").unwrap();
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
}
#[test]
fn parse_str_accepts_uppercase() {
let g = Guid::parse_str("B49F92F7-C748-4169-8ECA-A0670B012746").unwrap();
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
}
#[test]
fn parse_str_accepts_no_dashes() {
let g = Guid::parse_str("b49f92f7c74841698ecaa0670b012746").unwrap();
assert_eq!(g.to_string(), "b49f92f7-c748-4169-8eca-a0670b012746");
}
#[test]
fn parse_str_round_trips_zero() {
let g = Guid::parse_str("00000000-0000-0000-0000-000000000000").unwrap();
assert_eq!(g, Guid::ZERO);
}
#[test]
fn parse_str_rejects_too_short() {
let err = Guid::parse_str("b49f92f7-c748-4169-8eca-a0670b0127").unwrap_err();
assert!(matches!(
err,
crate::error::RpcError::Decode {
reason: "guid hex too short",
..
}
));
}
#[test]
fn parse_str_rejects_too_long() {
let err = Guid::parse_str("b49f92f7-c748-4169-8eca-a0670b01274600").unwrap_err();
assert!(matches!(
err,
crate::error::RpcError::Decode {
reason: "guid hex too long",
..
}
));
}
#[test]
fn parse_str_rejects_non_hex() {
let err = Guid::parse_str("b49f92f7-c748-4169-8eca-a0670b01274z").unwrap_err();
assert!(matches!(
err,
crate::error::RpcError::Decode {
reason: "non-hex character in guid",
..
}
));
}
}