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