Skip to main content

dre_protocol/
options.rs

1//! Plugin options: the keys a report's config block for a plugin may hold (a format's `output:`
2//! keys, a destination entry's keys), declared once by the plugin and checked by the SDK.
3//!
4//! A plugin lists its options as [`OptionField`]s. `describe` publishes them, and [`check`] tests
5//! a config block against them: unknown keys, types, allowed values and bounds. Rules the
6//! declaration can't express go in the plugin's own `validate`, which runs as well.
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11use crate::Kind;
12
13/// What a value must be.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum OptionType {
17    String,
18    /// A string of exactly one character (a delimiter, a pad).
19    Char,
20    Boolean,
21    /// A whole number.
22    Integer,
23    Number,
24    /// A string, or a list of strings (recipients, say).
25    Strings,
26    List,
27    Map,
28    /// Anything; the plugin's `validate` checks it.
29    Any,
30}
31
32/// One option a plugin takes.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct OptionField {
35    pub name: String,
36    #[serde(rename = "type")]
37    pub ty: OptionType,
38    #[serde(default)]
39    pub description: String,
40    #[serde(default)]
41    pub required: bool,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub default: Option<Value>,
44    /// The only values a string option may take.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub choices: Vec<String>,
47    /// Bounds for `integer` and `number`, inclusive.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub min: Option<f64>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub max: Option<f64>,
52}
53
54impl OptionField {
55    pub fn new(name: &str, ty: OptionType, description: &str) -> Self {
56        OptionField {
57            name: name.into(),
58            ty,
59            description: description.into(),
60            required: false,
61            default: None,
62            choices: Vec::new(),
63            min: None,
64            max: None,
65        }
66    }
67    pub fn required(mut self) -> Self {
68        self.required = true;
69        self
70    }
71    pub fn default(mut self, v: impl Into<Value>) -> Self {
72        self.default = Some(v.into());
73        self
74    }
75    pub fn choices(mut self, c: &[&str]) -> Self {
76        self.choices = c.iter().map(|s| s.to_string()).collect();
77        self
78    }
79    pub fn range(mut self, min: Option<f64>, max: Option<f64>) -> Self {
80        self.min = min;
81        self.max = max;
82        self
83    }
84}
85
86/// Whether a string holds Jinja that core renders before the plugin sees it (destination
87/// options). Its final value is unknown, so only its presence is checked.
88pub fn is_template(s: &str) -> bool {
89    s.contains("{{") || s.contains("{%")
90}
91
92/// A choice as it reads in a message: `` `all` ``, or `"\n"` for one with control characters.
93fn show(s: &str) -> String {
94    if s.chars().any(char::is_control) {
95        Value::String(s.into()).to_string()
96    } else {
97        format!("`{s}`")
98    }
99}
100
101fn one_of(choices: &[String]) -> String {
102    let shown: Vec<String> = choices.iter().map(|c| show(c)).collect();
103    match shown.as_slice() {
104        [a, b] => format!("{a} or {b}"),
105        _ => format!("one of {}", shown.join(", ")),
106    }
107}
108
109fn number(n: f64) -> String {
110    if n.fract() == 0.0 {
111        format!("{}", n as i64)
112    } else {
113        n.to_string()
114    }
115}
116
117/// Check `options` against the declared `fields` of the `kind` plugin `name`.
118pub fn check(kind: Kind, name: &str, fields: &[OptionField], options: &Map<String, Value>) -> Vec<String> {
119    let mut errs = Vec::new();
120    for k in options.keys() {
121        if fields.iter().any(|f| &f.name == k) {
122            continue;
123        }
124        errs.push(if fields.is_empty() {
125            format!("the `{name}` {kind} takes no options, but got `{k}`; check the key's spelling")
126        } else {
127            let known: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
128            format!(
129                "unknown option `{k}` for {kind} `{name}`; expected one of {}",
130                known.join(", ")
131            )
132        });
133    }
134    for f in fields {
135        let v = match options.get(&f.name) {
136            None | Some(Value::Null) => {
137                if f.required {
138                    errs.push(format!("`{}` is required", f.name));
139                }
140                continue;
141            }
142            Some(v) => v,
143        };
144        if v.as_str().is_some_and(is_template) {
145            continue;
146        }
147        if let Some(e) = check_value(f, v) {
148            errs.push(format!("`{}` {e}", f.name));
149        }
150    }
151    errs
152}
153
154fn check_value(f: &OptionField, v: &Value) -> Option<String> {
155    let ok = match f.ty {
156        OptionType::String => v.is_string(),
157        OptionType::Char => v.as_str().is_some_and(|s| s.chars().count() == 1),
158        OptionType::Boolean => v.is_boolean(),
159        OptionType::Integer => v.is_i64() || v.is_u64(),
160        OptionType::Number => v.is_number(),
161        OptionType::Strings => v.is_string() || v.as_array().is_some_and(|a| a.iter().all(Value::is_string)),
162        OptionType::List => v.is_array(),
163        OptionType::Map => v.is_object(),
164        OptionType::Any => true,
165    };
166    let bounded = matches!(f.ty, OptionType::Integer | OptionType::Number);
167    let in_range = v
168        .as_f64()
169        .is_none_or(|n| f.min.is_none_or(|m| n >= m) && f.max.is_none_or(|m| n <= m));
170    if !ok || (bounded && !in_range) {
171        let what = match f.ty {
172            OptionType::String => "a string",
173            OptionType::Char => "a single character",
174            OptionType::Boolean => "true or false",
175            OptionType::Integer => "a whole number",
176            OptionType::Number => "a number",
177            OptionType::Strings => "a string or a list of strings",
178            OptionType::List => "a list",
179            OptionType::Map => "a map",
180            OptionType::Any => "",
181        };
182        let bounds = match (bounded, f.min, f.max) {
183            (true, Some(a), Some(b)) => format!(" from {} to {}", number(a), number(b)),
184            (true, Some(a), None) => format!(" of at least {}", number(a)),
185            (true, None, Some(b)) => format!(" of at most {}", number(b)),
186            _ => String::new(),
187        };
188        return Some(format!("must be {what}{bounds}"));
189    }
190    if !f.choices.is_empty()
191        && let Some(s) = v.as_str()
192        && !f.choices.iter().any(|c| c == s)
193    {
194        return Some(format!("must be {}", one_of(&f.choices)));
195    }
196    None
197}
198
199/// What an Excel number format code displays, from [`check_num_format`].
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum NumFormatClass {
202    /// Digits, percentages, currency, `General`: for number columns.
203    Number,
204    /// Dates, times, elapsed time: for date, timestamp and time columns.
205    DateTime,
206    /// Only `@` (and literals): shows any value as text.
207    Text,
208}
209
210impl NumFormatClass {
211    pub fn describe(self) -> &'static str {
212        match self {
213            NumFormatClass::Number => "a number format",
214            NumFormatClass::DateTime => "a date/time format",
215            NumFormatClass::Text => "a text format",
216        }
217    }
218}
219
220/// Characters Excel shows as themselves in a format code without quoting.
221const LITERALS: &str = "$-+/():!^&'~{}<>= ";
222
223/// A light syntax check of an Excel number format code (the text of Excel's Format Cells →
224/// Custom dialog), and what it displays. Not a full grammar: conditions, colours and locale tags
225/// inside `[]` are accepted without being interpreted.
226pub fn check_num_format(code: &str) -> Result<NumFormatClass, String> {
227    if code.trim().is_empty() {
228        return Err("is empty".into());
229    }
230    let mut sections = vec![String::new()];
231    let mut chars = code.chars().peekable();
232    // Per section: the tokens left once quoted text, escapes, fills and brackets are removed.
233    let mut elapsed = vec![false];
234    while let Some(c) = chars.next() {
235        match c {
236            '"' => loop {
237                match chars.next() {
238                    Some('"') => break,
239                    Some(_) => {}
240                    None => return Err("has an unclosed `\"` quote".into()),
241                }
242            },
243            '[' => {
244                let mut inner = String::new();
245                loop {
246                    match chars.next() {
247                        Some(']') => break,
248                        Some('[') => return Err("has a `[` inside `[ ]`".into()),
249                        Some(ch) => inner.push(ch),
250                        None => return Err("has an unclosed `[` bracket".into()),
251                    }
252                }
253                let lower = inner.to_ascii_lowercase();
254                if !lower.is_empty()
255                    && ["h", "m", "s"]
256                        .iter()
257                        .any(|u| lower.chars().all(|ch| ch.to_string() == *u))
258                {
259                    *elapsed.last_mut().unwrap() = true;
260                }
261            }
262            ']' => return Err("has a `]` without a matching `[`".into()),
263            '\\' | '_' | '*' => {
264                if chars.next().is_none() {
265                    return Err(format!("ends with `{c}`, which needs a character after it"));
266                }
267            }
268            ';' => {
269                sections.push(String::new());
270                elapsed.push(false);
271            }
272            _ => sections.last_mut().unwrap().push(c),
273        }
274    }
275    if sections.len() > 4 {
276        return Err(format!(
277            "has {} `;` sections; Excel allows at most four (positive;negative;zero;text)",
278            sections.len()
279        ));
280    }
281    let mut class = None;
282    for (s, elapsed) in sections.iter().zip(elapsed) {
283        let general = s.to_ascii_lowercase().contains("general");
284        let s = remove_general(s);
285        let mut date = elapsed;
286        let mut number = general;
287        let upper = s.to_ascii_uppercase();
288        let mut rest = upper.as_str();
289        while let Some(c) = rest.chars().next() {
290            if rest.starts_with("AM/PM") {
291                date = true;
292                rest = &rest[5..];
293                continue;
294            }
295            if rest.starts_with("A/P") {
296                date = true;
297                rest = &rest[3..];
298                continue;
299            }
300            match c {
301                '0' | '#' | '?' | '%' | '.' | ',' => number = true,
302                '1'..='9' => {}
303                'E' if rest[1..].starts_with(['+', '-']) => {
304                    number = true;
305                    rest = &rest[2..];
306                    continue;
307                }
308                'Y' | 'M' | 'D' | 'H' | 'S' | 'E' | 'B' | 'G' => date = true,
309                '@' => {}
310                c if LITERALS.contains(c) => {}
311                c => {
312                    return Err(format!(
313                        "has `{c}`, which isn't part of an Excel number format; put literal text in double quotes"
314                    ));
315                }
316            }
317            rest = &rest[c.len_utf8()..];
318        }
319        let this = if date {
320            Some(NumFormatClass::DateTime)
321        } else if number {
322            Some(NumFormatClass::Number)
323        } else {
324            None
325        };
326        class = class.or(this);
327    }
328    Ok(class.unwrap_or(NumFormatClass::Text))
329}
330
331/// A section with every `General` (any case) taken out.
332fn remove_general(s: &str) -> String {
333    let lower = s.to_ascii_lowercase();
334    if !lower.contains("general") {
335        return s.to_string();
336    }
337    let mut out = String::new();
338    let mut i = 0;
339    while i < s.len() {
340        if lower[i..].starts_with("general") {
341            i += "general".len();
342        } else {
343            let c = s[i..].chars().next().unwrap();
344            out.push(c);
345            i += c.len_utf8();
346        }
347    }
348    out
349}
350
351/// The keys a column may set in a `columns:` map.
352pub const COLUMN_OPTION_KEYS: &[&str] = &["format", "formula", "total", "width", "style"];
353
354/// The functions a `total:` can name, and the Excel function each writes.
355pub const TOTAL_FUNCTIONS: &[(&str, &str)] = &[
356    ("sum", "SUM"),
357    ("average", "AVERAGE"),
358    ("count", "COUNTA"),
359    ("min", "MIN"),
360    ("max", "MAX"),
361];
362
363/// A piece of a formula from YAML.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum FormulaPart {
366    /// Text written as it is.
367    Text(String),
368    /// `{name}`: that column's cell on the same row.
369    Cell(String),
370    /// `{name:*}`: that column's data range on the sheet.
371    Column(String),
372}
373
374/// Split a formula into text and `{name}` / `{name:*}` references. `{{` and `}}` are literal
375/// braces. The formula must start with `=`.
376pub fn parse_formula(f: &str) -> Result<Vec<FormulaPart>, String> {
377    if !f.starts_with('=') {
378        return Err("must start with `=`".into());
379    }
380    let mut parts = Vec::new();
381    let mut text = String::new();
382    let mut chars = f.chars().peekable();
383    while let Some(c) = chars.next() {
384        match c {
385            '{' if chars.peek() == Some(&'{') => {
386                chars.next();
387                text.push('{');
388            }
389            '}' if chars.peek() == Some(&'}') => {
390                chars.next();
391                text.push('}');
392            }
393            '}' => return Err("has a `}` without a matching `{` (write `}}` for a literal brace)".into()),
394            '{' => {
395                let mut name = String::new();
396                loop {
397                    match chars.next() {
398                        Some('}') => break,
399                        Some('{') => return Err("has a `{` inside `{ }`".into()),
400                        Some(ch) => name.push(ch),
401                        None => return Err("has an unclosed `{`".into()),
402                    }
403                }
404                if !text.is_empty() {
405                    parts.push(FormulaPart::Text(std::mem::take(&mut text)));
406                }
407                let part = match name.strip_suffix(":*") {
408                    Some(col) => FormulaPart::Column(col.trim().to_string()),
409                    None => FormulaPart::Cell(name.trim().to_string()),
410                };
411                if matches!(&part, FormulaPart::Cell(n) | FormulaPart::Column(n) if n.is_empty()) {
412                    return Err("has an empty `{}` reference".into());
413                }
414                parts.push(part);
415            }
416            _ => text.push(c),
417        }
418    }
419    if !text.is_empty() {
420        parts.push(FormulaPart::Text(text));
421    }
422    Ok(parts)
423}
424
425/// A row formula: `{name}` references only, since a row can't see the whole column's extent.
426pub fn check_row_formula(f: &str) -> Result<Vec<FormulaPart>, String> {
427    let parts = parse_formula(f)?;
428    if let Some(FormulaPart::Column(n)) = parts.iter().find(|p| matches!(p, FormulaPart::Column(_))) {
429        return Err(format!(
430            "uses `{{{n}:*}}`, a whole column, which only a `total` can use; a row formula refers to cells on its own row, like `{{{n}}}`"
431        ));
432    }
433    Ok(parts)
434}
435
436/// A `total:`: a function name, or a formula with `{name:*}` references only.
437pub fn check_total(t: &str) -> Result<(), String> {
438    if t.starts_with('=') {
439        let parts = parse_formula(t)?;
440        if let Some(FormulaPart::Cell(n)) = parts.iter().find(|p| matches!(p, FormulaPart::Cell(_))) {
441            return Err(format!(
442                "uses `{{{n}}}`, a cell on the same row, which a totals row doesn't have; use `{{{n}:*}}` for the whole column"
443            ));
444        }
445        return Ok(());
446    }
447    if TOTAL_FUNCTIONS.iter().any(|(k, _)| *k == t) {
448        return Ok(());
449    }
450    Err(format!(
451        "must be one of {} or a formula starting with `=`",
452        TOTAL_FUNCTIONS
453            .iter()
454            .map(|(k, _)| format!("`{k}`"))
455            .collect::<Vec<_>>()
456            .join(", ")
457    ))
458}
459
460/// Parse a `columns:` map (column name → column options), collecting every problem as a
461/// sentence naming the column. Used by core for query entries and by the xlsx plugin for its
462/// output-level `columns` option.
463pub fn parse_columns(
464    v: &Value,
465) -> (
466    std::collections::BTreeMap<String, crate::msg::ColumnOptions>,
467    Vec<String>,
468) {
469    let mut out = std::collections::BTreeMap::new();
470    let mut errs = Vec::new();
471    let Some(m) = v.as_object() else {
472        errs.push("`columns` must be a map from column name to options like `{format: \"#,##0.00\"}`".into());
473        return (out, errs);
474    };
475    for (name, opts) in m {
476        let Some(o) = opts.as_object() else {
477            errs.push(format!(
478                "column `{name}` must be a map of options like `{{format: \"#,##0.00\"}}`"
479            ));
480            continue;
481        };
482        let mut col = crate::msg::ColumnOptions::default();
483        for (k, val) in o {
484            match k.as_str() {
485                "format" => match val.as_str() {
486                    Some(code) => match check_num_format(code) {
487                        Ok(_) => col.format = Some(code.to_string()),
488                        Err(e) => errs.push(format!("column `{name}`: format `{code}` {e}")),
489                    },
490                    None => errs.push(format!("column `{name}`: `format` must be a string")),
491                },
492                "formula" => match val.as_str() {
493                    Some(f) => match check_row_formula(f) {
494                        Ok(_) => col.formula = Some(f.to_string()),
495                        Err(e) => errs.push(format!("column `{name}`: formula `{f}` {e}")),
496                    },
497                    None => errs.push(format!("column `{name}`: `formula` must be a string")),
498                },
499                "style" => {
500                    let (s, e) = crate::style::parse_cell(val);
501                    errs.extend(e.into_iter().map(|e| format!("column `{name}`: `style`: {e}")));
502                    col.style = Some(s);
503                }
504                "width" => match val {
505                    Value::String(s) if s == "auto" => {
506                        col.width = Some(crate::msg::ColumnWidth::Auto(crate::msg::AutoWidth::Auto))
507                    }
508                    Value::Number(n) if n.as_f64().is_some_and(|w| w > 0.0 && w <= 255.0) => {
509                        col.width = Some(crate::msg::ColumnWidth::Chars(n.as_f64().unwrap()))
510                    }
511                    _ => errs.push(format!(
512                        "column `{name}`: `width` must be `auto` or a number of characters from 1 to 255"
513                    )),
514                },
515                "total" => match val.as_str() {
516                    Some(t) => match check_total(t) {
517                        Ok(()) => col.total = Some(t.to_string()),
518                        Err(e) => errs.push(format!("column `{name}`: total `{t}` {e}")),
519                    },
520                    None => errs.push(format!("column `{name}`: `total` must be a string")),
521                },
522                _ => errs.push(format!(
523                    "column `{name}`: unknown key `{k}`; expected {}",
524                    COLUMN_OPTION_KEYS
525                        .iter()
526                        .map(|k| format!("`{k}`"))
527                        .collect::<Vec<_>>()
528                        .join(", ")
529                )),
530            }
531        }
532        out.insert(name.clone(), col);
533    }
534    (out, errs)
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use serde_json::json;
541
542    fn fields() -> Vec<OptionField> {
543        vec![
544            OptionField::new("sep", OptionType::Char, ""),
545            OptionField::new("mode", OptionType::String, "").choices(&["a", "b", "c"]),
546            OptionField::new("eol", OptionType::String, "").choices(&["\n", "\r\n"]),
547            OptionField::new("rows", OptionType::Integer, "").range(Some(1.0), Some(10.0)),
548            OptionField::new("to", OptionType::Strings, ""),
549            OptionField::new("cols", OptionType::List, "").required(),
550        ]
551    }
552
553    fn errs(o: Value) -> Vec<String> {
554        let Value::Object(o) = o else { panic!() };
555        check(Kind::Format, "x", &fields(), &o)
556    }
557
558    #[test]
559    fn accepts_valid_options() {
560        assert!(
561            errs(json!({"sep": "|", "mode": "b", "eol": "\n", "rows": 3, "to": ["a"], "cols": []}))
562                .is_empty()
563        );
564    }
565
566    #[test]
567    fn reports_every_problem() {
568        assert_eq!(
569            errs(json!({"sep": "||", "mode": "z", "eol": "\r", "rows": 11, "to": [1], "extra": 1})),
570            vec![
571                "unknown option `extra` for format `x`; expected one of sep, mode, eol, rows, to, cols",
572                "`sep` must be a single character",
573                "`mode` must be one of `a`, `b`, `c`",
574                r#"`eol` must be "\n" or "\r\n""#,
575                "`rows` must be a whole number from 1 to 10",
576                "`to` must be a string or a list of strings",
577                "`cols` is required",
578            ]
579        );
580    }
581
582    #[test]
583    fn templated_values_are_left_for_the_plugin() {
584        assert!(errs(json!({"mode": "{{ var('m') }}", "cols": []})).is_empty());
585    }
586
587    #[test]
588    fn a_plugin_without_options_refuses_any() {
589        let Value::Object(o) = json!({"to": "x"}) else {
590            panic!()
591        };
592        assert_eq!(
593            check(Kind::Destination, "sftp", &[], &o),
594            vec!["the `sftp` destination takes no options, but got `to`; check the key's spelling"]
595        );
596    }
597
598    #[test]
599    fn number_format_codes() {
600        use NumFormatClass::*;
601        let ok = [
602            ("General", Number),
603            ("0", Number),
604            ("#,##0.00", Number),
605            ("0.0%", Number),
606            ("0.00E+00", Number),
607            ("# ?/?", Number),
608            ("[$€-x-euro2] #,##0.00", Number),
609            ("[$$-409]#,##0.00", Number),
610            ("#,##0.00;[Red](#,##0.00)", Number),
611            ("#,##0;(#,##0);\"-\";@", Number),
612            ("_(* #,##0.00_);_(* (#,##0.00);_(* \"-\"??_);_(@_)", Number),
613            ("[>=1000]#,##0;0", Number),
614            ("\\€#,##0", Number),
615            ("yyyy-mm-dd", DateTime),
616            ("dd/mm/yyyy", DateTime),
617            ("mmm yyyy", DateTime),
618            ("yyyy-mm-dd hh:mm:ss", DateTime),
619            ("h:mm AM/PM", DateTime),
620            ("h:mm A/P", DateTime),
621            ("[h]:mm:ss", DateTime),
622            ("[mm]", DateTime),
623            ("hh:mm:ss.000", DateTime),
624            ("@", Text),
625            ("\"Total: \"@", Text),
626        ];
627        for (code, class) in ok {
628            assert_eq!(check_num_format(code), Ok(class), "{code}");
629        }
630        let bad = [
631            ("", "is empty"),
632            (
633                "0;0;0;@;0",
634                "has 5 `;` sections; Excel allows at most four (positive;negative;zero;text)",
635            ),
636            ("\"abc", "has an unclosed `\"` quote"),
637            ("[Red#,##0", "has an unclosed `[` bracket"),
638            ("#,##0]", "has a `]` without a matching `[`"),
639            ("0\\", "ends with `\\`, which needs a character after it"),
640            (
641                "0 units",
642                "has `U`, which isn't part of an Excel number format; put literal text in double quotes",
643            ),
644        ];
645        for (code, err) in bad {
646            assert_eq!(check_num_format(code), Err(err.to_string()), "{code}");
647        }
648    }
649
650    #[test]
651    fn column_maps() {
652        let (cols, errs) = parse_columns(
653            &json!({"a": {"format": "0.0%"}, "b": {"fromat": "0"}, "c": "0", "d": {"format": "\"x"}}),
654        );
655        assert_eq!(cols["a"].format.as_deref(), Some("0.0%"));
656        assert_eq!(
657            errs,
658            vec![
659                "column `b`: unknown key `fromat`; expected `format`, `formula`, `total`, `width`, `style`",
660                "column `c` must be a map of options like `{format: \"#,##0.00\"}`",
661                "column `d`: format `\"x` has an unclosed `\"` quote",
662            ]
663        );
664    }
665
666    #[test]
667    fn formulas() {
668        use FormulaPart::*;
669        assert_eq!(
670            parse_formula("={qty}*{ price }+{{1}}").unwrap(),
671            vec![
672                Text("=".into()),
673                Cell("qty".into()),
674                Text("*".into()),
675                Cell("price".into()),
676                Text("+{1}".into())
677            ]
678        );
679        assert_eq!(
680            parse_formula("=SUM({amount:*})").unwrap(),
681            vec![Text("=SUM(".into()), Column("amount".into()), Text(")".into())]
682        );
683        for (f, err) in [
684            ("{a}*2", "must start with `=`"),
685            ("={a", "has an unclosed `{`"),
686            (
687                "=a}",
688                "has a `}` without a matching `{` (write `}}` for a literal brace)",
689            ),
690            ("={}", "has an empty `{}` reference"),
691            ("={a{b}}", "has a `{` inside `{ }`"),
692        ] {
693            assert_eq!(parse_formula(f), Err(err.to_string()), "{f}");
694        }
695        assert!(
696            check_row_formula("={a}/SUM({a:*})")
697                .unwrap_err()
698                .contains("only a `total`")
699        );
700        assert!(check_total("sum").is_ok());
701        assert!(check_total("=SUM({a:*})-MIN({b:*})").is_ok());
702        assert!(check_total("={a}").unwrap_err().contains("doesn't have"));
703        assert_eq!(
704            check_total("total"),
705            Err(
706                "must be one of `sum`, `average`, `count`, `min`, `max` or a formula starting with `=`"
707                    .into()
708            )
709        );
710        let (cols, errs) = parse_columns(&json!({
711            "t": {"formula": "={a}*2", "format": "0.00", "total": "sum"},
712            "u": {"formula": "a*2"},
713            "v": {"total": "median"}
714        }));
715        assert_eq!(cols["t"].formula.as_deref(), Some("={a}*2"));
716        assert_eq!(cols["t"].total.as_deref(), Some("sum"));
717        assert_eq!(errs.len(), 2, "{errs:?}");
718        assert!(errs[0].starts_with("column `u`: formula `a*2` must start with `=`"));
719        assert!(errs[1].starts_with("column `v`: total `median` must be one of"));
720    }
721}