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}
27
28impl TaskQueryBuilder {
29    /// Create a new query builder
30    #[must_use]
31    pub fn new() -> Self {
32        Self {
33            filters: TaskFilters::default(),
34            #[cfg(feature = "advanced-queries")]
35            any_tags: None,
36            #[cfg(feature = "advanced-queries")]
37            exclude_tags: None,
38            #[cfg(feature = "advanced-queries")]
39            tag_count_min: None,
40            #[cfg(feature = "advanced-queries")]
41            fuzzy_query: None,
42            #[cfg(feature = "advanced-queries")]
43            fuzzy_threshold: None,
44            #[cfg(feature = "advanced-queries")]
45            where_expr: None,
46        }
47    }
48
49    /// Filter by status
50    #[must_use]
51    pub const fn status(mut self, status: TaskStatus) -> Self {
52        self.filters.status = Some(status);
53        self
54    }
55
56    /// Filter by task type
57    #[must_use]
58    pub const fn task_type(mut self, task_type: TaskType) -> Self {
59        self.filters.task_type = Some(task_type);
60        self
61    }
62
63    /// Filter by project UUID
64    #[must_use]
65    pub const fn project_uuid(mut self, project_uuid: Uuid) -> Self {
66        self.filters.project_uuid = Some(project_uuid);
67        self
68    }
69
70    /// Filter by area UUID
71    #[must_use]
72    pub const fn area_uuid(mut self, area_uuid: Uuid) -> Self {
73        self.filters.area_uuid = Some(area_uuid);
74        self
75    }
76
77    /// Filter by tags (AND semantics — task must contain every listed tag).
78    #[must_use]
79    pub fn tags(mut self, tags: Vec<String>) -> Self {
80        self.filters.tags = Some(tags);
81        self
82    }
83
84    /// Filter to tasks containing **any** of these tags (OR semantics).
85    ///
86    /// Composes with `.tags()` (AND) and `.exclude_tags()` (NOT) — all
87    /// active tag filters must be satisfied. Applied in Rust by `execute()`;
88    /// not reflected in `build()`.
89    ///
90    /// Requires the `advanced-queries` feature flag.
91    #[cfg(feature = "advanced-queries")]
92    #[must_use]
93    pub fn any_tags(mut self, tags: Vec<String>) -> Self {
94        self.any_tags = Some(tags);
95        self
96    }
97
98    /// Filter out tasks containing any of these tags. Applied in Rust by `execute()`;
99    /// not reflected in `build()`.
100    ///
101    /// Requires the `advanced-queries` feature flag.
102    #[cfg(feature = "advanced-queries")]
103    #[must_use]
104    pub fn exclude_tags(mut self, tags: Vec<String>) -> Self {
105        self.exclude_tags = Some(tags);
106        self
107    }
108
109    /// Filter to tasks with at least `min` tags total. Applied in Rust by `execute()`;
110    /// not reflected in `build()`.
111    ///
112    /// Requires the `advanced-queries` feature flag.
113    #[cfg(feature = "advanced-queries")]
114    #[must_use]
115    pub fn tag_count(mut self, min: usize) -> Self {
116        self.tag_count_min = Some(min);
117        self
118    }
119
120    /// Filter and rank tasks by fuzzy similarity to `query` (title and notes).
121    ///
122    /// Scores are computed with windowed Levenshtein; only tasks meeting the
123    /// threshold (default `0.6`, tunable via `fuzzy_threshold`) are returned.
124    /// If `.search()` is also set, fuzzy wins and a warning is logged.
125    ///
126    /// Applied in Rust by `execute()` / `execute_ranked()`; not reflected in `build()`.
127    ///
128    /// Requires the `advanced-queries` feature flag.
129    #[cfg(feature = "advanced-queries")]
130    #[must_use]
131    pub fn fuzzy_search(mut self, query: &str) -> Self {
132        self.fuzzy_query = Some(query.to_string());
133        self
134    }
135
136    /// Override the minimum fuzzy-match score threshold (clamped to `[0.0, 1.0]`).
137    /// Defaults to `0.6` when not called.
138    ///
139    /// Requires the `advanced-queries` feature flag.
140    #[cfg(feature = "advanced-queries")]
141    #[must_use]
142    pub fn fuzzy_threshold(mut self, threshold: f32) -> Self {
143        self.fuzzy_threshold = Some(threshold.clamp(0.0, 1.0));
144        self
145    }
146
147    /// Apply a boolean expression tree as an additional filter.
148    ///
149    /// The expression composes via AND with the SQL pre-fetch (`TaskFilters`)
150    /// and with the other post-filters (`any_tags`, `exclude_tags`,
151    /// `tag_count`, fuzzy search). To express disjunction or negation across
152    /// statuses or types, leave `filters.status` / `filters.task_type` unset
153    /// and put the OR/NOT branches inside `expr` instead.
154    ///
155    /// Evaluated in Rust by `execute()` after the database returns rows; not
156    /// reflected in `build()`.
157    ///
158    /// Requires the `advanced-queries` feature flag.
159    #[cfg(feature = "advanced-queries")]
160    #[must_use]
161    pub fn where_expr(mut self, expr: crate::filter_expr::FilterExpr) -> Self {
162        self.where_expr = Some(expr);
163        self
164    }
165
166    /// Filter by start date range
167    #[must_use]
168    pub const fn start_date_range(
169        mut self,
170        from: Option<NaiveDate>,
171        to: Option<NaiveDate>,
172    ) -> Self {
173        self.filters.start_date_from = from;
174        self.filters.start_date_to = to;
175        self
176    }
177
178    /// Filter by deadline range
179    #[must_use]
180    pub const fn deadline_range(mut self, from: Option<NaiveDate>, to: Option<NaiveDate>) -> Self {
181        self.filters.deadline_from = from;
182        self.filters.deadline_to = to;
183        self
184    }
185
186    /// Add search query
187    #[must_use]
188    pub fn search(mut self, query: &str) -> Self {
189        self.filters.search_query = Some(query.to_string());
190        self
191    }
192
193    /// Set limit
194    #[must_use]
195    pub const fn limit(mut self, limit: usize) -> Self {
196        self.filters.limit = Some(limit);
197        self
198    }
199
200    /// Set offset for pagination
201    #[must_use]
202    pub const fn offset(mut self, offset: usize) -> Self {
203        self.filters.offset = Some(offset);
204        self
205    }
206
207    /// Filter to tasks whose deadline is today.
208    #[must_use]
209    pub fn due_today(self) -> Self {
210        let today = today();
211        self.deadline_range(Some(today), Some(today))
212    }
213
214    /// Filter to tasks whose deadline falls between today and the upcoming Sunday
215    /// (Monday-Sunday week).
216    #[must_use]
217    pub fn due_this_week(self) -> Self {
218        let today = today();
219        self.deadline_range(Some(today), Some(end_of_week(today)))
220    }
221
222    /// Filter to tasks whose deadline falls in next calendar week (next Monday
223    /// through Sunday, Monday-Sunday week).
224    #[must_use]
225    pub fn due_next_week(self) -> Self {
226        let today = today();
227        let next_monday = end_of_week(today) + Duration::days(1);
228        self.deadline_range(Some(next_monday), Some(end_of_week(next_monday)))
229    }
230
231    /// Filter to tasks whose deadline is between today and `days` days from now (inclusive).
232    #[must_use]
233    pub fn due_in(self, days: i64) -> Self {
234        let today = today();
235        self.deadline_range(Some(today), Some(today + Duration::days(days)))
236    }
237
238    /// Filter to overdue tasks: deadline strictly before today.
239    ///
240    /// If no `status` filter has already been set, this also restricts results
241    /// to incomplete tasks (a completed task isn't meaningfully overdue). An
242    /// explicit `.status(...)` call before this helper is preserved.
243    #[must_use]
244    pub fn overdue(mut self) -> Self {
245        let yesterday = today() - Duration::days(1);
246        self.filters.deadline_from = None;
247        self.filters.deadline_to = Some(yesterday);
248        if self.filters.status.is_none() {
249            self.filters.status = Some(TaskStatus::Incomplete);
250        }
251        self
252    }
253
254    /// Filter to tasks with a start date of today.
255    #[must_use]
256    pub fn starting_today(self) -> Self {
257        let today = today();
258        self.start_date_range(Some(today), Some(today))
259    }
260
261    /// Filter to tasks with a start date between today and the upcoming Sunday
262    /// (Monday-Sunday week).
263    #[must_use]
264    pub fn starting_this_week(self) -> Self {
265        let today = today();
266        self.start_date_range(Some(today), Some(end_of_week(today)))
267    }
268
269    /// Build the final filters
270    #[must_use]
271    pub fn build(self) -> TaskFilters {
272        self.filters
273    }
274
275    /// Execute the query against a live database connection.
276    ///
277    /// SQL-level filters and the `tags` AND-filter are handled by `query_tasks`.
278    /// Builder-only predicates (`any_tags`, `exclude_tags`, `tag_count`,
279    /// `fuzzy_search`) are applied in Rust afterward; when any are active,
280    /// `limit`/`offset` pagination is deferred to Rust so pages count only
281    /// matching rows. When `fuzzy_search` is set, this delegates to
282    /// `execute_ranked` and strips scores.
283    ///
284    /// Requires the `advanced-queries` feature flag.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if the database query fails or task data cannot be mapped.
289    #[cfg(feature = "advanced-queries")]
290    pub async fn execute(
291        &self,
292        db: &crate::database::ThingsDatabase,
293    ) -> crate::error::Result<Vec<crate::models::Task>> {
294        if self.fuzzy_query.is_some() {
295            return self
296                .execute_ranked(db)
297                .await
298                .map(|ranked| ranked.into_iter().map(|r| r.task).collect());
299        }
300
301        let has_tag_post_filters = self.any_tags.as_ref().is_some_and(|t| !t.is_empty())
302            || self.exclude_tags.as_ref().is_some_and(|t| !t.is_empty())
303            || self.tag_count_min.is_some();
304        let has_where_expr = self.where_expr.is_some();
305
306        if !has_tag_post_filters && !has_where_expr {
307            return db.query_tasks(&self.filters).await;
308        }
309
310        let mut filters_no_page = self.filters.clone();
311        let limit = filters_no_page.limit.take();
312        let offset = filters_no_page.offset.take();
313
314        let tasks = db.query_tasks(&filters_no_page).await?;
315        let mut tasks = Self::apply_tag_filters(
316            tasks,
317            self.any_tags.as_deref(),
318            self.exclude_tags.as_deref(),
319            self.tag_count_min,
320        );
321
322        if let Some(expr) = &self.where_expr {
323            tasks.retain(|task| expr.matches(task));
324        }
325
326        let offset = offset.unwrap_or(0);
327        tasks = tasks.into_iter().skip(offset).collect();
328        if let Some(limit) = limit {
329            tasks.truncate(limit);
330        }
331
332        Ok(tasks)
333    }
334
335    #[cfg(feature = "advanced-queries")]
336    fn apply_tag_filters(
337        mut tasks: Vec<crate::models::Task>,
338        any_tags: Option<&[String]>,
339        exclude_tags: Option<&[String]>,
340        tag_count_min: Option<usize>,
341    ) -> Vec<crate::models::Task> {
342        if let Some(any) = any_tags {
343            if !any.is_empty() {
344                tasks.retain(|task| any.iter().any(|f| task.tags.contains(f)));
345            }
346        }
347        if let Some(excl) = exclude_tags {
348            if !excl.is_empty() {
349                tasks.retain(|task| !excl.iter().any(|f| task.tags.contains(f)));
350            }
351        }
352        if let Some(min) = tag_count_min {
353            tasks.retain(|task| task.tags.len() >= min);
354        }
355        tasks
356    }
357
358    /// Execute the query and return tasks paired with their fuzzy-match scores,
359    /// sorted by score descending (ties broken by UUID for determinism).
360    ///
361    /// Requires `.fuzzy_search(query)` to be set — returns a validation error
362    /// otherwise (it is a programming error to ask for ranked results with no
363    /// fuzzy predicate).
364    ///
365    /// Requires the `advanced-queries` feature flag.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if `fuzzy_search` is not set, or if the database query fails.
370    #[cfg(feature = "advanced-queries")]
371    pub async fn execute_ranked(
372        &self,
373        db: &crate::database::ThingsDatabase,
374    ) -> crate::error::Result<Vec<crate::models::RankedTask>> {
375        let query = self.fuzzy_query.as_deref().ok_or_else(|| {
376            crate::error::ThingsError::validation(
377                "execute_ranked requires fuzzy_search() to be set",
378            )
379        })?;
380
381        let query_lc = query.to_lowercase();
382        let threshold = self.fuzzy_threshold.unwrap_or(DEFAULT_FUZZY_THRESHOLD);
383
384        let mut filters_no_page = self.filters.clone();
385        let limit = filters_no_page.limit.take();
386        let offset = filters_no_page.offset.take();
387
388        if filters_no_page.search_query.is_some() {
389            tracing::warn!(
390                "fuzzy_search and search both set; fuzzy takes precedence, ignoring substring search"
391            );
392            filters_no_page.search_query = None;
393        }
394
395        let tasks = db.query_tasks(&filters_no_page).await?;
396        let mut tasks = Self::apply_tag_filters(
397            tasks,
398            self.any_tags.as_deref(),
399            self.exclude_tags.as_deref(),
400            self.tag_count_min,
401        );
402
403        if let Some(expr) = &self.where_expr {
404            tasks.retain(|task| expr.matches(task));
405        }
406
407        let mut scored: Vec<crate::models::RankedTask> = tasks
408            .into_iter()
409            .filter_map(|task| {
410                let score = task_fuzzy_score(&query_lc, &task);
411                if score >= threshold {
412                    Some(crate::models::RankedTask { task, score })
413                } else {
414                    None
415                }
416            })
417            .collect();
418
419        scored.sort_by(|a, b| {
420            b.score
421                .partial_cmp(&a.score)
422                .unwrap_or(std::cmp::Ordering::Equal)
423                .then_with(|| a.task.uuid.cmp(&b.task.uuid))
424        });
425
426        let offset = offset.unwrap_or(0);
427        scored = scored.into_iter().skip(offset).collect();
428        if let Some(limit) = limit {
429            scored.truncate(limit);
430        }
431
432        Ok(scored)
433    }
434
435    /// Snapshot the full builder state — both [`TaskFilters`] and the
436    /// builder-only post-1.0.0 predicates — into a [`crate::saved_queries::SavedQuery`].
437    ///
438    /// Combine with [`crate::saved_queries::SavedQueryStore`] to persist queries
439    /// to disk and replay them later via [`Self::from_saved_query`].
440    ///
441    /// Requires the `advanced-queries` feature flag.
442    #[cfg(feature = "advanced-queries")]
443    #[must_use]
444    pub fn to_saved_query(&self, name: impl Into<String>) -> crate::saved_queries::SavedQuery {
445        crate::saved_queries::SavedQuery {
446            name: name.into(),
447            description: None,
448            filters: self.filters.clone(),
449            any_tags: self.any_tags.clone(),
450            exclude_tags: self.exclude_tags.clone(),
451            tag_count_min: self.tag_count_min,
452            fuzzy_query: self.fuzzy_query.clone(),
453            fuzzy_threshold: self.fuzzy_threshold,
454            where_expr: self.where_expr.clone(),
455            saved_at: chrono::Utc::now(),
456        }
457    }
458
459    /// Reconstruct a builder from a previously-saved [`crate::saved_queries::SavedQuery`].
460    /// The returned builder can be executed directly via [`Self::execute`] or
461    /// [`Self::execute_ranked`].
462    ///
463    /// Requires the `advanced-queries` feature flag.
464    #[cfg(feature = "advanced-queries")]
465    #[must_use]
466    pub fn from_saved_query(query: &crate::saved_queries::SavedQuery) -> Self {
467        Self {
468            filters: query.filters.clone(),
469            any_tags: query.any_tags.clone(),
470            exclude_tags: query.exclude_tags.clone(),
471            tag_count_min: query.tag_count_min,
472            fuzzy_query: query.fuzzy_query.clone(),
473            fuzzy_threshold: query.fuzzy_threshold.map(|t| t.clamp(0.0, 1.0)),
474            where_expr: query.where_expr.clone(),
475        }
476    }
477}
478
479impl Default for TaskQueryBuilder {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485#[cfg(feature = "advanced-queries")]
486const DEFAULT_FUZZY_THRESHOLD: f32 = 0.6;
487
488#[cfg(feature = "advanced-queries")]
489fn task_fuzzy_score(query_lc: &str, task: &crate::models::Task) -> f32 {
490    let title_score = fuzzy_field_score(query_lc, &task.title);
491    let notes_score = task
492        .notes
493        .as_deref()
494        .map(|n| fuzzy_field_score(query_lc, n))
495        .unwrap_or(0.0);
496    title_score.max(notes_score)
497}
498
499#[cfg(feature = "advanced-queries")]
500fn fuzzy_field_score(query_lc: &str, field: &str) -> f32 {
501    let field_lc = field.to_lowercase();
502    if !query_lc.is_empty() && field_lc.contains(query_lc) {
503        return 1.0;
504    }
505    best_window_score(query_lc, &field_lc)
506}
507
508#[cfg(feature = "advanced-queries")]
509fn best_window_score(query: &str, field: &str) -> f32 {
510    if query.is_empty() || field.is_empty() {
511        return 0.0;
512    }
513    let window_len = (2 * query.len()).min(field.len());
514    let step = 1;
515    let chars: Vec<char> = field.chars().collect();
516    let n = chars.len();
517    let mut best = 0.0f32;
518    let mut i = 0;
519    loop {
520        let end = (i + window_len).min(n);
521        let slice: String = chars[i..end].iter().collect();
522        let score = strsim::normalized_levenshtein(query, &slice) as f32;
523        if score > best {
524            best = score;
525        }
526        if end >= n {
527            break;
528        }
529        i += step;
530    }
531    best
532}
533
534fn today() -> NaiveDate {
535    Utc::now().date_naive()
536}
537
538fn end_of_week(d: NaiveDate) -> NaiveDate {
539    let days_from_monday = i64::from(d.weekday().num_days_from_monday());
540    d + Duration::days(6 - days_from_monday)
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use chrono::NaiveDate;
547    use uuid::Uuid;
548
549    #[test]
550    fn test_task_query_builder_new() {
551        let builder = TaskQueryBuilder::new();
552        let filters = builder.build();
553
554        assert!(filters.status.is_none());
555        assert!(filters.task_type.is_none());
556        assert!(filters.project_uuid.is_none());
557        assert!(filters.area_uuid.is_none());
558        assert!(filters.tags.is_none());
559        assert!(filters.start_date_from.is_none());
560        assert!(filters.start_date_to.is_none());
561        assert!(filters.deadline_from.is_none());
562        assert!(filters.deadline_to.is_none());
563        assert!(filters.search_query.is_none());
564        assert!(filters.limit.is_none());
565        assert!(filters.offset.is_none());
566    }
567
568    #[test]
569    fn test_task_query_builder_default() {
570        let builder = TaskQueryBuilder::default();
571        let filters = builder.build();
572
573        assert!(filters.status.is_none());
574        assert!(filters.task_type.is_none());
575    }
576
577    #[test]
578    fn test_task_query_builder_status() {
579        let builder = TaskQueryBuilder::new().status(TaskStatus::Completed);
580        let filters = builder.build();
581
582        assert_eq!(filters.status, Some(TaskStatus::Completed));
583    }
584
585    #[test]
586    fn test_task_query_builder_task_type() {
587        let builder = TaskQueryBuilder::new().task_type(TaskType::Project);
588        let filters = builder.build();
589
590        assert_eq!(filters.task_type, Some(TaskType::Project));
591    }
592
593    #[test]
594    fn test_task_query_builder_project_uuid() {
595        let uuid = Uuid::new_v4();
596        let builder = TaskQueryBuilder::new().project_uuid(uuid);
597        let filters = builder.build();
598
599        assert_eq!(filters.project_uuid, Some(uuid));
600    }
601
602    #[test]
603    fn test_task_query_builder_area_uuid() {
604        let uuid = Uuid::new_v4();
605        let builder = TaskQueryBuilder::new().area_uuid(uuid);
606        let filters = builder.build();
607
608        assert_eq!(filters.area_uuid, Some(uuid));
609    }
610
611    #[test]
612    fn test_task_query_builder_tags() {
613        let tags = vec!["urgent".to_string(), "important".to_string()];
614        let builder = TaskQueryBuilder::new().tags(tags.clone());
615        let filters = builder.build();
616
617        assert_eq!(filters.tags, Some(tags));
618    }
619
620    #[cfg(feature = "advanced-queries")]
621    #[test]
622    fn test_task_query_builder_any_tags() {
623        let tags = vec!["a".to_string(), "b".to_string()];
624        let builder = TaskQueryBuilder::new().any_tags(tags.clone());
625        assert_eq!(builder.any_tags, Some(tags));
626    }
627
628    #[cfg(feature = "advanced-queries")]
629    #[test]
630    fn test_task_query_builder_exclude_tags() {
631        let tags = vec!["archived".to_string()];
632        let builder = TaskQueryBuilder::new().exclude_tags(tags.clone());
633        assert_eq!(builder.exclude_tags, Some(tags));
634    }
635
636    #[cfg(feature = "advanced-queries")]
637    #[test]
638    fn test_task_query_builder_tag_count() {
639        let builder = TaskQueryBuilder::new().tag_count(2);
640        assert_eq!(builder.tag_count_min, Some(2));
641    }
642
643    #[cfg(feature = "advanced-queries")]
644    #[test]
645    fn test_task_query_builder_where_expr_setter() {
646        use crate::filter_expr::FilterExpr;
647        let expr = FilterExpr::status(TaskStatus::Incomplete);
648        let builder = TaskQueryBuilder::new().where_expr(expr.clone());
649        assert_eq!(builder.where_expr, Some(expr));
650    }
651
652    #[cfg(feature = "advanced-queries")]
653    #[test]
654    fn test_task_query_builder_chaining_tag_methods() {
655        let builder = TaskQueryBuilder::new()
656            .tags(vec!["a".to_string()])
657            .any_tags(vec!["b".to_string(), "c".to_string()])
658            .exclude_tags(vec!["d".to_string()])
659            .tag_count(1);
660        assert_eq!(builder.filters.tags, Some(vec!["a".to_string()]));
661        assert_eq!(
662            builder.any_tags,
663            Some(vec!["b".to_string(), "c".to_string()])
664        );
665        assert_eq!(builder.exclude_tags, Some(vec!["d".to_string()]));
666        assert_eq!(builder.tag_count_min, Some(1));
667    }
668
669    #[cfg(feature = "advanced-queries")]
670    #[test]
671    fn test_fuzzy_search_sets_field() {
672        let builder = TaskQueryBuilder::new().fuzzy_search("meeting");
673        assert_eq!(builder.fuzzy_query, Some("meeting".to_string()));
674    }
675
676    #[cfg(feature = "advanced-queries")]
677    #[test]
678    fn test_fuzzy_threshold_clamps_low() {
679        let builder = TaskQueryBuilder::new().fuzzy_threshold(-0.5);
680        assert_eq!(builder.fuzzy_threshold, Some(0.0));
681    }
682
683    #[cfg(feature = "advanced-queries")]
684    #[test]
685    fn test_fuzzy_threshold_clamps_high() {
686        let builder = TaskQueryBuilder::new().fuzzy_threshold(1.5);
687        assert_eq!(builder.fuzzy_threshold, Some(1.0));
688    }
689
690    #[cfg(feature = "advanced-queries")]
691    #[test]
692    fn test_fuzzy_search_chains_with_other_filters() {
693        let builder = TaskQueryBuilder::new()
694            .status(TaskStatus::Incomplete)
695            .fuzzy_search("agenda")
696            .fuzzy_threshold(0.7);
697        assert_eq!(builder.fuzzy_query, Some("agenda".to_string()));
698        assert_eq!(builder.fuzzy_threshold, Some(0.7));
699        assert_eq!(builder.filters.status, Some(TaskStatus::Incomplete));
700    }
701
702    #[cfg(feature = "advanced-queries")]
703    mod fuzzy_score_tests {
704        use super::*;
705
706        #[test]
707        fn test_fuzzy_score_substring_short_circuit() {
708            assert_eq!(fuzzy_field_score("foo", "blah foo bar"), 1.0);
709        }
710
711        #[test]
712        fn test_fuzzy_score_typo_above_threshold() {
713            let score = fuzzy_field_score("urgent", "urgnt");
714            assert!(score >= 0.6, "expected score >= 0.6, got {score}");
715        }
716
717        #[test]
718        fn test_best_window_score_long_field() {
719            let long_field = "alexander needs to buy eggs and milk from the store today";
720            let whole = strsim::normalized_levenshtein("alex", long_field) as f32;
721            let windowed = best_window_score("alex", long_field);
722            assert!(
723                windowed > whole,
724                "windowed ({windowed}) should beat whole-field ({whole})"
725            );
726        }
727
728        #[test]
729        fn test_task_fuzzy_score_uses_max_of_title_notes() {
730            use chrono::Utc;
731            use uuid::Uuid;
732            let task = crate::models::Task {
733                uuid: Uuid::new_v4(),
734                title: "unrelated title xyz".to_string(),
735                notes: Some("meeting agenda important".to_string()),
736                task_type: crate::models::TaskType::Todo,
737                status: crate::models::TaskStatus::Incomplete,
738                start_date: None,
739                deadline: None,
740                created: Utc::now(),
741                modified: Utc::now(),
742                stop_date: None,
743                project_uuid: None,
744                area_uuid: None,
745                parent_uuid: None,
746                tags: vec![],
747                children: vec![],
748            };
749            let score = task_fuzzy_score("agenda", &task);
750            assert_eq!(score, 1.0, "notes contains 'agenda', score should be 1.0");
751        }
752    }
753
754    #[cfg(feature = "advanced-queries")]
755    mod saved_query_conversion_tests {
756        use super::*;
757
758        #[test]
759        fn test_to_saved_query_captures_all_state() {
760            let from = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
761            let to = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
762            let project = Uuid::new_v4();
763
764            let builder = TaskQueryBuilder::new()
765                .status(TaskStatus::Incomplete)
766                .task_type(TaskType::Todo)
767                .project_uuid(project)
768                .tags(vec!["work".to_string()])
769                .any_tags(vec!["urgent".to_string(), "p0".to_string()])
770                .exclude_tags(vec!["archived".to_string()])
771                .tag_count(2)
772                .fuzzy_search("budget")
773                .fuzzy_threshold(0.75)
774                .start_date_range(Some(from), Some(to))
775                .limit(10)
776                .offset(5);
777
778            let saved = builder.to_saved_query("everything");
779            assert_eq!(saved.name, "everything");
780            assert_eq!(saved.filters.status, Some(TaskStatus::Incomplete));
781            assert_eq!(saved.filters.task_type, Some(TaskType::Todo));
782            assert_eq!(saved.filters.project_uuid, Some(project));
783            assert_eq!(saved.filters.tags, Some(vec!["work".to_string()]));
784            assert_eq!(saved.filters.start_date_from, Some(from));
785            assert_eq!(saved.filters.limit, Some(10));
786            assert_eq!(saved.filters.offset, Some(5));
787            assert_eq!(
788                saved.any_tags,
789                Some(vec!["urgent".to_string(), "p0".to_string()])
790            );
791            assert_eq!(saved.exclude_tags, Some(vec!["archived".to_string()]));
792            assert_eq!(saved.tag_count_min, Some(2));
793            assert_eq!(saved.fuzzy_query, Some("budget".to_string()));
794            assert_eq!(saved.fuzzy_threshold, Some(0.75));
795            assert!(saved.where_expr.is_none());
796        }
797
798        #[test]
799        fn test_to_saved_query_captures_where_expr() {
800            use crate::filter_expr::FilterExpr;
801            let expr = FilterExpr::status(TaskStatus::Incomplete)
802                .or(FilterExpr::status(TaskStatus::Completed));
803            let saved = TaskQueryBuilder::new()
804                .where_expr(expr.clone())
805                .to_saved_query("with-expr");
806            assert_eq!(saved.where_expr, Some(expr));
807        }
808
809        #[test]
810        fn test_from_saved_query_restores_all_state() {
811            let original = TaskQueryBuilder::new()
812                .status(TaskStatus::Completed)
813                .any_tags(vec!["a".to_string()])
814                .fuzzy_search("hello")
815                .fuzzy_threshold(0.9)
816                .limit(7);
817            let saved = original.to_saved_query("test");
818            let rebuilt = TaskQueryBuilder::from_saved_query(&saved);
819
820            assert_eq!(rebuilt.filters.status, Some(TaskStatus::Completed));
821            assert_eq!(rebuilt.filters.limit, Some(7));
822            assert_eq!(rebuilt.any_tags, Some(vec!["a".to_string()]));
823            assert_eq!(rebuilt.fuzzy_query, Some("hello".to_string()));
824            assert_eq!(rebuilt.fuzzy_threshold, Some(0.9));
825        }
826
827        #[test]
828        fn test_saved_query_roundtrip_through_json() {
829            let original = TaskQueryBuilder::new()
830                .status(TaskStatus::Incomplete)
831                .any_tags(vec!["x".to_string()])
832                .fuzzy_search("foo")
833                .fuzzy_threshold(0.5);
834
835            let saved = original.to_saved_query("rt");
836            let json = serde_json::to_string(&saved).unwrap();
837            let restored: crate::saved_queries::SavedQuery = serde_json::from_str(&json).unwrap();
838            let rebuilt = TaskQueryBuilder::from_saved_query(&restored);
839
840            assert_eq!(rebuilt.filters.status, Some(TaskStatus::Incomplete));
841            assert_eq!(rebuilt.any_tags, Some(vec!["x".to_string()]));
842            assert_eq!(rebuilt.fuzzy_query, Some("foo".to_string()));
843            assert_eq!(rebuilt.fuzzy_threshold, Some(0.5));
844        }
845
846        #[test]
847        fn test_from_saved_query_restores_where_expr_through_json() {
848            use crate::filter_expr::FilterExpr;
849            let expr = FilterExpr::Or(vec![
850                FilterExpr::status(TaskStatus::Incomplete),
851                FilterExpr::status(TaskStatus::Completed),
852            ])
853            .and(FilterExpr::task_type(TaskType::Project).not());
854
855            let saved = TaskQueryBuilder::new()
856                .where_expr(expr.clone())
857                .to_saved_query("expr-rt");
858            let json = serde_json::to_string(&saved).unwrap();
859            let restored: crate::saved_queries::SavedQuery = serde_json::from_str(&json).unwrap();
860            let rebuilt = TaskQueryBuilder::from_saved_query(&restored);
861            assert_eq!(rebuilt.where_expr, Some(expr));
862        }
863    }
864
865    #[test]
866    fn test_task_query_builder_start_date_range() {
867        let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
868        let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
869        let builder = TaskQueryBuilder::new().start_date_range(Some(from), Some(to));
870        let filters = builder.build();
871
872        assert_eq!(filters.start_date_from, Some(from));
873        assert_eq!(filters.start_date_to, Some(to));
874    }
875
876    #[test]
877    fn test_task_query_builder_deadline_range() {
878        let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
879        let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
880        let builder = TaskQueryBuilder::new().deadline_range(Some(from), Some(to));
881        let filters = builder.build();
882
883        assert_eq!(filters.deadline_from, Some(from));
884        assert_eq!(filters.deadline_to, Some(to));
885    }
886
887    #[test]
888    fn test_task_query_builder_search() {
889        let query = "test search";
890        let builder = TaskQueryBuilder::new().search(query);
891        let filters = builder.build();
892
893        assert_eq!(filters.search_query, Some(query.to_string()));
894    }
895
896    #[test]
897    fn test_task_query_builder_limit() {
898        let builder = TaskQueryBuilder::new().limit(50);
899        let filters = builder.build();
900
901        assert_eq!(filters.limit, Some(50));
902    }
903
904    #[test]
905    fn test_task_query_builder_offset() {
906        let builder = TaskQueryBuilder::new().offset(10);
907        let filters = builder.build();
908
909        assert_eq!(filters.offset, Some(10));
910    }
911
912    #[cfg(feature = "advanced-queries")]
913    mod execute_tests {
914        use super::*;
915        use tempfile::NamedTempFile;
916
917        #[tokio::test]
918        async fn test_execute_empty_builder() {
919            let f = NamedTempFile::new().unwrap();
920            crate::test_utils::create_test_database(f.path())
921                .await
922                .unwrap();
923            let db = crate::database::ThingsDatabase::new(f.path())
924                .await
925                .unwrap();
926            let result = TaskQueryBuilder::new().execute(&db).await;
927            assert!(result.is_ok());
928        }
929
930        #[tokio::test]
931        async fn test_execute_with_status_filter() {
932            let f = NamedTempFile::new().unwrap();
933            crate::test_utils::create_test_database(f.path())
934                .await
935                .unwrap();
936            let db = crate::database::ThingsDatabase::new(f.path())
937                .await
938                .unwrap();
939            let result = TaskQueryBuilder::new()
940                .status(TaskStatus::Incomplete)
941                .execute(&db)
942                .await;
943            assert!(result.is_ok());
944            assert!(result
945                .unwrap()
946                .iter()
947                .all(|t| t.status == TaskStatus::Incomplete));
948        }
949    }
950
951    #[test]
952    fn test_task_query_builder_chaining() {
953        let uuid = Uuid::new_v4();
954        let tags = vec!["urgent".to_string()];
955        let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
956        let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
957
958        let builder = TaskQueryBuilder::new()
959            .status(TaskStatus::Incomplete)
960            .task_type(TaskType::Todo)
961            .project_uuid(uuid)
962            .tags(tags.clone())
963            .start_date_range(Some(from), Some(to))
964            .search("test")
965            .limit(25)
966            .offset(5);
967
968        let filters = builder.build();
969
970        assert_eq!(filters.status, Some(TaskStatus::Incomplete));
971        assert_eq!(filters.task_type, Some(TaskType::Todo));
972        assert_eq!(filters.project_uuid, Some(uuid));
973        assert_eq!(filters.tags, Some(tags));
974        assert_eq!(filters.start_date_from, Some(from));
975        assert_eq!(filters.start_date_to, Some(to));
976        assert_eq!(filters.search_query, Some("test".to_string()));
977        assert_eq!(filters.limit, Some(25));
978        assert_eq!(filters.offset, Some(5));
979    }
980
981    mod date_helper_tests {
982        use super::*;
983
984        #[test]
985        fn test_due_today_sets_deadline_range_to_today() {
986            let filters = TaskQueryBuilder::new().due_today().build();
987            let today = today();
988            assert_eq!(filters.deadline_from, Some(today));
989            assert_eq!(filters.deadline_to, Some(today));
990        }
991
992        #[test]
993        fn test_due_this_week_ends_on_sunday() {
994            let filters = TaskQueryBuilder::new().due_this_week().build();
995            let today = today();
996            assert_eq!(filters.deadline_from, Some(today));
997            let to = filters.deadline_to.unwrap();
998            assert_eq!(to.weekday(), chrono::Weekday::Sun);
999            assert!(to >= today);
1000        }
1001
1002        #[test]
1003        fn test_due_next_week_spans_monday_to_sunday() {
1004            let filters = TaskQueryBuilder::new().due_next_week().build();
1005            let from = filters.deadline_from.unwrap();
1006            let to = filters.deadline_to.unwrap();
1007            assert_eq!(from.weekday(), chrono::Weekday::Mon);
1008            assert_eq!(to.weekday(), chrono::Weekday::Sun);
1009            assert_eq!(to - from, Duration::days(6));
1010            assert!(from > today());
1011        }
1012
1013        #[test]
1014        fn test_due_in_n_days() {
1015            let filters = TaskQueryBuilder::new().due_in(7).build();
1016            let today = today();
1017            assert_eq!(filters.deadline_from, Some(today));
1018            assert_eq!(filters.deadline_to, Some(today + Duration::days(7)));
1019        }
1020
1021        #[test]
1022        fn test_due_in_zero_days_is_today() {
1023            let filters = TaskQueryBuilder::new().due_in(0).build();
1024            let today = today();
1025            assert_eq!(filters.deadline_from, Some(today));
1026            assert_eq!(filters.deadline_to, Some(today));
1027        }
1028
1029        #[test]
1030        fn test_overdue_sets_deadline_to_yesterday_with_no_lower_bound() {
1031            let filters = TaskQueryBuilder::new().overdue().build();
1032            let yesterday = today() - Duration::days(1);
1033            assert_eq!(filters.deadline_from, None);
1034            assert_eq!(filters.deadline_to, Some(yesterday));
1035        }
1036
1037        #[test]
1038        fn test_overdue_implicitly_sets_status_incomplete_when_unset() {
1039            let filters = TaskQueryBuilder::new().overdue().build();
1040            assert_eq!(filters.status, Some(TaskStatus::Incomplete));
1041        }
1042
1043        #[test]
1044        fn test_overdue_does_not_override_explicit_status() {
1045            let filters = TaskQueryBuilder::new()
1046                .status(TaskStatus::Canceled)
1047                .overdue()
1048                .build();
1049            assert_eq!(filters.status, Some(TaskStatus::Canceled));
1050        }
1051
1052        #[test]
1053        fn test_starting_today_sets_start_date_range() {
1054            let filters = TaskQueryBuilder::new().starting_today().build();
1055            let today = today();
1056            assert_eq!(filters.start_date_from, Some(today));
1057            assert_eq!(filters.start_date_to, Some(today));
1058        }
1059
1060        #[test]
1061        fn test_starting_this_week_ends_on_sunday() {
1062            let filters = TaskQueryBuilder::new().starting_this_week().build();
1063            let today = today();
1064            assert_eq!(filters.start_date_from, Some(today));
1065            let to = filters.start_date_to.unwrap();
1066            assert_eq!(to.weekday(), chrono::Weekday::Sun);
1067            assert!(to >= today);
1068        }
1069
1070        #[test]
1071        fn test_end_of_week_on_monday_returns_following_sunday() {
1072            let monday = NaiveDate::from_ymd_opt(2026, 4, 27).unwrap();
1073            assert_eq!(monday.weekday(), chrono::Weekday::Mon);
1074            let eow = end_of_week(monday);
1075            assert_eq!(eow, NaiveDate::from_ymd_opt(2026, 5, 3).unwrap());
1076            assert_eq!(eow.weekday(), chrono::Weekday::Sun);
1077        }
1078
1079        #[test]
1080        fn test_end_of_week_on_sunday_returns_same_day() {
1081            let sunday = NaiveDate::from_ymd_opt(2026, 5, 3).unwrap();
1082            assert_eq!(sunday.weekday(), chrono::Weekday::Sun);
1083            assert_eq!(end_of_week(sunday), sunday);
1084        }
1085    }
1086}