Skip to main content

rich_ext/table/
rules.rs

1//! Conditional styles (#215): rules that style a cell, a row or a column by
2//! value.
3//!
4//! A [`StyleRule`] names a column, a condition on that column's value and a
5//! style. The condition is either a [`Comparison`] against a value
6//! (`status == "failed"`, `latency > 250`) or a Rust predicate; there is no
7//! expression language. The rule's [`Target`] decides what the style lands
8//! on: the matching cell (the default), the whole row, or the whole column
9//! when any of its cells matches.
10//!
11//! [`TableData::style_rules`](super::TableData::style_rules) applies a set of
12//! rules while rendering; any other renderer resolves them once with
13//! [`StyleRules::resolve`] and asks the result for each row's and cell's
14//! style. With the `toml` feature, [`StyleRules::from_toml`] reads the same
15//! rules from TOML rule tables:
16//!
17//! ```toml
18//! [[rules]]
19//! column = "status"     # a header, or a 0-based index
20//! op = "eq"             # eq ne lt le gt ge contains starts_with ends_with
21//! value = "failed"      #   empty not_empty (or == != < <= > >=)
22//! style = "bold red"
23//! target = "row"        # cell (default), row or column
24//! ```
25//!
26//! # Rule specs
27//!
28//! [`StyleRule::parse`] reads one rule from a line of text, as `rich
29//! --style-rule` takes it: `COLUMN OP VALUE:STYLE`, or `COLUMN OP
30//! VALUE:TARGET:STYLE`. Each part maps onto one of the rule's:
31//!
32//! | Part | Written as |
33//! |---|---|
34//! | `COLUMN` | a header (`p99 ms`), a header in double quotes when it holds a comparison, a `:` or a comparison word (`"a<b"`), or `#N` for the 0-based index `N` |
35//! | `OP` | `==` or `=` (eq), `!=` (ne), `<` (lt), `<=` (le), `>` (gt), `>=` (ge), `*=` (contains), `^=` (starts_with), `$=` (ends_with), or any [`Comparison`] word after a space (`status eq failed`, `note empty`) |
36//! | `VALUE` | the text up to the next `:`, trimmed: an integer or a float compares numerically, anything else as text; in double quotes it is always text and may hold `:` (`"12:30"`, `"200"`). `empty` and `not_empty` take none |
37//! | `TARGET` | `cell` (the default), `row` or `column` |
38//! | `STYLE` | the rest: a style definition, which may itself hold `:` (`link https://…`) |
39//!
40//! In quotes, `\"`, `\\`, `\n`, `\t` and `\r` are escapes. [`StyleRule::to_spec`]
41//! writes a rule back as a spec, quoting where it must.
42//!
43//! ```
44//! use rich_ext::table::{Comparison, StyleRule, Target, Value};
45//!
46//! let rule = StyleRule::parse("status=failed:row:on red").unwrap();
47//! let (op, value) = rule.comparison().unwrap();
48//! assert_eq!((op, value.plain().as_str()), (Comparison::Eq, "failed"));
49//! assert_eq!(rule.target_kind(), Target::Row);
50//! assert!(rule.matches(&Value::from("failed")));
51//!
52//! let rule: StyleRule = "latency>500:bold red".parse().unwrap();
53//! assert!(rule.matches(&Value::from("612")));
54//! assert_eq!(rule.to_spec().as_deref(), Some("latency>500:bold red"));
55//!
56//! let error = StyleRule::parse("latency>:red").unwrap_err();
57//! assert!(error.to_string().starts_with("rule \"latency>:red\": no value after `>`"));
58//! ```
59//!
60//! ```
61//! use rich::{Console, Style};
62//! use rich_ext::table::{Column, Comparison, StyleRule, StyleRules, TableData, Target, Value};
63//!
64//! let rules = StyleRules::new()
65//!     .rule(StyleRule::new("p99 ms", Comparison::Gt, 100, Style::parse("red").unwrap()))
66//!     .rule(StyleRule::when("service", |v| v.plain() == "db", Style::parse("dim").unwrap())
67//!         .target(Target::Row));
68//! let mut data = TableData::new([Column::new("service"), Column::new("p99 ms")]);
69//! data.push(["web".into(), Value::Int(120)]);
70//! data.push(["db".into(), Value::Int(35)]);
71//!
72//! let resolved = rules.resolve(&["service", "p99 ms"], data.rows());
73//! assert!(resolved.cell_style(&data.rows()[0], 1).is_some());
74//! assert!(resolved.cell_style(&data.rows()[1], 1).is_none());
75//! assert!(resolved.row_style(&data.rows()[1]).is_some());
76//!
77//! // Rules restyle; they never change the text.
78//! let data = data.style_rules(rules);
79//! let out = Console::builder().width(40).build().render_export(&data);
80//! assert!(out.contains("│ web     │ 120    │"));
81//! ```
82
83use std::cmp::Ordering;
84use std::fmt;
85use std::sync::Arc;
86
87use rich::Style;
88
89use super::Value;
90
91/// What a matching rule styles.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum Target {
94    /// The cell that matched.
95    #[default]
96    Cell,
97    /// Every cell of the row the matching cell is in.
98    Row,
99    /// Every cell of the column, when any of its cells matches.
100    Column,
101}
102
103impl Target {
104    /// `cell`, `row` or `column`.
105    pub fn parse(name: &str) -> Option<Self> {
106        match name {
107            "cell" => Some(Target::Cell),
108            "row" => Some(Target::Row),
109            "column" => Some(Target::Column),
110            _ => None,
111        }
112    }
113
114    /// `cell`, `row` or `column`, as [`parse`](Self::parse) reads it.
115    pub fn name(self) -> &'static str {
116        match self {
117            Target::Cell => "cell",
118            Target::Row => "row",
119            Target::Column => "column",
120        }
121    }
122}
123
124/// The column a rule reads: a header, or a 0-based index.
125#[derive(Clone, Debug, PartialEq, Eq)]
126pub enum ColumnRef {
127    /// The first column with this header.
128    Name(String),
129    /// The column at this position.
130    Index(usize),
131}
132
133impl From<&str> for ColumnRef {
134    fn from(name: &str) -> Self {
135        ColumnRef::Name(name.to_string())
136    }
137}
138
139impl From<String> for ColumnRef {
140    fn from(name: String) -> Self {
141        ColumnRef::Name(name)
142    }
143}
144
145impl From<usize> for ColumnRef {
146    fn from(index: usize) -> Self {
147        ColumnRef::Index(index)
148    }
149}
150
151impl fmt::Display for ColumnRef {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        match self {
154            ColumnRef::Name(name) => f.write_str(name),
155            ColumnRef::Index(index) => write!(f, "#{index}"),
156        }
157    }
158}
159
160/// How a cell is compared with a rule's value.
161///
162/// A numeric rule value ([`Value::Int`] or [`Value::Float`]) compares
163/// numerically: a cell matches when it is a number, or text that parses as
164/// one, and a cell that is neither never matches an ordering. A text value
165/// compares as text, byte for byte. A [`Value::Null`] value matches empty
166/// cells for [`Eq`](Comparison::Eq) and the rest for
167/// [`Ne`](Comparison::Ne). [`Ne`](Comparison::Ne) is always the opposite of
168/// [`Eq`](Comparison::Eq).
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170#[non_exhaustive]
171pub enum Comparison {
172    /// Equal (`eq`, `==`).
173    Eq,
174    /// Not equal (`ne`, `!=`).
175    Ne,
176    /// Less than (`lt`, `<`).
177    Lt,
178    /// Less than or equal (`le`, `<=`).
179    Le,
180    /// Greater than (`gt`, `>`).
181    Gt,
182    /// Greater than or equal (`ge`, `>=`).
183    Ge,
184    /// The cell's text contains the value's (`contains`).
185    Contains,
186    /// The cell's text starts with the value's (`starts_with`).
187    StartsWith,
188    /// The cell's text ends with the value's (`ends_with`).
189    EndsWith,
190    /// The cell is null or empty text (`empty`); the value is ignored.
191    Empty,
192    /// The cell is not empty (`not_empty`); the value is ignored.
193    NotEmpty,
194}
195
196impl Comparison {
197    /// The comparison named `name`: a word (`eq`, `starts_with`) or a symbol
198    /// (`==`, `<=`).
199    pub fn parse(name: &str) -> Option<Self> {
200        Some(match name {
201            "eq" | "==" => Comparison::Eq,
202            "ne" | "!=" => Comparison::Ne,
203            "lt" | "<" => Comparison::Lt,
204            "le" | "<=" => Comparison::Le,
205            "gt" | ">" => Comparison::Gt,
206            "ge" | ">=" => Comparison::Ge,
207            "contains" => Comparison::Contains,
208            "starts_with" => Comparison::StartsWith,
209            "ends_with" => Comparison::EndsWith,
210            "empty" => Comparison::Empty,
211            "not_empty" => Comparison::NotEmpty,
212            _ => return None,
213        })
214    }
215
216    /// The comparison's word: `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `contains`,
217    /// `starts_with`, `ends_with`, `empty` or `not_empty`.
218    pub fn name(self) -> &'static str {
219        match self {
220            Comparison::Eq => "eq",
221            Comparison::Ne => "ne",
222            Comparison::Lt => "lt",
223            Comparison::Le => "le",
224            Comparison::Gt => "gt",
225            Comparison::Ge => "ge",
226            Comparison::Contains => "contains",
227            Comparison::StartsWith => "starts_with",
228            Comparison::EndsWith => "ends_with",
229            Comparison::Empty => "empty",
230            Comparison::NotEmpty => "not_empty",
231        }
232    }
233
234    /// The comparison's symbol in a rule spec ([`StyleRule::parse`]): `==`,
235    /// `!=`, `<`, `<=`, `>`, `>=`, `*=` (contains), `^=` (starts with) or `$=`
236    /// (ends with); `None` for `empty` and `not_empty`, which are words.
237    pub fn symbol(self) -> Option<&'static str> {
238        Some(match self {
239            Comparison::Eq => "==",
240            Comparison::Ne => "!=",
241            Comparison::Lt => "<",
242            Comparison::Le => "<=",
243            Comparison::Gt => ">",
244            Comparison::Ge => ">=",
245            Comparison::Contains => "*=",
246            Comparison::StartsWith => "^=",
247            Comparison::EndsWith => "$=",
248            Comparison::Empty | Comparison::NotEmpty => return None,
249        })
250    }
251
252    /// Whether `cell` compares to `operand` this way.
253    pub fn test(self, cell: &Value, operand: &Value) -> bool {
254        match self {
255            Comparison::Empty => cell.is_empty(),
256            Comparison::NotEmpty => !cell.is_empty(),
257            Comparison::Ne => !Comparison::Eq.test(cell, operand),
258            Comparison::Contains => cell.plain().contains(&operand.plain()),
259            Comparison::StartsWith => cell.plain().starts_with(&operand.plain()),
260            Comparison::EndsWith => cell.plain().ends_with(&operand.plain()),
261            Comparison::Eq => match operand {
262                Value::Null => cell.is_empty(),
263                Value::Int(_) | Value::Float(_) => order(cell, operand) == Some(Ordering::Equal),
264                _ => cell.plain() == operand.plain(),
265            },
266            Comparison::Lt | Comparison::Le | Comparison::Gt | Comparison::Ge => {
267                let Some(ordering) = order(cell, operand) else {
268                    return false;
269                };
270                match self {
271                    Comparison::Lt => ordering == Ordering::Less,
272                    Comparison::Le => ordering != Ordering::Greater,
273                    Comparison::Gt => ordering == Ordering::Greater,
274                    _ => ordering != Ordering::Less,
275                }
276            }
277        }
278    }
279}
280
281/// `cell` against `operand`: numerically when the operand is a number, as
282/// text when it is text, and not at all against null.
283fn order(cell: &Value, operand: &Value) -> Option<Ordering> {
284    match operand {
285        Value::Null => None,
286        // Two integers compare exactly: as `f64` they agree past 2^53.
287        Value::Int(right) => {
288            let left = match cell {
289                Value::Int(left) => Some(*left),
290                Value::Str(_) | Value::Text(_) => cell.plain().trim().parse::<i64>().ok(),
291                _ => None,
292            };
293            match left {
294                Some(left) => Some(left.cmp(right)),
295                None => order(cell, &Value::Float(*right as f64)),
296            }
297        }
298        Value::Float(_) => {
299            let left = cell
300                .as_f64()
301                .or_else(|| match cell {
302                    Value::Str(_) | Value::Text(_) => cell.plain().trim().parse::<f64>().ok(),
303                    _ => None,
304                })
305                .filter(|n| !n.is_nan())?;
306            left.partial_cmp(&operand.as_f64()?)
307        }
308        _ if matches!(cell, Value::Null) => None,
309        _ => Some(cell.plain().cmp(&operand.plain())),
310    }
311}
312
313/// A predicate over one cell's value.
314pub type Predicate = Arc<dyn Fn(&Value) -> bool + Send + Sync>;
315
316#[derive(Clone)]
317enum Condition {
318    Compare(Comparison, Box<Value>),
319    Predicate(Predicate),
320}
321
322impl fmt::Debug for Condition {
323    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324        match self {
325            Condition::Compare(op, value) => {
326                f.debug_tuple("Compare").field(op).field(value).finish()
327            }
328            Condition::Predicate(_) => f.write_str("Predicate(..)"),
329        }
330    }
331}
332
333/// One conditional style. See the [module docs](self).
334#[derive(Clone, Debug)]
335pub struct StyleRule {
336    column: ColumnRef,
337    condition: Condition,
338    style: Style,
339    target: Target,
340}
341
342impl StyleRule {
343    /// Style a cell of `column` when it compares to `value` by `comparison`.
344    pub fn new(
345        column: impl Into<ColumnRef>,
346        comparison: Comparison,
347        value: impl Into<Value>,
348        style: Style,
349    ) -> Self {
350        StyleRule {
351            column: column.into(),
352            condition: Condition::Compare(comparison, Box::new(value.into())),
353            style,
354            target: Target::Cell,
355        }
356    }
357
358    /// Style a cell of `column` when `predicate` holds for its value.
359    pub fn when(
360        column: impl Into<ColumnRef>,
361        predicate: impl Fn(&Value) -> bool + Send + Sync + 'static,
362        style: Style,
363    ) -> Self {
364        StyleRule {
365            column: column.into(),
366            condition: Condition::Predicate(Arc::new(predicate)),
367            style,
368            target: Target::Cell,
369        }
370    }
371
372    /// Style the row or the column instead of the cell.
373    #[must_use]
374    pub fn target(mut self, target: Target) -> Self {
375        self.target = target;
376        self
377    }
378
379    /// The column the rule reads.
380    pub fn column(&self) -> &ColumnRef {
381        &self.column
382    }
383
384    /// The style it applies.
385    pub fn style(&self) -> &Style {
386        &self.style
387    }
388
389    /// What it styles.
390    pub fn target_kind(&self) -> Target {
391        self.target
392    }
393
394    /// Whether `value` meets the rule's condition.
395    pub fn matches(&self, value: &Value) -> bool {
396        match &self.condition {
397            Condition::Compare(op, operand) => op.test(value, operand),
398            Condition::Predicate(predicate) => predicate(value),
399        }
400    }
401
402    /// The comparison and the value it compares with; `None` for a rule with
403    /// a Rust predicate.
404    pub fn comparison(&self) -> Option<(Comparison, &Value)> {
405        match &self.condition {
406            Condition::Compare(op, operand) => Some((*op, &**operand)),
407            Condition::Predicate(_) => None,
408        }
409    }
410
411    /// Read a rule spec, `COLUMN OP VALUE:STYLE` or `COLUMN OP
412    /// VALUE:TARGET:STYLE` (`latency>500:bold red`,
413    /// `status=failed:row:on red`). See [Rule specs](self#rule-specs). The
414    /// error names the spec.
415    pub fn parse(spec: &str) -> Result<Self, RuleError> {
416        parse_spec(spec).map_err(|message| RuleError(format!("rule {spec:?}: {message}")))
417    }
418
419    /// The rule as a spec [`parse`](Self::parse) reads back to the same
420    /// rule: `None` for a Rust predicate, an ordering against
421    /// [`Value::Null`] or a float that is not finite, which a spec cannot
422    /// write.
423    pub fn to_spec(&self) -> Option<String> {
424        let (op, operand) = self.comparison()?;
425        let column = match &self.column {
426            ColumnRef::Index(index) => format!("#{index}"),
427            ColumnRef::Name(name) if bare_column(name) => name.clone(),
428            ColumnRef::Name(name) => quote(name),
429        };
430        let condition = match (op, operand) {
431            (Comparison::Empty, _) | (Comparison::Eq, Value::Null) => format!("{column} empty"),
432            (Comparison::NotEmpty, _) | (Comparison::Ne, Value::Null) => {
433                format!("{column} not_empty")
434            }
435            (_, Value::Null) => return None,
436            (op, operand) => format!("{column}{}{}", op.symbol()?, spec_value(operand)?),
437        };
438        let target = match self.target {
439            Target::Cell => "",
440            Target::Row => "row:",
441            Target::Column => "column:",
442        };
443        Some(format!("{condition}:{target}{}", self.style.definition()))
444    }
445}
446
447/// The spec ([`StyleRule::to_spec`]), or `COLUMN <predicate>:STYLE` for a
448/// rule with a Rust predicate.
449impl fmt::Display for StyleRule {
450    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
451        match self.to_spec() {
452            Some(spec) => f.write_str(&spec),
453            None => {
454                let target = match self.target {
455                    Target::Cell => "",
456                    Target::Row => "row:",
457                    Target::Column => "column:",
458                };
459                write!(
460                    f,
461                    "{} <predicate>:{target}{}",
462                    self.column,
463                    self.style.definition()
464                )
465            }
466        }
467    }
468}
469
470impl std::str::FromStr for StyleRule {
471    type Err = RuleError;
472
473    fn from_str(spec: &str) -> Result<Self, RuleError> {
474        StyleRule::parse(spec)
475    }
476}
477
478/// Comparison symbols, longest first so `<=` is not read as `<`.
479const SYMBOLS: &[(&str, Comparison)] = &[
480    ("==", Comparison::Eq),
481    ("!=", Comparison::Ne),
482    ("<=", Comparison::Le),
483    (">=", Comparison::Ge),
484    ("*=", Comparison::Contains),
485    ("^=", Comparison::StartsWith),
486    ("$=", Comparison::EndsWith),
487    ("=", Comparison::Eq),
488    ("<", Comparison::Lt),
489    (">", Comparison::Gt),
490];
491
492/// Comparison words, written with whitespace before them.
493const WORDS: &[&str] = &[
494    "eq",
495    "ne",
496    "lt",
497    "le",
498    "gt",
499    "ge",
500    "contains",
501    "starts_with",
502    "ends_with",
503    "empty",
504    "not_empty",
505];
506
507/// The comparison `text` starts with and its length in bytes: a symbol, or
508/// (when `word` allows it) a word followed by whitespace, a quote, `:` or
509/// nothing.
510fn operator_at(text: &str, word: bool) -> Option<(Comparison, usize)> {
511    if let Some((symbol, op)) = SYMBOLS.iter().find(|(s, _)| text.starts_with(s)) {
512        return Some((*op, symbol.len()));
513    }
514    if !word {
515        return None;
516    }
517    WORDS.iter().find_map(|name| {
518        let rest = text.strip_prefix(name)?;
519        let ends = rest.is_empty()
520            || rest.starts_with(|c: char| c.is_whitespace() || c == ':' || c == '"');
521        ends.then(|| (Comparison::parse(name).expect("a known word"), name.len()))
522    })
523}
524
525/// A quoted name or value at the start of `text` (which starts with `"`):
526/// its text, with `\"`, `\\`, `\n`, `\t` and `\r` escapes, and what follows
527/// the closing quote.
528fn unquote(text: &str) -> Result<(String, &str), String> {
529    let mut out = String::new();
530    let mut chars = text.char_indices().skip(1);
531    while let Some((i, c)) = chars.next() {
532        match c {
533            '"' => return Ok((out, &text[i + 1..])),
534            '\\' => match chars.next() {
535                Some((_, '"')) => out.push('"'),
536                Some((_, '\\')) => out.push('\\'),
537                Some((_, 'n')) => out.push('\n'),
538                Some((_, 't')) => out.push('\t'),
539                Some((_, 'r')) => out.push('\r'),
540                Some((_, other)) => {
541                    return Err(format!(
542                        "unknown escape `\\{other}` in quotes (use \\\" \\\\ \\n \\t or \\r)"
543                    ));
544                }
545                None => break,
546            },
547            c => out.push(c),
548        }
549    }
550    Err("a quote is not closed".into())
551}
552
553/// `text` in quotes, escaped as [`unquote`] reads it.
554fn quote(text: &str) -> String {
555    let mut out = String::with_capacity(text.len() + 2);
556    out.push('"');
557    for c in text.chars() {
558        match c {
559            '"' => out.push_str("\\\""),
560            '\\' => out.push_str("\\\\"),
561            '\n' => out.push_str("\\n"),
562            '\t' => out.push_str("\\t"),
563            '\r' => out.push_str("\\r"),
564            c => out.push(c),
565        }
566    }
567    out.push('"');
568    out
569}
570
571/// An unquoted value: an integer, a float (any text with a digit that
572/// parses as a finite one), else text.
573fn spec_operand(text: &str) -> Value {
574    if let Ok(n) = text.parse::<i64>() {
575        return Value::Int(n);
576    }
577    if text.bytes().any(|b| b.is_ascii_digit())
578        && let Ok(n) = text.parse::<f64>()
579        && n.is_finite()
580    {
581        return Value::Float(n);
582    }
583    Value::Str(text.to_string())
584}
585
586/// Whether a column name reads back unquoted: no comparison characters, `:`
587/// or quotes, no leading `#` or `"`, no surrounding whitespace, and no
588/// comparison word after whitespace.
589fn bare_column(name: &str) -> bool {
590    !name.is_empty()
591        && name.trim() == name
592        && !name.starts_with('#')
593        && !name.contains(|c: char| "=<>!*^$:\"\\".contains(c) || c.is_control())
594        && !name
595            .split_whitespace()
596            .skip(1)
597            .any(|word| WORDS.contains(&word))
598}
599
600/// A value as a spec writes it: numbers as they are, text bare when it
601/// reads back as the same text, else quoted. `None` for a float that is
602/// not finite.
603fn spec_value(value: &Value) -> Option<String> {
604    Some(match value {
605        Value::Null => return None,
606        Value::Int(n) => n.to_string(),
607        Value::Float(f) if f.is_finite() => format!("{f:?}"),
608        Value::Float(_) => return None,
609        Value::Str(_) | Value::Text(_) => {
610            let text = value.plain();
611            let bare = !text.is_empty()
612                && text.trim() == text
613                && !text.starts_with('"')
614                && !text.contains(|c: char| c == ':' || c.is_control())
615                && matches!(spec_operand(&text), Value::Str(_));
616            if bare { text } else { quote(&text) }
617        }
618    })
619}
620
621/// [`StyleRule::parse`], with the message alone.
622fn parse_spec(spec: &str) -> Result<StyleRule, String> {
623    const HINT: &str = "expected COLUMN OP VALUE:STYLE, e.g. latency>500:bold red";
624    let text = spec.trim();
625    if text.is_empty() {
626        return Err(format!("empty; {HINT}"));
627    }
628    // The column: quoted, or everything before the first comparison that
629    // comes before the first `:`.
630    let (column, op, rest) = if text.starts_with('"') {
631        let (name, rest) = unquote(text)?;
632        let after = rest.trim_start();
633        let spaced = after.len() < rest.len();
634        let Some((op, len)) = operator_at(after, spaced) else {
635            return Err(format!("no comparison after the quoted column; {HINT}"));
636        };
637        (ColumnRef::Name(name), op, &after[len..])
638    } else {
639        let end = text.find(':').unwrap_or(text.len());
640        let found = text[..end].char_indices().find_map(|(at, _)| {
641            let word = text[..at].ends_with(char::is_whitespace);
642            operator_at(&text[at..], word).map(|hit| (at, hit))
643        });
644        let Some((at, (op, len))) = found else {
645            return Err(format!(
646                "no comparison (== != < <= > >= *= ^= $=, or a word such as contains or \
647                 empty) before the first `:`; quote a column name that holds one; {HINT}"
648            ));
649        };
650        let name = text[..at].trim();
651        if name.is_empty() {
652            return Err(format!("no column before the comparison; {HINT}"));
653        }
654        let column = match name.strip_prefix('#') {
655            Some(digits) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => {
656                ColumnRef::Index(
657                    digits
658                        .parse()
659                        .map_err(|_| format!("column index {digits} is too large"))?,
660                )
661            }
662            _ => ColumnRef::Name(name.to_string()),
663        };
664        (column, op, &text[at + len..])
665    };
666    // The value, up to the `:` before the style.
667    let rest = rest.trim_start();
668    let (operand, rest) = if matches!(op, Comparison::Empty | Comparison::NotEmpty) {
669        match rest.strip_prefix(':') {
670            Some(rest) => (Value::Null, rest),
671            None if rest.is_empty() => return Err(format!("no `:` and style; {HINT}")),
672            None => return Err(format!("`{}` takes no value", op.name())),
673        }
674    } else if rest.starts_with('"') {
675        let (value, after) = unquote(rest)?;
676        match after.trim_start().strip_prefix(':') {
677            Some(after) => (Value::Str(value), after),
678            None => return Err(format!("no `:` and style after the quoted value; {HINT}")),
679        }
680    } else {
681        let Some((value, after)) = rest.split_once(':') else {
682            return Err(format!("no `:` and style after the value; {HINT}"));
683        };
684        let value = value.trim();
685        if value.is_empty() {
686            return Err(format!(
687                "no value after `{}` (quote an empty one: \"\")",
688                op.symbol().unwrap_or(op.name())
689            ));
690        }
691        (spec_operand(value), after)
692    };
693    // An optional target, then the style (which may hold `:`, as a link's
694    // URL does).
695    let rest = rest.trim();
696    let (target, style) = match rest.split_once(':') {
697        Some((head, tail)) => match Target::parse(head.trim()) {
698            Some(target) => (target, tail.trim()),
699            None => (Target::Cell, rest),
700        },
701        None => (Target::Cell, rest),
702    };
703    if style.is_empty() || Target::parse(style).is_some() {
704        return Err(format!("no style after the `:`; {HINT}"));
705    }
706    let style = Style::parse(style).map_err(|e| format!("style {style:?}: {e}"))?;
707    Ok(StyleRule::new(column, op, operand, style).target(target))
708}
709
710/// An ordered set of [`StyleRule`]s. Where several match, their styles
711/// combine in order, a later rule winning wherever both set something.
712#[derive(Clone, Debug, Default)]
713pub struct StyleRules {
714    rules: Vec<StyleRule>,
715}
716
717impl StyleRules {
718    /// No rules.
719    pub fn new() -> Self {
720        StyleRules::default()
721    }
722
723    /// Add a rule (builder form).
724    #[must_use]
725    pub fn rule(mut self, rule: StyleRule) -> Self {
726        self.rules.push(rule);
727        self
728    }
729
730    /// Add a rule.
731    pub fn push(&mut self, rule: StyleRule) -> &mut Self {
732        self.rules.push(rule);
733        self
734    }
735
736    /// The rules, in order.
737    pub fn rules(&self) -> &[StyleRule] {
738        &self.rules
739    }
740
741    /// The number of rules.
742    pub fn len(&self) -> usize {
743        self.rules.len()
744    }
745
746    /// Whether there are no rules.
747    pub fn is_empty(&self) -> bool {
748        self.rules.is_empty()
749    }
750
751    /// Resolve the rules against a table's `headers` and `rows`: each rule's
752    /// column, and the styles of column rules (which need every row, so the
753    /// rows are only read when a column rule exists).
754    pub fn resolve<'a, R>(
755        &'a self,
756        headers: &[&str],
757        rows: impl IntoIterator<Item = R>,
758    ) -> ResolvedRules<'a>
759    where
760        R: AsRef<[Value]>,
761    {
762        let columns: Vec<Option<usize>> = self
763            .rules
764            .iter()
765            .map(|rule| match &rule.column {
766                ColumnRef::Name(name) => headers.iter().position(|h| h == name),
767                ColumnRef::Index(index) => (*index < headers.len()).then_some(*index),
768            })
769            .collect();
770        let mut column_styles: Vec<Option<Style>> = vec![None; headers.len()];
771        let column_rules: Vec<usize> = (0..self.rules.len())
772            .filter(|&i| self.rules[i].target == Target::Column && columns[i].is_some())
773            .collect();
774        if !column_rules.is_empty() {
775            let mut hit = vec![false; self.rules.len()];
776            for row in rows {
777                let row = row.as_ref();
778                for &i in &column_rules {
779                    if !hit[i] {
780                        let column = columns[i].expect("resolved above");
781                        hit[i] = row.get(column).is_some_and(|v| self.rules[i].matches(v));
782                    }
783                }
784                if column_rules.iter().all(|&i| hit[i]) {
785                    break;
786                }
787            }
788            for &i in column_rules.iter().filter(|&&i| hit[i]) {
789                let column = columns[i].expect("resolved above");
790                combine(&mut column_styles[column], &self.rules[i].style);
791            }
792        }
793        ResolvedRules {
794            rules: self,
795            columns,
796            column_styles,
797        }
798    }
799}
800
801fn combine(slot: &mut Option<Style>, style: &Style) {
802    *slot = Some(match slot.take() {
803        Some(previous) => previous.combine(style),
804        None => style.clone(),
805    });
806}
807
808/// [`StyleRules`] resolved against one table, from
809/// [`StyleRules::resolve`].
810#[derive(Clone, Debug)]
811pub struct ResolvedRules<'a> {
812    rules: &'a StyleRules,
813    columns: Vec<Option<usize>>,
814    column_styles: Vec<Option<Style>>,
815}
816
817impl ResolvedRules<'_> {
818    /// The style for the whole of `row`: its row rules that match, combined.
819    pub fn row_style(&self, row: &[Value]) -> Option<Style> {
820        let mut style = None;
821        for (rule, column) in self.rules.rules.iter().zip(&self.columns) {
822            if rule.target != Target::Row {
823                continue;
824            }
825            if let Some(value) = column.and_then(|c| row.get(c))
826                && rule.matches(value)
827            {
828                combine(&mut style, &rule.style);
829            }
830        }
831        style
832    }
833
834    /// The style for `row`'s cell in `column`: the column's style from
835    /// column rules, then its cell rules that match, combined.
836    pub fn cell_style(&self, row: &[Value], column: usize) -> Option<Style> {
837        let mut style = self.column_styles.get(column).cloned().flatten();
838        let Some(value) = row.get(column) else {
839            return style;
840        };
841        for (rule, resolved) in self.rules.rules.iter().zip(&self.columns) {
842            if rule.target == Target::Cell && *resolved == Some(column) && rule.matches(value) {
843                combine(&mut style, &rule.style);
844            }
845        }
846        style
847    }
848
849    /// The style column rules give `column`, if any matched.
850    pub fn column_style(&self, column: usize) -> Option<&Style> {
851        self.column_styles.get(column).and_then(Option::as_ref)
852    }
853
854    /// The rules whose column is not in the table (a header that is not
855    /// there, an index past the end). They never match.
856    pub fn unresolved(&self) -> Vec<&StyleRule> {
857        self.rules
858            .rules
859            .iter()
860            .zip(&self.columns)
861            .filter(|(_, column)| column.is_none())
862            .map(|(rule, _)| rule)
863            .collect()
864    }
865}
866
867/// Why rule tables could not be read.
868#[derive(Clone, Debug, PartialEq, Eq)]
869pub struct RuleError(String);
870
871impl fmt::Display for RuleError {
872    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
873        f.write_str(&self.0)
874    }
875}
876
877impl std::error::Error for RuleError {}
878
879#[cfg(feature = "toml")]
880impl StyleRules {
881    /// Read the `[[rules]]` tables of a TOML document. See the
882    /// [module docs](self) for the keys.
883    pub fn from_toml(text: &str) -> Result<Self, RuleError> {
884        let table: toml::Table = text
885            .parse()
886            .map_err(|e: toml::de::Error| RuleError(format!("not TOML: {}", e.message())))?;
887        match table.get("rules") {
888            Some(rules) => Self::from_toml_value(rules),
889            None => Ok(StyleRules::new()),
890        }
891    }
892
893    /// Read rule tables from an array of tables, wherever a configuration
894    /// keeps them (a profile's `rules` key, say).
895    pub fn from_toml_value(value: &toml::Value) -> Result<Self, RuleError> {
896        let toml::Value::Array(items) = value else {
897            return Err(RuleError("rules: expected an array of tables".into()));
898        };
899        let mut rules = StyleRules::new();
900        for (index, item) in items.iter().enumerate() {
901            let at = |message: String| RuleError(format!("rules[{index}]: {message}"));
902            let toml::Value::Table(table) = item else {
903                return Err(at("expected a table".into()));
904            };
905            if let Some(key) = table
906                .keys()
907                .find(|k| !matches!(k.as_str(), "column" | "op" | "value" | "style" | "target"))
908            {
909                return Err(at(format!("unknown key `{key}`")));
910            }
911            let column = match table.get("column") {
912                Some(toml::Value::String(name)) => ColumnRef::Name(name.clone()),
913                Some(toml::Value::Integer(n)) => usize::try_from(*n)
914                    .map(ColumnRef::Index)
915                    .map_err(|_| at(format!("column index {n} is negative")))?,
916                Some(_) => return Err(at("`column` is a header or an index".into())),
917                None => return Err(at("missing `column`".into())),
918            };
919            let op = match table.get("op") {
920                None => Comparison::Eq,
921                Some(toml::Value::String(name)) => {
922                    Comparison::parse(name).ok_or_else(|| at(format!("unknown op `{name}`")))?
923                }
924                Some(_) => return Err(at("`op` is a string".into())),
925            };
926            let operand = match table.get("value") {
927                None if matches!(op, Comparison::Empty | Comparison::NotEmpty) => Value::Null,
928                None => return Err(at("missing `value`".into())),
929                Some(toml::Value::String(s)) => Value::Str(s.clone()),
930                Some(toml::Value::Integer(n)) => Value::Int(*n),
931                Some(toml::Value::Float(f)) => Value::Float(*f),
932                Some(toml::Value::Boolean(b)) => Value::Str(b.to_string()),
933                Some(_) => return Err(at("`value` is a string, number or boolean".into())),
934            };
935            let style = match table.get("style") {
936                Some(toml::Value::String(spec)) => {
937                    Style::parse(spec).map_err(|e| at(format!("style `{spec}`: {e}")))?
938                }
939                Some(_) => return Err(at("`style` is a string".into())),
940                None => return Err(at("missing `style`".into())),
941            };
942            let target = match table.get("target") {
943                None => Target::Cell,
944                Some(toml::Value::String(name)) => {
945                    Target::parse(name).ok_or_else(|| at(format!("unknown target `{name}`")))?
946                }
947                Some(_) => return Err(at("`target` is a string".into())),
948            };
949            rules.push(StyleRule::new(column, op, operand, style).target(target));
950        }
951        Ok(rules)
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    fn red() -> Style {
960        Style::parse("red").unwrap()
961    }
962
963    #[test]
964    fn comparisons_read_numbers_from_text() {
965        let cell = Value::Str("12.5".into());
966        assert!(Comparison::Gt.test(&cell, &Value::Int(10)));
967        assert!(Comparison::Le.test(&cell, &Value::Float(12.5)));
968        assert!(Comparison::Eq.test(&Value::Int(3), &Value::Float(3.0)));
969        assert!(!Comparison::Lt.test(&Value::Str("n/a".into()), &Value::Int(1)));
970        assert!(!Comparison::Gt.test(&Value::Null, &Value::Int(-1)));
971        assert!(Comparison::Ne.test(&Value::Str("n/a".into()), &Value::Int(1)));
972        // Integers past 2^53 are told apart, as cells and as text.
973        let big = 9_007_199_254_740_993_i64;
974        assert!(!Comparison::Eq.test(&Value::Int(big), &Value::Int(big - 1)));
975        assert!(Comparison::Gt.test(&Value::Int(big), &Value::Int(big - 1)));
976        assert!(Comparison::Gt.test(&Value::Str(big.to_string()), &Value::Int(big - 1)));
977        assert!(Comparison::Eq.test(&Value::Str(" 7 ".into()), &Value::Int(7)));
978        assert!(Comparison::Lt.test(&Value::Float(6.5), &Value::Int(7)));
979        assert!(Comparison::Gt.test(&Value::Str("7.5".into()), &Value::Int(7)));
980    }
981
982    #[test]
983    fn comparisons_on_text_and_null() {
984        let cell = Value::Str("failed: timeout".into());
985        assert!(Comparison::StartsWith.test(&cell, &"failed".into()));
986        assert!(Comparison::EndsWith.test(&cell, &"timeout".into()));
987        assert!(Comparison::Contains.test(&cell, &": ".into()));
988        assert!(!Comparison::Eq.test(&cell, &"failed".into()));
989        assert!(Comparison::Lt.test(&Value::Str("a".into()), &"b".into()));
990        assert!(Comparison::Eq.test(&Value::Null, &Value::Null));
991        assert!(Comparison::Eq.test(&Value::Str(String::new()), &Value::Null));
992        assert!(Comparison::Ne.test(&Value::Int(0), &Value::Null));
993        assert!(Comparison::Empty.test(&Value::Null, &Value::Null));
994        assert!(Comparison::NotEmpty.test(&Value::Int(0), &Value::Null));
995        for name in ["==", "!=", "<", "<=", ">", ">=", "starts_with", "not_empty"] {
996            assert!(Comparison::parse(name).is_some(), "{name}");
997        }
998        assert_eq!(Comparison::parse("like"), None);
999    }
1000
1001    #[test]
1002    fn resolution_targets_cells_rows_and_columns() {
1003        let rows = vec![
1004            vec![Value::from("a"), Value::Int(1)],
1005            vec![Value::from("b"), Value::Int(-1)],
1006        ];
1007        let rules = StyleRules::new()
1008            .rule(StyleRule::new(1, Comparison::Lt, 0, red()))
1009            .rule(StyleRule::new("name", Comparison::Eq, "a", red()).target(Target::Row))
1010            .rule(
1011                StyleRule::when(
1012                    "n",
1013                    |v| v.as_f64() == Some(-1.0),
1014                    Style::parse("bold").unwrap(),
1015                )
1016                .target(Target::Column),
1017            )
1018            .rule(StyleRule::new(
1019                "missing",
1020                Comparison::NotEmpty,
1021                Value::Null,
1022                red(),
1023            ));
1024        let resolved = rules.resolve(&["name", "n"], &rows);
1025        assert_eq!(resolved.cell_style(&rows[0], 0), None);
1026        // Column rule only.
1027        assert_eq!(
1028            resolved.cell_style(&rows[0], 1),
1029            Some(Style::parse("bold").unwrap())
1030        );
1031        // Column rule, then the cell rule on top.
1032        assert_eq!(
1033            resolved.cell_style(&rows[1], 1),
1034            Some(Style::parse("bold red").unwrap())
1035        );
1036        assert_eq!(resolved.row_style(&rows[0]), Some(red()));
1037        assert_eq!(resolved.row_style(&rows[1]), None);
1038        assert_eq!(resolved.unresolved().len(), 1);
1039        assert_eq!(resolved.unresolved()[0].column().to_string(), "missing");
1040    }
1041
1042    /// The parts of a parsed spec: column, comparison, operand (as text, with
1043    /// its kind), target and style.
1044    fn parts(spec: &str) -> (String, &'static str, String, &'static str, String) {
1045        let rule = StyleRule::parse(spec).unwrap_or_else(|e| panic!("{e}"));
1046        let (op, value) = rule.comparison().unwrap();
1047        let operand = match value {
1048            Value::Null => "null".to_string(),
1049            Value::Int(n) => format!("int {n}"),
1050            Value::Float(f) => format!("float {f}"),
1051            other => format!("text {}", other.plain()),
1052        };
1053        (
1054            rule.column().to_string(),
1055            op.name(),
1056            operand,
1057            rule.target_kind().name(),
1058            rule.style().definition(),
1059        )
1060    }
1061
1062    #[test]
1063    fn specs_map_onto_comparisons_and_targets() {
1064        let p = |spec| parts(spec);
1065        assert_eq!(
1066            p("latency>500:bold red"),
1067            (
1068                "latency".into(),
1069                "gt",
1070                "int 500".into(),
1071                "cell",
1072                "bold red".into()
1073            )
1074        );
1075        assert_eq!(
1076            p("status=failed:row:on red"),
1077            (
1078                "status".into(),
1079                "eq",
1080                "text failed".into(),
1081                "row",
1082                "on red".into()
1083            )
1084        );
1085        assert_eq!(
1086            p("  p99 ms >= 12.5 : column : italic "),
1087            (
1088                "p99 ms".into(),
1089                "ge",
1090                "float 12.5".into(),
1091                "column",
1092                "italic".into()
1093            )
1094        );
1095        assert_eq!(p("delta<-5:red").2, "int -5");
1096        assert_eq!(p("a<=1:red").1, "le");
1097        assert_eq!(p("a==1:red").1, "eq");
1098        assert_eq!(p("a!=1:red").1, "ne");
1099        assert_eq!(p("a*=err:red").1, "contains");
1100        assert_eq!(p("a^=err:red").1, "starts_with");
1101        assert_eq!(p("a$=err:red").1, "ends_with");
1102        assert_eq!(p("#2>0:red").0, "#2");
1103        // Words, after whitespace.
1104        assert_eq!(
1105            p("msg contains a=b:red"),
1106            (
1107                "msg".into(),
1108                "contains",
1109                "text a=b".into(),
1110                "cell",
1111                "red".into()
1112            )
1113        );
1114        assert_eq!(p("status eq failed:red").1, "eq");
1115        assert_eq!(p("note empty:dim").1, "empty");
1116        assert_eq!(p("note not_empty:row:dim").3, "row");
1117        // A word inside a name is part of the name.
1118        assert_eq!(p("is empty=yes:red").0, "is empty");
1119        // Quotes: a value with `:` or that looks like a number stays text,
1120        // and a column with a comparison in it.
1121        assert_eq!(p("when=\"12:30\":red").2, "text 12:30");
1122        assert_eq!(p("code=\"200\":red").2, "text 200");
1123        assert_eq!(p("\"a<b\">1:red").0, "a<b");
1124        assert_eq!(p("\"p99: ms\" gt 1:red").0, "p99: ms");
1125        assert_eq!(p("x=\"say \\\"hi\\\"\":red").2, "text say \"hi\"");
1126        // A style may hold `:`.
1127        assert_eq!(
1128            p("site=docs:link https://example.com").4,
1129            "link https://example.com"
1130        );
1131        // A rule from a spec behaves as one built in code.
1132        let rule = StyleRule::parse("latency>500:red").unwrap();
1133        assert!(rule.matches(&Value::from("612")));
1134        assert!(!rule.matches(&Value::Int(500)));
1135        assert!(
1136            StyleRule::parse("code=\"200\":red")
1137                .unwrap()
1138                .matches(&Value::from("200"))
1139        );
1140    }
1141
1142    #[test]
1143    fn bad_specs_say_what_is_wrong_and_name_the_spec() {
1144        let error = |spec: &str| StyleRule::parse(spec).unwrap_err().to_string();
1145        assert!(error("").contains("empty"));
1146        assert!(
1147            error("latency:red").contains("no comparison"),
1148            "{}",
1149            error("latency:red")
1150        );
1151        assert!(error(">5:red").contains("no column"));
1152        assert!(error("latency>:red").starts_with("rule \"latency>:red\": no value after `>`"));
1153        assert!(error("latency>5").contains("no `:` and style"));
1154        assert!(error("latency>5:").contains("no style"));
1155        assert!(error("latency>5:row:").contains("no style"));
1156        assert!(error("latency>5:row").contains("no style"));
1157        assert!(error("latency>5:nonsense colour").contains("style \"nonsense colour\""));
1158        assert!(error("note empty x:red").contains("`empty` takes no value"));
1159        assert!(error("x=\"open:red").contains("not closed"));
1160        assert!(error("x=\"a\\q\":red").contains("unknown escape"));
1161        assert!(error("\"x\" ? 1:red").contains("no comparison after the quoted column"));
1162    }
1163
1164    #[test]
1165    fn specs_round_trip() {
1166        let red = || Style::parse("bold red").unwrap();
1167        let rules = [
1168            StyleRule::new("latency", Comparison::Gt, 500, red()),
1169            StyleRule::new("status", Comparison::Eq, "failed", red()).target(Target::Row),
1170            StyleRule::new("p99 ms", Comparison::Ge, 12.5, red()).target(Target::Column),
1171            StyleRule::new(2, Comparison::Lt, 1.0, red()),
1172            StyleRule::new("a<b", Comparison::Contains, "x: y", red()),
1173            StyleRule::new("count empty", Comparison::Eq, "200", red()),
1174            StyleRule::new("#tag", Comparison::Ne, " padded ", red()),
1175            StyleRule::new("q", Comparison::EndsWith, "say \"hi\"\n", red()),
1176            StyleRule::new("note", Comparison::Empty, Value::Null, red()),
1177            StyleRule::new("note", Comparison::Eq, Value::Null, red()),
1178            StyleRule::new("note", Comparison::NotEmpty, Value::Null, red()),
1179            StyleRule::new("", Comparison::StartsWith, "", red()),
1180        ];
1181        for rule in rules {
1182            let spec = rule.to_spec().unwrap();
1183            let back = StyleRule::parse(&spec).unwrap_or_else(|e| panic!("{spec}: {e}"));
1184            assert_eq!(back.to_spec().as_deref(), Some(spec.as_str()));
1185            assert_eq!(back.column(), rule.column(), "{spec}");
1186            assert_eq!(back.target_kind(), rule.target_kind(), "{spec}");
1187            assert_eq!(back.style(), rule.style(), "{spec}");
1188            for cell in [
1189                "",
1190                "200",
1191                "failed",
1192                "x: y",
1193                " padded ",
1194                "13",
1195                "0.5",
1196                "say \"hi\"\n",
1197            ] {
1198                let cell = Value::from(cell);
1199                assert_eq!(
1200                    back.matches(&cell),
1201                    rule.matches(&cell),
1202                    "{spec} on {cell:?}"
1203                );
1204            }
1205        }
1206        assert_eq!(
1207            StyleRule::new("p99 ms", Comparison::Ge, 12.5, red())
1208                .to_spec()
1209                .as_deref(),
1210            Some("p99 ms>=12.5:bold red")
1211        );
1212        assert_eq!(
1213            StyleRule::new(1, Comparison::Eq, 2.0, red())
1214                .to_spec()
1215                .as_deref(),
1216            Some("#1==2.0:bold red")
1217        );
1218        // What a spec cannot write.
1219        assert_eq!(StyleRule::when("a", |_| true, red()).to_spec(), None);
1220        assert_eq!(
1221            StyleRule::when("a", |_| true, red()).to_string(),
1222            "a <predicate>:bold red"
1223        );
1224        assert_eq!(
1225            StyleRule::new("a", Comparison::Lt, Value::Null, red()).to_spec(),
1226            None
1227        );
1228        assert_eq!(
1229            StyleRule::new("a", Comparison::Lt, f64::NAN, red()).to_spec(),
1230            None
1231        );
1232    }
1233
1234    #[cfg(feature = "toml")]
1235    #[test]
1236    fn rule_tables_from_toml() {
1237        let rules = StyleRules::from_toml(
1238            "[[rules]]\ncolumn = 'status'\nvalue = 'failed'\nstyle = 'bold red'\ntarget = 'row'\n\
1239             [[rules]]\ncolumn = 2\nop = '>='\nvalue = 0.5\nstyle = 'yellow'\n\
1240             [[rules]]\ncolumn = 'note'\nop = 'empty'\nstyle = 'dim'\n",
1241        )
1242        .unwrap();
1243        assert_eq!(rules.len(), 3);
1244        assert_eq!(rules.rules()[0].target_kind(), Target::Row);
1245        assert_eq!(rules.rules()[1].column(), &ColumnRef::Index(2));
1246        assert!(rules.rules()[1].matches(&Value::Float(0.75)));
1247        assert!(rules.rules()[2].matches(&Value::Null));
1248        assert!(StyleRules::from_toml("").unwrap().is_empty());
1249
1250        let error = |text: &str| StyleRules::from_toml(text).unwrap_err().to_string();
1251        assert_eq!(
1252            error("[[rules]]\ncolumn='a'\nvalue=1\nstyle='red'\nwhen='x'"),
1253            "rules[0]: unknown key `when`"
1254        );
1255        assert_eq!(
1256            error("[[rules]]\ncolumn='a'\nop='like'\nvalue=1\nstyle='red'"),
1257            "rules[0]: unknown op `like`"
1258        );
1259        assert_eq!(
1260            error("[[rules]]\ncolumn='a'\nstyle='red'"),
1261            "rules[0]: missing `value`"
1262        );
1263        assert_eq!(error("rules = 3"), "rules: expected an array of tables");
1264        assert!(
1265            error("[[rules]]\ncolumn='a'\nvalue=1\nstyle='nonsense colour'")
1266                .starts_with("rules[0]: style `nonsense colour`")
1267        );
1268    }
1269}