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 => a == b,
226  }
227}
228
229/// Check whether an operator is a string-specific operator.
230fn is_string_op(op: ComparisonOp) -> bool {
231  matches!(
232    op,
233    ComparisonOp::Contains | ComparisonOp::StartsWith | ComparisonOp::EndsWith
234  )
235}
236
237/// Parse a numeric value, stripping trailing `%`.
238fn parse_numeric(s: &str) -> Option<f64> {
239  let s = s.trim().trim_end_matches('%').trim();
240  s.parse::<f64>().ok()
241}
242
243/// Parse an operator string into a `ComparisonOp` and negation flag.
244fn parse_operator(raw: &str) -> Option<(ComparisonOp, bool)> {
245  match raw {
246    "<" => Some((ComparisonOp::LessThan, false)),
247    "<=" => Some((ComparisonOp::LessThanOrEqual, false)),
248    ">" => Some((ComparisonOp::GreaterThan, false)),
249    ">=" => Some((ComparisonOp::GreaterThanOrEqual, false)),
250    "=" | "==" => Some((ComparisonOp::Equal, false)),
251    "!=" => Some((ComparisonOp::Equal, true)),
252    "*=" => Some((ComparisonOp::Contains, false)),
253    "^=" => Some((ComparisonOp::StartsWith, false)),
254    "$=" => Some((ComparisonOp::EndsWith, false)),
255    "!<" => Some((ComparisonOp::LessThan, true)),
256    "!<=" => Some((ComparisonOp::LessThanOrEqual, true)),
257    "!>" => Some((ComparisonOp::GreaterThan, true)),
258    "!>=" => Some((ComparisonOp::GreaterThanOrEqual, true)),
259    _ => None,
260  }
261}
262
263/// Parse a property name into a `Property` enum.
264fn parse_property(name: &str) -> Property {
265  match name.to_lowercase().as_str() {
266    "date" => Property::Date,
267    "duration" => Property::Duration,
268    "elapsed" => Property::Duration,
269    "interval" => Property::Interval,
270    "note" => Property::Note,
271    "text" => Property::Text,
272    "time" => Property::Time,
273    "title" => Property::Title,
274    _ => Property::Tag(name.to_string()),
275  }
276}
277
278/// Strip surrounding double quotes from a string.
279fn strip_quotes(s: &str) -> &str {
280  s.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(s)
281}
282
283/// Case-insensitive wildcard match. `*` matches zero or more chars, `?` matches one.
284fn wildcard_match(text: &str, pattern: &str) -> bool {
285  if !pattern.contains('*') && !pattern.contains('?') {
286    return text == pattern;
287  }
288
289  let mut rx = String::from("(?i)^");
290  for ch in pattern.chars() {
291    match ch {
292      '*' => rx.push_str(".*"),
293      '?' => rx.push('.'),
294      _ => {
295        for escaped in regex::escape(&ch.to_string()).chars() {
296          rx.push(escaped);
297        }
298      }
299    }
300  }
301  rx.push('$');
302
303  Regex::new(&rx).is_ok_and(|r| r.is_match(text))
304}
305
306#[cfg(test)]
307mod test {
308  use chrono::{Duration, TimeZone};
309  use doing_taskpaper::{Note, Tag, Tags};
310
311  use super::*;
312
313  fn entry_with_tag(name: &str, value: Option<&str>) -> Entry {
314    Entry::new(
315      sample_date(),
316      "Working on project",
317      Tags::from_iter(vec![Tag::new(name, value)]),
318      Note::from_str("Some notes here"),
319      "Currently",
320      None::<String>,
321    )
322  }
323
324  fn finished_entry() -> Entry {
325    Entry::new(
326      sample_date(),
327      "Finished task",
328      Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 16:00"))]),
329      Note::from_str("Task notes"),
330      "Currently",
331      None::<String>,
332    )
333  }
334
335  fn sample_date() -> DateTime<Local> {
336    Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
337  }
338
339  mod compare_ord {
340    use super::super::*;
341
342    #[test]
343    fn it_compares_equal() {
344      assert!(compare_ord(5, 5, ComparisonOp::Equal));
345      assert!(!compare_ord(5, 6, ComparisonOp::Equal));
346    }
347
348    #[test]
349    fn it_compares_greater_than() {
350      assert!(compare_ord(6, 5, ComparisonOp::GreaterThan));
351      assert!(!compare_ord(5, 5, ComparisonOp::GreaterThan));
352    }
353
354    #[test]
355    fn it_compares_greater_than_or_equal() {
356      assert!(compare_ord(5, 5, ComparisonOp::GreaterThanOrEqual));
357      assert!(compare_ord(6, 5, ComparisonOp::GreaterThanOrEqual));
358      assert!(!compare_ord(4, 5, ComparisonOp::GreaterThanOrEqual));
359    }
360
361    #[test]
362    fn it_compares_less_than() {
363      assert!(compare_ord(4, 5, ComparisonOp::LessThan));
364      assert!(!compare_ord(5, 5, ComparisonOp::LessThan));
365    }
366
367    #[test]
368    fn it_compares_less_than_or_equal() {
369      assert!(compare_ord(5, 5, ComparisonOp::LessThanOrEqual));
370      assert!(compare_ord(4, 5, ComparisonOp::LessThanOrEqual));
371      assert!(!compare_ord(6, 5, ComparisonOp::LessThanOrEqual));
372    }
373  }
374
375  mod is_string_op {
376    use super::super::*;
377
378    #[test]
379    fn it_returns_true_for_string_operators() {
380      assert!(is_string_op(ComparisonOp::Contains));
381      assert!(is_string_op(ComparisonOp::StartsWith));
382      assert!(is_string_op(ComparisonOp::EndsWith));
383    }
384
385    #[test]
386    fn it_returns_false_for_non_string_operators() {
387      assert!(!is_string_op(ComparisonOp::Equal));
388      assert!(!is_string_op(ComparisonOp::GreaterThan));
389      assert!(!is_string_op(ComparisonOp::LessThan));
390    }
391  }
392
393  mod matches_entry {
394    use super::*;
395
396    mod date_property {
397      use super::*;
398
399      #[test]
400      fn it_matches_date_greater_than() {
401        let entry = finished_entry();
402        let query = TagQuery::parse("date > 2024-03-16").unwrap();
403
404        assert!(query.matches_entry(&entry));
405      }
406
407      #[test]
408      fn it_rejects_date_less_than() {
409        let entry = finished_entry();
410        let query = TagQuery::parse("date < 2024-03-16").unwrap();
411
412        assert!(!query.matches_entry(&entry));
413      }
414    }
415
416    mod duration_property {
417      use super::*;
418
419      #[test]
420      fn it_matches_unfinished_entry_duration() {
421        let entry = Entry::new(
422          Local::now() - Duration::hours(3),
423          "Active task",
424          Tags::new(),
425          Note::new(),
426          "Currently",
427          None::<String>,
428        );
429        let query = TagQuery::parse("duration > 2h").unwrap();
430
431        assert!(query.matches_entry(&entry));
432      }
433
434      #[test]
435      fn it_returns_false_for_finished_entry() {
436        let entry = finished_entry();
437        let query = TagQuery::parse("duration > 1h").unwrap();
438
439        assert!(!query.matches_entry(&entry));
440      }
441    }
442
443    mod interval_property {
444      use super::*;
445
446      #[test]
447      fn it_matches_interval_greater_than() {
448        let entry = finished_entry();
449        let query = TagQuery::parse("interval > 30m").unwrap();
450
451        assert!(query.matches_entry(&entry));
452      }
453
454      #[test]
455      fn it_rejects_interval_too_large() {
456        let entry = finished_entry();
457        let query = TagQuery::parse("interval > 3h").unwrap();
458
459        assert!(!query.matches_entry(&entry));
460      }
461
462      #[test]
463      fn it_returns_false_for_unfinished_entry() {
464        let entry = Entry::new(
465          sample_date(),
466          "Active",
467          Tags::new(),
468          Note::new(),
469          "Currently",
470          None::<String>,
471        );
472        let query = TagQuery::parse("interval > 1h").unwrap();
473
474        assert!(!query.matches_entry(&entry));
475      }
476    }
477
478    mod negation {
479      use super::*;
480
481      #[test]
482      fn it_negates_with_exclamation_prefix() {
483        let entry = entry_with_tag("progress", Some("80"));
484        let query = TagQuery::parse("!progress > 50").unwrap();
485
486        assert!(!query.matches_entry(&entry));
487      }
488
489      #[test]
490      fn it_negates_with_not_equal_operator() {
491        let entry = entry_with_tag("status", Some("active"));
492        let query = TagQuery::parse("status != active").unwrap();
493
494        assert!(!query.matches_entry(&entry));
495      }
496    }
497
498    mod note_property {
499      use super::*;
500
501      #[test]
502      fn it_matches_note_contains() {
503        let entry = finished_entry();
504        let query = TagQuery::parse("note *= notes").unwrap();
505
506        assert!(query.matches_entry(&entry));
507      }
508
509      #[test]
510      fn it_rejects_note_not_found() {
511        let entry = finished_entry();
512        let query = TagQuery::parse("note *= missing").unwrap();
513
514        assert!(!query.matches_entry(&entry));
515      }
516    }
517
518    mod numeric_tag {
519      use super::*;
520
521      #[test]
522      fn it_compares_numeric_tag_value() {
523        let entry = entry_with_tag("progress", Some("75"));
524        let query = TagQuery::parse("progress >= 50").unwrap();
525
526        assert!(query.matches_entry(&entry));
527      }
528
529      #[test]
530      fn it_handles_percentage_values() {
531        let entry = entry_with_tag("progress", Some("75%"));
532        let query = TagQuery::parse("progress >= 50").unwrap();
533
534        assert!(query.matches_entry(&entry));
535      }
536
537      #[test]
538      fn it_rejects_below_threshold() {
539        let entry = entry_with_tag("progress", Some("25"));
540        let query = TagQuery::parse("progress >= 50").unwrap();
541
542        assert!(!query.matches_entry(&entry));
543      }
544    }
545
546    mod string_tag {
547      use super::*;
548
549      #[test]
550      fn it_matches_contains() {
551        let entry = entry_with_tag("project", Some("my-project"));
552        let query = TagQuery::parse("project *= project").unwrap();
553
554        assert!(query.matches_entry(&entry));
555      }
556
557      #[test]
558      fn it_matches_ends_with() {
559        let entry = entry_with_tag("project", Some("my-project"));
560        let query = TagQuery::parse("project $= project").unwrap();
561
562        assert!(query.matches_entry(&entry));
563      }
564
565      #[test]
566      fn it_matches_exact_with_wildcard() {
567        let entry = entry_with_tag("project", Some("my-project"));
568        let query = TagQuery::parse("project == my-*").unwrap();
569
570        assert!(query.matches_entry(&entry));
571      }
572
573      #[test]
574      fn it_matches_starts_with() {
575        let entry = entry_with_tag("project", Some("my-project"));
576        let query = TagQuery::parse("project ^= my").unwrap();
577
578        assert!(query.matches_entry(&entry));
579      }
580
581      #[test]
582      fn it_strips_quotes_from_value() {
583        let entry = entry_with_tag("project", Some("my-project"));
584        let query = TagQuery::parse(r#"project == "my-project""#).unwrap();
585
586        assert!(query.matches_entry(&entry));
587      }
588    }
589
590    mod tag_missing {
591      use super::*;
592
593      #[test]
594      fn it_returns_false_for_missing_tag() {
595        let entry = Entry::new(
596          sample_date(),
597          "No tags",
598          Tags::new(),
599          Note::new(),
600          "Currently",
601          None::<String>,
602        );
603        let query = TagQuery::parse("progress > 50").unwrap();
604
605        assert!(!query.matches_entry(&entry));
606      }
607    }
608
609    mod text_property {
610      use super::*;
611
612      #[test]
613      fn it_searches_title_and_note() {
614        let entry = finished_entry();
615        let query = TagQuery::parse("text *= Finished").unwrap();
616
617        assert!(query.matches_entry(&entry));
618      }
619
620      #[test]
621      fn it_searches_note_content() {
622        let entry = finished_entry();
623        let query = TagQuery::parse("text *= notes").unwrap();
624
625        assert!(query.matches_entry(&entry));
626      }
627    }
628
629    mod time_property {
630      use chrono::TimeZone;
631
632      use super::*;
633
634      #[test]
635      fn it_compares_time_of_day() {
636        let entry = Entry::new(
637          Local.with_ymd_and_hms(2024, 3, 17, 10, 0, 0).unwrap(),
638          "Morning task",
639          Tags::new(),
640          Note::new(),
641          "Currently",
642          None::<String>,
643        );
644        let query = TagQuery::parse("time < 2024-03-17 12:00").unwrap();
645
646        assert!(query.matches_entry(&entry));
647      }
648    }
649
650    mod title_property {
651      use super::*;
652
653      #[test]
654      fn it_matches_title_contains() {
655        let entry = finished_entry();
656        let query = TagQuery::parse("title *= Finished").unwrap();
657
658        assert!(query.matches_entry(&entry));
659      }
660
661      #[test]
662      fn it_matches_title_starts_with() {
663        let entry = finished_entry();
664        let query = TagQuery::parse("title ^= Fin").unwrap();
665
666        assert!(query.matches_entry(&entry));
667      }
668    }
669  }
670
671  mod parse {
672    use pretty_assertions::assert_eq;
673
674    use super::*;
675
676    #[test]
677    fn it_parses_basic_query() {
678      let query = TagQuery::parse("progress > 50").unwrap();
679
680      assert_eq!(query.property, Property::Tag("progress".into()));
681      assert_eq!(query.op, ComparisonOp::GreaterThan);
682      assert_eq!(query.value, "50");
683      assert!(!query.negated);
684    }
685
686    #[test]
687    fn it_parses_contains_operator() {
688      let query = TagQuery::parse("title *= text").unwrap();
689
690      assert_eq!(query.property, Property::Title);
691      assert_eq!(query.op, ComparisonOp::Contains);
692    }
693
694    #[test]
695    fn it_parses_ends_with_operator() {
696      let query = TagQuery::parse("title $= suffix").unwrap();
697
698      assert_eq!(query.property, Property::Title);
699      assert_eq!(query.op, ComparisonOp::EndsWith);
700    }
701
702    #[test]
703    fn it_parses_equal_operator() {
704      let query = TagQuery::parse("status == active").unwrap();
705
706      assert_eq!(query.op, ComparisonOp::Equal);
707    }
708
709    #[test]
710    fn it_parses_greater_than_or_equal() {
711      let query = TagQuery::parse("progress >= 75").unwrap();
712
713      assert_eq!(query.op, ComparisonOp::GreaterThanOrEqual);
714    }
715
716    #[test]
717    fn it_parses_less_than_or_equal() {
718      let query = TagQuery::parse("progress <= 100").unwrap();
719
720      assert_eq!(query.op, ComparisonOp::LessThanOrEqual);
721    }
722
723    #[test]
724    fn it_parses_negated_query() {
725      let query = TagQuery::parse("!status == active").unwrap();
726
727      assert!(query.negated);
728    }
729
730    #[test]
731    fn it_parses_not_equal_operator() {
732      let query = TagQuery::parse("status != blocked").unwrap();
733
734      assert_eq!(query.op, ComparisonOp::Equal);
735      assert!(query.negated);
736    }
737
738    #[test]
739    fn it_parses_single_equal_sign() {
740      let query = TagQuery::parse("status = active").unwrap();
741
742      assert_eq!(query.op, ComparisonOp::Equal);
743    }
744
745    #[test]
746    fn it_parses_starts_with_operator() {
747      let query = TagQuery::parse("title ^= prefix").unwrap();
748
749      assert_eq!(query.property, Property::Title);
750      assert_eq!(query.op, ComparisonOp::StartsWith);
751    }
752
753    #[test]
754    fn it_parses_with_at_prefix() {
755      let query = TagQuery::parse("@done > yesterday").unwrap();
756
757      assert_eq!(query.property, Property::Tag("done".into()));
758    }
759
760    #[test]
761    fn it_returns_none_for_invalid_input() {
762      assert!(TagQuery::parse("not a query").is_none());
763      assert!(TagQuery::parse("").is_none());
764    }
765  }
766
767  mod parse_numeric {
768    use pretty_assertions::assert_eq;
769
770    use super::super::parse_numeric;
771
772    #[test]
773    fn it_parses_float() {
774      assert_eq!(parse_numeric("3.14"), Some(3.14));
775    }
776
777    #[test]
778    fn it_parses_integer() {
779      assert_eq!(parse_numeric("42"), Some(42.0));
780    }
781
782    #[test]
783    fn it_returns_none_for_non_numeric() {
784      assert!(parse_numeric("abc").is_none());
785    }
786
787    #[test]
788    fn it_strips_percentage() {
789      assert_eq!(parse_numeric("75%"), Some(75.0));
790    }
791  }
792
793  mod parse_operator {
794    use pretty_assertions::assert_eq;
795
796    use super::{super::parse_operator, *};
797
798    #[test]
799    fn it_parses_all_operators() {
800      assert_eq!(parse_operator("<"), Some((ComparisonOp::LessThan, false)));
801      assert_eq!(parse_operator("<="), Some((ComparisonOp::LessThanOrEqual, false)));
802      assert_eq!(parse_operator(">"), Some((ComparisonOp::GreaterThan, false)));
803      assert_eq!(parse_operator(">="), Some((ComparisonOp::GreaterThanOrEqual, false)));
804      assert_eq!(parse_operator("="), Some((ComparisonOp::Equal, false)));
805      assert_eq!(parse_operator("=="), Some((ComparisonOp::Equal, false)));
806      assert_eq!(parse_operator("!="), Some((ComparisonOp::Equal, true)));
807      assert_eq!(parse_operator("*="), Some((ComparisonOp::Contains, false)));
808      assert_eq!(parse_operator("^="), Some((ComparisonOp::StartsWith, false)));
809      assert_eq!(parse_operator("$="), Some((ComparisonOp::EndsWith, false)));
810    }
811
812    #[test]
813    fn it_returns_none_for_invalid() {
814      assert!(parse_operator("??").is_none());
815    }
816  }
817
818  mod parse_property {
819    use pretty_assertions::assert_eq;
820
821    use super::{super::parse_property, *};
822
823    #[test]
824    fn it_parses_virtual_properties() {
825      assert_eq!(parse_property("date"), Property::Date);
826      assert_eq!(parse_property("duration"), Property::Duration);
827      assert_eq!(parse_property("elapsed"), Property::Duration);
828      assert_eq!(parse_property("interval"), Property::Interval);
829      assert_eq!(parse_property("note"), Property::Note);
830      assert_eq!(parse_property("text"), Property::Text);
831      assert_eq!(parse_property("time"), Property::Time);
832      assert_eq!(parse_property("title"), Property::Title);
833    }
834
835    #[test]
836    fn it_parses_virtual_properties_case_insensitively() {
837      assert_eq!(parse_property("Date"), Property::Date);
838      assert_eq!(parse_property("TITLE"), Property::Title);
839    }
840
841    #[test]
842    fn it_treats_unknown_as_tag() {
843      assert_eq!(parse_property("project"), Property::Tag("project".into()));
844      assert_eq!(parse_property("custom"), Property::Tag("custom".into()));
845    }
846  }
847
848  mod strip_quotes {
849    use pretty_assertions::assert_eq;
850
851    use super::super::strip_quotes;
852
853    #[test]
854    fn it_returns_unquoted_string_unchanged() {
855      assert_eq!(strip_quotes("hello"), "hello");
856    }
857
858    #[test]
859    fn it_strips_surrounding_double_quotes() {
860      assert_eq!(strip_quotes("\"hello\""), "hello");
861    }
862  }
863
864  mod wildcard_match {
865    use super::super::wildcard_match;
866
867    #[test]
868    fn it_matches_exact_string() {
869      assert!(wildcard_match("hello", "hello"));
870      assert!(!wildcard_match("hello", "world"));
871    }
872
873    #[test]
874    fn it_matches_question_mark_wildcard() {
875      assert!(wildcard_match("hello", "hell?"));
876      assert!(!wildcard_match("hello", "hel?"));
877    }
878
879    #[test]
880    fn it_matches_star_wildcard() {
881      assert!(wildcard_match("my-project", "my-*"));
882      assert!(wildcard_match("my-project", "*project"));
883      assert!(wildcard_match("my-project", "*"));
884    }
885  }
886}