1use crate::models::{TaskFilters, TaskStatus, TaskType, ThingsId};
4use chrono::{Datelike, Duration, NaiveDate, Utc};
5
6#[derive(Debug, Clone)]
8pub struct TaskQueryBuilder {
9 filters: TaskFilters,
10 #[cfg(feature = "advanced-queries")]
14 any_tags: Option<Vec<String>>,
15 #[cfg(feature = "advanced-queries")]
16 exclude_tags: Option<Vec<String>>,
17 #[cfg(feature = "advanced-queries")]
18 tag_count_min: Option<usize>,
19 #[cfg(feature = "advanced-queries")]
20 fuzzy_query: Option<String>,
21 #[cfg(feature = "advanced-queries")]
22 fuzzy_threshold: Option<f32>,
23 #[cfg(feature = "advanced-queries")]
24 where_expr: Option<crate::filter_expr::FilterExpr>,
25 #[cfg(feature = "batch-operations")]
28 after: Option<crate::cursor::Cursor>,
29}
30
31impl TaskQueryBuilder {
32 #[must_use]
34 pub fn new() -> Self {
35 Self {
36 filters: TaskFilters::default(),
37 #[cfg(feature = "advanced-queries")]
38 any_tags: None,
39 #[cfg(feature = "advanced-queries")]
40 exclude_tags: None,
41 #[cfg(feature = "advanced-queries")]
42 tag_count_min: None,
43 #[cfg(feature = "advanced-queries")]
44 fuzzy_query: None,
45 #[cfg(feature = "advanced-queries")]
46 fuzzy_threshold: None,
47 #[cfg(feature = "advanced-queries")]
48 where_expr: None,
49 #[cfg(feature = "batch-operations")]
50 after: None,
51 }
52 }
53
54 #[must_use]
56 pub const fn status(mut self, status: TaskStatus) -> Self {
57 self.filters.status = Some(status);
58 self
59 }
60
61 #[must_use]
63 pub const fn task_type(mut self, task_type: TaskType) -> Self {
64 self.filters.task_type = Some(task_type);
65 self
66 }
67
68 #[must_use]
70 pub fn project_uuid(mut self, project_uuid: ThingsId) -> Self {
71 self.filters.project_uuid = Some(project_uuid);
72 self
73 }
74
75 #[must_use]
77 pub fn area_uuid(mut self, area_uuid: ThingsId) -> Self {
78 self.filters.area_uuid = Some(area_uuid);
79 self
80 }
81
82 #[must_use]
84 pub fn tags(mut self, tags: Vec<String>) -> Self {
85 self.filters.tags = Some(tags);
86 self
87 }
88
89 #[cfg(feature = "advanced-queries")]
97 #[must_use]
98 pub fn any_tags(mut self, tags: Vec<String>) -> Self {
99 self.any_tags = Some(tags);
100 self
101 }
102
103 #[cfg(feature = "advanced-queries")]
108 #[must_use]
109 pub fn exclude_tags(mut self, tags: Vec<String>) -> Self {
110 self.exclude_tags = Some(tags);
111 self
112 }
113
114 #[cfg(feature = "advanced-queries")]
119 #[must_use]
120 pub fn tag_count(mut self, min: usize) -> Self {
121 self.tag_count_min = Some(min);
122 self
123 }
124
125 #[cfg(feature = "advanced-queries")]
135 #[must_use]
136 pub fn fuzzy_search(mut self, query: &str) -> Self {
137 self.fuzzy_query = Some(query.to_string());
138 self
139 }
140
141 #[cfg(feature = "advanced-queries")]
146 #[must_use]
147 pub fn fuzzy_threshold(mut self, threshold: f32) -> Self {
148 self.fuzzy_threshold = Some(threshold.clamp(0.0, 1.0));
149 self
150 }
151
152 #[cfg(feature = "advanced-queries")]
165 #[must_use]
166 pub fn where_expr(mut self, expr: crate::filter_expr::FilterExpr) -> Self {
167 self.where_expr = Some(expr);
168 self
169 }
170
171 #[cfg(feature = "batch-operations")]
181 #[must_use]
182 pub fn after(mut self, cursor: crate::cursor::Cursor) -> Self {
183 self.after = Some(cursor);
184 self
185 }
186
187 #[must_use]
189 pub const fn start_date_range(
190 mut self,
191 from: Option<NaiveDate>,
192 to: Option<NaiveDate>,
193 ) -> Self {
194 self.filters.start_date_from = from;
195 self.filters.start_date_to = to;
196 self
197 }
198
199 #[must_use]
201 pub const fn deadline_range(mut self, from: Option<NaiveDate>, to: Option<NaiveDate>) -> Self {
202 self.filters.deadline_from = from;
203 self.filters.deadline_to = to;
204 self
205 }
206
207 #[must_use]
209 pub fn search(mut self, query: &str) -> Self {
210 self.filters.search_query = Some(query.to_string());
211 self
212 }
213
214 #[must_use]
216 pub const fn limit(mut self, limit: usize) -> Self {
217 self.filters.limit = Some(limit);
218 self
219 }
220
221 #[must_use]
223 pub const fn offset(mut self, offset: usize) -> Self {
224 self.filters.offset = Some(offset);
225 self
226 }
227
228 #[must_use]
230 pub fn due_today(self) -> Self {
231 let today = today();
232 self.deadline_range(Some(today), Some(today))
233 }
234
235 #[must_use]
238 pub fn due_this_week(self) -> Self {
239 let today = today();
240 self.deadline_range(Some(today), Some(end_of_week(today)))
241 }
242
243 #[must_use]
246 pub fn due_next_week(self) -> Self {
247 let today = today();
248 let next_monday = end_of_week(today) + Duration::days(1);
249 self.deadline_range(Some(next_monday), Some(end_of_week(next_monday)))
250 }
251
252 #[must_use]
254 pub fn due_in(self, days: i64) -> Self {
255 let today = today();
256 self.deadline_range(Some(today), Some(today + Duration::days(days)))
257 }
258
259 #[must_use]
265 pub fn overdue(mut self) -> Self {
266 let yesterday = today() - Duration::days(1);
267 self.filters.deadline_from = None;
268 self.filters.deadline_to = Some(yesterday);
269 if self.filters.status.is_none() {
270 self.filters.status = Some(TaskStatus::Incomplete);
271 }
272 self
273 }
274
275 #[must_use]
277 pub fn starting_today(self) -> Self {
278 let today = today();
279 self.start_date_range(Some(today), Some(today))
280 }
281
282 #[must_use]
285 pub fn starting_this_week(self) -> Self {
286 let today = today();
287 self.start_date_range(Some(today), Some(end_of_week(today)))
288 }
289
290 #[must_use]
292 pub fn build(self) -> TaskFilters {
293 self.filters
294 }
295
296 #[cfg(feature = "advanced-queries")]
311 pub async fn execute(
312 &self,
313 db: &crate::database::ThingsDatabase,
314 ) -> crate::error::Result<Vec<crate::models::Task>> {
315 if self.fuzzy_query.is_some() {
316 return self
317 .execute_ranked(db)
318 .await
319 .map(|ranked| ranked.into_iter().map(|r| r.task).collect());
320 }
321
322 let has_tag_post_filters = self.any_tags.as_ref().is_some_and(|t| !t.is_empty())
323 || self.exclude_tags.as_ref().is_some_and(|t| !t.is_empty())
324 || self.tag_count_min.is_some();
325 let has_where_expr = self.where_expr.is_some();
326
327 if !has_tag_post_filters && !has_where_expr {
328 return db.query_tasks(&self.filters).await;
329 }
330
331 let mut filters_no_page = self.filters.clone();
332 let limit = filters_no_page.limit.take();
333 let offset = filters_no_page.offset.take();
334
335 let tasks = db.query_tasks(&filters_no_page).await?;
336 let mut tasks = Self::apply_tag_filters(
337 tasks,
338 self.any_tags.as_deref(),
339 self.exclude_tags.as_deref(),
340 self.tag_count_min,
341 );
342
343 if let Some(expr) = &self.where_expr {
344 tasks.retain(|task| expr.matches(task));
345 }
346
347 let offset = offset.unwrap_or(0);
348 tasks = tasks.into_iter().skip(offset).collect();
349 if let Some(limit) = limit {
350 tasks.truncate(limit);
351 }
352
353 Ok(tasks)
354 }
355
356 #[cfg(feature = "advanced-queries")]
357 fn apply_tag_filters(
358 mut tasks: Vec<crate::models::Task>,
359 any_tags: Option<&[String]>,
360 exclude_tags: Option<&[String]>,
361 tag_count_min: Option<usize>,
362 ) -> Vec<crate::models::Task> {
363 if let Some(any) = any_tags {
364 if !any.is_empty() {
365 tasks.retain(|task| any.iter().any(|f| task.tags.contains(f)));
366 }
367 }
368 if let Some(excl) = exclude_tags {
369 if !excl.is_empty() {
370 tasks.retain(|task| !excl.iter().any(|f| task.tags.contains(f)));
371 }
372 }
373 if let Some(min) = tag_count_min {
374 tasks.retain(|task| task.tags.len() >= min);
375 }
376 tasks
377 }
378
379 #[cfg(all(feature = "advanced-queries", feature = "batch-operations"))]
402 pub async fn execute_paged(
403 &self,
404 db: &crate::database::ThingsDatabase,
405 ) -> crate::error::Result<crate::cursor::Page<crate::models::Task>> {
406 if self.fuzzy_query.is_some() {
407 return Err(crate::error::ThingsError::InvalidCursor(
408 "execute_paged and execute_stream do not support fuzzy_search; use execute_ranked instead".to_string(),
409 ));
410 }
411 if self.filters.offset.is_some() && self.after.is_some() {
412 return Err(crate::error::ThingsError::InvalidCursor(
413 "offset and after are mutually exclusive".to_string(),
414 ));
415 }
416
417 let after_payload = self
418 .after
419 .as_ref()
420 .map(crate::cursor::Cursor::decode)
421 .transpose()?;
422 let after_anchor = after_payload.as_ref().map(|p| (p.c.timestamp(), p.u));
423
424 let page_size = self.filters.limit.unwrap_or(DEFAULT_PAGE_SIZE);
425
426 let has_tag_post_filters = self.any_tags.as_ref().is_some_and(|t| !t.is_empty())
429 || self.exclude_tags.as_ref().is_some_and(|t| !t.is_empty())
430 || self.tag_count_min.is_some();
431 let has_where_expr = self.where_expr.is_some();
432 let has_post_filters = has_tag_post_filters || has_where_expr;
433
434 let mut filters = self.filters.clone();
435 filters.offset = None;
436 if has_post_filters {
437 filters.limit = None;
439 } else {
440 filters.limit = Some(page_size);
441 }
442
443 let mut tasks = db.query_tasks_inner(&filters, after_anchor).await?;
444
445 if has_tag_post_filters {
446 tasks = Self::apply_tag_filters(
447 tasks,
448 self.any_tags.as_deref(),
449 self.exclude_tags.as_deref(),
450 self.tag_count_min,
451 );
452 }
453 if let Some(expr) = &self.where_expr {
454 tasks.retain(|task| expr.matches(task));
455 }
456 if has_post_filters {
457 tasks.truncate(page_size);
458 }
459
460 let next_cursor = if tasks.len() == page_size {
461 tasks
462 .last()
463 .map(|last| {
464 let u = uuid::Uuid::parse_str(last.uuid.as_str()).map_err(|e| {
465 crate::error::ThingsError::InvalidCursor(format!(
466 "task uuid is not RFC-4122: {e}"
467 ))
468 })?;
469 let payload = crate::cursor::CursorPayload { c: last.created, u };
470 crate::cursor::Cursor::encode(&payload)
471 })
472 .transpose()?
473 } else {
474 None
475 };
476
477 Ok(crate::cursor::Page {
478 items: tasks,
479 next_cursor,
480 })
481 }
482
483 #[cfg(all(feature = "advanced-queries", feature = "batch-operations"))]
519 pub fn execute_stream<'a>(
520 mut self,
521 db: &'a crate::database::ThingsDatabase,
522 ) -> std::pin::Pin<
523 Box<dyn futures_core::Stream<Item = crate::error::Result<crate::models::Task>> + Send + 'a>,
524 >
525 where
526 Self: Send + 'a,
527 {
528 Box::pin(async_stream::try_stream! {
529 loop {
530 let page = self.execute_paged(db).await?;
531 let next = page.next_cursor;
532 for task in page.items {
533 yield task;
534 }
535 match next {
536 Some(c) => self.after = Some(c),
537 None => break,
538 }
539 }
540 })
541 }
542
543 #[cfg(feature = "advanced-queries")]
556 pub async fn execute_ranked(
557 &self,
558 db: &crate::database::ThingsDatabase,
559 ) -> crate::error::Result<Vec<crate::models::RankedTask>> {
560 let query = self.fuzzy_query.as_deref().ok_or_else(|| {
561 crate::error::ThingsError::validation(
562 "execute_ranked requires fuzzy_search() to be set",
563 )
564 })?;
565
566 let query_lc = query.to_lowercase();
567 let threshold = self.fuzzy_threshold.unwrap_or(DEFAULT_FUZZY_THRESHOLD);
568
569 let mut filters_no_page = self.filters.clone();
570 let limit = filters_no_page.limit.take();
571 let offset = filters_no_page.offset.take();
572
573 if filters_no_page.search_query.is_some() {
574 tracing::warn!(
575 "fuzzy_search and search both set; fuzzy takes precedence, ignoring substring search"
576 );
577 filters_no_page.search_query = None;
578 }
579
580 let tasks = db.query_tasks(&filters_no_page).await?;
581 let mut tasks = Self::apply_tag_filters(
582 tasks,
583 self.any_tags.as_deref(),
584 self.exclude_tags.as_deref(),
585 self.tag_count_min,
586 );
587
588 if let Some(expr) = &self.where_expr {
589 tasks.retain(|task| expr.matches(task));
590 }
591
592 let mut scored: Vec<crate::models::RankedTask> = tasks
593 .into_iter()
594 .filter_map(|task| {
595 let score = task_fuzzy_score(&query_lc, &task);
596 if score >= threshold {
597 Some(crate::models::RankedTask { task, score })
598 } else {
599 None
600 }
601 })
602 .collect();
603
604 scored.sort_by(|a, b| {
605 b.score
606 .partial_cmp(&a.score)
607 .unwrap_or(std::cmp::Ordering::Equal)
608 .then_with(|| a.task.uuid.as_str().cmp(b.task.uuid.as_str()))
609 });
610
611 let offset = offset.unwrap_or(0);
612 scored = scored.into_iter().skip(offset).collect();
613 if let Some(limit) = limit {
614 scored.truncate(limit);
615 }
616
617 Ok(scored)
618 }
619
620 #[cfg(feature = "advanced-queries")]
628 #[must_use]
629 pub fn to_saved_query(&self, name: impl Into<String>) -> crate::saved_queries::SavedQuery {
630 crate::saved_queries::SavedQuery {
631 name: name.into(),
632 description: None,
633 filters: self.filters.clone(),
634 any_tags: self.any_tags.clone(),
635 exclude_tags: self.exclude_tags.clone(),
636 tag_count_min: self.tag_count_min,
637 fuzzy_query: self.fuzzy_query.clone(),
638 fuzzy_threshold: self.fuzzy_threshold,
639 where_expr: self.where_expr.clone(),
640 saved_at: chrono::Utc::now(),
641 }
642 }
643
644 #[cfg(feature = "advanced-queries")]
650 #[must_use]
651 pub fn from_saved_query(query: &crate::saved_queries::SavedQuery) -> Self {
652 Self {
653 filters: query.filters.clone(),
654 any_tags: query.any_tags.clone(),
655 exclude_tags: query.exclude_tags.clone(),
656 tag_count_min: query.tag_count_min,
657 fuzzy_query: query.fuzzy_query.clone(),
658 fuzzy_threshold: query.fuzzy_threshold.map(|t| t.clamp(0.0, 1.0)),
659 where_expr: query.where_expr.clone(),
660 #[cfg(feature = "batch-operations")]
662 after: None,
663 }
664 }
665}
666
667impl Default for TaskQueryBuilder {
668 fn default() -> Self {
669 Self::new()
670 }
671}
672
673#[cfg(feature = "advanced-queries")]
674const DEFAULT_FUZZY_THRESHOLD: f32 = 0.6;
675
676#[cfg(all(feature = "advanced-queries", feature = "batch-operations"))]
677const DEFAULT_PAGE_SIZE: usize = 100;
678
679#[cfg(feature = "advanced-queries")]
680fn task_fuzzy_score(query_lc: &str, task: &crate::models::Task) -> f32 {
681 let title_score = fuzzy_field_score(query_lc, &task.title);
682 let notes_score = task
683 .notes
684 .as_deref()
685 .map(|n| fuzzy_field_score(query_lc, n))
686 .unwrap_or(0.0);
687 title_score.max(notes_score)
688}
689
690#[cfg(feature = "advanced-queries")]
691fn fuzzy_field_score(query_lc: &str, field: &str) -> f32 {
692 let field_lc = field.to_lowercase();
693 if !query_lc.is_empty() && field_lc.contains(query_lc) {
694 return 1.0;
695 }
696 best_window_score(query_lc, &field_lc)
697}
698
699#[cfg(feature = "advanced-queries")]
700fn best_window_score(query: &str, field: &str) -> f32 {
701 if query.is_empty() || field.is_empty() {
702 return 0.0;
703 }
704 let window_len = (2 * query.len()).min(field.len());
705 let step = 1;
706 let chars: Vec<char> = field.chars().collect();
707 let n = chars.len();
708 let mut best = 0.0f32;
709 let mut i = 0;
710 loop {
711 let end = (i + window_len).min(n);
712 let slice: String = chars[i..end].iter().collect();
713 let score = strsim::normalized_levenshtein(query, &slice) as f32;
714 if score > best {
715 best = score;
716 }
717 if end >= n {
718 break;
719 }
720 i += step;
721 }
722 best
723}
724
725fn today() -> NaiveDate {
726 Utc::now().date_naive()
727}
728
729fn end_of_week(d: NaiveDate) -> NaiveDate {
730 let days_from_monday = i64::from(d.weekday().num_days_from_monday());
731 d + Duration::days(6 - days_from_monday)
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 use chrono::NaiveDate;
738
739 #[test]
740 fn test_task_query_builder_new() {
741 let builder = TaskQueryBuilder::new();
742 let filters = builder.build();
743
744 assert!(filters.status.is_none());
745 assert!(filters.task_type.is_none());
746 assert!(filters.project_uuid.is_none());
747 assert!(filters.area_uuid.is_none());
748 assert!(filters.tags.is_none());
749 assert!(filters.start_date_from.is_none());
750 assert!(filters.start_date_to.is_none());
751 assert!(filters.deadline_from.is_none());
752 assert!(filters.deadline_to.is_none());
753 assert!(filters.search_query.is_none());
754 assert!(filters.limit.is_none());
755 assert!(filters.offset.is_none());
756 }
757
758 #[test]
759 fn test_task_query_builder_default() {
760 let builder = TaskQueryBuilder::default();
761 let filters = builder.build();
762
763 assert!(filters.status.is_none());
764 assert!(filters.task_type.is_none());
765 }
766
767 #[test]
768 fn test_task_query_builder_status() {
769 let builder = TaskQueryBuilder::new().status(TaskStatus::Completed);
770 let filters = builder.build();
771
772 assert_eq!(filters.status, Some(TaskStatus::Completed));
773 }
774
775 #[test]
776 fn test_task_query_builder_task_type() {
777 let builder = TaskQueryBuilder::new().task_type(TaskType::Project);
778 let filters = builder.build();
779
780 assert_eq!(filters.task_type, Some(TaskType::Project));
781 }
782
783 #[test]
784 fn test_task_query_builder_project_uuid() {
785 let uuid = ThingsId::new_v4();
786 let builder = TaskQueryBuilder::new().project_uuid(uuid.clone());
787 let filters = builder.build();
788
789 assert_eq!(filters.project_uuid, Some(uuid));
790 }
791
792 #[test]
793 fn test_task_query_builder_area_uuid() {
794 let uuid = ThingsId::new_v4();
795 let builder = TaskQueryBuilder::new().area_uuid(uuid.clone());
796 let filters = builder.build();
797
798 assert_eq!(filters.area_uuid, Some(uuid));
799 }
800
801 #[test]
802 fn test_task_query_builder_tags() {
803 let tags = vec!["urgent".to_string(), "important".to_string()];
804 let builder = TaskQueryBuilder::new().tags(tags.clone());
805 let filters = builder.build();
806
807 assert_eq!(filters.tags, Some(tags));
808 }
809
810 #[cfg(feature = "advanced-queries")]
811 #[test]
812 fn test_task_query_builder_any_tags() {
813 let tags = vec!["a".to_string(), "b".to_string()];
814 let builder = TaskQueryBuilder::new().any_tags(tags.clone());
815 assert_eq!(builder.any_tags, Some(tags));
816 }
817
818 #[cfg(feature = "advanced-queries")]
819 #[test]
820 fn test_task_query_builder_exclude_tags() {
821 let tags = vec!["archived".to_string()];
822 let builder = TaskQueryBuilder::new().exclude_tags(tags.clone());
823 assert_eq!(builder.exclude_tags, Some(tags));
824 }
825
826 #[cfg(feature = "advanced-queries")]
827 #[test]
828 fn test_task_query_builder_tag_count() {
829 let builder = TaskQueryBuilder::new().tag_count(2);
830 assert_eq!(builder.tag_count_min, Some(2));
831 }
832
833 #[cfg(feature = "advanced-queries")]
834 #[test]
835 fn test_task_query_builder_where_expr_setter() {
836 use crate::filter_expr::FilterExpr;
837 let expr = FilterExpr::status(TaskStatus::Incomplete);
838 let builder = TaskQueryBuilder::new().where_expr(expr.clone());
839 assert_eq!(builder.where_expr, Some(expr));
840 }
841
842 #[cfg(feature = "batch-operations")]
843 mod cursor_builder_tests {
844 use super::*;
845 use crate::cursor::{Cursor, CursorPayload};
846 use chrono::Utc;
847 use uuid::Uuid;
848
849 fn sample_cursor() -> Cursor {
850 Cursor::encode(&CursorPayload {
851 c: Utc::now(),
852 u: Uuid::new_v4(),
853 })
854 .unwrap()
855 }
856
857 #[test]
858 fn test_after_setter_stores_cursor() {
859 let c = sample_cursor();
860 let builder = TaskQueryBuilder::new().after(c.clone());
861 assert_eq!(builder.after, Some(c));
862 }
863
864 #[cfg(feature = "advanced-queries")]
865 #[tokio::test]
866 async fn test_execute_paged_rejects_offset_and_after() {
867 use tempfile::NamedTempFile;
868 let f = NamedTempFile::new().unwrap();
869 crate::test_utils::create_test_database(f.path())
870 .await
871 .unwrap();
872 let db = crate::database::ThingsDatabase::new(f.path())
873 .await
874 .unwrap();
875
876 let result = TaskQueryBuilder::new()
877 .offset(5)
878 .after(sample_cursor())
879 .execute_paged(&db)
880 .await;
881 match result {
882 Err(crate::error::ThingsError::InvalidCursor(msg)) => {
883 assert!(msg.contains("offset and after"), "msg: {msg}");
884 }
885 other => panic!("expected InvalidCursor, got {other:?}"),
886 }
887 }
888
889 #[cfg(feature = "advanced-queries")]
890 #[tokio::test]
891 async fn test_execute_paged_rejects_fuzzy_search() {
892 use tempfile::NamedTempFile;
893 let f = NamedTempFile::new().unwrap();
894 crate::test_utils::create_test_database(f.path())
895 .await
896 .unwrap();
897 let db = crate::database::ThingsDatabase::new(f.path())
898 .await
899 .unwrap();
900
901 let result = TaskQueryBuilder::new()
903 .fuzzy_search("anything")
904 .execute_paged(&db)
905 .await;
906 match result {
907 Err(crate::error::ThingsError::InvalidCursor(msg)) => {
908 assert!(msg.contains("fuzzy"), "msg: {msg}");
909 }
910 other => panic!("expected InvalidCursor, got {other:?}"),
911 }
912 }
913 }
914
915 #[cfg(feature = "advanced-queries")]
916 #[test]
917 fn test_task_query_builder_chaining_tag_methods() {
918 let builder = TaskQueryBuilder::new()
919 .tags(vec!["a".to_string()])
920 .any_tags(vec!["b".to_string(), "c".to_string()])
921 .exclude_tags(vec!["d".to_string()])
922 .tag_count(1);
923 assert_eq!(builder.filters.tags, Some(vec!["a".to_string()]));
924 assert_eq!(
925 builder.any_tags,
926 Some(vec!["b".to_string(), "c".to_string()])
927 );
928 assert_eq!(builder.exclude_tags, Some(vec!["d".to_string()]));
929 assert_eq!(builder.tag_count_min, Some(1));
930 }
931
932 #[cfg(feature = "advanced-queries")]
933 #[test]
934 fn test_fuzzy_search_sets_field() {
935 let builder = TaskQueryBuilder::new().fuzzy_search("meeting");
936 assert_eq!(builder.fuzzy_query, Some("meeting".to_string()));
937 }
938
939 #[cfg(feature = "advanced-queries")]
940 #[test]
941 fn test_fuzzy_threshold_clamps_low() {
942 let builder = TaskQueryBuilder::new().fuzzy_threshold(-0.5);
943 assert_eq!(builder.fuzzy_threshold, Some(0.0));
944 }
945
946 #[cfg(feature = "advanced-queries")]
947 #[test]
948 fn test_fuzzy_threshold_clamps_high() {
949 let builder = TaskQueryBuilder::new().fuzzy_threshold(1.5);
950 assert_eq!(builder.fuzzy_threshold, Some(1.0));
951 }
952
953 #[cfg(feature = "advanced-queries")]
954 #[test]
955 fn test_fuzzy_search_chains_with_other_filters() {
956 let builder = TaskQueryBuilder::new()
957 .status(TaskStatus::Incomplete)
958 .fuzzy_search("agenda")
959 .fuzzy_threshold(0.7);
960 assert_eq!(builder.fuzzy_query, Some("agenda".to_string()));
961 assert_eq!(builder.fuzzy_threshold, Some(0.7));
962 assert_eq!(builder.filters.status, Some(TaskStatus::Incomplete));
963 }
964
965 #[cfg(feature = "advanced-queries")]
966 mod fuzzy_score_tests {
967 use super::*;
968
969 #[test]
970 fn test_fuzzy_score_substring_short_circuit() {
971 assert_eq!(fuzzy_field_score("foo", "blah foo bar"), 1.0);
972 }
973
974 #[test]
975 fn test_fuzzy_score_typo_above_threshold() {
976 let score = fuzzy_field_score("urgent", "urgnt");
977 assert!(score >= 0.6, "expected score >= 0.6, got {score}");
978 }
979
980 #[test]
981 fn test_best_window_score_long_field() {
982 let long_field = "alexander needs to buy eggs and milk from the store today";
983 let whole = strsim::normalized_levenshtein("alex", long_field) as f32;
984 let windowed = best_window_score("alex", long_field);
985 assert!(
986 windowed > whole,
987 "windowed ({windowed}) should beat whole-field ({whole})"
988 );
989 }
990
991 #[test]
992 fn test_task_fuzzy_score_uses_max_of_title_notes() {
993 use chrono::Utc;
994 let task = crate::models::Task {
995 uuid: ThingsId::new_v4(),
996 title: "unrelated title xyz".to_string(),
997 notes: Some("meeting agenda important".to_string()),
998 task_type: crate::models::TaskType::Todo,
999 status: crate::models::TaskStatus::Incomplete,
1000 start_date: None,
1001 deadline: None,
1002 created: Utc::now(),
1003 modified: Utc::now(),
1004 stop_date: None,
1005 project_uuid: None,
1006 area_uuid: None,
1007 parent_uuid: None,
1008 tags: vec![],
1009 children: vec![],
1010 };
1011 let score = task_fuzzy_score("agenda", &task);
1012 assert_eq!(score, 1.0, "notes contains 'agenda', score should be 1.0");
1013 }
1014 }
1015
1016 #[cfg(feature = "advanced-queries")]
1017 mod saved_query_conversion_tests {
1018 use super::*;
1019
1020 #[test]
1021 fn test_to_saved_query_captures_all_state() {
1022 let from = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
1023 let to = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
1024 let project = ThingsId::new_v4();
1025
1026 let builder = TaskQueryBuilder::new()
1027 .status(TaskStatus::Incomplete)
1028 .task_type(TaskType::Todo)
1029 .project_uuid(project.clone())
1030 .tags(vec!["work".to_string()])
1031 .any_tags(vec!["urgent".to_string(), "p0".to_string()])
1032 .exclude_tags(vec!["archived".to_string()])
1033 .tag_count(2)
1034 .fuzzy_search("budget")
1035 .fuzzy_threshold(0.75)
1036 .start_date_range(Some(from), Some(to))
1037 .limit(10)
1038 .offset(5);
1039
1040 let saved = builder.to_saved_query("everything");
1041 assert_eq!(saved.name, "everything");
1042 assert_eq!(saved.filters.status, Some(TaskStatus::Incomplete));
1043 assert_eq!(saved.filters.task_type, Some(TaskType::Todo));
1044 assert_eq!(saved.filters.project_uuid, Some(project));
1045 assert_eq!(saved.filters.tags, Some(vec!["work".to_string()]));
1046 assert_eq!(saved.filters.start_date_from, Some(from));
1047 assert_eq!(saved.filters.limit, Some(10));
1048 assert_eq!(saved.filters.offset, Some(5));
1049 assert_eq!(
1050 saved.any_tags,
1051 Some(vec!["urgent".to_string(), "p0".to_string()])
1052 );
1053 assert_eq!(saved.exclude_tags, Some(vec!["archived".to_string()]));
1054 assert_eq!(saved.tag_count_min, Some(2));
1055 assert_eq!(saved.fuzzy_query, Some("budget".to_string()));
1056 assert_eq!(saved.fuzzy_threshold, Some(0.75));
1057 assert!(saved.where_expr.is_none());
1058 }
1059
1060 #[test]
1061 fn test_to_saved_query_captures_where_expr() {
1062 use crate::filter_expr::FilterExpr;
1063 let expr = FilterExpr::status(TaskStatus::Incomplete)
1064 .or(FilterExpr::status(TaskStatus::Completed));
1065 let saved = TaskQueryBuilder::new()
1066 .where_expr(expr.clone())
1067 .to_saved_query("with-expr");
1068 assert_eq!(saved.where_expr, Some(expr));
1069 }
1070
1071 #[test]
1072 fn test_from_saved_query_restores_all_state() {
1073 let original = TaskQueryBuilder::new()
1074 .status(TaskStatus::Completed)
1075 .any_tags(vec!["a".to_string()])
1076 .fuzzy_search("hello")
1077 .fuzzy_threshold(0.9)
1078 .limit(7);
1079 let saved = original.to_saved_query("test");
1080 let rebuilt = TaskQueryBuilder::from_saved_query(&saved);
1081
1082 assert_eq!(rebuilt.filters.status, Some(TaskStatus::Completed));
1083 assert_eq!(rebuilt.filters.limit, Some(7));
1084 assert_eq!(rebuilt.any_tags, Some(vec!["a".to_string()]));
1085 assert_eq!(rebuilt.fuzzy_query, Some("hello".to_string()));
1086 assert_eq!(rebuilt.fuzzy_threshold, Some(0.9));
1087 }
1088
1089 #[test]
1090 fn test_saved_query_roundtrip_through_json() {
1091 let original = TaskQueryBuilder::new()
1092 .status(TaskStatus::Incomplete)
1093 .any_tags(vec!["x".to_string()])
1094 .fuzzy_search("foo")
1095 .fuzzy_threshold(0.5);
1096
1097 let saved = original.to_saved_query("rt");
1098 let json = serde_json::to_string(&saved).unwrap();
1099 let restored: crate::saved_queries::SavedQuery = serde_json::from_str(&json).unwrap();
1100 let rebuilt = TaskQueryBuilder::from_saved_query(&restored);
1101
1102 assert_eq!(rebuilt.filters.status, Some(TaskStatus::Incomplete));
1103 assert_eq!(rebuilt.any_tags, Some(vec!["x".to_string()]));
1104 assert_eq!(rebuilt.fuzzy_query, Some("foo".to_string()));
1105 assert_eq!(rebuilt.fuzzy_threshold, Some(0.5));
1106 }
1107
1108 #[test]
1109 fn test_from_saved_query_restores_where_expr_through_json() {
1110 use crate::filter_expr::FilterExpr;
1111 let expr = FilterExpr::Or(vec![
1112 FilterExpr::status(TaskStatus::Incomplete),
1113 FilterExpr::status(TaskStatus::Completed),
1114 ])
1115 .and(FilterExpr::task_type(TaskType::Project).not());
1116
1117 let saved = TaskQueryBuilder::new()
1118 .where_expr(expr.clone())
1119 .to_saved_query("expr-rt");
1120 let json = serde_json::to_string(&saved).unwrap();
1121 let restored: crate::saved_queries::SavedQuery = serde_json::from_str(&json).unwrap();
1122 let rebuilt = TaskQueryBuilder::from_saved_query(&restored);
1123 assert_eq!(rebuilt.where_expr, Some(expr));
1124 }
1125 }
1126
1127 #[test]
1128 fn test_task_query_builder_start_date_range() {
1129 let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1130 let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1131 let builder = TaskQueryBuilder::new().start_date_range(Some(from), Some(to));
1132 let filters = builder.build();
1133
1134 assert_eq!(filters.start_date_from, Some(from));
1135 assert_eq!(filters.start_date_to, Some(to));
1136 }
1137
1138 #[test]
1139 fn test_task_query_builder_deadline_range() {
1140 let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1141 let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1142 let builder = TaskQueryBuilder::new().deadline_range(Some(from), Some(to));
1143 let filters = builder.build();
1144
1145 assert_eq!(filters.deadline_from, Some(from));
1146 assert_eq!(filters.deadline_to, Some(to));
1147 }
1148
1149 #[test]
1150 fn test_task_query_builder_search() {
1151 let query = "test search";
1152 let builder = TaskQueryBuilder::new().search(query);
1153 let filters = builder.build();
1154
1155 assert_eq!(filters.search_query, Some(query.to_string()));
1156 }
1157
1158 #[test]
1159 fn test_task_query_builder_limit() {
1160 let builder = TaskQueryBuilder::new().limit(50);
1161 let filters = builder.build();
1162
1163 assert_eq!(filters.limit, Some(50));
1164 }
1165
1166 #[test]
1167 fn test_task_query_builder_offset() {
1168 let builder = TaskQueryBuilder::new().offset(10);
1169 let filters = builder.build();
1170
1171 assert_eq!(filters.offset, Some(10));
1172 }
1173
1174 #[cfg(feature = "advanced-queries")]
1175 mod execute_tests {
1176 use super::*;
1177 use tempfile::NamedTempFile;
1178
1179 #[tokio::test]
1180 async fn test_execute_empty_builder() {
1181 let f = NamedTempFile::new().unwrap();
1182 crate::test_utils::create_test_database(f.path())
1183 .await
1184 .unwrap();
1185 let db = crate::database::ThingsDatabase::new(f.path())
1186 .await
1187 .unwrap();
1188 let result = TaskQueryBuilder::new().execute(&db).await;
1189 assert!(result.is_ok());
1190 }
1191
1192 #[tokio::test]
1193 async fn test_execute_with_status_filter() {
1194 let f = NamedTempFile::new().unwrap();
1195 crate::test_utils::create_test_database(f.path())
1196 .await
1197 .unwrap();
1198 let db = crate::database::ThingsDatabase::new(f.path())
1199 .await
1200 .unwrap();
1201 let result = TaskQueryBuilder::new()
1202 .status(TaskStatus::Incomplete)
1203 .execute(&db)
1204 .await;
1205 assert!(result.is_ok());
1206 assert!(result
1207 .unwrap()
1208 .iter()
1209 .all(|t| t.status == TaskStatus::Incomplete));
1210 }
1211 }
1212
1213 #[test]
1214 fn test_task_query_builder_chaining() {
1215 let uuid = ThingsId::new_v4();
1216 let tags = vec!["urgent".to_string()];
1217 let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1218 let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1219
1220 let builder = TaskQueryBuilder::new()
1221 .status(TaskStatus::Incomplete)
1222 .task_type(TaskType::Todo)
1223 .project_uuid(uuid.clone())
1224 .tags(tags.clone())
1225 .start_date_range(Some(from), Some(to))
1226 .search("test")
1227 .limit(25)
1228 .offset(5);
1229
1230 let filters = builder.build();
1231
1232 assert_eq!(filters.status, Some(TaskStatus::Incomplete));
1233 assert_eq!(filters.task_type, Some(TaskType::Todo));
1234 assert_eq!(filters.project_uuid, Some(uuid));
1235 assert_eq!(filters.tags, Some(tags));
1236 assert_eq!(filters.start_date_from, Some(from));
1237 assert_eq!(filters.start_date_to, Some(to));
1238 assert_eq!(filters.search_query, Some("test".to_string()));
1239 assert_eq!(filters.limit, Some(25));
1240 assert_eq!(filters.offset, Some(5));
1241 }
1242
1243 mod date_helper_tests {
1244 use super::*;
1245
1246 #[test]
1247 fn test_due_today_sets_deadline_range_to_today() {
1248 let filters = TaskQueryBuilder::new().due_today().build();
1249 let today = today();
1250 assert_eq!(filters.deadline_from, Some(today));
1251 assert_eq!(filters.deadline_to, Some(today));
1252 }
1253
1254 #[test]
1255 fn test_due_this_week_ends_on_sunday() {
1256 let filters = TaskQueryBuilder::new().due_this_week().build();
1257 let today = today();
1258 assert_eq!(filters.deadline_from, Some(today));
1259 let to = filters.deadline_to.unwrap();
1260 assert_eq!(to.weekday(), chrono::Weekday::Sun);
1261 assert!(to >= today);
1262 }
1263
1264 #[test]
1265 fn test_due_next_week_spans_monday_to_sunday() {
1266 let filters = TaskQueryBuilder::new().due_next_week().build();
1267 let from = filters.deadline_from.unwrap();
1268 let to = filters.deadline_to.unwrap();
1269 assert_eq!(from.weekday(), chrono::Weekday::Mon);
1270 assert_eq!(to.weekday(), chrono::Weekday::Sun);
1271 assert_eq!(to - from, Duration::days(6));
1272 assert!(from > today());
1273 }
1274
1275 #[test]
1276 fn test_due_in_n_days() {
1277 let filters = TaskQueryBuilder::new().due_in(7).build();
1278 let today = today();
1279 assert_eq!(filters.deadline_from, Some(today));
1280 assert_eq!(filters.deadline_to, Some(today + Duration::days(7)));
1281 }
1282
1283 #[test]
1284 fn test_due_in_zero_days_is_today() {
1285 let filters = TaskQueryBuilder::new().due_in(0).build();
1286 let today = today();
1287 assert_eq!(filters.deadline_from, Some(today));
1288 assert_eq!(filters.deadline_to, Some(today));
1289 }
1290
1291 #[test]
1292 fn test_overdue_sets_deadline_to_yesterday_with_no_lower_bound() {
1293 let filters = TaskQueryBuilder::new().overdue().build();
1294 let yesterday = today() - Duration::days(1);
1295 assert_eq!(filters.deadline_from, None);
1296 assert_eq!(filters.deadline_to, Some(yesterday));
1297 }
1298
1299 #[test]
1300 fn test_overdue_implicitly_sets_status_incomplete_when_unset() {
1301 let filters = TaskQueryBuilder::new().overdue().build();
1302 assert_eq!(filters.status, Some(TaskStatus::Incomplete));
1303 }
1304
1305 #[test]
1306 fn test_overdue_does_not_override_explicit_status() {
1307 let filters = TaskQueryBuilder::new()
1308 .status(TaskStatus::Canceled)
1309 .overdue()
1310 .build();
1311 assert_eq!(filters.status, Some(TaskStatus::Canceled));
1312 }
1313
1314 #[test]
1315 fn test_starting_today_sets_start_date_range() {
1316 let filters = TaskQueryBuilder::new().starting_today().build();
1317 let today = today();
1318 assert_eq!(filters.start_date_from, Some(today));
1319 assert_eq!(filters.start_date_to, Some(today));
1320 }
1321
1322 #[test]
1323 fn test_starting_this_week_ends_on_sunday() {
1324 let filters = TaskQueryBuilder::new().starting_this_week().build();
1325 let today = today();
1326 assert_eq!(filters.start_date_from, Some(today));
1327 let to = filters.start_date_to.unwrap();
1328 assert_eq!(to.weekday(), chrono::Weekday::Sun);
1329 assert!(to >= today);
1330 }
1331
1332 #[test]
1333 fn test_end_of_week_on_monday_returns_following_sunday() {
1334 let monday = NaiveDate::from_ymd_opt(2026, 4, 27).unwrap();
1335 assert_eq!(monday.weekday(), chrono::Weekday::Mon);
1336 let eow = end_of_week(monday);
1337 assert_eq!(eow, NaiveDate::from_ymd_opt(2026, 5, 3).unwrap());
1338 assert_eq!(eow.weekday(), chrono::Weekday::Sun);
1339 }
1340
1341 #[test]
1342 fn test_end_of_week_on_sunday_returns_same_day() {
1343 let sunday = NaiveDate::from_ymd_opt(2026, 5, 3).unwrap();
1344 assert_eq!(sunday.weekday(), chrono::Weekday::Sun);
1345 assert_eq!(end_of_week(sunday), sunday);
1346 }
1347 }
1348}