1use crate::heartbeat::MAX_HEARTBEAT_INTERVAL_SECS;
49use ironfix_core::types::{COMP_ID_MAX_LEN, CompId};
50use std::time::Duration;
51
52pub const MIN_HEARTBEAT_INTERVAL_SECS: u64 = 1;
58
59pub const MAX_TIMEOUT: Duration = Duration::from_secs(300);
65
66pub const MIN_MESSAGE_SIZE_LIMIT: usize = 512;
73
74pub const MAX_MESSAGE_SIZE_LIMIT: usize = 64 * 1024 * 1024;
81
82pub const MAX_ID_LEN: usize = COMP_ID_MAX_LEN;
89
90const DEFAULT_BEGIN_STRING: &str = "FIX.4.4";
92
93const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
95
96const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
98
99const DEFAULT_MESSAGE_SIZE_LIMIT: usize = 1024 * 1024;
101
102#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
107#[non_exhaustive]
108pub enum SessionConfigError {
109 #[error("{field} is required")]
111 MissingField {
112 field: &'static str,
114 },
115
116 #[error("{field} must not be empty")]
121 EmptyField {
122 field: &'static str,
124 },
125
126 #[error("{field} is {len} bytes, over the {max}-byte maximum")]
128 FieldTooLong {
129 field: &'static str,
131 len: usize,
133 max: usize,
135 },
136
137 #[error("{field} contains illegal byte 0x{byte:02x} at position {position}")]
143 IllegalByte {
144 field: &'static str,
146 byte: u8,
148 position: usize,
150 },
151
152 #[error(
154 "heartbeat interval {interval:?} is not a whole number of seconds; HeartBtInt (108) is expressed in whole seconds"
155 )]
156 FractionalHeartbeatInterval {
157 interval: Duration,
159 },
160
161 #[error("heartbeat interval of {secs}s is outside the supported range {min}s..={max}s")]
163 HeartbeatIntervalOutOfRange {
164 secs: u64,
166 min: u64,
168 max: u64,
170 },
171
172 #[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 #[error("{field} of {timeout:?} is outside the supported range (non-zero, at most {max:?})")]
185 TimeoutOutOfRange {
186 field: &'static str,
188 timeout: Duration,
190 max: Duration,
192 },
193
194 #[error("max_message_size of {size} bytes is outside the supported range {min}..={max}")]
196 MessageSizeLimitOutOfRange {
197 size: usize,
199 min: usize,
201 max: usize,
203 },
204}
205
206fn 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
234fn 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
246fn 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#[derive(Debug, Clone)]
275pub struct SessionConfig {
276 pub sender_comp_id: CompId,
278 pub target_comp_id: CompId,
280 pub begin_string: String,
286 pub heartbeat_interval: Duration,
292 pub reset_on_logon: bool,
294 pub reset_on_logout: bool,
296 pub reset_on_disconnect: bool,
298 pub max_message_size: usize,
303 pub logon_timeout: Duration,
307 pub logout_timeout: Duration,
311 pub validate_checksum: bool,
313 pub validate_length: bool,
315 pub sending_time_tolerance: Duration,
331 pub resend_timeout: Duration,
341 pub max_resend_requests: u32,
353 pub sender_sub_id: Option<String>,
355 pub target_sub_id: Option<String>,
357 pub sender_location_id: Option<String>,
359 pub target_location_id: Option<String>,
361}
362
363impl SessionConfig {
364 #[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 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 #[must_use]
454 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
455 self.heartbeat_interval = interval;
456 self
457 }
458
459 #[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 #[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 #[must_use]
481 pub const fn with_logon_timeout(mut self, timeout: Duration) -> Self {
482 self.logon_timeout = timeout;
483 self
484 }
485
486 #[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 #[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 #[must_use]
508 pub fn with_logout_timeout(mut self, timeout: Duration) -> Self {
509 self.logout_timeout = timeout;
510 self
511 }
512
513 #[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 #[must_use]
527 pub fn with_resend_timeout(mut self, timeout: Duration) -> Self {
528 self.resend_timeout = timeout;
529 self
530 }
531
532 #[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 #[must_use]
548 pub const fn heartbeat_interval_secs(&self) -> u64 {
549 self.heartbeat_interval.as_secs()
550 }
551
552 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569enum HeartbeatSetting {
570 Interval(Duration),
572 Disabled,
574}
575
576#[derive(Debug, Clone)]
604pub struct SessionConfigBuilder {
605 sender_comp_id: Option<CompId>,
607 target_comp_id: Option<CompId>,
609 begin_string: String,
611 heartbeat: HeartbeatSetting,
613 reset_on_logon: bool,
615 reset_on_logout: bool,
617 reset_on_disconnect: bool,
619 max_message_size: usize,
621 logon_timeout: Duration,
623 logout_timeout: Duration,
625 validate_checksum: bool,
627 sender_sub_id: Option<String>,
629 target_sub_id: Option<String>,
631 sender_location_id: Option<String>,
633 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 #[must_use = "builders do nothing unless .build() is called"]
663 pub fn new() -> Self {
664 Self::default()
665 }
666
667 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 fn required() -> SessionConfigBuilder {
879 SessionConfigBuilder::new()
880 .sender_comp_id(comp_id("SENDER"))
881 .target_comp_id(comp_id("TARGET"))
882 }
883
884 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 let config = built(required().logon_timeout(Duration::from_millis(300)));
1148 assert_eq!(config.logon_timeout, Duration::from_millis(300));
1149 }
1150
1151 #[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 #[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 assert!(matches!(
1256 build_error(required().sender_sub_id("DESKé")),
1257 SessionConfigError::IllegalByte {
1258 field: "sender_sub_id",
1259 ..
1260 }
1261 ));
1262 }
1263
1264 #[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 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 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 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}