Skip to main content

ironfix_engine/
builder.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 27/1/26
5******************************************************************************/
6
7//! Engine builder for fluent configuration.
8//!
9//! [`EngineBuilder`] collects an [`Application`] and a session configuration
10//! and terminates in a ready-to-run engine: [`EngineBuilder::into_initiator`]
11//! for the client side, [`EngineBuilder::into_acceptor`] for the server side.
12//!
13//! Every setter on this builder configures something an engine actually honors.
14//! The builder does **not** carry TLS or reconnection knobs: there is no TLS in
15//! the workspace, and reconnection is the consumer's responsibility (the
16//! [`Initiator`] establishes a single session per `connect`, and a supervisor
17//! calls it again). The connect timeout applies to the initiator only; an
18//! acceptor waits for inbound connections and bounds the Logon handshake with
19//! [`SessionConfig::logon_timeout`] instead.
20
21use crate::acceptor::Acceptor;
22use crate::application::{Application, NoOpApplication};
23use crate::error::EngineError;
24use crate::initiator::Initiator;
25use ironfix_session::config::SessionConfig;
26use std::sync::Arc;
27use std::time::Duration;
28
29/// Builder for configuring a FIX engine.
30///
31/// Set the [`Application`] and add exactly one [`SessionConfig`], then call
32/// [`EngineBuilder::into_initiator`] or [`EngineBuilder::into_acceptor`] to
33/// obtain a runnable engine.
34#[derive(Debug)]
35pub struct EngineBuilder<A: Application = NoOpApplication> {
36    /// Application callback handler.
37    application: Arc<A>,
38    /// Session configurations.
39    sessions: Vec<SessionConfig>,
40    /// TCP connect timeout applied by [`EngineBuilder::into_initiator`].
41    connect_timeout: Duration,
42}
43
44impl Default for EngineBuilder<NoOpApplication> {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl EngineBuilder<NoOpApplication> {
51    /// Creates a new engine builder with default settings.
52    #[must_use]
53    pub fn new() -> Self {
54        Self {
55            application: Arc::new(NoOpApplication),
56            sessions: Vec::new(),
57            connect_timeout: Duration::from_secs(30),
58        }
59    }
60}
61
62impl<A: Application + 'static> EngineBuilder<A> {
63    /// Sets the application callback handler.
64    #[must_use]
65    pub fn with_application<B: Application>(self, application: B) -> EngineBuilder<B> {
66        EngineBuilder {
67            application: Arc::new(application),
68            sessions: self.sessions,
69            connect_timeout: self.connect_timeout,
70        }
71    }
72
73    /// Adds a session configuration.
74    ///
75    /// The terminal methods build a single-session engine, so exactly one
76    /// session must be added before [`EngineBuilder::into_initiator`] or
77    /// [`EngineBuilder::into_acceptor`] is called.
78    #[must_use]
79    pub fn add_session(mut self, config: SessionConfig) -> Self {
80        self.sessions.push(config);
81        self
82    }
83
84    /// Sets the TCP connect timeout used by [`EngineBuilder::into_initiator`]
85    /// (default 30s). It has no effect on an acceptor.
86    #[must_use]
87    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
88        self.connect_timeout = timeout;
89        self
90    }
91
92    /// Returns the configured sessions.
93    #[must_use]
94    pub fn sessions(&self) -> &[SessionConfig] {
95        &self.sessions
96    }
97
98    /// Returns the connection timeout.
99    #[must_use]
100    pub const fn connect_timeout(&self) -> Duration {
101        self.connect_timeout
102    }
103
104    /// Returns the application handler.
105    #[must_use]
106    pub fn application(&self) -> Arc<A> {
107        Arc::clone(&self.application)
108    }
109
110    /// Consumes the builder and produces a client-side [`Initiator`] for the
111    /// single configured session, applying the configured connect timeout.
112    ///
113    /// # Errors
114    /// Returns [`EngineError::Configuration`] unless exactly one session has
115    /// been added.
116    pub fn into_initiator(self) -> Result<Initiator<A>, EngineError> {
117        let config = self.single_session()?;
118        Ok(Initiator::new(config, self.application).with_connect_timeout(self.connect_timeout))
119    }
120
121    /// Consumes the builder and produces a server-side [`Acceptor`] for the
122    /// single configured session.
123    ///
124    /// # Errors
125    /// Returns [`EngineError::Configuration`] unless exactly one session has
126    /// been added.
127    pub fn into_acceptor(self) -> Result<Acceptor<A>, EngineError> {
128        let config = self.single_session()?;
129        Ok(Acceptor::new(config, self.application))
130    }
131
132    /// Extracts the single configured session, or explains why there is not
133    /// exactly one.
134    fn single_session(&self) -> Result<SessionConfig, EngineError> {
135        match self.sessions.as_slice() {
136            [config] => Ok(config.clone()),
137            [] => Err(EngineError::Configuration(
138                "no session configured: add exactly one with add_session".to_string(),
139            )),
140            many => Err(EngineError::Configuration(format!(
141                "{} sessions configured, but a single-session engine requires exactly one",
142                many.len()
143            ))),
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use ironfix_core::types::CompId;
152
153    /// Fails the test with context instead of `.unwrap()` / `.expect()`.
154    #[track_caller]
155    fn comp_id(value: &str) -> CompId {
156        match CompId::new(value) {
157            Ok(id) => id,
158            Err(err) => panic!("test CompId must be valid: {err}"),
159        }
160    }
161
162    fn session() -> SessionConfig {
163        SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4")
164    }
165
166    #[test]
167    fn test_engine_builder_default_is_empty() {
168        let builder = EngineBuilder::new();
169        assert_eq!(builder.connect_timeout(), Duration::from_secs(30));
170        assert!(builder.sessions().is_empty());
171    }
172
173    #[test]
174    fn test_engine_builder_add_session_records_it() {
175        let builder = EngineBuilder::new()
176            .add_session(session())
177            .with_connect_timeout(Duration::from_secs(60));
178
179        assert_eq!(builder.sessions().len(), 1);
180        assert_eq!(builder.connect_timeout(), Duration::from_secs(60));
181    }
182
183    #[test]
184    fn test_into_initiator_single_session_carries_connect_timeout() {
185        let initiator = EngineBuilder::new()
186            .add_session(session())
187            .with_connect_timeout(Duration::from_secs(7))
188            .into_initiator();
189
190        match initiator {
191            Ok(initiator) => {
192                assert_eq!(initiator.session_id().to_string(), "FIX.4.4:SENDER->TARGET");
193            }
194            Err(err) => panic!("a single-session builder must produce an initiator: {err}"),
195        }
196    }
197
198    #[test]
199    fn test_into_acceptor_single_session_builds() {
200        let acceptor = EngineBuilder::new().add_session(session()).into_acceptor();
201
202        match acceptor {
203            Ok(acceptor) => {
204                assert_eq!(acceptor.session_id().to_string(), "FIX.4.4:SENDER->TARGET");
205            }
206            Err(err) => panic!("a single-session builder must produce an acceptor: {err}"),
207        }
208    }
209
210    #[test]
211    fn test_into_initiator_without_session_is_configuration_error() {
212        match EngineBuilder::new().into_initiator() {
213            Err(EngineError::Configuration(detail)) => assert!(detail.contains("no session")),
214            other => panic!("an empty builder must fail with Configuration, got {other:?}"),
215        }
216    }
217
218    #[test]
219    fn test_into_acceptor_with_two_sessions_is_configuration_error() {
220        match EngineBuilder::new()
221            .add_session(session())
222            .add_session(session())
223            .into_acceptor()
224        {
225            Err(EngineError::Configuration(detail)) => assert!(detail.contains("2 sessions")),
226            other => panic!("a two-session builder must fail with Configuration, got {other:?}"),
227        }
228    }
229}