Skip to main content

ironfix_session/
config.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 27/1/26
5******************************************************************************/
6
7//! Session configuration.
8//!
9//! [`SessionConfig`] is the typed configuration surface of a FIX session.
10//! There is no environment-variable or file-based configuration anywhere in
11//! this workspace, by design: a session knob is a typed field with a default,
12//! a documented unit and range, and validation.
13//!
14//! # Where validity is decided
15//!
16//! [`SessionConfig::validate`] is the single definition of "valid". Two
17//! callers use it:
18//!
19//! - [`SessionConfigBuilder::build`], the canonical constructor — it exposes a
20//!   setter for every knob and reports the first violation as a typed
21//!   [`SessionConfigError`] instead of panicking.
22//! - `ironfix-engine`, before it dials, so an out-of-range knob assembled by
23//!   hand through the public fields still cannot reach the wire.
24//!
25//! # Ranges
26//!
27//! | Knob | Unit | Range | Default |
28//! |---|---|---|---|
29//! | `sender_comp_id` / `target_comp_id` | — | validated by [`CompId`] | required |
30//! | `begin_string` | ASCII | 1..=[`MAX_ID_LEN`] bytes, printable ASCII except `=` | `FIX.4.4` |
31//! | `heartbeat_interval` | whole seconds | 0 (disabled) or [`MIN_HEARTBEAT_INTERVAL_SECS`]..=[`MAX_HEARTBEAT_INTERVAL_SECS`] | 30 s |
32//! | `logon_timeout` / `logout_timeout` | duration | non-zero, at most [`MAX_TIMEOUT`] | 10 s |
33//! | `max_message_size` | bytes | [`MIN_MESSAGE_SIZE_LIMIT`]..=[`MAX_MESSAGE_SIZE_LIMIT`] | 1 MiB |
34//! | `sender_sub_id` / `target_sub_id` | ASCII | unset, or 1..=[`MAX_ID_LEN`] bytes, printable ASCII except `=` | unset |
35//! | `sender_location_id` / `target_location_id` | ASCII | as above | unset |
36//! | `reset_on_*`, `validate_*` | flag | any | see [`SessionConfig::new`] |
37//!
38//! # `HeartBtInt` (108) is whole seconds
39//!
40//! Tag 108 carries whole seconds, and `HeartBtInt = 0` means *do not
41//! heartbeat* (see the [`crate::heartbeat`] module). A fractional
42//! `heartbeat_interval` therefore has no honest wire form: truncating 500 ms
43//! to `108=0` would negotiate no heartbeating at all while local timers ran
44//! sub-second. Fractional intervals are rejected rather than truncated, and
45//! `HeartBtInt = 0` must be asked for explicitly through
46//! [`SessionConfigBuilder::disable_heartbeats`].
47
48use crate::heartbeat::MAX_HEARTBEAT_INTERVAL_SECS;
49use ironfix_core::types::{COMP_ID_MAX_LEN, CompId};
50use std::time::Duration;
51
52/// Smallest `HeartBtInt` (108) this engine will configure, in seconds.
53///
54/// One second is the smallest interval tag 108 can express. Anything shorter
55/// has no wire representation; see the module documentation. Zero is not in
56/// this range — it is the separate "heartbeating disabled" case.
57pub const MIN_HEARTBEAT_INTERVAL_SECS: u64 = 1;
58
59/// Largest handshake timeout this engine will configure.
60///
61/// Both `logon_timeout` and `logout_timeout` bound one round trip with the
62/// counterparty. Five minutes is far beyond any real venue's response time; a
63/// larger value would leave a dead handshake hanging rather than failing it.
64pub const MAX_TIMEOUT: Duration = Duration::from_secs(300);
65
66/// Smallest `max_message_size` this engine will configure, in bytes.
67///
68/// The limit must at least admit the session's own Logon: the standard header
69/// with maximum-length CompIDs, SubIDs and LocationIDs plus a Logon body runs
70/// to a few hundred bytes. Below this floor the session could not complete its
71/// own handshake.
72pub const MIN_MESSAGE_SIZE_LIMIT: usize = 512;
73
74/// Largest `max_message_size` this engine will configure, in bytes (64 MiB).
75///
76/// The codec buffers up to this much per connection before it can reject a
77/// frame, so the knob is also a per-connection memory ceiling. 64 MiB is well
78/// above the largest realistic FIX message (a mass quote or security list)
79/// and far below anything that would let one connection exhaust a host.
80pub const MAX_MESSAGE_SIZE_LIMIT: usize = 64 * 1024 * 1024;
81
82/// Maximum length, in bytes, of a configured identity string.
83///
84/// Applies to `begin_string`, the sub IDs and the location IDs. It is
85/// [`COMP_ID_MAX_LEN`], the same bound [`CompId`] applies to tags 49 and 56:
86/// these values sit in the same standard header and are written verbatim
87/// alongside it.
88pub const MAX_ID_LEN: usize = COMP_ID_MAX_LEN;
89
90/// `BeginString` (tag 8) used when the builder is not told otherwise.
91const DEFAULT_BEGIN_STRING: &str = "FIX.4.4";
92
93/// `HeartBtInt` (108) used when the builder is not told otherwise.
94const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
95
96/// Handshake timeout used when the builder is not told otherwise.
97const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
98
99/// `max_message_size` used when the builder is not told otherwise (1 MiB).
100const DEFAULT_MESSAGE_SIZE_LIMIT: usize = 1024 * 1024;
101
102/// Reason a session configuration cannot be used.
103///
104/// Every variant names the offending knob, so the message is actionable
105/// without the caller having to guess which setter produced it.
106#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
107#[non_exhaustive]
108pub enum SessionConfigError {
109    /// A field with no default was never set on the builder.
110    #[error("{field} is required")]
111    MissingField {
112        /// Name of the field that was never set.
113        field: &'static str,
114    },
115
116    /// A string knob was set to the empty string.
117    ///
118    /// An empty value has no wire form: the encoder refuses it, so it would
119    /// fail at the first outbound message rather than at configuration time.
120    #[error("{field} must not be empty")]
121    EmptyField {
122        /// Name of the empty field.
123        field: &'static str,
124    },
125
126    /// A string knob is longer than [`MAX_ID_LEN`] bytes.
127    #[error("{field} is {len} bytes, over the {max}-byte maximum")]
128    FieldTooLong {
129        /// Name of the oversized field.
130        field: &'static str,
131        /// Length of the configured value, in bytes.
132        len: usize,
133        /// The maximum accepted, in bytes.
134        max: usize,
135    },
136
137    /// A string knob carries a byte with no on-the-wire form.
138    ///
139    /// These values are written verbatim into the standard header, so SOH
140    /// would terminate the field early and `=` would open a new tag/value
141    /// pair: either byte structurally corrupts every outbound message.
142    #[error("{field} contains illegal byte 0x{byte:02x} at position {position}")]
143    IllegalByte {
144        /// Name of the offending field.
145        field: &'static str,
146        /// The byte that was refused.
147        byte: u8,
148        /// Its zero-based position in the value.
149        position: usize,
150    },
151
152    /// The heartbeat interval is not a whole number of seconds.
153    #[error(
154        "heartbeat interval {interval:?} is not a whole number of seconds; HeartBtInt (108) is expressed in whole seconds"
155    )]
156    FractionalHeartbeatInterval {
157        /// The configured interval.
158        interval: Duration,
159    },
160
161    /// The heartbeat interval is outside the supported range.
162    #[error("heartbeat interval of {secs}s is outside the supported range {min}s..={max}s")]
163    HeartbeatIntervalOutOfRange {
164        /// The configured interval, in seconds.
165        secs: u64,
166        /// The smallest interval accepted, in seconds.
167        min: u64,
168        /// The largest interval accepted, in seconds.
169        max: u64,
170    },
171
172    /// A zero heartbeat interval reached the builder without the explicit
173    /// opt-in.
174    ///
175    /// `HeartBtInt = 0` is legal and means "do not heartbeat", which switches
176    /// dead-peer detection off for the whole session. That is a decision, not
177    /// a default, so it has to be asked for by name.
178    #[error(
179        "a zero heartbeat interval disables heartbeating entirely (HeartBtInt = 0); call SessionConfigBuilder::disable_heartbeats to ask for that explicitly"
180    )]
181    HeartbeatDisabledWithoutOptIn,
182
183    /// A handshake timeout is zero or above [`MAX_TIMEOUT`].
184    #[error("{field} of {timeout:?} is outside the supported range (non-zero, at most {max:?})")]
185    TimeoutOutOfRange {
186        /// Name of the offending timeout.
187        field: &'static str,
188        /// The configured timeout.
189        timeout: Duration,
190        /// The largest timeout accepted.
191        max: Duration,
192    },
193
194    /// `max_message_size` is outside its supported range.
195    #[error("max_message_size of {size} bytes is outside the supported range {min}..={max}")]
196    MessageSizeLimitOutOfRange {
197        /// The configured limit, in bytes.
198        size: usize,
199        /// The smallest limit accepted, in bytes.
200        min: usize,
201        /// The largest limit accepted, in bytes.
202        max: usize,
203    },
204}
205
206/// Validates one identity string written verbatim into the standard header.
207///
208/// Charset and length match [`CompId`]: printable ASCII (`0x20..=0x7e`)
209/// except `=`, at most [`MAX_ID_LEN`] bytes, never empty.
210fn validate_id(field: &'static str, value: &str) -> Result<(), SessionConfigError> {
211    if value.is_empty() {
212        return Err(SessionConfigError::EmptyField { field });
213    }
214    if value.len() > MAX_ID_LEN {
215        return Err(SessionConfigError::FieldTooLong {
216            field,
217            len: value.len(),
218            max: MAX_ID_LEN,
219        });
220    }
221    for (position, &byte) in value.as_bytes().iter().enumerate() {
222        let printable = byte.is_ascii_graphic() || byte == b' ';
223        if !printable || byte == b'=' {
224            return Err(SessionConfigError::IllegalByte {
225                field,
226                byte,
227                position,
228            });
229        }
230    }
231    Ok(())
232}
233
234/// Validates a handshake timeout: non-zero and at most [`MAX_TIMEOUT`].
235fn validate_timeout(field: &'static str, timeout: Duration) -> Result<(), SessionConfigError> {
236    if timeout.is_zero() || timeout > MAX_TIMEOUT {
237        return Err(SessionConfigError::TimeoutOutOfRange {
238            field,
239            timeout,
240            max: MAX_TIMEOUT,
241        });
242    }
243    Ok(())
244}
245
246/// Validates the heartbeat interval against what tag 108 can carry.
247///
248/// [`Duration::ZERO`] is accepted here: it is the legal `HeartBtInt = 0`
249/// case. The builder is what insists that zero be asked for explicitly.
250fn validate_heartbeat_interval(interval: Duration) -> Result<(), SessionConfigError> {
251    if interval.is_zero() {
252        return Ok(());
253    }
254    if interval.subsec_nanos() != 0 {
255        return Err(SessionConfigError::FractionalHeartbeatInterval { interval });
256    }
257    let secs = interval.as_secs();
258    if !(MIN_HEARTBEAT_INTERVAL_SECS..=MAX_HEARTBEAT_INTERVAL_SECS).contains(&secs) {
259        return Err(SessionConfigError::HeartbeatIntervalOutOfRange {
260            secs,
261            min: MIN_HEARTBEAT_INTERVAL_SECS,
262            max: MAX_HEARTBEAT_INTERVAL_SECS,
263        });
264    }
265    Ok(())
266}
267
268/// Configuration for a FIX session.
269///
270/// Construct one with [`SessionConfigBuilder`], which validates every knob.
271/// The fields are public so a configuration can also be assembled or adjusted
272/// directly; [`SessionConfig::validate`] then says whether the result is
273/// usable, and `ironfix-engine` calls it before dialling.
274#[derive(Debug, Clone)]
275pub struct SessionConfig {
276    /// Sender CompID (tag 49).
277    pub sender_comp_id: CompId,
278    /// Target CompID (tag 56).
279    pub target_comp_id: CompId,
280    /// FIX version BeginString, tag 8 (e.g. `FIX.4.4`).
281    ///
282    /// 1..=[`MAX_ID_LEN`] bytes of printable ASCII except `=`. Whether the
283    /// version can actually be framed is a separate question, answered by
284    /// `ironfix-engine`.
285    pub begin_string: String,
286    /// Heartbeat interval, `HeartBtInt` (108).
287    ///
288    /// Whole seconds, from [`MIN_HEARTBEAT_INTERVAL_SECS`] to
289    /// [`MAX_HEARTBEAT_INTERVAL_SECS`]. [`Duration::ZERO`] is the legal
290    /// `HeartBtInt = 0` case and disables heartbeating entirely.
291    pub heartbeat_interval: Duration,
292    /// Whether to set `ResetSeqNumFlag` (141) on the outbound Logon.
293    pub reset_on_logon: bool,
294    /// Whether to reset sequence numbers after a graceful Logout.
295    pub reset_on_logout: bool,
296    /// Whether to reset sequence numbers when the session disconnects.
297    pub reset_on_disconnect: bool,
298    /// Maximum accepted message size, in bytes.
299    ///
300    /// From [`MIN_MESSAGE_SIZE_LIMIT`] to [`MAX_MESSAGE_SIZE_LIMIT`]. Also the
301    /// per-connection buffering ceiling of the codec.
302    pub max_message_size: usize,
303    /// How long to wait for the Logon acknowledgement.
304    ///
305    /// Non-zero, at most [`MAX_TIMEOUT`].
306    pub logon_timeout: Duration,
307    /// How long to wait for the Logout acknowledgement.
308    ///
309    /// Non-zero, at most [`MAX_TIMEOUT`].
310    pub logout_timeout: Duration,
311    /// Whether to validate the `CheckSum` (10) of inbound messages.
312    pub validate_checksum: bool,
313    /// Whether to validate incoming message length.
314    pub validate_length: bool,
315    /// Largest difference tolerated, in either direction, between an inbound
316    /// message's `SendingTime` (52) and the local clock.
317    ///
318    /// Units: wall-clock duration. Range: any duration; `Duration::ZERO`
319    /// disables `SendingTime` validation entirely, including the presence and
320    /// format checks. Default: 120 seconds.
321    ///
322    /// The default matches the tolerance FIX engines have converged on
323    /// (QuickFIX's `MaxLatency`) and is the interval it is worth choosing:
324    /// a host synchronised by NTP stays within milliseconds of true time, so
325    /// two minutes is orders of magnitude more slack than a healthy peer ever
326    /// needs, while a host that is not synchronised at all drifts past two
327    /// minutes within days. Anything much tighter starts rejecting sessions
328    /// over ordinary drift and queueing latency; anything much looser stops
329    /// distinguishing a wrong clock from a right one.
330    pub sending_time_tolerance: Duration,
331    /// How long an outstanding `ResendRequest` (2) may make no progress before
332    /// it is retried, and eventually abandoned.
333    ///
334    /// Units: wall-clock duration, measured from the moment the request was
335    /// sent and restarted by every request that follows it. Any in-sequence
336    /// message clears the outstanding request altogether, so this measures a
337    /// gap that is not being filled at all, not a slow replay. Range: any
338    /// duration; a value below the engine's 100 ms reactor tick simply retries
339    /// on the next tick. Default: 10 seconds.
340    pub resend_timeout: Duration,
341    /// Maximum number of `ResendRequest` (2) messages sent for one gap,
342    /// counting the first.
343    ///
344    /// Once they are spent the session is ended with a Logout rather than left
345    /// waiting for a peer that is not answering. Range: 1 and above; 0 is read
346    /// as 1, because the first request is unconditional. Default: 3, which
347    /// with the default [`SessionConfig::resend_timeout`] bounds an
348    /// unrecoverable gap at 30 seconds plus the logout handshake.
349    ///
350    /// Read it through [`SessionConfig::resend_attempt_limit`], which applies
351    /// the lower bound.
352    pub max_resend_requests: u32,
353    /// Optional sender sub ID (tag 50), 1..=[`MAX_ID_LEN`] bytes when set.
354    pub sender_sub_id: Option<String>,
355    /// Optional target sub ID (tag 57), 1..=[`MAX_ID_LEN`] bytes when set.
356    pub target_sub_id: Option<String>,
357    /// Optional sender location ID (tag 142), 1..=[`MAX_ID_LEN`] bytes when set.
358    pub sender_location_id: Option<String>,
359    /// Optional target location ID (tag 143), 1..=[`MAX_ID_LEN`] bytes when set.
360    pub target_location_id: Option<String>,
361}
362
363impl SessionConfig {
364    /// Creates a session configuration with the documented defaults: a 30 s
365    /// heartbeat, 10 s handshake timeouts, a 1 MiB message limit, checksum and
366    /// length validation on, and no sequence resets.
367    ///
368    /// This applies defaults; it does not validate. `begin_string` is the one
369    /// argument that can be malformed — call [`SessionConfig::validate`], or
370    /// build through [`SessionConfigBuilder`], to find out before the session
371    /// dials.
372    ///
373    /// # Arguments
374    /// * `sender_comp_id` - The sender CompID (tag 49)
375    /// * `target_comp_id` - The target CompID (tag 56)
376    /// * `begin_string` - The `BeginString` (tag 8), e.g. `FIX.4.4`
377    #[must_use]
378    pub fn new(
379        sender_comp_id: CompId,
380        target_comp_id: CompId,
381        begin_string: impl Into<String>,
382    ) -> Self {
383        Self {
384            sender_comp_id,
385            target_comp_id,
386            begin_string: begin_string.into(),
387            heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
388            reset_on_logon: false,
389            reset_on_logout: false,
390            reset_on_disconnect: false,
391            max_message_size: DEFAULT_MESSAGE_SIZE_LIMIT,
392            logon_timeout: DEFAULT_TIMEOUT,
393            logout_timeout: DEFAULT_TIMEOUT,
394            validate_checksum: true,
395            validate_length: true,
396            sending_time_tolerance: Duration::from_secs(120),
397            resend_timeout: Duration::from_secs(10),
398            max_resend_requests: 3,
399            sender_sub_id: None,
400            target_sub_id: None,
401            sender_location_id: None,
402            target_location_id: None,
403        }
404    }
405
406    /// Checks every knob against the ranges documented on the module.
407    ///
408    /// The CompIDs are not re-checked: [`CompId`] validates its charset and
409    /// length at construction, so an illegal one is unrepresentable.
410    ///
411    /// [`Duration::ZERO`] passes as a heartbeat interval — it is the legal
412    /// `HeartBtInt = 0`. Only [`SessionConfigBuilder`] requires that case to
413    /// be opted into by name.
414    ///
415    /// # Errors
416    /// Returns the first [`SessionConfigError`] found: an empty, oversized or
417    /// non-encodable identity string, a fractional or out-of-range heartbeat
418    /// interval, a zero or excessive handshake timeout, or a message-size
419    /// limit outside [`MIN_MESSAGE_SIZE_LIMIT`]..=[`MAX_MESSAGE_SIZE_LIMIT`].
420    pub fn validate(&self) -> Result<(), SessionConfigError> {
421        validate_id("begin_string", &self.begin_string)?;
422        validate_heartbeat_interval(self.heartbeat_interval)?;
423        validate_timeout("logon_timeout", self.logon_timeout)?;
424        validate_timeout("logout_timeout", self.logout_timeout)?;
425
426        if !(MIN_MESSAGE_SIZE_LIMIT..=MAX_MESSAGE_SIZE_LIMIT).contains(&self.max_message_size) {
427            return Err(SessionConfigError::MessageSizeLimitOutOfRange {
428                size: self.max_message_size,
429                min: MIN_MESSAGE_SIZE_LIMIT,
430                max: MAX_MESSAGE_SIZE_LIMIT,
431            });
432        }
433
434        let optional = [
435            ("sender_sub_id", self.sender_sub_id.as_deref()),
436            ("target_sub_id", self.target_sub_id.as_deref()),
437            ("sender_location_id", self.sender_location_id.as_deref()),
438            ("target_location_id", self.target_location_id.as_deref()),
439        ];
440        for (field, value) in optional {
441            if let Some(value) = value {
442                validate_id(field, value)?;
443            }
444        }
445        Ok(())
446    }
447
448    /// Sets the heartbeat interval, `HeartBtInt` (108).
449    ///
450    /// Whole seconds, [`MIN_HEARTBEAT_INTERVAL_SECS`]..=[`MAX_HEARTBEAT_INTERVAL_SECS`],
451    /// or [`Duration::ZERO`] to disable heartbeating. Checked by
452    /// [`SessionConfig::validate`], not here.
453    #[must_use]
454    pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
455        self.heartbeat_interval = interval;
456        self
457    }
458
459    /// Sets whether the outbound Logon carries `ResetSeqNumFlag` (141) = Y.
460    #[must_use]
461    pub const fn with_reset_on_logon(mut self, reset: bool) -> Self {
462        self.reset_on_logon = reset;
463        self
464    }
465
466    /// Sets the maximum accepted message size, in bytes.
467    ///
468    /// [`MIN_MESSAGE_SIZE_LIMIT`]..=[`MAX_MESSAGE_SIZE_LIMIT`]. Checked by
469    /// [`SessionConfig::validate`], not here.
470    #[must_use]
471    pub const fn with_max_message_size(mut self, size: usize) -> Self {
472        self.max_message_size = size;
473        self
474    }
475
476    /// Sets how long to wait for the Logon acknowledgement.
477    ///
478    /// Non-zero, at most [`MAX_TIMEOUT`]. Checked by
479    /// [`SessionConfig::validate`], not here.
480    #[must_use]
481    pub const fn with_logon_timeout(mut self, timeout: Duration) -> Self {
482        self.logon_timeout = timeout;
483        self
484    }
485
486    /// Sets the sender sub ID (tag 50).
487    ///
488    /// 1..=[`MAX_ID_LEN`] bytes of printable ASCII except `=`. Checked by
489    /// [`SessionConfig::validate`], not here.
490    #[must_use]
491    pub fn with_sender_sub_id(mut self, sub_id: impl Into<String>) -> Self {
492        self.sender_sub_id = Some(sub_id.into());
493        self
494    }
495
496    /// Sets the target sub ID (tag 57).
497    ///
498    /// 1..=[`MAX_ID_LEN`] bytes of printable ASCII except `=`. Checked by
499    /// [`SessionConfig::validate`], not here.
500    #[must_use]
501    pub fn with_target_sub_id(mut self, sub_id: impl Into<String>) -> Self {
502        self.target_sub_id = Some(sub_id.into());
503        self
504    }
505
506    /// Sets the logout timeout.
507    #[must_use]
508    pub fn with_logout_timeout(mut self, timeout: Duration) -> Self {
509        self.logout_timeout = timeout;
510        self
511    }
512
513    /// Sets the tolerance applied to an inbound `SendingTime` (52).
514    ///
515    /// `Duration::ZERO` disables `SendingTime` validation. See
516    /// [`SessionConfig::sending_time_tolerance`] for the default and its
517    /// rationale.
518    #[must_use]
519    pub fn with_sending_time_tolerance(mut self, tolerance: Duration) -> Self {
520        self.sending_time_tolerance = tolerance;
521        self
522    }
523
524    /// Sets how long an outstanding `ResendRequest` (2) may make no progress
525    /// before it is retried.
526    #[must_use]
527    pub fn with_resend_timeout(mut self, timeout: Duration) -> Self {
528        self.resend_timeout = timeout;
529        self
530    }
531
532    /// Sets how many `ResendRequest` (2) messages may be sent for one gap,
533    /// counting the first. A value of 0 is read as 1.
534    #[must_use]
535    pub const fn with_max_resend_requests(mut self, attempts: u32) -> Self {
536        self.max_resend_requests = attempts;
537        self
538    }
539
540    /// Returns the heartbeat interval as the whole seconds that go into
541    /// `HeartBtInt` (108).
542    ///
543    /// Exact for any configuration that passed [`SessionConfig::validate`],
544    /// which is what rules out a fractional interval; on an unvalidated
545    /// configuration a sub-second interval would floor to 0, which on the wire
546    /// means "do not heartbeat".
547    #[must_use]
548    pub const fn heartbeat_interval_secs(&self) -> u64 {
549        self.heartbeat_interval.as_secs()
550    }
551
552    /// Returns how many `ResendRequest` (2) messages may be sent for one gap,
553    /// never less than the one that opens the recovery.
554    #[must_use]
555    pub const fn resend_attempt_limit(&self) -> u32 {
556        if self.max_resend_requests == 0 {
557            1
558        } else {
559            self.max_resend_requests
560        }
561    }
562}
563
564/// How the builder was told to configure heartbeating.
565///
566/// Keeps "no heartbeats, deliberately" distinguishable from "an interval that
567/// happens to be zero", which the plain [`Duration`] field cannot express.
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569enum HeartbeatSetting {
570    /// Heartbeat at this interval.
571    Interval(Duration),
572    /// `HeartBtInt = 0`: do not heartbeat at all.
573    Disabled,
574}
575
576/// Builder for [`SessionConfig`] — the canonical way to configure a session.
577///
578/// Every knob has a setter here, and [`SessionConfigBuilder::build`] validates
579/// all of them together. The sender and target CompIDs have no default and
580/// must be set; everything else falls back to the defaults documented on
581/// [`SessionConfig::new`].
582///
583/// # Example
584///
585/// ```
586/// use ironfix_core::types::CompId;
587/// use ironfix_session::config::SessionConfigBuilder;
588/// use std::time::Duration;
589///
590/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
591/// let config = SessionConfigBuilder::new()
592///     .sender_comp_id(CompId::new("CLIENT")?)
593///     .target_comp_id(CompId::new("VENUE")?)
594///     .begin_string("FIX.4.4")
595///     .heartbeat_interval(Duration::from_secs(30))
596///     .sender_sub_id("DESK")
597///     .build()?;
598///
599/// assert_eq!(config.heartbeat_interval_secs(), 30);
600/// # Ok(())
601/// # }
602/// ```
603#[derive(Debug, Clone)]
604pub struct SessionConfigBuilder {
605    /// Sender CompID (tag 49); required.
606    sender_comp_id: Option<CompId>,
607    /// Target CompID (tag 56); required.
608    target_comp_id: Option<CompId>,
609    /// `BeginString` (tag 8).
610    begin_string: String,
611    /// Heartbeat configuration, including the explicit "disabled" case.
612    heartbeat: HeartbeatSetting,
613    /// `ResetSeqNumFlag` (141) on the outbound Logon.
614    reset_on_logon: bool,
615    /// Reset sequence numbers after a graceful Logout.
616    reset_on_logout: bool,
617    /// Reset sequence numbers on disconnect.
618    reset_on_disconnect: bool,
619    /// Maximum accepted message size, in bytes.
620    max_message_size: usize,
621    /// Logon acknowledgement timeout.
622    logon_timeout: Duration,
623    /// Logout acknowledgement timeout.
624    logout_timeout: Duration,
625    /// Validate inbound `CheckSum` (10).
626    validate_checksum: bool,
627    /// Sender sub ID (tag 50).
628    sender_sub_id: Option<String>,
629    /// Target sub ID (tag 57).
630    target_sub_id: Option<String>,
631    /// Sender location ID (tag 142).
632    sender_location_id: Option<String>,
633    /// Target location ID (tag 143).
634    target_location_id: Option<String>,
635}
636
637impl Default for SessionConfigBuilder {
638    fn default() -> Self {
639        Self {
640            sender_comp_id: None,
641            target_comp_id: None,
642            begin_string: DEFAULT_BEGIN_STRING.to_string(),
643            heartbeat: HeartbeatSetting::Interval(DEFAULT_HEARTBEAT_INTERVAL),
644            reset_on_logon: false,
645            reset_on_logout: false,
646            reset_on_disconnect: false,
647            max_message_size: DEFAULT_MESSAGE_SIZE_LIMIT,
648            logon_timeout: DEFAULT_TIMEOUT,
649            logout_timeout: DEFAULT_TIMEOUT,
650            validate_checksum: true,
651            sender_sub_id: None,
652            target_sub_id: None,
653            sender_location_id: None,
654            target_location_id: None,
655        }
656    }
657}
658
659impl SessionConfigBuilder {
660    /// Creates a builder holding the defaults documented on
661    /// [`SessionConfig::new`].
662    #[must_use = "builders do nothing unless .build() is called"]
663    pub fn new() -> Self {
664        Self::default()
665    }
666
667    /// Sets the sender CompID (tag 49). Required.
668    #[must_use = "builders do nothing unless .build() is called"]
669    pub fn sender_comp_id(mut self, id: CompId) -> Self {
670        self.sender_comp_id = Some(id);
671        self
672    }
673
674    /// Sets the target CompID (tag 56). Required.
675    #[must_use = "builders do nothing unless .build() is called"]
676    pub fn target_comp_id(mut self, id: CompId) -> Self {
677        self.target_comp_id = Some(id);
678        self
679    }
680
681    /// Sets the `BeginString` (tag 8), e.g. `FIX.4.4`.
682    ///
683    /// 1..=[`MAX_ID_LEN`] bytes of printable ASCII except `=`.
684    #[must_use = "builders do nothing unless .build() is called"]
685    pub fn begin_string(mut self, version: impl Into<String>) -> Self {
686        self.begin_string = version.into();
687        self
688    }
689
690    /// Sets the heartbeat interval, `HeartBtInt` (108).
691    ///
692    /// Whole seconds, from [`MIN_HEARTBEAT_INTERVAL_SECS`] to
693    /// [`MAX_HEARTBEAT_INTERVAL_SECS`]. A fractional or out-of-range value is
694    /// refused by [`SessionConfigBuilder::build`]; [`Duration::ZERO`] is
695    /// refused there too, because disabling heartbeats is
696    /// [`SessionConfigBuilder::disable_heartbeats`].
697    #[must_use = "builders do nothing unless .build() is called"]
698    pub const fn heartbeat_interval(mut self, interval: Duration) -> Self {
699        self.heartbeat = HeartbeatSetting::Interval(interval);
700        self
701    }
702
703    /// Configures `HeartBtInt` (108) = 0: no Heartbeats, no TestRequests, and
704    /// no heartbeat-driven liveness check for the life of the session.
705    ///
706    /// This is legal FIX and sometimes what a venue asks for, but it means a
707    /// dead peer is only noticed when TCP notices. See the
708    /// [`crate::heartbeat`] module.
709    #[must_use = "builders do nothing unless .build() is called"]
710    pub const fn disable_heartbeats(mut self) -> Self {
711        self.heartbeat = HeartbeatSetting::Disabled;
712        self
713    }
714
715    /// Sets whether the outbound Logon carries `ResetSeqNumFlag` (141) = Y.
716    #[must_use = "builders do nothing unless .build() is called"]
717    pub const fn reset_on_logon(mut self, reset: bool) -> Self {
718        self.reset_on_logon = reset;
719        self
720    }
721
722    /// Sets whether sequence numbers reset after a graceful Logout.
723    #[must_use = "builders do nothing unless .build() is called"]
724    pub const fn reset_on_logout(mut self, reset: bool) -> Self {
725        self.reset_on_logout = reset;
726        self
727    }
728
729    /// Sets whether sequence numbers reset when the session disconnects.
730    #[must_use = "builders do nothing unless .build() is called"]
731    pub const fn reset_on_disconnect(mut self, reset: bool) -> Self {
732        self.reset_on_disconnect = reset;
733        self
734    }
735
736    /// Sets the maximum accepted message size, in bytes.
737    ///
738    /// [`MIN_MESSAGE_SIZE_LIMIT`]..=[`MAX_MESSAGE_SIZE_LIMIT`].
739    #[must_use = "builders do nothing unless .build() is called"]
740    pub const fn max_message_size(mut self, size: usize) -> Self {
741        self.max_message_size = size;
742        self
743    }
744
745    /// Sets how long to wait for the Logon acknowledgement.
746    ///
747    /// Non-zero, at most [`MAX_TIMEOUT`].
748    #[must_use = "builders do nothing unless .build() is called"]
749    pub const fn logon_timeout(mut self, timeout: Duration) -> Self {
750        self.logon_timeout = timeout;
751        self
752    }
753
754    /// Sets how long to wait for the Logout acknowledgement.
755    ///
756    /// Non-zero, at most [`MAX_TIMEOUT`].
757    #[must_use = "builders do nothing unless .build() is called"]
758    pub const fn logout_timeout(mut self, timeout: Duration) -> Self {
759        self.logout_timeout = timeout;
760        self
761    }
762
763    /// Sets whether inbound `CheckSum` (10) is validated.
764    #[must_use = "builders do nothing unless .build() is called"]
765    pub const fn validate_checksum(mut self, validate: bool) -> Self {
766        self.validate_checksum = validate;
767        self
768    }
769
770    /// Sets the sender sub ID (tag 50).
771    ///
772    /// 1..=[`MAX_ID_LEN`] bytes of printable ASCII except `=`.
773    #[must_use = "builders do nothing unless .build() is called"]
774    pub fn sender_sub_id(mut self, sub_id: impl Into<String>) -> Self {
775        self.sender_sub_id = Some(sub_id.into());
776        self
777    }
778
779    /// Sets the target sub ID (tag 57).
780    ///
781    /// 1..=[`MAX_ID_LEN`] bytes of printable ASCII except `=`.
782    #[must_use = "builders do nothing unless .build() is called"]
783    pub fn target_sub_id(mut self, sub_id: impl Into<String>) -> Self {
784        self.target_sub_id = Some(sub_id.into());
785        self
786    }
787
788    /// Sets the sender location ID (tag 142).
789    ///
790    /// 1..=[`MAX_ID_LEN`] bytes of printable ASCII except `=`.
791    #[must_use = "builders do nothing unless .build() is called"]
792    pub fn sender_location_id(mut self, location_id: impl Into<String>) -> Self {
793        self.sender_location_id = Some(location_id.into());
794        self
795    }
796
797    /// Sets the target location ID (tag 143).
798    ///
799    /// 1..=[`MAX_ID_LEN`] bytes of printable ASCII except `=`.
800    #[must_use = "builders do nothing unless .build() is called"]
801    pub fn target_location_id(mut self, location_id: impl Into<String>) -> Self {
802        self.target_location_id = Some(location_id.into());
803        self
804    }
805
806    /// Builds the configuration, validating every knob.
807    ///
808    /// # Errors
809    /// Returns [`SessionConfigError::MissingField`] if either CompID was never
810    /// set, and [`SessionConfigError::HeartbeatDisabledWithoutOptIn`] if
811    /// [`SessionConfigBuilder::heartbeat_interval`] was given
812    /// [`Duration::ZERO`] instead of calling
813    /// [`SessionConfigBuilder::disable_heartbeats`]. Everything else is the
814    /// first violation reported by [`SessionConfig::validate`].
815    pub fn build(self) -> Result<SessionConfig, SessionConfigError> {
816        let sender_comp_id = self
817            .sender_comp_id
818            .ok_or(SessionConfigError::MissingField {
819                field: "sender_comp_id",
820            })?;
821        let target_comp_id = self
822            .target_comp_id
823            .ok_or(SessionConfigError::MissingField {
824                field: "target_comp_id",
825            })?;
826
827        let heartbeat_interval = match self.heartbeat {
828            HeartbeatSetting::Disabled => Duration::ZERO,
829            HeartbeatSetting::Interval(interval) if interval.is_zero() => {
830                return Err(SessionConfigError::HeartbeatDisabledWithoutOptIn);
831            }
832            HeartbeatSetting::Interval(interval) => interval,
833        };
834
835        let config = SessionConfig {
836            sender_comp_id,
837            target_comp_id,
838            begin_string: self.begin_string,
839            heartbeat_interval,
840            reset_on_logon: self.reset_on_logon,
841            reset_on_logout: self.reset_on_logout,
842            reset_on_disconnect: self.reset_on_disconnect,
843            max_message_size: self.max_message_size,
844            logon_timeout: self.logon_timeout,
845            logout_timeout: self.logout_timeout,
846            validate_checksum: self.validate_checksum,
847            // Inbound-hardening knobs are not exposed on the builder; they take
848            // the same defaults as `SessionConfig::new` and are tuned through
849            // the fluent `SessionConfig::with_*` setters after `build()`.
850            validate_length: true,
851            sending_time_tolerance: Duration::from_secs(120),
852            resend_timeout: Duration::from_secs(10),
853            max_resend_requests: 3,
854            sender_sub_id: self.sender_sub_id,
855            target_sub_id: self.target_sub_id,
856            sender_location_id: self.sender_location_id,
857            target_location_id: self.target_location_id,
858        };
859        config.validate()?;
860        Ok(config)
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    /// Builds a CompID, failing the test rather than the session.
869    #[track_caller]
870    fn comp_id(value: &str) -> CompId {
871        match CompId::new(value) {
872            Ok(id) => id,
873            Err(err) => panic!("test CompID '{value}' must be valid: {err}"),
874        }
875    }
876
877    /// A builder with only the required knobs set.
878    fn required() -> SessionConfigBuilder {
879        SessionConfigBuilder::new()
880            .sender_comp_id(comp_id("SENDER"))
881            .target_comp_id(comp_id("TARGET"))
882    }
883
884    /// Unwraps a build that the test expects to succeed.
885    #[track_caller]
886    fn built(builder: SessionConfigBuilder) -> SessionConfig {
887        match builder.build() {
888            Ok(config) => config,
889            Err(err) => panic!("configuration must build: {err}"),
890        }
891    }
892
893    /// Returns the error from a build the test expects to fail.
894    #[track_caller]
895    fn build_error(builder: SessionConfigBuilder) -> SessionConfigError {
896        match builder.build() {
897            Ok(_) => panic!("configuration must not build"),
898            Err(err) => err,
899        }
900    }
901
902    // --- Defaults ------------------------------------------------------------
903
904    #[test]
905    fn test_session_config_new_applies_documented_defaults() {
906        let config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4");
907
908        assert_eq!(config.sender_comp_id.as_str(), "SENDER");
909        assert_eq!(config.target_comp_id.as_str(), "TARGET");
910        assert_eq!(config.begin_string, "FIX.4.4");
911        assert_eq!(config.heartbeat_interval, Duration::from_secs(30));
912        assert_eq!(config.logon_timeout, Duration::from_secs(10));
913        assert_eq!(config.logout_timeout, Duration::from_secs(10));
914        assert_eq!(config.max_message_size, 1024 * 1024);
915        assert!(config.validate_checksum);
916        assert!(!config.reset_on_logon);
917        assert_eq!(config.validate(), Ok(()));
918    }
919
920    #[test]
921    fn test_builder_defaults_match_session_config_new() {
922        let built_config = built(required());
923        let direct = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4");
924
925        assert_eq!(built_config.begin_string, direct.begin_string);
926        assert_eq!(built_config.heartbeat_interval, direct.heartbeat_interval);
927        assert_eq!(built_config.logon_timeout, direct.logon_timeout);
928        assert_eq!(built_config.logout_timeout, direct.logout_timeout);
929        assert_eq!(built_config.max_message_size, direct.max_message_size);
930        assert_eq!(built_config.validate_checksum, direct.validate_checksum);
931    }
932
933    // --- Required fields -----------------------------------------------------
934
935    #[test]
936    fn test_build_without_sender_comp_id_reports_missing_field() {
937        let builder = SessionConfigBuilder::new().target_comp_id(comp_id("TARGET"));
938        assert_eq!(
939            build_error(builder),
940            SessionConfigError::MissingField {
941                field: "sender_comp_id"
942            }
943        );
944    }
945
946    #[test]
947    fn test_build_without_target_comp_id_reports_missing_field() {
948        let builder = SessionConfigBuilder::new().sender_comp_id(comp_id("SENDER"));
949        assert_eq!(
950            build_error(builder),
951            SessionConfigError::MissingField {
952                field: "target_comp_id"
953            }
954        );
955    }
956
957    // --- Every setter lands in the built configuration -----------------------
958
959    #[test]
960    fn test_builder_setters_land_in_the_built_config() {
961        let config = built(
962            required()
963                .begin_string("FIX.4.2")
964                .heartbeat_interval(Duration::from_secs(60))
965                .reset_on_logon(true)
966                .reset_on_logout(true)
967                .reset_on_disconnect(true)
968                .max_message_size(4096)
969                .logon_timeout(Duration::from_secs(5))
970                .logout_timeout(Duration::from_secs(7))
971                .validate_checksum(false)
972                .sender_sub_id("SDESK")
973                .target_sub_id("TDESK")
974                .sender_location_id("LON")
975                .target_location_id("NYC"),
976        );
977
978        assert_eq!(config.sender_comp_id.as_str(), "SENDER");
979        assert_eq!(config.target_comp_id.as_str(), "TARGET");
980        assert_eq!(config.begin_string, "FIX.4.2");
981        assert_eq!(config.heartbeat_interval, Duration::from_secs(60));
982        assert!(config.reset_on_logon);
983        assert!(config.reset_on_logout);
984        assert!(config.reset_on_disconnect);
985        assert_eq!(config.max_message_size, 4096);
986        assert_eq!(config.logon_timeout, Duration::from_secs(5));
987        assert_eq!(config.logout_timeout, Duration::from_secs(7));
988        assert!(!config.validate_checksum);
989        assert_eq!(config.sender_sub_id.as_deref(), Some("SDESK"));
990        assert_eq!(config.target_sub_id.as_deref(), Some("TDESK"));
991        assert_eq!(config.sender_location_id.as_deref(), Some("LON"));
992        assert_eq!(config.target_location_id.as_deref(), Some("NYC"));
993    }
994
995    // --- Heartbeat interval --------------------------------------------------
996
997    #[test]
998    fn test_build_with_fractional_heartbeat_interval_is_rejected() {
999        let interval = Duration::from_millis(500);
1000        assert_eq!(
1001            build_error(required().heartbeat_interval(interval)),
1002            SessionConfigError::FractionalHeartbeatInterval { interval }
1003        );
1004    }
1005
1006    #[test]
1007    fn test_build_with_sub_second_heartbeat_never_truncates_to_zero() {
1008        // The defect this replaces: 500ms floored to HeartBtInt=0, which now
1009        // means "no heartbeating at all" on the wire.
1010        assert!(
1011            required()
1012                .heartbeat_interval(Duration::from_millis(500))
1013                .build()
1014                .is_err()
1015        );
1016    }
1017
1018    #[test]
1019    fn test_build_with_zero_heartbeat_interval_requires_the_explicit_opt_in() {
1020        assert_eq!(
1021            build_error(required().heartbeat_interval(Duration::ZERO)),
1022            SessionConfigError::HeartbeatDisabledWithoutOptIn
1023        );
1024    }
1025
1026    #[test]
1027    fn test_disable_heartbeats_builds_a_zero_interval() {
1028        let config = built(required().disable_heartbeats());
1029        assert_eq!(config.heartbeat_interval, Duration::ZERO);
1030        assert_eq!(config.heartbeat_interval_secs(), 0);
1031    }
1032
1033    #[test]
1034    fn test_build_with_heartbeat_interval_above_the_ceiling_is_rejected() {
1035        let secs = MAX_HEARTBEAT_INTERVAL_SECS + 1;
1036        assert_eq!(
1037            build_error(required().heartbeat_interval(Duration::from_secs(secs))),
1038            SessionConfigError::HeartbeatIntervalOutOfRange {
1039                secs,
1040                min: MIN_HEARTBEAT_INTERVAL_SECS,
1041                max: MAX_HEARTBEAT_INTERVAL_SECS,
1042            }
1043        );
1044    }
1045
1046    #[test]
1047    fn test_build_accepts_the_heartbeat_interval_bounds() {
1048        let min =
1049            built(required().heartbeat_interval(Duration::from_secs(MIN_HEARTBEAT_INTERVAL_SECS)));
1050        assert_eq!(min.heartbeat_interval_secs(), MIN_HEARTBEAT_INTERVAL_SECS);
1051
1052        let max =
1053            built(required().heartbeat_interval(Duration::from_secs(MAX_HEARTBEAT_INTERVAL_SECS)));
1054        assert_eq!(max.heartbeat_interval_secs(), MAX_HEARTBEAT_INTERVAL_SECS);
1055    }
1056
1057    #[test]
1058    fn test_heartbeat_interval_secs_equals_the_configured_whole_seconds() {
1059        let config = built(required().heartbeat_interval(Duration::from_secs(45)));
1060        assert_eq!(
1061            config.heartbeat_interval_secs(),
1062            config.heartbeat_interval.as_secs()
1063        );
1064        assert_eq!(config.heartbeat_interval_secs(), 45);
1065    }
1066
1067    // --- BeginString ---------------------------------------------------------
1068
1069    #[test]
1070    fn test_build_with_empty_begin_string_is_rejected() {
1071        assert_eq!(
1072            build_error(required().begin_string("")),
1073            SessionConfigError::EmptyField {
1074                field: "begin_string"
1075            }
1076        );
1077    }
1078
1079    #[test]
1080    fn test_build_with_soh_in_begin_string_is_rejected() {
1081        assert_eq!(
1082            build_error(required().begin_string("FIX\x014.4")),
1083            SessionConfigError::IllegalByte {
1084                field: "begin_string",
1085                byte: 0x01,
1086                position: 3,
1087            }
1088        );
1089    }
1090
1091    #[test]
1092    fn test_build_with_overlong_begin_string_is_rejected() {
1093        let long = "F".repeat(MAX_ID_LEN + 1);
1094        assert_eq!(
1095            build_error(required().begin_string(long)),
1096            SessionConfigError::FieldTooLong {
1097                field: "begin_string",
1098                len: MAX_ID_LEN + 1,
1099                max: MAX_ID_LEN,
1100            }
1101        );
1102    }
1103
1104    // --- Timeouts ------------------------------------------------------------
1105
1106    #[test]
1107    fn test_build_with_zero_logon_timeout_is_rejected() {
1108        assert_eq!(
1109            build_error(required().logon_timeout(Duration::ZERO)),
1110            SessionConfigError::TimeoutOutOfRange {
1111                field: "logon_timeout",
1112                timeout: Duration::ZERO,
1113                max: MAX_TIMEOUT,
1114            }
1115        );
1116    }
1117
1118    #[test]
1119    fn test_build_with_zero_logout_timeout_is_rejected() {
1120        assert_eq!(
1121            build_error(required().logout_timeout(Duration::ZERO)),
1122            SessionConfigError::TimeoutOutOfRange {
1123                field: "logout_timeout",
1124                timeout: Duration::ZERO,
1125                max: MAX_TIMEOUT,
1126            }
1127        );
1128    }
1129
1130    #[test]
1131    fn test_build_with_excessive_logon_timeout_is_rejected() {
1132        let timeout = MAX_TIMEOUT + Duration::from_secs(1);
1133        assert_eq!(
1134            build_error(required().logon_timeout(timeout)),
1135            SessionConfigError::TimeoutOutOfRange {
1136                field: "logon_timeout",
1137                timeout,
1138                max: MAX_TIMEOUT,
1139            }
1140        );
1141    }
1142
1143    #[test]
1144    fn test_build_accepts_a_sub_second_timeout() {
1145        // Timeouts are not wire values: unlike HeartBtInt they may be
1146        // fractional, and short ones are how a test drives the handshake.
1147        let config = built(required().logon_timeout(Duration::from_millis(300)));
1148        assert_eq!(config.logon_timeout, Duration::from_millis(300));
1149    }
1150
1151    // --- Message size limit --------------------------------------------------
1152
1153    #[test]
1154    fn test_build_with_zero_max_message_size_is_rejected() {
1155        assert_eq!(
1156            build_error(required().max_message_size(0)),
1157            SessionConfigError::MessageSizeLimitOutOfRange {
1158                size: 0,
1159                min: MIN_MESSAGE_SIZE_LIMIT,
1160                max: MAX_MESSAGE_SIZE_LIMIT,
1161            }
1162        );
1163    }
1164
1165    #[test]
1166    fn test_build_with_max_message_size_below_the_floor_is_rejected() {
1167        let size = MIN_MESSAGE_SIZE_LIMIT - 1;
1168        assert_eq!(
1169            build_error(required().max_message_size(size)),
1170            SessionConfigError::MessageSizeLimitOutOfRange {
1171                size,
1172                min: MIN_MESSAGE_SIZE_LIMIT,
1173                max: MAX_MESSAGE_SIZE_LIMIT,
1174            }
1175        );
1176    }
1177
1178    #[test]
1179    fn test_build_with_max_message_size_above_the_ceiling_is_rejected() {
1180        let size = MAX_MESSAGE_SIZE_LIMIT + 1;
1181        assert_eq!(
1182            build_error(required().max_message_size(size)),
1183            SessionConfigError::MessageSizeLimitOutOfRange {
1184                size,
1185                min: MIN_MESSAGE_SIZE_LIMIT,
1186                max: MAX_MESSAGE_SIZE_LIMIT,
1187            }
1188        );
1189    }
1190
1191    #[test]
1192    fn test_build_accepts_the_message_size_bounds() {
1193        assert_eq!(
1194            built(required().max_message_size(MIN_MESSAGE_SIZE_LIMIT)).max_message_size,
1195            MIN_MESSAGE_SIZE_LIMIT
1196        );
1197        assert_eq!(
1198            built(required().max_message_size(MAX_MESSAGE_SIZE_LIMIT)).max_message_size,
1199            MAX_MESSAGE_SIZE_LIMIT
1200        );
1201    }
1202
1203    // --- Sub IDs and location IDs --------------------------------------------
1204
1205    #[test]
1206    fn test_build_with_empty_sender_sub_id_is_rejected() {
1207        assert_eq!(
1208            build_error(required().sender_sub_id("")),
1209            SessionConfigError::EmptyField {
1210                field: "sender_sub_id"
1211            }
1212        );
1213    }
1214
1215    #[test]
1216    fn test_build_with_soh_in_target_sub_id_is_rejected() {
1217        assert_eq!(
1218            build_error(required().target_sub_id("DE\x01SK")),
1219            SessionConfigError::IllegalByte {
1220                field: "target_sub_id",
1221                byte: 0x01,
1222                position: 2,
1223            }
1224        );
1225    }
1226
1227    #[test]
1228    fn test_build_with_equals_in_sender_location_id_is_rejected() {
1229        assert_eq!(
1230            build_error(required().sender_location_id("LON=1")),
1231            SessionConfigError::IllegalByte {
1232                field: "sender_location_id",
1233                byte: b'=',
1234                position: 3,
1235            }
1236        );
1237    }
1238
1239    #[test]
1240    fn test_build_with_overlong_target_location_id_is_rejected() {
1241        let long = "N".repeat(MAX_ID_LEN + 1);
1242        assert_eq!(
1243            build_error(required().target_location_id(long)),
1244            SessionConfigError::FieldTooLong {
1245                field: "target_location_id",
1246                len: MAX_ID_LEN + 1,
1247                max: MAX_ID_LEN,
1248            }
1249        );
1250    }
1251
1252    #[test]
1253    fn test_build_with_non_ascii_sub_id_is_rejected() {
1254        // 'é' is two UTF-8 bytes, neither of them printable ASCII.
1255        assert!(matches!(
1256            build_error(required().sender_sub_id("DESKé")),
1257            SessionConfigError::IllegalByte {
1258                field: "sender_sub_id",
1259                ..
1260            }
1261        ));
1262    }
1263
1264    // --- validate() on a hand-assembled configuration ------------------------
1265
1266    #[test]
1267    fn test_validate_rejects_a_hand_assembled_fractional_heartbeat() {
1268        let config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4")
1269            .with_heartbeat_interval(Duration::from_millis(1500));
1270
1271        assert_eq!(
1272            config.validate(),
1273            Err(SessionConfigError::FractionalHeartbeatInterval {
1274                interval: Duration::from_millis(1500)
1275            })
1276        );
1277    }
1278
1279    #[test]
1280    fn test_validate_accepts_a_zero_heartbeat_interval() {
1281        // HeartBtInt = 0 is legal FIX; only the builder insists it be asked
1282        // for by name.
1283        let config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4")
1284            .with_heartbeat_interval(Duration::ZERO);
1285        assert_eq!(config.validate(), Ok(()));
1286    }
1287
1288    #[test]
1289    fn test_validate_rejects_a_hand_assembled_sub_id_with_soh() {
1290        let config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4")
1291            .with_sender_sub_id("DESK\x01");
1292
1293        assert_eq!(
1294            config.validate(),
1295            Err(SessionConfigError::IllegalByte {
1296                field: "sender_sub_id",
1297                byte: 0x01,
1298                position: 4,
1299            })
1300        );
1301    }
1302
1303    #[test]
1304    fn test_validate_rejects_a_hand_assembled_message_size_of_zero() {
1305        let mut config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4");
1306        config.max_message_size = 0;
1307
1308        assert_eq!(
1309            config.validate(),
1310            Err(SessionConfigError::MessageSizeLimitOutOfRange {
1311                size: 0,
1312                min: MIN_MESSAGE_SIZE_LIMIT,
1313                max: MAX_MESSAGE_SIZE_LIMIT,
1314            })
1315        );
1316    }
1317
1318    fn config() -> SessionConfig {
1319        SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4")
1320    }
1321
1322    #[test]
1323    fn test_session_config_recovery_defaults_are_bounded() {
1324        let config = config();
1325
1326        // Two minutes of clock skew, the interval FIX engines have converged
1327        // on; three resend attempts ten seconds apart.
1328        assert_eq!(config.sending_time_tolerance, Duration::from_secs(120));
1329        assert_eq!(config.resend_timeout, Duration::from_secs(10));
1330        assert_eq!(config.max_resend_requests, 3);
1331        assert_eq!(config.resend_attempt_limit(), 3);
1332    }
1333
1334    #[test]
1335    fn test_session_config_zero_resend_requests_still_allows_one() {
1336        // The first ResendRequest is unconditional: it is what opens the
1337        // recovery the limit governs, so zero cannot mean "never ask".
1338        let config = config().with_max_resend_requests(0);
1339
1340        assert_eq!(config.resend_attempt_limit(), 1);
1341    }
1342
1343    #[test]
1344    fn test_session_config_zero_tolerance_disables_sending_time_check() {
1345        let config = config().with_sending_time_tolerance(Duration::ZERO);
1346
1347        assert_eq!(config.sending_time_tolerance, Duration::ZERO);
1348    }
1349
1350    #[test]
1351    fn test_session_config_recovery_setters_apply() {
1352        let config = config()
1353            .with_sending_time_tolerance(Duration::from_secs(5))
1354            .with_resend_timeout(Duration::from_millis(250))
1355            .with_max_resend_requests(7)
1356            .with_logout_timeout(Duration::from_secs(3));
1357
1358        assert_eq!(config.sending_time_tolerance, Duration::from_secs(5));
1359        assert_eq!(config.resend_timeout, Duration::from_millis(250));
1360        assert_eq!(config.resend_attempt_limit(), 7);
1361        assert_eq!(config.logout_timeout, Duration::from_secs(3));
1362    }
1363}