ironfix-core 0.4.0

Core types, traits, and error definitions for IronFix FIX protocol engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
/******************************************************************************
   Author: Joaquín Béjar García
   Email: jb@taunais.com
   Date: 27/1/26
******************************************************************************/

//! Error types for the IronFix FIX protocol engine.
//!
//! This module provides a unified error hierarchy using `thiserror` for typed,
//! domain-specific errors across all IronFix operations.

use thiserror::Error;

/// Result type alias using [`FixError`] as the error type.
pub type Result<T> = std::result::Result<T, FixError>;

/// Top-level error type for all IronFix operations.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum FixError {
    /// Error during message decoding.
    #[error("decode error: {0}")]
    Decode(#[from] DecodeError),

    /// Error during message encoding.
    #[error("encode error: {0}")]
    Encode(#[from] EncodeError),

    /// Error in session layer operations.
    #[error("session error: {0}")]
    Session(#[from] SessionError),

    /// Error in message store operations.
    #[error("store error: {0}")]
    Store(#[from] StoreError),

    /// I/O error from underlying transport.
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
}

/// Errors that occur during FIX message decoding.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DecodeError {
    /// Message buffer is incomplete, need more data.
    #[error("incomplete message, need more data")]
    Incomplete,

    /// Invalid BeginString field (tag 8).
    #[error("invalid begin string: expected 8=FIX.x.y")]
    InvalidBeginString,

    /// Missing BodyLength field (tag 9).
    #[error("missing body length field (tag 9)")]
    MissingBodyLength,

    /// Invalid BodyLength value.
    #[error("invalid body length value")]
    InvalidBodyLength,

    /// Missing MsgType field (tag 35).
    #[error("missing msg type field (tag 35)")]
    MissingMsgType,

    /// Checksum mismatch between calculated and declared values.
    #[error("checksum mismatch: calculated {calculated}, declared {declared}")]
    ChecksumMismatch {
        /// Calculated checksum value.
        calculated: u8,
        /// Declared checksum value in message.
        declared: u8,
    },

    /// Invalid tag format (not a valid integer).
    #[error("invalid tag format: {0}")]
    InvalidTag(String),

    /// Missing required field.
    #[error("missing required field: tag {tag}")]
    MissingRequiredField {
        /// The tag number of the missing field.
        tag: u32,
    },

    /// Invalid field value for the expected type.
    #[error("invalid field value for tag {tag}: {reason}")]
    InvalidFieldValue {
        /// The tag number of the field.
        tag: u32,
        /// Description of why the value is invalid.
        reason: String,
    },

    /// Invalid UTF-8 in string field.
    #[error("invalid utf-8 in field: {0}")]
    InvalidUtf8(#[from] std::str::Utf8Error),

    /// A field is not terminated by the SOH delimiter.
    ///
    /// Distinct from [`DecodeError::Incomplete`]: the tag was well formed, so
    /// the bytes are structurally a field, but its value never ends.
    #[error("unterminated field for tag {tag}: missing SOH delimiter")]
    UnterminatedField {
        /// The tag whose value is not terminated.
        tag: u32,
    },

    /// A Length/Data field pair declares a byte count the frame cannot satisfy.
    ///
    /// Raised when the count declared by a `LENGTH` field (for example
    /// `RawDataLength`, tag 95) runs past what the field may consume, or when
    /// the byte at the declared end of the `DATA` field is not the SOH
    /// delimiter.
    #[error(
        "data field {data_tag} declares {declared} bytes not terminated by SOH within {available} remaining bytes"
    )]
    InvalidDataLength {
        /// The tag of the `DATA` field being framed.
        data_tag: u32,
        /// The byte count declared by the paired `LENGTH` field.
        declared: usize,
        /// Bytes the field was allowed to consume: what remains after the `=`
        /// delimiter, bounded by the frame's declared body end when decoding a
        /// whole message. The terminating SOH must fall inside this.
        available: usize,
    },

    /// A stored byte range does not lie within the message buffer.
    ///
    /// Guards the offset bookkeeping in [`crate::message::RawMessage`], whose
    /// ranges are buffer-relative.
    #[error("range {start}..{end} is out of bounds for a {buffer_len}-byte buffer")]
    RangeOutOfBounds {
        /// Start offset of the offending range.
        start: usize,
        /// End offset of the offending range.
        end: usize,
        /// Length of the buffer the range was applied to.
        buffer_len: usize,
    },

    /// The MsgType value (tag 35) is not a representable message type.
    ///
    /// Raised when the bytes of tag 35 are empty, longer than
    /// [`crate::message::MSG_TYPE_MAX_LEN`], or contain a byte that cannot
    /// appear in a MsgType. The frame is rejected rather than routed under a
    /// truncated or defaulted message type.
    #[error("invalid msg type (tag 35): {0}")]
    InvalidMsgType(#[from] MsgTypeError),
}

/// Errors that occur during FIX message encoding.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum EncodeError {
    /// Buffer capacity exceeded during encoding.
    #[error("buffer overflow: need {needed} bytes, have {available}")]
    BufferOverflow {
        /// Bytes needed to complete encoding.
        needed: usize,
        /// Bytes available in buffer.
        available: usize,
    },

    /// Missing required field during encoding.
    #[error("missing required field: tag {tag}")]
    MissingRequiredField {
        /// The tag number of the missing field.
        tag: u32,
    },

    /// Invalid field value for encoding.
    #[error("invalid field value for tag {tag}: {reason}")]
    InvalidFieldValue {
        /// The tag number of the field.
        tag: u32,
        /// Description of why the value is invalid.
        reason: String,
    },

    /// Field value exceeds maximum length.
    #[error("field value too long for tag {tag}: {length} exceeds max {max_length}")]
    FieldTooLong {
        /// The tag number of the field.
        tag: u32,
        /// Actual length of the value.
        length: usize,
        /// Maximum allowed length.
        max_length: usize,
    },
}

/// Errors in FIX session layer operations.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SessionError {
    /// Session is not in the correct state for the operation.
    #[error("invalid session state: expected {expected}, current {current}")]
    InvalidState {
        /// Expected state for the operation.
        expected: String,
        /// Current session state.
        current: String,
    },

    /// Logon was rejected by counterparty.
    #[error("logon rejected: {reason}")]
    LogonRejected {
        /// Reason for rejection.
        reason: String,
    },

    /// Heartbeat timeout - no response to TestRequest.
    #[error("heartbeat timeout after {elapsed_ms} milliseconds")]
    HeartbeatTimeout {
        /// Elapsed time in milliseconds since last message.
        elapsed_ms: u64,
    },

    /// Sequence number gap detected.
    #[error("sequence gap detected: expected {expected}, received {received}")]
    SequenceGap {
        /// Expected sequence number.
        expected: u64,
        /// Received sequence number.
        received: u64,
    },

    /// Sequence number too low (possible duplicate).
    #[error("sequence too low: expected >= {expected}, received {received}")]
    SequenceTooLow {
        /// Minimum expected sequence number.
        expected: u64,
        /// Received sequence number.
        received: u64,
    },

    /// Message rejected by counterparty.
    #[error("message rejected: ref_seq={ref_seq_num}, reason={reason}")]
    MessageRejected {
        /// Reference sequence number of rejected message.
        ref_seq_num: u64,
        /// Rejection reason.
        reason: String,
    },

    /// Resend request for unavailable messages.
    #[error("resend request for unavailable range: {begin}..{end}")]
    ResendUnavailable {
        /// Begin sequence number of requested range.
        begin: u64,
        /// End sequence number of requested range.
        end: u64,
    },

    /// Session configuration error.
    #[error("configuration error: {0}")]
    Configuration(String),

    /// Connection error.
    #[error("connection error: {0}")]
    Connection(String),
}

