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}