Skip to main content

ironfix_core/
version.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 21/7/26
5******************************************************************************/
6
7//! FIX protocol versions and their on-the-wire representation.
8//!
9//! [`FixVersion`] is the **single** place in the workspace that maps a FIX
10//! version to the two values that carry it on the wire: `BeginString` (tag 8)
11//! and, for the 5.0 family, the application version stamped into
12//! `DefaultApplVerID` (1137) on the Logon and `ApplVerID` (1128) on
13//! application messages.
14//!
15//! It lives in `ironfix-core` because two crates need the same answer and
16//! neither may depend on the other: `ironfix-dictionary` re-exports it as
17//! `Version` for schema loading, and `ironfix-engine` uses it to stamp the
18//! standard header. `ironfix-engine` must not depend on `ironfix-dictionary`
19//! (a hard DAG invariant, see `CLAUDE.md`), so before this type existed the
20//! table was duplicated in both and could drift apart untested.
21//!
22//! ## The FIXT.1.1 split
23//!
24//! FIX 5.0 separates the transport (session) version from the application
25//! version. A 5.0 session is framed as a FIXT.1.1 session — `BeginString` is
26//! always `FIXT.1.1` — and the application version travels in 1137 / 1128
27//! (FIXT 1.1 specification, "Standard Message Header"; see also
28//! `doc/fix_operations.md`, "FIX 5.0 / FIXT.1.1"). Putting `FIX.5.0*` in tag 8
29//! is rejected outright by conforming counterparties.
30//!
31//! Consequently [`FixVersion::as_str`] (the version's own name, e.g.
32//! `FIX.5.0SP2`) and [`FixVersion::begin_string`] (what goes in tag 8, e.g.
33//! `FIXT.1.1`) are different questions and must not be confused.
34
35use crate::error::UnknownFixVersion;
36use serde::{Deserialize, Serialize};
37use std::fmt;
38use std::str::FromStr;
39
40/// A FIX protocol version.
41///
42/// The canonical name of a variant is [`FixVersion::as_str`], which is also
43/// the string [`FromStr`] accepts. Its wire framing is
44/// [`FixVersion::begin_string`] plus [`FixVersion::appl_ver_id`].
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub enum FixVersion {
47    /// FIX 4.0
48    Fix40,
49    /// FIX 4.1
50    Fix41,
51    /// FIX 4.2
52    Fix42,
53    /// FIX 4.3
54    Fix43,
55    /// FIX 4.4
56    Fix44,
57    /// FIX 5.0, framed as a FIXT.1.1 session.
58    Fix50,
59    /// FIX 5.0 SP1, framed as a FIXT.1.1 session.
60    Fix50Sp1,
61    /// FIX 5.0 SP2, framed as a FIXT.1.1 session.
62    Fix50Sp2,
63    /// FIXT 1.1, the transport version used by FIX 5.0 and later.
64    ///
65    /// On its own it names no application version, so it has no
66    /// [`FixVersion::appl_ver_id`]: a session that must stamp
67    /// `DefaultApplVerID` (1137) cannot be described by this variant alone.
68    Fixt11,
69}
70
71impl FixVersion {
72    /// Every version this workspace knows, in ascending order.
73    ///
74    /// Iterating this is the way to assert the version mapping exhaustively
75    /// from a single place, rather than restating the table in a consumer.
76    pub const ALL: [Self; 9] = [
77        Self::Fix40,
78        Self::Fix41,
79        Self::Fix42,
80        Self::Fix43,
81        Self::Fix44,
82        Self::Fix50,
83        Self::Fix50Sp1,
84        Self::Fix50Sp2,
85        Self::Fixt11,
86    ];
87
88    /// Returns the version's canonical name, e.g. `FIX.5.0SP2`.
89    ///
90    /// This is the version's own identity — the string a session is
91    /// configured with and the string [`FromStr`] parses. For the 5.0 family
92    /// it is **not** what goes in `BeginString`; see
93    /// [`FixVersion::begin_string`].
94    #[must_use]
95    pub const fn as_str(self) -> &'static str {
96        match self {
97            Self::Fix40 => "FIX.4.0",
98            Self::Fix41 => "FIX.4.1",
99            Self::Fix42 => "FIX.4.2",
100            Self::Fix43 => "FIX.4.3",
101            Self::Fix44 => "FIX.4.4",
102            Self::Fix50 => "FIX.5.0",
103            Self::Fix50Sp1 => "FIX.5.0SP1",
104            Self::Fix50Sp2 => "FIX.5.0SP2",
105            Self::Fixt11 => "FIXT.1.1",
106        }
107    }
108
109    /// Returns the value stamped into `BeginString` (tag 8).
110    ///
111    /// Pre-5.0 versions carry their own name. FIX 5.0 and later are framed as
112    /// FIXT.1.1 sessions and carry `FIXT.1.1`, with the application version
113    /// in 1137 / 1128 instead.
114    #[must_use]
115    pub const fn begin_string(self) -> &'static str {
116        match self {
117            Self::Fix40 => "FIX.4.0",
118            Self::Fix41 => "FIX.4.1",
119            Self::Fix42 => "FIX.4.2",
120            Self::Fix43 => "FIX.4.3",
121            Self::Fix44 => "FIX.4.4",
122            Self::Fix50 | Self::Fix50Sp1 | Self::Fix50Sp2 | Self::Fixt11 => "FIXT.1.1",
123        }
124    }
125
126    /// Returns the application version to stamp into `DefaultApplVerID`
127    /// (1137) on the Logon and `ApplVerID` (1128) on application messages,
128    /// or `None` for a session that carries neither field.
129    ///
130    /// The codes are the `ApplVerID` enumeration: `7` = FIX.5.0,
131    /// `8` = FIX.5.0SP1, `9` = FIX.5.0SP2. That enumeration also defines
132    /// codes for pre-5.0 versions (`2` = FIX.4.0 … `6` = FIX.4.4), but they
133    /// are not returned here: a pre-5.0 session is not a FIXT session and
134    /// never carries 1137 or 1128, so the honest answer is `None`.
135    ///
136    /// [`FixVersion::Fixt11`] is also `None` — it names the transport version
137    /// only. A caller that must stamp the **required** 1137 has to reject
138    /// that combination rather than guess an application version.
139    pub const fn appl_ver_id(self) -> Option<&'static str> {
140        match self {
141            Self::Fix50 => Some("7"),
142            Self::Fix50Sp1 => Some("8"),
143            Self::Fix50Sp2 => Some("9"),
144            Self::Fix40 | Self::Fix41 | Self::Fix42 | Self::Fix43 | Self::Fix44 | Self::Fixt11 => {
145                None
146            }
147        }
148    }
149
150    /// Returns `true` when this version is framed as a FIXT.1.1 session,
151    /// i.e. FIX 5.0 and later plus FIXT.1.1 itself.
152    #[must_use]
153    pub const fn uses_fixt(self) -> bool {
154        matches!(
155            self,
156            Self::Fix50 | Self::Fix50Sp1 | Self::Fix50Sp2 | Self::Fixt11
157        )
158    }
159}
160
161impl fmt::Display for FixVersion {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        f.write_str(self.as_str())
164    }
165}
166
167impl FromStr for FixVersion {
168    type Err = UnknownFixVersion;
169
170    /// Parses a version's canonical name, e.g. `FIX.4.4` or `FIX.5.0SP2`.
171    ///
172    /// Matching is exact: FIX version strings travel on the wire verbatim and
173    /// are case-sensitive, so a lenient parse here would accept a value that
174    /// no counterparty would.
175    ///
176    /// # Errors
177    /// Returns [`UnknownFixVersion`] when the string names no version in
178    /// [`FixVersion::ALL`].
179    fn from_str(value: &str) -> Result<Self, Self::Err> {
180        Self::ALL
181            .into_iter()
182            .find(|version| version.as_str() == value)
183            .ok_or_else(|| UnknownFixVersion::new(value))
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    /// The one table in the workspace, asserted entry by entry. Both
192    /// `ironfix-dictionary` and `ironfix-engine` derive their answer from
193    /// these methods, so this covers the mapping for every consumer.
194    #[test]
195    fn test_fix_version_mapping_is_exhaustive_and_exact() {
196        let expected = [
197            (FixVersion::Fix40, "FIX.4.0", "FIX.4.0", None, false),
198            (FixVersion::Fix41, "FIX.4.1", "FIX.4.1", None, false),
199            (FixVersion::Fix42, "FIX.4.2", "FIX.4.2", None, false),
200            (FixVersion::Fix43, "FIX.4.3", "FIX.4.3", None, false),
201            (FixVersion::Fix44, "FIX.4.4", "FIX.4.4", None, false),
202            (FixVersion::Fix50, "FIX.5.0", "FIXT.1.1", Some("7"), true),
203            (
204                FixVersion::Fix50Sp1,
205                "FIX.5.0SP1",
206                "FIXT.1.1",
207                Some("8"),
208                true,
209            ),
210            (
211                FixVersion::Fix50Sp2,
212                "FIX.5.0SP2",
213                "FIXT.1.1",
214                Some("9"),
215                true,
216            ),
217            (FixVersion::Fixt11, "FIXT.1.1", "FIXT.1.1", None, true),
218        ];
219
220        assert_eq!(
221            expected.len(),
222            FixVersion::ALL.len(),
223            "every version must be covered"
224        );
225        for (version, name, begin_string, appl_ver_id, uses_fixt) in expected {
226            assert!(
227                FixVersion::ALL.contains(&version),
228                "{version:?} is missing from FixVersion::ALL"
229            );
230            assert_eq!(version.as_str(), name);
231            assert_eq!(version.begin_string(), begin_string);
232            assert_eq!(version.appl_ver_id(), appl_ver_id);
233            assert_eq!(version.uses_fixt(), uses_fixt);
234        }
235    }
236
237    #[test]
238    fn test_fix_version_roundtrips_through_its_canonical_name() {
239        for version in FixVersion::ALL {
240            assert_eq!(version.as_str().parse(), Ok(version));
241            assert_eq!(version.to_string(), version.as_str());
242        }
243    }
244
245    #[test]
246    fn test_fix_version_names_are_unique() {
247        for (index, version) in FixVersion::ALL.into_iter().enumerate() {
248            let duplicates = FixVersion::ALL
249                .into_iter()
250                .enumerate()
251                .filter(|(other_index, other)| {
252                    *other_index != index && other.as_str() == version.as_str()
253                })
254                .count();
255            assert_eq!(duplicates, 0, "{version:?} shares its name with another");
256        }
257    }
258
259    #[test]
260    fn test_fix_version_from_str_unknown_is_typed_error() {
261        match "FIX.9.9".parse::<FixVersion>() {
262            Err(err) => assert_eq!(err.value(), "FIX.9.9"),
263            Ok(version) => unreachable!("FIX.9.9 is not a version, got {version:?}"),
264        }
265    }
266
267    #[test]
268    fn test_fix_version_from_str_is_case_sensitive() {
269        assert!("fix.4.4".parse::<FixVersion>().is_err());
270        assert!("FIX.5.0sp2".parse::<FixVersion>().is_err());
271    }
272
273    #[test]
274    fn test_fix_version_only_fifty_family_carries_appl_ver_id() {
275        for version in FixVersion::ALL {
276            if version.appl_ver_id().is_some() {
277                assert!(
278                    version.uses_fixt(),
279                    "{version:?} stamps an ApplVerID but is not a FIXT session"
280                );
281            }
282        }
283    }
284}