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