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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum ComparisonOp {
19 Contains,
21 EndsWith,
23 Equal,
25 GreaterThan,
27 GreaterThanOrEqual,
29 LessThan,
31 LessThanOrEqual,
33 StartsWith,
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
39pub enum Property {
40 Date,
42 Duration,
44 Interval,
46 Note,
48 Tag(String),
50 Text,
52 Time,
54 Title,
56}
57
58#[derive(Clone, Debug)]
62pub struct TagQuery {
63 negated: bool,
64 op: ComparisonOp,
65 property: Property,
66 value: String,
67}
68
69impl TagQuery {
70 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 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 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 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 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 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 self.compare_string(&tag_value, &self.value)
231 }
232}
233
234fn 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
247fn 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
265fn is_string_op(op: ComparisonOp) -> bool {
267 matches!(
268 op,
269 ComparisonOp::Contains | ComparisonOp::StartsWith | ComparisonOp::EndsWith
270 )
271}
272
273fn parse_numeric(s: &str) -> Option<f64> {
275 let s = s.trim().trim_end_matches('%').trim();
276 s.parse::<f64>().ok()
277}
278
279fn 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
299fn 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
314fn strip_quotes(s: &str) -> &str {
316 s.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(s)
317}
318
319fn 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(®ex::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}