Skip to main content

zerodds_cdr/
fixed.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! IDL `fixed<P, S>` decimal type (XCDR2 §7.4.4.5).
4//!
5//! Wire format: packed binary-coded decimal (BCD), CORBA/GIOP §9.3.2.7
6//! (identical in XCDR2 §7.4.4.5).
7//! - Digit count = P, scale = S (number of digits after the decimal point).
8//! - Nibbles, big-endian: `[d0 .. d(P-1)][sign]` — P digit nibbles (most
9//!   significant first) followed by the sign nibble (`0xC` = positive/zero,
10//!   `0xD` = negative). If `P + 1` is odd, a single leading `0x0` nibble is
11//!   prepended so the total nibble count is even.
12//! - Byte count = `(P + 2) / 2` = `ceil((P + 1) / 2)`. Verified byte-exact
13//!   against JacORB 3.9 and omniORB 4.3 (CORBA vendor oracle): a `fixed<5,2>`
14//!   is 3 octets `12 34 5c`, a `fixed<4,0>` is `01 23 4c`. The leading
15//!   zero digits are kept (omniORB pad-to-P form); some ORBs (JacORB) trim
16//!   them, which the decoder also accepts.
17
18#![allow(clippy::manual_div_ceil, clippy::while_let_on_iterator)]
19
20extern crate alloc;
21use alloc::string::String;
22use alloc::vec::Vec;
23
24use crate::buffer::{BufferReader, BufferWriter};
25use crate::encode::{CdrDecode, CdrEncode};
26use crate::error::{DecodeError, EncodeError};
27
28/// IDL `fixed<P, S>` decimal with `P` total digits and `S` digits
29/// after the decimal point.
30///
31/// Stored as packed BCD bytes (XCDR2 §7.4.4.5). Pure Rust with no
32/// external crate dependency. Deliberate architectural choice: this
33/// type offers **roundtrip + string conversion**, no decimal
34/// arithmetic (add/mul). End users who need decimal arithmetic combine
35/// it with `rust_decimal` or similar via a `From` impl.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Fixed<const P: u32, const S: u32> {
38    /// Packed-BCD storage. Length `(P + 2) / 2` bytes (last
39    /// nibble = sign).
40    digits: Vec<u8>,
41}
42
43impl<const P: u32, const S: u32> Default for Fixed<P, S> {
44    fn default() -> Self {
45        let n = ((P + 2) / 2) as usize;
46        let mut digits = alloc::vec![0u8; n];
47        // Set the sign nibble to positive (0xC).
48        let last = digits.len() - 1;
49        digits[last] = 0x0C;
50        Self { digits }
51    }
52}
53
54impl<const P: u32, const S: u32> Fixed<P, S> {
55    /// Constructs a `Fixed<P, S>` from a raw BCD byte sequence.
56    ///
57    /// # Errors
58    /// `Invalid` if the byte length is not `(P + 2) / 2`.
59    pub fn from_bcd_bytes(bytes: Vec<u8>) -> Result<Self, DecodeError> {
60        let expected = ((P + 2) / 2) as usize;
61        if bytes.len() != expected {
62            return Err(DecodeError::LengthExceeded {
63                announced: bytes.len(),
64                remaining: expected,
65                offset: 0,
66            });
67        }
68        Ok(Self { digits: bytes })
69    }
70
71    /// Raw BCD bytes.
72    #[must_use]
73    pub fn as_bcd_bytes(&self) -> &[u8] {
74        &self.digits
75    }
76
77    /// Creates from a string (e.g. `"123.45"` or `"-1.5"`).
78    ///
79    /// # Errors
80    /// `Invalid` for non-numeric input or overflow against P/S.
81    pub fn from_str_repr(s: &str) -> Result<Self, DecodeError> {
82        let (sign, rest) = if let Some(stripped) = s.strip_prefix('-') {
83            (false, stripped)
84        } else if let Some(stripped) = s.strip_prefix('+') {
85            (true, stripped)
86        } else {
87            (true, s)
88        };
89        let (int_part, frac_part) = rest.split_once('.').unwrap_or((rest, ""));
90        // Trim to the P/S layout.
91        let total_p = P as usize;
92        let total_s = S as usize;
93        let mut digits_buf = String::with_capacity(total_p);
94        // Pad int_part on the left if too short.
95        let int_needed = total_p - total_s;
96        if int_part.len() > int_needed {
97            return Err(DecodeError::InvalidString {
98                offset: 0,
99                reason: "fixed: integer part exceeds P-S",
100            });
101        }
102        for _ in int_part.len()..int_needed {
103            digits_buf.push('0');
104        }
105        digits_buf.push_str(int_part);
106        // Pad frac_part on the right if too short, trim if too long.
107        if frac_part.len() > total_s {
108            return Err(DecodeError::InvalidString {
109                offset: 0,
110                reason: "fixed: fractional part exceeds S",
111            });
112        }
113        digits_buf.push_str(frac_part);
114        for _ in frac_part.len()..total_s {
115            digits_buf.push('0');
116        }
117        // Build the BCD nibble sequence big-endian: an optional leading 0
118        // pad nibble (so the total nibble count is even), the P digit
119        // nibbles most-significant first, then the sign nibble. `digits_buf`
120        // already holds exactly P digit chars. Then pack 2 nibbles per byte,
121        // high nibble first (CORBA §9.3.2.7). This replaces the old
122        // low-nibble-first loop, which dropped the most-significant digit
123        // for even P and emitted a spurious leading byte for odd P.
124        let mut nibbles: Vec<u8> = Vec::with_capacity(P as usize + 2);
125        if (P + 1) % 2 == 1 {
126            nibbles.push(0);
127        }
128        for c in digits_buf.chars() {
129            let d = c.to_digit(10).ok_or(DecodeError::InvalidString {
130                offset: 0,
131                reason: "fixed: non-digit char",
132            })? as u8;
133            nibbles.push(d & 0x0F);
134        }
135        nibbles.push(if sign { 0x0C } else { 0x0D });
136        // `nibbles.len()` is even by construction.
137        let mut packed: Vec<u8> = Vec::with_capacity(nibbles.len() / 2);
138        for pair in nibbles.chunks_exact(2) {
139            packed.push((pair[0] << 4) | pair[1]);
140        }
141        Ok(Self { digits: packed })
142    }
143
144    /// Decimal string representation (e.g. `"123.45"`).
145    #[must_use]
146    pub fn to_string_repr(&self) -> String {
147        let mut digits_chars: Vec<char> = Vec::new();
148        let mut sign = '+';
149        for (idx, byte) in self.digits.iter().enumerate() {
150            let high = (byte >> 4) & 0x0F;
151            let low = byte & 0x0F;
152            if idx == self.digits.len() - 1 {
153                digits_chars.push(char::from_digit(u32::from(high), 10).unwrap_or('?'));
154                sign = if low == 0x0D { '-' } else { '+' };
155            } else {
156                digits_chars.push(char::from_digit(u32::from(high), 10).unwrap_or('?'));
157                digits_chars.push(char::from_digit(u32::from(low), 10).unwrap_or('?'));
158            }
159        }
160        // Trim leading zeros (keep at least one digit)
161        while digits_chars.len() > (S as usize + 1) && digits_chars[0] == '0' {
162            digits_chars.remove(0);
163        }
164        // Insert the decimal point if S > 0
165        let mut out = String::new();
166        if sign == '-' {
167            out.push('-');
168        }
169        if (S as usize) > 0 {
170            let dot_pos = digits_chars.len().saturating_sub(S as usize);
171            for (i, c) in digits_chars.iter().enumerate() {
172                if i == dot_pos {
173                    out.push('.');
174                }
175                out.push(*c);
176            }
177        } else {
178            for c in &digits_chars {
179                out.push(*c);
180            }
181        }
182        out
183    }
184}
185
186impl<const P: u32, const S: u32> CdrEncode for Fixed<P, S> {
187    fn encode(&self, w: &mut BufferWriter) -> Result<(), EncodeError> {
188        // XCDR2 §7.4.4.5: raw BCD bytes, no length prefix (since the byte
189        // count is statically known via P).
190        w.write_bytes(&self.digits)
191    }
192}
193
194impl<const P: u32, const S: u32> CdrDecode for Fixed<P, S> {
195    fn decode(r: &mut BufferReader<'_>) -> Result<Self, DecodeError> {
196        let n = ((P + 2) / 2) as usize;
197        let bytes = r.read_bytes(n)?;
198        Self::from_bcd_bytes(bytes.to_vec())
199    }
200}
201
202#[cfg(test)]
203#[allow(clippy::expect_used, clippy::unwrap_used)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn fixed_default_is_zero_positive() {
209        let f: Fixed<5, 2> = Fixed::default();
210        assert_eq!(f.to_string_repr(), "0.00");
211    }
212
213    #[test]
214    fn fixed_roundtrip_via_string() {
215        let f: Fixed<5, 2> = Fixed::from_str_repr("123.45").expect("parse");
216        assert_eq!(f.to_string_repr(), "123.45");
217    }
218
219    #[test]
220    fn fixed_roundtrip_negative() {
221        let f: Fixed<6, 3> = Fixed::from_str_repr("-1.500").expect("parse");
222        let s = f.to_string_repr();
223        assert!(s.starts_with('-'));
224        assert!(s.contains("1.500") || s.contains("1.5"));
225    }
226
227    #[test]
228    fn fixed_wire_roundtrip() {
229        use crate::Endianness;
230        let f: Fixed<5, 2> = Fixed::from_str_repr("42.00").expect("parse");
231        let mut writer = BufferWriter::new(Endianness::Little);
232        f.encode(&mut writer).expect("encode");
233        let bytes = writer.into_bytes();
234        let mut reader = BufferReader::new(&bytes, Endianness::Little);
235        let back: Fixed<5, 2> = <Fixed<5, 2> as CdrDecode>::decode(&mut reader).expect("decode");
236        assert_eq!(back, f);
237    }
238
239    #[test]
240    fn fixed_overflow_returns_error() {
241        let res: Result<Fixed<3, 1>, _> = Fixed::from_str_repr("9999.5");
242        assert!(res.is_err());
243    }
244
245    // ---- CORBA vendor-oracle regression (JacORB 3.9 ≡ omniORB 4.3) ----
246
247    /// Odd P: `fixed<5,2>` is exactly 3 octets `12 34 5c` — no spurious
248    /// leading byte. (Old encoder emitted 4 octets `00 12 34 5c`.)
249    #[test]
250    fn fixed_bcd_bytes_odd_p_match_orb() {
251        let f: Fixed<5, 2> = Fixed::from_str_repr("123.45").expect("parse");
252        assert_eq!(f.as_bcd_bytes(), &[0x12, 0x34, 0x5C]);
253        assert_eq!(f.to_string_repr(), "123.45");
254    }
255
256    /// Even P: `fixed<4,0>` must keep the most-significant digit — the old
257    /// encoder dropped it, encoding 1234 as `00 23 4c` and round-tripping
258    /// to "234" (silent data corruption). Correct = `01 23 4c` → "1234".
259    #[test]
260    fn fixed_even_p_keeps_msd_no_corruption() {
261        let f: Fixed<4, 0> = Fixed::from_str_repr("1234").expect("parse");
262        assert_eq!(f.as_bcd_bytes(), &[0x01, 0x23, 0x4C]);
263        assert_eq!(f.to_string_repr(), "1234");
264    }
265
266    /// Negative, pad-to-P (omniORB form): `fixed<6,2>` of -1.50 →
267    /// `00 00 15 0d` (4 octets), the conformant P-digit representation.
268    #[test]
269    fn fixed_negative_pad_to_p_match_orb() {
270        let f: Fixed<6, 2> = Fixed::from_str_repr("-1.50").expect("parse");
271        assert_eq!(f.as_bcd_bytes(), &[0x00, 0x00, 0x15, 0x0D]);
272        assert_eq!(f.to_string_repr(), "-1.50");
273    }
274
275    /// Even P with a full digit set: `fixed<6,3>` of 123.456 → `01 23 45 6c`
276    /// (leading pad nibble), every digit preserved through roundtrip.
277    #[test]
278    fn fixed_even_p_full_digits_roundtrip() {
279        let f: Fixed<6, 3> = Fixed::from_str_repr("123.456").expect("parse");
280        assert_eq!(f.as_bcd_bytes(), &[0x01, 0x23, 0x45, 0x6C]);
281        assert_eq!(f.to_string_repr(), "123.456");
282    }
283
284    /// Exhaustive small-range roundtrip: every integer 0..=9999 through
285    /// `fixed<4,0>` must survive encode→decode-as-string unchanged (guards
286    /// against any residual nibble misplacement).
287    #[test]
288    fn fixed_exhaustive_roundtrip_4_0() {
289        for n in 0u32..=9999 {
290            let s = alloc::format!("{n}");
291            let f: Fixed<4, 0> = Fixed::from_str_repr(&s).expect("parse");
292            // to_string_repr trims leading zeros, so compare numerically.
293            assert_eq!(
294                f.to_string_repr().parse::<u32>().expect("reparse"),
295                n,
296                "roundtrip lost value for {n}"
297            );
298        }
299    }
300}