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