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