Skip to main content

things3_core/
query.rs

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