1use chrono::{NaiveTime, Weekday};
9use chrono_tz::Tz;
10use serde::Deserialize;
11use std::fs;
12use std::path::Path;
13
14#[derive(Clone, Debug, Deserialize)]
16pub struct Config {
17 pub sessions: Vec<SessionConfig>,
18}
19
20impl Config {
21 pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
23 let config_str = fs::read_to_string(path)?;
24 let config = toml::from_str::<Self>(&config_str)?;
25 Ok(config)
26 }
27}
28
29#[derive(Clone, Debug, Deserialize, PartialEq)]
31#[serde(tag = "trust_store", rename_all = "snake_case")]
32pub enum TlsConfig {
33 File {
35 ca_certificate_path: String,
37 },
38 Native,
40 Webpki,
42}
43
44#[derive(Clone, Debug, Deserialize, PartialEq)]
46pub struct ScheduleConfig {
47 pub start_time: Option<NaiveTime>,
48 pub end_time: Option<NaiveTime>,
49 pub start_day: Option<Weekday>,
50 pub end_day: Option<Weekday>,
51 #[serde(default)]
52 pub weekdays: Vec<Weekday>,
53 pub timezone: Option<Tz>,
54}
55
56fn default_reconnect_interval() -> u64 {
57 30
58}
59
60fn default_logon_timeout() -> u64 {
61 10
62}
63
64fn default_logout_timeout() -> u64 {
65 2
66}
67
68#[derive(Clone, Debug, Deserialize)]
70pub struct SessionConfig {
71 pub begin_string: String,
73
74 pub sender_comp_id: String,
76
77 pub target_comp_id: String,
79
80 pub data_dictionary_path: Option<String>,
82
83 pub connection_host: String,
88
89 pub connection_port: u16,
91
92 #[serde(flatten)]
94 pub tls_config: Option<TlsConfig>,
95
96 pub heartbeat_interval: u64,
98
99 #[serde(default = "default_logon_timeout")]
101 pub logon_timeout: u64,
102
103 #[serde(default = "default_logout_timeout")]
105 pub logout_timeout: u64,
106
107 #[serde(default = "default_reconnect_interval")]
109 pub reconnect_interval: u64,
110
111 #[serde(default)]
113 pub reset_on_logon: bool,
114
115 pub schedule: Option<ScheduleConfig>,
117
118 #[serde(default)]
120 pub validation: ValidationConfig,
121}
122
123#[derive(Clone, Debug, Deserialize)]
124pub struct ValidationConfig {
126 #[serde(default = "default_true")]
128 pub validate_user_defined_fields: bool,
129}
130
131impl ValidationConfig {
132 pub fn builder() -> ValidationConfigBuilder {
133 ValidationConfigBuilder::default()
134 }
135}
136
137impl Default for ValidationConfig {
138 fn default() -> Self {
139 ValidationConfigBuilder::default().build()
140 }
141}
142
143pub struct ValidationConfigBuilder {
144 validate_user_defined_fields: bool,
145}
146
147impl Default for ValidationConfigBuilder {
148 fn default() -> Self {
149 Self {
150 validate_user_defined_fields: true,
151 }
152 }
153}
154
155impl ValidationConfigBuilder {
156 pub fn new() -> Self {
157 Self::default()
158 }
159
160 pub fn validate_user_defined_fields(mut self, value: bool) -> Self {
161 self.validate_user_defined_fields = value;
162 self
163 }
164
165 pub fn build(self) -> ValidationConfig {
166 ValidationConfig {
167 validate_user_defined_fields: self.validate_user_defined_fields,
168 }
169 }
170}
171
172fn default_true() -> bool {
173 true
174}
175
176#[derive(Debug, thiserror::Error)]
178pub enum ConfigError {
179 #[error("failed to read config file")]
180 Io(#[from] std::io::Error),
181
182 #[error("failed to parse config")]
183 Parse(#[from] toml::de::Error),
184}
185
186#[cfg(test)]
187mod tests {
188 use crate::config::{
189 Config, ConfigError, TlsConfig, ValidationConfig, ValidationConfigBuilder,
190 };
191 use chrono::{NaiveTime, Weekday};
192 use std::io::Write;
193 use tempfile::NamedTempFile;
194
195 #[test]
196 fn test_simple_config() {
197 let config_contents = r#"
198[[sessions]]
199begin_string = "FIX.4.4"
200sender_comp_id = "send-comp-id"
201target_comp_id = "target-comp-id"
202data_dictionary_path = "./spec/FIX44.xml"
203
204connection_port = 443
205connection_host = "127.0.0.1"
206trust_store = "file"
207ca_certificate_path = "my_cert.crt"
208heartbeat_interval = 30
209reset_on_logon = false
210 "#;
211
212 let config: Config = toml::from_str(config_contents).unwrap();
213 assert_eq!(config.sessions.len(), 1);
214
215 let session_config = config.sessions.first().unwrap();
216 assert_eq!(session_config.begin_string, "FIX.4.4");
217 assert_eq!(session_config.sender_comp_id, "send-comp-id");
218 assert_eq!(session_config.target_comp_id, "target-comp-id");
219 assert_eq!(
220 session_config.data_dictionary_path,
221 Some("./spec/FIX44.xml".to_string())
222 );
223 assert_eq!(session_config.connection_port, 443);
224 assert_eq!(session_config.connection_host, "127.0.0.1");
225 assert_eq!(session_config.heartbeat_interval, 30);
226 let expected_tls_config = TlsConfig::File {
227 ca_certificate_path: "my_cert.crt".to_string(),
228 };
229 assert_eq!(session_config.tls_config, Some(expected_tls_config));
230 assert_eq!(session_config.reconnect_interval, 30);
231 assert_eq!(session_config.logon_timeout, 10);
232 assert!(session_config.validation.validate_user_defined_fields);
233 }
234
235 #[test]
236 fn test_tls_config_native() {
237 let config_contents = r#"
238[[sessions]]
239begin_string = "FIX.4.4"
240sender_comp_id = "send-comp-id"
241target_comp_id = "target-comp-id"
242connection_port = 443
243connection_host = "127.0.0.1"
244heartbeat_interval = 30
245trust_store = "native"
246 "#;
247
248 let config: Config = toml::from_str(config_contents).unwrap();
249 let session_config = config.sessions.first().unwrap();
250 assert_eq!(session_config.tls_config, Some(TlsConfig::Native));
251 }
252
253 #[test]
254 fn test_tls_config_webpki() {
255 let config_contents = r#"
256[[sessions]]
257begin_string = "FIX.4.4"
258sender_comp_id = "send-comp-id"
259target_comp_id = "target-comp-id"
260connection_port = 443
261connection_host = "127.0.0.1"
262heartbeat_interval = 30
263trust_store = "webpki"
264 "#;
265
266 let config: Config = toml::from_str(config_contents).unwrap();
267 let session_config = config.sessions.first().unwrap();
268 assert_eq!(session_config.tls_config, Some(TlsConfig::Webpki));
269 }
270
271 #[test]
272 fn test_no_tls_config() {
273 let config_contents = r#"
274[[sessions]]
275begin_string = "FIX.4.4"
276sender_comp_id = "send-comp-id"
277target_comp_id = "target-comp-id"
278connection_port = 9880
279connection_host = "127.0.0.1"
280heartbeat_interval = 30
281 "#;
282
283 let config: Config = toml::from_str(config_contents).unwrap();
284 let session_config = config.sessions.first().unwrap();
285 assert_eq!(session_config.tls_config, None);
286 }
287
288 #[test]
289 fn test_schedule_config_weekdays() {
290 let config_contents = r#"
291[[sessions]]
292begin_string = "FIX.4.4"
293sender_comp_id = "send-comp-id"
294target_comp_id = "target-comp-id"
295heartbeat_interval = 30
296
297connection_port = 443
298connection_host = "127.0.0.1"
299
300[sessions.schedule]
301start_time = "00:00:00"
302end_time = "23:55:00"
303weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
304 "#;
305
306 let config: Config = toml::from_str(config_contents).unwrap();
307 assert_eq!(config.sessions.len(), 1);
308 let session = config.sessions.first().unwrap();
309
310 assert!(session.schedule.is_some());
311 let schedule = session.schedule.as_ref().unwrap();
312
313 assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(0, 0, 0));
314 assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(23, 55, 0));
315 assert_eq!(
316 schedule.weekdays,
317 vec![
318 Weekday::Mon,
319 Weekday::Tue,
320 Weekday::Wed,
321 Weekday::Thu,
322 Weekday::Fri
323 ]
324 );
325 assert_eq!(schedule.start_day, None);
326 assert_eq!(schedule.end_day, None);
327 }
328
329 #[test]
330 fn test_schedule_config_weeklong_session() {
331 let config_contents = r#"
332[[sessions]]
333begin_string = "FIX.4.4"
334sender_comp_id = "send-comp-id"
335target_comp_id = "target-comp-id"
336heartbeat_interval = 30
337
338connection_port = 443
339connection_host = "127.0.0.1"
340
341[sessions.schedule]
342start_time = "00:00:00"
343end_time = "23:55:00"
344start_day = "Monday"
345end_day = "Friday"
346 "#;
347
348 let config: Config = toml::from_str(config_contents).unwrap();
349 assert_eq!(config.sessions.len(), 1);
350 let session = config.sessions.first().unwrap();
351
352 assert!(session.schedule.is_some());
353 let schedule = session.schedule.as_ref().unwrap();
354
355 assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(0, 0, 0));
356 assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(23, 55, 0));
357 assert_eq!(schedule.start_day, Some(Weekday::Mon));
358 assert_eq!(schedule.end_day, Some(Weekday::Fri));
359 }
360
361 #[test]
362 fn test_schedule_config_with_new_york_timezone() {
363 use chrono_tz::Tz;
364
365 let config_contents = r#"
366 [[sessions]]
367 begin_string = "FIX.4.4"
368 sender_comp_id = "send-comp-id"
369 target_comp_id = "target-comp-id"
370 heartbeat_interval = 30
371
372 connection_port = 443
373 connection_host = "127.0.0.1"
374
375 [sessions.schedule]
376 start_time = "08:00:00"
377 end_time = "16:30:00"
378 weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
379 timezone = "America/New_York"
380 "#;
381
382 let config: Config = toml::from_str(config_contents).unwrap();
383 assert_eq!(config.sessions.len(), 1);
384 let session = config.sessions.first().unwrap();
385
386 assert!(session.schedule.is_some());
387 let schedule = session.schedule.as_ref().unwrap();
388
389 assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(8, 0, 0));
390 assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(16, 30, 0));
391 assert_eq!(schedule.timezone, Some(Tz::America__New_York));
392 }
393
394 #[test]
395 fn test_schedule_config_with_utc_timezone() {
396 use chrono_tz::Tz;
397
398 let config_contents = r#"
399 [[sessions]]
400 begin_string = "FIX.4.4"
401 sender_comp_id = "send-comp-id"
402 target_comp_id = "target-comp-id"
403 heartbeat_interval = 30
404
405 connection_port = 443
406 connection_host = "127.0.0.1"
407
408 [sessions.schedule]
409 start_time = "00:00:00"
410 end_time = "23:55:00"
411 weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
412 timezone = "UTC"
413 "#;
414
415 let config: Config = toml::from_str(config_contents).unwrap();
416 let session = config.sessions.first().unwrap();
417 let schedule = session.schedule.as_ref().unwrap();
418
419 assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(0, 0, 0));
420 assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(23, 55, 0));
421 assert_eq!(schedule.timezone, Some(Tz::UTC));
422 }
423
424 #[test]
425 fn test_schedule_config_with_london_timezone() {
426 use chrono_tz::Tz;
427
428 let config_contents = r#"
429 [[sessions]]
430 begin_string = "FIX.4.4"
431 sender_comp_id = "send-comp-id"
432 target_comp_id = "target-comp-id"
433 heartbeat_interval = 30
434
435 connection_port = 443
436 connection_host = "127.0.0.1"
437
438 [sessions.schedule]
439 start_time = "09:30:00"
440 end_time = "17:00:00"
441 weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
442 timezone = "Europe/London"
443 "#;
444
445 let config: Config = toml::from_str(config_contents).unwrap();
446 let session = config.sessions.first().unwrap();
447 let schedule = session.schedule.as_ref().unwrap();
448
449 assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(9, 30, 0));
450 assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(17, 0, 0));
451 assert_eq!(schedule.timezone, Some(Tz::Europe__London));
452 }
453
454 #[test]
455 fn test_logon_timeout_config() {
456 let config_contents = r#"
457 [[sessions]]
458 begin_string = "FIX.4.4"
459 sender_comp_id = "send-comp-id"
460 target_comp_id = "target-comp-id"
461 data_dictionary_path = "./spec/FIX44.xml"
462
463 connection_port = 443
464 connection_host = "127.0.0.1"
465 trust_store = "file"
466 ca_certificate_path = "my_cert.crt"
467 heartbeat_interval = 30
468 logon_timeout = 20
469 "#;
470
471 let config: Config = toml::from_str(config_contents).unwrap();
472 assert_eq!(config.sessions.len(), 1);
473
474 let session_config = config.sessions.first().unwrap();
475 assert_eq!(session_config.logon_timeout, 20);
476 }
477
478 #[test]
479 fn test_reconnect_interval_config() {
480 let config_contents = r#"
481 [[sessions]]
482 begin_string = "FIX.4.4"
483 sender_comp_id = "send-comp-id"
484 target_comp_id = "target-comp-id"
485 data_dictionary_path = "./spec/FIX44.xml"
486
487 connection_port = 443
488 connection_host = "127.0.0.1"
489 trust_store = "file"
490 ca_certificate_path = "my_cert.crt"
491 heartbeat_interval = 30
492 reconnect_interval = 15
493 "#;
494
495 let config: Config = toml::from_str(config_contents).unwrap();
496 assert_eq!(config.sessions.len(), 1);
497
498 let session_config = config.sessions.first().unwrap();
499 assert_eq!(session_config.reconnect_interval, 15);
500 }
501
502 #[test]
503 fn test_verification_config_defaults_when_omitted() {
504 let config_contents = r#"
505[[sessions]]
506begin_string = "FIX.4.4"
507sender_comp_id = "send-comp-id"
508target_comp_id = "target-comp-id"
509connection_port = 443
510connection_host = "127.0.0.1"
511heartbeat_interval = 30
512 "#;
513
514 let config: Config = toml::from_str(config_contents).unwrap();
515 let session_config = config.sessions.first().unwrap();
516
517 assert!(session_config.validation.validate_user_defined_fields);
518 }
519
520 #[test]
521 fn test_verification_config_can_disable_user_defined_field_validation() {
522 let config_contents = r#"
523[[sessions]]
524begin_string = "FIX.4.4"
525sender_comp_id = "send-comp-id"
526target_comp_id = "target-comp-id"
527connection_port = 443
528connection_host = "127.0.0.1"
529heartbeat_interval = 30
530
531[sessions.validation]
532validate_user_defined_fields = false
533 "#;
534
535 let config: Config = toml::from_str(config_contents).unwrap();
536 let session_config = config.sessions.first().unwrap();
537
538 assert!(!session_config.validation.validate_user_defined_fields);
539 }
540
541 #[test]
542 fn test_validation_config_builder_produces_default() {
543 let config = ValidationConfig::builder().build();
544 assert!(config.validate_user_defined_fields);
545 }
546
547 #[test]
548 fn test_verification_config_builder_new_and_override() {
549 let enabled = ValidationConfigBuilder::new().build();
550 assert!(enabled.validate_user_defined_fields);
551
552 let disabled = ValidationConfigBuilder::new()
553 .validate_user_defined_fields(false)
554 .build();
555 assert!(!disabled.validate_user_defined_fields);
556 }
557
558 #[test]
559 fn test_validation_field_default_when_section_present_but_field_omitted() {
560 let config_contents = r#"
561[[sessions]]
562begin_string = "FIX.4.4"
563sender_comp_id = "send-comp-id"
564target_comp_id = "target-comp-id"
565connection_port = 443
566connection_host = "127.0.0.1"
567heartbeat_interval = 30
568
569[sessions.validation]
570 "#;
571
572 let config: Config = toml::from_str(config_contents).unwrap();
573 let session_config = config.sessions.first().unwrap();
574
575 assert!(session_config.validation.validate_user_defined_fields);
580 }
581
582 #[test]
583 fn test_load_from_path_success() {
584 let config_contents = r#"
585[[sessions]]
586begin_string = "FIX.4.4"
587sender_comp_id = "sender"
588target_comp_id = "target"
589connection_host = "127.0.0.1"
590connection_port = 9876
591heartbeat_interval = 30
592"#;
593
594 let mut temp_file = NamedTempFile::new().unwrap();
595 temp_file.write_all(config_contents.as_bytes()).unwrap();
596
597 let config = Config::load_from_path(temp_file.path()).unwrap();
598 assert_eq!(config.sessions.len(), 1);
599
600 let session = config.sessions.first().unwrap();
601 assert_eq!(session.begin_string, "FIX.4.4");
602 assert_eq!(session.sender_comp_id, "sender");
603 assert_eq!(session.target_comp_id, "target");
604 assert_eq!(session.connection_host, "127.0.0.1");
605 assert_eq!(session.connection_port, 9876);
606 assert_eq!(session.heartbeat_interval, 30);
607 }
608
609 #[test]
610 fn test_load_from_path_missing_file() {
611 let result = Config::load_from_path("/nonexistent/path/to/config.toml");
612 assert!(result.is_err());
613 assert!(matches!(result.unwrap_err(), ConfigError::Io(_)));
614 }
615
616 #[test]
617 fn test_load_from_path_invalid_toml() {
618 let invalid_toml = "this is not valid toml {{{{";
619
620 let mut temp_file = NamedTempFile::new().unwrap();
621 temp_file.write_all(invalid_toml.as_bytes()).unwrap();
622
623 let result = Config::load_from_path(temp_file.path());
624 assert!(result.is_err());
625 assert!(matches!(result.unwrap_err(), ConfigError::Parse(_)));
626 }
627}