/// Errors in message store operations.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum StoreError {
    /// Failed to store message.
    #[error("failed to store message seq={seq_num}: {reason}")]
    StoreFailed {
        /// Sequence number of the message.
        seq_num: u64,
        /// Reason for failure.
        reason: String,
    },

    /// Failed to retrieve message.
    #[error("failed to retrieve message seq={seq_num}: {reason}")]
    RetrieveFailed {
        /// Sequence number of the message.
        seq_num: u64,
        /// Reason for failure.
        reason: String,
    },

    /// Message not found in store.
    #[error("message not found: seq={seq_num}")]
    NotFound {
        /// Sequence number of the missing message.
        seq_num: u64,
    },

    /// No message is available anywhere in the requested range.
    ///
    /// The bounds are **inclusive**, which is how a FIX `ResendRequest`
    /// expresses them. A half-open `Range<u64>` cannot represent an upper
    /// bound of `u64::MAX` — the "to infinity" case a `ResendRequest` with
    /// `EndSeqNo` (16) = 0 asks for — without an `end + 1` that overflows, so
    /// the bounds are carried as two numbers and no arithmetic is performed on
    /// them.
    #[error("messages not available for range: {begin}..={end}")]
    RangeNotAvailable {
        /// First requested sequence number, inclusive.
        begin: u64,
        /// Last requested sequence number, inclusive.
        end: u64,
    },

    /// The requested range is inverted: `begin` is above `end`.
    ///
    /// This is a caller error, not an empty result. It is reported rather than
    /// normalised because a store cannot tell an inverted range apart from a
    /// range that happens to hold nothing, and silently answering "empty"
    /// hides the bug that produced it.
    #[error("invalid range: begin {begin} is above end {end}")]
    InvalidRange {
        /// First requested sequence number, inclusive.
        begin: u64,
        /// Last requested sequence number, inclusive.
        end: u64,
    },

    /// Store is corrupted.
    #[error("store corrupted: {reason}")]
    Corrupted {
        /// Description of the corruption.
        reason: String,
    },

    /// I/O error in persistent store.
    #[error("store i/o error: {0}")]
    Io(String),
}

