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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum ComparisonOp {
14 Contains,
16 EndsWith,
18 Equal,
20 GreaterThan,
22 GreaterThanOrEqual,
24 LessThan,
26 LessThanOrEqual,
28 StartsWith,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
34pub enum Property {
35 Date,
37 Duration,
39 Interval,
41 Note,
43 Tag(String),
45 Text,
47 Time,
49 Title,
51}
52
53#[derive(Clone, Debug)]
57pub struct TagQuery {
58 negated: bool,
59 op: ComparisonOp,
60 property: Property,
61 value: String,
62}
63
64impl TagQuery {
65 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 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 compare_ord(OrdF64(entry_val), OrdF64(target_val), self.op)
131 }
132
133 fn compare_string(&self, haystack: &str, needle: &str) -> bool {
134 let needle = strip_quotes(needle);
135 let h = haystack.to_lowercase();
136 let n = needle.to_lowercase();
137
138 match self.op {
139 ComparisonOp::Contains => h.contains(&n),
140 ComparisonOp::EndsWith => h.ends_with(&n),
141 ComparisonOp::Equal => wildcard_match(&h, &n),
142 ComparisonOp::StartsWith => h.starts_with(&n),
143 ComparisonOp::GreaterThan => h.as_str() > n.as_str(),
144 ComparisonOp::GreaterThanOrEqual => h.as_str() >= n.as_str(),
145 ComparisonOp::LessThan => (h.as_str()) < n.as_str(),
146 ComparisonOp::LessThanOrEqual => h.as_str() <= n.as_str(),
147 }
148 }
149
150 fn compare_time(&self, entry: &Entry) -> bool {
151 if is_string_op(self.op) {
152 return false;
153 }
154 let Ok(target) = chronify(&self.value) else {
156 return false;
157 };
158 let entry_time = entry.date().time();
159 let target_time = target.time();
160 compare_ord(entry_time, target_time, self.op)
161 }
162
163 fn evaluate(&self, entry: &Entry) -> bool {
164 match &self.property {
165 Property::Date => self.compare_date(entry.date(), &self.value),
166 Property::Duration => self.compare_duration(entry, false),
167 Property::Interval => self.compare_duration(entry, true),
168 Property::Note => self.compare_string(&entry.note().to_line(" "), &self.value),
169 Property::Tag(name) => self.evaluate_tag(entry, name),
170 Property::Text => {
171 let text = format!("{} {}", entry.title(), entry.note().to_line(" "));
172 self.compare_string(&text, &self.value)
173 }
174 Property::Time => self.compare_time(entry),
175 Property::Title => self.compare_string(entry.title(), &self.value),
176 }
177 }
178
179 fn evaluate_tag(&self, entry: &Entry, tag_name: &str) -> bool {
180 let tag_value = match entry
181 .tags()
182 .iter()
183 .find(|t| t.name().eq_ignore_ascii_case(tag_name))
184 .and_then(|t| t.value().map(String::from))
185 {
186 Some(v) => v,
187 None => return false,
188 };
189
190 if is_string_op(self.op) {
191 return self.compare_string(&tag_value, &self.value);
192 }
193
194 let entry_num = parse_numeric(&tag_value);
196 let target_num = parse_numeric(&self.value);
197 if let (Some(e), Some(t)) = (entry_num, target_num) {
198 return self.compare_numeric(e, t);
199 }
200
201 if let (Ok(entry_dt), Ok(target_dt)) = (chronify(&tag_value), chronify(&self.value)) {
203 return compare_ord(entry_dt, target_dt, self.op);
204 }
205
206 if let (Ok(entry_dur), Ok(target_dur)) = (parse_duration(&tag_value), parse_duration(&self.value)) {
208 return compare_ord(entry_dur.num_seconds(), target_dur.num_seconds(), self.op);
209 }
210
211 self.compare_string(&tag_value, &self.value)
213 }
214}
215
216#[derive(Debug, PartialEq)]
218struct OrdF64(f64);
219
220impl PartialOrd for OrdF64 {
221 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
222 self.0.partial_cmp(&other.0)
223 }
224}
225
226fn 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
240fn is_string_op(op: ComparisonOp) -> bool {
242 matches!(
243 op,
244 ComparisonOp::Contains | ComparisonOp::StartsWith | ComparisonOp::EndsWith
245 )
246}
247
248fn parse_numeric(s: &str) -> Option<f64> {
250 let s = s.trim().trim_end_matches('%').trim();
251 s.parse::<f64>().ok()
252}
253
254fn 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
274fn 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
289fn strip_quotes(s: &str) -> &str {
291 s.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(s)
292}
293
294fn 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}