1use std::cmp::Ordering;
84use std::fmt;
85use std::sync::Arc;
86
87use rich::Style;
88
89use super::Value;
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum Target {
94 #[default]
96 Cell,
97 Row,
99 Column,
101}
102
103impl Target {
104 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 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#[derive(Clone, Debug, PartialEq, Eq)]
126pub enum ColumnRef {
127 Name(String),
129 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170#[non_exhaustive]
171pub enum Comparison {
172 Eq,
174 Ne,
176 Lt,
178 Le,
180 Gt,
182 Ge,
184 Contains,
186 StartsWith,
188 EndsWith,
190 Empty,
192 NotEmpty,
194}
195
196impl Comparison {
197 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 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 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 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
281fn order(cell: &Value, operand: &Value) -> Option<Ordering> {
284 match operand {
285 Value::Null => None,
286 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
313pub 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#[derive(Clone, Debug)]
335pub struct StyleRule {
336 column: ColumnRef,
337 condition: Condition,
338 style: Style,
339 target: Target,
340}
341
342impl StyleRule {
343 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 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 #[must_use]
374 pub fn target(mut self, target: Target) -> Self {
375 self.target = target;
376 self
377 }
378
379 pub fn column(&self) -> &ColumnRef {
381 &self.column
382 }
383
384 pub fn style(&self) -> &Style {
386 &self.style
387 }
388
389 pub fn target_kind(&self) -> Target {
391 self.target
392 }
393
394 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 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 pub fn parse(spec: &str) -> Result<Self, RuleError> {
416 parse_spec(spec).map_err(|message| RuleError(format!("rule {spec:?}: {message}")))
417 }
418
419 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
447impl 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
478const 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
492const 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
507fn 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
525fn 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
553fn 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
571fn 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
586fn 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
600fn 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
621fn 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 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 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 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#[derive(Clone, Debug, Default)]
713pub struct StyleRules {
714 rules: Vec<StyleRule>,
715}
716
717impl StyleRules {
718 pub fn new() -> Self {
720 StyleRules::default()
721 }
722
723 #[must_use]
725 pub fn rule(mut self, rule: StyleRule) -> Self {
726 self.rules.push(rule);
727 self
728 }
729
730 pub fn push(&mut self, rule: StyleRule) -> &mut Self {
732 self.rules.push(rule);
733 self
734 }
735
736 pub fn rules(&self) -> &[StyleRule] {
738 &self.rules
739 }
740
741 pub fn len(&self) -> usize {
743 self.rules.len()
744 }
745
746 pub fn is_empty(&self) -> bool {
748 self.rules.is_empty()
749 }
750
751 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#[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 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 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 pub fn column_style(&self, column: usize) -> Option<&Style> {
851 self.column_styles.get(column).and_then(Option::as_ref)
852 }
853
854 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#[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 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 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 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 assert_eq!(
1028 resolved.cell_style(&rows[0], 1),
1029 Some(Style::parse("bold").unwrap())
1030 );
1031 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 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 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 assert_eq!(p("is empty=yes:red").0, "is empty");
1119 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 assert_eq!(
1128 p("site=docs:link https://example.com").4,
1129 "link https://example.com"
1130 );
1131 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 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}