/// Rejection reasons for [`crate::types::CompId`] construction.
///
/// A CompID is written verbatim into SenderCompID (49) and TargetCompID (56)
/// on every outbound message, so an empty value or one carrying SOH or another
/// control byte would produce a malformed or injectable frame. Construction is
/// the chokepoint that makes that unrepresentable.
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CompIdError {
    /// The value is empty.
    ///
    /// An empty CompID would encode as `49=<SOH>` (or `56=<SOH>`), a field
    /// with no value that FIX TagValue treats as malformed.
    #[error("comp id is empty")]
    Empty,

    /// The value does not fit in the fixed inline storage.
    #[error("comp id is {len} bytes, exceeding the {max_len}-byte inline storage bound")]
    TooLong {
        /// Length of the offered value in bytes.
        len: usize,
        /// Maximum length in bytes, [`crate::types::COMP_ID_MAX_LEN`].
        max_len: usize,
    },

    /// The value contains a byte outside printable ASCII (`0x20..=0x7e`).
    #[error(
        "comp id contains illegal byte {byte:#04x} at offset {position}: \
         only printable ASCII (0x20..=0x7e) is allowed"
    )]
    IllegalByte {
        /// The offending byte.
        byte: u8,
        /// Zero-based offset of the offending byte within the value.
        position: usize,
    },
}

/// Rejection reasons for [`crate::message::MsgType`] construction.
///
/// A MsgType decides which handler a message reaches and is written verbatim
/// into tag 35 of every outbound message, so a value that cannot be
/// represented exactly is rejected at construction. Truncating it would
/// silently reroute the message to the handler for a different, shorter code.
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MsgTypeError {
    /// The value is empty; tag 35 always carries at least one byte.
    #[error("msg type is empty")]
    Empty,

    /// The value does not fit in the fixed inline storage.
    #[error("msg type is {len} bytes, exceeding the {max_len}-byte inline storage bound")]
    TooLong {
        /// Length of the offered value in bytes.
        len: usize,
        /// Maximum length in bytes, [`crate::message::MSG_TYPE_MAX_LEN`].
        max_len: usize,
    },

    /// The value contains a byte outside printable ASCII — a control byte
    /// (SOH included) or a non-ASCII byte.
    ///
    /// `=` and space are **not** illegal here: both are legal inside a FIX
    /// field value, so a bilaterally agreed MsgType carrying either is accepted.
    #[error(
        "msg type contains illegal byte {byte:#04x} at offset {position}: \
         only printable ASCII (0x20..=0x7e) is allowed"
    )]
    IllegalByte {
        /// The offending byte.
        byte: u8,
        /// Zero-based offset of the offending byte within the value.
        position: usize,
    },
}

