1use chrono::{DateTime, Local};
2use doing_config::SortOrder;
3use doing_taskpaper::Entry;
4
5use crate::{
6 search::{self, CaseSensitivity, SearchMode},
7 tag_filter::TagFilter,
8 tag_query::TagQuery,
9};
10
11#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
13pub enum Age {
14 #[default]
16 Newest,
17 Oldest,
19}
20
21pub struct FilterOptions {
26 pub after: Option<DateTime<Local>>,
28 pub age: Option<Age>,
30 pub before: Option<DateTime<Local>>,
32 pub count: Option<usize>,
34 pub include_notes: bool,
36 pub negate: bool,
38 pub only_timed: bool,
40 pub search: Option<(SearchMode, CaseSensitivity)>,
42 pub section: Option<String>,
44 pub sort: Option<SortOrder>,
46 pub tag_filter: Option<TagFilter>,
48 pub tag_queries: Vec<TagQuery>,
50 pub unfinished: bool,
52}
53
54impl Default for FilterOptions {
55 fn default() -> Self {
56 Self {
57 after: None,
58 age: None,
59 before: None,
60 count: None,
61 include_notes: true,
62 negate: false,
63 only_timed: false,
64 search: None,
65 section: None,
66 sort: None,
67 tag_filter: None,
68 tag_queries: Vec::new(),
69 unfinished: false,
70 }
71 }
72}
73
74pub fn filter_entries(mut entries: Vec<Entry>, options: &FilterOptions) -> Vec<Entry> {
81 entries.retain(|entry| {
82 let passed = matches_section(entry, options)
83 && matches_tags(entry, options)
84 && matches_tag_queries(entry, options)
85 && matches_search(entry, options)
86 && matches_date_range(entry, options)
87 && matches_only_timed(entry, options)
88 && matches_unfinished(entry, options);
89 if options.negate { !passed } else { passed }
90 });
91
92 if entries.is_empty() {
93 return entries;
94 }
95
96 apply_sort_and_limit(entries, options)
97}
98
99fn apply_sort_and_limit(mut entries: Vec<Entry>, options: &FilterOptions) -> Vec<Entry> {
101 entries.sort_by_key(|a| a.date());
103
104 if let Some(count) = options.count {
106 let len = entries.len();
107 if count < len {
108 match options.age.unwrap_or_default() {
109 Age::Newest => {
110 entries.drain(..len - count);
111 }
112 Age::Oldest => {
113 entries.truncate(count);
114 }
115 }
116 }
117 }
118
119 entries.sort_by(|a, b| {
121 let ord = a.date().cmp(&b.date());
122 let ord = match options.sort {
123 Some(SortOrder::Desc) => ord.reverse(),
124 _ => ord,
125 };
126 ord.then_with(|| a.title().cmp(b.title()))
127 });
128
129 entries
130}
131
132fn matches_date_range(entry: &Entry, options: &FilterOptions) -> bool {
134 if let Some(after) = options.after
135 && entry.date() < after
136 {
137 return false;
138 }
139 if let Some(before) = options.before
140 && entry.date() > before
141 {
142 return false;
143 }
144 true
145}
146
147fn matches_only_timed(entry: &Entry, options: &FilterOptions) -> bool {
152 if options.only_timed {
153 return entry.interval().is_some_and(|d| d.num_minutes() > 0);
154 }
155 true
156}
157
158fn matches_search(entry: &Entry, options: &FilterOptions) -> bool {
160 if let Some((mode, case)) = &options.search {
161 return search::matches_entry(entry, mode, *case, options.include_notes);
162 }
163 true
164}
165
166fn matches_section(entry: &Entry, options: &FilterOptions) -> bool {
168 if let Some(section) = &options.section
169 && !section.eq_ignore_ascii_case("all")
170 {
171 return entry.section().eq_ignore_ascii_case(section);
172 }
173 true
174}
175
176fn matches_tag_queries(entry: &Entry, options: &FilterOptions) -> bool {
178 options.tag_queries.iter().all(|q| q.matches_entry(entry))
179}
180
181fn matches_tags(entry: &Entry, options: &FilterOptions) -> bool {
183 if let Some(tag_filter) = &options.tag_filter {
184 return tag_filter.matches_entry(entry);
185 }
186 true
187}
188
189fn matches_unfinished(entry: &Entry, options: &FilterOptions) -> bool {
191 if options.unfinished {
192 return entry.unfinished();
193 }
194 true
195}
196
197#[cfg(test)]
198mod test {
199 use chrono::{Local, TimeZone};
200 use doing_taskpaper::{Note, Tag, Tags};
201
202 use super::*;
203 use crate::tag_filter::BooleanMode;
204
205 fn date(year: i32, month: u32, day: u32, hour: u32, min: u32) -> DateTime<Local> {
206 Local.with_ymd_and_hms(year, month, day, hour, min, 0).unwrap()
207 }
208
209 fn done_tags(done_date: &str) -> Tags {
210 Tags::from_iter(vec![Tag::new("done", Some(done_date))])
211 }
212
213 fn make_entry(title: &str, section: &str, date: DateTime<Local>, tags: Tags) -> Entry {
214 Entry::new(date, title, tags, Note::new(), section, None::<String>)
215 }
216
217 fn make_entry_with_note(title: &str, section: &str, date: DateTime<Local>, tags: Tags, note: &str) -> Entry {
218 Entry::new(date, title, tags, Note::from_text(note), section, None::<String>)
219 }
220
221 mod apply_sort_and_limit {
222 use pretty_assertions::assert_eq;
223
224 use super::*;
225
226 #[test]
227 fn it_keeps_newest_entries_by_default() {
228 let entries = vec![
229 make_entry("old", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
230 make_entry("mid", "Currently", date(2024, 1, 2, 10, 0), Tags::new()),
231 make_entry("new", "Currently", date(2024, 1, 3, 10, 0), Tags::new()),
232 ];
233 let options = FilterOptions {
234 count: Some(2),
235 ..Default::default()
236 };
237
238 let result = super::super::apply_sort_and_limit(entries, &options);
239
240 assert_eq!(result.len(), 2);
241 assert_eq!(result[0].title(), "mid");
242 assert_eq!(result[1].title(), "new");
243 }
244
245 #[test]
246 fn it_keeps_oldest_entries_when_age_is_oldest() {
247 let entries = vec![
248 make_entry("old", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
249 make_entry("mid", "Currently", date(2024, 1, 2, 10, 0), Tags::new()),
250 make_entry("new", "Currently", date(2024, 1, 3, 10, 0), Tags::new()),
251 ];
252 let options = FilterOptions {
253 age: Some(Age::Oldest),
254 count: Some(2),
255 ..Default::default()
256 };
257
258 let result = super::super::apply_sort_and_limit(entries, &options);
259
260 assert_eq!(result.len(), 2);
261 assert_eq!(result[0].title(), "old");
262 assert_eq!(result[1].title(), "mid");
263 }
264
265 #[test]
266 fn it_returns_all_when_count_exceeds_length() {
267 let entries = vec![
268 make_entry("one", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
269 make_entry("two", "Currently", date(2024, 1, 2, 10, 0), Tags::new()),
270 ];
271 let options = FilterOptions {
272 count: Some(10),
273 ..Default::default()
274 };
275
276 let result = super::super::apply_sort_and_limit(entries, &options);
277
278 assert_eq!(result.len(), 2);
279 }
280
281 #[test]
282 fn it_sorts_by_title_when_dates_are_equal() {
283 let entries = vec![
284 make_entry("Charlie", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
285 make_entry("Alpha", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
286 make_entry("Bravo", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
287 ];
288 let options = FilterOptions::default();
289
290 let result = super::super::apply_sort_and_limit(entries, &options);
291
292 assert_eq!(result[0].title(), "Alpha");
293 assert_eq!(result[1].title(), "Bravo");
294 assert_eq!(result[2].title(), "Charlie");
295 }
296
297 #[test]
298 fn it_sorts_descending_when_specified() {
299 let entries = vec![
300 make_entry("old", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
301 make_entry("new", "Currently", date(2024, 1, 2, 10, 0), Tags::new()),
302 ];
303 let options = FilterOptions {
304 sort: Some(SortOrder::Desc),
305 ..Default::default()
306 };
307
308 let result = super::super::apply_sort_and_limit(entries, &options);
309
310 assert_eq!(result[0].title(), "new");
311 assert_eq!(result[1].title(), "old");
312 }
313 }
314
315 mod filter_entries {
316 use pretty_assertions::assert_eq;
317
318 use super::*;
319
320 #[test]
321 fn it_applies_full_pipeline() {
322 let entries = vec![
323 make_entry("coding rust", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
324 make_entry("writing docs", "Archive", date(2024, 1, 2, 10, 0), Tags::new()),
325 make_entry("coding python", "Currently", date(2024, 1, 3, 10, 0), Tags::new()),
326 ];
327 let (mode, case) = search::parse_query("coding", &Default::default()).unwrap();
328 let options = FilterOptions {
329 search: Some((mode, case)),
330 section: Some("Currently".into()),
331 ..Default::default()
332 };
333
334 let result = super::super::filter_entries(entries, &options);
335
336 assert_eq!(result.len(), 2);
337 assert!(result.iter().all(|e| e.title().contains("coding")));
338 }
339
340 #[test]
341 fn it_negates_all_filters() {
342 let entries = vec![
343 make_entry("keep", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
344 make_entry("exclude", "Archive", date(2024, 1, 2, 10, 0), Tags::new()),
345 ];
346 let options = FilterOptions {
347 negate: true,
348 section: Some("Currently".into()),
349 ..Default::default()
350 };
351
352 let result = super::super::filter_entries(entries, &options);
353
354 assert_eq!(result.len(), 1);
355 assert_eq!(result[0].title(), "exclude");
356 }
357
358 #[test]
359 fn it_returns_all_with_default_options() {
360 let entries = vec![
361 make_entry("one", "Currently", date(2024, 1, 1, 10, 0), Tags::new()),
362 make_entry("two", "Currently", date(2024, 1, 2, 10, 0), Tags::new()),
363 ];
364 let options = FilterOptions::default();
365
366 let result = super::super::filter_entries(entries, &options);
367
368 assert_eq!(result.len(), 2);
369 }
370
371 #[test]
372 fn it_returns_empty_for_empty_input() {
373 let options = FilterOptions::default();
374
375 let result = super::super::filter_entries(Vec::new(), &options);
376
377 assert!(result.is_empty());
378 }
379
380 #[test]
381 fn it_short_circuits_on_empty_after_filter() {
382 let entries = vec![make_entry("one", "Archive", date(2024, 1, 1, 10, 0), Tags::new())];
383 let (mode, case) = search::parse_query("nonexistent", &Default::default()).unwrap();
384 let options = FilterOptions {
385 search: Some((mode, case)),
386 section: Some("Currently".into()),
387 ..Default::default()
388 };
389
390 let result = super::super::filter_entries(entries, &options);
391
392 assert!(result.is_empty());
393 }
394 }
395
396 mod matches_date_range {
397 use super::*;
398
399 #[test]
400 fn it_excludes_entries_after_before_date() {
401 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
402 let options = FilterOptions {
403 before: Some(date(2024, 3, 10, 0, 0)),
404 ..Default::default()
405 };
406
407 assert!(!super::super::matches_date_range(&entry, &options));
408 }
409
410 #[test]
411 fn it_excludes_entries_before_after_date() {
412 let entry = make_entry("test", "Currently", date(2024, 3, 5, 10, 0), Tags::new());
413 let options = FilterOptions {
414 after: Some(date(2024, 3, 10, 0, 0)),
415 ..Default::default()
416 };
417
418 assert!(!super::super::matches_date_range(&entry, &options));
419 }
420
421 #[test]
422 fn it_includes_entries_within_range() {
423 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
424 let options = FilterOptions {
425 after: Some(date(2024, 3, 10, 0, 0)),
426 before: Some(date(2024, 3, 20, 0, 0)),
427 ..Default::default()
428 };
429
430 assert!(super::super::matches_date_range(&entry, &options));
431 }
432
433 #[test]
434 fn it_passes_when_no_date_range() {
435 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
436 let options = FilterOptions::default();
437
438 assert!(super::super::matches_date_range(&entry, &options));
439 }
440 }
441
442 mod matches_only_timed {
443 use super::*;
444
445 #[test]
446 fn it_excludes_unfinished_entries_when_only_timed() {
447 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
448 let options = FilterOptions {
449 only_timed: true,
450 ..Default::default()
451 };
452
453 assert!(!super::super::matches_only_timed(&entry, &options));
454 }
455
456 #[test]
457 fn it_includes_finished_entries_when_only_timed() {
458 let entry = make_entry(
459 "test",
460 "Currently",
461 date(2024, 3, 15, 10, 0),
462 done_tags("2024-03-15 12:00"),
463 );
464 let options = FilterOptions {
465 only_timed: true,
466 ..Default::default()
467 };
468
469 assert!(super::super::matches_only_timed(&entry, &options));
470 }
471
472 #[test]
473 fn it_excludes_zero_duration_entries_when_only_timed() {
474 let entry = make_entry(
475 "test",
476 "Currently",
477 date(2024, 3, 15, 10, 0),
478 done_tags("2024-03-15 10:00"),
479 );
480 let options = FilterOptions {
481 only_timed: true,
482 ..Default::default()
483 };
484
485 assert!(!super::super::matches_only_timed(&entry, &options));
486 }
487
488 #[test]
489 fn it_passes_when_not_only_timed() {
490 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
491 let options = FilterOptions::default();
492
493 assert!(super::super::matches_only_timed(&entry, &options));
494 }
495 }
496
497 mod matches_search {
498 use super::*;
499
500 #[test]
501 fn it_matches_note_text_when_included() {
502 let entry = make_entry_with_note(
503 "working",
504 "Currently",
505 date(2024, 3, 15, 10, 0),
506 Tags::new(),
507 "important note about rust",
508 );
509 let (mode, case) = search::parse_query("rust", &Default::default()).unwrap();
510 let options = FilterOptions {
511 include_notes: true,
512 search: Some((mode, case)),
513 ..Default::default()
514 };
515
516 assert!(super::super::matches_search(&entry, &options));
517 }
518
519 #[test]
520 fn it_matches_title_text() {
521 let entry = make_entry("working on rust", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
522 let (mode, case) = search::parse_query("rust", &Default::default()).unwrap();
523 let options = FilterOptions {
524 search: Some((mode, case)),
525 ..Default::default()
526 };
527
528 assert!(super::super::matches_search(&entry, &options));
529 }
530
531 #[test]
532 fn it_passes_when_no_search() {
533 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
534 let options = FilterOptions::default();
535
536 assert!(super::super::matches_search(&entry, &options));
537 }
538
539 #[test]
540 fn it_rejects_non_matching_text() {
541 let entry = make_entry("working on python", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
542 let (mode, case) = search::parse_query("rust", &Default::default()).unwrap();
543 let options = FilterOptions {
544 search: Some((mode, case)),
545 ..Default::default()
546 };
547
548 assert!(!super::super::matches_search(&entry, &options));
549 }
550 }
551
552 mod matches_section {
553 use super::*;
554
555 #[test]
556 fn it_excludes_wrong_section() {
557 let entry = make_entry("test", "Archive", date(2024, 3, 15, 10, 0), Tags::new());
558 let options = FilterOptions {
559 section: Some("Currently".into()),
560 ..Default::default()
561 };
562
563 assert!(!super::super::matches_section(&entry, &options));
564 }
565
566 #[test]
567 fn it_matches_case_insensitively() {
568 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
569 let options = FilterOptions {
570 section: Some("currently".into()),
571 ..Default::default()
572 };
573
574 assert!(super::super::matches_section(&entry, &options));
575 }
576
577 #[test]
578 fn it_passes_all_section() {
579 let entry = make_entry("test", "Archive", date(2024, 3, 15, 10, 0), Tags::new());
580 let options = FilterOptions {
581 section: Some("All".into()),
582 ..Default::default()
583 };
584
585 assert!(super::super::matches_section(&entry, &options));
586 }
587
588 #[test]
589 fn it_passes_matching_section() {
590 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
591 let options = FilterOptions {
592 section: Some("Currently".into()),
593 ..Default::default()
594 };
595
596 assert!(super::super::matches_section(&entry, &options));
597 }
598
599 #[test]
600 fn it_passes_when_no_section_filter() {
601 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
602 let options = FilterOptions::default();
603
604 assert!(super::super::matches_section(&entry, &options));
605 }
606 }
607
608 mod matches_tag_queries {
609 use super::*;
610
611 #[test]
612 fn it_passes_when_no_queries() {
613 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
614 let options = FilterOptions::default();
615
616 assert!(super::super::matches_tag_queries(&entry, &options));
617 }
618
619 #[test]
620 fn it_rejects_when_any_query_fails() {
621 let tags = Tags::from_iter(vec![Tag::new("progress", Some("80"))]);
622 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), tags);
623 let options = FilterOptions {
624 tag_queries: vec![
625 TagQuery::parse("progress > 50").unwrap(),
626 TagQuery::parse("progress < 70").unwrap(),
627 ],
628 ..Default::default()
629 };
630
631 assert!(!super::super::matches_tag_queries(&entry, &options));
632 }
633
634 #[test]
635 fn it_requires_all_queries_to_match() {
636 let tags = Tags::from_iter(vec![Tag::new("progress", Some("80"))]);
637 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), tags);
638 let options = FilterOptions {
639 tag_queries: vec![
640 TagQuery::parse("progress > 50").unwrap(),
641 TagQuery::parse("progress < 90").unwrap(),
642 ],
643 ..Default::default()
644 };
645
646 assert!(super::super::matches_tag_queries(&entry, &options));
647 }
648 }
649
650 mod matches_tags {
651 use super::*;
652
653 #[test]
654 fn it_excludes_when_tags_dont_match() {
655 let tags = Tags::from_iter(vec![Tag::new("rust", None::<String>)]);
656 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), tags);
657 let options = FilterOptions {
658 tag_filter: Some(TagFilter::new(&["python"], BooleanMode::Or)),
659 ..Default::default()
660 };
661
662 assert!(!super::super::matches_tags(&entry, &options));
663 }
664
665 #[test]
666 fn it_matches_when_tags_match() {
667 let tags = Tags::from_iter(vec![Tag::new("rust", None::<String>)]);
668 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), tags);
669 let options = FilterOptions {
670 tag_filter: Some(TagFilter::new(&["rust"], BooleanMode::Or)),
671 ..Default::default()
672 };
673
674 assert!(super::super::matches_tags(&entry, &options));
675 }
676
677 #[test]
678 fn it_passes_when_no_tag_filter() {
679 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
680 let options = FilterOptions::default();
681
682 assert!(super::super::matches_tags(&entry, &options));
683 }
684 }
685
686 mod matches_unfinished {
687 use super::*;
688
689 #[test]
690 fn it_excludes_finished_entries_when_unfinished() {
691 let entry = make_entry(
692 "test",
693 "Currently",
694 date(2024, 3, 15, 10, 0),
695 done_tags("2024-03-15 12:00"),
696 );
697 let options = FilterOptions {
698 unfinished: true,
699 ..Default::default()
700 };
701
702 assert!(!super::super::matches_unfinished(&entry, &options));
703 }
704
705 #[test]
706 fn it_includes_unfinished_entries_when_unfinished() {
707 let entry = make_entry("test", "Currently", date(2024, 3, 15, 10, 0), Tags::new());
708 let options = FilterOptions {
709 unfinished: true,
710 ..Default::default()
711 };
712
713 assert!(super::super::matches_unfinished(&entry, &options));
714 }
715
716 #[test]
717 fn it_passes_when_not_filtering_unfinished() {
718 let entry = make_entry(
719 "test",
720 "Currently",
721 date(2024, 3, 15, 10, 0),
722 done_tags("2024-03-15 12:00"),
723 );
724 let options = FilterOptions::default();
725
726 assert!(super::super::matches_unfinished(&entry, &options));
727 }
728 }
729}