Skip to main content

doing_ops/
tag_query.rs

1use std::sync::LazyLock;
2
3use chrono::{DateTime, Local};
4use doing_taskpaper::Entry;
5use doing_time::{chronify, parse_duration};
6use regex::Regex;
7
8static VALUE_QUERY_RE: LazyLock<Regex> =
9  LazyLock::new(|| Regex::new(r"^(!)?@?(\S+)\s+(!?[<>=][=*]?|[$*^]=)\s+(.+)$").unwrap());
10
11/// A comparison operator for tag value queries.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum ComparisonOp {
14  /// String contains (`*=`).
15  Contains,
16  /// String ends with (`$=`).
17  EndsWith,
18  /// Equal (`==` or `=`).
19  Equal,
20  /// Greater than (`>`).
21  GreaterThan,
22  /// Greater than or equal (`>=`).
23  GreaterThanOrEqual,
24  /// Less than (`<`).
25  LessThan,
26  /// Less than or equal (`<=`).
27  LessThanOrEqual,
28  /// String starts with (`^=`).
29  StartsWith,
30}
31
32/// A virtual property or tag name that a query targets.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub enum Property {
35  /// Start date of the entry.
36  Date,
37  /// Elapsed time since start (for unfinished entries).
38  Duration,
39  /// Time between start and `@done` date.
40  Interval,
41  /// Entry note text.
42  Note,
43  /// A named tag's value.
44  Tag(String),
45  /// Combined title and note text.
46  Text,
47  /// Time-of-day portion of the start date.
48  Time,
49  /// Entry title.
50  Title,
51}
52
53/// A parsed tag value query from a `--val` flag.
54///
55/// Format: `[!][@]property operator value`
56#[derive(Clone, Debug)]
57pub struct TagQuery {
58  negated: bool,
59  op: ComparisonOp,
60  property: Property,
61  value: String,
62}
63
64impl TagQuery {
65  /// Parse a query string into a structured `TagQuery`.
66  ///
67  /// Format: `[!][@]property operator value`
68  ///
69  /// The `@` prefix on the property name is optional and stripped during
70  /// parsing, so `"project == clientA"` and `"@project == clientA"` are
71  /// equivalent.
72  ///
73  /// Operators: `<`, `<=`, `>`, `>=`, `==`, `=`, `!=`, `*=`, `^=`, `$=`
74  pub fn parse(input: &str) -> Option<Self> {
75    let caps = VALUE_QUERY_RE.captures(input.trim())?;
76
77    let negated = caps.get(1).is_some();
78    let property = parse_property(&caps[2]);
79    let raw_op = &caps[3];
80    let value = caps[4].trim().to_string();
81
82    let (op, op_negated) = parse_operator(raw_op)?;
83    let negated = negated ^ op_negated;
84
85    Some(Self {
86      negated,
87      op,
88      property,
89      value,
90    })
91  }
92
93  /// Test whether an entry matches this query.
94  pub fn matches_entry(&self, entry: &Entry) -> bool {
95    let result = self.evaluate(entry);
96    if self.negated { !result } else { result }
97  }
98
99  fn compare_date(&self, entry_date: DateTime<Local>, value: &str) -> bool {
100    let Ok(target) = chronify(value) else {
101      return false;
102    };
103    compare_ord(entry_date, target, self.op)
104  }
105
106  fn compare_duration(&self, entry: &Entry, is_interval: bool) -> bool {
107    let entry_duration = if is_interval {
108      entry.interval()
109    } else {
110      entry.duration()
111    };
112    let Some(entry_duration) = entry_duration else {
113      return false;
114    };
115
116    let Ok(target) = parse_duration(&self.value) else {
117      return false;
118    };
119
120    compare_ord(entry_duration.num_seconds(), target.num_seconds(), self.op)
121  }
122
123  fn compare_numeric(&self, entry_val: f64, target_val: f64) -> bool {
124    compare_ord(OrdF64(entry_val), OrdF64(target_val), self.op)
125  }
126
127  fn compare_string(&self, haystack: &str, needle: &str) -> bool {
128    let needle = strip_quotes(needle);
129    let h = haystack.to_lowercase();
130    let n = needle.to_lowercase();
131
132    match self.op {
133      ComparisonOp::Contains => h.contains(&n),
134      ComparisonOp::EndsWith => h.ends_with(&n),
135      ComparisonOp::Equal => wildcard_match(&h, &n),
136      ComparisonOp::StartsWith => h.starts_with(&n),
137      ComparisonOp::GreaterThan => h.as_str() > n.as_str(),
138      ComparisonOp::GreaterThanOrEqual => h.as_str() >= n.as_str(),
139      ComparisonOp::LessThan => (h.as_str()) < n.as_str(),
140      ComparisonOp::LessThanOrEqual => h.as_str() <= n.as_str(),
141    }
142  }
143
144  fn compare_time(&self, entry: &Entry) -> bool {
145    // Parse the target as a date/time expression, then compare time-of-day
146    let Ok(target) = chronify(&self.value) else {
147      return false;
148    };
149    let entry_time = entry.date().time();
150    let target_time = target.time();
151    compare_ord(entry_time, target_time, self.op)
152  }
153
154  fn evaluate(&self, entry: &Entry) -> bool {
155    match &self.property {
156      Property::Date => self.compare_date(entry.date(), &self.value),
157      Property::Duration => self.compare_duration(entry, false),
158      Property::Interval => self.compare_duration(entry, true),
159      Property::Note => self.compare_string(&entry.note().to_line(" "), &self.value),
160      Property::Tag(name) => self.evaluate_tag(entry, name),
161      Property::Text => {
162        let text = format!("{} {}", entry.title(), entry.note().to_line(" "));
163        self.compare_string(&text, &self.value)
164      }
165      Property::Time => self.compare_time(entry),
166      Property::Title => self.compare_string(entry.title(), &self.value),
167    }
168  }
169
170  fn evaluate_tag(&self, entry: &Entry, tag_name: &str) -> bool {
171    let tag_value = match entry
172      .tags()
173      .iter()
174      .find(|t| t.name().eq_ignore_ascii_case(tag_name))
175      .and_then(|t| t.value().map(String::from))
176    {
177      Some(v) => v,
178      None => return false,
179    };
180
181    if is_string_op(self.op) {
182      return self.compare_string(&tag_value, &self.value);
183    }
184
185    // Try numeric comparison
186    let entry_num = parse_numeric(&tag_value);
187    let target_num = parse_numeric(&self.value);
188    if let (Some(e), Some(t)) = (entry_num, target_num) {
189      return self.compare_numeric(e, t);
190    }
191
192    // Try date comparison
193    if let (Ok(entry_dt), Ok(target_dt)) = (chronify(&tag_value), chronify(&self.value)) {
194      return compare_ord(entry_dt, target_dt, self.op);
195    }
196
197    // Try duration comparison
198    if let (Ok(entry_dur), Ok(target_dur)) = (parse_duration(&tag_value), parse_duration(&self.value)) {
199      return compare_ord(entry_dur.num_seconds(), target_dur.num_seconds(), self.op);
200    }
201
202    // Fall back to string comparison
203    self.compare_string(&tag_value, &self.value)
204  }
205}
206
207/// Wrapper for f64 that implements `PartialOrd` without NaN issues.
208#[derive(Debug, PartialEq)]
209struct OrdF64(f64);
210
211impl PartialOrd for OrdF64 {
212  fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
213    self.0.partial_cmp(&other.0)
214  }
215}
216
217/// Apply a comparison operator to two ordered values.
218fn compare_ord<T: PartialOrd>(a: T, b: T, op: ComparisonOp) -> bool {
219  match op {
220    ComparisonOp::Equal => a == b,
221    ComparisonOp::GreaterThan => a > b,
222    ComparisonOp::GreaterThanOrEqual => a >= b,
223    ComparisonOp::LessThan => a < b,
224    ComparisonOp::LessThanOrEqual => a <= b,
225    ComparisonOp::Contains | ComparisonOp::StartsWith | ComparisonOp::EndsWith => {
226      unreachable!("string operators must be handled by compare_string, not compare_ord")
227    }
228  }
229}
230
231/// Check whether an operator is a string-specific operator.
232fn is_string_op(op: ComparisonOp) -> bool {
233  matches!(
234    op,
235    ComparisonOp::Contains | ComparisonOp::StartsWith | ComparisonOp::EndsWith
236  )
237}
238
239/// Parse a numeric value, stripping trailing `%`.
240fn parse_numeric(s: &str) -> Option<f64> {
241  let s = s.trim().trim_end_matches('%').trim();
242  s.parse::<f64>().ok()
243}
244
245/// Parse an operator string into a `ComparisonOp` and negation flag.
246fn parse_operator(raw: &str) -> Option<(ComparisonOp, bool)> {
247  match raw {
248    "<" => Some((ComparisonOp::LessThan, false)),
249    "<=" => Some((ComparisonOp::LessThanOrEqual, false)),
250    ">" => Some((ComparisonOp::GreaterThan, false)),
251    ">=" => Some((ComparisonOp::GreaterThanOrEqual, false)),
252    "=" | "==" => Some((ComparisonOp::Equal, false)),
253    "!=" => Some((ComparisonOp::Equal, true)),
254    "*=" => Some((ComparisonOp::Contains, false)),
255    "^=" => Some((ComparisonOp::StartsWith, false)),
256    "$=" => Some((ComparisonOp::EndsWith, false)),
257    "!<" => Some((ComparisonOp::LessThan, true)),
258    "!<=" => Some((ComparisonOp::LessThanOrEqual, true)),
259    "!>" => Some((ComparisonOp::GreaterThan, true)),
260    "!>=" => Some((ComparisonOp::GreaterThanOrEqual, true)),
261    _ => None,
262  }
263}
264
265/// Parse a property name into a `Property` enum.
266fn parse_property(name: &str) -> Property {
267  match name.to_lowercase().as_str() {
268    "date" => Property::Date,
269    "duration" => Property::Duration,
270    "elapsed" => Property::Duration,
271    "interval" => Property::Interval,
272    "note" => Property::Note,
273    "text" => Property::Text,
274    "time" => Property::Time,
275    "title" => Property::Title,
276    _ => Property::Tag(name.to_string()),
277  }
278}
279
280/// Strip surrounding double quotes from a string.
281fn strip_quotes(s: &str) -> &str {
282  s.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(s)
283}
284
285/// Case-insensitive wildcard match. `*` matches zero or more chars, `?` matches one.
286fn wildcard_match(text: &str, pattern: &str) -> bool {
287  if !pattern.contains('*') && !pattern.contains('?') {
288    return text == pattern;
289  }
290
291  let mut rx = String::from("(?i)^");
292  for ch in pattern.chars() {
293    match ch {
294      '*' => rx.push_str(".*"),
295      '?' => rx.push('.'),
296      _ => {
297        for escaped in regex::escape(&ch.to_string()).chars() {
298          rx.push(escaped);
299        }
300      }
301    }
302  }
303  rx.push('$');
304
305  Regex::new(&rx).is_ok_and(|r| r.is_match(text))
306}
307
308#[cfg(test)]
309mod test {
310  use chrono::{Duration, TimeZone};
311  use doing_taskpaper::{Note, Tag, Tags};
312
313  use super::*;
314
315  fn entry_with_tag(name: &str, value: Option<&str>) -> Entry {
316    Entry::new(
317      sample_date(),
318      "Working on project",
319      Tags::from_iter(vec![Tag::new(name, value)]),
320      Note::from_str("Some notes here"),
321      "Currently",
322      None::<String>,
323    )
324  }
325
326  fn finished_entry() -> Entry {
327    Entry::new(
328      sample_date(),
329      "Finished task",
330      Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 16:00"))]),
331      Note::from_str("Task notes"),
332      "Currently",
333      None::<String>,
334    )
335  }
336
337  fn sample_date() -> DateTime<Local> {
338    Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
339  }
340
341  mod compare_ord {
342    use super::super::*;
343
344    #[test]
345    fn it_compares_equal() {
346      assert!(compare_ord(5, 5, ComparisonOp::Equal));
347      assert!(!compare_ord(5, 6, ComparisonOp::Equal));
348    }
349
350    #[test]
351    fn it_compares_greater_than() {
352      assert!(compare_ord(6, 5, ComparisonOp::GreaterThan));
353      assert!(!compare_ord(5, 5, ComparisonOp::GreaterThan));
354    }
355
356    #[test]
357    fn it_compares_greater_than_or_equal() {
358      assert!(compare_ord(5, 5, ComparisonOp::GreaterThanOrEqual));
359      assert!(compare_ord(6, 5, ComparisonOp::GreaterThanOrEqual));
360      assert!(!compare_ord(4, 5, ComparisonOp::GreaterThanOrEqual));
361    }
362
363    #[test]
364    fn it_compares_less_than() {
365      assert!(compare_ord(4, 5, ComparisonOp::LessThan));
366      assert!(!compare_ord(5, 5, ComparisonOp::LessThan));
367    }
368
369    #[test]
370    fn it_compares_less_than_or_equal() {
371      assert!(compare_ord(5, 5, ComparisonOp::LessThanOrEqual));
372      assert!(compare_ord(4, 5, ComparisonOp::LessThanOrEqual));
373      assert!(!compare_ord(6, 5, ComparisonOp::LessThanOrEqual));
374    }
375  }
376
377  mod is_string_op {
378    use super::super::*;
379
380    #[test]
381    fn it_returns_true_for_string_operators() {
382      assert!(is_string_op(ComparisonOp::Contains));
383      assert!(is_string_op(ComparisonOp::StartsWith));
384      assert!(is_string_op(ComparisonOp::EndsWith));
385    }
386
387    #[test]
388    fn it_returns_false_for_non_string_operators() {
389      assert!(!is_string_op(ComparisonOp::Equal));
390      assert!(!is_string_op(ComparisonOp::GreaterThan));
391      assert!(!is_string_op(ComparisonOp::LessThan));
392    }
393  }
394
395  mod matches_entry {
396    use super::*;
397
398    mod date_property {
399      use super::*;
400
401      #[test]
402      fn it_matches_date_greater_than() {
403        let entry = finished_entry();
404        let query = TagQuery::parse("date > 2024-03-16").unwrap();
405
406        assert!(query.matches_entry(&entry));
407      }
408
409      #[test]
410      fn it_rejects_date_less_than() {
411        let entry = finished_entry();
412        let query = TagQuery::parse("date < 2024-03-16").unwrap();
413
414        assert!(!query.matches_entry(&entry));
415      }
416    }
417
418    mod duration_property {
419      use super::*;
420
421      #[test]
422      fn it_matches_unfinished_entry_duration() {
423        let entry = Entry::new(
424          Local::now() - Duration::hours(3),
425          "Active task",
426          Tags::new(),
427          Note::new(),
428          "Currently",
429          None::<String>,
430        );
431        let query = TagQuery::parse("duration > 2h").unwrap();
432
433        assert!(query.matches_entry(&entry));
434      }
435
436      #[test]
437      fn it_returns_false_for_finished_entry() {
438        let entry = finished_entry();
439        let query = TagQuery::parse("duration > 1h").unwrap();
440
441        assert!(!query.matches_entry(&entry));
442      }
443    }
444
445    mod interval_property {
446      use super::*;
447
448      #[test]
449      fn it_matches_interval_greater_than() {
450        let entry = finished_entry();
451        let query = TagQuery::parse("interval > 30m").unwrap();
452
453        assert!(query.matches_entry(&entry));
454      }
455
456      #[test]
457      fn it_rejects_interval_too_large() {
458        let entry = finished_entry();
459        let query = TagQuery::parse("interval > 3h").unwrap();
460
461        assert!(!query.matches_entry(&entry));
462      }
463
464      #[test]
465      fn it_returns_false_for_unfinished_entry() {
466        let entry = Entry::new(
467          sample_date(),
468          "Active",
469          Tags::new(),
470          Note::new(),
471          "Currently",
472          None::<String>,
473        );
474        let query = TagQuery::parse("interval > 1h").unwrap();
475
476        assert!(!query.matches_entry(&entry));
477      }
478    }
479
480    mod negation {
481      use super::*;
482
483      #[test]
484      fn it_negates_with_exclamation_prefix() {
485        let entry = entry_with_tag("progress", Some("80"));
486        let query = TagQuery::parse("!progress > 50").unwrap();
487
488        assert!(!query.matches_entry(&entry));
489      }
490
491      #[test]
492      fn it_negates_with_not_equal_operator() {
493        let entry = entry_with_tag("status", Some("active"));
494        let query = TagQuery::parse("status != active").unwrap();
495
496        assert!(!query.matches_entry(&entry));
497      }
498    }
499
500    mod note_property {
501      use super::*;
502
503      #[test]
504      fn it_matches_note_contains() {
505        let entry = finished_entry();
506        let query = TagQuery::parse("note *= notes").unwrap();
507
508        assert!(query.matches_entry(&entry));
509      }
510
511      #[test]
512      fn it_rejects_note_not_found() {
513        let entry = finished_entry();
514        let query = TagQuery::parse("note *= missing").unwrap();
515
516        assert!(!query.matches_entry(&entry));
517      }
518    }
519
520    mod numeric_tag {
521      use super::*;
522
523      #[test]
524      fn it_compares_numeric_tag_value() {
525        let entry = entry_with_tag("progress", Some("75"));
526        let query = TagQuery::parse("progress >= 50").unwrap();
527
528        assert!(query.matches_entry(&entry));
529      }
530
531      #[test]
532      fn it_handles_percentage_values() {
533        let entry = entry_with_tag("progress", Some("75%"));
534        let query = TagQuery::parse("progress >= 50").unwrap();
535
536        assert!(query.matches_entry(&entry));
537      }
538
539      #[test]
540      fn it_rejects_below_threshold() {
541        let entry = entry_with_tag("progress", Some("25"));
542        let query = TagQuery::parse("progress >= 50").unwrap();
543
544        assert!(!query.matches_entry(&entry));
545      }
546    }
547
548    mod string_tag {
549      use super::*;
550
551      #[test]
552      fn it_matches_contains() {
553        let entry = entry_with_tag("project", Some("my-project"));
554        let query = TagQuery::parse("project *= project").unwrap();
555
556        assert!(query.matches_entry(&entry));
557      }
558
559      #[test]
560      fn it_matches_ends_with() {
561        let entry = entry_with_tag("project", Some("my-project"));
562        let query = TagQuery::parse("project $= project").unwrap();
563
564        assert!(query.matches_entry(&entry));
565      }
566
567      #[test]
568      fn it_matches_exact_with_wildcard() {
569        let entry = entry_with_tag("project", Some("my-project"));
570        let query = TagQuery::parse("project == my-*").unwrap();
571
572        assert!(query.matches_entry(&entry));
573      }
574
575      #[test]
576      fn it_matches_starts_with() {
577        let entry = entry_with_tag("project", Some("my-project"));
578        let query = TagQuery::parse("project ^= my").unwrap();
579
580        assert!(query.matches_entry(&entry));
581      }
582
583      #[test]
584      fn it_strips_quotes_from_value() {
585        let entry = entry_with_tag("project", Some("my-project"));
586        let query = TagQuery::parse(r#"project == "my-project""#).unwrap();
587
588        assert!(query.matches_entry(&entry));
589      }
590    }
591
592    mod tag_missing {
593      use super::*;
594
595      #[test]
596      fn it_returns_false_for_missing_tag() {
597        let entry = Entry::new(
598          sample_date(),
599          "No tags",
600          Tags::new(),
601          Note::new(),
602          "Currently",
603          None::<String>,
604        );
605        let query = TagQuery::parse("progress > 50").unwrap();
606
607        assert!(!query.matches_entry(&entry));
608      }
609    }
610
611    mod text_property {
612      use super::*;
613
614      #[test]
615      fn it_searches_title_and_note() {
616        let entry = finished_entry();
617        let query = TagQuery::parse("text *= Finished").unwrap();
618
619        assert!(query.matches_entry(&entry));
620      }
621
622      #[test]
623      fn it_searches_note_content() {
624        let entry = finished_entry();
625        let query = TagQuery::parse("text *= notes").unwrap();
626
627        assert!(query.matches_entry(&entry));
628      }
629    }
630
631    mod time_property {
632      use chrono::TimeZone;
633
634      use super::*;
635
636      #[test]
637      fn it_compares_time_of_day() {
638        let entry = Entry::new(
639          Local.with_ymd_and_hms(2024, 3, 17, 10, 0, 0).unwrap(),
640          "Morning task",
641          Tags::new(),
642          Note::new(),
643          "Currently",
644          None::<String>,
645        );
646        let query = TagQuery::parse("time < 2024-03-17 12:00").unwrap();
647
648        assert!(query.matches_entry(&entry));
649      }
650    }
651
652    mod title_property {
653      use super::*;
654
655      #[test]
656      fn it_matches_title_contains() {
657        let entry = finished_entry();
658        let query = TagQuery::parse("title *= Finished").unwrap();
659
660        assert!(query.matches_entry(&entry));
661      }
662
663      #[test]
664      fn it_matches_title_starts_with() {
665        let entry = finished_entry();
666        let query = TagQuery::parse("title ^= Fin").unwrap();
667
668        assert!(query.matches_entry(&entry));
669      }
670    }
671  }
672
673  mod parse {
674    use pretty_assertions::assert_eq;
675
676    use super::*;
677
678    #[test]
679    fn it_parses_basic_query() {
680      let query = TagQuery::parse("progress > 50").unwrap();
681
682      assert_eq!(query.property, Property::Tag("progress".into()));
683      assert_eq!(query.op, ComparisonOp::GreaterThan);
684      assert_eq!(query.value, "50");
685      assert!(!query.negated);
686    }
687
688    #[test]
689    fn it_parses_contains_operator() {
690      let query = TagQuery::parse("title *= text").unwrap();
691
692      assert_eq!(query.property, Property::Title);
693      assert_eq!(query.op, ComparisonOp::Contains);
694    }
695
696    #[test]
697    fn it_parses_ends_with_operator() {
698      let query = TagQuery::parse("title $= suffix").unwrap();
699
700      assert_eq!(query.property, Property::Title);
701      assert_eq!(query.op, ComparisonOp::EndsWith);
702    }
703
704    #[test]
705    fn it_parses_equal_operator() {
706      let query = TagQuery::parse("status == active").unwrap();
707
708      assert_eq!(query.op, ComparisonOp::Equal);
709    }
710
711    #[test]
712    fn it_parses_greater_than_or_equal() {
713      let query = TagQuery::parse("progress >= 75").unwrap();
714
715      assert_eq!(query.op, ComparisonOp::GreaterThanOrEqual);
716    }
717
718    #[test]
719    fn it_parses_less_than_or_equal() {
720      let query = TagQuery::parse("progress <= 100").unwrap();
721
722      assert_eq!(query.op, ComparisonOp::LessThanOrEqual);
723    }
724
725    #[test]
726    fn it_parses_negated_query() {
727      let query = TagQuery::parse("!status == active").unwrap();
728
729      assert!(query.negated);
730    }
731
732    #[test]
733    fn it_parses_not_equal_operator() {
734      let query = TagQuery::parse("status != blocked").unwrap();
735
736      assert_eq!(query.op, ComparisonOp::Equal);
737      assert!(query.negated);
738    }
739
740    #[test]
741    fn it_parses_single_equal_sign() {
742      let query = TagQuery::parse("status = active").unwrap();
743
744      assert_eq!(query.op, ComparisonOp::Equal);
745    }
746
747    #[test]
748    fn it_parses_starts_with_operator() {
749      let query = TagQuery::parse("title ^= prefix").unwrap();
750
751      assert_eq!(query.property, Property::Title);
752      assert_eq!(query.op, ComparisonOp::StartsWith);
753    }
754
755    #[test]
756    fn it_parses_with_at_prefix() {
757      let query = TagQuery::parse("@done > yesterday").unwrap();
758
759      assert_eq!(query.property, Property::Tag("done".into()));
760    }
761
762    #[test]
763    fn it_returns_none_for_invalid_input() {
764      assert!(TagQuery::parse("not a query").is_none());
765      assert!(TagQuery::parse("").is_none());
766    }
767  }
768
769  mod parse_numeric {
770    use pretty_assertions::assert_eq;
771
772    use super::super::parse_numeric;
773
774    #[test]
775    fn it_parses_float() {
776      assert_eq!(parse_numeric("3.14"), Some(3.14));
777    }
778
779    #[test]
780    fn it_parses_integer() {
781      assert_eq!(parse_numeric("42"), Some(42.0));
782    }
783
784    #[test]
785    fn it_returns_none_for_non_numeric() {
786      assert!(parse_numeric("abc").is_none());
787    }
788
789    #[test]
790    fn it_strips_percentage() {
791      assert_eq!(parse_numeric("75%"), Some(75.0));
792    }
793  }
794
795  mod parse_operator {
796    use pretty_assertions::assert_eq;
797
798    use super::{super::parse_operator, *};
799
800    #[test]
801    fn it_parses_all_operators() {
802      assert_eq!(parse_operator("<"), Some((ComparisonOp::LessThan, false)));
803      assert_eq!(parse_operator("<="), Some((ComparisonOp::LessThanOrEqual, false)));
804      assert_eq!(parse_operator(">"), Some((ComparisonOp::GreaterThan, false)));
805      assert_eq!(parse_operator(">="), Some((ComparisonOp::GreaterThanOrEqual, false)));
806      assert_eq!(parse_operator("="), Some((ComparisonOp::Equal, false)));
807      assert_eq!(parse_operator("=="), Some((ComparisonOp::Equal, false)));
808      assert_eq!(parse_operator("!="), Some((ComparisonOp::Equal, true)));
809      assert_eq!(parse_operator("*="), Some((ComparisonOp::Contains, false)));
810      assert_eq!(parse_operator("^="), Some((ComparisonOp::StartsWith, false)));
811      assert_eq!(parse_operator("$="), Some((ComparisonOp::EndsWith, false)));
812    }
813
814    #[test]
815    fn it_returns_none_for_invalid() {
816      assert!(parse_operator("??").is_none());
817    }
818  }
819
820  mod parse_property {
821    use pretty_assertions::assert_eq;
822
823    use super::{super::parse_property, *};
824
825    #[test]
826    fn it_parses_virtual_properties() {
827      assert_eq!(parse_property("date"), Property::Date);
828      assert_eq!(parse_property("duration"), Property::Duration);
829      assert_eq!(parse_property("elapsed"), Property::Duration);
830      assert_eq!(parse_property("interval"), Property::Interval);
831      assert_eq!(parse_property("note"), Property::Note);
832      assert_eq!(parse_property("text"), Property::Text);
833      assert_eq!(parse_property("time"), Property::Time);
834      assert_eq!(parse_property("title"), Property::Title);
835    }
836
837    #[test]
838    fn it_parses_virtual_properties_case_insensitively() {
839      assert_eq!(parse_property("Date"), Property::Date);
840      assert_eq!(parse_property("TITLE"), Property::Title);
841    }
842
843    #[test]
844    fn it_treats_unknown_as_tag() {
845      assert_eq!(parse_property("project"), Property::Tag("project".into()));
846      assert_eq!(parse_property("custom"), Property::Tag("custom".into()));
847    }
848  }
849
850  mod strip_quotes {
851    use pretty_assertions::assert_eq;
852
853    use super::super::strip_quotes;
854
855    #[test]
856    fn it_returns_unquoted_string_unchanged() {
857      assert_eq!(strip_quotes("hello"), "hello");
858    }
859
860    #[test]
861    fn it_strips_surrounding_double_quotes() {
862      assert_eq!(strip_quotes("\"hello\""), "hello");
863    }
864  }
865
866  mod wildcard_match {
867    use super::super::wildcard_match;
868
869    #[test]
870    fn it_matches_exact_string() {
871      assert!(wildcard_match("hello", "hello"));
872      assert!(!wildcard_match("hello", "world"));
873    }
874
875    #[test]
876    fn it_matches_question_mark_wildcard() {
877      assert!(wildcard_match("hello", "hell?"));
878      assert!(!wildcard_match("hello", "hel?"));
879    }
880
881    #[test]
882    fn it_matches_star_wildcard() {
883      assert!(wildcard_match("my-project", "my-*"));
884      assert!(wildcard_match("my-project", "*project"));
885      assert!(wildcard_match("my-project", "*"));
886    }
887  }
888}