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//! ```
27//! use rich::{Console, Style};
28//! use rich_ext::table::{Column, Comparison, StyleRule, StyleRules, TableData, Target, Value};
29//!
30//! let rules = StyleRules::new()
31//!     .rule(StyleRule::new("p99 ms", Comparison::Gt, 100, Style::parse("red").unwrap()))
32//!     .rule(StyleRule::when("service", |v| v.plain() == "db", Style::parse("dim").unwrap())
33//!         .target(Target::Row));
34//! let mut data = TableData::new([Column::new("service"), Column::new("p99 ms")]);
35//! data.push(["web".into(), Value::Int(120)]);
36//! data.push(["db".into(), Value::Int(35)]);
37//!
38//! let resolved = rules.resolve(&["service", "p99 ms"], data.rows());
39//! assert!(resolved.cell_style(&data.rows()[0], 1).is_some());
40//! assert!(resolved.cell_style(&data.rows()[1], 1).is_none());
41//! assert!(resolved.row_style(&data.rows()[1]).is_some());
42//!
43//! // Rules restyle; they never change the text.
44//! let data = data.style_rules(rules);
45//! let out = Console::builder().width(40).build().render_export(&data);
46//! assert!(out.contains("│ web     │ 120    │"));
47//! ```
48
49use std::cmp::Ordering;
50use std::fmt;
51use std::sync::Arc;
52
53use rich::Style;
54
55use super::Value;
56
57/// What a matching rule styles.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum Target {
60    /// The cell that matched.
61    #[default]
62    Cell,
63    /// Every cell of the row the matching cell is in.
64    Row,
65    /// Every cell of the column, when any of its cells matches.
66    Column,
67}
68
69impl Target {
70    /// `cell`, `row` or `column`.
71    pub fn parse(name: &str) -> Option<Self> {
72        match name {
73            "cell" => Some(Target::Cell),
74            "row" => Some(Target::Row),
75            "column" => Some(Target::Column),
76            _ => None,
77        }
78    }
79}
80
81/// The column a rule reads: a header, or a 0-based index.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub enum ColumnRef {
84    /// The first column with this header.
85    Name(String),
86    /// The column at this position.
87    Index(usize),
88}
89
90impl From<&str> for ColumnRef {
91    fn from(name: &str) -> Self {
92        ColumnRef::Name(name.to_string())
93    }
94}
95
96impl From<String> for ColumnRef {
97    fn from(name: String) -> Self {
98        ColumnRef::Name(name)
99    }
100}
101
102impl From<usize> for ColumnRef {
103    fn from(index: usize) -> Self {
104        ColumnRef::Index(index)
105    }
106}
107
108impl fmt::Display for ColumnRef {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            ColumnRef::Name(name) => f.write_str(name),
112            ColumnRef::Index(index) => write!(f, "#{index}"),
113        }
114    }
115}
116
117/// How a cell is compared with a rule's value.
118///
119/// A numeric rule value ([`Value::Int`] or [`Value::Float`]) compares
120/// numerically: a cell matches when it is a number, or text that parses as
121/// one, and a cell that is neither never matches an ordering. A text value
122/// compares as text, byte for byte. A [`Value::Null`] value matches empty
123/// cells for [`Eq`](Comparison::Eq) and the rest for
124/// [`Ne`](Comparison::Ne). [`Ne`](Comparison::Ne) is always the opposite of
125/// [`Eq`](Comparison::Eq).
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127#[non_exhaustive]
128pub enum Comparison {
129    /// Equal (`eq`, `==`).
130    Eq,
131    /// Not equal (`ne`, `!=`).
132    Ne,
133    /// Less than (`lt`, `<`).
134    Lt,
135    /// Less than or equal (`le`, `<=`).
136    Le,
137    /// Greater than (`gt`, `>`).
138    Gt,
139    /// Greater than or equal (`ge`, `>=`).
140    Ge,
141    /// The cell's text contains the value's (`contains`).
142    Contains,
143    /// The cell's text starts with the value's (`starts_with`).
144    StartsWith,
145    /// The cell's text ends with the value's (`ends_with`).
146    EndsWith,
147    /// The cell is null or empty text (`empty`); the value is ignored.
148    Empty,
149    /// The cell is not empty (`not_empty`); the value is ignored.
150    NotEmpty,
151}
152
153impl Comparison {
154    /// The comparison named `name`: a word (`eq`, `starts_with`) or a symbol
155    /// (`==`, `<=`).
156    pub fn parse(name: &str) -> Option<Self> {
157        Some(match name {
158            "eq" | "==" => Comparison::Eq,
159            "ne" | "!=" => Comparison::Ne,
160            "lt" | "<" => Comparison::Lt,
161            "le" | "<=" => Comparison::Le,
162            "gt" | ">" => Comparison::Gt,
163            "ge" | ">=" => Comparison::Ge,
164            "contains" => Comparison::Contains,
165            "starts_with" => Comparison::StartsWith,
166            "ends_with" => Comparison::EndsWith,
167            "empty" => Comparison::Empty,
168            "not_empty" => Comparison::NotEmpty,
169            _ => return None,
170        })
171    }
172
173    /// Whether `cell` compares to `operand` this way.
174    pub fn test(self, cell: &Value, operand: &Value) -> bool {
175        match self {
176            Comparison::Empty => cell.is_empty(),
177            Comparison::NotEmpty => !cell.is_empty(),
178            Comparison::Ne => !Comparison::Eq.test(cell, operand),
179            Comparison::Contains => cell.plain().contains(&operand.plain()),
180            Comparison::StartsWith => cell.plain().starts_with(&operand.plain()),
181            Comparison::EndsWith => cell.plain().ends_with(&operand.plain()),
182            Comparison::Eq => match operand {
183                Value::Null => cell.is_empty(),
184                Value::Int(_) | Value::Float(_) => order(cell, operand) == Some(Ordering::Equal),
185                _ => cell.plain() == operand.plain(),
186            },
187            Comparison::Lt | Comparison::Le | Comparison::Gt | Comparison::Ge => {
188                let Some(ordering) = order(cell, operand) else {
189                    return false;
190                };
191                match self {
192                    Comparison::Lt => ordering == Ordering::Less,
193                    Comparison::Le => ordering != Ordering::Greater,
194                    Comparison::Gt => ordering == Ordering::Greater,
195                    _ => ordering != Ordering::Less,
196                }
197            }
198        }
199    }
200}
201
202/// `cell` against `operand`: numerically when the operand is a number, as
203/// text when it is text, and not at all against null.
204fn order(cell: &Value, operand: &Value) -> Option<Ordering> {
205    match operand {
206        Value::Null => None,
207        // Two integers compare exactly: as `f64` they agree past 2^53.
208        Value::Int(right) => {
209            let left = match cell {
210                Value::Int(left) => Some(*left),
211                Value::Str(_) | Value::Text(_) => cell.plain().trim().parse::<i64>().ok(),
212                _ => None,
213            };
214            match left {
215                Some(left) => Some(left.cmp(right)),
216                None => order(cell, &Value::Float(*right as f64)),
217            }
218        }
219        Value::Float(_) => {
220            let left = cell
221                .as_f64()
222                .or_else(|| match cell {
223                    Value::Str(_) | Value::Text(_) => cell.plain().trim().parse::<f64>().ok(),
224                    _ => None,
225                })
226                .filter(|n| !n.is_nan())?;
227            left.partial_cmp(&operand.as_f64()?)
228        }
229        _ if matches!(cell, Value::Null) => None,
230        _ => Some(cell.plain().cmp(&operand.plain())),
231    }
232}
233
234/// A predicate over one cell's value.
235pub type Predicate = Arc<dyn Fn(&Value) -> bool + Send + Sync>;
236
237#[derive(Clone)]
238enum Condition {
239    Compare(Comparison, Value),
240    Predicate(Predicate),
241}
242
243impl fmt::Debug for Condition {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        match self {
246            Condition::Compare(op, value) => {
247                f.debug_tuple("Compare").field(op).field(value).finish()
248            }
249            Condition::Predicate(_) => f.write_str("Predicate(..)"),
250        }
251    }
252}
253
254/// One conditional style. See the [module docs](self).
255#[derive(Clone, Debug)]
256pub struct StyleRule {
257    column: ColumnRef,
258    condition: Condition,
259    style: Style,
260    target: Target,
261}
262
263impl StyleRule {
264    /// Style a cell of `column` when it compares to `value` by `comparison`.
265    pub fn new(
266        column: impl Into<ColumnRef>,
267        comparison: Comparison,
268        value: impl Into<Value>,
269        style: Style,
270    ) -> Self {
271        StyleRule {
272            column: column.into(),
273            condition: Condition::Compare(comparison, value.into()),
274            style,
275            target: Target::Cell,
276        }
277    }
278
279    /// Style a cell of `column` when `predicate` holds for its value.
280    pub fn when(
281        column: impl Into<ColumnRef>,
282        predicate: impl Fn(&Value) -> bool + Send + Sync + 'static,
283        style: Style,
284    ) -> Self {
285        StyleRule {
286            column: column.into(),
287            condition: Condition::Predicate(Arc::new(predicate)),
288            style,
289            target: Target::Cell,
290        }
291    }
292
293    /// Style the row or the column instead of the cell.
294    pub fn target(mut self, target: Target) -> Self {
295        self.target = target;
296        self
297    }
298
299    /// The column the rule reads.
300    pub fn column(&self) -> &ColumnRef {
301        &self.column
302    }
303
304    /// The style it applies.
305    pub fn style(&self) -> &Style {
306        &self.style
307    }
308
309    /// What it styles.
310    pub fn target_kind(&self) -> Target {
311        self.target
312    }
313
314    /// Whether `value` meets the rule's condition.
315    pub fn matches(&self, value: &Value) -> bool {
316        match &self.condition {
317            Condition::Compare(op, operand) => op.test(value, operand),
318            Condition::Predicate(predicate) => predicate(value),
319        }
320    }
321}
322
323/// An ordered set of [`StyleRule`]s. Where several match, their styles
324/// combine in order, a later rule winning wherever both set something.
325#[derive(Clone, Debug, Default)]
326pub struct StyleRules {
327    rules: Vec<StyleRule>,
328}
329
330impl StyleRules {
331    /// No rules.
332    pub fn new() -> Self {
333        StyleRules::default()
334    }
335
336    /// Add a rule (builder form).
337    pub fn rule(mut self, rule: StyleRule) -> Self {
338        self.rules.push(rule);
339        self
340    }
341
342    /// Add a rule.
343    pub fn push(&mut self, rule: StyleRule) -> &mut Self {
344        self.rules.push(rule);
345        self
346    }
347
348    /// The rules, in order.
349    pub fn rules(&self) -> &[StyleRule] {
350        &self.rules
351    }
352
353    /// The number of rules.
354    pub fn len(&self) -> usize {
355        self.rules.len()
356    }
357
358    /// Whether there are no rules.
359    pub fn is_empty(&self) -> bool {
360        self.rules.is_empty()
361    }
362
363    /// Resolve the rules against a table's `headers` and `rows`: each rule's
364    /// column, and the styles of column rules (which need every row, so the
365    /// rows are only read when a column rule exists).
366    pub fn resolve<'a, R>(
367        &'a self,
368        headers: &[&str],
369        rows: impl IntoIterator<Item = R>,
370    ) -> ResolvedRules<'a>
371    where
372        R: AsRef<[Value]>,
373    {
374        let columns: Vec<Option<usize>> = self
375            .rules
376            .iter()
377            .map(|rule| match &rule.column {
378                ColumnRef::Name(name) => headers.iter().position(|h| h == name),
379                ColumnRef::Index(index) => (*index < headers.len()).then_some(*index),
380            })
381            .collect();
382        let mut column_styles: Vec<Option<Style>> = vec![None; headers.len()];
383        let column_rules: Vec<usize> = (0..self.rules.len())
384            .filter(|&i| self.rules[i].target == Target::Column && columns[i].is_some())
385            .collect();
386        if !column_rules.is_empty() {
387            let mut hit = vec![false; self.rules.len()];
388            for row in rows {
389                let row = row.as_ref();
390                for &i in &column_rules {
391                    if !hit[i] {
392                        let column = columns[i].expect("resolved above");
393                        hit[i] = row.get(column).is_some_and(|v| self.rules[i].matches(v));
394                    }
395                }
396                if column_rules.iter().all(|&i| hit[i]) {
397                    break;
398                }
399            }
400            for &i in column_rules.iter().filter(|&&i| hit[i]) {
401                let column = columns[i].expect("resolved above");
402                combine(&mut column_styles[column], &self.rules[i].style);
403            }
404        }
405        ResolvedRules {
406            rules: self,
407            columns,
408            column_styles,
409        }
410    }
411}
412
413fn combine(slot: &mut Option<Style>, style: &Style) {
414    *slot = Some(match slot.take() {
415        Some(previous) => previous.combine(style),
416        None => style.clone(),
417    });
418}
419
420/// [`StyleRules`] resolved against one table, from
421/// [`StyleRules::resolve`].
422#[derive(Clone, Debug)]
423pub struct ResolvedRules<'a> {
424    rules: &'a StyleRules,
425    columns: Vec<Option<usize>>,
426    column_styles: Vec<Option<Style>>,
427}
428
429impl ResolvedRules<'_> {
430    /// The style for the whole of `row`: its row rules that match, combined.
431    pub fn row_style(&self, row: &[Value]) -> Option<Style> {
432        let mut style = None;
433        for (rule, column) in self.rules.rules.iter().zip(&self.columns) {
434            if rule.target != Target::Row {
435                continue;
436            }
437            if let Some(value) = column.and_then(|c| row.get(c)) {
438                if rule.matches(value) {
439                    combine(&mut style, &rule.style);
440                }
441            }
442        }
443        style
444    }
445
446    /// The style for `row`'s cell in `column`: the column's style from
447    /// column rules, then its cell rules that match, combined.
448    pub fn cell_style(&self, row: &[Value], column: usize) -> Option<Style> {
449        let mut style = self.column_styles.get(column).cloned().flatten();
450        let Some(value) = row.get(column) else {
451            return style;
452        };
453        for (rule, resolved) in self.rules.rules.iter().zip(&self.columns) {
454            if rule.target == Target::Cell && *resolved == Some(column) && rule.matches(value) {
455                combine(&mut style, &rule.style);
456            }
457        }
458        style
459    }
460
461    /// The style column rules give `column`, if any matched.
462    pub fn column_style(&self, column: usize) -> Option<&Style> {
463        self.column_styles.get(column).and_then(Option::as_ref)
464    }
465
466    /// The rules whose column is not in the table (a header that is not
467    /// there, an index past the end). They never match.
468    pub fn unresolved(&self) -> Vec<&StyleRule> {
469        self.rules
470            .rules
471            .iter()
472            .zip(&self.columns)
473            .filter(|(_, column)| column.is_none())
474            .map(|(rule, _)| rule)
475            .collect()
476    }
477}
478
479/// Why rule tables could not be read.
480#[derive(Clone, Debug, PartialEq, Eq)]
481pub struct RuleError(String);
482
483impl fmt::Display for RuleError {
484    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
485        f.write_str(&self.0)
486    }
487}
488
489impl std::error::Error for RuleError {}
490
491#[cfg(feature = "toml")]
492impl StyleRules {
493    /// Read the `[[rules]]` tables of a TOML document. See the
494    /// [module docs](self) for the keys.
495    pub fn from_toml(text: &str) -> Result<Self, RuleError> {
496        let table: toml::Table = text
497            .parse()
498            .map_err(|e: toml::de::Error| RuleError(format!("not TOML: {}", e.message())))?;
499        match table.get("rules") {
500            Some(rules) => Self::from_toml_value(rules),
501            None => Ok(StyleRules::new()),
502        }
503    }
504
505    /// Read rule tables from an array of tables, wherever a configuration
506    /// keeps them (a profile's `rules` key, say).
507    pub fn from_toml_value(value: &toml::Value) -> Result<Self, RuleError> {
508        let toml::Value::Array(items) = value else {
509            return Err(RuleError("rules: expected an array of tables".into()));
510        };
511        let mut rules = StyleRules::new();
512        for (index, item) in items.iter().enumerate() {
513            let at = |message: String| RuleError(format!("rules[{index}]: {message}"));
514            let toml::Value::Table(table) = item else {
515                return Err(at("expected a table".into()));
516            };
517            if let Some(key) = table
518                .keys()
519                .find(|k| !matches!(k.as_str(), "column" | "op" | "value" | "style" | "target"))
520            {
521                return Err(at(format!("unknown key `{key}`")));
522            }
523            let column = match table.get("column") {
524                Some(toml::Value::String(name)) => ColumnRef::Name(name.clone()),
525                Some(toml::Value::Integer(n)) => usize::try_from(*n)
526                    .map(ColumnRef::Index)
527                    .map_err(|_| at(format!("column index {n} is negative")))?,
528                Some(_) => return Err(at("`column` is a header or an index".into())),
529                None => return Err(at("missing `column`".into())),
530            };
531            let op = match table.get("op") {
532                None => Comparison::Eq,
533                Some(toml::Value::String(name)) => {
534                    Comparison::parse(name).ok_or_else(|| at(format!("unknown op `{name}`")))?
535                }
536                Some(_) => return Err(at("`op` is a string".into())),
537            };
538            let operand = match table.get("value") {
539                None if matches!(op, Comparison::Empty | Comparison::NotEmpty) => Value::Null,
540                None => return Err(at("missing `value`".into())),
541                Some(toml::Value::String(s)) => Value::Str(s.clone()),
542                Some(toml::Value::Integer(n)) => Value::Int(*n),
543                Some(toml::Value::Float(f)) => Value::Float(*f),
544                Some(toml::Value::Boolean(b)) => Value::Str(b.to_string()),
545                Some(_) => return Err(at("`value` is a string, number or boolean".into())),
546            };
547            let style = match table.get("style") {
548                Some(toml::Value::String(spec)) => {
549                    Style::parse(spec).map_err(|e| at(format!("style `{spec}`: {e}")))?
550                }
551                Some(_) => return Err(at("`style` is a string".into())),
552                None => return Err(at("missing `style`".into())),
553            };
554            let target = match table.get("target") {
555                None => Target::Cell,
556                Some(toml::Value::String(name)) => {
557                    Target::parse(name).ok_or_else(|| at(format!("unknown target `{name}`")))?
558                }
559                Some(_) => return Err(at("`target` is a string".into())),
560            };
561            rules.push(StyleRule::new(column, op, operand, style).target(target));
562        }
563        Ok(rules)
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    fn red() -> Style {
572        Style::parse("red").unwrap()
573    }
574
575    #[test]
576    fn comparisons_read_numbers_from_text() {
577        let cell = Value::Str("12.5".into());
578        assert!(Comparison::Gt.test(&cell, &Value::Int(10)));
579        assert!(Comparison::Le.test(&cell, &Value::Float(12.5)));
580        assert!(Comparison::Eq.test(&Value::Int(3), &Value::Float(3.0)));
581        assert!(!Comparison::Lt.test(&Value::Str("n/a".into()), &Value::Int(1)));
582        assert!(!Comparison::Gt.test(&Value::Null, &Value::Int(-1)));
583        assert!(Comparison::Ne.test(&Value::Str("n/a".into()), &Value::Int(1)));
584        // Integers past 2^53 are told apart, as cells and as text.
585        let big = 9_007_199_254_740_993_i64;
586        assert!(!Comparison::Eq.test(&Value::Int(big), &Value::Int(big - 1)));
587        assert!(Comparison::Gt.test(&Value::Int(big), &Value::Int(big - 1)));
588        assert!(Comparison::Gt.test(&Value::Str(big.to_string()), &Value::Int(big - 1)));
589        assert!(Comparison::Eq.test(&Value::Str(" 7 ".into()), &Value::Int(7)));
590        assert!(Comparison::Lt.test(&Value::Float(6.5), &Value::Int(7)));
591        assert!(Comparison::Gt.test(&Value::Str("7.5".into()), &Value::Int(7)));
592    }
593
594    #[test]
595    fn comparisons_on_text_and_null() {
596        let cell = Value::Str("failed: timeout".into());
597        assert!(Comparison::StartsWith.test(&cell, &"failed".into()));
598        assert!(Comparison::EndsWith.test(&cell, &"timeout".into()));
599        assert!(Comparison::Contains.test(&cell, &": ".into()));
600        assert!(!Comparison::Eq.test(&cell, &"failed".into()));
601        assert!(Comparison::Lt.test(&Value::Str("a".into()), &"b".into()));
602        assert!(Comparison::Eq.test(&Value::Null, &Value::Null));
603        assert!(Comparison::Eq.test(&Value::Str(String::new()), &Value::Null));
604        assert!(Comparison::Ne.test(&Value::Int(0), &Value::Null));
605        assert!(Comparison::Empty.test(&Value::Null, &Value::Null));
606        assert!(Comparison::NotEmpty.test(&Value::Int(0), &Value::Null));
607        for name in ["==", "!=", "<", "<=", ">", ">=", "starts_with", "not_empty"] {
608            assert!(Comparison::parse(name).is_some(), "{name}");
609        }
610        assert_eq!(Comparison::parse("like"), None);
611    }
612
613    #[test]
614    fn resolution_targets_cells_rows_and_columns() {
615        let rows = vec![
616            vec![Value::from("a"), Value::Int(1)],
617            vec![Value::from("b"), Value::Int(-1)],
618        ];
619        let rules = StyleRules::new()
620            .rule(StyleRule::new(1, Comparison::Lt, 0, red()))
621            .rule(StyleRule::new("name", Comparison::Eq, "a", red()).target(Target::Row))
622            .rule(
623                StyleRule::when(
624                    "n",
625                    |v| v.as_f64() == Some(-1.0),
626                    Style::parse("bold").unwrap(),
627                )
628                .target(Target::Column),
629            )
630            .rule(StyleRule::new(
631                "missing",
632                Comparison::NotEmpty,
633                Value::Null,
634                red(),
635            ));
636        let resolved = rules.resolve(&["name", "n"], &rows);
637        assert_eq!(resolved.cell_style(&rows[0], 0), None);
638        // Column rule only.
639        assert_eq!(
640            resolved.cell_style(&rows[0], 1),
641            Some(Style::parse("bold").unwrap())
642        );
643        // Column rule, then the cell rule on top.
644        assert_eq!(
645            resolved.cell_style(&rows[1], 1),
646            Some(Style::parse("bold red").unwrap())
647        );
648        assert_eq!(resolved.row_style(&rows[0]), Some(red()));
649        assert_eq!(resolved.row_style(&rows[1]), None);
650        assert_eq!(resolved.unresolved().len(), 1);
651        assert_eq!(resolved.unresolved()[0].column().to_string(), "missing");
652    }
653
654    #[cfg(feature = "toml")]
655    #[test]
656    fn rule_tables_from_toml() {
657        let rules = StyleRules::from_toml(
658            "[[rules]]\ncolumn = 'status'\nvalue = 'failed'\nstyle = 'bold red'\ntarget = 'row'\n\
659             [[rules]]\ncolumn = 2\nop = '>='\nvalue = 0.5\nstyle = 'yellow'\n\
660             [[rules]]\ncolumn = 'note'\nop = 'empty'\nstyle = 'dim'\n",
661        )
662        .unwrap();
663        assert_eq!(rules.len(), 3);
664        assert_eq!(rules.rules()[0].target_kind(), Target::Row);
665        assert_eq!(rules.rules()[1].column(), &ColumnRef::Index(2));
666        assert!(rules.rules()[1].matches(&Value::Float(0.75)));
667        assert!(rules.rules()[2].matches(&Value::Null));
668        assert!(StyleRules::from_toml("").unwrap().is_empty());
669
670        let error = |text: &str| StyleRules::from_toml(text).unwrap_err().to_string();
671        assert_eq!(
672            error("[[rules]]\ncolumn='a'\nvalue=1\nstyle='red'\nwhen='x'"),
673            "rules[0]: unknown key `when`"
674        );
675        assert_eq!(
676            error("[[rules]]\ncolumn='a'\nop='like'\nvalue=1\nstyle='red'"),
677            "rules[0]: unknown op `like`"
678        );
679        assert_eq!(
680            error("[[rules]]\ncolumn='a'\nstyle='red'"),
681            "rules[0]: missing `value`"
682        );
683        assert_eq!(error("rules = 3"), "rules: expected an array of tables");
684        assert!(
685            error("[[rules]]\ncolumn='a'\nvalue=1\nstyle='nonsense colour'")
686                .starts_with("rules[0]: style `nonsense colour`")
687        );
688    }
689}