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).
6//! - Digit-Count = P, Scale = S (Anzahl Stellen nach Komma).
7//! - Bytes = `(P + 1) / 2 + 1`. Letztes Halb-Byte ist Sign-Nibble:
8//!   `0xC` = positiv, `0xD` = negativ.
9//! - Digits werden in Big-Endian-Reihenfolge, je 2 pro Byte gepackt.
10
11#![allow(clippy::manual_div_ceil, clippy::while_let_on_iterator)]
12
13extern crate alloc;
14use alloc::string::String;
15use alloc::vec::Vec;
16
17use crate::buffer::{BufferReader, BufferWriter};
18use crate::encode::{CdrDecode, CdrEncode};
19use crate::error::{DecodeError, EncodeError};
20
21/// IDL-`fixed<P, S>`-Decimal mit `P` Gesamt-Stellen und `S` Stellen
22/// nach dem Komma.
23///
24/// Storage als Packed-BCD-Bytes (XCDR2 §7.4.4.5). Pure-Rust ohne
25/// externe Crate-Dep. Bewusste Architektur-Wahl: dieser Type bietet
26/// **Roundtrip + String-Konversion**, keine Decimal-Arithmetik
27/// (Add/Mul). End-User die Decimal-Arithmetik brauchen, kombinieren
28/// das mit `rust_decimal` o.ae. via `From`-Impl.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Fixed<const P: u32, const S: u32> {
31    /// Packed-BCD-Storage. Laenge `(P + 1) / 2 + 1` Byte (letztes
32    /// Nibble = Sign).
33    digits: Vec<u8>,
34}
35
36impl<const P: u32, const S: u32> Default for Fixed<P, S> {
37    fn default() -> Self {
38        let n = ((P + 1) / 2 + 1) as usize;
39        let mut digits = alloc::vec![0u8; n];
40        // Sign-Nibble auf positiv (0xC) setzen.
41        let last = digits.len() - 1;
42        digits[last] = 0x0C;
43        Self { digits }
44    }
45}
46
47impl<const P: u32, const S: u32> Fixed<P, S> {
48    /// Konstruiert eine `Fixed<P, S>` aus einer Roh-BCD-Bytes-Sequenz.
49    ///
50    /// # Errors
51    /// `Invalid` wenn die Bytes-Laenge nicht `(P + 1) / 2 + 1` ist.
52    pub fn from_bcd_bytes(bytes: Vec<u8>) -> Result<Self, DecodeError> {
53        let expected = ((P + 1) / 2 + 1) as usize;
54        if bytes.len() != expected {
55            return Err(DecodeError::LengthExceeded {
56                announced: bytes.len(),
57                remaining: expected,
58                offset: 0,
59            });
60        }
61        Ok(Self { digits: bytes })
62    }
63
64    /// Roh-BCD-Bytes.
65    #[must_use]
66    pub fn as_bcd_bytes(&self) -> &[u8] {
67        &self.digits
68    }
69
70    /// Erzeugt aus String (z.B. `"123.45"` oder `"-1.5"`).
71    ///
72    /// # Errors
73    /// `Invalid` bei nicht-numerischen Eingaben oder Overflow gegen P/S.
74    pub fn from_str_repr(s: &str) -> Result<Self, DecodeError> {
75        let (sign, rest) = if let Some(stripped) = s.strip_prefix('-') {
76            (false, stripped)
77        } else if let Some(stripped) = s.strip_prefix('+') {
78            (true, stripped)
79        } else {
80            (true, s)
81        };
82        let (int_part, frac_part) = rest.split_once('.').unwrap_or((rest, ""));
83        // Trimme zu P/S-Layout.
84        let total_p = P as usize;
85        let total_s = S as usize;
86        let mut digits_buf = String::with_capacity(total_p);
87        // Pad int_part links wenn zu kurz.
88        let int_needed = total_p - total_s;
89        if int_part.len() > int_needed {
90            return Err(DecodeError::InvalidString {
91                offset: 0,
92                reason: "fixed: integer part exceeds P-S",
93            });
94        }
95        for _ in int_part.len()..int_needed {
96            digits_buf.push('0');
97        }
98        digits_buf.push_str(int_part);
99        // Pad frac_part rechts wenn zu kurz, trim wenn zu lang.
100        if frac_part.len() > total_s {
101            return Err(DecodeError::InvalidString {
102                offset: 0,
103                reason: "fixed: fractional part exceeds S",
104            });
105        }
106        digits_buf.push_str(frac_part);
107        for _ in frac_part.len()..total_s {
108            digits_buf.push('0');
109        }
110        // Parse digits + pack BCD
111        let mut packed: Vec<u8> = Vec::with_capacity(((P + 1) / 2 + 1) as usize);
112        let mut chars = digits_buf.chars().rev().peekable();
113        let sign_nibble: u8 = if sign { 0x0C } else { 0x0D };
114        let mut current = sign_nibble;
115        let mut have_low = true;
116        while let Some(c) = chars.next() {
117            let d = c.to_digit(10).ok_or(DecodeError::InvalidString {
118                offset: 0,
119                reason: "fixed: non-digit char",
120            })? as u8;
121            if have_low {
122                current |= (d & 0x0F) << 4;
123                packed.push(current);
124                current = 0;
125                have_low = false;
126            } else {
127                current |= d & 0x0F;
128                have_low = true;
129            }
130        }
131        if !have_low {
132            packed.push(current);
133        }
134        packed.reverse();
135        // Sicherstellen dass Laenge stimmt
136        let expected = ((P + 1) / 2 + 1) as usize;
137        while packed.len() < expected {
138            packed.insert(0, 0);
139        }
140        Ok(Self { digits: packed })
141    }
142
143    /// Decimal-String-Repraesentation (z.B. `"123.45"`).
144    #[must_use]
145    pub fn to_string_repr(&self) -> String {
146        let mut digits_chars: Vec<char> = Vec::new();
147        let mut sign = '+';
148        for (idx, byte) in self.digits.iter().enumerate() {
149            let high = (byte >> 4) & 0x0F;
150            let low = byte & 0x0F;
151            if idx == self.digits.len() - 1 {
152                digits_chars.push(char::from_digit(u32::from(high), 10).unwrap_or('?'));
153                sign = if low == 0x0D { '-' } else { '+' };
154            } else {
155                digits_chars.push(char::from_digit(u32::from(high), 10).unwrap_or('?'));
156                digits_chars.push(char::from_digit(u32::from(low), 10).unwrap_or('?'));
157            }
158        }
159        // Trim leading zeros (mind. eine Ziffer behalten)
160        while digits_chars.len() > (S as usize + 1) && digits_chars[0] == '0' {
161            digits_chars.remove(0);
162        }
163        // Komma einfuegen falls S > 0
164        let mut out = String::new();
165        if sign == '-' {
166            out.push('-');
167        }
168        if (S as usize) > 0 {
169            let dot_pos = digits_chars.len().saturating_sub(S as usize);
170            for (i, c) in digits_chars.iter().enumerate() {
171                if i == dot_pos {
172                    out.push('.');
173                }
174                out.push(*c);
175            }
176        } else {
177            for c in &digits_chars {
178                out.push(*c);
179            }
180        }
181        out
182    }
183}
184
185impl<const P: u32, const S: u32> CdrEncode for Fixed<P, S> {
186    fn encode(&self, w: &mut BufferWriter) -> Result<(), EncodeError> {
187        // XCDR2 §7.4.4.5: BCD-Bytes raw, kein length-prefix (weil Bytes-
188        // Anzahl statisch via P bekannt ist).
189        w.write_bytes(&self.digits)
190    }
191}
192
193impl<const P: u32, const S: u32> CdrDecode for Fixed<P, S> {
194    fn decode(r: &mut BufferReader<'_>) -> Result<Self, DecodeError> {
195        let n = ((P + 1) / 2 + 1) as usize;
196        let bytes = r.read_bytes(n)?;
197        Self::from_bcd_bytes(bytes.to_vec())
198    }
199}
200
201#[cfg(test)]
202#[allow(clippy::expect_used, clippy::unwrap_used)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn fixed_default_is_zero_positive() {
208        let f: Fixed<5, 2> = Fixed::default();
209        assert_eq!(f.to_string_repr(), "0.00");
210    }
211
212    #[test]
213    fn fixed_roundtrip_via_string() {
214        let f: Fixed<5, 2> = Fixed::from_str_repr("123.45").expect("parse");
215        assert_eq!(f.to_string_repr(), "123.45");
216    }
217
218    #[test]
219    fn fixed_roundtrip_negative() {
220        let f: Fixed<6, 3> = Fixed::from_str_repr("-1.500").expect("parse");
221        let s = f.to_string_repr();
222        assert!(s.starts_with('-'));
223        assert!(s.contains("1.500") || s.contains("1.5"));
224    }
225
226    #[test]
227    fn fixed_wire_roundtrip() {
228        use crate::Endianness;
229        let f: Fixed<5, 2> = Fixed::from_str_repr("42.00").expect("parse");
230        let mut writer = BufferWriter::new(Endianness::Little);
231        f.encode(&mut writer).expect("encode");
232        let bytes = writer.into_bytes();
233        let mut reader = BufferReader::new(&bytes, Endianness::Little);
234        let back: Fixed<5, 2> = <Fixed<5, 2> as CdrDecode>::decode(&mut reader).expect("decode");
235        assert_eq!(back, f);
236    }
237
238    #[test]
239    fn fixed_overflow_returns_error() {
240        let res: Result<Fixed<3, 1>, _> = Fixed::from_str_repr("9999.5");
241        assert!(res.is_err());
242    }
243}