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 (stable sort preserves insertion order for ties)
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, breaking ties by title for deterministic display
120  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
132/// Test whether an entry's date falls within the configured date range.
133fn 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
147/// Test whether an entry has a recorded time interval with positive duration.
148///
149/// Entries with zero-minute intervals are excluded because they have no meaningful
150/// tracked time.
151fn 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
158/// Test whether an entry matches the text search criteria.
159fn 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
166/// Test whether an entry belongs to the specified section.
167fn 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
176/// Test whether an entry matches all tag value queries.
177fn matches_tag_queries(entry: &Entry, options: &FilterOptions) -> bool {
178  options.tag_queries.iter().all(|q| q.matches_entry(entry))
179}
180
181/// Test whether an entry matches the tag membership filter.
182fn 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
189/// Test whether an entry is unfinished when required.
190fn 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}