Skip to main content

quickfix_tokio/
settings.rs

1//! QuickFIX-compatible configuration: `[DEFAULT]` + `[SESSION]` INI sections.
2//!
3//! Key names follow the classic engines (`SenderCompID`, `HeartBtInt`,
4//! `SocketConnectPort`, ...) so existing config files carry over.
5
6use std::collections::HashMap;
7use std::time::Duration;
8
9use crate::datadictionary::ValidationSettings;
10use crate::error::{Error, Result};
11use crate::schedule::Schedule;
12use crate::session_id::SessionId;
13use crate::value::TimestampPrecision;
14
15#[derive(Debug, Clone, Default)]
16pub struct Settings {
17    pub defaults: HashMap<String, String>,
18    pub sessions: Vec<HashMap<String, String>>,
19}
20
21impl Settings {
22    pub fn parse(text: &str) -> Result<Self> {
23        let mut settings = Settings::default();
24        let mut current: Option<HashMap<String, String>> = None;
25        let mut in_default = false;
26
27        for (line_no, line) in text.lines().enumerate() {
28            let line = line.trim();
29            if line.is_empty() || line.starts_with('#') {
30                continue;
31            }
32            if line.starts_with('[') && line.ends_with(']') {
33                if let Some(section) = current.take() {
34                    settings.sessions.push(section);
35                }
36                let name = line[1..line.len() - 1].trim().to_ascii_uppercase();
37                match name.as_str() {
38                    "DEFAULT" => in_default = true,
39                    "SESSION" => {
40                        in_default = false;
41                        current = Some(HashMap::new());
42                    }
43                    other => {
44                        return Err(Error::Config(format!(
45                            "line {}: unknown section [{other}]",
46                            line_no + 1
47                        )));
48                    }
49                }
50                continue;
51            }
52            let Some((key, value)) = line.split_once('=') else {
53                return Err(Error::Config(format!(
54                    "line {}: expected key=value, got {line:?}",
55                    line_no + 1
56                )));
57            };
58            let (key, value) = (key.trim().to_owned(), value.trim().to_owned());
59            if let Some(section) = current.as_mut() {
60                section.insert(key, value);
61            } else if in_default {
62                settings.defaults.insert(key, value);
63            } else {
64                return Err(Error::Config(format!(
65                    "line {}: key outside of [DEFAULT]/[SESSION]",
66                    line_no + 1
67                )));
68            }
69        }
70        if let Some(section) = current.take() {
71            settings.sessions.push(section);
72        }
73        Ok(settings)
74    }
75
76    pub async fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
77        let text = tokio::fs::read_to_string(path).await?;
78        Self::parse(&text)
79    }
80
81    /// Resolve every `[SESSION]` into a typed config (session keys override
82    /// `[DEFAULT]`).
83    pub fn session_configs(&self) -> Result<Vec<SessionConfig>> {
84        self.sessions
85            .iter()
86            .map(|s| {
87                let mut merged = self.defaults.clone();
88                merged.extend(s.iter().map(|(k, v)| (k.clone(), v.clone())));
89                SessionConfig::from_map(&merged)
90            })
91            .collect()
92    }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ConnectionType {
97    Initiator,
98    Acceptor,
99}
100
101/// TLS transport configuration (go-style `Socket*` keys).
102#[derive(Debug, Clone, Default)]
103pub struct TlsSettings {
104    /// `SocketUseSSL=Y` — wrap the connection in TLS.
105    pub enabled: bool,
106    /// `SocketCertificateFile` — PEM certificate chain. Required for an
107    /// acceptor; supplies the client certificate for mutual TLS on an
108    /// initiator.
109    pub certificate_file: Option<String>,
110    /// `SocketPrivateKeyFile` — PEM private key paired with the certificate.
111    pub private_key_file: Option<String>,
112    /// `SocketCAFile` — PEM trust anchors. An initiator verifies the server
113    /// against these (falling back to the webpki roots when absent); an
114    /// acceptor requires and verifies client certificates against them
115    /// (mutual TLS).
116    pub ca_file: Option<String>,
117    /// `SocketInsecureSkipVerify=Y` — initiator skips server certificate
118    /// verification. Testing/self-signed only.
119    pub insecure_skip_verify: bool,
120    /// `SocketServerName` — SNI / verification hostname for an initiator.
121    pub server_name: Option<String>,
122}
123
124#[derive(Debug, Clone)]
125pub struct SessionConfig {
126    pub session_id: SessionId,
127    pub connection_type: ConnectionType,
128    /// Negotiated heartbeat interval. Required for initiators; acceptors
129    /// adopt the initiator's value unless `HeartBtIntOverride` is set.
130    pub heart_bt_int: Duration,
131    pub heart_bt_int_override: Option<Duration>,
132    pub socket_connect_host: String,
133    pub socket_connect_port: u16,
134    pub socket_accept_port: u16,
135    pub reconnect_interval: Duration,
136    pub logon_timeout: Duration,
137    pub logout_timeout: Duration,
138    pub reset_on_logon: bool,
139    pub reset_on_logout: bool,
140    pub reset_on_disconnect: bool,
141    pub refresh_on_logon: bool,
142    /// Send ResetSeqNumFlag(141)=Y on our Logon.
143    pub send_reset_seq_num_flag: bool,
144    pub persist_messages: bool,
145    pub check_comp_id: bool,
146    pub check_latency: bool,
147    pub max_latency: Duration,
148    pub send_redundant_resend_requests: bool,
149    pub timestamp_precision: TimestampPrecision,
150    pub validate_length_checksum: bool,
151    pub use_data_dictionary: bool,
152    pub data_dictionary: Option<String>,
153    /// FIXT sessions: session-level dictionary (FIXT11.xml).
154    pub transport_data_dictionary: Option<String>,
155    /// FIXT sessions: application-level dictionary (FIX50.xml etc).
156    pub app_data_dictionary: Option<String>,
157    pub validation: ValidationSettings,
158    /// Stamp LastMsgSeqNumProcessed(369) on every outgoing header.
159    pub enable_last_msg_seq_num_processed: bool,
160    /// Cap ResendRequests to this many messages per chunk (0 = unlimited,
161    /// EndSeqNo sent as 0/999999 "infinity").
162    pub max_messages_in_resend_request: u64,
163    /// Send a Logout before disconnecting on heartbeat timeout.
164    pub send_logout_before_disconnect_from_timeout: bool,
165    /// Require OrigSendingTime(122) on PossDup messages (default Y).
166    pub requires_orig_sending_time: bool,
167    /// Negotiate sequence recovery via NextExpectedMsgSeqNum(789) on the
168    /// Logon handshake (C++ `SendNextExpectedMsgSeqNum`, go
169    /// `EnableNextExpectedMsgSeqNum`). Default off, like both references.
170    pub send_next_expected_msg_seq_num: bool,
171    /// TLS transport settings (go-style `Socket*` keys). See [`TlsSettings`].
172    pub tls: TlsSettings,
173    /// When the session is active and resets (`StartTime`/`EndTime`,
174    /// `StartDay`/`EndDay`, `NonStopSession`, `UseLocalTime`). Non-stop by
175    /// default (no times configured).
176    pub schedule: Schedule,
177    /// When logons are accepted (`LogonTime`/`LogoutTime`,
178    /// `LogonDay`/`LogoutDay`); defaults to the session schedule.
179    pub logon_schedule: Schedule,
180    pub file_store_path: Option<String>,
181    pub file_log_path: Option<String>,
182    /// FIXT.1.1 sessions: DefaultApplVerID(1137) for our Logon.
183    pub default_appl_ver_id: Option<String>,
184}
185
186fn get_bool(m: &HashMap<String, String>, key: &str, default: bool) -> Result<bool> {
187    match m.get(key).map(|s| s.as_str()) {
188        None => Ok(default),
189        Some("Y") => Ok(true),
190        Some("N") => Ok(false),
191        Some(other) => Err(Error::Config(format!("{key} must be Y or N, got {other:?}"))),
192    }
193}
194
195fn get_u64(m: &HashMap<String, String>, key: &str, default: u64) -> Result<u64> {
196    match m.get(key) {
197        None => Ok(default),
198        Some(v) => v.parse().map_err(|_| Error::Config(format!("{key} must be a number"))),
199    }
200}
201
202impl SessionConfig {
203    pub fn from_map(m: &HashMap<String, String>) -> Result<Self> {
204        let require = |key: &str| {
205            m.get(key)
206                .cloned()
207                .ok_or_else(|| Error::Config(format!("missing required setting {key}")))
208        };
209
210        let begin_string = require("BeginString")?;
211        const VALID: &[&str] =
212            &["FIX.4.0", "FIX.4.1", "FIX.4.2", "FIX.4.3", "FIX.4.4", "FIXT.1.1"];
213        if !VALID.contains(&begin_string.as_str()) {
214            return Err(Error::Config(format!("unsupported BeginString {begin_string:?}")));
215        }
216
217        let session_id = SessionId {
218            begin_string: begin_string.clone(),
219            sender_comp_id: require("SenderCompID")?,
220            sender_sub_id: m.get("SenderSubID").cloned().unwrap_or_default(),
221            sender_location_id: m.get("SenderLocationID").cloned().unwrap_or_default(),
222            target_comp_id: require("TargetCompID")?,
223            target_sub_id: m.get("TargetSubID").cloned().unwrap_or_default(),
224            target_location_id: m.get("TargetLocationID").cloned().unwrap_or_default(),
225            qualifier: m.get("SessionQualifier").cloned().unwrap_or_default(),
226        };
227
228        let connection_type = match require("ConnectionType")?.as_str() {
229            "initiator" => ConnectionType::Initiator,
230            "acceptor" => ConnectionType::Acceptor,
231            other => {
232                return Err(Error::Config(format!(
233                    "ConnectionType must be initiator or acceptor, got {other:?}"
234                )));
235            }
236        };
237
238        // Session schedule; logon schedule defaults to it.
239        let use_local = get_bool(m, "UseLocalTime", false)?;
240        let non_stop = get_bool(m, "NonStopSession", false)?;
241        let str_opt = |k: &str| m.get(k).map(|s| s.as_str());
242        let schedule = Schedule::parse(
243            str_opt("StartTime"),
244            str_opt("EndTime"),
245            str_opt("StartDay"),
246            str_opt("EndDay"),
247            use_local,
248            non_stop,
249        )?;
250        let logon_schedule = if m.contains_key("LogonTime") || m.contains_key("LogoutTime") {
251            Schedule::parse(
252                str_opt("LogonTime"),
253                str_opt("LogoutTime"),
254                str_opt("LogonDay").or(str_opt("StartDay")),
255                str_opt("LogoutDay").or(str_opt("EndDay")),
256                use_local,
257                non_stop,
258            )?
259        } else {
260            schedule.clone()
261        };
262
263        let heart_bt_int = match connection_type {
264            ConnectionType::Initiator => {
265                let secs: u64 = require("HeartBtInt")?
266                    .parse()
267                    .map_err(|_| Error::Config("HeartBtInt must be a number".into()))?;
268                if secs == 0 {
269                    return Err(Error::Config("HeartBtInt must be > 0".into()));
270                }
271                Duration::from_secs(secs)
272            }
273            ConnectionType::Acceptor => Duration::from_secs(get_u64(m, "HeartBtInt", 30)?),
274        };
275
276        let (socket_connect_host, socket_connect_port, socket_accept_port) = match connection_type {
277            ConnectionType::Initiator => (
278                require("SocketConnectHost")?,
279                require("SocketConnectPort")?
280                    .parse()
281                    .map_err(|_| Error::Config("SocketConnectPort must be a port".into()))?,
282                0,
283            ),
284            ConnectionType::Acceptor => (
285                String::new(),
286                0,
287                require("SocketAcceptPort")?
288                    .parse()
289                    .map_err(|_| Error::Config("SocketAcceptPort must be a port".into()))?,
290            ),
291        };
292
293        // FIX < 4.2 has no sub-second timestamps.
294        let default_precision = if begin_string.as_str() < "FIX.4.2" {
295            TimestampPrecision::Seconds
296        } else {
297            TimestampPrecision::Millis
298        };
299        let timestamp_precision = match m.get("TimestampPrecision").map(|s| s.as_str()) {
300            None => match get_bool(m, "MillisecondsInTimeStamp", true)? {
301                true => default_precision,
302                false => TimestampPrecision::Seconds,
303            },
304            Some("0") => TimestampPrecision::Seconds,
305            Some("3") => TimestampPrecision::Millis,
306            Some("6") => TimestampPrecision::Micros,
307            Some("9") => TimestampPrecision::Nanos,
308            Some(other) => {
309                return Err(Error::Config(format!(
310                    "TimestampPrecision must be 0, 3, 6 or 9, got {other:?}"
311                )));
312            }
313        };
314
315        if session_id.is_fixt() && !m.contains_key("DefaultApplVerID") {
316            return Err(Error::Config("FIXT.1.1 sessions require DefaultApplVerID".into()));
317        }
318
319        Ok(Self {
320            connection_type,
321            heart_bt_int,
322            heart_bt_int_override: m
323                .get("HeartBtIntOverride")
324                .map(|v| {
325                    v.parse::<u64>()
326                        .map(Duration::from_secs)
327                        .map_err(|_| Error::Config("HeartBtIntOverride must be a number".into()))
328                })
329                .transpose()?,
330            socket_connect_host,
331            socket_connect_port,
332            socket_accept_port,
333            reconnect_interval: Duration::from_secs(get_u64(m, "ReconnectInterval", 30)?),
334            logon_timeout: Duration::from_secs(get_u64(m, "LogonTimeout", 10)?),
335            logout_timeout: Duration::from_secs(get_u64(m, "LogoutTimeout", 2)?),
336            reset_on_logon: get_bool(m, "ResetOnLogon", false)?,
337            reset_on_logout: get_bool(m, "ResetOnLogout", false)?,
338            reset_on_disconnect: get_bool(m, "ResetOnDisconnect", false)?,
339            refresh_on_logon: get_bool(m, "RefreshOnLogon", false)?,
340            send_reset_seq_num_flag: get_bool(m, "SendResetSeqNumFlag", false)?,
341            persist_messages: get_bool(m, "PersistMessages", true)?,
342            check_comp_id: get_bool(m, "CheckCompID", true)?,
343            check_latency: get_bool(m, "CheckLatency", true)?,
344            max_latency: Duration::from_secs(get_u64(m, "MaxLatency", 120)?),
345            send_redundant_resend_requests: get_bool(m, "SendRedundantResendRequests", false)?,
346            timestamp_precision,
347            validate_length_checksum: get_bool(m, "ValidateLengthAndChecksum", true)?,
348            use_data_dictionary: get_bool(m, "UseDataDictionary", true)?,
349            data_dictionary: m.get("DataDictionary").cloned(),
350            transport_data_dictionary: m.get("TransportDataDictionary").cloned(),
351            app_data_dictionary: m.get("AppDataDictionary").cloned(),
352            enable_last_msg_seq_num_processed: get_bool(m, "EnableLastMsgSeqNumProcessed", false)?,
353            max_messages_in_resend_request: get_u64(m, "MaxMessagesInResendRequest", 0)?,
354            send_logout_before_disconnect_from_timeout: get_bool(
355                m,
356                "SendLogoutBeforeDisconnectFromTimeout",
357                false,
358            )?,
359            requires_orig_sending_time: get_bool(m, "RequiresOrigSendingTime", true)?,
360            send_next_expected_msg_seq_num: get_bool(m, "SendNextExpectedMsgSeqNum", false)?
361                || get_bool(m, "EnableNextExpectedMsgSeqNum", false)?,
362            tls: TlsSettings {
363                enabled: get_bool(m, "SocketUseSSL", false)?,
364                certificate_file: m.get("SocketCertificateFile").cloned(),
365                private_key_file: m.get("SocketPrivateKeyFile").cloned(),
366                ca_file: m.get("SocketCAFile").cloned(),
367                insecure_skip_verify: get_bool(m, "SocketInsecureSkipVerify", false)?,
368                server_name: m.get("SocketServerName").cloned(),
369            },
370            schedule,
371            logon_schedule,
372            validation: ValidationSettings {
373                check_fields_out_of_order: get_bool(m, "ValidateFieldsOutOfOrder", true)?,
374                check_fields_have_values: get_bool(m, "ValidateFieldsHaveValues", true)?,
375                check_user_defined_fields: get_bool(m, "ValidateUserDefinedFields", true)?,
376                allow_unknown_message_fields: get_bool(m, "AllowUnknownMsgFields", false)?,
377            },
378            file_store_path: m.get("FileStorePath").cloned(),
379            file_log_path: m.get("FileLogPath").cloned(),
380            default_appl_ver_id: m.get("DefaultApplVerID").cloned(),
381            session_id,
382        })
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    const SAMPLE: &str = r#"
391# comment
392[DEFAULT]
393ConnectionType=initiator
394ReconnectInterval=15
395SocketConnectHost=127.0.0.1
396
397[SESSION]
398BeginString=FIX.4.2
399SenderCompID=CLIENT1
400TargetCompID=EXEC
401HeartBtInt=30
402SocketConnectPort=9876
403
404[SESSION]
405BeginString=FIX.4.4
406SenderCompID=CLIENT1
407TargetCompID=EXEC2
408HeartBtInt=20
409SocketConnectPort=9877
410ResetOnLogon=Y
411"#;
412
413    #[test]
414    fn parses_sessions_with_default_overlay() {
415        let settings = Settings::parse(SAMPLE).unwrap();
416        let configs = settings.session_configs().unwrap();
417        assert_eq!(configs.len(), 2);
418
419        let c = &configs[0];
420        assert_eq!(c.session_id.to_string(), "FIX.4.2:CLIENT1->EXEC");
421        assert_eq!(c.connection_type, ConnectionType::Initiator);
422        assert_eq!(c.heart_bt_int, Duration::from_secs(30));
423        assert_eq!(c.reconnect_interval, Duration::from_secs(15));
424        assert_eq!(c.socket_connect_host, "127.0.0.1");
425        assert_eq!(c.socket_connect_port, 9876);
426        assert!(!c.reset_on_logon);
427
428        let c = &configs[1];
429        assert!(c.reset_on_logon);
430        assert_eq!(c.timestamp_precision, TimestampPrecision::Millis);
431    }
432
433    #[test]
434    fn missing_required_key_errors() {
435        let settings = Settings::parse(
436            "[SESSION]\nBeginString=FIX.4.2\nSenderCompID=A\nConnectionType=initiator\n",
437        )
438        .unwrap();
439        assert!(settings.session_configs().is_err());
440    }
441
442    #[test]
443    fn old_fix_defaults_to_second_precision() {
444        let text = "[SESSION]\nConnectionType=acceptor\nBeginString=FIX.4.0\nSenderCompID=A\nTargetCompID=B\nSocketAcceptPort=5001\n";
445        let c = &Settings::parse(text).unwrap().session_configs().unwrap()[0];
446        assert_eq!(c.timestamp_precision, TimestampPrecision::Seconds);
447    }
448}