Skip to main content

doing_ops/
tag_query.rs

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