1use std::collections::BTreeMap;
33use std::sync::Arc;
34
35use chrono::{NaiveDate, NaiveDateTime};
36use serde::{Deserialize, Serialize};
37
38use crate::task_index::FileTasks;
39use crate::tasks::{Task, TaskKind, TaskPriority, TaskStatus};
40
41pub const DEFAULT_TASK_LIMIT: usize = 500;
43
44#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
49#[serde(default)]
50pub struct TaskQuery {
51 pub q: String,
54 pub folder: Option<String>,
56 pub statuses: Vec<TaskStatus>,
59 pub priorities: Vec<TaskPriority>,
61 pub due: DueFilter,
63 pub include: IncludeFilter,
65 pub mode: TaskMode,
67 pub limit: usize,
69}
70
71impl Default for TaskQuery {
72 fn default() -> Self {
73 Self {
74 q: String::new(),
75 folder: None,
76 statuses: Vec::new(),
77 priorities: Vec::new(),
78 due: DueFilter::Any,
79 include: IncludeFilter::All,
80 mode: TaskMode::Category,
81 limit: DEFAULT_TASK_LIMIT,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
89#[serde(rename_all = "lowercase")]
90pub enum DueFilter {
91 #[default]
93 Any,
94 Overdue,
96 Today,
98 Tomorrow,
100 Upcoming,
102 #[serde(rename = "none")]
104 NoDue,
105}
106
107impl DueFilter {
108 pub fn matches(self, bucket: &DueBucket) -> bool {
110 match self {
111 Self::Any => true,
112 Self::Overdue => matches!(bucket, DueBucket::Overdue),
113 Self::Today => matches!(bucket, DueBucket::Today),
114 Self::Tomorrow => matches!(bucket, DueBucket::Tomorrow),
115 Self::Upcoming => matches!(bucket, DueBucket::Upcoming(_)),
116 Self::NoDue => matches!(bucket, DueBucket::NoDue),
117 }
118 }
119}
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
130#[serde(rename_all = "lowercase")]
131pub enum IncludeFilter {
132 #[default]
134 All,
135 Tasks,
137 Markers,
139}
140
141impl IncludeFilter {
142 pub fn matches(self, kind: TaskKind) -> bool {
144 match self {
145 Self::All => true,
146 Self::Tasks => kind == TaskKind::Task,
147 Self::Markers => kind == TaskKind::Marker,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
154#[serde(rename_all = "lowercase")]
155pub enum TaskMode {
156 #[default]
158 Category,
159 Calendar,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
165pub enum DueBucket {
166 Overdue,
168 Today,
170 Tomorrow,
172 Upcoming(NaiveDate),
174 NoDue,
176}
177
178pub fn due_bucket(due: Option<NaiveDateTime>, today: NaiveDate) -> DueBucket {
199 let Some(due) = due else {
200 return DueBucket::NoDue;
201 };
202 let date = due.date();
203 if date < today {
204 return DueBucket::Overdue;
205 }
206 if date == today {
207 return DueBucket::Today;
208 }
209 if today.succ_opt() == Some(date) {
213 return DueBucket::Tomorrow;
214 }
215 DueBucket::Upcoming(date)
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct QueryTerms {
221 pub words: Vec<String>,
223 pub tags: Vec<String>,
225}
226
227impl QueryTerms {
228 pub fn matches(&self, task: &Task) -> bool {
230 if self.words.is_empty() && self.tags.is_empty() {
231 return true;
232 }
233 let text = task.text.to_lowercase();
234 self.tags.iter().all(|tag| has_tag_prefix(task, tag))
235 && self
236 .words
237 .iter()
238 .all(|word| text.contains(word) || has_tag_prefix(task, word))
239 }
240}
241
242fn has_tag_prefix(task: &Task, needle: &str) -> bool {
248 task.tags.iter().any(|tag| {
249 tag.get(..needle.len())
252 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(needle))
253 })
254}
255
256pub fn parse_task_query(q: &str) -> QueryTerms {
280 let mut terms = QueryTerms::default();
281 for token in q.split_whitespace() {
282 match token.strip_prefix('#') {
283 Some(tag) if !tag.is_empty() => terms.tags.push(tag.to_lowercase()),
284 _ => terms.words.push(token.to_lowercase()),
285 }
286 }
287 terms
288}
289
290pub fn normalize_folder(folder: Option<&str>) -> Option<String> {
308 let trimmed = folder?.trim().trim_matches('/');
309 if trimmed.is_empty() {
310 return None;
311 }
312 Some(format!("/{trimmed}/"))
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
320pub struct TaskHit {
321 #[serde(flatten)]
323 pub task: Task,
324 pub url_path: String,
326 pub path: String,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
340pub struct TaskGroup {
341 pub key: String,
345 pub label: String,
347 pub sublabel: String,
350 pub url_path: Option<String>,
353 pub date: Option<NaiveDate>,
356 pub done: u32,
358 pub total: u32,
360 pub tasks: Vec<TaskHit>,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
366pub struct FolderFacet {
367 pub path: String,
369 pub count: u32,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
375pub struct TaskQueryResponse {
376 pub groups: Vec<TaskGroup>,
378 pub folders: Vec<FolderFacet>,
381 pub total_matches: usize,
383 pub duration_ms: u64,
385 pub scan_in_progress: bool,
388}
389
390struct Filters {
392 terms: QueryTerms,
393 statuses: Vec<TaskStatus>,
394 priorities: Vec<TaskPriority>,
395 due: DueFilter,
396 include: IncludeFilter,
397 today: NaiveDate,
398 calendar: bool,
399}
400
401impl Filters {
402 fn new(query: &TaskQuery, today: NaiveDate) -> Self {
403 Self {
404 terms: parse_task_query(&query.q),
405 statuses: if query.statuses.is_empty() {
407 vec![TaskStatus::Open]
408 } else {
409 query.statuses.clone()
410 },
411 priorities: query.priorities.clone(),
412 due: query.due,
413 include: query.include,
414 today,
415 calendar: query.mode == TaskMode::Calendar,
416 }
417 }
418
419 fn admissible(&self, task: &Task) -> bool {
439 if !self.include.matches(task.kind) {
440 return false;
441 }
442 if self.calendar {
443 return task.kind == TaskKind::Task && task.status != TaskStatus::Canceled;
444 }
445 true
446 }
447
448 fn matches_except_status(&self, task: &Task) -> bool {
452 (self.priorities.is_empty() || self.priorities.contains(&task.priority))
453 && self.due.matches(&due_bucket(task.due, self.today))
454 && self.terms.matches(task)
455 }
456
457 fn matches_status(&self, task: &Task) -> bool {
458 self.statuses.contains(&task.status)
459 }
460
461 fn matches(&self, task: &Task) -> bool {
462 self.matches_except_status(task) && self.matches_status(task)
463 }
464}
465
466pub fn run_query(
474 files: &[Arc<FileTasks>],
475 query: &TaskQuery,
476 today: NaiveDate,
477) -> TaskQueryResponse {
478 let start = std::time::Instant::now();
479 let filters = Filters::new(query, today);
480 let folder = normalize_folder(query.folder.as_deref());
481
482 let folders = folder_facets(files, &filters);
483 let scoped: Vec<&Arc<FileTasks>> = files
484 .iter()
485 .filter(|file| in_folder(file, folder.as_deref()))
486 .collect();
487
488 let mut groups = match query.mode {
489 TaskMode::Category => group_by_file(&scoped, &filters),
490 TaskMode::Calendar => group_by_due(&scoped, &filters),
491 };
492
493 let total_matches = groups.iter().map(|g| g.tasks.len()).sum();
494 truncate_groups(&mut groups, query.limit);
495
496 TaskQueryResponse {
497 groups,
498 folders,
499 total_matches,
500 duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
501 scan_in_progress: false,
502 }
503}
504
505fn in_folder(file: &FileTasks, folder: Option<&str>) -> bool {
510 folder.is_none_or(|scope| file.folder().starts_with(scope))
511}
512
513fn group_by_file(files: &[&Arc<FileTasks>], filters: &Filters) -> Vec<TaskGroup> {
518 let mut groups: Vec<TaskGroup> = files
519 .iter()
520 .filter_map(|file| {
521 let path = crate::url_path::path_to_url(&file.raw_path);
523 let mut tasks: Vec<TaskHit> = file
524 .tasks
525 .iter()
526 .filter(|task| filters.admissible(task) && filters.matches(task))
527 .map(|task| TaskHit {
528 task: task.clone(),
529 url_path: file.url_path.clone(),
530 path: path.clone(),
531 })
532 .collect();
533 if tasks.is_empty() {
534 return None;
535 }
536 tasks.sort_by_key(|hit| hit.task.line);
537
538 Some(TaskGroup {
539 key: file.url_path.clone(),
540 label: file.display_title().to_string(),
541 sublabel: file.folder().trim_matches('/').to_string(),
542 url_path: Some(file.url_path.clone()),
543 date: None,
544 done: file.done,
545 total: file.tracked(),
546 tasks,
547 })
548 })
549 .collect();
550
551 groups.sort_by(|a, b| a.key.cmp(&b.key));
554 groups
555}
556
557fn group_by_due(files: &[&Arc<FileTasks>], filters: &Filters) -> Vec<TaskGroup> {
563 let mut buckets: BTreeMap<DueBucket, BucketAccumulator> = BTreeMap::new();
567
568 for file in files {
569 let path = crate::url_path::path_to_url(&file.raw_path);
572 for task in &file.tasks {
573 if !filters.admissible(task) || !filters.matches_except_status(task) {
574 continue;
575 }
576 let entry = buckets
577 .entry(due_bucket(task.due, filters.today))
578 .or_default();
579
580 entry.total = entry.total.saturating_add(1);
581 if task.status == TaskStatus::Done {
582 entry.done = entry.done.saturating_add(1);
583 }
584 if filters.matches_status(task) {
585 entry.tasks.push(TaskHit {
586 task: task.clone(),
587 url_path: file.url_path.clone(),
588 path: path.clone(),
589 });
590 }
591 }
592 }
593
594 buckets
595 .into_iter()
596 .filter_map(|(bucket, mut acc)| {
597 if acc.tasks.is_empty() {
598 return None;
599 }
600 acc.tasks.sort_by(|a, b| {
603 a.task
604 .due
605 .cmp(&b.task.due)
606 .then_with(|| a.url_path.cmp(&b.url_path))
607 .then_with(|| a.task.line.cmp(&b.task.line))
608 });
609
610 let (done, total) = if bucket == DueBucket::Overdue {
613 (0, 0)
614 } else {
615 (acc.done, acc.total)
616 };
617
618 Some(TaskGroup {
619 key: bucket_key(&bucket),
620 label: bucket_label(&bucket),
621 sublabel: String::new(),
622 url_path: None,
623 date: bucket_date(&bucket, filters.today),
624 done,
625 total,
626 tasks: acc.tasks,
627 })
628 })
629 .collect()
630}
631
632#[derive(Default)]
634struct BucketAccumulator {
635 tasks: Vec<TaskHit>,
636 done: u32,
637 total: u32,
638}
639
640fn bucket_key(bucket: &DueBucket) -> String {
641 match bucket {
642 DueBucket::Overdue => "overdue".to_string(),
643 DueBucket::Today => "today".to_string(),
644 DueBucket::Tomorrow => "tomorrow".to_string(),
645 DueBucket::Upcoming(date) => format!("upcoming:{date}"),
646 DueBucket::NoDue => "none".to_string(),
647 }
648}
649
650fn bucket_label(bucket: &DueBucket) -> String {
651 match bucket {
652 DueBucket::Overdue => "Overdue".to_string(),
653 DueBucket::Today => "Today".to_string(),
654 DueBucket::Tomorrow => "Tomorrow".to_string(),
655 DueBucket::Upcoming(date) => date.to_string(),
658 DueBucket::NoDue => "No due date".to_string(),
659 }
660}
661
662fn bucket_date(bucket: &DueBucket, today: NaiveDate) -> Option<NaiveDate> {
663 match bucket {
664 DueBucket::Today => Some(today),
665 DueBucket::Tomorrow => today.succ_opt(),
666 DueBucket::Upcoming(date) => Some(*date),
667 DueBucket::Overdue | DueBucket::NoDue => None,
668 }
669}
670
671fn folder_facets(files: &[Arc<FileTasks>], filters: &Filters) -> Vec<FolderFacet> {
679 let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
680
681 for file in files {
682 let matched = u32::try_from(
683 file.tasks
684 .iter()
685 .filter(|task| filters.admissible(task) && filters.matches(task))
686 .count(),
687 )
688 .unwrap_or(u32::MAX);
689 if matched == 0 {
690 continue;
691 }
692
693 let folder = file.folder();
694 for (index, byte) in folder.bytes().enumerate() {
698 if byte == b'/' {
699 let ancestor = &folder[..=index];
700 *counts.entry(ancestor).or_insert(0) += matched;
701 }
702 }
703 }
704
705 counts
706 .into_iter()
707 .map(|(path, count)| FolderFacet {
708 path: path.to_string(),
709 count,
710 })
711 .collect()
712}
713
714fn truncate_groups(groups: &mut Vec<TaskGroup>, limit: usize) {
719 let mut remaining = limit;
720 for group in groups.iter_mut() {
721 if group.tasks.len() > remaining {
722 group.tasks.truncate(remaining);
723 }
724 remaining -= group.tasks.len();
725 }
726 groups.retain(|group| !group.tasks.is_empty());
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732 use crate::tasks::{MarkerRule, scan_source_tasks, scan_source_tasks_with_markers};
733
734 fn day(y: i32, m: u32, d: u32) -> NaiveDate {
735 NaiveDate::from_ymd_opt(y, m, d).expect("valid test date")
736 }
737
738 fn today() -> NaiveDate {
740 day(2026, 8, 4)
741 }
742
743 fn file(url: &str, raw: &str, title: Option<&str>, body: &str) -> Arc<FileTasks> {
746 Arc::new(FileTasks::new(
747 url,
748 std::path::PathBuf::from(raw),
749 title.map(str::to_string),
750 scan_source_tasks(body),
751 ))
752 }
753
754 fn marked_file(url: &str, raw: &str, body: &str) -> Arc<FileTasks> {
757 let markers: Vec<String> = ["TK", "TODO", "FIXME", "XXX"]
758 .iter()
759 .map(|m| (*m).to_string())
760 .collect();
761 let rule = MarkerRule::new(&markers).expect("the default markers compile");
762 Arc::new(FileTasks::new(
763 url,
764 std::path::PathBuf::from(raw),
765 None,
766 scan_source_tasks_with_markers(body, Some(&rule)),
767 ))
768 }
769
770 fn kinds(response: &TaskQueryResponse) -> Vec<TaskKind> {
772 response
773 .groups
774 .iter()
775 .flat_map(|g| g.tasks.iter().map(|t| t.task.kind))
776 .collect()
777 }
778
779 fn query() -> TaskQuery {
780 TaskQuery::default()
781 }
782
783 fn texts(response: &TaskQueryResponse) -> Vec<&str> {
785 response
786 .groups
787 .iter()
788 .flat_map(|g| g.tasks.iter().map(|t| t.task.text.as_str()))
789 .collect()
790 }
791
792 #[test]
795 fn query_parsing_splits_words_and_tags() {
796 let terms = parse_task_query("report #work #Ops draft");
797 assert_eq!(terms.words, ["report", "draft"]);
798 assert_eq!(terms.tags, ["work", "ops"]);
799 }
800
801 #[test]
802 fn query_parsing_lowercases_and_ignores_extra_whitespace() {
803 let terms = parse_task_query(" REPORT \t #WORK ");
804 assert_eq!(terms.words, ["report"]);
805 assert_eq!(terms.tags, ["work"]);
806 }
807
808 #[test]
809 fn query_parsing_treats_a_bare_hash_as_a_word() {
810 assert_eq!(parse_task_query("#").words, ["#"]);
811 assert!(parse_task_query("#").tags.is_empty());
812 }
813
814 #[test]
815 fn empty_query_matches_everything() {
816 let terms = parse_task_query(" ");
817 assert!(terms.words.is_empty() && terms.tags.is_empty());
818 let task = crate::tasks::parse_task_line("- [ ] anything", 1).expect("task");
819 assert!(terms.matches(&task));
820 }
821
822 #[test]
823 fn words_and_multiple_terms_are_anded() {
824 let task =
825 crate::tasks::parse_task_line("- [ ] write the quarterly report", 1).expect("task");
826 assert!(parse_task_query("write report").matches(&task));
827 assert!(!parse_task_query("write missing").matches(&task));
828 }
829
830 #[test]
831 fn word_matching_is_a_case_insensitive_substring() {
832 let task = crate::tasks::parse_task_line("- [ ] Write The Report", 1).expect("task");
833 assert!(parse_task_query("write").matches(&task));
834 assert!(
835 parse_task_query("REPO").matches(&task),
836 "substring, any case"
837 );
838 }
839
840 #[test]
841 fn tag_tokens_match_by_prefix_not_substring() {
842 let task = crate::tasks::parse_task_line("- [ ] a #homework", 1).expect("task");
843 assert!(parse_task_query("#home").matches(&task), "prefix matches");
844 assert!(
845 !parse_task_query("#work").matches(&task),
846 "substring must not match, or #work would hit #homework"
847 );
848 }
849
850 #[test]
851 fn a_bare_word_also_matches_a_tag() {
852 let task = crate::tasks::parse_task_line("- [ ] review #work", 1).expect("task");
855 assert_eq!(task.text, "review");
856 assert!(parse_task_query("work").matches(&task));
857 }
858
859 #[test]
860 fn a_tag_token_does_not_match_the_display_text() {
861 let task = crate::tasks::parse_task_line("- [ ] do the work", 1).expect("task");
862 assert!(!parse_task_query("#work").matches(&task));
863 }
864
865 #[test]
868 fn folder_normalization_forces_both_slashes() {
869 for input in ["docs", "/docs", "docs/", "/docs/", " /docs/ "] {
870 assert_eq!(
871 normalize_folder(Some(input)).as_deref(),
872 Some("/docs/"),
873 "for {input:?}"
874 );
875 }
876 }
877
878 #[test]
879 fn folder_normalization_treats_the_root_as_no_filter() {
880 for input in ["", "/", " ", "//"] {
881 assert_eq!(normalize_folder(Some(input)), None, "for {input:?}");
882 }
883 assert_eq!(normalize_folder(None), None);
884 }
885
886 fn bucket_for(date: NaiveDate, today: NaiveDate) -> DueBucket {
889 due_bucket(date.and_hms_opt(9, 0, 0), today)
890 }
891
892 #[test]
893 fn due_bucketing_covers_every_bucket() {
894 let today = today();
895 assert_eq!(bucket_for(day(2026, 8, 3), today), DueBucket::Overdue);
896 assert_eq!(bucket_for(today, today), DueBucket::Today);
897 assert_eq!(bucket_for(day(2026, 8, 5), today), DueBucket::Tomorrow);
898 assert_eq!(
899 bucket_for(day(2026, 8, 9), today),
900 DueBucket::Upcoming(day(2026, 8, 9))
901 );
902 assert_eq!(due_bucket(None, today), DueBucket::NoDue);
903 }
904
905 #[test]
906 fn a_task_due_later_today_is_not_yet_overdue() {
907 let today = today();
909 let early = today.and_hms_opt(0, 1, 0);
910 assert_eq!(due_bucket(early, today), DueBucket::Today);
911 let late = today.and_hms_opt(23, 59, 0);
912 assert_eq!(due_bucket(late, today), DueBucket::Today);
913 }
914
915 #[test]
916 fn due_bucketing_crosses_a_month_boundary() {
917 let today = day(2026, 8, 31);
918 assert_eq!(bucket_for(day(2026, 8, 30), today), DueBucket::Overdue);
919 assert_eq!(bucket_for(day(2026, 9, 1), today), DueBucket::Tomorrow);
920 assert_eq!(
921 bucket_for(day(2026, 9, 2), today),
922 DueBucket::Upcoming(day(2026, 9, 2))
923 );
924 }
925
926 #[test]
927 fn due_bucketing_crosses_a_year_boundary() {
928 let today = day(2026, 12, 31);
929 assert_eq!(bucket_for(day(2026, 12, 30), today), DueBucket::Overdue);
930 assert_eq!(bucket_for(day(2027, 1, 1), today), DueBucket::Tomorrow);
931 assert_eq!(
932 bucket_for(day(2027, 1, 2), today),
933 DueBucket::Upcoming(day(2027, 1, 2))
934 );
935 }
936
937 #[test]
938 fn due_bucketing_crosses_a_leap_day() {
939 let today = day(2028, 2, 28);
940 assert_eq!(bucket_for(day(2028, 2, 29), today), DueBucket::Tomorrow);
941 let leap_day = day(2028, 2, 29);
942 assert_eq!(bucket_for(day(2028, 3, 1), leap_day), DueBucket::Tomorrow);
943 }
944
945 #[test]
946 fn bucket_ordering_is_the_display_order() {
947 let mut buckets = vec![
948 DueBucket::NoDue,
949 DueBucket::Upcoming(day(2026, 9, 1)),
950 DueBucket::Tomorrow,
951 DueBucket::Upcoming(day(2026, 8, 20)),
952 DueBucket::Today,
953 DueBucket::Overdue,
954 ];
955 buckets.sort();
956 assert_eq!(
957 buckets,
958 vec![
959 DueBucket::Overdue,
960 DueBucket::Today,
961 DueBucket::Tomorrow,
962 DueBucket::Upcoming(day(2026, 8, 20)),
963 DueBucket::Upcoming(day(2026, 9, 1)),
964 DueBucket::NoDue,
965 ]
966 );
967 }
968
969 fn mixed_status_file() -> Vec<Arc<FileTasks>> {
972 vec![file(
973 "/notes/",
974 "notes.md",
975 None,
976 "- [ ] open one\n- [x] done one\n- [-] canceled one\n",
977 )]
978 }
979
980 #[test]
981 fn default_status_filter_is_incomplete_only() {
982 let response = run_query(&mixed_status_file(), &query(), today());
983 assert_eq!(texts(&response), ["open one"]);
984 assert_eq!(response.total_matches, 1);
985 }
986
987 #[test]
988 fn status_filter_is_a_multi_select() {
989 let q = TaskQuery {
990 statuses: vec![TaskStatus::Open, TaskStatus::Done],
991 ..query()
992 };
993 let response = run_query(&mixed_status_file(), &q, today());
994 assert_eq!(texts(&response), ["open one", "done one"]);
995
996 let q = TaskQuery {
997 statuses: vec![TaskStatus::Canceled],
998 ..query()
999 };
1000 let response = run_query(&mixed_status_file(), &q, today());
1001 assert_eq!(texts(&response), ["canceled one"]);
1002 }
1003
1004 #[test]
1005 fn priority_filter_is_a_multi_select() {
1006 let files = vec![file(
1007 "/notes/",
1008 "notes.md",
1009 None,
1010 "- [ ] plain\n- [ ] high !!\n- [ ] urgent !!!\n",
1011 )];
1012
1013 let all = run_query(&files, &query(), today());
1014 assert_eq!(texts(&all), ["plain", "high", "urgent"]);
1015
1016 let q = TaskQuery {
1017 priorities: vec![TaskPriority::High, TaskPriority::Urgent],
1018 ..query()
1019 };
1020 let filtered = run_query(&files, &q, today());
1021 assert_eq!(texts(&filtered), ["high", "urgent"]);
1022 }
1023
1024 #[test]
1025 fn due_filter_selects_one_bucket() {
1026 let files = vec![file(
1027 "/notes/",
1028 "notes.md",
1029 None,
1030 concat!(
1031 "- [ ] late @due(2026-08-01)\n",
1032 "- [ ] now @due(2026-08-04)\n",
1033 "- [ ] soon @due(2026-08-05)\n",
1034 "- [ ] later @due(2026-08-20)\n",
1035 "- [ ] someday\n",
1036 ),
1037 )];
1038
1039 for (filter, expected) in [
1040 (DueFilter::Overdue, vec!["late"]),
1041 (DueFilter::Today, vec!["now"]),
1042 (DueFilter::Tomorrow, vec!["soon"]),
1043 (DueFilter::Upcoming, vec!["later"]),
1044 (DueFilter::NoDue, vec!["someday"]),
1045 ] {
1046 let q = TaskQuery {
1047 due: filter,
1048 ..query()
1049 };
1050 let response = run_query(&files, &q, today());
1051 assert_eq!(texts(&response), expected, "for {filter:?}");
1052 }
1053 }
1054
1055 fn folder_tree() -> Vec<Arc<FileTasks>> {
1058 vec![
1059 file("/top/", "top.md", None, "- [ ] at root\n"),
1060 file("/docs/guide/", "docs/guide.md", None, "- [ ] in docs\n"),
1061 file(
1062 "/docs/notes/weekly/",
1063 "docs/notes/weekly.md",
1064 None,
1065 "- [ ] in notes\n",
1066 ),
1067 file("/other/thing/", "other/thing.md", None, "- [ ] elsewhere\n"),
1068 ]
1069 }
1070
1071 #[test]
1072 fn folder_scope_includes_subfolders() {
1073 let q = TaskQuery {
1074 folder: Some("/docs/".to_string()),
1075 ..query()
1076 };
1077 let response = run_query(&folder_tree(), &q, today());
1078 assert_eq!(texts(&response), ["in docs", "in notes"]);
1079 }
1080
1081 #[test]
1082 fn folder_scope_does_not_half_match_a_sibling() {
1083 let files = vec![
1084 file("/docs/a/", "docs/a.md", None, "- [ ] real\n"),
1085 file("/docsy/b/", "docsy/b.md", None, "- [ ] decoy\n"),
1086 ];
1087 let q = TaskQuery {
1088 folder: Some("docs".to_string()),
1089 ..query()
1090 };
1091 assert_eq!(texts(&run_query(&files, &q, today())), ["real"]);
1092 }
1093
1094 #[test]
1095 fn folder_facets_are_cumulative_and_ignore_the_folder_filter() {
1096 let q = TaskQuery {
1097 folder: Some("/docs/notes/".to_string()),
1098 ..query()
1099 };
1100 let response = run_query(&folder_tree(), &q, today());
1101
1102 assert_eq!(texts(&response), ["in notes"]);
1104 let facets: Vec<(&str, u32)> = response
1107 .folders
1108 .iter()
1109 .map(|f| (f.path.as_str(), f.count))
1110 .collect();
1111 assert_eq!(
1112 facets,
1113 vec![("/", 4), ("/docs/", 2), ("/docs/notes/", 1), ("/other/", 1),]
1114 );
1115 }
1116
1117 #[test]
1118 fn folder_facets_respect_the_other_filters() {
1119 let files = vec![file(
1120 "/docs/a/",
1121 "docs/a.md",
1122 None,
1123 "- [ ] open\n- [x] done\n",
1124 )];
1125 let response = run_query(&files, &query(), today());
1126 assert_eq!(
1127 response
1128 .folders
1129 .iter()
1130 .map(|f| (f.path.as_str(), f.count))
1131 .collect::<Vec<_>>(),
1132 vec![("/", 1), ("/docs/", 1)],
1133 "the completed task is filtered out of the facet counts too"
1134 );
1135 }
1136
1137 #[test]
1140 fn category_groups_are_per_file_and_ordered_by_line() {
1141 let files = vec![file(
1142 "/notes/",
1143 "notes.md",
1144 Some("Weekly notes"),
1145 "# Notes\n\n- [ ] second\n\nprose\n\n- [ ] first\n",
1146 )];
1147 let response = run_query(&files, &query(), today());
1148
1149 assert_eq!(response.groups.len(), 1);
1150 let group = &response.groups[0];
1151 assert_eq!(group.label, "Weekly notes");
1152 assert_eq!(group.key, "/notes/");
1153 assert_eq!(group.url_path.as_deref(), Some("/notes/"));
1154 assert_eq!(group.date, None);
1155 assert_eq!(
1158 group.tasks.iter().map(|t| t.task.line).collect::<Vec<_>>(),
1159 vec![3, 7]
1160 );
1161 }
1162
1163 #[test]
1164 fn category_group_labels_fall_back_to_the_file_stem() {
1165 let files = vec![file(
1166 "/docs/untitled-note/",
1167 "docs/untitled-note.md",
1168 None,
1169 "- [ ] a\n",
1170 )];
1171 let response = run_query(&files, &query(), today());
1172 assert_eq!(response.groups[0].label, "untitled-note");
1173 assert_eq!(response.groups[0].sublabel, "docs");
1174 }
1175
1176 #[test]
1177 fn category_totals_include_tasks_filtered_out_of_the_view() {
1178 let files = vec![file(
1181 "/notes/",
1182 "notes.md",
1183 None,
1184 concat!(
1185 "- [x] alpha\n",
1186 "- [x] beta\n",
1187 "- [x] gamma\n",
1188 "- [ ] delta\n",
1189 "- [ ] epsilon\n",
1190 "- [ ] zeta\n",
1191 "- [ ] findme\n",
1192 "- [-] canceled\n",
1193 ),
1194 )];
1195 let q = TaskQuery {
1196 q: "findme".to_string(),
1197 ..query()
1198 };
1199 let response = run_query(&files, &q, today());
1200
1201 let group = &response.groups[0];
1202 assert_eq!(group.tasks.len(), 1, "only the matching task is returned");
1203 assert_eq!(
1204 (group.done, group.total),
1205 (3, 7),
1206 "counts describe the file, not the view, and exclude the canceled task"
1207 );
1208 assert_eq!(response.total_matches, 1);
1209 }
1210
1211 #[test]
1212 fn category_mode_can_show_canceled_tasks_without_counting_them() {
1213 let files = vec![file(
1214 "/notes/",
1215 "notes.md",
1216 None,
1217 "- [ ] open\n- [x] done\n- [-] canceled\n",
1218 )];
1219 let q = TaskQuery {
1220 statuses: vec![TaskStatus::Canceled],
1221 ..query()
1222 };
1223 let response = run_query(&files, &q, today());
1224
1225 assert_eq!(texts(&response), ["canceled"]);
1226 assert_eq!((response.groups[0].done, response.groups[0].total), (1, 2));
1227 }
1228
1229 #[test]
1230 fn category_groups_are_sorted_by_url_and_empty_ones_dropped() {
1231 let files = vec![
1232 file("/zeta/", "zeta.md", None, "- [ ] z\n"),
1233 file("/alpha/", "alpha.md", None, "- [ ] a\n"),
1234 file("/nothing/", "nothing.md", None, "- [x] all done here\n"),
1235 ];
1236 let response = run_query(&files, &query(), today());
1237 assert_eq!(
1238 response
1239 .groups
1240 .iter()
1241 .map(|g| g.key.as_str())
1242 .collect::<Vec<_>>(),
1243 vec!["/alpha/", "/zeta/"]
1244 );
1245 }
1246
1247 fn calendar_query() -> TaskQuery {
1250 TaskQuery {
1251 mode: TaskMode::Calendar,
1252 ..TaskQuery::default()
1253 }
1254 }
1255
1256 fn calendar_files() -> Vec<Arc<FileTasks>> {
1257 vec![file(
1258 "/notes/",
1259 "notes.md",
1260 None,
1261 concat!(
1262 "- [ ] late @due(2026-08-01)\n",
1263 "- [ ] now @due(2026-08-04)\n",
1264 "- [ ] soon @due(2026-08-05)\n",
1265 "- [ ] far @due(2026-08-20)\n",
1266 "- [ ] farther @due(2026-08-21)\n",
1267 "- [ ] whenever\n",
1268 ),
1269 )]
1270 }
1271
1272 #[test]
1273 fn calendar_buckets_are_in_display_order_with_one_group_per_upcoming_date() {
1274 let response = run_query(&calendar_files(), &calendar_query(), today());
1275 assert_eq!(
1276 response
1277 .groups
1278 .iter()
1279 .map(|g| (g.key.as_str(), g.label.as_str()))
1280 .collect::<Vec<_>>(),
1281 vec![
1282 ("overdue", "Overdue"),
1283 ("today", "Today"),
1284 ("tomorrow", "Tomorrow"),
1285 ("upcoming:2026-08-20", "2026-08-20"),
1286 ("upcoming:2026-08-21", "2026-08-21"),
1287 ("none", "No due date"),
1288 ]
1289 );
1290 }
1291
1292 #[test]
1293 fn calendar_groups_carry_their_date_and_no_page_url() {
1294 let response = run_query(&calendar_files(), &calendar_query(), today());
1295 let by_key = |key: &str| {
1296 response
1297 .groups
1298 .iter()
1299 .find(|g| g.key == key)
1300 .unwrap_or_else(|| panic!("expected a {key} group"))
1301 };
1302 assert_eq!(by_key("today").date, Some(day(2026, 8, 4)));
1303 assert_eq!(by_key("tomorrow").date, Some(day(2026, 8, 5)));
1304 assert_eq!(by_key("upcoming:2026-08-20").date, Some(day(2026, 8, 20)));
1305 assert_eq!(by_key("overdue").date, None);
1306 assert_eq!(by_key("none").date, None);
1307 assert!(response.groups.iter().all(|g| g.url_path.is_none()));
1308 }
1309
1310 #[test]
1311 fn calendar_overdue_has_no_totals() {
1312 let response = run_query(&calendar_files(), &calendar_query(), today());
1313 let overdue = &response.groups[0];
1314 assert_eq!(overdue.key, "overdue");
1315 assert_eq!(overdue.tasks.len(), 1);
1316 assert_eq!((overdue.done, overdue.total), (0, 0));
1317 }
1318
1319 #[test]
1320 fn calendar_totals_ignore_the_status_filter_but_honor_the_rest() {
1321 let files = vec![file(
1322 "/notes/",
1323 "notes.md",
1324 None,
1325 concat!(
1326 "- [ ] open one @due(2026-08-04)\n",
1327 "- [x] done one @due(2026-08-04)\n",
1328 "- [x] done two @due(2026-08-04)\n",
1329 "- [ ] unrelated @due(2026-08-04)\n",
1331 ),
1332 )];
1333 let q = TaskQuery {
1334 q: "one".to_string(),
1335 ..calendar_query()
1336 };
1337 let response = run_query(&files, &q, today());
1338
1339 let today_group = &response.groups[0];
1340 assert_eq!(today_group.key, "today");
1341 assert_eq!(
1343 today_group
1344 .tasks
1345 .iter()
1346 .map(|t| t.task.text.as_str())
1347 .collect::<Vec<_>>(),
1348 vec!["open one"]
1349 );
1350 assert_eq!((today_group.done, today_group.total), (2, 3));
1352 }
1353
1354 #[test]
1355 fn calendar_ignores_canceled_tasks_entirely() {
1356 let files = vec![file(
1357 "/notes/",
1358 "notes.md",
1359 None,
1360 concat!(
1361 "- [ ] open @due(2026-08-04)\n",
1362 "- [-] canceled @due(2026-08-04)\n",
1363 "- [>] moved @due(2026-08-04) > 2026-08-10\n",
1364 ),
1365 )];
1366
1367 let response = run_query(&files, &calendar_query(), today());
1368 assert_eq!(texts(&response), ["open"]);
1369 assert_eq!((response.groups[0].done, response.groups[0].total), (0, 1));
1371
1372 let q = TaskQuery {
1374 statuses: vec![TaskStatus::Canceled],
1375 ..calendar_query()
1376 };
1377 assert!(run_query(&files, &q, today()).groups.is_empty());
1378 }
1379
1380 #[test]
1381 fn calendar_tasks_are_chronological_within_a_bucket() {
1382 let files = vec![
1383 file(
1384 "/b/",
1385 "b.md",
1386 None,
1387 "- [ ] afternoon @due(2026-08-04 15:00)\n",
1388 ),
1389 file(
1390 "/a/",
1391 "a.md",
1392 None,
1393 "- [ ] morning @due(2026-08-04 09:00)\n",
1394 ),
1395 ];
1396 let response = run_query(&files, &calendar_query(), today());
1397 assert_eq!(texts(&response), ["morning", "afternoon"]);
1398 }
1399
1400 #[test]
1403 fn limit_truncates_tasks_without_changing_counts_or_total_matches() {
1404 let files = vec![
1405 file("/a/", "a.md", None, "- [ ] a1\n- [ ] a2\n- [ ] a3\n"),
1406 file("/b/", "b.md", None, "- [ ] b1\n- [ ] b2\n"),
1407 ];
1408 let q = TaskQuery {
1409 limit: 4,
1410 ..query()
1411 };
1412 let response = run_query(&files, &q, today());
1413
1414 assert_eq!(texts(&response), ["a1", "a2", "a3", "b1"]);
1415 assert_eq!(response.total_matches, 5, "counted before truncation");
1416 assert_eq!((response.groups[0].done, response.groups[0].total), (0, 3));
1418 assert_eq!((response.groups[1].done, response.groups[1].total), (0, 2));
1419 }
1420
1421 #[test]
1422 fn limit_drops_groups_that_end_up_empty() {
1423 let files = vec![
1424 file("/a/", "a.md", None, "- [ ] a1\n- [ ] a2\n"),
1425 file("/b/", "b.md", None, "- [ ] b1\n"),
1426 ];
1427 let q = TaskQuery {
1428 limit: 2,
1429 ..query()
1430 };
1431 let response = run_query(&files, &q, today());
1432
1433 assert_eq!(response.groups.len(), 1, "the empty /b/ group is dropped");
1434 assert_eq!(response.total_matches, 3);
1435 }
1436
1437 #[test]
1438 fn a_zero_limit_returns_no_tasks_but_still_counts_them() {
1439 let files = vec![file("/a/", "a.md", None, "- [ ] a1\n- [ ] a2\n")];
1440 let q = TaskQuery {
1441 limit: 0,
1442 ..query()
1443 };
1444 let response = run_query(&files, &q, today());
1445
1446 assert!(response.groups.is_empty());
1447 assert_eq!(response.total_matches, 2);
1448 assert_eq!(response.folders[0].count, 2, "facets are never truncated");
1449 }
1450
1451 #[test]
1454 fn an_empty_index_yields_an_empty_response() {
1455 let response = run_query(&[], &query(), today());
1456 assert!(response.groups.is_empty());
1457 assert!(response.folders.is_empty());
1458 assert_eq!(response.total_matches, 0);
1459 assert!(!response.scan_in_progress);
1460 }
1461
1462 #[test]
1463 fn a_task_hit_serializes_its_task_fields_alongside_the_page_url() {
1464 let files = vec![file(
1465 "/notes/",
1466 "notes.md",
1467 None,
1468 "- [ ] ship it !!! #work @due(2026-08-05)\n",
1469 )];
1470 let response = run_query(&files, &query(), today());
1471 let json = serde_json::to_value(&response).expect("response serializes");
1472 let hit = &json["groups"][0]["tasks"][0];
1473
1474 assert_eq!(hit["url_path"], "/notes/");
1475 assert_eq!(hit["path"], "notes.md");
1476 assert_eq!(hit["line"], 1);
1477 assert_eq!(hit["status"], "open");
1478 assert_eq!(hit["priority"], "urgent");
1479 assert_eq!(hit["text"], "ship it");
1480 assert_eq!(hit["tags"][0], "work");
1481 assert_eq!(hit["due"], "2026-08-05T00:00:00");
1482 }
1483
1484 #[test]
1485 fn a_hit_carries_the_source_path_that_the_page_url_cannot_reproduce() {
1486 let files = vec![
1489 file("/docs/", "docs/index.md", None, "- [ ] from the index\n"),
1490 file(
1491 "/docs/guide/",
1492 "docs/guide.md",
1493 None,
1494 "- [ ] from the guide\n",
1495 ),
1496 ];
1497 let response = run_query(&files, &query(), today());
1498
1499 let located: Vec<(&str, &str)> = response
1500 .groups
1501 .iter()
1502 .flat_map(|g| {
1503 g.tasks
1504 .iter()
1505 .map(|t| (t.url_path.as_str(), t.path.as_str()))
1506 })
1507 .collect();
1508 assert_eq!(
1509 located,
1510 vec![
1511 ("/docs/", "docs/index.md"),
1512 ("/docs/guide/", "docs/guide.md"),
1513 ]
1514 );
1515 }
1516
1517 #[test]
1518 fn calendar_hits_carry_the_source_path_too() {
1519 let files = vec![file(
1520 "/docs/",
1521 "docs/index.md",
1522 None,
1523 "- [ ] due today @due(2026-08-04)\n",
1524 )];
1525 let q = TaskQuery {
1526 mode: TaskMode::Calendar,
1527 ..query()
1528 };
1529 let response = run_query(&files, &q, today());
1530
1531 assert_eq!(response.groups[0].tasks[0].path, "docs/index.md");
1532 }
1533
1534 #[test]
1535 fn a_query_deserializes_from_an_empty_object() {
1536 let parsed: TaskQuery = serde_json::from_str("{}").expect("empty object is a query");
1537 assert_eq!(parsed, TaskQuery::default());
1538 assert_eq!(parsed.limit, DEFAULT_TASK_LIMIT);
1539 assert_eq!(parsed.include, IncludeFilter::All);
1543 }
1544
1545 #[test]
1546 fn a_query_deserializes_every_field() {
1547 let parsed: TaskQuery = serde_json::from_str(
1548 r#"{"q":"report #work","folder":"/docs/","statuses":["open","done"],
1549 "priorities":["urgent"],"due":"overdue","include":"markers",
1550 "mode":"calendar","limit":10}"#,
1551 )
1552 .expect("full object is a query");
1553
1554 assert_eq!(parsed.q, "report #work");
1555 assert_eq!(parsed.folder.as_deref(), Some("/docs/"));
1556 assert_eq!(parsed.statuses, [TaskStatus::Open, TaskStatus::Done]);
1557 assert_eq!(parsed.priorities, [TaskPriority::Urgent]);
1558 assert_eq!(parsed.due, DueFilter::Overdue);
1559 assert_eq!(parsed.include, IncludeFilter::Markers);
1560 assert_eq!(parsed.mode, TaskMode::Calendar);
1561 assert_eq!(parsed.limit, 10);
1562
1563 let tasks: TaskQuery =
1565 serde_json::from_str(r#"{"include":"tasks"}"#).expect("include is a query field");
1566 assert_eq!(tasks.include, IncludeFilter::Tasks);
1567 }
1568
1569 #[test]
1570 fn the_no_due_filter_is_spelled_none_on_the_wire() {
1571 let parsed: TaskQuery = serde_json::from_str(r#"{"due":"none"}"#).expect("query");
1572 assert_eq!(parsed.due, DueFilter::NoDue);
1573 }
1574
1575 fn mixed_kind_file() -> Vec<Arc<FileTasks>> {
1579 vec![marked_file(
1580 "/notes/",
1581 "notes.md",
1582 concat!(
1583 "TK verify this figure\n", "\n", "- [ ] write the report\n", "prose with a FIXME here\n" ),
1588 )]
1589 }
1590
1591 #[test]
1592 fn markers_appear_in_category_mode_in_source_order() {
1593 let response = run_query(&mixed_kind_file(), &query(), today());
1594 assert_eq!(
1595 texts(&response),
1596 [
1597 "TK verify this figure",
1598 "write the report",
1599 "prose with a FIXME here"
1600 ]
1601 );
1602 assert_eq!(
1603 kinds(&response),
1604 [TaskKind::Marker, TaskKind::Task, TaskKind::Marker]
1605 );
1606 assert_eq!(response.total_matches, 3, "markers are real matches");
1607 }
1608
1609 #[test]
1610 fn markers_never_appear_in_calendar_mode() {
1611 let q = TaskQuery {
1612 mode: TaskMode::Calendar,
1613 ..query()
1614 };
1615 let response = run_query(&mixed_kind_file(), &q, today());
1616
1617 assert_eq!(texts(&response), ["write the report"]);
1618 let none = response
1622 .groups
1623 .iter()
1624 .find(|g| g.key == "none")
1625 .expect("the undated task still makes a bucket");
1626 assert_eq!((none.done, none.total), (0, 1));
1627 assert_eq!(response.total_matches, 1);
1628
1629 let markers_only = vec![marked_file("/prose/", "prose.md", "the source is TK\n")];
1631 let response = run_query(&markers_only, &q, today());
1632 assert!(
1633 response.groups.is_empty(),
1634 "a marker must not conjure a calendar bucket: {:?}",
1635 response.groups
1636 );
1637 assert_eq!(response.total_matches, 0);
1638 }
1639
1640 #[test]
1641 fn include_filter_selects_tasks_markers_or_both() {
1642 let files = mixed_kind_file();
1643 for (include, expected) in [
1644 (
1645 IncludeFilter::All,
1646 vec![TaskKind::Marker, TaskKind::Task, TaskKind::Marker],
1647 ),
1648 (IncludeFilter::Tasks, vec![TaskKind::Task]),
1649 (IncludeFilter::Markers, vec![TaskKind::Marker; 2]),
1650 ] {
1651 let q = TaskQuery { include, ..query() };
1652 let response = run_query(&files, &q, today());
1653 assert_eq!(kinds(&response), expected, "for {include:?}");
1654 assert_eq!(response.total_matches, expected.len(), "for {include:?}");
1655 }
1656 }
1657
1658 #[test]
1659 fn markers_pass_the_default_status_and_priority_filters() {
1660 let files = mixed_kind_file();
1664 for q in [
1665 TaskQuery {
1666 statuses: vec![TaskStatus::Open],
1667 ..query()
1668 },
1669 TaskQuery {
1670 priorities: vec![TaskPriority::Normal],
1671 ..query()
1672 },
1673 ] {
1674 assert_eq!(run_query(&files, &q, today()).total_matches, 3);
1675 }
1676
1677 let done_only = TaskQuery {
1679 statuses: vec![TaskStatus::Done],
1680 ..query()
1681 };
1682 assert_eq!(run_query(&files, &done_only, today()).total_matches, 0);
1683 }
1684
1685 #[test]
1686 fn markers_are_dropped_by_a_dated_due_filter_but_kept_by_none() {
1687 let files = mixed_kind_file();
1688
1689 for due in [DueFilter::Overdue, DueFilter::Today, DueFilter::Upcoming] {
1692 let q = TaskQuery { due, ..query() };
1693 assert_eq!(
1694 run_query(&files, &q, today()).total_matches,
1695 0,
1696 "for {due:?}"
1697 );
1698 }
1699
1700 let undated = TaskQuery {
1701 due: DueFilter::NoDue,
1702 ..query()
1703 };
1704 assert_eq!(run_query(&files, &undated, today()).total_matches, 3);
1705 }
1706
1707 #[test]
1708 fn a_word_query_matches_marker_text_but_a_tag_query_never_does() {
1709 let files = vec![marked_file(
1712 "/notes/",
1713 "notes.md",
1714 "TODO cross-link this #docs\n",
1715 )];
1716 let word = TaskQuery {
1717 q: "cross-link".to_string(),
1718 ..query()
1719 };
1720 assert_eq!(run_query(&files, &word, today()).total_matches, 1);
1721
1722 let tag = TaskQuery {
1728 q: "#docs".to_string(),
1729 ..query()
1730 };
1731 assert_eq!(run_query(&files, &tag, today()).total_matches, 0);
1732 }
1733
1734 #[test]
1735 fn folder_facets_count_markers() {
1736 let files = vec![
1739 marked_file("/docs/notes/", "docs/notes.md", "the source is TK\n"),
1740 marked_file("/inbox/", "inbox.md", "- [ ] write the report\n"),
1741 ];
1742 let response = run_query(&files, &query(), today());
1743 let counts: Vec<(&str, u32)> = response
1744 .folders
1745 .iter()
1746 .map(|f| (f.path.as_str(), f.count))
1747 .collect();
1748 assert_eq!(counts, vec![("/", 2), ("/docs/", 1)]);
1749
1750 let tasks_only = TaskQuery {
1752 include: IncludeFilter::Tasks,
1753 ..query()
1754 };
1755 let response = run_query(&files, &tasks_only, today());
1756 assert_eq!(
1757 response
1758 .folders
1759 .iter()
1760 .map(|f| (f.path.as_str(), f.count))
1761 .collect::<Vec<_>>(),
1762 vec![("/", 1)]
1763 );
1764 }
1765
1766 #[test]
1767 fn a_group_of_only_markers_reports_zero_of_zero() {
1768 let files = vec![marked_file(
1771 "/prose/",
1772 "prose.md",
1773 "The market fell 10% (source: TK).\nand FIXME that too\n",
1774 )];
1775 let response = run_query(&files, &query(), today());
1776 let group = &response.groups[0];
1777 assert_eq!((group.done, group.total), (0, 0));
1778 assert_eq!(group.tasks.len(), 2);
1779 }
1780}