Skip to main content

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