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