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}
27
28impl TaskQueryBuilder {
29 #[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 #[must_use]
51 pub const fn status(mut self, status: TaskStatus) -> Self {
52 self.filters.status = Some(status);
53 self
54 }
55
56 #[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 #[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 #[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 #[must_use]
79 pub fn tags(mut self, tags: Vec<String>) -> Self {
80 self.filters.tags = Some(tags);
81 self
82 }
83
84 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
195 pub const fn limit(mut self, limit: usize) -> Self {
196 self.filters.limit = Some(limit);
197 self
198 }
199
200 #[must_use]
202 pub const fn offset(mut self, offset: usize) -> Self {
203 self.filters.offset = Some(offset);
204 self
205 }
206
207 #[must_use]
209 pub fn due_today(self) -> Self {
210 let today = today();
211 self.deadline_range(Some(today), Some(today))
212 }
213
214 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
271 pub fn build(self) -> TaskFilters {
272 self.filters
273 }
274
275 #[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 #[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 #[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 #[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}