Skip to main content

doing_ops/
filter.rs

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/// Which end of the chronological list to keep when applying a count limit.
12#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
13pub enum Age {
14  /// Keep the most recent entries.
15  #[default]
16  Newest,
17  /// Keep the oldest entries.
18  Oldest,
19}
20
21/// Aggregated filter parameters for the entry filter pipeline.
22///
23/// The pipeline applies filters in order: section → tags → tag values → search →
24/// date → only_timed → unfinished → negate → sort → count limit.
25pub struct FilterOptions {
26  /// Lower bound for entry date (inclusive).
27  pub after: Option<DateTime<Local>>,
28  /// Which end to keep when limiting by count.
29  pub age: Option<Age>,
30  /// Upper bound for entry date (inclusive).
31  pub before: Option<DateTime<Local>>,
32  /// Maximum number of entries to return.
33  pub count: Option<usize>,
34  /// Whether text search should include note content.
35  pub include_notes: bool,
36  /// Invert all filter results.
37  pub negate: bool,
38  /// Only include entries with a recorded time interval.
39  pub only_timed: bool,
40  /// Text search mode and case sensitivity.
41  pub search: Option<(SearchMode, CaseSensitivity)>,
42  /// Section name to filter by. "All" means no section filtering.
43  pub section: Option<String>,
44  /// Sort order for the final results.
45  pub sort: Option<SortOrder>,
46  /// Tag membership filter.
47  pub tag_filter: Option<TagFilter>,
48  /// Tag value queries (all must match).
49  pub tag_queries: Vec<TagQuery>,
50  /// Only include unfinished entries (no `@done` tag).
51  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
74/// Filter a collection of entries through the filter pipeline.
75///
76/// Applies filters in order: section → tags → tag values → search → date →
77/// only_timed → unfinished → negate → sort → count limit.
78///
79/// Short-circuits when no entries remain after a filter stage.
80pub 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
99/// Sort entries and apply age-based count limiting.
100fn apply_sort_and_limit(mut entries: Vec<Entry>, options: &FilterOptions) -> Vec<Entry> {
101  // Sort chronologically for age selection
102  entries.sort_by_key(|a| a.date());
103
104  // Apply count limit based on age
105  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  // Apply final sort order
120  if let Some(sort) = options.sort {
121    match sort {
122      SortOrder::Asc => {} // already ascending
123      SortOrder::Desc => entries.reverse(),
124    }
125  }
126
127  entries
128}
129
130/// Test whether an entry's date falls within the configured date range.
131fn 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
145/// Test whether an entry has a recorded time interval with positive duration.
146///
147/// Entries with zero-minute intervals are excluded because they have no meaningful
148/// tracked time.
149fn 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
156/// Test whether an entry matches the text search criteria.
157fn 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
164/// Test whether an entry belongs to the specified section.
165fn 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
174/// Test whether an entry matches all tag value queries.
175fn matches_tag_queries(entry: &Entry, options: &FilterOptions) -> bool {
176  options.tag_queries.iter().all(|q| q.matches_entry(entry))
177}
178
179/// Test whether an entry matches the tag membership filter.
180fn 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
187/// Test whether an entry is unfinished when required.
188fn 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}