Skip to main content

things3_core/
query.rs

1//! Query builder for filtering and searching tasks
2
3use crate::models::{TaskFilters, TaskStatus, TaskType, ThingsId};
4use chrono::{Datelike, Duration, NaiveDate, Utc};
5
6/// Builder for constructing task queries with filters
7#[derive(Debug, Clone)]
8pub struct TaskQueryBuilder {
9    filters: TaskFilters,
10    /// OR-semantics / exclusion / count tag filters applied by `execute()` in Rust.
11    /// Kept off `TaskFilters` to preserve the stable public struct surface.
12    /// Only present in `advanced-queries` builds (same gate as `execute()`).
13    #[cfg(feature = "advanced-queries")]
14    any_tags: Option<Vec<String>>,
15    #[cfg(feature = "advanced-queries")]
16    exclude_tags: Option<Vec<String>>,
17    #[cfg(feature = "advanced-queries")]
18    tag_count_min: Option<usize>,
19    #[cfg(feature = "advanced-queries")]
20    fuzzy_query: Option<String>,
21    #[cfg(feature = "advanced-queries")]
22    fuzzy_threshold: Option<f32>,
23    #[cfg(feature = "advanced-queries")]
24    where_expr: Option<crate::filter_expr::FilterExpr>,
25    /// Cursor for keyset pagination via `execute_paged`. Stored on the builder
26    /// because `TaskFilters` is frozen public API.
27    #[cfg(feature = "batch-operations")]
28    after: Option<crate::cursor::Cursor>,
29}
30
31impl TaskQueryBuilder {
32    /// Create a new query builder
33    #[must_use]
34    pub fn new() -> Self {
35        Self {
36            filters: TaskFilters::default(),
37            #[cfg(feature = "advanced-queries")]
38            any_tags: None,
39            #[cfg(feature = "advanced-queries")]
40            exclude_tags: None,
41            #[cfg(feature = "advanced-queries")]
42            tag_count_min: None,
43            #[cfg(feature = "advanced-queries")]
44            fuzzy_query: None,
45            #[cfg(feature = "advanced-queries")]
46            fuzzy_threshold: None,
47            #[cfg(feature = "advanced-queries")]
48            where_expr: None,
49            #[cfg(feature = "batch-operations")]
50            after: None,
51        }
52    }
53
54    /// Filter by status
55    #[must_use]
56    pub const fn status(mut self, status: TaskStatus) -> Self {
57        self.filters.status = Some(status);
58        self
59    }
60
61    /// Filter by task type
62    #[must_use]
63    pub const fn task_type(mut self, task_type: TaskType) -> Self {
64        self.filters.task_type = Some(task_type);
65        self
66    }
67
68    /// Filter by project UUID
69    #[must_use]
70    pub fn project_uuid(mut self, project_uuid: ThingsId) -> Self {
71        self.filters.project_uuid = Some(project_uuid);
72        self
73    }
74
75    /// Filter by area UUID
76    #[must_use]
77    pub fn area_uuid(mut self, area_uuid: ThingsId) -> Self {
78        self.filters.area_uuid = Some(area_uuid);
79        self
80    }
81
82    /// Filter by tags (AND semantics — task must contain every listed tag).
83    #[must_use]
84    pub fn tags(mut self, tags: Vec<String>) -> Self {
85        self.filters.tags = Some(tags);
86        self
87    }
88
89    /// Filter to tasks containing **any** of these tags (OR semantics).
90    ///
91    /// Composes with `.tags()` (AND) and `.exclude_tags()` (NOT) — all
92    /// active tag filters must be satisfied. Applied in Rust by `execute()`;
93    /// not reflected in `build()`.
94    ///
95    /// Requires the `advanced-queries` feature flag.
96    #[cfg(feature = "advanced-queries")]
97    #[must_use]
98    pub fn any_tags(mut self, tags: Vec<String>) -> Self {
99        self.any_tags = Some(tags);
100        self
101    }
102
103    /// Filter out tasks containing any of these tags. Applied in Rust by `execute()`;
104    /// not reflected in `build()`.
105    ///
106    /// Requires the `advanced-queries` feature flag.
107    #[cfg(feature = "advanced-queries")]
108    #[must_use]
109    pub fn exclude_tags(mut self, tags: Vec<String>) -> Self {
110        self.exclude_tags = Some(tags);
111        self
112    }
113
114    /// Filter to tasks with at least `min` tags total. Applied in Rust by `execute()`;
115    /// not reflected in `build()`.
116    ///
117    /// Requires the `advanced-queries` feature flag.
118    #[cfg(feature = "advanced-queries")]
119    #[must_use]
120    pub fn tag_count(mut self, min: usize) -> Self {
121        self.tag_count_min = Some(min);
122        self
123    }
124
125    /// Filter and rank tasks by fuzzy similarity to `query` (title and notes).
126    ///
127    /// Scores are computed with windowed Levenshtein; only tasks meeting the
128    /// threshold (default `0.6`, tunable via `fuzzy_threshold`) are returned.
129    /// If `.search()` is also set, fuzzy wins and a warning is logged.
130    ///
131    /// Applied in Rust by `execute()` / `execute_ranked()`; not reflected in `build()`.
132    ///
133    /// Requires the `advanced-queries` feature flag.
134    #[cfg(feature = "advanced-queries")]
135    #[must_use]
136    pub fn fuzzy_search(mut self, query: &str) -> Self {
137        self.fuzzy_query = Some(query.to_string());
138        self
139    }
140
141    /// Override the minimum fuzzy-match score threshold (clamped to `[0.0, 1.0]`).
142    /// Defaults to `0.6` when not called.
143    ///
144    /// Requires the `advanced-queries` feature flag.
145    #[cfg(feature = "advanced-queries")]
146    #[must_use]
147    pub fn fuzzy_threshold(mut self, threshold: f32) -> Self {
148        self.fuzzy_threshold = Some(threshold.clamp(0.0, 1.0));
149        self
150    }
151
152    /// Apply a boolean expression tree as an additional filter.
153    ///
154    /// The expression composes via AND with the SQL pre-fetch (`TaskFilters`)
155    /// and with the other post-filters (`any_tags`, `exclude_tags`,
156    /// `tag_count`, fuzzy search). To express disjunction or negation across
157    /// statuses or types, leave `filters.status` / `filters.task_type` unset
158    /// and put the OR/NOT branches inside `expr` instead.
159    ///
160    /// Evaluated in Rust by `execute()` after the database returns rows; not
161    /// reflected in `build()`.
162    ///
163    /// Requires the `advanced-queries` feature flag.
164    #[cfg(feature = "advanced-queries")]
165    #[must_use]
166    pub fn where_expr(mut self, expr: crate::filter_expr::FilterExpr) -> Self {
167        self.where_expr = Some(expr);
168        self
169    }
170
171    /// Continue cursor-based pagination from a previously-returned [`crate::cursor::Cursor`].
172    ///
173    /// The cursor identifies the last task delivered on the previous page;
174    /// [`Self::execute_paged`] will return tasks strictly after it in the
175    /// `(creationDate DESC, uuid DESC)` ordering. Mutually exclusive with
176    /// `.offset(...)` — `execute_paged` will return [`crate::error::ThingsError::InvalidCursor`]
177    /// if both are set.
178    ///
179    /// Requires the `batch-operations` feature flag.
180    #[cfg(feature = "batch-operations")]
181    #[must_use]
182    pub fn after(mut self, cursor: crate::cursor::Cursor) -> Self {
183        self.after = Some(cursor);
184        self
185    }
186
187    /// Filter by start date range
188    #[must_use]
189    pub const fn start_date_range(
190        mut self,
191        from: Option<NaiveDate>,
192        to: Option<NaiveDate>,
193    ) -> Self {
194        self.filters.start_date_from = from;
195        self.filters.start_date_to = to;
196        self
197    }
198
199    /// Filter by deadline range
200    #[must_use]
201    pub const fn deadline_range(mut self, from: Option<NaiveDate>, to: Option<NaiveDate>) -> Self {
202        self.filters.deadline_from = from;
203        self.filters.deadline_to = to;
204        self
205    }
206
207    /// Add search query
208    #[must_use]
209    pub fn search(mut self, query: &str) -> Self {
210        self.filters.search_query = Some(query.to_string());
211        self
212    }
213
214    /// Set limit
215    #[must_use]
216    pub const fn limit(mut self, limit: usize) -> Self {
217        self.filters.limit = Some(limit);
218        self
219    }
220
221    /// Set offset for pagination
222    #[must_use]
223    pub const fn offset(mut self, offset: usize) -> Self {
224        self.filters.offset = Some(offset);
225        self
226    }
227
228    /// Filter to tasks whose deadline is today.
229    #[must_use]
230    pub fn due_today(self) -> Self {
231        let today = today();
232        self.deadline_range(Some(today), Some(today))
233    }
234
235    /// Filter to tasks whose deadline falls between today and the upcoming Sunday
236    /// (Monday-Sunday week).
237    #[must_use]
238    pub fn due_this_week(self) -> Self {
239        let today = today();
240        self.deadline_range(Some(today), Some(end_of_week(today)))
241    }
242
243    /// Filter to tasks whose deadline falls in next calendar week (next Monday
244    /// through Sunday, Monday-Sunday week).
245    #[must_use]
246    pub fn due_next_week(self) -> Self {
247        let today = today();
248        let next_monday = end_of_week(today) + Duration::days(1);
249        self.deadline_range(Some(next_monday), Some(end_of_week(next_monday)))
250    }
251
252    /// Filter to tasks whose deadline is between today and `days` days from now (inclusive).
253    #[must_use]
254    pub fn due_in(self, days: i64) -> Self {
255        let today = today();
256        self.deadline_range(Some(today), Some(today + Duration::days(days)))
257    }
258
259    /// Filter to overdue tasks: deadline strictly before today.
260    ///
261    /// If no `status` filter has already been set, this also restricts results
262    /// to incomplete tasks (a completed task isn't meaningfully overdue). An
263    /// explicit `.status(...)` call before this helper is preserved.
264    #[must_use]
265    pub fn overdue(mut self) -> Self {
266        let yesterday = today() - Duration::days(1);
267        self.filters.deadline_from = None;
268        self.filters.deadline_to = Some(yesterday);
269        if self.filters.status.is_none() {
270            self.filters.status = Some(TaskStatus::Incomplete);
271        }
272        self
273    }
274
275    /// Filter to tasks with a start date of today.
276    #[must_use]
277    pub fn starting_today(self) -> Self {
278        let today = today();
279        self.start_date_range(Some(today), Some(today))
280    }
281
282    /// Filter to tasks with a start date between today and the upcoming Sunday
283    /// (Monday-Sunday week).
284    #[must_use]
285    pub fn starting_this_week(self) -> Self {
286        let today = today();
287        self.start_date_range(Some(today), Some(end_of_week(today)))
288    }
289
290    /// Build the final filters
291    #[must_use]
292    pub fn build(self) -> TaskFilters {
293        self.filters
294    }
295
296    /// Execute the query against a live database connection.
297    ///
298    /// SQL-level filters and the `tags` AND-filter are handled by `query_tasks`.
299    /// Builder-only predicates (`any_tags`, `exclude_tags`, `tag_count`,
300    /// `fuzzy_search`) are applied in Rust afterward; when any are active,
301    /// `limit`/`offset` pagination is deferred to Rust so pages count only
302    /// matching rows. When `fuzzy_search` is set, this delegates to
303    /// `execute_ranked` and strips scores.
304    ///
305    /// Requires the `advanced-queries` feature flag.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the database query fails or task data cannot be mapped.
310    #[cfg(feature = "advanced-queries")]
311    pub async fn execute(
312        &self,
313        db: &crate::database::ThingsDatabase,
314    ) -> crate::error::Result<Vec<crate::models::Task>> {
315        if self.fuzzy_query.is_some() {
316            return self
317                .execute_ranked(db)
318                .await
319                .map(|ranked| ranked.into_iter().map(|r| r.task).collect());
320        }
321
322        let has_tag_post_filters = self.any_tags.as_ref().is_some_and(|t| !t.is_empty())
323            || self.exclude_tags.as_ref().is_some_and(|t| !t.is_empty())
324            || self.tag_count_min.is_some();
325        let has_where_expr = self.where_expr.is_some();
326
327        if !has_tag_post_filters && !has_where_expr {
328            return db.query_tasks(&self.filters).await;
329        }
330
331        let mut filters_no_page = self.filters.clone();
332        let limit = filters_no_page.limit.take();
333        let offset = filters_no_page.offset.take();
334
335        let tasks = db.query_tasks(&filters_no_page).await?;
336        let mut tasks = Self::apply_tag_filters(
337            tasks,
338            self.any_tags.as_deref(),
339            self.exclude_tags.as_deref(),
340            self.tag_count_min,
341        );
342
343        if let Some(expr) = &self.where_expr {
344            tasks.retain(|task| expr.matches(task));
345        }
346
347        let offset = offset.unwrap_or(0);
348        tasks = tasks.into_iter().skip(offset).collect();
349        if let Some(limit) = limit {
350            tasks.truncate(limit);
351        }
352
353        Ok(tasks)
354    }
355
356    #[cfg(feature = "advanced-queries")]
357    fn apply_tag_filters(
358        mut tasks: Vec<crate::models::Task>,
359        any_tags: Option<&[String]>,
360        exclude_tags: Option<&[String]>,
361        tag_count_min: Option<usize>,
362    ) -> Vec<crate::models::Task> {
363        if let Some(any) = any_tags {
364            if !any.is_empty() {
365                tasks.retain(|task| any.iter().any(|f| task.tags.contains(f)));
366            }
367        }
368        if let Some(excl) = exclude_tags {
369            if !excl.is_empty() {
370                tasks.retain(|task| !excl.iter().any(|f| task.tags.contains(f)));
371            }
372        }
373        if let Some(min) = tag_count_min {
374            tasks.retain(|task| task.tags.len() >= min);
375        }
376        tasks
377    }
378
379    /// Execute the query and return one page of results plus an optional
380    /// cursor for the next page.
381    ///
382    /// Pagination is keyset-based: the cursor encodes `(created, uuid)` of the
383    /// last-returned task and the next page admits only rows strictly older in
384    /// the canonical `(creationDate DESC, uuid DESC)` ordering. Unlike
385    /// `offset`, page boundaries are stable when underlying data changes
386    /// between fetches.
387    ///
388    /// Page size is `self.filters.limit` if set, otherwise `100`. The returned
389    /// `Page::next_cursor` is `Some` only if the page is full (i.e. there may
390    /// be more rows); the last page always has `next_cursor: None`.
391    ///
392    /// Requires both the `advanced-queries` and `batch-operations` feature
393    /// flags (cursor pagination is built on top of `query_tasks`).
394    ///
395    /// # Errors
396    ///
397    /// - [`crate::error::ThingsError::InvalidCursor`] if `.offset(...)` and
398    ///   `.after(...)` are both set, or if `.fuzzy_search(...)` and
399    ///   `.after(...)` are both set, or if the cursor itself is malformed.
400    /// - Any error returned by [`crate::database::ThingsDatabase::query_tasks`].
401    #[cfg(all(feature = "advanced-queries", feature = "batch-operations"))]
402    pub async fn execute_paged(
403        &self,
404        db: &crate::database::ThingsDatabase,
405    ) -> crate::error::Result<crate::cursor::Page<crate::models::Task>> {
406        if self.fuzzy_query.is_some() {
407            return Err(crate::error::ThingsError::InvalidCursor(
408                "execute_paged and execute_stream do not support fuzzy_search; use execute_ranked instead".to_string(),
409            ));
410        }
411        if self.filters.offset.is_some() && self.after.is_some() {
412            return Err(crate::error::ThingsError::InvalidCursor(
413                "offset and after are mutually exclusive".to_string(),
414            ));
415        }
416
417        let after_payload = self
418            .after
419            .as_ref()
420            .map(crate::cursor::Cursor::decode)
421            .transpose()?;
422        let after_anchor = after_payload.as_ref().map(|p| (p.c.timestamp(), p.u));
423
424        let page_size = self.filters.limit.unwrap_or(DEFAULT_PAGE_SIZE);
425
426        // Build a TaskFilters with the page size applied at the SQL layer when
427        // there are no post-filters; with post-filters, defer to Rust below.
428        let has_tag_post_filters = self.any_tags.as_ref().is_some_and(|t| !t.is_empty())
429            || self.exclude_tags.as_ref().is_some_and(|t| !t.is_empty())
430            || self.tag_count_min.is_some();
431        let has_where_expr = self.where_expr.is_some();
432        let has_post_filters = has_tag_post_filters || has_where_expr;
433
434        let mut filters = self.filters.clone();
435        filters.offset = None;
436        if has_post_filters {
437            // SQL fetches everything; Rust does the slicing.
438            filters.limit = None;
439        } else {
440            filters.limit = Some(page_size);
441        }
442
443        let mut tasks = db.query_tasks_inner(&filters, after_anchor).await?;
444
445        if has_tag_post_filters {
446            tasks = Self::apply_tag_filters(
447                tasks,
448                self.any_tags.as_deref(),
449                self.exclude_tags.as_deref(),
450                self.tag_count_min,
451            );
452        }
453        if let Some(expr) = &self.where_expr {
454            tasks.retain(|task| expr.matches(task));
455        }
456        if has_post_filters {
457            tasks.truncate(page_size);
458        }
459
460        let next_cursor = if tasks.len() == page_size {
461            tasks
462                .last()
463                .map(|last| {
464                    let u = uuid::Uuid::parse_str(last.uuid.as_str()).map_err(|e| {
465                        crate::error::ThingsError::InvalidCursor(format!(
466                            "task uuid is not RFC-4122: {e}"
467                        ))
468                    })?;
469                    let payload = crate::cursor::CursorPayload { c: last.created, u };
470                    crate::cursor::Cursor::encode(&payload)
471                })
472                .transpose()?
473        } else {
474            None
475        };
476
477        Ok(crate::cursor::Page {
478            items: tasks,
479            next_cursor,
480        })
481    }
482
483    /// Execute the query as a [`futures_core::Stream`] of tasks, internally
484    /// chunked via cursor pagination.
485    ///
486    /// Yields tasks one at a time in `(creationDate DESC, uuid DESC)` order,
487    /// transparently fetching the next page when the current one is exhausted.
488    /// The stream completes when the underlying query has no more rows.
489    ///
490    /// `self.filters.limit` (overridable via [`limit`](Self::limit)) sets the
491    /// **chunk size** in this context, not a cap on total emitted items —
492    /// defaults to `100` if unset. Pre-filters and post-filters
493    /// (`status`, `any_tags`, `where_expr`, etc.) compose with streaming
494    /// exactly as they do with [`execute_paged`](Self::execute_paged).
495    ///
496    /// The first item is `Err(ThingsError)` if the underlying `execute_paged`
497    /// call rejects the query (e.g. `.offset()` and `.after()` both set, or
498    /// `.fuzzy_search()` and `.after()` both set). After any error, the
499    /// stream terminates.
500    ///
501    /// Requires both the `advanced-queries` and `batch-operations` feature
502    /// flags.
503    ///
504    /// # Examples
505    ///
506    /// ```ignore
507    /// use futures_util::StreamExt;
508    /// let mut stream = TaskQueryBuilder::new()
509    ///     .status(TaskStatus::Incomplete)
510    ///     .limit(50) // chunk size, not total cap
511    ///     .execute_stream(&db);
512    /// while let Some(task) = stream.next().await {
513    ///     let task = task?;
514    ///     // process task
515    /// }
516    /// # Ok::<(), things3_core::ThingsError>(())
517    /// ```
518    #[cfg(all(feature = "advanced-queries", feature = "batch-operations"))]
519    pub fn execute_stream<'a>(
520        mut self,
521        db: &'a crate::database::ThingsDatabase,
522    ) -> std::pin::Pin<
523        Box<dyn futures_core::Stream<Item = crate::error::Result<crate::models::Task>> + Send + 'a>,
524    >
525    where
526        Self: Send + 'a,
527    {
528        Box::pin(async_stream::try_stream! {
529            loop {
530                let page = self.execute_paged(db).await?;
531                let next = page.next_cursor;
532                for task in page.items {
533                    yield task;
534                }
535                match next {
536                    Some(c) => self.after = Some(c),
537                    None => break,
538                }
539            }
540        })
541    }
542
543    /// Execute the query and return tasks paired with their fuzzy-match scores,
544    /// sorted by score descending (ties broken by UUID for determinism).
545    ///
546    /// Requires `.fuzzy_search(query)` to be set — returns a validation error
547    /// otherwise (it is a programming error to ask for ranked results with no
548    /// fuzzy predicate).
549    ///
550    /// Requires the `advanced-queries` feature flag.
551    ///
552    /// # Errors
553    ///
554    /// Returns an error if `fuzzy_search` is not set, or if the database query fails.
555    #[cfg(feature = "advanced-queries")]
556    pub async fn execute_ranked(
557        &self,
558        db: &crate::database::ThingsDatabase,
559    ) -> crate::error::Result<Vec<crate::models::RankedTask>> {
560        let query = self.fuzzy_query.as_deref().ok_or_else(|| {
561            crate::error::ThingsError::validation(
562                "execute_ranked requires fuzzy_search() to be set",
563            )
564        })?;
565
566        let query_lc = query.to_lowercase();
567        let threshold = self.fuzzy_threshold.unwrap_or(DEFAULT_FUZZY_THRESHOLD);
568
569        let mut filters_no_page = self.filters.clone();
570        let limit = filters_no_page.limit.take();
571        let offset = filters_no_page.offset.take();
572
573        if filters_no_page.search_query.is_some() {
574            tracing::warn!(
575                "fuzzy_search and search both set; fuzzy takes precedence, ignoring substring search"
576            );
577            filters_no_page.search_query = None;
578        }
579
580        let tasks = db.query_tasks(&filters_no_page).await?;
581        let mut tasks = Self::apply_tag_filters(
582            tasks,
583            self.any_tags.as_deref(),
584            self.exclude_tags.as_deref(),
585            self.tag_count_min,
586        );
587
588        if let Some(expr) = &self.where_expr {
589            tasks.retain(|task| expr.matches(task));
590        }
591
592        let mut scored: Vec<crate::models::RankedTask> = tasks
593            .into_iter()
594            .filter_map(|task| {
595                let score = task_fuzzy_score(&query_lc, &task);
596                if score >= threshold {
597                    Some(crate::models::RankedTask { task, score })
598                } else {
599                    None
600                }
601            })
602            .collect();
603
604        scored.sort_by(|a, b| {
605            b.score
606                .partial_cmp(&a.score)
607                .unwrap_or(std::cmp::Ordering::Equal)
608                .then_with(|| a.task.uuid.as_str().cmp(b.task.uuid.as_str()))
609        });
610
611        let offset = offset.unwrap_or(0);
612        scored = scored.into_iter().skip(offset).collect();
613        if let Some(limit) = limit {
614            scored.truncate(limit);
615        }
616
617        Ok(scored)
618    }
619
620    /// Snapshot the full builder state — both [`TaskFilters`] and the
621    /// builder-only post-1.0.0 predicates — into a [`crate::saved_queries::SavedQuery`].
622    ///
623    /// Combine with [`crate::saved_queries::SavedQueryStore`] to persist queries
624    /// to disk and replay them later via [`Self::from_saved_query`].
625    ///
626    /// Requires the `advanced-queries` feature flag.
627    #[cfg(feature = "advanced-queries")]
628    #[must_use]
629    pub fn to_saved_query(&self, name: impl Into<String>) -> crate::saved_queries::SavedQuery {
630        crate::saved_queries::SavedQuery {
631            name: name.into(),
632            description: None,
633            filters: self.filters.clone(),
634            any_tags: self.any_tags.clone(),
635            exclude_tags: self.exclude_tags.clone(),
636            tag_count_min: self.tag_count_min,
637            fuzzy_query: self.fuzzy_query.clone(),
638            fuzzy_threshold: self.fuzzy_threshold,
639            where_expr: self.where_expr.clone(),
640            saved_at: chrono::Utc::now(),
641        }
642    }
643
644    /// Reconstruct a builder from a previously-saved [`crate::saved_queries::SavedQuery`].
645    /// The returned builder can be executed directly via [`Self::execute`] or
646    /// [`Self::execute_ranked`].
647    ///
648    /// Requires the `advanced-queries` feature flag.
649    #[cfg(feature = "advanced-queries")]
650    #[must_use]
651    pub fn from_saved_query(query: &crate::saved_queries::SavedQuery) -> Self {
652        Self {
653            filters: query.filters.clone(),
654            any_tags: query.any_tags.clone(),
655            exclude_tags: query.exclude_tags.clone(),
656            tag_count_min: query.tag_count_min,
657            fuzzy_query: query.fuzzy_query.clone(),
658            fuzzy_threshold: query.fuzzy_threshold.map(|t| t.clamp(0.0, 1.0)),
659            where_expr: query.where_expr.clone(),
660            // Cursors are ephemeral and not part of saved-query state.
661            #[cfg(feature = "batch-operations")]
662            after: None,
663        }
664    }
665}
666
667impl Default for TaskQueryBuilder {
668    fn default() -> Self {
669        Self::new()
670    }
671}
672
673#[cfg(feature = "advanced-queries")]
674const DEFAULT_FUZZY_THRESHOLD: f32 = 0.6;
675
676#[cfg(all(feature = "advanced-queries", feature = "batch-operations"))]
677const DEFAULT_PAGE_SIZE: usize = 100;
678
679#[cfg(feature = "advanced-queries")]
680fn task_fuzzy_score(query_lc: &str, task: &crate::models::Task) -> f32 {
681    let title_score = fuzzy_field_score(query_lc, &task.title);
682    let notes_score = task
683        .notes
684        .as_deref()
685        .map(|n| fuzzy_field_score(query_lc, n))
686        .unwrap_or(0.0);
687    title_score.max(notes_score)
688}
689
690#[cfg(feature = "advanced-queries")]
691fn fuzzy_field_score(query_lc: &str, field: &str) -> f32 {
692    let field_lc = field.to_lowercase();
693    if !query_lc.is_empty() && field_lc.contains(query_lc) {
694        return 1.0;
695    }
696    best_window_score(query_lc, &field_lc)
697}
698
699#[cfg(feature = "advanced-queries")]
700fn best_window_score(query: &str, field: &str) -> f32 {
701    if query.is_empty() || field.is_empty() {
702        return 0.0;
703    }
704    let window_len = (2 * query.len()).min(field.len());
705    let step = 1;
706    let chars: Vec<char> = field.chars().collect();
707    let n = chars.len();
708    let mut best = 0.0f32;
709    let mut i = 0;
710    loop {
711        let end = (i + window_len).min(n);
712        let slice: String = chars[i..end].iter().collect();
713        let score = strsim::normalized_levenshtein(query, &slice) as f32;
714        if score > best {
715            best = score;
716        }
717        if end >= n {
718            break;
719        }
720        i += step;
721    }
722    best
723}
724
725fn today() -> NaiveDate {
726    Utc::now().date_naive()
727}
728
729fn end_of_week(d: NaiveDate) -> NaiveDate {
730    let days_from_monday = i64::from(d.weekday().num_days_from_monday());
731    d + Duration::days(6 - days_from_monday)
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use chrono::NaiveDate;
738
739    #[test]
740    fn test_task_query_builder_new() {
741        let builder = TaskQueryBuilder::new();
742        let filters = builder.build();
743
744        assert!(filters.status.is_none());
745        assert!(filters.task_type.is_none());
746        assert!(filters.project_uuid.is_none());
747        assert!(filters.area_uuid.is_none());
748        assert!(filters.tags.is_none());
749        assert!(filters.start_date_from.is_none());
750        assert!(filters.start_date_to.is_none());
751        assert!(filters.deadline_from.is_none());
752        assert!(filters.deadline_to.is_none());
753        assert!(filters.search_query.is_none());
754        assert!(filters.limit.is_none());
755        assert!(filters.offset.is_none());
756    }
757
758    #[test]
759    fn test_task_query_builder_default() {
760        let builder = TaskQueryBuilder::default();
761        let filters = builder.build();
762
763        assert!(filters.status.is_none());
764        assert!(filters.task_type.is_none());
765    }
766
767    #[test]
768    fn test_task_query_builder_status() {
769        let builder = TaskQueryBuilder::new().status(TaskStatus::Completed);
770        let filters = builder.build();
771
772        assert_eq!(filters.status, Some(TaskStatus::Completed));
773    }
774
775    #[test]
776    fn test_task_query_builder_task_type() {
777        let builder = TaskQueryBuilder::new().task_type(TaskType::Project);
778        let filters = builder.build();
779
780        assert_eq!(filters.task_type, Some(TaskType::Project));
781    }
782
783    #[test]
784    fn test_task_query_builder_project_uuid() {
785        let uuid = ThingsId::new_v4();
786        let builder = TaskQueryBuilder::new().project_uuid(uuid.clone());
787        let filters = builder.build();
788
789        assert_eq!(filters.project_uuid, Some(uuid));
790    }
791
792    #[test]
793    fn test_task_query_builder_area_uuid() {
794        let uuid = ThingsId::new_v4();
795        let builder = TaskQueryBuilder::new().area_uuid(uuid.clone());
796        let filters = builder.build();
797
798        assert_eq!(filters.area_uuid, Some(uuid));
799    }
800
801    #[test]
802    fn test_task_query_builder_tags() {
803        let tags = vec!["urgent".to_string(), "important".to_string()];
804        let builder = TaskQueryBuilder::new().tags(tags.clone());
805        let filters = builder.build();
806
807        assert_eq!(filters.tags, Some(tags));
808    }
809
810    #[cfg(feature = "advanced-queries")]
811    #[test]
812    fn test_task_query_builder_any_tags() {
813        let tags = vec!["a".to_string(), "b".to_string()];
814        let builder = TaskQueryBuilder::new().any_tags(tags.clone());
815        assert_eq!(builder.any_tags, Some(tags));
816    }
817
818    #[cfg(feature = "advanced-queries")]
819    #[test]
820    fn test_task_query_builder_exclude_tags() {
821        let tags = vec!["archived".to_string()];
822        let builder = TaskQueryBuilder::new().exclude_tags(tags.clone());
823        assert_eq!(builder.exclude_tags, Some(tags));
824    }
825
826    #[cfg(feature = "advanced-queries")]
827    #[test]
828    fn test_task_query_builder_tag_count() {
829        let builder = TaskQueryBuilder::new().tag_count(2);
830        assert_eq!(builder.tag_count_min, Some(2));
831    }
832
833    #[cfg(feature = "advanced-queries")]
834    #[test]
835    fn test_task_query_builder_where_expr_setter() {
836        use crate::filter_expr::FilterExpr;
837        let expr = FilterExpr::status(TaskStatus::Incomplete);
838        let builder = TaskQueryBuilder::new().where_expr(expr.clone());
839        assert_eq!(builder.where_expr, Some(expr));
840    }
841
842    #[cfg(feature = "batch-operations")]
843    mod cursor_builder_tests {
844        use super::*;
845        use crate::cursor::{Cursor, CursorPayload};
846        use chrono::Utc;
847        use uuid::Uuid;
848
849        fn sample_cursor() -> Cursor {
850            Cursor::encode(&CursorPayload {
851                c: Utc::now(),
852                u: Uuid::new_v4(),
853            })
854            .unwrap()
855        }
856
857        #[test]
858        fn test_after_setter_stores_cursor() {
859            let c = sample_cursor();
860            let builder = TaskQueryBuilder::new().after(c.clone());
861            assert_eq!(builder.after, Some(c));
862        }
863
864        #[cfg(feature = "advanced-queries")]
865        #[tokio::test]
866        async fn test_execute_paged_rejects_offset_and_after() {
867            use tempfile::NamedTempFile;
868            let f = NamedTempFile::new().unwrap();
869            crate::test_utils::create_test_database(f.path())
870                .await
871                .unwrap();
872            let db = crate::database::ThingsDatabase::new(f.path())
873                .await
874                .unwrap();
875
876            let result = TaskQueryBuilder::new()
877                .offset(5)
878                .after(sample_cursor())
879                .execute_paged(&db)
880                .await;
881            match result {
882                Err(crate::error::ThingsError::InvalidCursor(msg)) => {
883                    assert!(msg.contains("offset and after"), "msg: {msg}");
884                }
885                other => panic!("expected InvalidCursor, got {other:?}"),
886            }
887        }
888
889        #[cfg(feature = "advanced-queries")]
890        #[tokio::test]
891        async fn test_execute_paged_rejects_fuzzy_search() {
892            use tempfile::NamedTempFile;
893            let f = NamedTempFile::new().unwrap();
894            crate::test_utils::create_test_database(f.path())
895                .await
896                .unwrap();
897            let db = crate::database::ThingsDatabase::new(f.path())
898                .await
899                .unwrap();
900
901            // fuzzy_search alone must be rejected — no .after() needed to trigger.
902            let result = TaskQueryBuilder::new()
903                .fuzzy_search("anything")
904                .execute_paged(&db)
905                .await;
906            match result {
907                Err(crate::error::ThingsError::InvalidCursor(msg)) => {
908                    assert!(msg.contains("fuzzy"), "msg: {msg}");
909                }
910                other => panic!("expected InvalidCursor, got {other:?}"),
911            }
912        }
913    }
914
915    #[cfg(feature = "advanced-queries")]
916    #[test]
917    fn test_task_query_builder_chaining_tag_methods() {
918        let builder = TaskQueryBuilder::new()
919            .tags(vec!["a".to_string()])
920            .any_tags(vec!["b".to_string(), "c".to_string()])
921            .exclude_tags(vec!["d".to_string()])
922            .tag_count(1);
923        assert_eq!(builder.filters.tags, Some(vec!["a".to_string()]));
924        assert_eq!(
925            builder.any_tags,
926            Some(vec!["b".to_string(), "c".to_string()])
927        );
928        assert_eq!(builder.exclude_tags, Some(vec!["d".to_string()]));
929        assert_eq!(builder.tag_count_min, Some(1));
930    }
931
932    #[cfg(feature = "advanced-queries")]
933    #[test]
934    fn test_fuzzy_search_sets_field() {
935        let builder = TaskQueryBuilder::new().fuzzy_search("meeting");
936        assert_eq!(builder.fuzzy_query, Some("meeting".to_string()));
937    }
938
939    #[cfg(feature = "advanced-queries")]
940    #[test]
941    fn test_fuzzy_threshold_clamps_low() {
942        let builder = TaskQueryBuilder::new().fuzzy_threshold(-0.5);
943        assert_eq!(builder.fuzzy_threshold, Some(0.0));
944    }
945
946    #[cfg(feature = "advanced-queries")]
947    #[test]
948    fn test_fuzzy_threshold_clamps_high() {
949        let builder = TaskQueryBuilder::new().fuzzy_threshold(1.5);
950        assert_eq!(builder.fuzzy_threshold, Some(1.0));
951    }
952
953    #[cfg(feature = "advanced-queries")]
954    #[test]
955    fn test_fuzzy_search_chains_with_other_filters() {
956        let builder = TaskQueryBuilder::new()
957            .status(TaskStatus::Incomplete)
958            .fuzzy_search("agenda")
959            .fuzzy_threshold(0.7);
960        assert_eq!(builder.fuzzy_query, Some("agenda".to_string()));
961        assert_eq!(builder.fuzzy_threshold, Some(0.7));
962        assert_eq!(builder.filters.status, Some(TaskStatus::Incomplete));
963    }
964
965    #[cfg(feature = "advanced-queries")]
966    mod fuzzy_score_tests {
967        use super::*;
968
969        #[test]
970        fn test_fuzzy_score_substring_short_circuit() {
971            assert_eq!(fuzzy_field_score("foo", "blah foo bar"), 1.0);
972        }
973
974        #[test]
975        fn test_fuzzy_score_typo_above_threshold() {
976            let score = fuzzy_field_score("urgent", "urgnt");
977            assert!(score >= 0.6, "expected score >= 0.6, got {score}");
978        }
979
980        #[test]
981        fn test_best_window_score_long_field() {
982            let long_field = "alexander needs to buy eggs and milk from the store today";
983            let whole = strsim::normalized_levenshtein("alex", long_field) as f32;
984            let windowed = best_window_score("alex", long_field);
985            assert!(
986                windowed > whole,
987                "windowed ({windowed}) should beat whole-field ({whole})"
988            );
989        }
990
991        #[test]
992        fn test_task_fuzzy_score_uses_max_of_title_notes() {
993            use chrono::Utc;
994            let task = crate::models::Task {
995                uuid: ThingsId::new_v4(),
996                title: "unrelated title xyz".to_string(),
997                notes: Some("meeting agenda important".to_string()),
998                task_type: crate::models::TaskType::Todo,
999                status: crate::models::TaskStatus::Incomplete,
1000                start_date: None,
1001                deadline: None,
1002                created: Utc::now(),
1003                modified: Utc::now(),
1004                stop_date: None,
1005                project_uuid: None,
1006                area_uuid: None,
1007                parent_uuid: None,
1008                tags: vec![],
1009                children: vec![],
1010            };
1011            let score = task_fuzzy_score("agenda", &task);
1012            assert_eq!(score, 1.0, "notes contains 'agenda', score should be 1.0");
1013        }
1014    }
1015
1016    #[cfg(feature = "advanced-queries")]
1017    mod saved_query_conversion_tests {
1018        use super::*;
1019
1020        #[test]
1021        fn test_to_saved_query_captures_all_state() {
1022            let from = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
1023            let to = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
1024            let project = ThingsId::new_v4();
1025
1026            let builder = TaskQueryBuilder::new()
1027                .status(TaskStatus::Incomplete)
1028                .task_type(TaskType::Todo)
1029                .project_uuid(project.clone())
1030                .tags(vec!["work".to_string()])
1031                .any_tags(vec!["urgent".to_string(), "p0".to_string()])
1032                .exclude_tags(vec!["archived".to_string()])
1033                .tag_count(2)
1034                .fuzzy_search("budget")
1035                .fuzzy_threshold(0.75)
1036                .start_date_range(Some(from), Some(to))
1037                .limit(10)
1038                .offset(5);
1039
1040            let saved = builder.to_saved_query("everything");
1041            assert_eq!(saved.name, "everything");
1042            assert_eq!(saved.filters.status, Some(TaskStatus::Incomplete));
1043            assert_eq!(saved.filters.task_type, Some(TaskType::Todo));
1044            assert_eq!(saved.filters.project_uuid, Some(project));
1045            assert_eq!(saved.filters.tags, Some(vec!["work".to_string()]));
1046            assert_eq!(saved.filters.start_date_from, Some(from));
1047            assert_eq!(saved.filters.limit, Some(10));
1048            assert_eq!(saved.filters.offset, Some(5));
1049            assert_eq!(
1050                saved.any_tags,
1051                Some(vec!["urgent".to_string(), "p0".to_string()])
1052            );
1053            assert_eq!(saved.exclude_tags, Some(vec!["archived".to_string()]));
1054            assert_eq!(saved.tag_count_min, Some(2));
1055            assert_eq!(saved.fuzzy_query, Some("budget".to_string()));
1056            assert_eq!(saved.fuzzy_threshold, Some(0.75));
1057            assert!(saved.where_expr.is_none());
1058        }
1059
1060        #[test]
1061        fn test_to_saved_query_captures_where_expr() {
1062            use crate::filter_expr::FilterExpr;
1063            let expr = FilterExpr::status(TaskStatus::Incomplete)
1064                .or(FilterExpr::status(TaskStatus::Completed));
1065            let saved = TaskQueryBuilder::new()
1066                .where_expr(expr.clone())
1067                .to_saved_query("with-expr");
1068            assert_eq!(saved.where_expr, Some(expr));
1069        }
1070
1071        #[test]
1072        fn test_from_saved_query_restores_all_state() {
1073            let original = TaskQueryBuilder::new()
1074                .status(TaskStatus::Completed)
1075                .any_tags(vec!["a".to_string()])
1076                .fuzzy_search("hello")
1077                .fuzzy_threshold(0.9)
1078                .limit(7);
1079            let saved = original.to_saved_query("test");
1080            let rebuilt = TaskQueryBuilder::from_saved_query(&saved);
1081
1082            assert_eq!(rebuilt.filters.status, Some(TaskStatus::Completed));
1083            assert_eq!(rebuilt.filters.limit, Some(7));
1084            assert_eq!(rebuilt.any_tags, Some(vec!["a".to_string()]));
1085            assert_eq!(rebuilt.fuzzy_query, Some("hello".to_string()));
1086            assert_eq!(rebuilt.fuzzy_threshold, Some(0.9));
1087        }
1088
1089        #[test]
1090        fn test_saved_query_roundtrip_through_json() {
1091            let original = TaskQueryBuilder::new()
1092                .status(TaskStatus::Incomplete)
1093                .any_tags(vec!["x".to_string()])
1094                .fuzzy_search("foo")
1095                .fuzzy_threshold(0.5);
1096
1097            let saved = original.to_saved_query("rt");
1098            let json = serde_json::to_string(&saved).unwrap();
1099            let restored: crate::saved_queries::SavedQuery = serde_json::from_str(&json).unwrap();
1100            let rebuilt = TaskQueryBuilder::from_saved_query(&restored);
1101
1102            assert_eq!(rebuilt.filters.status, Some(TaskStatus::Incomplete));
1103            assert_eq!(rebuilt.any_tags, Some(vec!["x".to_string()]));
1104            assert_eq!(rebuilt.fuzzy_query, Some("foo".to_string()));
1105            assert_eq!(rebuilt.fuzzy_threshold, Some(0.5));
1106        }
1107
1108        #[test]
1109        fn test_from_saved_query_restores_where_expr_through_json() {
1110            use crate::filter_expr::FilterExpr;
1111            let expr = FilterExpr::Or(vec![
1112                FilterExpr::status(TaskStatus::Incomplete),
1113                FilterExpr::status(TaskStatus::Completed),
1114            ])
1115            .and(FilterExpr::task_type(TaskType::Project).not());
1116
1117            let saved = TaskQueryBuilder::new()
1118                .where_expr(expr.clone())
1119                .to_saved_query("expr-rt");
1120            let json = serde_json::to_string(&saved).unwrap();
1121            let restored: crate::saved_queries::SavedQuery = serde_json::from_str(&json).unwrap();
1122            let rebuilt = TaskQueryBuilder::from_saved_query(&restored);
1123            assert_eq!(rebuilt.where_expr, Some(expr));
1124        }
1125    }
1126
1127    #[test]
1128    fn test_task_query_builder_start_date_range() {
1129        let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1130        let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1131        let builder = TaskQueryBuilder::new().start_date_range(Some(from), Some(to));
1132        let filters = builder.build();
1133
1134        assert_eq!(filters.start_date_from, Some(from));
1135        assert_eq!(filters.start_date_to, Some(to));
1136    }
1137
1138    #[test]
1139    fn test_task_query_builder_deadline_range() {
1140        let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1141        let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1142        let builder = TaskQueryBuilder::new().deadline_range(Some(from), Some(to));
1143        let filters = builder.build();
1144
1145        assert_eq!(filters.deadline_from, Some(from));
1146        assert_eq!(filters.deadline_to, Some(to));
1147    }
1148
1149    #[test]
1150    fn test_task_query_builder_search() {
1151        let query = "test search";
1152        let builder = TaskQueryBuilder::new().search(query);
1153        let filters = builder.build();
1154
1155        assert_eq!(filters.search_query, Some(query.to_string()));
1156    }
1157
1158    #[test]
1159    fn test_task_query_builder_limit() {
1160        let builder = TaskQueryBuilder::new().limit(50);
1161        let filters = builder.build();
1162
1163        assert_eq!(filters.limit, Some(50));
1164    }
1165
1166    #[test]
1167    fn test_task_query_builder_offset() {
1168        let builder = TaskQueryBuilder::new().offset(10);
1169        let filters = builder.build();
1170
1171        assert_eq!(filters.offset, Some(10));
1172    }
1173
1174    #[cfg(feature = "advanced-queries")]
1175    mod execute_tests {
1176        use super::*;
1177        use tempfile::NamedTempFile;
1178
1179        #[tokio::test]
1180        async fn test_execute_empty_builder() {
1181            let f = NamedTempFile::new().unwrap();
1182            crate::test_utils::create_test_database(f.path())
1183                .await
1184                .unwrap();
1185            let db = crate::database::ThingsDatabase::new(f.path())
1186                .await
1187                .unwrap();
1188            let result = TaskQueryBuilder::new().execute(&db).await;
1189            assert!(result.is_ok());
1190        }
1191
1192        #[tokio::test]
1193        async fn test_execute_with_status_filter() {
1194            let f = NamedTempFile::new().unwrap();
1195            crate::test_utils::create_test_database(f.path())
1196                .await
1197                .unwrap();
1198            let db = crate::database::ThingsDatabase::new(f.path())
1199                .await
1200                .unwrap();
1201            let result = TaskQueryBuilder::new()
1202                .status(TaskStatus::Incomplete)
1203                .execute(&db)
1204                .await;
1205            assert!(result.is_ok());
1206            assert!(result
1207                .unwrap()
1208                .iter()
1209                .all(|t| t.status == TaskStatus::Incomplete));
1210        }
1211    }
1212
1213    #[test]
1214    fn test_task_query_builder_chaining() {
1215        let uuid = ThingsId::new_v4();
1216        let tags = vec!["urgent".to_string()];
1217        let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1218        let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1219
1220        let builder = TaskQueryBuilder::new()
1221            .status(TaskStatus::Incomplete)
1222            .task_type(TaskType::Todo)
1223            .project_uuid(uuid.clone())
1224            .tags(tags.clone())
1225            .start_date_range(Some(from), Some(to))
1226            .search("test")
1227            .limit(25)
1228            .offset(5);
1229
1230        let filters = builder.build();
1231
1232        assert_eq!(filters.status, Some(TaskStatus::Incomplete));
1233        assert_eq!(filters.task_type, Some(TaskType::Todo));
1234        assert_eq!(filters.project_uuid, Some(uuid));
1235        assert_eq!(filters.tags, Some(tags));
1236        assert_eq!(filters.start_date_from, Some(from));
1237        assert_eq!(filters.start_date_to, Some(to));
1238        assert_eq!(filters.search_query, Some("test".to_string()));
1239        assert_eq!(filters.limit, Some(25));
1240        assert_eq!(filters.offset, Some(5));
1241    }
1242
1243    mod date_helper_tests {
1244        use super::*;
1245
1246        #[test]
1247        fn test_due_today_sets_deadline_range_to_today() {
1248            let filters = TaskQueryBuilder::new().due_today().build();
1249            let today = today();
1250            assert_eq!(filters.deadline_from, Some(today));
1251            assert_eq!(filters.deadline_to, Some(today));
1252        }
1253
1254        #[test]
1255        fn test_due_this_week_ends_on_sunday() {
1256            let filters = TaskQueryBuilder::new().due_this_week().build();
1257            let today = today();
1258            assert_eq!(filters.deadline_from, Some(today));
1259            let to = filters.deadline_to.unwrap();
1260            assert_eq!(to.weekday(), chrono::Weekday::Sun);
1261            assert!(to >= today);
1262        }
1263
1264        #[test]
1265        fn test_due_next_week_spans_monday_to_sunday() {
1266            let filters = TaskQueryBuilder::new().due_next_week().build();
1267            let from = filters.deadline_from.unwrap();
1268            let to = filters.deadline_to.unwrap();
1269            assert_eq!(from.weekday(), chrono::Weekday::Mon);
1270            assert_eq!(to.weekday(), chrono::Weekday::Sun);
1271            assert_eq!(to - from, Duration::days(6));
1272            assert!(from > today());
1273        }
1274
1275        #[test]
1276        fn test_due_in_n_days() {
1277            let filters = TaskQueryBuilder::new().due_in(7).build();
1278            let today = today();
1279            assert_eq!(filters.deadline_from, Some(today));
1280            assert_eq!(filters.deadline_to, Some(today + Duration::days(7)));
1281        }
1282
1283        #[test]
1284        fn test_due_in_zero_days_is_today() {
1285            let filters = TaskQueryBuilder::new().due_in(0).build();
1286            let today = today();
1287            assert_eq!(filters.deadline_from, Some(today));
1288            assert_eq!(filters.deadline_to, Some(today));
1289        }
1290
1291        #[test]
1292        fn test_overdue_sets_deadline_to_yesterday_with_no_lower_bound() {
1293            let filters = TaskQueryBuilder::new().overdue().build();
1294            let yesterday = today() - Duration::days(1);
1295            assert_eq!(filters.deadline_from, None);
1296            assert_eq!(filters.deadline_to, Some(yesterday));
1297        }
1298
1299        #[test]
1300        fn test_overdue_implicitly_sets_status_incomplete_when_unset() {
1301            let filters = TaskQueryBuilder::new().overdue().build();
1302            assert_eq!(filters.status, Some(TaskStatus::Incomplete));
1303        }
1304
1305        #[test]
1306        fn test_overdue_does_not_override_explicit_status() {
1307            let filters = TaskQueryBuilder::new()
1308                .status(TaskStatus::Canceled)
1309                .overdue()
1310                .build();
1311            assert_eq!(filters.status, Some(TaskStatus::Canceled));
1312        }
1313
1314        #[test]
1315        fn test_starting_today_sets_start_date_range() {
1316            let filters = TaskQueryBuilder::new().starting_today().build();
1317            let today = today();
1318            assert_eq!(filters.start_date_from, Some(today));
1319            assert_eq!(filters.start_date_to, Some(today));
1320        }
1321
1322        #[test]
1323        fn test_starting_this_week_ends_on_sunday() {
1324            let filters = TaskQueryBuilder::new().starting_this_week().build();
1325            let today = today();
1326            assert_eq!(filters.start_date_from, Some(today));
1327            let to = filters.start_date_to.unwrap();
1328            assert_eq!(to.weekday(), chrono::Weekday::Sun);
1329            assert!(to >= today);
1330        }
1331
1332        #[test]
1333        fn test_end_of_week_on_monday_returns_following_sunday() {
1334            let monday = NaiveDate::from_ymd_opt(2026, 4, 27).unwrap();
1335            assert_eq!(monday.weekday(), chrono::Weekday::Mon);
1336            let eow = end_of_week(monday);
1337            assert_eq!(eow, NaiveDate::from_ymd_opt(2026, 5, 3).unwrap());
1338            assert_eq!(eow.weekday(), chrono::Weekday::Sun);
1339        }
1340
1341        #[test]
1342        fn test_end_of_week_on_sunday_returns_same_day() {
1343            let sunday = NaiveDate::from_ymd_opt(2026, 5, 3).unwrap();
1344            assert_eq!(sunday.weekday(), chrono::Weekday::Sun);
1345            assert_eq!(end_of_week(sunday), sunday);
1346        }
1347    }
1348}