1use crate::models::{TaskFilters, TaskStatus, TaskType};
4use chrono::{Datelike, Duration, NaiveDate, Utc};
5use uuid::Uuid;
6
7#[derive(Debug, Clone)]
9pub struct TaskQueryBuilder {
10 filters: TaskFilters,
11 #[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 #[cfg(feature = "batch-operations")]
29 after: Option<crate::cursor::Cursor>,
30}
31
32impl TaskQueryBuilder {
33 #[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 #[must_use]
57 pub const fn status(mut self, status: TaskStatus) -> Self {
58 self.filters.status = Some(status);
59 self
60 }
61
62 #[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 #[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 #[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 #[must_use]
85 pub fn tags(mut self, tags: Vec<String>) -> Self {
86 self.filters.tags = Some(tags);
87 self
88 }
89
90 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
217 pub const fn limit(mut self, limit: usize) -> Self {
218 self.filters.limit = Some(limit);
219 self
220 }
221
222 #[must_use]
224 pub const fn offset(mut self, offset: usize) -> Self {
225 self.filters.offset = Some(offset);
226 self
227 }
228
229 #[must_use]
231 pub fn due_today(self) -> Self {
232 let today = today();
233 self.deadline_range(Some(today), Some(today))
234 }
235
236 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
293 pub fn build(self) -> TaskFilters {
294 self.filters
295 }
296
297 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 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}