use std::cmp::Ordering;
use std::fmt;
use std::sync::Arc;
use rich::Style;
use super::Value;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Target {
#[default]
Cell,
Row,
Column,
}
impl Target {
pub fn parse(name: &str) -> Option<Self> {
match name {
"cell" => Some(Target::Cell),
"row" => Some(Target::Row),
"column" => Some(Target::Column),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Target::Cell => "cell",
Target::Row => "row",
Target::Column => "column",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ColumnRef {
Name(String),
Index(usize),
}
impl From<&str> for ColumnRef {
fn from(name: &str) -> Self {
ColumnRef::Name(name.to_string())
}
}
impl From<String> for ColumnRef {
fn from(name: String) -> Self {
ColumnRef::Name(name)
}
}
impl From<usize> for ColumnRef {
fn from(index: usize) -> Self {
ColumnRef::Index(index)
}
}
impl fmt::Display for ColumnRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ColumnRef::Name(name) => f.write_str(name),
ColumnRef::Index(index) => write!(f, "#{index}"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Comparison {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
Contains,
StartsWith,
EndsWith,
Empty,
NotEmpty,
}
impl Comparison {
pub fn parse(name: &str) -> Option<Self> {
Some(match name {
"eq" | "==" => Comparison::Eq,
"ne" | "!=" => Comparison::Ne,
"lt" | "<" => Comparison::Lt,
"le" | "<=" => Comparison::Le,
"gt" | ">" => Comparison::Gt,
"ge" | ">=" => Comparison::Ge,
"contains" => Comparison::Contains,
"starts_with" => Comparison::StartsWith,
"ends_with" => Comparison::EndsWith,
"empty" => Comparison::Empty,
"not_empty" => Comparison::NotEmpty,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
Comparison::Eq => "eq",
Comparison::Ne => "ne",
Comparison::Lt => "lt",
Comparison::Le => "le",
Comparison::Gt => "gt",
Comparison::Ge => "ge",
Comparison::Contains => "contains",
Comparison::StartsWith => "starts_with",
Comparison::EndsWith => "ends_with",
Comparison::Empty => "empty",
Comparison::NotEmpty => "not_empty",
}
}
pub fn symbol(self) -> Option<&'static str> {
Some(match self {
Comparison::Eq => "==",
Comparison::Ne => "!=",
Comparison::Lt => "<",
Comparison::Le => "<=",
Comparison::Gt => ">",
Comparison::Ge => ">=",
Comparison::Contains => "*=",
Comparison::StartsWith => "^=",
Comparison::EndsWith => "$=",
Comparison::Empty | Comparison::NotEmpty => return None,
})
}
pub fn test(self, cell: &Value, operand: &Value) -> bool {
match self {
Comparison::Empty => cell.is_empty(),
Comparison::NotEmpty => !cell.is_empty(),
Comparison::Ne => !Comparison::Eq.test(cell, operand),
Comparison::Contains => cell.plain().contains(&operand.plain()),
Comparison::StartsWith => cell.plain().starts_with(&operand.plain()),
Comparison::EndsWith => cell.plain().ends_with(&operand.plain()),
Comparison::Eq => match operand {
Value::Null => cell.is_empty(),
Value::Int(_) | Value::Float(_) => order(cell, operand) == Some(Ordering::Equal),
_ => cell.plain() == operand.plain(),
},
Comparison::Lt | Comparison::Le | Comparison::Gt | Comparison::Ge => {
let Some(ordering) = order(cell, operand) else {
return false;
};
match self {
Comparison::Lt => ordering == Ordering::Less,
Comparison::Le => ordering != Ordering::Greater,
Comparison::Gt => ordering == Ordering::Greater,
_ => ordering != Ordering::Less,
}
}
}
}
}
fn order(cell: &Value, operand: &Value) -> Option<Ordering> {
match operand {
Value::Null => None,
Value::Int(right) => {
let left = match cell {
Value::Int(left) => Some(*left),
Value::Str(_) | Value::Text(_) => cell.plain().trim().parse::<i64>().ok(),
_ => None,
};
match left {
Some(left) => Some(left.cmp(right)),
None => order(cell, &Value::Float(*right as f64)),
}
}
Value::Float(_) => {
let left = cell
.as_f64()
.or_else(|| match cell {
Value::Str(_) | Value::Text(_) => cell.plain().trim().parse::<f64>().ok(),
_ => None,
})
.filter(|n| !n.is_nan())?;
left.partial_cmp(&operand.as_f64()?)
}
_ if matches!(cell, Value::Null) => None,
_ => Some(cell.plain().cmp(&operand.plain())),
}
}
pub type Predicate = Arc<dyn Fn(&Value) -> bool + Send + Sync>;
#[derive(Clone)]
enum Condition {
Compare(Comparison, Box<Value>),
Predicate(Predicate),
}
impl fmt::Debug for Condition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Condition::Compare(op, value) => {
f.debug_tuple("Compare").field(op).field(value).finish()
}
Condition::Predicate(_) => f.write_str("Predicate(..)"),
}
}
}
#[derive(Clone, Debug)]
pub struct StyleRule {
column: ColumnRef,
condition: Condition,
style: Style,
target: Target,
}
impl StyleRule {
pub fn new(
column: impl Into<ColumnRef>,
comparison: Comparison,
value: impl Into<Value>,
style: Style,
) -> Self {
StyleRule {
column: column.into(),
condition: Condition::Compare(comparison, Box::new(value.into())),
style,
target: Target::Cell,
}
}
pub fn when(
column: impl Into<ColumnRef>,
predicate: impl Fn(&Value) -> bool + Send + Sync + 'static,
style: Style,
) -> Self {
StyleRule {
column: column.into(),
condition: Condition::Predicate(Arc::new(predicate)),
style,
target: Target::Cell,
}
}
#[must_use]
pub fn target(mut self, target: Target) -> Self {
self.target = target;
self
}
pub fn column(&self) -> &ColumnRef {
&self.column
}
pub fn style(&self) -> &Style {
&self.style
}
pub fn target_kind(&self) -> Target {
self.target
}
pub fn matches(&self, value: &Value) -> bool {
match &self.condition {
Condition::Compare(op, operand) => op.test(value, operand),
Condition::Predicate(predicate) => predicate(value),
}
}
pub fn comparison(&self) -> Option<(Comparison, &Value)> {
match &self.condition {
Condition::Compare(op, operand) => Some((*op, &**operand)),
Condition::Predicate(_) => None,
}
}
pub fn parse(spec: &str) -> Result<Self, RuleError> {
parse_spec(spec).map_err(|message| RuleError(format!("rule {spec:?}: {message}")))
}
pub fn to_spec(&self) -> Option<String> {
let (op, operand) = self.comparison()?;
let column = match &self.column {
ColumnRef::Index(index) => format!("#{index}"),
ColumnRef::Name(name) if bare_column(name) => name.clone(),
ColumnRef::Name(name) => quote(name),
};
let condition = match (op, operand) {
(Comparison::Empty, _) | (Comparison::Eq, Value::Null) => format!("{column} empty"),
(Comparison::NotEmpty, _) | (Comparison::Ne, Value::Null) => {
format!("{column} not_empty")
}
(_, Value::Null) => return None,
(op, operand) => format!("{column}{}{}", op.symbol()?, spec_value(operand)?),
};
let target = match self.target {
Target::Cell => "",
Target::Row => "row:",
Target::Column => "column:",
};
Some(format!("{condition}:{target}{}", self.style.definition()))
}
}
impl fmt::Display for StyleRule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.to_spec() {
Some(spec) => f.write_str(&spec),
None => {
let target = match self.target {
Target::Cell => "",
Target::Row => "row:",
Target::Column => "column:",
};
write!(
f,
"{} <predicate>:{target}{}",
self.column,
self.style.definition()
)
}
}
}
}
impl std::str::FromStr for StyleRule {
type Err = RuleError;
fn from_str(spec: &str) -> Result<Self, RuleError> {
StyleRule::parse(spec)
}
}
const SYMBOLS: &[(&str, Comparison)] = &[
("==", Comparison::Eq),
("!=", Comparison::Ne),
("<=", Comparison::Le),
(">=", Comparison::Ge),
("*=", Comparison::Contains),
("^=", Comparison::StartsWith),
("$=", Comparison::EndsWith),
("=", Comparison::Eq),
("<", Comparison::Lt),
(">", Comparison::Gt),
];
const WORDS: &[&str] = &[
"eq",
"ne",
"lt",
"le",
"gt",
"ge",
"contains",
"starts_with",
"ends_with",
"empty",
"not_empty",
];
fn operator_at(text: &str, word: bool) -> Option<(Comparison, usize)> {
if let Some((symbol, op)) = SYMBOLS.iter().find(|(s, _)| text.starts_with(s)) {
return Some((*op, symbol.len()));
}
if !word {
return None;
}
WORDS.iter().find_map(|name| {
let rest = text.strip_prefix(name)?;
let ends = rest.is_empty()
|| rest.starts_with(|c: char| c.is_whitespace() || c == ':' || c == '"');
ends.then(|| (Comparison::parse(name).expect("a known word"), name.len()))
})
}
fn unquote(text: &str) -> Result<(String, &str), String> {
let mut out = String::new();
let mut chars = text.char_indices().skip(1);
while let Some((i, c)) = chars.next() {
match c {
'"' => return Ok((out, &text[i + 1..])),
'\\' => match chars.next() {
Some((_, '"')) => out.push('"'),
Some((_, '\\')) => out.push('\\'),
Some((_, 'n')) => out.push('\n'),
Some((_, 't')) => out.push('\t'),
Some((_, 'r')) => out.push('\r'),
Some((_, other)) => {
return Err(format!(
"unknown escape `\\{other}` in quotes (use \\\" \\\\ \\n \\t or \\r)"
));
}
None => break,
},
c => out.push(c),
}
}
Err("a quote is not closed".into())
}
fn quote(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for c in text.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
c => out.push(c),
}
}
out.push('"');
out
}
fn spec_operand(text: &str) -> Value {
if let Ok(n) = text.parse::<i64>() {
return Value::Int(n);
}
if text.bytes().any(|b| b.is_ascii_digit())
&& let Ok(n) = text.parse::<f64>()
&& n.is_finite()
{
return Value::Float(n);
}
Value::Str(text.to_string())
}
fn bare_column(name: &str) -> bool {
!name.is_empty()
&& name.trim() == name
&& !name.starts_with('#')
&& !name.contains(|c: char| "=<>!*^$:\"\\".contains(c) || c.is_control())
&& !name
.split_whitespace()
.skip(1)
.any(|word| WORDS.contains(&word))
}
fn spec_value(value: &Value) -> Option<String> {
Some(match value {
Value::Null => return None,
Value::Int(n) => n.to_string(),
Value::Float(f) if f.is_finite() => format!("{f:?}"),
Value::Float(_) => return None,
Value::Str(_) | Value::Text(_) => {
let text = value.plain();
let bare = !text.is_empty()
&& text.trim() == text
&& !text.starts_with('"')
&& !text.contains(|c: char| c == ':' || c.is_control())
&& matches!(spec_operand(&text), Value::Str(_));
if bare { text } else { quote(&text) }
}
})
}
fn parse_spec(spec: &str) -> Result<StyleRule, String> {
const HINT: &str = "expected COLUMN OP VALUE:STYLE, e.g. latency>500:bold red";
let text = spec.trim();
if text.is_empty() {
return Err(format!("empty; {HINT}"));
}
let (column, op, rest) = if text.starts_with('"') {
let (name, rest) = unquote(text)?;
let after = rest.trim_start();
let spaced = after.len() < rest.len();
let Some((op, len)) = operator_at(after, spaced) else {
return Err(format!("no comparison after the quoted column; {HINT}"));
};
(ColumnRef::Name(name), op, &after[len..])
} else {
let end = text.find(':').unwrap_or(text.len());
let found = text[..end].char_indices().find_map(|(at, _)| {
let word = text[..at].ends_with(char::is_whitespace);
operator_at(&text[at..], word).map(|hit| (at, hit))
});
let Some((at, (op, len))) = found else {
return Err(format!(
"no comparison (== != < <= > >= *= ^= $=, or a word such as contains or \
empty) before the first `:`; quote a column name that holds one; {HINT}"
));
};
let name = text[..at].trim();
if name.is_empty() {
return Err(format!("no column before the comparison; {HINT}"));
}
let column = match name.strip_prefix('#') {
Some(digits) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => {
ColumnRef::Index(
digits
.parse()
.map_err(|_| format!("column index {digits} is too large"))?,
)
}
_ => ColumnRef::Name(name.to_string()),
};
(column, op, &text[at + len..])
};
let rest = rest.trim_start();
let (operand, rest) = if matches!(op, Comparison::Empty | Comparison::NotEmpty) {
match rest.strip_prefix(':') {
Some(rest) => (Value::Null, rest),
None if rest.is_empty() => return Err(format!("no `:` and style; {HINT}")),
None => return Err(format!("`{}` takes no value", op.name())),
}
} else if rest.starts_with('"') {
let (value, after) = unquote(rest)?;
match after.trim_start().strip_prefix(':') {
Some(after) => (Value::Str(value), after),
None => return Err(format!("no `:` and style after the quoted value; {HINT}")),
}
} else {
let Some((value, after)) = rest.split_once(':') else {
return Err(format!("no `:` and style after the value; {HINT}"));
};
let value = value.trim();
if value.is_empty() {
return Err(format!(
"no value after `{}` (quote an empty one: \"\")",
op.symbol().unwrap_or(op.name())
));
}
(spec_operand(value), after)
};
let rest = rest.trim();
let (target, style) = match rest.split_once(':') {
Some((head, tail)) => match Target::parse(head.trim()) {
Some(target) => (target, tail.trim()),
None => (Target::Cell, rest),
},
None => (Target::Cell, rest),
};
if style.is_empty() || Target::parse(style).is_some() {
return Err(format!("no style after the `:`; {HINT}"));
}
let style = Style::parse(style).map_err(|e| format!("style {style:?}: {e}"))?;
Ok(StyleRule::new(column, op, operand, style).target(target))
}
#[derive(Clone, Debug, Default)]
pub struct StyleRules {
rules: Vec<StyleRule>,
}
impl StyleRules {
pub fn new() -> Self {
StyleRules::default()
}
#[must_use]
pub fn rule(mut self, rule: StyleRule) -> Self {
self.rules.push(rule);
self
}
pub fn push(&mut self, rule: StyleRule) -> &mut Self {
self.rules.push(rule);
self
}
pub fn rules(&self) -> &[StyleRule] {
&self.rules
}
pub fn len(&self) -> usize {
self.rules.len()
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn resolve<'a, R>(
&'a self,
headers: &[&str],
rows: impl IntoIterator<Item = R>,
) -> ResolvedRules<'a>
where
R: AsRef<[Value]>,
{
let columns: Vec<Option<usize>> = self
.rules
.iter()
.map(|rule| match &rule.column {
ColumnRef::Name(name) => headers.iter().position(|h| h == name),
ColumnRef::Index(index) => (*index < headers.len()).then_some(*index),
})
.collect();
let mut column_styles: Vec<Option<Style>> = vec![None; headers.len()];
let column_rules: Vec<usize> = (0..self.rules.len())
.filter(|&i| self.rules[i].target == Target::Column && columns[i].is_some())
.collect();
if !column_rules.is_empty() {
let mut hit = vec![false; self.rules.len()];
for row in rows {
let row = row.as_ref();
for &i in &column_rules {
if !hit[i] {
let column = columns[i].expect("resolved above");
hit[i] = row.get(column).is_some_and(|v| self.rules[i].matches(v));
}
}
if column_rules.iter().all(|&i| hit[i]) {
break;
}
}
for &i in column_rules.iter().filter(|&&i| hit[i]) {
let column = columns[i].expect("resolved above");
combine(&mut column_styles[column], &self.rules[i].style);
}
}
ResolvedRules {
rules: self,
columns,
column_styles,
}
}
}
fn combine(slot: &mut Option<Style>, style: &Style) {
*slot = Some(match slot.take() {
Some(previous) => previous.combine(style),
None => style.clone(),
});
}
#[derive(Clone, Debug)]
pub struct ResolvedRules<'a> {
rules: &'a StyleRules,
columns: Vec<Option<usize>>,
column_styles: Vec<Option<Style>>,
}
impl ResolvedRules<'_> {
pub fn row_style(&self, row: &[Value]) -> Option<Style> {
let mut style = None;
for (rule, column) in self.rules.rules.iter().zip(&self.columns) {
if rule.target != Target::Row {
continue;
}
if let Some(value) = column.and_then(|c| row.get(c))
&& rule.matches(value)
{
combine(&mut style, &rule.style);
}
}
style
}
pub fn cell_style(&self, row: &[Value], column: usize) -> Option<Style> {
let mut style = self.column_styles.get(column).cloned().flatten();
let Some(value) = row.get(column) else {
return style;
};
for (rule, resolved) in self.rules.rules.iter().zip(&self.columns) {
if rule.target == Target::Cell && *resolved == Some(column) && rule.matches(value) {
combine(&mut style, &rule.style);
}
}
style
}
pub fn column_style(&self, column: usize) -> Option<&Style> {
self.column_styles.get(column).and_then(Option::as_ref)
}
pub fn unresolved(&self) -> Vec<&StyleRule> {
self.rules
.rules
.iter()
.zip(&self.columns)
.filter(|(_, column)| column.is_none())
.map(|(rule, _)| rule)
.collect()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuleError(String);
impl fmt::Display for RuleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for RuleError {}
#[cfg(feature = "toml")]
impl StyleRules {
pub fn from_toml(text: &str) -> Result<Self, RuleError> {
let table: toml::Table = text
.parse()
.map_err(|e: toml::de::Error| RuleError(format!("not TOML: {}", e.message())))?;
match table.get("rules") {
Some(rules) => Self::from_toml_value(rules),
None => Ok(StyleRules::new()),
}
}
pub fn from_toml_value(value: &toml::Value) -> Result<Self, RuleError> {
let toml::Value::Array(items) = value else {
return Err(RuleError("rules: expected an array of tables".into()));
};
let mut rules = StyleRules::new();
for (index, item) in items.iter().enumerate() {
let at = |message: String| RuleError(format!("rules[{index}]: {message}"));
let toml::Value::Table(table) = item else {
return Err(at("expected a table".into()));
};
if let Some(key) = table
.keys()
.find(|k| !matches!(k.as_str(), "column" | "op" | "value" | "style" | "target"))
{
return Err(at(format!("unknown key `{key}`")));
}
let column = match table.get("column") {
Some(toml::Value::String(name)) => ColumnRef::Name(name.clone()),
Some(toml::Value::Integer(n)) => usize::try_from(*n)
.map(ColumnRef::Index)
.map_err(|_| at(format!("column index {n} is negative")))?,
Some(_) => return Err(at("`column` is a header or an index".into())),
None => return Err(at("missing `column`".into())),
};
let op = match table.get("op") {
None => Comparison::Eq,
Some(toml::Value::String(name)) => {
Comparison::parse(name).ok_or_else(|| at(format!("unknown op `{name}`")))?
}
Some(_) => return Err(at("`op` is a string".into())),
};
let operand = match table.get("value") {
None if matches!(op, Comparison::Empty | Comparison::NotEmpty) => Value::Null,
None => return Err(at("missing `value`".into())),
Some(toml::Value::String(s)) => Value::Str(s.clone()),
Some(toml::Value::Integer(n)) => Value::Int(*n),
Some(toml::Value::Float(f)) => Value::Float(*f),
Some(toml::Value::Boolean(b)) => Value::Str(b.to_string()),
Some(_) => return Err(at("`value` is a string, number or boolean".into())),
};
let style = match table.get("style") {
Some(toml::Value::String(spec)) => {
Style::parse(spec).map_err(|e| at(format!("style `{spec}`: {e}")))?
}
Some(_) => return Err(at("`style` is a string".into())),
None => return Err(at("missing `style`".into())),
};
let target = match table.get("target") {
None => Target::Cell,
Some(toml::Value::String(name)) => {
Target::parse(name).ok_or_else(|| at(format!("unknown target `{name}`")))?
}
Some(_) => return Err(at("`target` is a string".into())),
};
rules.push(StyleRule::new(column, op, operand, style).target(target));
}
Ok(rules)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn red() -> Style {
Style::parse("red").unwrap()
}
#[test]
fn comparisons_read_numbers_from_text() {
let cell = Value::Str("12.5".into());
assert!(Comparison::Gt.test(&cell, &Value::Int(10)));
assert!(Comparison::Le.test(&cell, &Value::Float(12.5)));
assert!(Comparison::Eq.test(&Value::Int(3), &Value::Float(3.0)));
assert!(!Comparison::Lt.test(&Value::Str("n/a".into()), &Value::Int(1)));
assert!(!Comparison::Gt.test(&Value::Null, &Value::Int(-1)));
assert!(Comparison::Ne.test(&Value::Str("n/a".into()), &Value::Int(1)));
let big = 9_007_199_254_740_993_i64;
assert!(!Comparison::Eq.test(&Value::Int(big), &Value::Int(big - 1)));
assert!(Comparison::Gt.test(&Value::Int(big), &Value::Int(big - 1)));
assert!(Comparison::Gt.test(&Value::Str(big.to_string()), &Value::Int(big - 1)));
assert!(Comparison::Eq.test(&Value::Str(" 7 ".into()), &Value::Int(7)));
assert!(Comparison::Lt.test(&Value::Float(6.5), &Value::Int(7)));
assert!(Comparison::Gt.test(&Value::Str("7.5".into()), &Value::Int(7)));
}
#[test]
fn comparisons_on_text_and_null() {
let cell = Value::Str("failed: timeout".into());
assert!(Comparison::StartsWith.test(&cell, &"failed".into()));
assert!(Comparison::EndsWith.test(&cell, &"timeout".into()));
assert!(Comparison::Contains.test(&cell, &": ".into()));
assert!(!Comparison::Eq.test(&cell, &"failed".into()));
assert!(Comparison::Lt.test(&Value::Str("a".into()), &"b".into()));
assert!(Comparison::Eq.test(&Value::Null, &Value::Null));
assert!(Comparison::Eq.test(&Value::Str(String::new()), &Value::Null));
assert!(Comparison::Ne.test(&Value::Int(0), &Value::Null));
assert!(Comparison::Empty.test(&Value::Null, &Value::Null));
assert!(Comparison::NotEmpty.test(&Value::Int(0), &Value::Null));
for name in ["==", "!=", "<", "<=", ">", ">=", "starts_with", "not_empty"] {
assert!(Comparison::parse(name).is_some(), "{name}");
}
assert_eq!(Comparison::parse("like"), None);
}
#[test]
fn resolution_targets_cells_rows_and_columns() {
let rows = vec![
vec![Value::from("a"), Value::Int(1)],
vec![Value::from("b"), Value::Int(-1)],
];
let rules = StyleRules::new()
.rule(StyleRule::new(1, Comparison::Lt, 0, red()))
.rule(StyleRule::new("name", Comparison::Eq, "a", red()).target(Target::Row))
.rule(
StyleRule::when(
"n",
|v| v.as_f64() == Some(-1.0),
Style::parse("bold").unwrap(),
)
.target(Target::Column),
)
.rule(StyleRule::new(
"missing",
Comparison::NotEmpty,
Value::Null,
red(),
));
let resolved = rules.resolve(&["name", "n"], &rows);
assert_eq!(resolved.cell_style(&rows[0], 0), None);
assert_eq!(
resolved.cell_style(&rows[0], 1),
Some(Style::parse("bold").unwrap())
);
assert_eq!(
resolved.cell_style(&rows[1], 1),
Some(Style::parse("bold red").unwrap())
);
assert_eq!(resolved.row_style(&rows[0]), Some(red()));
assert_eq!(resolved.row_style(&rows[1]), None);
assert_eq!(resolved.unresolved().len(), 1);
assert_eq!(resolved.unresolved()[0].column().to_string(), "missing");
}
fn parts(spec: &str) -> (String, &'static str, String, &'static str, String) {
let rule = StyleRule::parse(spec).unwrap_or_else(|e| panic!("{e}"));
let (op, value) = rule.comparison().unwrap();
let operand = match value {
Value::Null => "null".to_string(),
Value::Int(n) => format!("int {n}"),
Value::Float(f) => format!("float {f}"),
other => format!("text {}", other.plain()),
};
(
rule.column().to_string(),
op.name(),
operand,
rule.target_kind().name(),
rule.style().definition(),
)
}
#[test]
fn specs_map_onto_comparisons_and_targets() {
let p = |spec| parts(spec);
assert_eq!(
p("latency>500:bold red"),
(
"latency".into(),
"gt",
"int 500".into(),
"cell",
"bold red".into()
)
);
assert_eq!(
p("status=failed:row:on red"),
(
"status".into(),
"eq",
"text failed".into(),
"row",
"on red".into()
)
);
assert_eq!(
p(" p99 ms >= 12.5 : column : italic "),
(
"p99 ms".into(),
"ge",
"float 12.5".into(),
"column",
"italic".into()
)
);
assert_eq!(p("delta<-5:red").2, "int -5");
assert_eq!(p("a<=1:red").1, "le");
assert_eq!(p("a==1:red").1, "eq");
assert_eq!(p("a!=1:red").1, "ne");
assert_eq!(p("a*=err:red").1, "contains");
assert_eq!(p("a^=err:red").1, "starts_with");
assert_eq!(p("a$=err:red").1, "ends_with");
assert_eq!(p("#2>0:red").0, "#2");
assert_eq!(
p("msg contains a=b:red"),
(
"msg".into(),
"contains",
"text a=b".into(),
"cell",
"red".into()
)
);
assert_eq!(p("status eq failed:red").1, "eq");
assert_eq!(p("note empty:dim").1, "empty");
assert_eq!(p("note not_empty:row:dim").3, "row");
assert_eq!(p("is empty=yes:red").0, "is empty");
assert_eq!(p("when=\"12:30\":red").2, "text 12:30");
assert_eq!(p("code=\"200\":red").2, "text 200");
assert_eq!(p("\"a<b\">1:red").0, "a<b");
assert_eq!(p("\"p99: ms\" gt 1:red").0, "p99: ms");
assert_eq!(p("x=\"say \\\"hi\\\"\":red").2, "text say \"hi\"");
assert_eq!(
p("site=docs:link https://example.com").4,
"link https://example.com"
);
let rule = StyleRule::parse("latency>500:red").unwrap();
assert!(rule.matches(&Value::from("612")));
assert!(!rule.matches(&Value::Int(500)));
assert!(
StyleRule::parse("code=\"200\":red")
.unwrap()
.matches(&Value::from("200"))
);
}
#[test]
fn bad_specs_say_what_is_wrong_and_name_the_spec() {
let error = |spec: &str| StyleRule::parse(spec).unwrap_err().to_string();
assert!(error("").contains("empty"));
assert!(
error("latency:red").contains("no comparison"),
"{}",
error("latency:red")
);
assert!(error(">5:red").contains("no column"));
assert!(error("latency>:red").starts_with("rule \"latency>:red\": no value after `>`"));
assert!(error("latency>5").contains("no `:` and style"));
assert!(error("latency>5:").contains("no style"));
assert!(error("latency>5:row:").contains("no style"));
assert!(error("latency>5:row").contains("no style"));
assert!(error("latency>5:nonsense colour").contains("style \"nonsense colour\""));
assert!(error("note empty x:red").contains("`empty` takes no value"));
assert!(error("x=\"open:red").contains("not closed"));
assert!(error("x=\"a\\q\":red").contains("unknown escape"));
assert!(error("\"x\" ? 1:red").contains("no comparison after the quoted column"));
}
#[test]
fn specs_round_trip() {
let red = || Style::parse("bold red").unwrap();
let rules = [
StyleRule::new("latency", Comparison::Gt, 500, red()),
StyleRule::new("status", Comparison::Eq, "failed", red()).target(Target::Row),
StyleRule::new("p99 ms", Comparison::Ge, 12.5, red()).target(Target::Column),
StyleRule::new(2, Comparison::Lt, 1.0, red()),
StyleRule::new("a<b", Comparison::Contains, "x: y", red()),
StyleRule::new("count empty", Comparison::Eq, "200", red()),
StyleRule::new("#tag", Comparison::Ne, " padded ", red()),
StyleRule::new("q", Comparison::EndsWith, "say \"hi\"\n", red()),
StyleRule::new("note", Comparison::Empty, Value::Null, red()),
StyleRule::new("note", Comparison::Eq, Value::Null, red()),
StyleRule::new("note", Comparison::NotEmpty, Value::Null, red()),
StyleRule::new("", Comparison::StartsWith, "", red()),
];
for rule in rules {
let spec = rule.to_spec().unwrap();
let back = StyleRule::parse(&spec).unwrap_or_else(|e| panic!("{spec}: {e}"));
assert_eq!(back.to_spec().as_deref(), Some(spec.as_str()));
assert_eq!(back.column(), rule.column(), "{spec}");
assert_eq!(back.target_kind(), rule.target_kind(), "{spec}");
assert_eq!(back.style(), rule.style(), "{spec}");
for cell in [
"",
"200",
"failed",
"x: y",
" padded ",
"13",
"0.5",
"say \"hi\"\n",
] {
let cell = Value::from(cell);
assert_eq!(
back.matches(&cell),
rule.matches(&cell),
"{spec} on {cell:?}"
);
}
}
assert_eq!(
StyleRule::new("p99 ms", Comparison::Ge, 12.5, red())
.to_spec()
.as_deref(),
Some("p99 ms>=12.5:bold red")
);
assert_eq!(
StyleRule::new(1, Comparison::Eq, 2.0, red())
.to_spec()
.as_deref(),
Some("#1==2.0:bold red")
);
assert_eq!(StyleRule::when("a", |_| true, red()).to_spec(), None);
assert_eq!(
StyleRule::when("a", |_| true, red()).to_string(),
"a <predicate>:bold red"
);
assert_eq!(
StyleRule::new("a", Comparison::Lt, Value::Null, red()).to_spec(),
None
);
assert_eq!(
StyleRule::new("a", Comparison::Lt, f64::NAN, red()).to_spec(),
None
);
}
#[cfg(feature = "toml")]
#[test]
fn rule_tables_from_toml() {
let rules = StyleRules::from_toml(
"[[rules]]\ncolumn = 'status'\nvalue = 'failed'\nstyle = 'bold red'\ntarget = 'row'\n\
[[rules]]\ncolumn = 2\nop = '>='\nvalue = 0.5\nstyle = 'yellow'\n\
[[rules]]\ncolumn = 'note'\nop = 'empty'\nstyle = 'dim'\n",
)
.unwrap();
assert_eq!(rules.len(), 3);
assert_eq!(rules.rules()[0].target_kind(), Target::Row);
assert_eq!(rules.rules()[1].column(), &ColumnRef::Index(2));
assert!(rules.rules()[1].matches(&Value::Float(0.75)));
assert!(rules.rules()[2].matches(&Value::Null));
assert!(StyleRules::from_toml("").unwrap().is_empty());
let error = |text: &str| StyleRules::from_toml(text).unwrap_err().to_string();
assert_eq!(
error("[[rules]]\ncolumn='a'\nvalue=1\nstyle='red'\nwhen='x'"),
"rules[0]: unknown key `when`"
);
assert_eq!(
error("[[rules]]\ncolumn='a'\nop='like'\nvalue=1\nstyle='red'"),
"rules[0]: unknown op `like`"
);
assert_eq!(
error("[[rules]]\ncolumn='a'\nstyle='red'"),
"rules[0]: missing `value`"
);
assert_eq!(error("rules = 3"), "rules: expected an array of tables");
assert!(
error("[[rules]]\ncolumn='a'\nvalue=1\nstyle='nonsense colour'")
.starts_with("rules[0]: style `nonsense colour`")
);
}
}