Skip to main content

truefix_at/
runner.rs

1//! The AT scenario model and runner.
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8use tokio::net::TcpStream;
9use tokio::task::JoinHandle;
10use tokio::time::timeout;
11
12use truefix_core::{Field, Message, decode, frame_length};
13use truefix_session::{Application, Role, SessionConfig, SessionId};
14use truefix_transport::AcceptorBuilder;
15
16/// A message the scenario expects the server to send: a MsgType plus required field matches.
17/// Volatile fields (SendingTime, BodyLength, CheckSum) are intentionally not matched.
18#[derive(Debug, Clone)]
19pub struct ExpectMsg {
20    /// Expected MsgType (tag 35).
21    pub msg_type: String,
22    /// `(tag, value)` pairs that must be present (in header or body) with the given value.
23    pub fields: Vec<(u32, String)>,
24    /// Tags that must be absent entirely (T015, feature 007 — e.g. confirming a Logon response
25    /// does NOT echo `ResetSeqNumFlag` when the inbound Logon never carried it).
26    pub fields_absent: Vec<u32>,
27    /// T136/T137 (feature 009, NEW-50): when set, `check_match` additionally fails if the actual
28    /// message carries any tag beyond `fields` and [`ALWAYS_ALLOWED_TAGS`] — i.e. an unexpected
29    /// extra field. Opt-in (default `false`, via [`ExpectMsg::exact`]) rather than the default for
30    /// every `ExpectMsg`: most of this suite's ~90 existing scenarios only assert the handful of
31    /// fields they actually care about, deliberately leaving every other legitimate field (e.g. a
32    /// Logon response's own `EncryptMethod`/`HeartBtInt`) unlisted — checking exhaustively by
33    /// default would require rewriting all of them, not just this one item's fix.
34    pub exact: bool,
35}
36
37/// Header/trailer bookkeeping and session-identity fields present on essentially every FIX
38/// message, which [`ExpectMsg::exact`] never flags as "extra" even when unlisted — matches the
39/// doc comment above `ExpectMsg` calling out `SendingTime`/`BodyLength`/`CheckSum` as always
40/// unmatched, plus the other framing/identity fields every message legitimately carries.
41pub const ALWAYS_ALLOWED_TAGS: &[u32] = &[
42    8,   // BeginString
43    9,   // BodyLength
44    35,  // MsgType
45    34,  // MsgSeqNum
46    49,  // SenderCompID
47    56,  // TargetCompID
48    52,  // SendingTime
49    43,  // PossDupFlag
50    122, // OrigSendingTime
51    10,  // CheckSum
52];
53
54impl ExpectMsg {
55    /// Expect a message of `msg_type` with no specific field requirements.
56    pub fn of(msg_type: &str) -> Self {
57        Self {
58            msg_type: msg_type.to_owned(),
59            fields: Vec::new(),
60            fields_absent: Vec::new(),
61            exact: false,
62        }
63    }
64
65    /// Require a field value.
66    #[must_use]
67    pub fn field(mut self, tag: u32, value: &str) -> Self {
68        self.fields.push((tag, value.to_owned()));
69        self
70    }
71
72    /// Require a tag to be entirely absent from the message.
73    #[must_use]
74    pub fn without_field(mut self, tag: u32) -> Self {
75        self.fields_absent.push(tag);
76        self
77    }
78
79    /// Additionally fail if the actual message carries any tag beyond the ones already required
80    /// via [`ExpectMsg::field`] and [`ALWAYS_ALLOWED_TAGS`] (T136/T137, NEW-50).
81    #[must_use]
82    pub fn exact(mut self) -> Self {
83        self.exact = true;
84        self
85    }
86}
87
88/// A single scenario step.
89#[derive(Debug, Clone)]
90pub enum Step {
91    /// Send a message to the server.
92    Send(Message),
93    /// Send raw bytes to the server (e.g. a deliberately garbled frame).
94    SendRaw(Vec<u8>),
95    /// Expect a matching message from the server.
96    Expect(ExpectMsg),
97    /// Expect the server to disconnect (no further message).
98    ExpectDisconnect,
99}
100
101/// Optional acceptor session-feature toggles a scenario needs (special-category suites).
102#[derive(Debug, Clone, Default)]
103pub struct SessionTweaks {
104    /// Enable NextExpectedMsgSeqNum (789) handling.
105    pub enable_next_expected: bool,
106    /// Enable LastMsgSeqNumProcessed (369) stamping.
107    pub enable_last_processed: bool,
108    /// Enable CheckLatency (stale SendingTime aborts the session).
109    pub check_latency: bool,
110    /// ResendRequest chunk size (0 = request the whole range at once).
111    pub resend_chunk_size: u32,
112    /// Use an active application that replies to each NewOrderSingle with an ExecutionReport.
113    pub executor_app: bool,
114    /// Enable RejectGarbledMessage (a garbled frame draws a session Reject instead of a silent drop).
115    pub reject_garbled: bool,
116    /// Enable `ValidateFieldsOutOfOrder` on the acceptor's dictionary validator (FR-006).
117    pub validate_fields_out_of_order: bool,
118    /// Disconnect (in addition to rejecting) on an inbound dictionary-validation failure
119    /// (006/US1, B5/FR-008).
120    pub disconnect_on_error: bool,
121    /// T133/T134/T135 (feature 009, NEW-83): when set, `run_report` starts this scenario's
122    /// acceptor via [`start_fixed_identity_acceptor`] (fixed `SenderCompID`/`TargetCompID`,
123    /// `(server, client)`) instead of [`start_acceptor`]'s dynamic-template mode — required to
124    /// exercise an inbound Logon whose identity doesn't match a pre-existing session at all,
125    /// which the dynamic template has nothing to "mismatch" against.
126    pub fixed_identity: Option<(String, String)>,
127}
128
129/// A scripted acceptance-test scenario.
130#[derive(Debug, Clone)]
131pub struct Scenario {
132    /// Scenario name (matches the QuickFIX AT scenario it reproduces).
133    pub name: String,
134    /// FIX versions this scenario targets.
135    pub versions: Vec<String>,
136    /// The scripted steps.
137    pub steps: Vec<Step>,
138    /// Acceptor session-feature toggles (default: all off, CheckLatency off).
139    pub tweaks: SessionTweaks,
140}
141
142/// The result of running one scenario against one version.
143#[derive(Debug, Clone)]
144pub struct ScenarioResult {
145    /// Scenario name.
146    pub name: String,
147    /// FIX version.
148    pub version: String,
149    /// `Ok` on pass, `Err(reason)` on failure.
150    pub outcome: Result<(), String>,
151}
152
153/// A one-line-per-run PASS/FAIL report (T139/T140, feature 009, NEW-52) — distinct from a single
154/// all-or-nothing boolean: a caller (or a human reading test output) can see every scenario's own
155/// result, not just whether *something* in the whole suite failed, without re-running anything.
156#[must_use]
157pub fn per_scenario_report(results: &[ScenarioResult]) -> String {
158    results
159        .iter()
160        .map(|r| match &r.outcome {
161            Ok(()) => format!("PASS  {} [{}]", r.name, r.version),
162            Err(reason) => format!("FAIL  {} [{}]: {reason}", r.name, r.version),
163        })
164        .collect::<Vec<_>>()
165        .join("\n")
166}
167
168/// The bundled dictionary matching a `SUITE_VERSIONS` entry, for the acceptor's field validator
169/// (T128/T129, feature 009) — previously only `FIX.4.2`/`FIX.4.4` had one, so a field-validation
170/// scenario against any other pre-FIXT version silently ran with no dictionary checks at all.
171///
172/// Deliberately `None` for `FIX.5.0`/`SP1`/`SP2`/`FIX.Latest`: those versions split admin messages
173/// (Logon, Heartbeat, ...) into the separate FIXT 1.1 transport dictionary, so the bundled
174/// `FIX50*`/`FIXLATEST` sources are application-message-only. `Services::validator` applies its one
175/// dictionary to *every* inbound message, admin included (`Session::validate_app`'s flat-dictionary
176/// branch, unlike its `fixt_validator`/`FixtDictionaries` branch, has no admin/app split) — handing
177/// it an app-only dictionary would make a plain Logon fail as an unregistered MsgType, breaking
178/// every scenario for that version. A merge attempt (`DataDictionary::extend`, folding FIXT11's
179/// admin definitions into the app dict) was tried and rejected: tag 35 (MsgType)'s enum differs
180/// between the transport and application dictionaries, so `extend` correctly reports a field
181/// conflict rather than silently picking one side. Properly supporting these four versions needs
182/// `start_acceptor` to wire a real `FixtDictionaries` dual-dictionary instead of the flat
183/// `validator` field — the same "substantial harness-level work" already deferred at
184/// T019/T022/T029/T054/T159, not attempted here.
185/// The `SUITE_VERSIONS` subset [`dictionary_for_version`] returns `Some` for.
186pub const FLAT_DICTIONARY_VERSIONS: &[&str] =
187    &["FIX.4.0", "FIX.4.1", "FIX.4.2", "FIX.4.3", "FIX.4.4"];
188
189pub fn dictionary_for_version(version: &str) -> Option<truefix_dict::DataDictionary> {
190    match version {
191        "FIX.4.0" => truefix_dict::load_fix40().ok(),
192        "FIX.4.1" => truefix_dict::load_fix41().ok(),
193        "FIX.4.2" => truefix_dict::load_fix42().ok(),
194        "FIX.4.3" => truefix_dict::load_fix43().ok(),
195        "FIX.4.4" => truefix_dict::load_fix44().ok(),
196        _ => None,
197    }
198}
199
200/// Start a black-box acceptor that serves any session via a dynamic template for `version`.
201/// Returns the bound address and the accept-loop join handle.
202pub async fn start_acceptor(
203    version: &str,
204    tweaks: &SessionTweaks,
205) -> std::io::Result<(SocketAddr, JoinHandle<()>)> {
206    // One app type: when it holds a Monitor it acts as an executor, replying to each
207    // NewOrderSingle(35=D) with an ExecutionReport(35=8); otherwise it is passive.
208    struct AtApp {
209        monitor: Option<truefix_transport::Monitor>,
210    }
211    #[async_trait::async_trait]
212    impl Application for AtApp {
213        async fn on_logon(&self, _s: &SessionId) {}
214        async fn from_app(
215            &self,
216            message: &Message,
217            id: &SessionId,
218        ) -> Result<(), truefix_core::BusinessReject> {
219            if let Some(monitor) = &self.monitor
220                && message.msg_type() == Some("D")
221            {
222                // A sentinel ClOrdID lets a scenario trigger an acceptor-initiated logout.
223                let clordid = message.body.get(11).and_then(|f| f.as_str().ok());
224                if clordid == Some("LOGOUT") {
225                    monitor.force_logout(id).await;
226                } else {
227                    monitor.send_app(id, execution_report(message)).await;
228                }
229            }
230            Ok(())
231        }
232        // T021 (US3, feature 005, GAP-07/FR-007): a sentinel ClOrdID ("VETO-RESEND", carried over
233        // from the originating NewOrderSingle onto its ExecutionReport by `execution_report`'s
234        // existing field-copy) lets a scenario prove a resend-originated (PossDupFlag=Y) send gets
235        // vetoed and replaced by a GapFill — unconditional, not gated by a tweak, matching the
236        // existing "LOGOUT" sentinel's pattern above. The *original* live send is never vetoed
237        // (only its later resend is), matching `resend_veto.rs`'s transport-level test.
238        async fn to_app(
239            &self,
240            message: &mut Message,
241            _id: &SessionId,
242        ) -> Result<(), truefix_core::DoNotSend> {
243            let is_veto_sentinel =
244                message.body.get(11).and_then(|f| f.as_str().ok()) == Some("VETO-RESEND");
245            let is_resend = message.header.get(43).and_then(|f| f.as_str().ok()) == Some("Y");
246            if is_veto_sentinel && is_resend {
247                Err(truefix_core::DoNotSend)
248            } else {
249                Ok(())
250            }
251        }
252    }
253
254    let mut template = SessionConfig::new(
255        wire_begin_string(version),
256        "SERVER",
257        "CLIENT",
258        Role::Acceptor,
259    );
260    template.heartbeat_interval = 30;
261    // Scenarios use fixed timestamps, so CheckLatency is off unless a scenario opts in.
262    template.check_latency = tweaks.check_latency;
263    template.enable_next_expected_msg_seq_num = tweaks.enable_next_expected;
264    template.enable_last_msg_seq_num_processed = tweaks.enable_last_processed;
265    template.resend_request_chunk_size = tweaks.resend_chunk_size;
266    template.reject_garbled_message = tweaks.reject_garbled;
267    template.disconnect_on_error = tweaks.disconnect_on_error;
268
269    // Enable dictionary validation so field-level reject scenarios produce Reject messages.
270    let validation_opts = truefix_dict::ValidationOptions {
271        validate_fields_out_of_order: tweaks.validate_fields_out_of_order,
272        ..truefix_dict::ValidationOptions::default()
273    };
274    let validator = dictionary_for_version(version).map(|dict| (dict, validation_opts));
275    let monitor = tweaks.executor_app.then(truefix_transport::Monitor::new);
276    let services = truefix_transport::Services {
277        validator,
278        monitor: monitor.clone(),
279        ..truefix_transport::Services::default()
280    };
281
282    let acceptor = AcceptorBuilder::bind(
283        "127.0.0.1:0".parse().unwrap_or_else(|_| unreachable_addr()),
284        Arc::new(AtApp { monitor }),
285    )
286    .await?
287    .with_dynamic_template(template)
288    .with_services(services);
289    let addr = acceptor.local_addr()?;
290    let handle = acceptor.serve();
291    Ok((addr, handle))
292}
293
294/// Start a black-box acceptor with a fixed session identity (T133/T134, feature 009, NEW-83) —
295/// additive alongside [`start_acceptor`]'s dynamic-template mode, which adopts whatever identity
296/// the first Logon claims and so has nothing to "mismatch" against (see the note above
297/// `logon_response_carries_reset_flag` on why `1c_InvalidSenderCompID`/`1c_InvalidTargetCompID`/a
298/// wrong-`BeginString` Logon couldn't be represented until now). A Logon whose SenderCompID/
299/// TargetCompID/BeginString doesn't match `sender`/`target`/`version` never resolves to a
300/// registered session (`route_and_run`'s `registry.sessions.get(&sid)` lookup, no template
301/// fallback here) — the connection is simply dropped, not answered with a Reject/Logout.
302pub async fn start_fixed_identity_acceptor(
303    version: &str,
304    sender: &str,
305    target: &str,
306    tweaks: &SessionTweaks,
307) -> std::io::Result<(SocketAddr, JoinHandle<()>)> {
308    struct FixedIdentityApp;
309    #[async_trait::async_trait]
310    impl Application for FixedIdentityApp {
311        async fn on_logon(&self, _s: &SessionId) {}
312    }
313
314    let mut config = SessionConfig::new(wire_begin_string(version), sender, target, Role::Acceptor);
315    config.heartbeat_interval = 30;
316    config.check_latency = tweaks.check_latency;
317    config.enable_next_expected_msg_seq_num = tweaks.enable_next_expected;
318    config.enable_last_msg_seq_num_processed = tweaks.enable_last_processed;
319    config.resend_request_chunk_size = tweaks.resend_chunk_size;
320    config.reject_garbled_message = tweaks.reject_garbled;
321    config.disconnect_on_error = tweaks.disconnect_on_error;
322
323    let validation_opts = truefix_dict::ValidationOptions {
324        validate_fields_out_of_order: tweaks.validate_fields_out_of_order,
325        ..truefix_dict::ValidationOptions::default()
326    };
327    let validator = dictionary_for_version(version).map(|dict| (dict, validation_opts));
328    let services = truefix_transport::Services {
329        validator,
330        ..truefix_transport::Services::default()
331    };
332
333    let acceptor = AcceptorBuilder::bind(
334        "127.0.0.1:0".parse().unwrap_or_else(|_| unreachable_addr()),
335        Arc::new(FixedIdentityApp),
336    )
337    .await?
338    .with_session(config)
339    .with_services(services);
340    let addr = acceptor.local_addr()?;
341    let handle = acceptor.serve();
342    Ok((addr, handle))
343}
344
345/// Build a minimal ExecutionReport (35=8) acknowledging `order`, echoing its key fields. The
346/// engine stamps the session header (BeginString/CompIDs/MsgSeqNum/SendingTime) on send.
347fn execution_report(order: &Message) -> Message {
348    let mut m = Message::new();
349    m.header.set(Field::string(35, "8"));
350    m.body.set(Field::string(37, "ORDER-1")); // OrderID
351    m.body.set(Field::string(17, "EXEC-1")); // ExecID
352    m.body.set(Field::string(150, "0")); // ExecType = New
353    m.body.set(Field::string(39, "0")); // OrdStatus = New
354    for tag in [11u32, 55, 54, 38] {
355        if let Some(f) = order.body.get(tag) {
356            m.body.set(Field::new(tag, f.value_bytes().to_vec()));
357        }
358    }
359    m
360}
361
362fn unreachable_addr() -> SocketAddr {
363    SocketAddr::from(([127, 0, 0, 1], 0))
364}
365
366/// Run one scenario against an already-started acceptor at `addr`.
367pub async fn run_scenario(scenario: &Scenario, addr: SocketAddr) -> Result<(), String> {
368    let mut stream = TcpStream::connect(addr)
369        .await
370        .map_err(|e| format!("connect: {e}"))?;
371    let mut buf: Vec<u8> = Vec::new();
372
373    for (i, step) in scenario.steps.iter().enumerate() {
374        match step {
375            Step::Send(msg) => {
376                stream
377                    .write_all(&msg.encode())
378                    .await
379                    .map_err(|e| format!("step {i}: send: {e}"))?;
380            }
381            Step::SendRaw(bytes) => {
382                stream
383                    .write_all(bytes)
384                    .await
385                    .map_err(|e| format!("step {i}: send raw: {e}"))?;
386            }
387            Step::Expect(expect) => {
388                // 3s tolerates the 1s tick granularity used by timer-driven scenarios.
389                let msg = match read_message(&mut stream, &mut buf, Duration::from_secs(3)).await {
390                    ReadMessageOutcome::Message(msg) => msg,
391                    ReadMessageOutcome::TimedOut => {
392                        return Err(format!(
393                            "step {i}: expected {} but timed out",
394                            expect.msg_type
395                        ));
396                    }
397                    ReadMessageOutcome::DecodeFailed(error) => {
398                        return Err(format!(
399                            "step {i}: expected {} but got an undecodable message: {error}",
400                            expect.msg_type
401                        ));
402                    }
403                    ReadMessageOutcome::CleanEof => {
404                        return Err(format!(
405                            "step {i}: expected {} but the peer disconnected",
406                            expect.msg_type
407                        ));
408                    }
409                    ReadMessageOutcome::ReadFailed(error) => {
410                        return Err(format!("step {i}: read failed: {error}"));
411                    }
412                };
413                check_match(&msg, expect).map_err(|e| format!("step {i}: {e}"))?;
414            }
415            Step::ExpectDisconnect => {
416                match read_message(&mut stream, &mut buf, Duration::from_secs(3)).await {
417                    ReadMessageOutcome::CleanEof => {}
418                    ReadMessageOutcome::Message(_) => {
419                        return Err(format!("step {i}: expected disconnect but got a message"));
420                    }
421                    ReadMessageOutcome::TimedOut => {
422                        return Err(format!("step {i}: expected disconnect but timed out"));
423                    }
424                    ReadMessageOutcome::DecodeFailed(error) => {
425                        return Err(format!(
426                            "step {i}: expected disconnect but got an undecodable message: {error}"
427                        ));
428                    }
429                    ReadMessageOutcome::ReadFailed(error) => {
430                        return Err(format!(
431                            "step {i}: expected disconnect but read failed: {error}"
432                        ));
433                    }
434                }
435            }
436        }
437    }
438    // T131/T132 (feature 009, NEW-30): a scenario that only checks its own scripted steps can
439    // silently pass even when the server sent something extra it never asked about (e.g. a
440    // spurious duplicate response) — a short grace wait for anything left over (already-buffered
441    // or arriving shortly after the last step) catches that instead of leaving it unnoticed.
442    match read_message(&mut stream, &mut buf, Duration::from_millis(25)).await {
443        ReadMessageOutcome::TimedOut | ReadMessageOutcome::CleanEof => Ok(()),
444        ReadMessageOutcome::Message(msg) => Err(format!(
445            "scenario complete but an extra, unrequested message arrived: {msg:?}"
446        )),
447        ReadMessageOutcome::DecodeFailed(error) => Err(format!(
448            "scenario complete but extra, undecodable bytes arrived: {error}"
449        )),
450        ReadMessageOutcome::ReadFailed(error) => Err(format!(
451            "scenario complete but the trailing read failed: {error}"
452        )),
453    }
454}
455
456/// Run the full matrix (each scenario against each of its target versions), starting a fresh
457/// acceptor per version. Returns one [`ScenarioResult`] per (scenario, version).
458pub async fn run_report(scenarios: &[Scenario]) -> Vec<ScenarioResult> {
459    let mut results = Vec::new();
460    // A fresh acceptor per (scenario, version) isolates session state and lets each scenario
461    // request its own acceptor feature toggles.
462    for s in scenarios {
463        for version in &s.versions {
464            let started = match &s.tweaks.fixed_identity {
465                Some((sender, target)) => {
466                    start_fixed_identity_acceptor(version, sender, target, &s.tweaks).await
467                }
468                None => start_acceptor(version, &s.tweaks).await,
469            };
470            let outcome = match started {
471                Ok((addr, handle)) => {
472                    let outcome = run_scenario(s, addr).await;
473                    handle.abort();
474                    outcome
475                }
476                Err(e) => Err(format!("could not start acceptor: {e}")),
477            };
478            results.push(ScenarioResult {
479                name: s.name.clone(),
480                version: version.clone(),
481                outcome,
482            });
483        }
484    }
485    results
486}
487
488fn check_match(msg: &Message, expect: &ExpectMsg) -> Result<(), String> {
489    if msg.msg_type() != Some(expect.msg_type.as_str()) {
490        return Err(format!(
491            "expected MsgType {:?}, got {:?}",
492            expect.msg_type,
493            msg.msg_type()
494        ));
495    }
496    for (tag, want) in &expect.fields {
497        let got = field_value(msg, *tag);
498        if got.as_deref() != Some(want.as_str()) {
499            return Err(format!("tag {tag}: expected {want:?}, got {got:?}"));
500        }
501    }
502    for tag in &expect.fields_absent {
503        if let Some(got) = field_value(msg, *tag) {
504            return Err(format!("tag {tag}: expected absent, got {got:?}"));
505        }
506    }
507    if expect.exact {
508        let allowed = |tag: u32| {
509            ALWAYS_ALLOWED_TAGS.contains(&tag) || expect.fields.iter().any(|(t, _)| *t == tag)
510        };
511        for field in msg
512            .header
513            .fields()
514            .chain(msg.body.fields())
515            .chain(msg.trailer.fields())
516        {
517            if !allowed(field.tag()) {
518                return Err(format!(
519                    "unexpected extra tag {}: {:?}",
520                    field.tag(),
521                    field.as_str().ok()
522                ));
523            }
524        }
525    }
526    Ok(())
527}
528
529fn field_value(msg: &Message, tag: u32) -> Option<String> {
530    let field = msg
531        .header
532        .get(tag)
533        .or_else(|| msg.body.get(tag))
534        .or_else(|| msg.trailer.get(tag))?;
535    field.as_str().ok().map(str::to_owned)
536}
537
538/// Result of reading one message in the acceptance-test harness.
539#[derive(Debug)]
540pub enum ReadMessageOutcome {
541    /// A complete, decoded FIX message.
542    Message(Message),
543    /// No bytes arrived before the requested wait elapsed.
544    TimedOut,
545    /// A complete frame arrived but failed FIX decoding.
546    DecodeFailed(truefix_core::DecodeError),
547    /// The peer cleanly closed the stream.
548    CleanEof,
549    /// The socket read itself failed.
550    ReadFailed(std::io::Error),
551}
552
553/// Read one framed message from `stream`, retaining incomplete bytes in `buf`.
554pub async fn read_message(
555    stream: &mut TcpStream,
556    buf: &mut Vec<u8>,
557    wait: Duration,
558) -> ReadMessageOutcome {
559    loop {
560        if let Ok(Some(total)) = frame_length(buf) {
561            let raw: Vec<u8> = buf.drain(..total).collect();
562            return match decode(&raw) {
563                Ok(message) => ReadMessageOutcome::Message(message),
564                Err(error) => ReadMessageOutcome::DecodeFailed(error),
565            };
566        }
567        let mut chunk = [0u8; 4096];
568        match timeout(wait, stream.read(&mut chunk)).await {
569            Ok(Ok(0)) => return ReadMessageOutcome::CleanEof,
570            Ok(Err(error)) => return ReadMessageOutcome::ReadFailed(error),
571            Err(_) => return ReadMessageOutcome::TimedOut,
572            Ok(Ok(n)) => {
573                if let Some(slice) = chunk.get(..n) {
574                    buf.extend_from_slice(slice);
575                }
576            }
577        }
578    }
579}
580
581/// `SUITE_VERSIONS`' `"FIX.Latest"` entry is a version-agnostic-testing label, not a real wire
582/// `BeginString` (feature 007, BUG-79/FR-048's stricter `frame_length` format check would
583/// otherwise reject it outright, unlike every real value in `SUITE_VERSIONS`) — this maps it to
584/// a real, well-formed `BeginString` for anywhere actual wire bytes get constructed, leaving every
585/// other version untouched. The bundled `FIXLATEST` dictionary's own `.version` field is a
586/// separate, unrelated label (only ever compared via `version_meta`, which no bundled dictionary
587/// populates) — not affected by this translation.
588pub fn wire_begin_string(version: &str) -> &str {
589    if version == "FIX.Latest" {
590        "FIX.5.0SP2"
591    } else {
592        version
593    }
594}
595
596/// Helper to build a client-side message with the standard header.
597pub fn client_message(version: &str, msg_type: &str, seq: i64) -> Message {
598    let mut m = Message::new();
599    m.header.set(Field::string(8, wire_begin_string(version)));
600    m.header.set(Field::string(35, msg_type));
601    m.header.set(Field::int(34, seq));
602    m.header.set(Field::string(49, "CLIENT"));
603    m.header.set(Field::string(56, "SERVER"));
604    m.header.set(Field::string(52, "20240101-00:00:00"));
605    m
606}