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