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