1use std::cmp::Ordering;
50use std::fmt;
51use std::sync::Arc;
52
53use rich::Style;
54
55use super::Value;
56
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum Target {
60 #[default]
62 Cell,
63 Row,
65 Column,
67}
68
69impl Target {
70 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#[derive(Clone, Debug, PartialEq, Eq)]
83pub enum ColumnRef {
84 Name(String),
86 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127#[non_exhaustive]
128pub enum Comparison {
129 Eq,
131 Ne,
133 Lt,
135 Le,
137 Gt,
139 Ge,
141 Contains,
143 StartsWith,
145 EndsWith,
147 Empty,
149 NotEmpty,
151}
152
153impl Comparison {
154 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 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
202fn order(cell: &Value, operand: &Value) -> Option<Ordering> {
205 match operand {
206 Value::Null => None,
207 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
234pub 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#[derive(Clone, Debug)]
256pub struct StyleRule {
257 column: ColumnRef,
258 condition: Condition,
259 style: Style,
260 target: Target,
261}
262
263impl StyleRule {
264 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 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 pub fn target(mut self, target: Target) -> Self {
295 self.target = target;
296 self
297 }
298
299 pub fn column(&self) -> &ColumnRef {
301 &self.column
302 }
303
304 pub fn style(&self) -> &Style {
306 &self.style
307 }
308
309 pub fn target_kind(&self) -> Target {
311 self.target
312 }
313
314 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#[derive(Clone, Debug, Default)]
326pub struct StyleRules {
327 rules: Vec<StyleRule>,
328}
329
330impl StyleRules {
331 pub fn new() -> Self {
333 StyleRules::default()
334 }
335
336 pub fn rule(mut self, rule: StyleRule) -> Self {
338 self.rules.push(rule);
339 self
340 }
341
342 pub fn push(&mut self, rule: StyleRule) -> &mut Self {
344 self.rules.push(rule);
345 self
346 }
347
348 pub fn rules(&self) -> &[StyleRule] {
350 &self.rules
351 }
352
353 pub fn len(&self) -> usize {
355 self.rules.len()
356 }
357
358 pub fn is_empty(&self) -> bool {
360 self.rules.is_empty()
361 }
362
363 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#[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 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 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 pub fn column_style(&self, column: usize) -> Option<&Style> {
463 self.column_styles.get(column).and_then(Option::as_ref)
464 }
465
466 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#[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 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 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 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 assert_eq!(
640 resolved.cell_style(&rows[0], 1),
641 Some(Style::parse("bold").unwrap())
642 );
643 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}