/// Rejection reasons for [`crate::types::Timestamp`] construction.
///
/// A `Timestamp` counts nanoseconds since the Unix epoch as an unsigned value
/// bounded by [`crate::types::Timestamp::MAX_NANOS`], so instants before
/// 1970-01-01 and after 2262-04-11 are not representable.
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TimestampError {
    /// The nanosecond count exceeds the representable range.
    #[error("{nanos} nanoseconds since the epoch exceeds the maximum {max_nanos}")]
    NanosOutOfRange {
        /// The offered nanosecond count.
        nanos: u64,
        /// The largest representable nanosecond count.
        max_nanos: u64,
    },

    /// The millisecond count overflows when scaled to nanoseconds.
    #[error("{millis} milliseconds since the epoch is not representable in nanoseconds")]
    MillisOutOfRange {
        /// The offered millisecond count.
        millis: u64,
    },

    /// A calendar instant falls outside the representable range — before the
    /// Unix epoch, or past the nanosecond ceiling.
    #[error("instant at {seconds} seconds from the epoch is outside the representable range")]
    InstantOutOfRange {
        /// Whole seconds from the Unix epoch, negative before 1970.
        seconds: i64,
    },
}

/// A number that is not a legal FIX field tag.
///
/// FIX tags are positive integers; `0` is neither a standard nor a
/// user-defined tag.
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
#[error("{tag} is not a legal FIX field tag: tags are positive integers starting at 1")]
pub struct InvalidFieldTag {
    tag: u32,
}

impl InvalidFieldTag {
    /// Creates the error for an offending tag number.
    #[inline]
    #[must_use]
    pub const fn new(tag: u32) -> Self {
        Self { tag }
    }

    /// Returns the offending tag number.
    #[inline]
    #[must_use]
    pub const fn tag(self) -> u32 {
        self.tag
    }
}

/// A byte that is not a legal Side (tag 54) code.
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
#[error("{value:#04x} is not a FIX Side (tag 54) code")]
pub struct InvalidSide {
    value: u8,
}

impl InvalidSide {
    /// Creates the error for an offending byte.
    #[inline]
    #[must_use]
    pub const fn new(value: u8) -> Self {
        Self { value }
    }

    /// Returns the offending byte.
    #[inline]
    #[must_use]
    pub const fn value(self) -> u8 {
        self.value
    }
}

/// A string that names no [`FixVersion`](crate::version::FixVersion).
///
/// An unrecognised version cannot be framed: its `BeginString` (8) and, for a
/// FIXT session, its `DefaultApplVerID` (1137) are unknown. Callers must
/// surface this rather than substitute a default, which would put a fabricated
/// version on the wire.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[error("'{value}' is not a known FIX version")]
pub struct UnknownFixVersion {
    value: String,
}

impl UnknownFixVersion {
    /// Creates the error for an offending version string.
    #[inline]
    #[must_use]
    pub fn new(value: &str) -> Self {
        Self {
            value: value.to_owned(),
        }
    }

    /// Returns the offending version string.
    #[inline]
    #[must_use]
    pub fn value(&self) -> &str {
        &self.value
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_decode_error_display() {
        let err = DecodeError::ChecksumMismatch {
            calculated: 100,
            declared: 200,
        };
        assert_eq!(
            err.to_string(),
            "checksum mismatch: calculated 100, declared 200"
        );
    }

    #[test]
    fn test_fix_error_from_decode() {
        let decode_err = DecodeError::Incomplete;
        let fix_err: FixError = decode_err.into();
        assert!(matches!(fix_err, FixError::Decode(DecodeError::Incomplete)));
    }

    #[test]
    fn test_session_error_display() {
        let err = SessionError::SequenceGap {
            expected: 5,
            received: 10,
        };
        assert_eq!(
            err.to_string(),
            "sequence gap detected: expected 5, received 10"
        );
    }

    #[test]
    fn test_store_error_display() {
        let err = StoreError::NotFound { seq_num: 42 };
        assert_eq!(err.to_string(), "message not found: seq=42");
    }
}