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  if let Some(count) = options.count.filter(|&c| c < entries.len()) {
102    // Sort chronologically for age-based selection (stable sort preserves insertion order for ties)
103    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  // Apply final sort order, breaking ties by title for deterministic display
117  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
129/// Test whether an entry's date falls within the configured date range.
130fn 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
144/// Test whether an entry has a recorded time interval with positive duration.
145///
146/// Entries with zero-minute intervals are excluded because they have no meaningful
147/// tracked time.
148fn 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
155/// Test whether an entry matches the text search criteria.
156fn 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
163/// Test whether an entry belongs to the specified section.
164fn 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
173/// Test whether an entry matches all tag value queries.
174fn matches_tag_queries(entry: &Entry, options: &FilterOptions) -> bool {
175  options.tag_queries.iter().all(|q| q.matches_entry(entry))
176}
177
178/// Test whether an entry matches the tag membership filter.
179fn 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
186/// Test whether an entry is unfinished when required.
187fn 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}