Skip to main content

datui_lib/fix/
dict.rs

1//! FIX data dictionaries: what a tag is called, what type its values are, and what its
2//! enumerated codes mean.
3//!
4//! The built-in dictionary is FIX 4.2, 4.4 and 5.0 SP2 merged, generated from
5//! QuickFIX's data dictionaries by `scripts/code/fix_dictionary.py`. Custom ones are
6//! found on the format spec search path (`~/.config/datui/formats/`,
7//! `$DATUI_FORMATS_PATH`, `[formats] path`) or named by `--dict`, in two forms: a
8//! QuickFIX XML data dictionary, read as it is, or a short TOML file of `kind = "fix"`.
9//! A custom dictionary may apply only to the messages of one counterparty (`match` on
10//! SenderCompID, TargetCompID or BeginString).
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, OnceLock};
15
16use crate::formats::SpecError;
17
18/// The largest dictionary file read.
19pub const MAX_FILE: u64 = 64 << 20;
20/// The deepest XML nesting followed.
21const MAX_DEPTH: usize = 64;
22/// The longest tag name or enum name kept.
23pub const MAX_NAME: usize = 256;
24/// The highest tag number: FIX tags are positive and fit in a signed 32-bit integer.
25pub const MAX_TAG: u32 = i32::MAX as u32;
26
27/// The built-in dictionary's table; see the generator for its columns.
28const BUILT_IN: &str = include_str!("dictionary.tsv");
29
30/// What a tag's values are, as far as datui types them.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum FixType {
33    Int,
34    Float,
35    /// UTCTIMESTAMP: `YYYYMMDD-HH:MM:SS[.sss]`.
36    Timestamp,
37    /// UTCDATEONLY, LOCALMKTDATE: `YYYYMMDD`.
38    Date,
39    /// `Y` or `N`.
40    Bool,
41    /// A LENGTH tag: the size of the DATA tag after it.
42    Length,
43    /// A DATA tag, sized by its LENGTH tag; may hold the delimiter.
44    Data,
45    Text,
46}
47
48impl FixType {
49    /// A QuickFIX type name, or a TOML dictionary's.
50    pub fn parse(name: &str) -> Option<Self> {
51        Some(match name.to_ascii_uppercase().as_str() {
52            "INT" | "SEQNUM" | "NUMINGROUP" | "TAGNUM" | "DAYOFMONTH" => Self::Int,
53            "LENGTH" => Self::Length,
54            "FLOAT" | "PRICE" | "QTY" | "AMT" | "PRICEOFFSET" | "PERCENTAGE" | "QUANTITY" => {
55                Self::Float
56            }
57            "UTCTIMESTAMP" | "TIMESTAMP" | "DATETIME" => Self::Timestamp,
58            "UTCDATEONLY" | "UTCDATE" | "LOCALMKTDATE" | "DATE" => Self::Date,
59            "BOOLEAN" | "BOOL" => Self::Bool,
60            "DATA" | "XMLDATA" => Self::Data,
61            "STRING"
62            | "CHAR"
63            | "CURRENCY"
64            | "EXCHANGE"
65            | "COUNTRY"
66            | "MONTHYEAR"
67            | "MULTIPLECHARVALUE"
68            | "MULTIPLESTRINGVALUE"
69            | "MULTIPLEVALUESTRING"
70            | "XID"
71            | "XIDREF"
72            | "LANGUAGE"
73            | "LOCALMKTTIME"
74            | "UTCTIMEONLY"
75            | "TZTIMEONLY"
76            | "TZTIMESTAMP"
77            | "TEXT"
78            | "STR" => Self::Text,
79            _ => return None,
80        })
81    }
82
83    /// Whether `value` reads as this type.
84    pub fn reads(self, value: &str) -> bool {
85        match self {
86            Self::Int | Self::Length => value.parse::<i64>().is_ok(),
87            Self::Float => value.parse::<f64>().is_ok_and(f64::is_finite),
88            Self::Timestamp => parse_timestamp(value).is_some(),
89            Self::Date => parse_date(value).is_some(),
90            Self::Bool => matches!(value, "Y" | "N"),
91            Self::Data | Self::Text => true,
92        }
93    }
94}
95
96/// A UTCTIMESTAMP as nanoseconds since the epoch: `20260102-03:04:05`, with up to nine
97/// digits of fraction.
98pub fn parse_timestamp(text: &str) -> Option<i64> {
99    let (date, time) = text.split_once('-')?;
100    let days = parse_date(date)?;
101    let (hms, fraction) = match time.split_once('.') {
102        Some((hms, f)) => (hms, Some(f)),
103        None => (time, None),
104    };
105    let b = hms.as_bytes();
106    if b.len() != 8 || b[2] != b':' || b[5] != b':' {
107        return None;
108    }
109    let two = |i: usize| -> Option<i64> {
110        let (a, b) = (b[i], b[i + 1]);
111        (a.is_ascii_digit() && b.is_ascii_digit()).then(|| i64::from((a - b'0') * 10 + (b - b'0')))
112    };
113    let (h, m, s) = (two(0)?, two(3)?, two(6)?);
114    if h > 23 || m > 59 || s > 59 {
115        return None;
116    }
117    let nanos = match fraction {
118        None => 0,
119        Some(f) if !f.is_empty() && f.len() <= 9 && f.bytes().all(|c| c.is_ascii_digit()) => {
120            f.parse::<i64>().ok()? * 10i64.pow(9 - f.len() as u32)
121        }
122        Some(_) => return None,
123    };
124    // Out of a nanosecond Datetime's range (1677 to 2262) is not a time datui types.
125    days.checked_mul(86_400_000_000_000)?
126        .checked_add((h * 3600 + m * 60 + s) * 1_000_000_000 + nanos)
127}
128
129/// A `YYYYMMDD` date as days since the epoch.
130pub fn parse_date(text: &str) -> Option<i64> {
131    if text.len() != 8 || !text.bytes().all(|b| b.is_ascii_digit()) {
132        return None;
133    }
134    let y: i32 = text[..4].parse().ok()?;
135    let m: u32 = text[4..6].parse().ok()?;
136    let d: u32 = text[6..].parse().ok()?;
137    let date = chrono::NaiveDate::from_ymd_opt(y, m, d)?;
138    let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?;
139    Some((date - epoch).num_days())
140}
141
142/// What one dictionary says of one tag.
143#[derive(Debug, Clone, Default, PartialEq)]
144pub struct TagDef {
145    pub name: Option<String>,
146    pub ty: Option<FixType>,
147    /// Code to name.
148    pub enums: HashMap<String, String>,
149    /// For a LENGTH tag, the DATA tag it sizes.
150    pub data: Option<u32>,
151}
152
153/// Which messages a dictionary applies to; every field given must match.
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
155pub struct Match {
156    /// SenderCompID (49).
157    pub sender: Option<String>,
158    /// TargetCompID (56).
159    pub target: Option<String>,
160    /// BeginString (8).
161    pub begin_string: Option<String>,
162}
163
164impl Match {
165    pub fn is_empty(&self) -> bool {
166        self.sender.is_none() && self.target.is_none() && self.begin_string.is_none()
167    }
168
169    /// Whether a message of this sender, target and begin string is one of its.
170    pub fn matches(&self, sender: Option<&str>, target: Option<&str>, begin: Option<&str>) -> bool {
171        let one = |want: &Option<String>, have: Option<&str>| {
172            want.as_deref().is_none_or(|want| Some(want) == have)
173        };
174        one(&self.sender, sender) && one(&self.target, target) && one(&self.begin_string, begin)
175    }
176
177    /// `sender BROKERX, begin string FIX.4.4`.
178    pub fn summary(&self) -> String {
179        let mut parts = Vec::new();
180        if let Some(s) = &self.sender {
181            parts.push(format!("sender {s}"));
182        }
183        if let Some(t) = &self.target {
184            parts.push(format!("target {t}"));
185        }
186        if let Some(b) = &self.begin_string {
187            parts.push(format!("begin string {b}"));
188        }
189        parts.join(", ")
190    }
191}
192
193/// A FIX dictionary.
194#[derive(Debug, Clone, PartialEq)]
195pub struct Dictionary {
196    /// `fix` for the built-in one; a TOML dictionary's `name`; an XML file's stem.
197    pub name: String,
198    pub path: Option<PathBuf>,
199    pub matcher: Match,
200    pub tags: HashMap<u32, TagDef>,
201}
202
203/// The built-in dictionary, read once.
204pub fn built_in() -> Arc<Dictionary> {
205    static DICT: OnceLock<Arc<Dictionary>> = OnceLock::new();
206    DICT.get_or_init(|| Arc::new(parse_table(BUILT_IN))).clone()
207}
208
209fn parse_table(text: &str) -> Dictionary {
210    let mut tags = HashMap::new();
211    for line in text
212        .lines()
213        .filter(|l| !l.starts_with('#') && !l.is_empty())
214    {
215        let mut cols = line.split('\t');
216        let (Some(tag), Some(name), Some(ty), data, enums) = (
217            cols.next().and_then(|t| t.parse::<u32>().ok()),
218            cols.next(),
219            cols.next(),
220            cols.next().and_then(|d| d.parse::<u32>().ok()),
221            cols.next().unwrap_or_default(),
222        ) else {
223            continue;
224        };
225        let enums = enums
226            .split(';')
227            .filter_map(|pair| pair.split_once('='))
228            .map(|(code, name)| (code.to_string(), name.to_string()))
229            .collect();
230        tags.insert(
231            tag,
232            TagDef {
233                name: Some(name.to_string()),
234                ty: FixType::parse(ty),
235                enums,
236                data,
237            },
238        );
239    }
240    Dictionary {
241        name: "fix".to_string(),
242        path: None,
243        matcher: Match::default(),
244        tags,
245    }
246}
247
248fn error(path: Option<&Path>, line: usize, column: usize, message: impl Into<String>) -> SpecError {
249    SpecError {
250        path: path.map(Path::to_path_buf),
251        line,
252        column,
253        message: message.into(),
254    }
255}
256
257/// Line and column (one-based) of byte `offset` in `text`.
258fn line_column(text: &str, offset: usize) -> (usize, usize) {
259    let mut offset = offset.min(text.len());
260    while !text.is_char_boundary(offset) {
261        offset -= 1;
262    }
263    let before = &text[..offset];
264    let line = before.matches('\n').count() + 1;
265    let column = before
266        .rsplit('\n')
267        .next()
268        .unwrap_or_default()
269        .chars()
270        .count()
271        + 1;
272    (line, column)
273}
274
275/// Whether a TOML file is a FIX dictionary (`kind = "fix"`) rather than a format spec.
276pub fn is_fix_toml(text: &str) -> bool {
277    text.parse::<toml::Table>()
278        .is_ok_and(|t| t.get("kind").and_then(|k| k.as_str()) == Some("fix"))
279}
280
281fn short(name: &str) -> String {
282    let mut cut = name.len().min(MAX_NAME);
283    while !name.is_char_boundary(cut) {
284        cut -= 1;
285    }
286    name[..cut].to_string()
287}
288
289impl Dictionary {
290    /// Read the dictionary in `path`: QuickFIX XML (`.xml`) or TOML. `Ok(None)` for an
291    /// XML file that is not a FIX dictionary or a TOML file that is not `kind = "fix"`.
292    pub fn load(path: &Path) -> Result<Option<Self>, SpecError> {
293        let size = std::fs::metadata(path)
294            .map_err(|e| {
295                error(
296                    Some(path),
297                    0,
298                    0,
299                    format!(
300                        "could not read it. {}",
301                        crate::error_display::user_message_from_io(&e, None)
302                    ),
303                )
304            })?
305            .len();
306        if size > MAX_FILE {
307            return Err(error(
308                Some(path),
309                0,
310                0,
311                format!(
312                    "{} MiB is past the {} MiB a dictionary may be",
313                    size >> 20,
314                    MAX_FILE >> 20
315                ),
316            ));
317        }
318        let text = std::fs::read_to_string(path).map_err(|e| {
319            error(
320                Some(path),
321                0,
322                0,
323                format!(
324                    "could not read it. {}",
325                    crate::error_display::user_message_from_io(&e, None)
326                ),
327            )
328        })?;
329        let xml = path
330            .extension()
331            .is_some_and(|e| e.eq_ignore_ascii_case("xml"));
332        if xml {
333            let stem = path
334                .file_stem()
335                .map(|s| s.to_string_lossy().into_owned())
336                .unwrap_or_default();
337            Self::parse_xml(&text, &stem, Some(path))
338        } else if is_fix_toml(&text) {
339            Self::parse_toml(&text, Some(path)).map(Some)
340        } else {
341            Ok(None)
342        }
343    }
344
345    /// A `kind = "fix"` TOML dictionary.
346    pub fn parse_toml(text: &str, path: Option<&Path>) -> Result<Self, SpecError> {
347        let table: toml::Table = text.parse().map_err(|e: toml::de::Error| {
348            let (line, column) = e
349                .span()
350                .map_or((0, 0), |span| line_column(text, span.start));
351            error(path, line, column, e.message().to_string())
352        })?;
353        let at = |key: &str| {
354            // Where a top-level key starts, for errors about its value.
355            text.lines()
356                .position(|l| l.trim_start().starts_with(key))
357                .map_or(0, |i| i + 1)
358        };
359        let fail = |key: &str, message: String| error(path, at(key), 1, message);
360        for key in table.keys() {
361            if !matches!(
362                key.as_str(),
363                "name" | "kind" | "description" | "match" | "tags"
364            ) {
365                return Err(fail(
366                    key,
367                    format!(
368                        "unknown key `{key}`; a dictionary of kind = \"fix\" has name, kind, description, match and tags"
369                    ),
370                ));
371            }
372        }
373        let name = match table.get("name") {
374            Some(toml::Value::String(name)) if crate::formats::is_spec_name(name) => name.clone(),
375            Some(_) => {
376                return Err(fail(
377                    "name",
378                    "name: expected a namespaced name such as acme.fix.broker-x".into(),
379                ));
380            }
381            None => {
382                return Err(error(
383                    path,
384                    0,
385                    0,
386                    "missing `name`, such as name = \"acme.fix.broker-x\"",
387                ));
388            }
389        };
390        let mut matcher = Match::default();
391        if let Some(m) = table.get("match") {
392            let Some(m) = m.as_table() else {
393                return Err(fail(
394                    "match",
395                    "match: expected a table such as { sender = \"BROKERX\" }".into(),
396                ));
397            };
398            for (key, value) in m {
399                let Some(value) = value.as_str() else {
400                    return Err(fail("match", format!("match.{key}: expected a string")));
401                };
402                let slot = match key.as_str() {
403                    "sender" => &mut matcher.sender,
404                    "target" => &mut matcher.target,
405                    "begin_string" => &mut matcher.begin_string,
406                    _ => {
407                        return Err(fail(
408                            "match",
409                            format!("match.{key}: expected sender, target or begin_string"),
410                        ));
411                    }
412                };
413                *slot = Some(value.to_string());
414            }
415        }
416        let mut tags = HashMap::new();
417        if let Some(t) = table.get("tags") {
418            let Some(t) = t.as_table() else {
419                return Err(fail(
420                    "tags",
421                    "tags: expected a table such as { 9001 = \"AlgoName\" }".into(),
422                ));
423            };
424            for (key, value) in t {
425                let tag = key
426                    .parse::<u32>()
427                    .ok()
428                    .filter(|t| (1..=MAX_TAG).contains(t))
429                    .ok_or_else(|| fail("tags", format!("tags.{key}: expected a tag number")))?;
430                let def = match value {
431                    toml::Value::String(name) => TagDef {
432                        name: Some(short(name)),
433                        ..TagDef::default()
434                    },
435                    toml::Value::Table(def) => tag_def(def, key).map_err(|m| fail("tags", m))?,
436                    _ => {
437                        return Err(fail(
438                            "tags",
439                            format!(
440                                "tags.{key}: expected a name or a table with name, type and enum"
441                            ),
442                        ));
443                    }
444                };
445                tags.insert(tag, def);
446            }
447        }
448        Ok(Self {
449            name,
450            path: path.map(Path::to_path_buf),
451            matcher,
452            tags,
453        })
454    }
455
456    /// A QuickFIX XML data dictionary: its `<fields>`, each with its `<value>`s. It
457    /// applies to the messages of its version's BeginString. `Ok(None)` when the root
458    /// element is not `<fix>`.
459    pub fn parse_xml(
460        text: &str,
461        name: &str,
462        path: Option<&Path>,
463    ) -> Result<Option<Self>, SpecError> {
464        let mut stack: Vec<String> = Vec::new();
465        let mut tags = HashMap::new();
466        let mut field: Option<(u32, TagDef)> = None;
467        let mut root = None;
468        let mut pos = 0;
469        let bytes = text.as_bytes();
470        while let Some(open) = text[pos..].find('<').map(|i| pos + i) {
471            let rest = &text[open..];
472            let fail = |message: &str| {
473                let (line, column) = line_column(text, open);
474                error(path, line, column, message.to_string())
475            };
476            if rest.starts_with("<!--") {
477                pos = match rest.find("-->") {
478                    Some(end) => open + end + 3,
479                    None => return Err(fail("a comment is not closed")),
480                };
481                continue;
482            }
483            if rest.starts_with("<?") || rest.starts_with("<!") {
484                pos = match rest.find('>') {
485                    Some(end) => open + end + 1,
486                    None => return Err(fail("markup is not closed")),
487                };
488                continue;
489            }
490            let Some(end) = markup_end(&bytes[open..]) else {
491                return Err(fail("a tag is not closed"));
492            };
493            let inner = &text[open + 1..open + end - 1];
494            pos = open + end;
495            if let Some(closing) = inner.strip_prefix('/') {
496                let closing = closing.trim();
497                if stack.last().map(String::as_str) != Some(closing) {
498                    return Err(fail(&format!("</{closing}> closes no <{closing}>")));
499                }
500                stack.pop();
501                if closing == "field"
502                    && let Some((tag, def)) = field.take()
503                {
504                    tags.insert(tag, def);
505                }
506                continue;
507            }
508            let empty = inner.ends_with('/');
509            let inner = inner.strip_suffix('/').unwrap_or(inner);
510            let (element, attrs) = match inner.find(|c: char| c.is_ascii_whitespace()) {
511                Some(i) => (&inner[..i], &inner[i..]),
512                None => (inner, ""),
513            };
514            let attrs = attributes(attrs).map_err(|m| fail(&m))?;
515            let get = |key: &str| {
516                attrs
517                    .iter()
518                    .find(|(k, _)| k == key)
519                    .map(|(_, v)| v.as_str())
520            };
521            if root.is_none() {
522                if element != "fix" {
523                    return Ok(None);
524                }
525                let begin = match (get("type"), get("major"), get("minor")) {
526                    (_, Some("5"), _) | (Some("FIXT"), _, _) => Some("FIXT.1.1".to_string()),
527                    (_, Some(major), Some(minor)) => Some(format!("FIX.{major}.{minor}")),
528                    _ => None,
529                };
530                root = Some(begin);
531            }
532            let in_fields = stack.last().map(String::as_str) == Some("fields");
533            if element == "field" && in_fields {
534                let tag = get("number")
535                    .and_then(|n| n.parse::<u32>().ok())
536                    .filter(|t| (1..=MAX_TAG).contains(t))
537                    .ok_or_else(|| fail("<field> needs a number"))?;
538                let def = TagDef {
539                    name: get("name").map(short),
540                    ty: get("type").and_then(FixType::parse),
541                    enums: HashMap::new(),
542                    data: None,
543                };
544                if empty {
545                    tags.insert(tag, def);
546                } else {
547                    field = Some((tag, def));
548                }
549            } else if element == "value"
550                && stack.last().map(String::as_str) == Some("field")
551                && let Some((_, def)) = field.as_mut()
552                && let (Some(code), Some(description)) = (get("enum"), get("description"))
553            {
554                def.enums.insert(short(code), camel(description));
555            }
556            if !empty {
557                if stack.len() >= MAX_DEPTH {
558                    return Err(fail("elements nest too deep"));
559                }
560                stack.push(element.to_string());
561            }
562        }
563        let Some(begin) = root else {
564            return Ok(None);
565        };
566        // A LENGTH field sizes the DATA field named as it is without Len or Length.
567        let by_name: HashMap<String, u32> = tags
568            .iter()
569            .filter(|(_, d)| d.ty == Some(FixType::Data))
570            .filter_map(|(t, d)| Some((d.name.clone()?, *t)))
571            .collect();
572        for def in tags.values_mut() {
573            if def.ty == Some(FixType::Length)
574                && let Some(name) = &def.name
575            {
576                def.data = ["Length", "Len"]
577                    .iter()
578                    .find_map(|s| name.strip_suffix(s))
579                    .and_then(|stem| by_name.get(stem).copied());
580            }
581        }
582        Ok(Some(Self {
583            name: name.to_string(),
584            path: path.map(Path::to_path_buf),
585            matcher: Match {
586                begin_string: begin,
587                ..Match::default()
588            },
589            tags,
590        }))
591    }
592}
593
594/// A TOML dictionary's table for one tag: `{ name = "Urgency", type = "int", enum =
595/// { 1 = "Low" } }`.
596fn tag_def(def: &toml::Table, key: &str) -> Result<TagDef, String> {
597    let mut out = TagDef::default();
598    for (k, v) in def {
599        match k.as_str() {
600            "name" => {
601                out.name =
602                    Some(short(v.as_str().ok_or_else(|| {
603                        format!("tags.{key}.name: expected a string")
604                    })?));
605            }
606            "type" => {
607                let name = v
608                    .as_str()
609                    .ok_or_else(|| format!("tags.{key}.type: expected a string"))?;
610                out.ty = Some(FixType::parse(name).ok_or_else(|| {
611                    format!(
612                        "tags.{key}.type: expected int, float, price, qty, string, char, timestamp, date, bool, length or data"
613                    )
614                })?);
615            }
616            "enum" => {
617                let table = v.as_table().ok_or_else(|| {
618                    format!("tags.{key}.enum: expected a table such as {{ 1 = \"Low\" }}")
619                })?;
620                for (code, name) in table {
621                    let name = name
622                        .as_str()
623                        .ok_or_else(|| format!("tags.{key}.enum.{code}: expected a string"))?;
624                    out.enums.insert(short(code), short(name));
625                }
626            }
627            "data" => {
628                let tag = v
629                    .as_integer()
630                    .and_then(|t| u32::try_from(t).ok())
631                    .filter(|t| (1..=MAX_TAG).contains(t))
632                    .ok_or_else(|| {
633                        format!("tags.{key}.data: expected the tag number this length sizes")
634                    })?;
635                out.data = Some(tag);
636                out.ty.get_or_insert(FixType::Length);
637            }
638            other => {
639                return Err(format!(
640                    "tags.{key}.{other}: unknown key; a tag has name, type, enum and data"
641                ));
642            }
643        }
644    }
645    Ok(out)
646}
647
648/// The length of the tag at the front of `bytes`, `>` included, minding quotes.
649fn markup_end(bytes: &[u8]) -> Option<usize> {
650    let mut quote = None;
651    for (i, &b) in bytes.iter().enumerate().skip(1) {
652        match (quote, b) {
653            (None, b'"' | b'\'') => quote = Some(b),
654            (Some(q), b) if b == q => quote = None,
655            (None, b'>') => return Some(i + 1),
656            _ => {}
657        }
658    }
659    None
660}
661
662/// `name='v' other="w"` as pairs, entities decoded.
663fn attributes(text: &str) -> Result<Vec<(String, String)>, String> {
664    let mut out = Vec::new();
665    let mut rest = text.trim_start();
666    while !rest.is_empty() {
667        let eq = rest.find('=').ok_or("an attribute has no value")?;
668        let key = rest[..eq].trim().to_string();
669        let after = rest[eq + 1..].trim_start();
670        let quote = after.chars().next().ok_or("an attribute has no value")?;
671        if quote != '"' && quote != '\'' {
672            return Err("an attribute's value is not quoted".into());
673        }
674        let close = after[1..]
675            .find(quote)
676            .ok_or("an attribute's quote is not closed")?;
677        out.push((key, unescape(&after[1..1 + close])));
678        rest = after[close + 2..].trim_start();
679        if out.len() > 64 {
680            return Err("too many attributes".into());
681        }
682    }
683    Ok(out)
684}
685
686fn unescape(text: &str) -> String {
687    if !text.contains('&') {
688        return text.to_string();
689    }
690    text.replace("&lt;", "<")
691        .replace("&gt;", ">")
692        .replace("&quot;", "\"")
693        .replace("&apos;", "'")
694        .replace("&amp;", "&")
695}
696
697/// `SELL_SHORT` as SellShort, as the built-in dictionary writes QuickFIX's names.
698pub fn camel(words: &str) -> String {
699    if words.chars().any(|c| c.is_lowercase()) {
700        return short(
701            &words
702                .split('_')
703                .map(|p| {
704                    let mut c = p.chars();
705                    c.next()
706                        .map(|f| f.to_uppercase().chain(c).collect::<String>())
707                        .unwrap_or_default()
708                })
709                .collect::<String>(),
710        );
711    }
712    short(
713        &words
714            .split('_')
715            .filter(|p| !p.is_empty())
716            .map(|p| {
717                let lower = p.to_lowercase();
718                let mut c = lower.chars();
719                c.next()
720                    .map(|f| f.to_uppercase().chain(c).collect::<String>())
721                    .unwrap_or_default()
722            })
723            .collect::<String>(),
724    )
725}
726
727/// The dictionaries a FIX log is read with, in order: the built-in one, then the custom
728/// ones on the search path, then `--dict`. Later ones win.
729#[derive(Debug, Clone)]
730pub struct Layers {
731    pub dicts: Vec<Arc<Dictionary>>,
732    /// For each LENGTH tag in any dictionary, the DATA tag it sizes.
733    data: HashMap<u32, u32>,
734}
735
736impl Default for Layers {
737    fn default() -> Self {
738        Self::new(Vec::new())
739    }
740}
741
742/// What the dictionaries that apply to a message say of one tag.
743#[derive(Debug, Clone, Default, PartialEq)]
744pub struct Resolved {
745    pub name: Option<String>,
746    /// The dictionary that named it.
747    pub named_by: Option<usize>,
748    pub ty: Option<FixType>,
749    pub enums: HashMap<String, String>,
750}
751
752impl Layers {
753    /// The built-in dictionary under `custom`, in order.
754    pub fn new(custom: Vec<Arc<Dictionary>>) -> Self {
755        let mut dicts = vec![built_in()];
756        dicts.extend(custom);
757        let mut data = HashMap::new();
758        for dict in &dicts {
759            for (tag, def) in &dict.tags {
760                if let Some(d) = def.data {
761                    data.insert(*tag, d);
762                }
763            }
764        }
765        Self { dicts, data }
766    }
767
768    /// The DATA tag a LENGTH tag sizes, in any dictionary.
769    pub fn data_tag(&self, length: u32) -> Option<u32> {
770        self.data.get(&length).copied()
771    }
772
773    /// The dictionaries, by index, that apply to a message of this sender, target and
774    /// begin string.
775    pub fn applying(
776        &self,
777        sender: Option<&str>,
778        target: Option<&str>,
779        begin: Option<&str>,
780    ) -> Vec<usize> {
781        (0..self.dicts.len())
782            .filter(|&i| self.dicts[i].matcher.matches(sender, target, begin))
783            .collect()
784    }
785
786    /// What the dictionaries `applying` say of `tag`, later ones first.
787    pub fn resolve(&self, applying: &[usize], tag: u32) -> Resolved {
788        let mut out = Resolved::default();
789        for &i in applying {
790            let Some(def) = self.dicts[i].tags.get(&tag) else {
791                continue;
792            };
793            if let Some(name) = &def.name {
794                out.name = Some(name.clone());
795                out.named_by = Some(i);
796            }
797            if def.ty.is_some() {
798                out.ty = def.ty;
799            }
800            for (code, name) in &def.enums {
801                out.enums.insert(code.clone(), name.clone());
802            }
803        }
804        out
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn the_built_in_dictionary_names_tags_and_codes() {
814        let dict = built_in();
815        let msg_type = &dict.tags[&35];
816        assert_eq!(msg_type.name.as_deref(), Some("MsgType"));
817        assert_eq!(msg_type.enums["D"], "NewOrderSingle");
818        assert_eq!(dict.tags[&54].enums["1"], "Buy");
819        assert!(
820            dict.tags[&8].enums.is_empty(),
821            "FIX.4.4 is not renamed Fix44"
822        );
823        assert_eq!(dict.tags[&44].ty, Some(FixType::Float));
824        assert_eq!(dict.tags[&52].ty, Some(FixType::Timestamp));
825        assert_eq!(dict.tags[&95].data, Some(96));
826        assert_eq!(dict.tags[&212].data, Some(213));
827        assert_eq!(dict.tags[&93].data, Some(89));
828    }
829
830    #[test]
831    fn timestamps_and_dates() {
832        assert_eq!(parse_timestamp("19700101-00:00:01"), Some(1_000_000_000));
833        assert_eq!(parse_timestamp("19700101-00:00:00.5"), Some(500_000_000));
834        assert_eq!(
835            parse_timestamp("19700102-00:00:00.000000001"),
836            Some(86_400_000_000_001)
837        );
838        assert_eq!(parse_timestamp("19700101-24:00:00"), None);
839        assert_eq!(parse_timestamp("19700101-00:00:00."), None);
840        assert_eq!(parse_timestamp("19701301-00:00:00"), None);
841        assert_eq!(parse_date("19700201"), Some(31));
842        assert_eq!(parse_timestamp("99991231-23:59:59"), None, "past 2262");
843        assert_eq!(parse_timestamp("00010101-00:00:00"), None, "before 1677");
844    }
845
846    const TOML: &str = r#"
847name = "acme.fix.broker-x"
848kind = "fix"
849match = { sender = "BROKERX", begin_string = "FIX.4.4" }
850tags = { 9001 = "AlgoName", 9002 = { name = "Urgency", type = "int", enum = { 1 = "Low", 2 = "High" } }, 54 = { enum = { Z = "Zap" } } }
851"#;
852
853    #[test]
854    fn a_toml_dictionary() {
855        assert!(is_fix_toml(TOML));
856        let dict = Dictionary::parse_toml(TOML, None).unwrap();
857        assert_eq!(dict.name, "acme.fix.broker-x");
858        assert_eq!(dict.matcher.sender.as_deref(), Some("BROKERX"));
859        assert_eq!(dict.tags[&9001].name.as_deref(), Some("AlgoName"));
860        assert_eq!(dict.tags[&9002].ty, Some(FixType::Int));
861        assert_eq!(dict.tags[&9002].enums["2"], "High");
862
863        let layers = Layers::new(vec![Arc::new(dict)]);
864        let broker = layers.applying(Some("BROKERX"), None, Some("FIX.4.4"));
865        let other = layers.applying(Some("OTHER"), None, Some("FIX.4.4"));
866        assert_eq!(broker, [0, 1]);
867        assert_eq!(other, [0]);
868        let side = layers.resolve(&broker, 54);
869        assert_eq!(side.name.as_deref(), Some("Side"));
870        assert_eq!(side.enums["Z"], "Zap");
871        assert_eq!(side.enums["1"], "Buy");
872        assert_eq!(layers.resolve(&other, 9001).name, None);
873    }
874
875    #[test]
876    fn toml_errors_say_where() {
877        for (text, said) in [
878            (
879                "name = \"a.b\"\nkind = \"fix\"\ncolour = 1",
880                "Unknown key `colour`",
881            ),
882            ("name = \"plain\"\nkind = \"fix\"", "namespaced"),
883            (
884                "name = \"a.b\"\nkind = \"fix\"\ntags = { x = \"A\" }",
885                "tags.x: expected a tag number",
886            ),
887            (
888                "name = \"a.b\"\nkind = \"fix\"\ntags = { 1 = { type = \"blob\" } }",
889                "tags.1.type",
890            ),
891            (
892                "name = \"a.b\"\nkind = \"fix\"\nmatch = { venue = \"X\" }",
893                "match.venue",
894            ),
895            ("name = \"a.b\"\nkind = \"fix\"\n[[", ""),
896        ] {
897            let e = Dictionary::parse_toml(text, Some(Path::new("d.toml"))).unwrap_err();
898            let e = e.to_string();
899            assert!(e.contains(said), "{e}");
900            assert!(e.starts_with("\"d.toml\":"), "{e}");
901        }
902    }
903
904    const XML: &str = r#"<?xml version="1.0"?>
905<!-- custom fields -->
906<fix type='FIX' major='4' minor='4' servicepack='0'>
907 <header><field name='BeginString' required='Y'/></header>
908 <messages><message name='Heartbeat' msgtype='0' msgcat='admin'><field name='TestReqID' required='N'/></message></messages>
909 <fields>
910  <field number='5001' name='Strategy' type='STRING'>
911   <value enum='V' description='VWAP_PLUS' />
912   <value enum='T' description="TWAP &amp; MORE" />
913  </field>
914  <field number='5002' name='SpecialLen' type='LENGTH'/>
915  <field number='5003' name='Special' type='DATA'/>
916 </fields>
917</fix>"#;
918
919    #[test]
920    fn a_quickfix_xml_dictionary() {
921        let dict = Dictionary::parse_xml(XML, "custom44", None)
922            .unwrap()
923            .unwrap();
924        assert_eq!(dict.matcher.begin_string.as_deref(), Some("FIX.4.4"));
925        assert_eq!(dict.tags.len(), 3);
926        assert_eq!(dict.tags[&5001].enums["V"], "VwapPlus");
927        assert_eq!(dict.tags[&5001].enums["T"], "Twap & more");
928        assert_eq!(dict.tags[&5002].data, Some(5003));
929        assert!(
930            Dictionary::parse_xml("<gpx></gpx>", "x", None)
931                .unwrap()
932                .is_none()
933        );
934        assert!(
935            Dictionary::parse_xml("<fix><fields><field number='x'/></fields></fix>", "x", None)
936                .is_err()
937        );
938        assert!(Dictionary::parse_xml("<fix><fields></field></fix>", "x", None).is_err());
939    }
940}