//! `NmxOperationStatusMessage` — completion / status-word frames. //! //! Direct port of `src/MxNativeCodec/NmxOperationStatusMessage.cs`. //! //! Two on-the-wire shapes are recognised by the inner-body parser: //! //! 1. **5-byte status-word frame** — `00 00 SS SS CC` where `SS SS` is a u16 //! LE status code and `CC` is a completion code. The single proven mapping //! is `00 00 50 80 00` → [`MxStatus::WRITE_COMPLETE_OK`] //! (`NmxOperationStatusMessage.cs:48-62`, //! `design/40-protocol-invariants.md:346`). //! 2. **1-byte completion-only frame** — a single byte `CC`. Three values are //! observed in the wild (`0x00`, `0x41`, `0xEF`) but the byte→status //! mapping is unproven; they are preserved verbatim per //! `design/70-risks-and-open-questions.md` R3/R4 and //! `NmxOperationStatusMessage.cs:36-46,69-76`. //! //! The .NET reference also exposes `TryParseProcessDataReceivedBody`, which //! peels an outer `NmxObservedEnvelope` before delegating to the inner-body //! parser. The outer envelope codec has not yet been ported to Rust; only //! [`NmxOperationStatusMessage::try_parse_inner`] is provided here. When //! `NmxObservedEnvelope` lands, add `try_parse_process_data_received_body` as //! a thin wrapper. // Direct byte indexing — see reference_handle.rs for rationale. #![allow(clippy::indexing_slicing)] use crate::error::CodecError; use crate::status::{MxStatus, MxStatusCategory, MxStatusSource}; /// Which of the two recognised inner-frame shapes was decoded /// (`NmxOperationStatusMessage.cs:3-7`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum NmxOperationStatusFormat { /// Single-byte completion frame (`NmxOperationStatusMessage.cs:5,36-46`). CompletionOnly, /// 5-byte `00 00 SS SS CC` status-word frame /// (`NmxOperationStatusMessage.cs:6,48-62`). StatusWord, } /// Decoded operation-status frame /// (`NmxOperationStatusMessage.cs:9-15` — record fields). /// /// The four payload fields preserve the raw on-wire bytes; [`Self::status`] /// carries the promoted [`MxStatus`] when a known mapping exists, or an /// unpromoted placeholder otherwise (see `CompletionOnly` and the fallback /// branch of `StatusWord`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NmxOperationStatusMessage { /// Which inner frame shape was observed. pub format: NmxOperationStatusFormat, /// First byte of the 5-byte frame (`NmxOperationStatusMessage.cs:54`). /// `0` for `CompletionOnly` frames. pub command: u8, /// `inner[2..4]` u16 LE for `StatusWord` (`NmxOperationStatusMessage.cs:50`). /// `0` for `CompletionOnly` frames. pub status_code: u16, /// Completion byte. `inner[0]` for `CompletionOnly` /// (`NmxOperationStatusMessage.cs:38`); `inner[4]` for `StatusWord` /// (`NmxOperationStatusMessage.cs:51`). pub completion_code: u8, /// Promoted status. The only proven promotion is /// `status_code == 0x8050 && completion_code == 0x00 → WRITE_COMPLETE_OK` /// (`NmxOperationStatusMessage.cs:57`, /// `design/40-protocol-invariants.md:346`). Every other shape is wrapped /// in an `Unknown`/`Unknown` placeholder with the raw byte preserved in /// `detail`. pub status: MxStatus, } impl NmxOperationStatusMessage { /// `true` for the proven `00 00 50 80 00` frame /// (`NmxOperationStatusMessage.cs:16-18`). pub fn is_mx_access_write_complete(&self) -> bool { self.format == NmxOperationStatusFormat::StatusWord && self.status_code == 0x8050 && self.completion_code == 0x00 } /// Parse an inner body — either 1 byte (`CompletionOnly`) or 5 bytes /// (`StatusWord` with leading `00 00`). /// /// Mirrors `NmxOperationStatusMessage.TryParseInner` /// (`NmxOperationStatusMessage.cs:34-67`). /// /// # Errors /// /// Returns [`CodecError::ShortRead`] when the buffer length matches no /// recognised shape. The .NET reference returns `false` and a `null!` /// out-param; the Rust port surfaces the failure as a typed error so /// callers can distinguish "not an operation-status frame" from /// "successfully parsed". Match on the error to mirror the bool API. pub fn try_parse_inner(inner: &[u8]) -> Result { if inner.len() == 1 { // CompletionOnly — `NmxOperationStatusMessage.cs:36-46`. let completion_code = inner[0]; return Ok(Self { format: NmxOperationStatusFormat::CompletionOnly, command: 0, status_code: 0, completion_code, status: create_unpromoted_completion_status(completion_code), }); } if inner.len() == 5 && inner[0] == 0x00 && inner[1] == 0x00 { // StatusWord — `NmxOperationStatusMessage.cs:48-62`. let status_code = u16::from(inner[2]) | (u16::from(inner[3]) << 8); let completion_code = inner[4]; // Only the (0x8050, 0x00) shape is promoted to a typed status. // Every other (status_code, completion_code) pair is preserved as // an Unknown/Unknown placeholder with the raw byte in `detail`, // mirroring `NmxOperationStatusMessage.cs:57-61`. // // The .NET fallback packs `detail` as: // completion_code == 0x00 ? (short)status_code : completion_code // We replicate the same selection here, including the // `unchecked((short)statusCode)` reinterpretation (i.e. the u16's // bit pattern as i16). let status = if status_code == 0x8050 && completion_code == 0x00 { MxStatus::WRITE_COMPLETE_OK } else { let detail = if completion_code == 0x00 { // Reinterpret the u16 status_code as i16 (two's complement). status_code as i16 } else { i16::from(completion_code) }; MxStatus { success: 0, category: MxStatusCategory::Unknown, detected_by: MxStatusSource::Unknown, detail, } }; return Ok(Self { format: NmxOperationStatusFormat::StatusWord, command: inner[0], status_code, completion_code, status, }); } Err(CodecError::ShortRead { // 1 or 5 are the two valid lengths; report the smaller for the // diagnostic. Callers that need the strict bool API should pattern // match on `Err(_) => false`. expected: 1, actual: inner.len(), }) } } /// Build the unpromoted placeholder status used by `CompletionOnly` frames /// (`NmxOperationStatusMessage.cs:69-76`). fn create_unpromoted_completion_status(completion_code: u8) -> MxStatus { MxStatus { success: 0, category: MxStatusCategory::Unknown, detected_by: MxStatusSource::Unknown, detail: i16::from(completion_code), } } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] mod tests { use super::*; #[test] fn write_complete_ok_frame() { // The proven 5-byte mapping (`design/40-protocol-invariants.md:346`). let frame = [0x00, 0x00, 0x50, 0x80, 0x00]; let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap(); assert_eq!(msg.format, NmxOperationStatusFormat::StatusWord); assert_eq!(msg.command, 0x00); assert_eq!(msg.status_code, 0x8050); assert_eq!(msg.completion_code, 0x00); assert_eq!(msg.status, MxStatus::WRITE_COMPLETE_OK); assert!(msg.is_mx_access_write_complete()); } #[test] fn status_word_unknown_with_completion_zero_packs_status_code_as_i16() { // status_code 0x8051, completion 0x00 — not the proven mapping; falls // through to the unpromoted branch with detail = (i16)0x8051 = -32687. // Mirrors `NmxOperationStatusMessage.cs:61` (`unchecked((short)statusCode)`). let frame = [0x00, 0x00, 0x51, 0x80, 0x00]; let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap(); assert_eq!(msg.format, NmxOperationStatusFormat::StatusWord); assert_eq!(msg.status_code, 0x8051); assert_eq!(msg.completion_code, 0x00); assert_eq!(msg.status.category, MxStatusCategory::Unknown); assert_eq!(msg.status.detected_by, MxStatusSource::Unknown); assert_eq!(msg.status.detail, 0x8051u16 as i16); assert!(!msg.is_mx_access_write_complete()); } #[test] fn status_word_unknown_with_nonzero_completion_packs_completion_in_detail() { // completion_code != 0 — detail = completion_code as i16 // (`NmxOperationStatusMessage.cs:61`). let frame = [0x00, 0x00, 0x50, 0x80, 0x42]; let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap(); assert_eq!(msg.completion_code, 0x42); assert_eq!(msg.status.detail, 0x42); assert_eq!(msg.status.category, MxStatusCategory::Unknown); assert!(!msg.is_mx_access_write_complete()); } #[test] fn completion_only_zero_byte() { // 1-byte 0x00 — preserved verbatim per design R3/R4. let frame = [0x00]; let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap(); assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly); assert_eq!(msg.command, 0); assert_eq!(msg.status_code, 0); assert_eq!(msg.completion_code, 0x00); assert_eq!(msg.status.detail, 0x00); assert_eq!(msg.status.category, MxStatusCategory::Unknown); // `CompletionOnly` is never promoted to WriteCompleteOk. assert!(!msg.is_mx_access_write_complete()); } #[test] fn completion_only_0x41_byte() { // 1-byte 0x41 — observed in the wild, mapping unproven // (`design/70-risks-and-open-questions.md` R4). let frame = [0x41]; let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap(); assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly); assert_eq!(msg.completion_code, 0x41); assert_eq!(msg.status.detail, 0x41); assert_eq!(msg.status.category, MxStatusCategory::Unknown); } #[test] fn completion_only_0xef_byte() { // 1-byte 0xEF — observed in the wild, mapping unproven // (`design/70-risks-and-open-questions.md` R4). let frame = [0xEF]; let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap(); assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly); assert_eq!(msg.completion_code, 0xEF); // 0xEF as i16 is 0x00EF (zero-extended), not -17. assert_eq!(msg.status.detail, 0xEF); } #[test] fn rejects_unknown_length() { // 0 / 2 / 3 / 4 / 6 bytes — all non-recognised shapes. for len in [0_usize, 2, 3, 4, 6, 16] { let buf = vec![0u8; len]; assert!( NmxOperationStatusMessage::try_parse_inner(&buf).is_err(), "length {len} should be rejected" ); } } #[test] fn rejects_5_byte_frame_without_leading_zeros() { // 5 bytes with non-zero leading bytes — not a StatusWord frame // (`NmxOperationStatusMessage.cs:48` requires `inner[0] == 0 && inner[1] == 0`). let frame = [0x01, 0x00, 0x50, 0x80, 0x00]; assert!(NmxOperationStatusMessage::try_parse_inner(&frame).is_err()); let frame = [0x00, 0x01, 0x50, 0x80, 0x00]; assert!(NmxOperationStatusMessage::try_parse_inner(&frame).is_err()); } #[test] fn status_code_is_little_endian() { // `inner[2..4]` is read as u16 LE — `inner[2] | (inner[3] << 8)`. // 0xAA at [2], 0xBB at [3] → 0xBBAA. let frame = [0x00, 0x00, 0xAA, 0xBB, 0x00]; let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap(); assert_eq!(msg.status_code, 0xBBAA); } }