Skip to main content

truefix_core/
field.rs

1//! FIX fields and their typed value converters.
2
3use rust_decimal::Decimal;
4use time::OffsetDateTime;
5
6use crate::error::FieldError;
7
8/// A single FIX field: a tag number and its raw (wire) value bytes.
9///
10/// Raw bytes are preserved verbatim so re-encoding reproduces the original representation
11/// (round-trip fidelity, FR-B9). Typed accessors convert on demand and never panic.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Field {
14    tag: u32,
15    value: Vec<u8>,
16}
17
18impl Field {
19    /// Create a field from a tag and raw value bytes.
20    pub fn new(tag: u32, value: impl Into<Vec<u8>>) -> Self {
21        Self {
22            tag,
23            value: value.into(),
24        }
25    }
26
27    /// Create a field from a tag and a string value.
28    pub fn string(tag: u32, value: &str) -> Self {
29        Self::new(tag, value.as_bytes().to_vec())
30    }
31
32    /// Create an integer-valued field.
33    pub fn int(tag: u32, value: i64) -> Self {
34        Self::new(tag, value.to_string().into_bytes())
35    }
36
37    /// Create a UTC-timestamp-valued field at millisecond precision
38    /// (`YYYYMMDD-HH:MM:SS.sss`) — the FIX wire format, which does *not* match
39    /// `OffsetDateTime`'s own `Display` output. Sessions needing other precisions format their
40    /// own SendingTime; this constructor is the safe default for typed-message setters.
41    pub fn utc_timestamp(tag: u32, value: OffsetDateTime) -> Self {
42        Self::string(tag, &format_utc_timestamp_millis(value))
43    }
44
45    /// Create a `Data`-typed field from raw bytes (e.g. `RawData`/tag 96), preserving them
46    /// verbatim — including embedded SOH — since Data fields are not text (FR-011). A thin,
47    /// semantically-named wrapper over [`Self::new`]/[`Self::value_bytes`], distinguishing "this is
48    /// a Data field" call sites from generic byte construction.
49    pub fn bytes(tag: u32, value: &[u8]) -> Self {
50        Self::new(tag, value.to_vec())
51    }
52
53    /// The value as raw bytes, with no UTF-8 assumption (FIX `Data` fields; FR-011). Equivalent to
54    /// [`Self::value_bytes`], named for symmetry with [`Self::bytes`].
55    pub fn as_bytes(&self) -> &[u8] {
56        &self.value
57    }
58
59    /// Create a UTC-date-only-valued field (`YYYYMMDD`; FR-011).
60    pub fn utc_date_only(tag: u32, value: time::Date) -> Self {
61        Self::string(tag, &format_utc_date_only(value))
62    }
63
64    /// The value as a UTC date-only (`YYYYMMDD`; FR-011).
65    pub fn as_utc_date_only(&self) -> Result<time::Date, FieldError> {
66        let s = self.as_str()?;
67        parse_utc_date_only(s).ok_or_else(|| FieldError::NotDateOnly {
68            tag: self.tag,
69            value: s.to_owned(),
70        })
71    }
72
73    /// Create a UTC-time-only-valued field at millisecond precision (`HH:MM:SS.sss`; FR-011),
74    /// matching [`Self::utc_timestamp`]'s precision convention.
75    pub fn utc_time_only(tag: u32, value: time::Time) -> Self {
76        Self::string(tag, &format_utc_time_only_millis(value))
77    }
78
79    /// The value as a UTC time-only (`HH:MM:SS[.fraction]`; FR-011). Accepts only 0/3/6/9
80    /// fractional digits (BUG-48/FR-046, feature 007) and maps a leap second to `59` plus the
81    /// maximum sub-second fraction (BUG-78), matching [`Self::as_utc_timestamp`]'s tolerance.
82    pub fn as_utc_time_only(&self) -> Result<time::Time, FieldError> {
83        let s = self.as_str()?;
84        parse_utc_time_only(s).ok_or_else(|| FieldError::NotTimeOnly {
85            tag: self.tag,
86            value: s.to_owned(),
87        })
88    }
89
90    /// The field's tag number.
91    pub fn tag(&self) -> u32 {
92        self.tag
93    }
94
95    /// The raw value bytes.
96    pub fn value_bytes(&self) -> &[u8] {
97        &self.value
98    }
99
100    /// The value as a UTF-8 string slice.
101    pub fn as_str(&self) -> Result<&str, FieldError> {
102        core::str::from_utf8(&self.value).map_err(|_| FieldError::NotUtf8 { tag: self.tag })
103    }
104
105    /// The value as an integer.
106    ///
107    /// NEW-101 (audit 006): the raw wire value is parsed as-is, without trimming whitespace --
108    /// `i64::from_str` already rejects leading/trailing whitespace on its own, so the previous
109    /// `.trim()` call was the only thing making a whitespace-padded value like `" 123 "` parse
110    /// successfully, silently accepting a malformed wire value FIX never produces.
111    pub fn as_int(&self) -> Result<i64, FieldError> {
112        let s = self.as_str()?;
113        s.parse().map_err(|_| FieldError::NotInt {
114            tag: self.tag,
115            value: s.to_owned(),
116        })
117    }
118
119    /// The value as a fixed-precision decimal (FIX Price/Qty/Amt).
120    ///
121    /// NEW-101 (audit 006): see [`Self::as_int`]'s doc -- the same whitespace-padding leniency is
122    /// closed here.
123    pub fn as_decimal(&self) -> Result<Decimal, FieldError> {
124        let s = self.as_str()?;
125        s.parse().map_err(|_| FieldError::NotDecimal {
126            tag: self.tag,
127            value: s.to_owned(),
128        })
129    }
130
131    /// The value as a single character (FIX char).
132    pub fn as_char(&self) -> Result<char, FieldError> {
133        let s = self.as_str()?;
134        let mut chars = s.chars();
135        match (chars.next(), chars.next()) {
136            (Some(c), None) => Ok(c),
137            _ => Err(FieldError::NotChar {
138                tag: self.tag,
139                value: s.to_owned(),
140            }),
141        }
142    }
143
144    /// The value as a boolean (`Y`/`N`).
145    pub fn as_bool(&self) -> Result<bool, FieldError> {
146        match self.value.as_slice() {
147            b"Y" => Ok(true),
148            b"N" => Ok(false),
149            // NEW-138 (audit 006): a lossless UTF-8 decode when possible, falling back to a hex
150            // representation for genuinely invalid UTF-8 -- `String::from_utf8_lossy` would
151            // otherwise silently substitute U+FFFD replacement characters into the diagnostic,
152            // discarding the actual invalid bytes a caller might need to see.
153            _ => Err(FieldError::NotBool {
154                tag: self.tag,
155                value: match core::str::from_utf8(&self.value) {
156                    Ok(s) => s.to_owned(),
157                    Err(_) => format!("0x{}", hex_encode(&self.value)),
158                },
159            }),
160        }
161    }
162
163    /// The value as a UTC timestamp (FR-B7). Accepts only 0/3/6/9 fractional digits (BUG-48/
164    /// FR-046, feature 007), matching QFJ; a leap second (`sec == 60`) maps to `59` plus the
165    /// maximum sub-second fraction (BUG-78), matching QFJ's explicit handling.
166    pub fn as_utc_timestamp(&self) -> Result<OffsetDateTime, FieldError> {
167        let s = self.as_str()?;
168        parse_utc_timestamp(s).ok_or_else(|| FieldError::NotTimestamp {
169            tag: self.tag,
170            value: s.to_owned(),
171        })
172    }
173}
174
175/// Hex-encode `bytes` (NEW-138, audit 006): a lossless fallback representation for a diagnostic
176/// message when the value isn't valid UTF-8, avoiding `String::from_utf8_lossy`'s replacement
177/// characters, which would discard the actual invalid bytes.
178fn hex_encode(bytes: &[u8]) -> String {
179    let mut s = String::with_capacity(bytes.len() * 2);
180    for b in bytes {
181        s.push_str(&format!("{b:02x}"));
182    }
183    s
184}
185
186/// Format `dt` as a FIX UTCTimestamp `YYYYMMDD-HH:MM:SS.sss` at millisecond precision.
187fn format_utc_timestamp_millis(dt: OffsetDateTime) -> String {
188    format!(
189        "{:04}{:02}{:02}-{:02}:{:02}:{:02}.{:03}",
190        dt.year(),
191        u8::from(dt.month()),
192        dt.day(),
193        dt.hour(),
194        dt.minute(),
195        dt.second(),
196        dt.millisecond()
197    )
198}
199
200/// Parse `YYYYMMDD-HH:MM:SS[.fraction]` as UTC. Returns `None` on any malformation.
201fn parse_utc_timestamp(s: &str) -> Option<OffsetDateTime> {
202    let (date, time_part) = s.split_once('-')?;
203    if date.len() != 8 {
204        return None;
205    }
206    let year: i32 = date.get(0..4)?.parse().ok()?;
207    let month: u8 = date.get(4..6)?.parse().ok()?;
208    let day: u8 = date.get(6..8)?.parse().ok()?;
209
210    let (hms, frac) = match time_part.split_once('.') {
211        Some((h, f)) => (h, Some(f)),
212        None => (time_part, None),
213    };
214    let mut parts = hms.split(':');
215    let hour: u8 = parts.next()?.parse().ok()?;
216    let min: u8 = parts.next()?.parse().ok()?;
217    let sec: u8 = parts.next()?.parse().ok()?;
218    if parts.next().is_some() {
219        return None;
220    }
221
222    // BUG-48/FR-046 (feature 007): only 0 (no fraction)/3/6/9 fractional digits are accepted,
223    // matching QFJ -- previously any digit count >= 1 was accepted (e.g. a single-digit
224    // `.1`), which QFJ rejects. TrueFix's own `TimeStampPrecision` only goes up to nanoseconds (9
225    // digits) in the first place, so 12 (picoseconds) isn't representable regardless.
226    let nanos: u32 = match frac {
227        None => 0,
228        Some(f) if matches!(f.len(), 3 | 6 | 9) && f.bytes().all(|b| b.is_ascii_digit()) => {
229            let mut buf = [b'0'; 9];
230            for (slot, digit) in buf.iter_mut().zip(f.bytes()) {
231                *slot = digit;
232            }
233            core::str::from_utf8(&buf).ok()?.parse().ok()?
234        }
235        Some(_) => return None,
236    };
237
238    let month = time::Month::try_from(month).ok()?;
239    let date = time::Date::from_calendar_date(year, month, day).ok()?;
240    // BUG-78/FR-046 (feature 007): a leap second (`sec == 60`) maps to `59` seconds plus the
241    // maximum sub-second fraction, matching QFJ's explicit `s == 60` handling -- `time::Time::
242    // from_hms_nano` otherwise rejects `sec == 60` outright, unlike QFJ which tolerates it.
243    let (sec, nanos) = if sec == 60 {
244        (59, 999_999_999)
245    } else {
246        (sec, nanos)
247    };
248    let time = time::Time::from_hms_nano(hour, min, sec, nanos).ok()?;
249    Some(time::PrimitiveDateTime::new(date, time).assume_utc())
250}
251
252/// Format `date` as a FIX UtcDateOnly `YYYYMMDD`.
253fn format_utc_date_only(date: time::Date) -> String {
254    format!(
255        "{:04}{:02}{:02}",
256        date.year(),
257        u8::from(date.month()),
258        date.day()
259    )
260}
261
262/// Parse `YYYYMMDD` as a UTC date-only. Returns `None` on any malformation.
263fn parse_utc_date_only(s: &str) -> Option<time::Date> {
264    if s.len() != 8 || !s.bytes().all(|b| b.is_ascii_digit()) {
265        return None;
266    }
267    let year: i32 = s.get(0..4)?.parse().ok()?;
268    let month: u8 = s.get(4..6)?.parse().ok()?;
269    let day: u8 = s.get(6..8)?.parse().ok()?;
270    let month = time::Month::try_from(month).ok()?;
271    time::Date::from_calendar_date(year, month, day).ok()
272}
273
274/// Format `t` as a FIX UtcTimeOnly `HH:MM:SS.sss` at millisecond precision.
275fn format_utc_time_only_millis(t: time::Time) -> String {
276    format!(
277        "{:02}:{:02}:{:02}.{:03}",
278        t.hour(),
279        t.minute(),
280        t.second(),
281        t.millisecond()
282    )
283}
284
285/// Parse `HH:MM:SS[.fraction]` as a UTC time-only. Returns `None` on any malformation. Accepts
286/// only 0/3/6/9 fractional digits, and maps a leap second (`sec == 60`) to `59` plus the maximum
287/// sub-second fraction (matching `parse_utc_timestamp`'s BUG-48/BUG-78 tolerance, FR-046).
288fn parse_utc_time_only(s: &str) -> Option<time::Time> {
289    let (hms, frac) = match s.split_once('.') {
290        Some((h, f)) => (h, Some(f)),
291        None => (s, None),
292    };
293    let mut parts = hms.split(':');
294    let hour: u8 = parts.next()?.parse().ok()?;
295    let min: u8 = parts.next()?.parse().ok()?;
296    let sec: u8 = parts.next()?.parse().ok()?;
297    if parts.next().is_some() {
298        return None;
299    }
300
301    let nanos: u32 = match frac {
302        None => 0,
303        Some(f) if matches!(f.len(), 3 | 6 | 9) && f.bytes().all(|b| b.is_ascii_digit()) => {
304            let mut buf = [b'0'; 9];
305            for (slot, digit) in buf.iter_mut().zip(f.bytes()) {
306                *slot = digit;
307            }
308            core::str::from_utf8(&buf).ok()?.parse().ok()?
309        }
310        Some(_) => return None,
311    };
312
313    let (sec, nanos) = if sec == 60 {
314        (59, 999_999_999)
315    } else {
316        (sec, nanos)
317    };
318    time::Time::from_hms_nano(hour, min, sec, nanos).ok()
319}