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