Skip to main content

mbr_core/
task_query.rs

1//! Pure filtering, grouping and counting for task queries.
2//!
3//! Everything here is a function of its arguments: no clock, no filesystem, no
4//! shared state. [`run_query`] takes a snapshot of [`FileTasks`] (from
5//! [`crate::task_index::TaskIndex::snapshot`]) plus the day to treat as "today"
6//! and returns the whole response body. The axum handler is a thin wrapper, the
7//! same split `SearchEngine::search` / `search_handler` uses.
8//!
9//! # Counting rules
10//!
11//! The two display modes count very differently, and the difference is the
12//! whole reason grouping lives on the server:
13//!
14//! | Mode | Group | `total` counts | `done` counts |
15//! |----------|--------------------|--------------------------------------------|-----------------|
16//! | Category | one per file | **every** task in the file, filters ignored | its `Done` ones |
17//! | Calendar | one per due bucket | tasks matching everything *except* status | its `Done` ones |
18//!
19//! Canceled tasks never count toward a total in either mode. In calendar mode
20//! they are dropped outright — they cannot appear, be counted, or create a
21//! bucket — because a canceled task has no meaningful due date.
22//!
23//! Incomplete markers ([`crate::tasks::TaskKind::Marker`]) are category-mode
24//! only, dropped in calendar mode by the same rule and for a stronger reason:
25//! they can never carry a due date. They are real matches everywhere else —
26//! `total_matches` and the folder facets count them — but never move a note's
27//! `done`/`total`, which [`FileTasks`] derives from checkboxes alone.
28//!
29//! `limit` truncates the tasks that are *returned*; it never affects a count.
30//! `total_matches` is the pre-truncation number of matching tasks.
31
32use 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
41/// Default cap on returned tasks.
42pub const DEFAULT_TASK_LIMIT: usize = 500;
43
44/// A task query, as posted to `/.mbr/tasks`.
45///
46/// Every field is optional; `{}` is a valid request meaning "all incomplete
47/// tasks in the repository, grouped by file".
48#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
49#[serde(default)]
50pub struct TaskQuery {
51    /// Filter text. Bare words match the display text; `#foo` matches tags.
52    /// See [`parse_task_query`].
53    pub q: String,
54    /// Restrict to a folder **and all of its subfolders**, e.g. `/docs/`.
55    pub folder: Option<String>,
56    /// Statuses to show. **Empty means incomplete only** — the spec's default
57    /// view — not "everything"; ask for everything with all three values.
58    pub statuses: Vec<TaskStatus>,
59    /// Priorities to show. Empty means all.
60    pub priorities: Vec<TaskPriority>,
61    /// Due-date filter.
62    pub due: DueFilter,
63    /// Which kinds of entry to show: checkboxes, incomplete markers, or both.
64    pub include: IncludeFilter,
65    /// How results are grouped.
66    pub mode: TaskMode,
67    /// Maximum number of tasks returned across all groups.
68    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/// Due-date filter. Mirrors [`DueBucket`] so the filter and the calendar
87/// headings can never disagree about what "tomorrow" means.
88#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
89#[serde(rename_all = "lowercase")]
90pub enum DueFilter {
91    /// No due-date restriction.
92    #[default]
93    Any,
94    /// Due strictly before today.
95    Overdue,
96    /// Due today.
97    Today,
98    /// Due tomorrow.
99    Tomorrow,
100    /// Due after tomorrow.
101    Upcoming,
102    /// No due date at all.
103    #[serde(rename = "none")]
104    NoDue,
105}
106
107impl DueFilter {
108    /// Whether a task in `bucket` passes this filter.
109    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/// Which kinds of entry a query wants back.
122///
123/// **Single-select, like [`DueFilter`], rather than an array like
124/// `statuses`.** The three options are mutually exclusive and exhaust the
125/// space, so an array would admit two states that mean nothing — the empty
126/// array, and both values at once — and the client would need exactly the
127/// "refuses to clear the last one" guard `statuses` already carries. One enum
128/// with a default makes both unrepresentable.
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
130#[serde(rename_all = "lowercase")]
131pub enum IncludeFilter {
132    /// Checkbox tasks and incomplete markers.
133    #[default]
134    All,
135    /// Checkbox tasks only.
136    Tasks,
137    /// Incomplete markers only.
138    Markers,
139}
140
141impl IncludeFilter {
142    /// Whether an entry of this kind passes the filter.
143    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/// How results are grouped.
153#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
154#[serde(rename_all = "lowercase")]
155pub enum TaskMode {
156    /// One group per file, tasks in source order.
157    #[default]
158    Category,
159    /// One group per due-date bucket.
160    Calendar,
161}
162
163/// Which calendar heading a task falls under.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
165pub enum DueBucket {
166    /// Due on a day that has already passed.
167    Overdue,
168    /// Due today.
169    Today,
170    /// Due tomorrow.
171    Tomorrow,
172    /// Due on a specific later day (one group per date).
173    Upcoming(NaiveDate),
174    /// No due date. Always last.
175    NoDue,
176}
177
178/// Places a due datetime in its calendar bucket relative to `today`.
179///
180/// Bucketing is by **date**, never by time of day: a task due today at 09:00 is
181/// still "Today" at 17:00, and only becomes overdue once the day has ended.
182/// That matches how `@due(2026-08-05)` with no time is stored (start of day) —
183/// a time-sensitive rule would make an all-day task overdue the moment it was
184/// created.
185///
186/// # Examples
187///
188/// ```
189/// use chrono::NaiveDate;
190/// use mbr_core::task_query::{DueBucket, due_bucket};
191///
192/// let today = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
193/// let due = NaiveDate::from_ymd_opt(2027, 1, 1).unwrap().and_hms_opt(9, 0, 0);
194/// // New Year's Day is "tomorrow" from New Year's Eve.
195/// assert_eq!(due_bucket(due, today), DueBucket::Tomorrow);
196/// assert_eq!(due_bucket(None, today), DueBucket::NoDue);
197/// ```
198pub 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    // `succ_opt` handles month and year rollover; it is `None` only at the end
210    // of chrono's representable range, where "tomorrow" does not exist and
211    // every future date is simply upcoming.
212    if today.succ_opt() == Some(date) {
213        return DueBucket::Tomorrow;
214    }
215    DueBucket::Upcoming(date)
216}
217
218/// The text filter, split into plain words and `#tag` tokens.
219#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct QueryTerms {
221    /// Lowercased bare words. Each must match the display text *or* a tag.
222    pub words: Vec<String>,
223    /// Lowercased `#tag` tokens (without the `#`). Each must match a tag.
224    pub tags: Vec<String>,
225}
226
227impl QueryTerms {
228    /// Whether this filter is satisfied by a task. All terms must match (AND).
229    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
242/// Whether any of the task's tags starts with `needle` (case-insensitive).
243///
244/// Prefix rather than substring: tags are short whole-word identifiers, and a
245/// substring rule would make `#work` match `#homework`. Prefix still lets the
246/// filter field narrow results while the user is still typing the tag.
247fn has_tag_prefix(task: &Task, needle: &str) -> bool {
248    task.tags.iter().any(|tag| {
249        // `get` rather than a slice: `needle` is arbitrary user input and may
250        // not land on a character boundary of `tag`.
251        tag.get(..needle.len())
252            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(needle))
253    })
254}
255
256/// Splits a filter string into words and tags.
257///
258/// A token beginning with `#` and carrying at least one more character is a tag
259/// token; everything else is a bare word. Matching is case-insensitive, so both
260/// are lowercased here.
261///
262/// Bare words match the display text **or** a tag. Annotations are stripped out
263/// of [`Task::text`], so a task rendered as `review  #work` has the literal text
264/// `review` — a user who sees the `#work` pill and types `work` would otherwise
265/// get nothing back.
266///
267/// # Examples
268///
269/// ```
270/// use mbr_core::task_query::parse_task_query;
271///
272/// let terms = parse_task_query("Report  #Work");
273/// assert_eq!(terms.words, ["report"]);
274/// assert_eq!(terms.tags, ["work"]);
275///
276/// // A lone `#` is not a tag token.
277/// assert_eq!(parse_task_query("#").words, ["#"]);
278/// ```
279pub 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
290/// Normalizes a folder filter to a `/`-delimited prefix, or `None` for
291/// "everywhere".
292///
293/// Both slashes are forced on so the prefix test cannot half-match a sibling:
294/// `/doc` must not scope to `/docs/`. The repository root (`/` or an empty
295/// string) is treated as no filter at all.
296///
297/// # Examples
298///
299/// ```
300/// use mbr_core::task_query::normalize_folder;
301///
302/// assert_eq!(normalize_folder(Some("docs")).as_deref(), Some("/docs/"));
303/// assert_eq!(normalize_folder(Some("/docs/")).as_deref(), Some("/docs/"));
304/// assert_eq!(normalize_folder(Some("/")), None);
305/// assert_eq!(normalize_folder(None), None);
306/// ```
307pub 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/// One task in a response: every [`Task`] field, plus where it lives.
316///
317/// Two locations, deliberately, because they answer different questions:
318/// `url_path` is where a reader goes, `path` is what a writer patches.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
320pub struct TaskHit {
321    /// The task itself, flattened so `line`, `status`, `text` … are top-level.
322    #[serde(flatten)]
323    pub task: Task,
324    /// URL of the page containing the task.
325    pub url_path: String,
326    /// Source path **relative to the repository root**, extension included and
327    /// always `/`-separated — exactly what `POST /.mbr/task` wants as `path`.
328    ///
329    /// Sent rather than left to the client to reconstruct, because `url_path`
330    /// does not determine it: an index file's URL *is* its folder
331    /// (`docs/index.md` → `/docs/`), the static-folder overlay hides a whole
332    /// directory level, and the extension is dropped. Any string surgery that
333    /// recovered the file for one repository would silently patch the wrong
334    /// one — or nothing — in another.
335    pub path: String,
336}
337
338/// One heading in the results list, with its tasks and progress numbers.
339#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
340pub struct TaskGroup {
341    /// Stable identifier, unique within a response. The file URL in category
342    /// mode; `overdue` / `today` / `tomorrow` / `upcoming:<date>` / `none` in
343    /// calendar mode. Suitable as a collapse-state key.
344    pub key: String,
345    /// Heading text: the note title, or the calendar bucket's name.
346    pub label: String,
347    /// Smaller secondary heading: the note's folder in category mode, empty in
348    /// calendar mode.
349    pub sublabel: String,
350    /// Page to open when the heading is clicked; `None` for calendar buckets,
351    /// which are not a place.
352    pub url_path: Option<String>,
353    /// The date this group covers, for `Today`/`Tomorrow`/`Upcoming` buckets.
354    /// `None` in category mode and for `Overdue`/no-due-date.
355    pub date: Option<NaiveDate>,
356    /// Completed tasks counted by this group's rule (see the module docs).
357    pub done: u32,
358    /// Total tasks counted by this group's rule.
359    pub total: u32,
360    /// The matching tasks, already ordered and truncated.
361    pub tasks: Vec<TaskHit>,
362}
363
364/// A folder and the number of matching tasks at or below it.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
366pub struct FolderFacet {
367    /// Folder path with leading and trailing slashes; `/` is the whole repo.
368    pub path: String,
369    /// Matching tasks in this folder **and its subfolders**.
370    pub count: u32,
371}
372
373/// The `/.mbr/tasks` response body.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
375pub struct TaskQueryResponse {
376    /// Result groups, in display order.
377    pub groups: Vec<TaskGroup>,
378    /// Folder facet counts for the folder pane, computed *ignoring* the folder
379    /// filter so that selecting a folder does not empty out its siblings.
380    pub folders: Vec<FolderFacet>,
381    /// Matching tasks before `limit` was applied.
382    pub total_matches: usize,
383    /// Server-side query time.
384    pub duration_ms: u64,
385    /// True when the repository scan is still running, so the caller knows the
386    /// results are partial. Set by the handler.
387    pub scan_in_progress: bool,
388}
389
390/// Everything a task is tested against, resolved once per query.
391struct 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            // An empty selection is the spec's default view: incomplete only.
406            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    /// Whether an entry may take part in this query *at all* — before any
420    /// question about which group it lands in or whether it matches the text.
421    ///
422    /// Two rules live here, and they live here rather than in
423    /// [`Self::matches_except_status`] because [`group_by_due`] consults this
424    /// one **before** incrementing a bucket's `total`, so anything rejected
425    /// cannot leak into a count or conjure a bucket that has no visible tasks:
426    ///
427    /// - the `include` filter, which is a property of the entry and not of the
428    ///   view;
429    /// - calendar mode, which drops canceled tasks (no meaningful due date) and
430    ///   incomplete markers (no due date at all, and nothing to complete). A
431    ///   marker would otherwise pile into the "No due date" bucket, which is
432    ///   the one calendar heading that is a place for tasks whose deadline is
433    ///   still to be set.
434    ///
435    /// Everything else about a marker falls out of the ordinary filters without
436    /// a special case: its bucket is `NoDue`, its priority is `Normal` and its
437    /// status is `Open`.
438    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    /// Every filter *except* status. This is the predicate the calendar-mode
449    /// progress counts use, so a bucket's `3/7` does not shrink when the user
450    /// narrows the view to incomplete tasks.
451    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
466/// Runs a task query over an index snapshot.
467///
468/// `today` is a parameter rather than a `Local::now()` call so the bucketing is
469/// testable without mocking the clock; the handler supplies the real one.
470///
471/// `scan_in_progress` is left `false`; the handler fills it in from the repo,
472/// mirroring `search_handler`.
473pub 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
505/// Whether a file is inside the (already normalized) folder scope.
506///
507/// Prefix matching is what makes the scope include subfolders: `/docs/` matches
508/// `/docs/`, `/docs/notes/`, and everything below.
509fn in_folder(file: &FileTasks, folder: Option<&str>) -> bool {
510    folder.is_none_or(|scope| file.folder().starts_with(scope))
511}
512
513/// Category mode: one group per file, tasks in source order.
514///
515/// `done`/`total` come straight off [`FileTasks`], so they describe the *file*,
516/// not the view — a file showing one matching task can still read `3/7`.
517fn group_by_file(files: &[&Arc<FileTasks>], filters: &Filters) -> Vec<TaskGroup> {
518    let mut groups: Vec<TaskGroup> = files
519        .iter()
520        .filter_map(|file| {
521            // Built once per file, not once per hit: `path_to_url` allocates.
522            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    // Sorted by URL so notes in the same folder stay together and the order is
552    // stable across requests (the index is an unordered concurrent map).
553    groups.sort_by(|a, b| a.key.cmp(&b.key));
554    groups
555}
556
557/// Calendar mode: one group per due-date bucket.
558///
559/// Counts are filtered by everything except status, per the spec, so toggling
560/// "show completed" moves tasks in and out of the list without moving the
561/// progress bar.
562fn group_by_due(files: &[&Arc<FileTasks>], filters: &Filters) -> Vec<TaskGroup> {
563    // `BTreeMap` keyed by the bucket gives the display order for free:
564    // `DueBucket`'s variants are declared Overdue → Today → Tomorrow →
565    // Upcoming(date, ascending) → NoDue, and its derived `Ord` follows.
566    let mut buckets: BTreeMap<DueBucket, BucketAccumulator> = BTreeMap::new();
567
568    for file in files {
569        // Hoisted out of the task loop for the same reason as in
570        // `group_by_file`: one allocation per file rather than one per hit.
571        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            // Chronological within a bucket, then by page and line so the order
601            // never depends on the index's iteration order.
602            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            // Overdue deliberately carries no progress: a backlog of missed
611            // deadlines has no meaningful denominator.
612            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/// Per-bucket working state while grouping.
633#[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        // ISO here, not a formatted date: the frontend renders it in the user's
656        // locale, and a server-side format would hard-code English months.
657        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
671/// Counts matching tasks per folder, cumulatively up the tree.
672///
673/// Computed **without** the folder filter, because these numbers drive the
674/// folder pane: scoping them to the current selection would zero out every
675/// folder the user might want to switch to. Each task increments its own folder
676/// and every ancestor, so `/` always carries the repository total and a parent's
677/// count is what selecting it would show.
678fn 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        // `/docs/notes/` contributes to `/`, `/docs/` and `/docs/notes/`. Slice
695        // boundaries land on `/`, which is ASCII, so this cannot split a
696        // multi-byte character.
697        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
714/// Applies `limit` across the ordered groups, dropping groups left with nothing.
715///
716/// Counts are already computed, so truncation never changes a `done`/`total`
717/// pair — only which tasks came back.
718fn 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    /// Today for every test below, unless a test says otherwise.
739    fn today() -> NaiveDate {
740        day(2026, 8, 4)
741    }
742
743    /// Builds a `FileTasks` by scanning a markdown body, so tests exercise the
744    /// real parser rather than hand-built `Task` values.
745    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    /// [`file`], with the default `incomplete_markers` turned on, so the entries
755    /// come back merged and in source order exactly as the index stores them.
756    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    /// Every returned entry's kind, in response order.
771    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    /// Every returned task's display text, in response order.
784    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    // ---- parse_task_query ----------------------------------------------------
793
794    #[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        // The tag is stripped out of the display text, so a user who sees the
853        // `#work` pill and types `work` must still get the task.
854        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    // ---- normalize_folder ----------------------------------------------------
866
867    #[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    // ---- due_bucket ----------------------------------------------------------
887
888    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        // Bucketing is by date: 09:00 today is still "Today" all day long.
908        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    // ---- status filtering ----------------------------------------------------
970
971    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    // ---- folder scoping ------------------------------------------------------
1056
1057    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        // The view is scoped...
1103        assert_eq!(texts(&response), ["in notes"]);
1104        // ...but the facets still describe the whole repo, so the folder pane
1105        // can offer somewhere else to go.
1106        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    // ---- category mode -------------------------------------------------------
1138
1139    #[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        // Source order, which is line order — not the order they were written
1156        // about above.
1157        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        // Seven tracked tasks (three done), plus a canceled one that counts for
1179        // nothing, and only one of them matches the text filter.
1180        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    // ---- calendar mode -------------------------------------------------------
1248
1249    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                // Filtered out by the text query, so it is not counted at all.
1330                "- [ ] 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        // The view shows only the open task (default status filter)...
1342        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        // ...but the progress covers every task matching the text filter.
1351        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        // Neither canceled task is in the denominator.
1370        assert_eq!((response.groups[0].done, response.groups[0].total), (0, 1));
1371
1372        // Even asking for canceled tasks explicitly shows none in this mode.
1373        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    // ---- limits --------------------------------------------------------------
1401
1402    #[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        // The first group's own progress is untouched by the cut.
1417        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    // ---- shape ---------------------------------------------------------------
1452
1453    #[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        // Two files whose URLs are indistinguishable from a folder: only
1487        // `path` says which file `POST /.mbr/task` must open.
1488        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        // The container-level `#[serde(default)]` reads this off the manual
1540        // `Default` impl, so a client that predates the field still gets both
1541        // kinds rather than an empty panel.
1542        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        // Single-select, lowercase on the wire, like `due` and `mode`.
1564        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    // ---- incomplete markers --------------------------------------------------
1576
1577    /// One file carrying both kinds, interleaved, so source order is testable.
1578    fn mixed_kind_file() -> Vec<Arc<FileTasks>> {
1579        vec![marked_file(
1580            "/notes/",
1581            "notes.md",
1582            concat!(
1583                "TK verify this figure\n",   // 1, marker
1584                "\n",                        // 2
1585                "- [ ] write the report\n",  // 3, task
1586                "prose with a FIXME here\n"  // 4, marker
1587            ),
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        // The one bucket a marker could have landed in is the undated one, and
1619        // `admissible` runs before the bucket total is incremented — so the
1620        // markers neither create it nor inflate it.
1621        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        // And with nothing but markers there is no bucket at all.
1630        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        // Nothing about a marker is special-cased in `matches_except_status` or
1661        // `matches_status`: it is `Open` and `Normal`, so the default view shows
1662        // it and an explicit filter for those values still does.
1663        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        // ...and a filter they cannot satisfy drops them, with no extra rule.
1678        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        // A marker has no due date, so `due_bucket(None)` is `NoDue` and every
1690        // dated filter excludes it without a kind check.
1691        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        // A marker's text is the whole line, so a bare word searches it the way
1710        // it searches a task.
1711        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        // But a `#tag` token matches *tags*, and a marker has none — even when
1723        // the line literally contains `#docs`. That is intentional: markers get
1724        // no annotation parsing, so `#docs` here is prose the author wrote, not
1725        // a tag they applied, and letting it match would file the entry under a
1726        // label that exists nowhere else.
1727        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        // Markers are real matches, so the folder pane counts them and its
1737        // numbers keep agreeing with what selecting a folder would show.
1738        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        // Narrowing to tasks only takes the marker back out of the facet.
1751        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        // The progress bar describes checkboxes, and this file has none — so
1769        // the heading reads 0/0 rather than a total nobody can ever complete.
1770        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}