1#[cfg(feature = "advanced-queries")]
2use crate::models::TaskFilters;
3use crate::{
4 database::{mappers::map_task_row, query_builders::TaskUpdateBuilder, validators},
5 error::{Result as ThingsResult, ThingsError},
6 models::{
7 Area, CreateTaskRequest, DeleteChildHandling, Project, Task, TaskStatus, TaskType,
8 UpdateTaskRequest,
9 },
10};
11use chrono::{DateTime, NaiveDate, Utc};
12use serde::{Deserialize, Serialize};
13use sqlx::{pool::PoolOptions, Row, SqlitePool};
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16use tracing::{debug, error, info, instrument};
17use uuid::Uuid;
18
19pub(crate) fn safe_timestamp_convert(ts_f64: f64) -> i64 {
21 if ts_f64.is_finite() && ts_f64 >= 0.0 {
23 let max_timestamp = 4_102_444_800_f64; if ts_f64 <= max_timestamp {
26 let ts_str = format!("{:.0}", ts_f64.trunc());
28 ts_str.parse::<i64>().unwrap_or(0)
29 } else {
30 0 }
32 } else {
33 0 }
35}
36
37pub(crate) fn things_date_to_naive_date(seconds_since_2001: i64) -> Option<chrono::NaiveDate> {
39 use chrono::{TimeZone, Utc};
40
41 if seconds_since_2001 <= 0 {
42 return None;
43 }
44
45 let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
47
48 let date_time = base_date + chrono::Duration::seconds(seconds_since_2001);
50
51 Some(date_time.date_naive())
52}
53
54pub fn naive_date_to_things_timestamp(date: NaiveDate) -> i64 {
56 use chrono::{NaiveTime, TimeZone, Utc};
57
58 let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
60
61 let date_time = date
63 .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
64 .and_local_timezone(Utc)
65 .single()
66 .unwrap();
67
68 date_time.timestamp() - base_date.timestamp()
70}
71
72pub fn serialize_tags_to_blob(tags: &[String]) -> ThingsResult<Vec<u8>> {
76 serde_json::to_vec(tags)
77 .map_err(|e| ThingsError::unknown(format!("Failed to serialize tags: {e}")))
78}
79
80pub fn deserialize_tags_from_blob(blob: &[u8]) -> ThingsResult<Vec<String>> {
82 serde_json::from_slice(blob)
83 .map_err(|e| ThingsError::unknown(format!("Failed to deserialize tags: {e}")))
84}
85
86pub(crate) fn things_uuid_to_uuid(things_uuid: &str) -> Uuid {
89 use std::collections::hash_map::DefaultHasher;
92 use std::hash::{Hash, Hasher};
93
94 let mut hasher = DefaultHasher::new();
95 things_uuid.hash(&mut hasher);
96 let hash = hasher.finish();
97
98 let bytes = [
101 ((hash >> 56) & 0xFF) as u8,
102 ((hash >> 48) & 0xFF) as u8,
103 ((hash >> 40) & 0xFF) as u8,
104 ((hash >> 32) & 0xFF) as u8,
105 ((hash >> 24) & 0xFF) as u8,
106 ((hash >> 16) & 0xFF) as u8,
107 ((hash >> 8) & 0xFF) as u8,
108 (hash & 0xFF) as u8,
109 u8::try_from(things_uuid.len().min(255)).unwrap_or(255),
111 things_uuid.chars().next().unwrap_or('0') as u8,
112 things_uuid.chars().nth(1).unwrap_or('0') as u8,
113 things_uuid.chars().nth(2).unwrap_or('0') as u8,
114 things_uuid.chars().nth(3).unwrap_or('0') as u8,
115 things_uuid.chars().nth(4).unwrap_or('0') as u8,
116 things_uuid.chars().nth(5).unwrap_or('0') as u8,
117 things_uuid.chars().nth(6).unwrap_or('0') as u8,
118 ];
119
120 Uuid::from_bytes(bytes)
121}
122
123impl TaskStatus {
124 fn from_i32(value: i32) -> Option<Self> {
125 match value {
126 0 => Some(TaskStatus::Incomplete),
127 1 => Some(TaskStatus::Completed),
128 2 => Some(TaskStatus::Canceled),
129 3 => Some(TaskStatus::Trashed),
130 _ => None,
131 }
132 }
133}
134
135impl TaskType {
136 fn from_i32(value: i32) -> Option<Self> {
137 match value {
138 0 => Some(TaskType::Todo),
139 1 => Some(TaskType::Project),
140 2 => Some(TaskType::Heading),
141 3 => Some(TaskType::Area),
142 _ => None,
143 }
144 }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct DatabasePoolConfig {
150 pub max_connections: u32,
152 pub min_connections: u32,
154 pub connect_timeout: Duration,
156 pub idle_timeout: Duration,
158 pub max_lifetime: Duration,
160 pub test_before_acquire: bool,
162 pub sqlite_optimizations: SqliteOptimizations,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SqliteOptimizations {
169 pub enable_wal_mode: bool,
171 pub synchronous_mode: String,
173 pub cache_size: i32,
175 pub enable_foreign_keys: bool,
177 pub journal_mode: String,
179 pub temp_store: String,
181 pub mmap_size: i64,
183 pub enable_query_planner: bool,
185}
186
187impl Default for DatabasePoolConfig {
188 fn default() -> Self {
189 Self {
190 max_connections: 10,
191 min_connections: 1,
192 connect_timeout: Duration::from_secs(30),
193 idle_timeout: Duration::from_secs(600), max_lifetime: Duration::from_secs(1800), test_before_acquire: true,
196 sqlite_optimizations: SqliteOptimizations::default(),
197 }
198 }
199}
200
201impl Default for SqliteOptimizations {
202 fn default() -> Self {
203 Self {
204 enable_wal_mode: true,
205 synchronous_mode: "NORMAL".to_string(),
206 cache_size: -20000, enable_foreign_keys: true,
208 journal_mode: "WAL".to_string(),
209 temp_store: "MEMORY".to_string(),
210 mmap_size: 268_435_456, enable_query_planner: true,
212 }
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct PoolHealthStatus {
219 pub is_healthy: bool,
220 pub pool_size: u32,
221 pub active_connections: u32,
222 pub idle_connections: u32,
223 pub max_connections: u32,
224 pub min_connections: u32,
225 pub connection_timeout: Duration,
226 pub idle_timeout: Option<Duration>,
227 pub max_lifetime: Option<Duration>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct PoolMetrics {
233 pub pool_size: u32,
234 pub active_connections: u32,
235 pub idle_connections: u32,
236 pub max_connections: u32,
237 pub min_connections: u32,
238 pub utilization_percentage: f64,
239 pub is_healthy: bool,
240 pub response_time_ms: u64,
241 pub connection_timeout: Duration,
242 pub idle_timeout: Option<Duration>,
243 pub max_lifetime: Option<Duration>,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct ComprehensiveHealthStatus {
249 pub overall_healthy: bool,
250 pub pool_health: PoolHealthStatus,
251 pub pool_metrics: PoolMetrics,
252 pub database_stats: DatabaseStats,
253 pub timestamp: DateTime<Utc>,
254}
255
256#[derive(Debug, Clone)]
259pub struct ThingsDatabase {
260 pool: SqlitePool,
261 config: DatabasePoolConfig,
262}
263
264impl ThingsDatabase {
265 #[instrument]
288 pub async fn new(database_path: &Path) -> ThingsResult<Self> {
289 Self::new_with_config(database_path, DatabasePoolConfig::default()).await
290 }
291
292 #[instrument]
326 pub async fn new_with_config(
327 database_path: &Path,
328 config: DatabasePoolConfig,
329 ) -> ThingsResult<Self> {
330 let database_url = format!("sqlite:{}", database_path.display());
331
332 info!(
333 "Connecting to SQLite database at: {} with optimized pool",
334 database_url
335 );
336
337 let pool = PoolOptions::new()
339 .max_connections(config.max_connections)
340 .min_connections(config.min_connections)
341 .acquire_timeout(config.connect_timeout)
342 .idle_timeout(Some(config.idle_timeout))
343 .max_lifetime(Some(config.max_lifetime))
344 .test_before_acquire(config.test_before_acquire)
345 .connect(&database_url)
346 .await
347 .map_err(|e| ThingsError::unknown(format!("Failed to connect to database: {e}")))?;
348
349 Self::apply_sqlite_optimizations(&pool, &config.sqlite_optimizations).await?;
351
352 info!(
353 "Database connection pool established successfully with {} max connections",
354 config.max_connections
355 );
356
357 Ok(Self { pool, config })
358 }
359
360 async fn apply_sqlite_optimizations(
362 pool: &SqlitePool,
363 optimizations: &SqliteOptimizations,
364 ) -> ThingsResult<()> {
365 sqlx::query(&format!(
367 "PRAGMA journal_mode = {}",
368 optimizations.journal_mode
369 ))
370 .execute(pool)
371 .await
372 .map_err(|e| ThingsError::unknown(format!("Failed to set journal mode: {e}")))?;
373
374 sqlx::query(&format!(
376 "PRAGMA synchronous = {}",
377 optimizations.synchronous_mode
378 ))
379 .execute(pool)
380 .await
381 .map_err(|e| ThingsError::unknown(format!("Failed to set synchronous mode: {e}")))?;
382
383 sqlx::query(&format!("PRAGMA cache_size = {}", optimizations.cache_size))
385 .execute(pool)
386 .await
387 .map_err(|e| ThingsError::unknown(format!("Failed to set cache size: {e}")))?;
388
389 let fk_setting = if optimizations.enable_foreign_keys {
391 "ON"
392 } else {
393 "OFF"
394 };
395 sqlx::query(&format!("PRAGMA foreign_keys = {fk_setting}"))
396 .execute(pool)
397 .await
398 .map_err(|e| ThingsError::unknown(format!("Failed to set foreign keys: {e}")))?;
399
400 sqlx::query(&format!("PRAGMA temp_store = {}", optimizations.temp_store))
402 .execute(pool)
403 .await
404 .map_err(|e| ThingsError::unknown(format!("Failed to set temp store: {e}")))?;
405
406 sqlx::query(&format!("PRAGMA mmap_size = {}", optimizations.mmap_size))
408 .execute(pool)
409 .await
410 .map_err(|e| ThingsError::unknown(format!("Failed to set mmap size: {e}")))?;
411
412 if optimizations.enable_query_planner {
414 sqlx::query("PRAGMA optimize")
415 .execute(pool)
416 .await
417 .map_err(|e| ThingsError::unknown(format!("Failed to optimize database: {e}")))?;
418 }
419
420 debug!(
421 "Applied SQLite optimizations: WAL={}, sync={}, cache={}KB, fk={}, temp={}, mmap={}MB",
422 optimizations.enable_wal_mode,
423 optimizations.synchronous_mode,
424 optimizations.cache_size.abs() / 1024,
425 optimizations.enable_foreign_keys,
426 optimizations.temp_store,
427 optimizations.mmap_size / 1024 / 1024
428 );
429
430 Ok(())
431 }
432
433 #[instrument]
439 pub async fn from_connection_string(database_url: &str) -> ThingsResult<Self> {
440 Self::from_connection_string_with_config(database_url, DatabasePoolConfig::default()).await
441 }
442
443 #[instrument]
449 pub async fn from_connection_string_with_config(
450 database_url: &str,
451 config: DatabasePoolConfig,
452 ) -> ThingsResult<Self> {
453 info!(
454 "Connecting to SQLite database: {} with optimized pool",
455 database_url
456 );
457
458 let pool = PoolOptions::new()
460 .max_connections(config.max_connections)
461 .min_connections(config.min_connections)
462 .acquire_timeout(config.connect_timeout)
463 .idle_timeout(Some(config.idle_timeout))
464 .max_lifetime(Some(config.max_lifetime))
465 .test_before_acquire(config.test_before_acquire)
466 .connect(database_url)
467 .await
468 .map_err(|e| ThingsError::unknown(format!("Failed to connect to database: {e}")))?;
469
470 Self::apply_sqlite_optimizations(&pool, &config.sqlite_optimizations).await?;
472
473 info!(
474 "Database connection pool established successfully with {} max connections",
475 config.max_connections
476 );
477
478 Ok(Self { pool, config })
479 }
480
481 #[must_use]
483 pub fn pool(&self) -> &SqlitePool {
484 &self.pool
485 }
486
487 #[instrument]
489 pub async fn is_connected(&self) -> bool {
490 match sqlx::query("SELECT 1").fetch_one(&self.pool).await {
491 Ok(_) => {
492 debug!("Database connection is healthy");
493 true
494 }
495 Err(e) => {
496 error!("Database connection check failed: {}", e);
497 false
498 }
499 }
500 }
501
502 #[instrument]
508 pub async fn get_pool_health(&self) -> ThingsResult<PoolHealthStatus> {
509 let pool_size = self.pool.size();
510 let idle_connections = self.pool.num_idle();
511 let active_connections = pool_size - u32::try_from(idle_connections).unwrap_or(0);
512
513 let is_healthy = self.is_connected().await;
515
516 Ok(PoolHealthStatus {
517 is_healthy,
518 pool_size,
519 active_connections,
520 idle_connections: u32::try_from(idle_connections).unwrap_or(0),
521 max_connections: self.config.max_connections,
522 min_connections: self.config.min_connections,
523 connection_timeout: self.config.connect_timeout,
524 idle_timeout: Some(self.config.idle_timeout),
525 max_lifetime: Some(self.config.max_lifetime),
526 })
527 }
528
529 #[instrument]
535 pub async fn get_pool_metrics(&self) -> ThingsResult<PoolMetrics> {
536 let pool_size = self.pool.size();
537 let idle_connections = self.pool.num_idle();
538 let active_connections = pool_size - u32::try_from(idle_connections).unwrap_or(0);
539
540 let max_connections = self.config.max_connections;
542 let utilization_percentage = if max_connections > 0 {
543 (f64::from(active_connections) / f64::from(max_connections)) * 100.0
544 } else {
545 0.0
546 };
547
548 let start_time = std::time::Instant::now();
550 let is_connected = self.is_connected().await;
551 let response_time_ms = u64::try_from(start_time.elapsed().as_millis()).unwrap_or(0);
552
553 Ok(PoolMetrics {
554 pool_size,
555 active_connections,
556 idle_connections: u32::try_from(idle_connections).unwrap_or(0),
557 max_connections,
558 min_connections: self.config.min_connections,
559 utilization_percentage,
560 is_healthy: is_connected,
561 response_time_ms,
562 connection_timeout: self.config.connect_timeout,
563 idle_timeout: Some(self.config.idle_timeout),
564 max_lifetime: Some(self.config.max_lifetime),
565 })
566 }
567
568 #[instrument]
574 pub async fn comprehensive_health_check(&self) -> ThingsResult<ComprehensiveHealthStatus> {
575 let pool_health = self.get_pool_health().await?;
576 let pool_metrics = self.get_pool_metrics().await?;
577 let db_stats = self.get_stats().await?;
578
579 let overall_healthy = pool_health.is_healthy && pool_metrics.is_healthy;
580
581 Ok(ComprehensiveHealthStatus {
582 overall_healthy,
583 pool_health,
584 pool_metrics,
585 database_stats: db_stats,
586 timestamp: Utc::now(),
587 })
588 }
589
590 #[instrument]
596 pub async fn get_stats(&self) -> ThingsResult<DatabaseStats> {
597 let task_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMTask")
598 .fetch_one(&self.pool)
599 .await
600 .map_err(|e| ThingsError::unknown(format!("Failed to get task count: {e}")))?;
601
602 let project_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMTask WHERE type = 1")
603 .fetch_one(&self.pool)
604 .await
605 .map_err(|e| ThingsError::unknown(format!("Failed to get project count: {e}")))?;
606
607 let area_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMArea")
608 .fetch_one(&self.pool)
609 .await
610 .map_err(|e| ThingsError::unknown(format!("Failed to get area count: {e}")))?;
611
612 Ok(DatabaseStats {
613 task_count: task_count.try_into().unwrap_or(0),
614 project_count: project_count.try_into().unwrap_or(0),
615 area_count: area_count.try_into().unwrap_or(0),
616 })
617 }
618
619 #[instrument]
647 pub async fn get_all_tasks(&self) -> ThingsResult<Vec<Task>> {
648 let rows = sqlx::query(
649 r"
650 SELECT
651 uuid, title, status, type,
652 start_date, due_date,
653 project_uuid, area_uuid,
654 notes, tags,
655 created, modified
656 FROM TMTask
657 ORDER BY created DESC
658 ",
659 )
660 .fetch_all(&self.pool)
661 .await
662 .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks: {e}")))?;
663
664 let mut tasks = Vec::new();
665 for row in rows {
666 let task = Task {
667 uuid: Uuid::parse_str(&row.get::<String, _>("uuid"))
668 .map_err(|e| ThingsError::unknown(format!("Invalid task UUID: {e}")))?,
669 title: row.get("title"),
670 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
671 task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
672 start_date: row
673 .get::<Option<String>, _>("start_date")
674 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
675 deadline: row
676 .get::<Option<String>, _>("due_date")
677 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
678 project_uuid: row
679 .get::<Option<String>, _>("project_uuid")
680 .and_then(|s| Uuid::parse_str(&s).ok()),
681 area_uuid: row
682 .get::<Option<String>, _>("area_uuid")
683 .and_then(|s| Uuid::parse_str(&s).ok()),
684 parent_uuid: None, notes: row.get("notes"),
686 tags: row
687 .get::<Option<String>, _>("tags")
688 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
689 .unwrap_or_default(),
690 children: Vec::new(), created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
692 .ok()
693 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
694 modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
695 .ok()
696 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
697 stop_date: None, };
699 tasks.push(task);
700 }
701
702 debug!("Fetched {} tasks", tasks.len());
703 Ok(tasks)
704 }
705
706 #[instrument]
712 pub async fn get_all_projects(&self) -> ThingsResult<Vec<Project>> {
713 let rows = sqlx::query(
714 r"
715 SELECT
716 uuid, title, status,
717 area, notes,
718 creationDate, userModificationDate,
719 startDate, deadline
720 FROM TMTask
721 WHERE type = 1 AND trashed = 0
722 ORDER BY creationDate DESC
723 ",
724 )
725 .fetch_all(&self.pool)
726 .await
727 .map_err(|e| ThingsError::unknown(format!("Failed to fetch projects: {e}")))?;
728
729 let mut projects = Vec::new();
730 for row in rows {
731 let project = Project {
732 uuid: things_uuid_to_uuid(&row.get::<String, _>("uuid")),
733 title: row.get("title"),
734 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
735 area_uuid: row
736 .get::<Option<String>, _>("area")
737 .map(|s| things_uuid_to_uuid(&s)),
738 notes: row.get("notes"),
739 deadline: row
740 .get::<Option<i64>, _>("deadline")
741 .and_then(|ts| DateTime::from_timestamp(ts, 0))
742 .map(|dt| dt.date_naive()),
743 start_date: row
744 .get::<Option<i64>, _>("startDate")
745 .and_then(|ts| DateTime::from_timestamp(ts, 0))
746 .map(|dt| dt.date_naive()),
747 tags: Vec::new(), tasks: Vec::new(), created: {
750 let ts_f64 = row.get::<f64, _>("creationDate");
751 let ts = safe_timestamp_convert(ts_f64);
752 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
753 },
754 modified: {
755 let ts_f64 = row.get::<f64, _>("userModificationDate");
756 let ts = safe_timestamp_convert(ts_f64);
757 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
758 },
759 };
760 projects.push(project);
761 }
762
763 debug!("Fetched {} projects", projects.len());
764 Ok(projects)
765 }
766
767 #[instrument]
773 pub async fn get_all_areas(&self) -> ThingsResult<Vec<Area>> {
774 let rows = sqlx::query(
776 r"
777 SELECT
778 uuid, title, visible, `index`
779 FROM TMArea
780 ORDER BY `index` ASC
781 ",
782 )
783 .fetch_all(&self.pool)
784 .await
785 .map_err(|e| ThingsError::unknown(format!("Failed to fetch areas: {e}")))?;
786
787 let mut areas = Vec::new();
788 for row in rows {
789 let uuid_str: String = row.get("uuid");
790 let uuid =
792 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
793
794 let area = Area {
795 uuid,
796 title: row.get("title"),
797 notes: None, projects: Vec::new(), tags: Vec::new(), created: Utc::now(), modified: Utc::now(), };
803 areas.push(area);
804 }
805
806 debug!("Fetched {} areas", areas.len());
807 Ok(areas)
808 }
809
810 #[instrument]
816 pub async fn get_tasks_by_status(&self, status: TaskStatus) -> ThingsResult<Vec<Task>> {
817 let status_value = status as i32;
818 let rows = sqlx::query(
819 r"
820 SELECT
821 uuid, title, status, type,
822 start_date, due_date,
823 project_uuid, area_uuid,
824 notes, tags,
825 created, modified
826 FROM TMTask
827 WHERE status = ?
828 ORDER BY created DESC
829 ",
830 )
831 .bind(status_value)
832 .fetch_all(&self.pool)
833 .await
834 .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks by status: {e}")))?;
835
836 let mut tasks = Vec::new();
837 for row in rows {
838 let task = Task {
839 uuid: Uuid::parse_str(&row.get::<String, _>("uuid"))
840 .map_err(|e| ThingsError::unknown(format!("Invalid task UUID: {e}")))?,
841 title: row.get("title"),
842 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
843 task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
844 start_date: row
845 .get::<Option<String>, _>("start_date")
846 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
847 deadline: row
848 .get::<Option<String>, _>("due_date")
849 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
850 project_uuid: row
851 .get::<Option<String>, _>("project_uuid")
852 .and_then(|s| Uuid::parse_str(&s).ok()),
853 area_uuid: row
854 .get::<Option<String>, _>("area_uuid")
855 .and_then(|s| Uuid::parse_str(&s).ok()),
856 parent_uuid: None, notes: row.get("notes"),
858 tags: row
859 .get::<Option<String>, _>("tags")
860 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
861 .unwrap_or_default(),
862 children: Vec::new(), created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
864 .ok()
865 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
866 modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
867 .ok()
868 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
869 stop_date: None, };
871 tasks.push(task);
872 }
873
874 debug!("Fetched {} tasks with status {:?}", tasks.len(), status);
875 Ok(tasks)
876 }
877
878 #[instrument]
884 pub async fn search_tasks(&self, query: &str) -> ThingsResult<Vec<Task>> {
885 let search_pattern = format!("%{query}%");
886 let rows = sqlx::query(
887 r"
888 SELECT
889 uuid, title, status, type,
890 startDate, deadline, stopDate,
891 project, area, heading,
892 notes, cachedTags,
893 creationDate, userModificationDate
894 FROM TMTask
895 WHERE (title LIKE ? OR notes LIKE ?) AND trashed = 0 AND type = 0
896 ORDER BY creationDate DESC
897 ",
898 )
899 .bind(&search_pattern)
900 .bind(&search_pattern)
901 .fetch_all(&self.pool)
902 .await
903 .map_err(|e| ThingsError::unknown(format!("Failed to search tasks: {e}")))?;
904
905 let tasks = rows
906 .iter()
907 .map(map_task_row)
908 .collect::<ThingsResult<Vec<Task>>>()?;
909
910 debug!("Found {} tasks matching query: {}", tasks.len(), query);
911 Ok(tasks)
912 }
913
914 #[cfg(feature = "advanced-queries")]
932 pub async fn query_tasks(&self, filters: &TaskFilters) -> ThingsResult<Vec<Task>> {
933 const COLS: &str = "uuid, title, type, status, notes, startDate, deadline, stopDate, \
934 creationDate, userModificationDate, project, area, heading, cachedTags";
935
936 let trashed_val = i32::from(matches!(filters.status, Some(TaskStatus::Trashed)));
939 let mut conditions: Vec<String> = vec![format!("trashed = {trashed_val}")];
940
941 if let Some(status) = filters.status {
942 let n = match status {
943 TaskStatus::Incomplete => Some(0),
944 TaskStatus::Completed => Some(1),
945 TaskStatus::Canceled => Some(2),
946 TaskStatus::Trashed => None, };
948 if let Some(n) = n {
949 conditions.push(format!("status = {n}"));
950 }
951 }
952
953 if let Some(task_type) = filters.task_type {
954 let n = match task_type {
955 TaskType::Todo => 0,
956 TaskType::Project => 1,
957 TaskType::Heading => 2,
958 TaskType::Area => 3,
959 };
960 conditions.push(format!("type = {n}"));
961 }
962
963 if let Some(ref uuid) = filters.project_uuid {
964 conditions.push(format!("project = '{uuid}'"));
965 }
966
967 if let Some(ref uuid) = filters.area_uuid {
968 conditions.push(format!("area = '{uuid}'"));
969 }
970
971 if let Some(from) = filters.start_date_from {
972 conditions.push(format!(
973 "startDate >= {}",
974 naive_date_to_things_timestamp(from)
975 ));
976 }
977 if let Some(to) = filters.start_date_to {
978 conditions.push(format!(
979 "startDate <= {}",
980 naive_date_to_things_timestamp(to)
981 ));
982 }
983
984 if let Some(from) = filters.deadline_from {
985 conditions.push(format!(
986 "deadline >= {}",
987 naive_date_to_things_timestamp(from)
988 ));
989 }
990 if let Some(to) = filters.deadline_to {
991 conditions.push(format!(
992 "deadline <= {}",
993 naive_date_to_things_timestamp(to)
994 ));
995 }
996
997 let where_clause = conditions.join(" AND ");
998 let mut sql =
999 format!("SELECT {COLS} FROM TMTask WHERE {where_clause} ORDER BY creationDate DESC");
1000
1001 let has_post_filters =
1004 filters.tags.as_ref().is_some_and(|t| !t.is_empty()) || filters.search_query.is_some();
1005
1006 if !has_post_filters {
1007 match (filters.limit, filters.offset) {
1008 (Some(limit), Some(offset)) => {
1009 sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
1010 }
1011 (Some(limit), None) => {
1012 sql.push_str(&format!(" LIMIT {limit}"));
1013 }
1014 (None, Some(offset)) => {
1015 sql.push_str(&format!(" LIMIT -1 OFFSET {offset}"));
1017 }
1018 (None, None) => {}
1019 }
1020 }
1021
1022 let rows = sqlx::query(&sql)
1023 .fetch_all(&self.pool)
1024 .await
1025 .map_err(|e| ThingsError::unknown(format!("Failed to query tasks: {e}")))?;
1026
1027 let mut tasks = rows
1028 .iter()
1029 .map(map_task_row)
1030 .collect::<ThingsResult<Vec<Task>>>()?;
1031
1032 if let Some(ref filter_tags) = filters.tags {
1033 if !filter_tags.is_empty() {
1034 tasks.retain(|task| filter_tags.iter().all(|f| task.tags.contains(f)));
1035 }
1036 }
1037
1038 if let Some(ref q) = filters.search_query {
1039 let q_lower = q.to_lowercase();
1040 tasks.retain(|task| {
1041 task.title.to_lowercase().contains(&q_lower)
1042 || task
1043 .notes
1044 .as_deref()
1045 .unwrap_or("")
1046 .to_lowercase()
1047 .contains(&q_lower)
1048 });
1049 }
1050
1051 if has_post_filters {
1052 let offset = filters.offset.unwrap_or(0);
1053 tasks = tasks.into_iter().skip(offset).collect();
1054 if let Some(limit) = filters.limit {
1055 tasks.truncate(limit);
1056 }
1057 }
1058
1059 Ok(tasks)
1060 }
1061
1062 #[allow(clippy::too_many_arguments)]
1081 #[instrument(skip(self))]
1082 pub async fn search_logbook(
1083 &self,
1084 search_text: Option<String>,
1085 from_date: Option<NaiveDate>,
1086 to_date: Option<NaiveDate>,
1087 project_uuid: Option<Uuid>,
1088 area_uuid: Option<Uuid>,
1089 tags: Option<Vec<String>>,
1090 limit: Option<u32>,
1091 ) -> ThingsResult<Vec<Task>> {
1092 let result_limit = limit.unwrap_or(50).min(500);
1094
1095 let rows = if let Some(ref text) = search_text {
1097 let pattern = format!("%{text}%");
1098 let mut q = String::from(
1099 "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, cachedTags, creationDate, userModificationDate FROM TMTask WHERE status = 1 AND trashed = 0 AND type = 0",
1100 );
1101 q.push_str(" AND (title LIKE ? OR notes LIKE ?)");
1102
1103 if let Some(date) = from_date {
1104 let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1106 let timestamp = date_time.timestamp() as f64;
1107 q.push_str(&format!(" AND stopDate >= {}", timestamp));
1108 }
1109
1110 if let Some(date) = to_date {
1111 let end_date = date + chrono::Duration::days(1);
1113 let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1114 let timestamp = date_time.timestamp() as f64;
1115 q.push_str(&format!(" AND stopDate < {}", timestamp));
1116 }
1117
1118 if let Some(uuid) = project_uuid {
1119 q.push_str(&format!(" AND project = '{}'", uuid));
1120 }
1121
1122 if let Some(uuid) = area_uuid {
1123 q.push_str(&format!(" AND area = '{}'", uuid));
1124 }
1125
1126 q.push_str(&format!(" ORDER BY stopDate DESC LIMIT {result_limit}"));
1127
1128 sqlx::query(&q)
1129 .bind(&pattern)
1130 .bind(&pattern)
1131 .fetch_all(&self.pool)
1132 .await
1133 .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
1134 } else {
1135 let mut q = String::from(
1136 "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, cachedTags, creationDate, userModificationDate FROM TMTask WHERE status = 1 AND trashed = 0 AND type = 0",
1137 );
1138
1139 if let Some(date) = from_date {
1140 let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1142 let timestamp = date_time.timestamp() as f64;
1143 q.push_str(&format!(" AND stopDate >= {}", timestamp));
1144 }
1145
1146 if let Some(date) = to_date {
1147 let end_date = date + chrono::Duration::days(1);
1149 let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1150 let timestamp = date_time.timestamp() as f64;
1151 q.push_str(&format!(" AND stopDate < {}", timestamp));
1152 }
1153
1154 if let Some(uuid) = project_uuid {
1155 q.push_str(&format!(" AND project = '{}'", uuid));
1156 }
1157
1158 if let Some(uuid) = area_uuid {
1159 q.push_str(&format!(" AND area = '{}'", uuid));
1160 }
1161
1162 q.push_str(&format!(" ORDER BY stopDate DESC LIMIT {result_limit}"));
1163
1164 sqlx::query(&q)
1165 .fetch_all(&self.pool)
1166 .await
1167 .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
1168 };
1169
1170 let mut tasks = rows
1172 .iter()
1173 .map(map_task_row)
1174 .collect::<ThingsResult<Vec<Task>>>()?;
1175
1176 if let Some(ref filter_tags) = tags {
1177 if !filter_tags.is_empty() {
1178 tasks.retain(|task| {
1179 filter_tags
1181 .iter()
1182 .all(|filter_tag| task.tags.contains(filter_tag))
1183 });
1184 }
1185 }
1186
1187 debug!("Found {} completed tasks in logbook", tasks.len());
1188 Ok(tasks)
1189 }
1190
1191 #[instrument(skip(self))]
1197 pub async fn get_inbox(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
1198 let query = if let Some(limit) = limit {
1199 format!("SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE type = 0 AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC LIMIT {limit}")
1200 } else {
1201 "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE type = 0 AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC"
1202 .to_string()
1203 };
1204
1205 let rows = sqlx::query(&query)
1206 .fetch_all(&self.pool)
1207 .await
1208 .map_err(|e| ThingsError::unknown(format!("Failed to fetch inbox tasks: {e}")))?;
1209
1210 let tasks = rows
1211 .iter()
1212 .map(map_task_row)
1213 .collect::<ThingsResult<Vec<Task>>>()?;
1214
1215 Ok(tasks)
1216 }
1217
1218 #[instrument(skip(self))]
1228 pub async fn get_today(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
1229 let query = if let Some(limit) = limit {
1232 format!(
1233 "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC LIMIT {limit}"
1234 )
1235 } else {
1236 "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC".to_string()
1237 };
1238
1239 let rows = sqlx::query(&query)
1240 .fetch_all(&self.pool)
1241 .await
1242 .map_err(|e| ThingsError::unknown(format!("Failed to fetch today's tasks: {e}")))?;
1243
1244 let tasks = rows
1245 .iter()
1246 .map(map_task_row)
1247 .collect::<ThingsResult<Vec<Task>>>()?;
1248
1249 Ok(tasks)
1250 }
1251
1252 #[instrument(skip(self))]
1258 pub async fn get_projects(&self, limit: Option<usize>) -> ThingsResult<Vec<Project>> {
1259 let _ = limit; self.get_all_projects().await
1261 }
1262
1263 #[instrument(skip(self))]
1269 pub async fn get_areas(&self) -> ThingsResult<Vec<Area>> {
1270 self.get_all_areas().await
1271 }
1272
1273 #[instrument(skip(self))]
1317 pub async fn create_task(&self, request: CreateTaskRequest) -> ThingsResult<Uuid> {
1318 crate::database::validate_date_range(request.start_date, request.deadline)?;
1320
1321 let uuid = Uuid::new_v4();
1323 let uuid_str = uuid.to_string();
1324
1325 if let Some(project_uuid) = &request.project_uuid {
1327 validators::validate_project_exists(&self.pool, project_uuid).await?;
1328 }
1329
1330 if let Some(area_uuid) = &request.area_uuid {
1331 validators::validate_area_exists(&self.pool, area_uuid).await?;
1332 }
1333
1334 if let Some(parent_uuid) = &request.parent_uuid {
1335 validators::validate_task_exists(&self.pool, parent_uuid).await?;
1336 }
1337
1338 let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
1340 let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
1341
1342 let now = Utc::now().timestamp() as f64;
1344
1345 let cached_tags = request
1347 .tags
1348 .as_ref()
1349 .map(|tags| serialize_tags_to_blob(tags))
1350 .transpose()?;
1351
1352 sqlx::query(
1354 r"
1355 INSERT INTO TMTask (
1356 uuid, title, type, status, notes,
1357 startDate, deadline, project, area, heading,
1358 cachedTags, creationDate, userModificationDate,
1359 trashed
1360 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1361 ",
1362 )
1363 .bind(&uuid_str)
1364 .bind(&request.title)
1365 .bind(request.task_type.unwrap_or(TaskType::Todo) as i32)
1366 .bind(request.status.unwrap_or(TaskStatus::Incomplete) as i32)
1367 .bind(request.notes.as_ref())
1368 .bind(start_date_ts)
1369 .bind(deadline_ts)
1370 .bind(request.project_uuid.map(|u| u.to_string()))
1371 .bind(request.area_uuid.map(|u| u.to_string()))
1372 .bind(request.parent_uuid.map(|u| u.to_string()))
1373 .bind(cached_tags)
1374 .bind(now)
1375 .bind(now)
1376 .bind(0) .execute(&self.pool)
1378 .await
1379 .map_err(|e| ThingsError::unknown(format!("Failed to create task: {e}")))?;
1380
1381 info!("Created task with UUID: {}", uuid);
1382 Ok(uuid)
1383 }
1384
1385 #[instrument(skip(self))]
1393 pub async fn create_project(
1394 &self,
1395 request: crate::models::CreateProjectRequest,
1396 ) -> ThingsResult<Uuid> {
1397 crate::database::validate_date_range(request.start_date, request.deadline)?;
1399
1400 let uuid = Uuid::new_v4();
1402 let uuid_str = uuid.to_string();
1403
1404 if let Some(area_uuid) = &request.area_uuid {
1406 validators::validate_area_exists(&self.pool, area_uuid).await?;
1407 }
1408
1409 let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
1411 let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
1412
1413 let now = Utc::now().timestamp() as f64;
1415
1416 let cached_tags = request
1418 .tags
1419 .as_ref()
1420 .map(|tags| serialize_tags_to_blob(tags))
1421 .transpose()?;
1422
1423 sqlx::query(
1425 r"
1426 INSERT INTO TMTask (
1427 uuid, title, type, status, notes,
1428 startDate, deadline, project, area, heading,
1429 cachedTags, creationDate, userModificationDate,
1430 trashed
1431 ) VALUES (?, ?, 1, 0, ?, ?, ?, NULL, ?, NULL, ?, ?, ?, 0)
1432 ",
1433 )
1434 .bind(&uuid_str)
1435 .bind(&request.title)
1436 .bind(request.notes.as_ref())
1437 .bind(start_date_ts)
1438 .bind(deadline_ts)
1439 .bind(request.area_uuid.map(|u| u.to_string()))
1440 .bind(cached_tags)
1441 .bind(now)
1442 .bind(now)
1443 .execute(&self.pool)
1444 .await
1445 .map_err(|e| ThingsError::unknown(format!("Failed to create project: {e}")))?;
1446
1447 info!("Created project with UUID: {}", uuid);
1448 Ok(uuid)
1449 }
1450
1451 #[instrument(skip(self))]
1460 pub async fn update_task(&self, request: UpdateTaskRequest) -> ThingsResult<()> {
1461 validators::validate_task_exists(&self.pool, &request.uuid).await?;
1463
1464 if request.start_date.is_some() || request.deadline.is_some() {
1466 if let Some(current_task) = self.get_task_by_uuid(&request.uuid).await? {
1468 let final_start = request.start_date.or(current_task.start_date);
1469 let final_deadline = request.deadline.or(current_task.deadline);
1470 crate::database::validate_date_range(final_start, final_deadline)?;
1471 }
1472 }
1473
1474 if let Some(project_uuid) = &request.project_uuid {
1476 validators::validate_project_exists(&self.pool, project_uuid).await?;
1477 }
1478
1479 if let Some(area_uuid) = &request.area_uuid {
1480 validators::validate_area_exists(&self.pool, area_uuid).await?;
1481 }
1482
1483 let builder = TaskUpdateBuilder::from_request(&request);
1485
1486 if builder.is_empty() {
1488 return Ok(());
1489 }
1490
1491 let query_string = builder.build_query_string();
1492 let mut q = sqlx::query(&query_string);
1493
1494 if let Some(title) = &request.title {
1496 q = q.bind(title);
1497 }
1498
1499 if let Some(notes) = &request.notes {
1500 q = q.bind(notes);
1501 }
1502
1503 if let Some(start_date) = request.start_date {
1504 q = q.bind(naive_date_to_things_timestamp(start_date));
1505 }
1506
1507 if let Some(deadline) = request.deadline {
1508 q = q.bind(naive_date_to_things_timestamp(deadline));
1509 }
1510
1511 if let Some(status) = request.status {
1512 q = q.bind(status as i32);
1513 }
1514
1515 if let Some(project_uuid) = request.project_uuid {
1516 q = q.bind(project_uuid.to_string());
1517 }
1518
1519 if let Some(area_uuid) = request.area_uuid {
1520 q = q.bind(area_uuid.to_string());
1521 }
1522
1523 if let Some(tags) = &request.tags {
1524 let cached_tags = serialize_tags_to_blob(tags)?;
1525 q = q.bind(cached_tags);
1526 }
1527
1528 let now = Utc::now().timestamp() as f64;
1530 q = q.bind(now).bind(request.uuid.to_string());
1531
1532 q.execute(&self.pool)
1533 .await
1534 .map_err(|e| ThingsError::unknown(format!("Failed to update task: {e}")))?;
1535
1536 info!("Updated task with UUID: {}", request.uuid);
1537 Ok(())
1538 }
1539
1540 #[instrument(skip(self))]
1548 pub async fn get_project_by_uuid(&self, uuid: &Uuid) -> ThingsResult<Option<Project>> {
1549 let row = sqlx::query(
1550 r"
1551 SELECT
1552 uuid, title, status,
1553 area, notes,
1554 creationDate, userModificationDate,
1555 startDate, deadline,
1556 trashed, type
1557 FROM TMTask
1558 WHERE uuid = ? AND type = 1
1559 ",
1560 )
1561 .bind(uuid.to_string())
1562 .fetch_optional(&self.pool)
1563 .await
1564 .map_err(|e| ThingsError::unknown(format!("Failed to fetch project: {e}")))?;
1565
1566 if let Some(row) = row {
1567 let trashed: i64 = row.get("trashed");
1569 if trashed == 1 {
1570 return Ok(None); }
1572
1573 let project = Project {
1574 uuid: things_uuid_to_uuid(&row.get::<String, _>("uuid")),
1575 title: row.get("title"),
1576 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
1577 area_uuid: row
1578 .get::<Option<String>, _>("area")
1579 .map(|s| things_uuid_to_uuid(&s)),
1580 notes: row.get("notes"),
1581 deadline: row
1582 .get::<Option<i64>, _>("deadline")
1583 .and_then(|ts| DateTime::from_timestamp(ts, 0))
1584 .map(|dt| dt.date_naive()),
1585 start_date: row
1586 .get::<Option<i64>, _>("startDate")
1587 .and_then(|ts| DateTime::from_timestamp(ts, 0))
1588 .map(|dt| dt.date_naive()),
1589 tags: Vec::new(), tasks: Vec::new(), created: {
1592 let ts_f64 = row.get::<f64, _>("creationDate");
1593 let ts = safe_timestamp_convert(ts_f64);
1594 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
1595 },
1596 modified: {
1597 let ts_f64 = row.get::<f64, _>("userModificationDate");
1598 let ts = safe_timestamp_convert(ts_f64);
1599 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
1600 },
1601 };
1602 Ok(Some(project))
1603 } else {
1604 Ok(None)
1605 }
1606 }
1607
1608 #[instrument(skip(self))]
1617 pub async fn update_project(
1618 &self,
1619 request: crate::models::UpdateProjectRequest,
1620 ) -> ThingsResult<()> {
1621 validators::validate_project_exists(&self.pool, &request.uuid).await?;
1623
1624 if request.start_date.is_some() || request.deadline.is_some() {
1626 if let Some(current_project) = self.get_project_by_uuid(&request.uuid).await? {
1628 let final_start = request.start_date.or(current_project.start_date);
1629 let final_deadline = request.deadline.or(current_project.deadline);
1630 crate::database::validate_date_range(final_start, final_deadline)?;
1631 }
1632 }
1633
1634 if let Some(area_uuid) = &request.area_uuid {
1636 validators::validate_area_exists(&self.pool, area_uuid).await?;
1637 }
1638
1639 let mut builder = TaskUpdateBuilder::new();
1641
1642 if request.title.is_some() {
1644 builder = builder.add_field("title");
1645 }
1646 if request.notes.is_some() {
1647 builder = builder.add_field("notes");
1648 }
1649 if request.start_date.is_some() {
1650 builder = builder.add_field("startDate");
1651 }
1652 if request.deadline.is_some() {
1653 builder = builder.add_field("deadline");
1654 }
1655 if request.area_uuid.is_some() {
1656 builder = builder.add_field("area");
1657 }
1658 if request.tags.is_some() {
1659 builder = builder.add_field("cachedTags");
1660 }
1661
1662 if builder.is_empty() {
1664 return Ok(());
1665 }
1666
1667 let query_str = builder.build_query_string();
1669 let mut q = sqlx::query(&query_str);
1670
1671 if let Some(ref title) = request.title {
1673 q = q.bind(title);
1674 }
1675 if let Some(ref notes) = request.notes {
1676 q = q.bind(notes);
1677 }
1678 if let Some(start_date) = request.start_date {
1679 q = q.bind(naive_date_to_things_timestamp(start_date));
1680 }
1681 if let Some(deadline) = request.deadline {
1682 q = q.bind(naive_date_to_things_timestamp(deadline));
1683 }
1684 if let Some(area_uuid) = request.area_uuid {
1685 q = q.bind(area_uuid.to_string());
1686 }
1687 if let Some(tags) = &request.tags {
1688 let cached_tags = serialize_tags_to_blob(tags)?;
1689 q = q.bind(cached_tags);
1690 }
1691
1692 let now = Utc::now().timestamp() as f64;
1694 q = q.bind(now).bind(request.uuid.to_string());
1695
1696 q.execute(&self.pool)
1697 .await
1698 .map_err(|e| ThingsError::unknown(format!("Failed to update project: {e}")))?;
1699
1700 info!("Updated project with UUID: {}", request.uuid);
1701 Ok(())
1702 }
1703
1704 #[instrument(skip(self))]
1710 pub async fn get_task_by_uuid(&self, uuid: &Uuid) -> ThingsResult<Option<Task>> {
1711 let row = sqlx::query(
1712 r"
1713 SELECT
1714 uuid, title, status, type,
1715 startDate, deadline, stopDate,
1716 project, area, heading,
1717 notes, cachedTags,
1718 creationDate, userModificationDate,
1719 trashed
1720 FROM TMTask
1721 WHERE uuid = ?
1722 ",
1723 )
1724 .bind(uuid.to_string())
1725 .fetch_optional(&self.pool)
1726 .await
1727 .map_err(|e| ThingsError::unknown(format!("Failed to fetch task: {e}")))?;
1728
1729 if let Some(row) = row {
1730 let trashed: i64 = row.get("trashed");
1732 if trashed == 1 {
1733 return Ok(None); }
1735
1736 let task = map_task_row(&row)?;
1738 Ok(Some(task))
1739 } else {
1740 Ok(None)
1741 }
1742 }
1743
1744 #[instrument(skip(self))]
1750 pub async fn complete_task(&self, uuid: &Uuid) -> ThingsResult<()> {
1751 validators::validate_task_exists(&self.pool, uuid).await?;
1753
1754 let now = Utc::now().timestamp() as f64;
1755
1756 sqlx::query(
1757 "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
1758 )
1759 .bind(now)
1760 .bind(now)
1761 .bind(uuid.to_string())
1762 .execute(&self.pool)
1763 .await
1764 .map_err(|e| ThingsError::unknown(format!("Failed to complete task: {e}")))?;
1765
1766 info!("Completed task with UUID: {}", uuid);
1767 Ok(())
1768 }
1769
1770 #[instrument(skip(self))]
1776 pub async fn uncomplete_task(&self, uuid: &Uuid) -> ThingsResult<()> {
1777 validators::validate_task_exists(&self.pool, uuid).await?;
1779
1780 let now = Utc::now().timestamp() as f64;
1781
1782 sqlx::query(
1783 "UPDATE TMTask SET status = 0, stopDate = NULL, userModificationDate = ? WHERE uuid = ?",
1784 )
1785 .bind(now)
1786 .bind(uuid.to_string())
1787 .execute(&self.pool)
1788 .await
1789 .map_err(|e| ThingsError::unknown(format!("Failed to uncomplete task: {e}")))?;
1790
1791 info!("Uncompleted task with UUID: {}", uuid);
1792 Ok(())
1793 }
1794
1795 #[instrument(skip(self))]
1801 pub async fn complete_project(
1802 &self,
1803 uuid: &Uuid,
1804 child_handling: crate::models::ProjectChildHandling,
1805 ) -> ThingsResult<()> {
1806 validators::validate_project_exists(&self.pool, uuid).await?;
1808
1809 let now = Utc::now().timestamp() as f64;
1810
1811 match child_handling {
1813 crate::models::ProjectChildHandling::Error => {
1814 let child_count: i64 = sqlx::query_scalar(
1816 "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
1817 )
1818 .bind(uuid.to_string())
1819 .fetch_one(&self.pool)
1820 .await
1821 .map_err(|e| {
1822 ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
1823 })?;
1824
1825 if child_count > 0 {
1826 return Err(ThingsError::unknown(format!(
1827 "Project {} has {} child task(s). Use cascade or orphan mode to complete.",
1828 uuid, child_count
1829 )));
1830 }
1831 }
1832 crate::models::ProjectChildHandling::Cascade => {
1833 sqlx::query(
1835 "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE project = ? AND trashed = 0",
1836 )
1837 .bind(now)
1838 .bind(now)
1839 .bind(uuid.to_string())
1840 .execute(&self.pool)
1841 .await
1842 .map_err(|e| ThingsError::unknown(format!("Failed to complete child tasks: {e}")))?;
1843 }
1844 crate::models::ProjectChildHandling::Orphan => {
1845 sqlx::query(
1847 "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
1848 )
1849 .bind(now)
1850 .bind(uuid.to_string())
1851 .execute(&self.pool)
1852 .await
1853 .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
1854 }
1855 }
1856
1857 sqlx::query(
1859 "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
1860 )
1861 .bind(now)
1862 .bind(now)
1863 .bind(uuid.to_string())
1864 .execute(&self.pool)
1865 .await
1866 .map_err(|e| ThingsError::unknown(format!("Failed to complete project: {e}")))?;
1867
1868 info!("Completed project with UUID: {}", uuid);
1869 Ok(())
1870 }
1871
1872 #[instrument(skip(self))]
1878 pub async fn delete_task(
1879 &self,
1880 uuid: &Uuid,
1881 child_handling: DeleteChildHandling,
1882 ) -> ThingsResult<()> {
1883 validators::validate_task_exists(&self.pool, uuid).await?;
1885
1886 let children = sqlx::query("SELECT uuid FROM TMTask WHERE heading = ? AND trashed = 0")
1888 .bind(uuid.to_string())
1889 .fetch_all(&self.pool)
1890 .await
1891 .map_err(|e| ThingsError::unknown(format!("Failed to query child tasks: {e}")))?;
1892
1893 let has_children = !children.is_empty();
1894
1895 if has_children {
1896 match child_handling {
1897 DeleteChildHandling::Error => {
1898 return Err(ThingsError::unknown(format!(
1899 "Task {} has {} child task(s). Use cascade or orphan mode to delete.",
1900 uuid,
1901 children.len()
1902 )));
1903 }
1904 DeleteChildHandling::Cascade => {
1905 let now = Utc::now().timestamp() as f64;
1907 for child_row in &children {
1908 let child_uuid: String = child_row.get("uuid");
1909 sqlx::query(
1910 "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?",
1911 )
1912 .bind(now)
1913 .bind(&child_uuid)
1914 .execute(&self.pool)
1915 .await
1916 .map_err(|e| {
1917 ThingsError::unknown(format!("Failed to delete child task: {e}"))
1918 })?;
1919 }
1920 info!("Cascade deleted {} child task(s)", children.len());
1921 }
1922 DeleteChildHandling::Orphan => {
1923 let now = Utc::now().timestamp() as f64;
1925 for child_row in &children {
1926 let child_uuid: String = child_row.get("uuid");
1927 sqlx::query(
1928 "UPDATE TMTask SET heading = NULL, userModificationDate = ? WHERE uuid = ?",
1929 )
1930 .bind(now)
1931 .bind(&child_uuid)
1932 .execute(&self.pool)
1933 .await
1934 .map_err(|e| {
1935 ThingsError::unknown(format!("Failed to orphan child task: {e}"))
1936 })?;
1937 }
1938 info!("Orphaned {} child task(s)", children.len());
1939 }
1940 }
1941 }
1942
1943 let now = Utc::now().timestamp() as f64;
1945 sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
1946 .bind(now)
1947 .bind(uuid.to_string())
1948 .execute(&self.pool)
1949 .await
1950 .map_err(|e| ThingsError::unknown(format!("Failed to delete task: {e}")))?;
1951
1952 info!("Deleted task with UUID: {}", uuid);
1953 Ok(())
1954 }
1955
1956 #[instrument(skip(self))]
1962 pub async fn delete_project(
1963 &self,
1964 uuid: &Uuid,
1965 child_handling: crate::models::ProjectChildHandling,
1966 ) -> ThingsResult<()> {
1967 validators::validate_project_exists(&self.pool, uuid).await?;
1969
1970 let now = Utc::now().timestamp() as f64;
1971
1972 match child_handling {
1974 crate::models::ProjectChildHandling::Error => {
1975 let child_count: i64 = sqlx::query_scalar(
1977 "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
1978 )
1979 .bind(uuid.to_string())
1980 .fetch_one(&self.pool)
1981 .await
1982 .map_err(|e| {
1983 ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
1984 })?;
1985
1986 if child_count > 0 {
1987 return Err(ThingsError::unknown(format!(
1988 "Project {} has {} child task(s). Use cascade or orphan mode to delete.",
1989 uuid, child_count
1990 )));
1991 }
1992 }
1993 crate::models::ProjectChildHandling::Cascade => {
1994 sqlx::query(
1996 "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE project = ? AND trashed = 0",
1997 )
1998 .bind(now)
1999 .bind(uuid.to_string())
2000 .execute(&self.pool)
2001 .await
2002 .map_err(|e| ThingsError::unknown(format!("Failed to delete child tasks: {e}")))?;
2003 }
2004 crate::models::ProjectChildHandling::Orphan => {
2005 sqlx::query(
2007 "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
2008 )
2009 .bind(now)
2010 .bind(uuid.to_string())
2011 .execute(&self.pool)
2012 .await
2013 .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
2014 }
2015 }
2016
2017 sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
2019 .bind(now)
2020 .bind(uuid.to_string())
2021 .execute(&self.pool)
2022 .await
2023 .map_err(|e| ThingsError::unknown(format!("Failed to delete project: {e}")))?;
2024
2025 info!("Deleted project with UUID: {}", uuid);
2026 Ok(())
2027 }
2028
2029 #[instrument(skip(self))]
2035 pub async fn create_area(
2036 &self,
2037 request: crate::models::CreateAreaRequest,
2038 ) -> ThingsResult<Uuid> {
2039 let uuid = Uuid::new_v4();
2041 let uuid_str = uuid.to_string();
2042
2043 let now = Utc::now().timestamp() as f64;
2045
2046 let max_index: Option<i64> = sqlx::query_scalar("SELECT MAX(`index`) FROM TMArea")
2048 .fetch_one(&self.pool)
2049 .await
2050 .map_err(|e| ThingsError::unknown(format!("Failed to get max area index: {e}")))?;
2051
2052 let next_index = max_index.unwrap_or(-1) + 1;
2053
2054 sqlx::query(
2056 r"
2057 INSERT INTO TMArea (
2058 uuid, title, visible, `index`,
2059 creationDate, userModificationDate
2060 ) VALUES (?, ?, 1, ?, ?, ?)
2061 ",
2062 )
2063 .bind(&uuid_str)
2064 .bind(&request.title)
2065 .bind(next_index)
2066 .bind(now)
2067 .bind(now)
2068 .execute(&self.pool)
2069 .await
2070 .map_err(|e| ThingsError::unknown(format!("Failed to create area: {e}")))?;
2071
2072 info!("Created area with UUID: {}", uuid);
2073 Ok(uuid)
2074 }
2075
2076 #[instrument(skip(self))]
2082 pub async fn update_area(&self, request: crate::models::UpdateAreaRequest) -> ThingsResult<()> {
2083 validators::validate_area_exists(&self.pool, &request.uuid).await?;
2085
2086 let now = Utc::now().timestamp() as f64;
2087
2088 sqlx::query("UPDATE TMArea SET title = ?, userModificationDate = ? WHERE uuid = ?")
2089 .bind(&request.title)
2090 .bind(now)
2091 .bind(request.uuid.to_string())
2092 .execute(&self.pool)
2093 .await
2094 .map_err(|e| ThingsError::unknown(format!("Failed to update area: {e}")))?;
2095
2096 info!("Updated area with UUID: {}", request.uuid);
2097 Ok(())
2098 }
2099
2100 #[instrument(skip(self))]
2109 pub async fn delete_area(&self, uuid: &Uuid) -> ThingsResult<()> {
2110 validators::validate_area_exists(&self.pool, uuid).await?;
2112
2113 let now = Utc::now().timestamp() as f64;
2114
2115 sqlx::query(
2117 "UPDATE TMTask SET area = NULL, userModificationDate = ? WHERE area = ? AND type = 1 AND trashed = 0",
2118 )
2119 .bind(now)
2120 .bind(uuid.to_string())
2121 .execute(&self.pool)
2122 .await
2123 .map_err(|e| ThingsError::unknown(format!("Failed to orphan projects in area: {e}")))?;
2124
2125 sqlx::query("DELETE FROM TMArea WHERE uuid = ?")
2127 .bind(uuid.to_string())
2128 .execute(&self.pool)
2129 .await
2130 .map_err(|e| ThingsError::unknown(format!("Failed to delete area: {e}")))?;
2131
2132 info!("Deleted area with UUID: {}", uuid);
2133 Ok(())
2134 }
2135
2136 #[instrument(skip(self))]
2146 pub async fn find_tag_by_normalized_title(
2147 &self,
2148 normalized: &str,
2149 ) -> ThingsResult<Option<crate::models::Tag>> {
2150 let row = sqlx::query(
2151 "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate
2152 FROM TMTag
2153 WHERE LOWER(title) = LOWER(?)",
2154 )
2155 .bind(normalized)
2156 .fetch_optional(&self.pool)
2157 .await
2158 .map_err(|e| ThingsError::unknown(format!("Failed to find tag by title: {e}")))?;
2159
2160 if let Some(row) = row {
2161 let uuid_str: String = row.get("uuid");
2162 let uuid =
2163 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2164 let title: String = row.get("title");
2165 let shortcut: Option<String> = row.get("shortcut");
2166 let parent_str: Option<String> = row.get("parent");
2167 let parent_uuid =
2168 parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2169
2170 let creation_ts: f64 = row.get("creationDate");
2171 let created = {
2172 let ts = safe_timestamp_convert(creation_ts);
2173 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2174 };
2175
2176 let modification_ts: f64 = row.get("userModificationDate");
2177 let modified = {
2178 let ts = safe_timestamp_convert(modification_ts);
2179 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2180 };
2181
2182 let used_ts: Option<f64> = row.get("usedDate");
2183 let last_used = used_ts.and_then(|ts| {
2184 let ts_i64 = safe_timestamp_convert(ts);
2185 DateTime::from_timestamp(ts_i64, 0)
2186 });
2187
2188 let usage_count: i64 = sqlx::query_scalar(
2190 "SELECT COUNT(*) FROM TMTask
2191 WHERE cachedTags IS NOT NULL
2192 AND json_extract(cachedTags, '$') LIKE ?
2193 AND trashed = 0",
2194 )
2195 .bind(format!("%\"{}\"%", title))
2196 .fetch_one(&self.pool)
2197 .await
2198 .unwrap_or(0);
2199
2200 Ok(Some(crate::models::Tag {
2201 uuid,
2202 title,
2203 shortcut,
2204 parent_uuid,
2205 created,
2206 modified,
2207 usage_count: usage_count as u32,
2208 last_used,
2209 }))
2210 } else {
2211 Ok(None)
2212 }
2213 }
2214
2215 #[instrument(skip(self))]
2223 pub async fn find_similar_tags(
2224 &self,
2225 title: &str,
2226 min_similarity: f32,
2227 ) -> ThingsResult<Vec<crate::models::TagMatch>> {
2228 use crate::database::tag_utils::{calculate_similarity, get_match_type};
2229
2230 let all_tags = self.get_all_tags().await?;
2232
2233 let mut matches: Vec<crate::models::TagMatch> = all_tags
2235 .into_iter()
2236 .filter_map(|tag| {
2237 let similarity = calculate_similarity(title, &tag.title);
2238 if similarity >= min_similarity {
2239 let match_type = get_match_type(title, &tag.title, min_similarity);
2240 Some(crate::models::TagMatch {
2241 tag,
2242 similarity_score: similarity,
2243 match_type,
2244 })
2245 } else {
2246 None
2247 }
2248 })
2249 .collect();
2250
2251 matches.sort_by(|a, b| {
2253 b.similarity_score
2254 .partial_cmp(&a.similarity_score)
2255 .unwrap_or(std::cmp::Ordering::Equal)
2256 });
2257
2258 Ok(matches)
2259 }
2260
2261 #[instrument(skip(self))]
2267 pub async fn search_tags(&self, query: &str) -> ThingsResult<Vec<crate::models::Tag>> {
2268 let rows = sqlx::query(
2269 "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate
2270 FROM TMTag
2271 WHERE title LIKE ?
2272 ORDER BY title",
2273 )
2274 .bind(format!("%{}%", query))
2275 .fetch_all(&self.pool)
2276 .await
2277 .map_err(|e| ThingsError::unknown(format!("Failed to search tags: {e}")))?;
2278
2279 let mut tags = Vec::new();
2280 for row in rows {
2281 let uuid_str: String = row.get("uuid");
2282 let uuid =
2283 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2284 let title: String = row.get("title");
2285 let shortcut: Option<String> = row.get("shortcut");
2286 let parent_str: Option<String> = row.get("parent");
2287 let parent_uuid =
2288 parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2289
2290 let creation_ts: f64 = row.get("creationDate");
2291 let created = {
2292 let ts = safe_timestamp_convert(creation_ts);
2293 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2294 };
2295
2296 let modification_ts: f64 = row.get("userModificationDate");
2297 let modified = {
2298 let ts = safe_timestamp_convert(modification_ts);
2299 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2300 };
2301
2302 let used_ts: Option<f64> = row.get("usedDate");
2303 let last_used = used_ts.and_then(|ts| {
2304 let ts_i64 = safe_timestamp_convert(ts);
2305 DateTime::from_timestamp(ts_i64, 0)
2306 });
2307
2308 let usage_count: i64 = sqlx::query_scalar(
2310 "SELECT COUNT(*) FROM TMTask
2311 WHERE cachedTags IS NOT NULL
2312 AND json_extract(cachedTags, '$') LIKE ?
2313 AND trashed = 0",
2314 )
2315 .bind(format!("%\"{}\"%", title))
2316 .fetch_one(&self.pool)
2317 .await
2318 .unwrap_or(0);
2319
2320 tags.push(crate::models::Tag {
2321 uuid,
2322 title,
2323 shortcut,
2324 parent_uuid,
2325 created,
2326 modified,
2327 usage_count: usage_count as u32,
2328 last_used,
2329 });
2330 }
2331
2332 Ok(tags)
2333 }
2334
2335 #[instrument(skip(self))]
2341 pub async fn get_all_tags(&self) -> ThingsResult<Vec<crate::models::Tag>> {
2342 let rows = sqlx::query(
2343 "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate
2344 FROM TMTag
2345 ORDER BY title",
2346 )
2347 .fetch_all(&self.pool)
2348 .await
2349 .map_err(|e| ThingsError::unknown(format!("Failed to get all tags: {e}")))?;
2350
2351 let mut tags = Vec::new();
2352 for row in rows {
2353 let uuid_str: String = row.get("uuid");
2354 let uuid =
2355 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2356 let title: String = row.get("title");
2357 let shortcut: Option<String> = row.get("shortcut");
2358 let parent_str: Option<String> = row.get("parent");
2359 let parent_uuid =
2360 parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2361
2362 let creation_ts: f64 = row.get("creationDate");
2363 let created = {
2364 let ts = safe_timestamp_convert(creation_ts);
2365 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2366 };
2367
2368 let modification_ts: f64 = row.get("userModificationDate");
2369 let modified = {
2370 let ts = safe_timestamp_convert(modification_ts);
2371 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2372 };
2373
2374 let used_ts: Option<f64> = row.get("usedDate");
2375 let last_used = used_ts.and_then(|ts| {
2376 let ts_i64 = safe_timestamp_convert(ts);
2377 DateTime::from_timestamp(ts_i64, 0)
2378 });
2379
2380 let usage_count: i64 = sqlx::query_scalar(
2382 "SELECT COUNT(*) FROM TMTask
2383 WHERE cachedTags IS NOT NULL
2384 AND json_extract(cachedTags, '$') LIKE ?
2385 AND trashed = 0",
2386 )
2387 .bind(format!("%\"{}\"%", title))
2388 .fetch_one(&self.pool)
2389 .await
2390 .unwrap_or(0);
2391
2392 tags.push(crate::models::Tag {
2393 uuid,
2394 title,
2395 shortcut,
2396 parent_uuid,
2397 created,
2398 modified,
2399 usage_count: usage_count as u32,
2400 last_used,
2401 });
2402 }
2403
2404 Ok(tags)
2405 }
2406
2407 #[instrument(skip(self))]
2413 pub async fn get_popular_tags(&self, limit: usize) -> ThingsResult<Vec<crate::models::Tag>> {
2414 let mut all_tags = self.get_all_tags().await?;
2415
2416 all_tags.sort_by_key(|t| std::cmp::Reverse(t.usage_count));
2418
2419 all_tags.truncate(limit);
2421
2422 Ok(all_tags)
2423 }
2424
2425 #[instrument(skip(self))]
2431 pub async fn get_recent_tags(&self, limit: usize) -> ThingsResult<Vec<crate::models::Tag>> {
2432 let rows = sqlx::query(
2433 "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate
2434 FROM TMTag
2435 WHERE usedDate IS NOT NULL
2436 ORDER BY usedDate DESC
2437 LIMIT ?",
2438 )
2439 .bind(limit as i64)
2440 .fetch_all(&self.pool)
2441 .await
2442 .map_err(|e| ThingsError::unknown(format!("Failed to get recent tags: {e}")))?;
2443
2444 let mut tags = Vec::new();
2445 for row in rows {
2446 let uuid_str: String = row.get("uuid");
2447 let uuid =
2448 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2449 let title: String = row.get("title");
2450 let shortcut: Option<String> = row.get("shortcut");
2451 let parent_str: Option<String> = row.get("parent");
2452 let parent_uuid =
2453 parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2454
2455 let creation_ts: f64 = row.get("creationDate");
2456 let created = {
2457 let ts = safe_timestamp_convert(creation_ts);
2458 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2459 };
2460
2461 let modification_ts: f64 = row.get("userModificationDate");
2462 let modified = {
2463 let ts = safe_timestamp_convert(modification_ts);
2464 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2465 };
2466
2467 let used_ts: Option<f64> = row.get("usedDate");
2468 let last_used = used_ts.and_then(|ts| {
2469 let ts_i64 = safe_timestamp_convert(ts);
2470 DateTime::from_timestamp(ts_i64, 0)
2471 });
2472
2473 let usage_count: i64 = sqlx::query_scalar(
2475 "SELECT COUNT(*) FROM TMTask
2476 WHERE cachedTags IS NOT NULL
2477 AND json_extract(cachedTags, '$') LIKE ?
2478 AND trashed = 0",
2479 )
2480 .bind(format!("%\"{}\"%", title))
2481 .fetch_one(&self.pool)
2482 .await
2483 .unwrap_or(0);
2484
2485 tags.push(crate::models::Tag {
2486 uuid,
2487 title,
2488 shortcut,
2489 parent_uuid,
2490 created,
2491 modified,
2492 usage_count: usage_count as u32,
2493 last_used,
2494 });
2495 }
2496
2497 Ok(tags)
2498 }
2499
2500 #[instrument(skip(self))]
2511 pub async fn create_tag_smart(
2512 &self,
2513 request: crate::models::CreateTagRequest,
2514 ) -> ThingsResult<crate::models::TagCreationResult> {
2515 use crate::database::tag_utils::normalize_tag_title;
2516 use crate::models::TagCreationResult;
2517
2518 let normalized = normalize_tag_title(&request.title);
2520
2521 if let Some(existing) = self.find_tag_by_normalized_title(&normalized).await? {
2523 return Ok(TagCreationResult::Existing {
2524 tag: existing,
2525 is_new: false,
2526 });
2527 }
2528
2529 let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2531
2532 if !similar_tags.is_empty() {
2534 return Ok(TagCreationResult::SimilarFound {
2535 similar_tags,
2536 requested_title: request.title,
2537 });
2538 }
2539
2540 let uuid = Uuid::new_v4();
2542 let now = Utc::now().timestamp() as f64;
2543
2544 sqlx::query(
2545 "INSERT INTO TMTag (uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate, `index`)
2546 VALUES (?, ?, ?, ?, ?, ?, NULL, 0)"
2547 )
2548 .bind(uuid.to_string())
2549 .bind(&request.title)
2550 .bind(request.shortcut.as_ref())
2551 .bind(request.parent_uuid.map(|u| u.to_string()))
2552 .bind(now)
2553 .bind(now)
2554 .execute(&self.pool)
2555 .await
2556 .map_err(|e| ThingsError::unknown(format!("Failed to create tag: {e}")))?;
2557
2558 info!("Created tag with UUID: {}", uuid);
2559 Ok(TagCreationResult::Created { uuid, is_new: true })
2560 }
2561
2562 #[instrument(skip(self))]
2568 pub async fn create_tag_force(
2569 &self,
2570 request: crate::models::CreateTagRequest,
2571 ) -> ThingsResult<Uuid> {
2572 let uuid = Uuid::new_v4();
2573 let now = Utc::now().timestamp() as f64;
2574
2575 sqlx::query(
2576 "INSERT INTO TMTag (uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate, `index`)
2577 VALUES (?, ?, ?, ?, ?, ?, NULL, 0)"
2578 )
2579 .bind(uuid.to_string())
2580 .bind(&request.title)
2581 .bind(request.shortcut.as_ref())
2582 .bind(request.parent_uuid.map(|u| u.to_string()))
2583 .bind(now)
2584 .bind(now)
2585 .execute(&self.pool)
2586 .await
2587 .map_err(|e| ThingsError::unknown(format!("Failed to create tag: {e}")))?;
2588
2589 info!("Forcefully created tag with UUID: {}", uuid);
2590 Ok(uuid)
2591 }
2592
2593 #[instrument(skip(self))]
2599 pub async fn update_tag(&self, request: crate::models::UpdateTagRequest) -> ThingsResult<()> {
2600 use crate::database::tag_utils::normalize_tag_title;
2601
2602 let existing = self
2604 .find_tag_by_normalized_title(&request.uuid.to_string())
2605 .await?;
2606 if existing.is_none() {
2607 let row = sqlx::query("SELECT 1 FROM TMTag WHERE uuid = ?")
2609 .bind(request.uuid.to_string())
2610 .fetch_optional(&self.pool)
2611 .await
2612 .map_err(|e| ThingsError::unknown(format!("Failed to validate tag: {e}")))?;
2613
2614 if row.is_none() {
2615 return Err(ThingsError::unknown(format!(
2616 "Tag not found: {}",
2617 request.uuid
2618 )));
2619 }
2620 }
2621
2622 if let Some(new_title) = &request.title {
2624 let normalized = normalize_tag_title(new_title);
2625 if let Some(duplicate) = self.find_tag_by_normalized_title(&normalized).await? {
2626 if duplicate.uuid != request.uuid {
2627 return Err(ThingsError::unknown(format!(
2628 "Tag with title '{}' already exists",
2629 new_title
2630 )));
2631 }
2632 }
2633 }
2634
2635 let now = Utc::now().timestamp() as f64;
2636
2637 let mut updates = Vec::new();
2639 let mut params: Vec<String> = Vec::new();
2640
2641 if let Some(title) = &request.title {
2642 updates.push("title = ?");
2643 params.push(title.clone());
2644 }
2645 if let Some(shortcut) = &request.shortcut {
2646 updates.push("shortcut = ?");
2647 params.push(shortcut.clone());
2648 }
2649 if let Some(parent_uuid) = request.parent_uuid {
2650 updates.push("parent = ?");
2651 params.push(parent_uuid.to_string());
2652 }
2653
2654 if updates.is_empty() {
2655 return Ok(()); }
2657
2658 updates.push("userModificationDate = ?");
2659 params.push(now.to_string());
2660
2661 let sql = format!("UPDATE TMTag SET {} WHERE uuid = ?", updates.join(", "));
2662 params.push(request.uuid.to_string());
2663
2664 let mut query = sqlx::query(&sql);
2665 for param in params {
2666 query = query.bind(param);
2667 }
2668
2669 query
2670 .execute(&self.pool)
2671 .await
2672 .map_err(|e| ThingsError::unknown(format!("Failed to update tag: {e}")))?;
2673
2674 info!("Updated tag with UUID: {}", request.uuid);
2675 Ok(())
2676 }
2677
2678 #[instrument(skip(self))]
2689 pub async fn delete_tag(&self, uuid: &Uuid, remove_from_tasks: bool) -> ThingsResult<()> {
2690 let tag = self.find_tag_by_normalized_title(&uuid.to_string()).await?;
2692
2693 if tag.is_none() {
2694 let row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2696 .bind(uuid.to_string())
2697 .fetch_optional(&self.pool)
2698 .await
2699 .map_err(|e| ThingsError::unknown(format!("Failed to find tag: {e}")))?;
2700
2701 if row.is_none() {
2702 return Err(ThingsError::unknown(format!("Tag not found: {}", uuid)));
2703 }
2704 }
2705
2706 if remove_from_tasks {
2707 info!("Removing tag {} from all tasks (not yet implemented)", uuid);
2710 }
2711
2712 sqlx::query("DELETE FROM TMTag WHERE uuid = ?")
2714 .bind(uuid.to_string())
2715 .execute(&self.pool)
2716 .await
2717 .map_err(|e| ThingsError::unknown(format!("Failed to delete tag: {e}")))?;
2718
2719 info!("Deleted tag with UUID: {}", uuid);
2720 Ok(())
2721 }
2722
2723 #[instrument(skip(self))]
2734 pub async fn merge_tags(&self, source_uuid: &Uuid, target_uuid: &Uuid) -> ThingsResult<()> {
2735 let source_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2737 .bind(source_uuid.to_string())
2738 .fetch_optional(&self.pool)
2739 .await
2740 .map_err(|e| ThingsError::unknown(format!("Failed to find source tag: {e}")))?;
2741
2742 if source_row.is_none() {
2743 return Err(ThingsError::unknown(format!(
2744 "Source tag not found: {}",
2745 source_uuid
2746 )));
2747 }
2748
2749 let target_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2750 .bind(target_uuid.to_string())
2751 .fetch_optional(&self.pool)
2752 .await
2753 .map_err(|e| ThingsError::unknown(format!("Failed to find target tag: {e}")))?;
2754
2755 if target_row.is_none() {
2756 return Err(ThingsError::unknown(format!(
2757 "Target tag not found: {}",
2758 target_uuid
2759 )));
2760 }
2761
2762 info!(
2765 "Merging tag {} into {} (tag replacement in tasks not yet fully implemented)",
2766 source_uuid, target_uuid
2767 );
2768
2769 let now = Utc::now().timestamp() as f64;
2771 sqlx::query("UPDATE TMTag SET userModificationDate = ?, usedDate = ? WHERE uuid = ?")
2772 .bind(now)
2773 .bind(now)
2774 .bind(target_uuid.to_string())
2775 .execute(&self.pool)
2776 .await
2777 .map_err(|e| ThingsError::unknown(format!("Failed to update target tag: {e}")))?;
2778
2779 sqlx::query("DELETE FROM TMTag WHERE uuid = ?")
2781 .bind(source_uuid.to_string())
2782 .execute(&self.pool)
2783 .await
2784 .map_err(|e| ThingsError::unknown(format!("Failed to delete source tag: {e}")))?;
2785
2786 info!("Merged tag {} into {}", source_uuid, target_uuid);
2787 Ok(())
2788 }
2789
2790 #[instrument(skip(self))]
2804 pub async fn add_tag_to_task(
2805 &self,
2806 task_uuid: &Uuid,
2807 tag_title: &str,
2808 ) -> ThingsResult<crate::models::TagAssignmentResult> {
2809 use crate::database::tag_utils::normalize_tag_title;
2810 use crate::models::TagAssignmentResult;
2811
2812 validators::validate_task_exists(&self.pool, task_uuid).await?;
2814
2815 let normalized = normalize_tag_title(tag_title);
2817
2818 let tag = if let Some(existing_tag) = self.find_tag_by_normalized_title(&normalized).await?
2820 {
2821 existing_tag
2822 } else {
2823 let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2825
2826 if !similar_tags.is_empty() {
2827 return Ok(TagAssignmentResult::Suggestions { similar_tags });
2828 }
2829
2830 let request = crate::models::CreateTagRequest {
2832 title: tag_title.to_string(),
2833 shortcut: None,
2834 parent_uuid: None,
2835 };
2836 let _uuid = self.create_tag_force(request).await?;
2837
2838 self.find_tag_by_normalized_title(&normalized)
2840 .await?
2841 .ok_or_else(|| ThingsError::unknown("Failed to retrieve newly created tag"))?
2842 };
2843
2844 let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
2846 .bind(task_uuid.to_string())
2847 .fetch_one(&self.pool)
2848 .await
2849 .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
2850
2851 let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
2852 let mut tags: Vec<String> = if let Some(blob) = cached_tags_blob {
2853 deserialize_tags_from_blob(&blob)?
2854 } else {
2855 Vec::new()
2856 };
2857
2858 if !tags.contains(&tag.title) {
2860 tags.push(tag.title.clone());
2861
2862 let cached_tags = serialize_tags_to_blob(&tags)?;
2864 let now = Utc::now().timestamp() as f64;
2865
2866 sqlx::query(
2867 "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2868 )
2869 .bind(cached_tags)
2870 .bind(now)
2871 .bind(task_uuid.to_string())
2872 .execute(&self.pool)
2873 .await
2874 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2875
2876 sqlx::query("UPDATE TMTag SET usedDate = ?, userModificationDate = ? WHERE uuid = ?")
2878 .bind(now)
2879 .bind(now)
2880 .bind(tag.uuid.to_string())
2881 .execute(&self.pool)
2882 .await
2883 .map_err(|e| ThingsError::unknown(format!("Failed to update tag usedDate: {e}")))?;
2884
2885 info!("Added tag '{}' to task {}", tag.title, task_uuid);
2886 }
2887
2888 Ok(TagAssignmentResult::Assigned { tag_uuid: tag.uuid })
2889 }
2890
2891 #[instrument(skip(self))]
2897 pub async fn remove_tag_from_task(
2898 &self,
2899 task_uuid: &Uuid,
2900 tag_title: &str,
2901 ) -> ThingsResult<()> {
2902 use crate::database::tag_utils::normalize_tag_title;
2903
2904 validators::validate_task_exists(&self.pool, task_uuid).await?;
2906
2907 let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
2909 .bind(task_uuid.to_string())
2910 .fetch_one(&self.pool)
2911 .await
2912 .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
2913
2914 let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
2915 let mut tags: Vec<String> = if let Some(blob) = cached_tags_blob {
2916 deserialize_tags_from_blob(&blob)?
2917 } else {
2918 return Ok(()); };
2920
2921 let normalized = normalize_tag_title(tag_title);
2923 let original_len = tags.len();
2924 tags.retain(|t| normalize_tag_title(t) != normalized);
2925
2926 if tags.len() < original_len {
2928 let cached_tags = if tags.is_empty() {
2929 None
2930 } else {
2931 Some(serialize_tags_to_blob(&tags)?)
2932 };
2933
2934 let now = Utc::now().timestamp() as f64;
2935
2936 if let Some(cached_tags_val) = cached_tags {
2937 sqlx::query(
2938 "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2939 )
2940 .bind(cached_tags_val)
2941 .bind(now)
2942 .bind(task_uuid.to_string())
2943 .execute(&self.pool)
2944 .await
2945 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2946 } else {
2947 sqlx::query(
2949 "UPDATE TMTask SET cachedTags = NULL, userModificationDate = ? WHERE uuid = ?",
2950 )
2951 .bind(now)
2952 .bind(task_uuid.to_string())
2953 .execute(&self.pool)
2954 .await
2955 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2956 }
2957
2958 info!("Removed tag '{}' from task {}", tag_title, task_uuid);
2959 }
2960
2961 Ok(())
2962 }
2963
2964 #[instrument(skip(self))]
2972 pub async fn set_task_tags(
2973 &self,
2974 task_uuid: &Uuid,
2975 tag_titles: Vec<String>,
2976 ) -> ThingsResult<Vec<crate::models::TagMatch>> {
2977 use crate::database::tag_utils::normalize_tag_title;
2978
2979 validators::validate_task_exists(&self.pool, task_uuid).await?;
2981
2982 let mut resolved_tags = Vec::new();
2983 let mut suggestions = Vec::new();
2984
2985 for title in tag_titles {
2987 let normalized = normalize_tag_title(&title);
2988
2989 if let Some(existing_tag) = self.find_tag_by_normalized_title(&normalized).await? {
2991 resolved_tags.push(existing_tag.title);
2992 } else {
2993 let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2995
2996 if !similar_tags.is_empty() {
2997 suggestions.extend(similar_tags);
2998 }
2999
3000 resolved_tags.push(title);
3002 }
3003 }
3004
3005 for title in &resolved_tags {
3007 let normalized = normalize_tag_title(title);
3008 if self
3009 .find_tag_by_normalized_title(&normalized)
3010 .await?
3011 .is_none()
3012 {
3013 let request = crate::models::CreateTagRequest {
3014 title: title.clone(),
3015 shortcut: None,
3016 parent_uuid: None,
3017 };
3018 self.create_tag_force(request).await?;
3019 }
3020 }
3021
3022 let cached_tags = if resolved_tags.is_empty() {
3024 None
3025 } else {
3026 Some(serialize_tags_to_blob(&resolved_tags)?)
3027 };
3028
3029 let now = Utc::now().timestamp() as f64;
3030
3031 if let Some(cached_tags_val) = cached_tags {
3032 sqlx::query(
3033 "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
3034 )
3035 .bind(cached_tags_val)
3036 .bind(now)
3037 .bind(task_uuid.to_string())
3038 .execute(&self.pool)
3039 .await
3040 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
3041 } else {
3042 sqlx::query(
3043 "UPDATE TMTask SET cachedTags = NULL, userModificationDate = ? WHERE uuid = ?",
3044 )
3045 .bind(now)
3046 .bind(task_uuid.to_string())
3047 .execute(&self.pool)
3048 .await
3049 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
3050 }
3051
3052 for title in &resolved_tags {
3054 let normalized = normalize_tag_title(title);
3055 if let Some(tag) = self.find_tag_by_normalized_title(&normalized).await? {
3056 sqlx::query(
3057 "UPDATE TMTag SET usedDate = ?, userModificationDate = ? WHERE uuid = ?",
3058 )
3059 .bind(now)
3060 .bind(now)
3061 .bind(tag.uuid.to_string())
3062 .execute(&self.pool)
3063 .await
3064 .map_err(|e| ThingsError::unknown(format!("Failed to update tag usedDate: {e}")))?;
3065 }
3066 }
3067
3068 info!("Set tags on task {} to: {:?}", task_uuid, resolved_tags);
3069 Ok(suggestions)
3070 }
3071
3072 #[instrument(skip(self))]
3088 pub async fn get_tag_completions(
3089 &self,
3090 partial_input: &str,
3091 limit: usize,
3092 ) -> ThingsResult<Vec<crate::models::TagCompletion>> {
3093 use crate::database::tag_utils::{calculate_similarity, normalize_tag_title};
3094
3095 let normalized_input = normalize_tag_title(partial_input);
3096 let all_tags = self.get_all_tags().await?;
3097
3098 let mut completions: Vec<crate::models::TagCompletion> = all_tags
3099 .into_iter()
3100 .filter_map(|tag| {
3101 let normalized_tag = normalize_tag_title(&tag.title);
3102
3103 let score = if normalized_tag.starts_with(&normalized_input) {
3105 3.0 + (tag.usage_count as f32 / 100.0)
3107 } else if normalized_tag.contains(&normalized_input) {
3108 2.0 + (tag.usage_count as f32 / 100.0)
3110 } else {
3111 let similarity = calculate_similarity(partial_input, &tag.title);
3113 if similarity >= 0.6 {
3114 similarity + (tag.usage_count as f32 / 1000.0)
3115 } else {
3116 return None; }
3118 };
3119
3120 Some(crate::models::TagCompletion { tag, score })
3121 })
3122 .collect();
3123
3124 completions.sort_by(|a, b| {
3126 b.score
3127 .partial_cmp(&a.score)
3128 .unwrap_or(std::cmp::Ordering::Equal)
3129 });
3130
3131 completions.truncate(limit);
3133
3134 Ok(completions)
3135 }
3136
3137 #[instrument(skip(self))]
3143 pub async fn get_tag_statistics(
3144 &self,
3145 uuid: &Uuid,
3146 ) -> ThingsResult<crate::models::TagStatistics> {
3147 let tag_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
3149 .bind(uuid.to_string())
3150 .fetch_optional(&self.pool)
3151 .await
3152 .map_err(|e| ThingsError::unknown(format!("Failed to find tag: {e}")))?;
3153
3154 let title: String = tag_row
3155 .ok_or_else(|| ThingsError::unknown(format!("Tag not found: {}", uuid)))?
3156 .get("title");
3157
3158 let task_rows = sqlx::query(
3161 "SELECT uuid, cachedTags FROM TMTask
3162 WHERE cachedTags IS NOT NULL
3163 AND trashed = 0",
3164 )
3165 .fetch_all(&self.pool)
3166 .await
3167 .map_err(|e| ThingsError::unknown(format!("Failed to query tasks with tag: {e}")))?;
3168
3169 let mut task_uuids = Vec::new();
3170 for row in task_rows {
3171 let uuid_str: String = row.get("uuid");
3172 let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
3173
3174 if let Some(blob) = cached_tags_blob {
3176 if let Ok(tags) = deserialize_tags_from_blob(&blob) {
3177 if tags.iter().any(|t| t.eq_ignore_ascii_case(&title)) {
3178 let task_uuid = Uuid::parse_str(&uuid_str)
3179 .unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
3180 task_uuids.push(task_uuid);
3181 }
3182 }
3183 }
3184 }
3185
3186 let usage_count = task_uuids.len() as u32;
3187
3188 let mut related_tags: std::collections::HashMap<String, u32> =
3190 std::collections::HashMap::new();
3191
3192 for task_uuid in &task_uuids {
3193 let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
3194 .bind(task_uuid.to_string())
3195 .fetch_optional(&self.pool)
3196 .await
3197 .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
3198
3199 if let Some(row) = row {
3200 let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
3201 if let Some(blob) = cached_tags_blob {
3202 let tags: Vec<String> = deserialize_tags_from_blob(&blob)?;
3203 for tag in tags {
3204 if tag != title {
3205 *related_tags.entry(tag).or_insert(0) += 1;
3206 }
3207 }
3208 }
3209 }
3210 }
3211
3212 let mut related_vec: Vec<(String, u32)> = related_tags.into_iter().collect();
3214 related_vec.sort_by_key(|r| std::cmp::Reverse(r.1));
3215
3216 Ok(crate::models::TagStatistics {
3217 uuid: *uuid,
3218 title,
3219 usage_count,
3220 task_uuids,
3221 related_tags: related_vec,
3222 })
3223 }
3224
3225 #[instrument(skip(self))]
3233 pub async fn find_duplicate_tags(
3234 &self,
3235 min_similarity: f32,
3236 ) -> ThingsResult<Vec<crate::models::TagPair>> {
3237 use crate::database::tag_utils::calculate_similarity;
3238
3239 let all_tags = self.get_all_tags().await?;
3240 let mut pairs = Vec::new();
3241
3242 for i in 0..all_tags.len() {
3244 for j in (i + 1)..all_tags.len() {
3245 let tag1 = &all_tags[i];
3246 let tag2 = &all_tags[j];
3247
3248 let similarity = calculate_similarity(&tag1.title, &tag2.title);
3249
3250 if similarity >= min_similarity {
3251 pairs.push(crate::models::TagPair {
3252 tag1: tag1.clone(),
3253 tag2: tag2.clone(),
3254 similarity,
3255 });
3256 }
3257 }
3258 }
3259
3260 pairs.sort_by(|a, b| {
3262 b.similarity
3263 .partial_cmp(&a.similarity)
3264 .unwrap_or(std::cmp::Ordering::Equal)
3265 });
3266
3267 Ok(pairs)
3268 }
3269
3270 const MAX_BULK_BATCH_SIZE: usize = 1000;
3277
3278 #[instrument(skip(self))]
3291 pub async fn bulk_move(
3292 &self,
3293 request: crate::models::BulkMoveRequest,
3294 ) -> ThingsResult<crate::models::BulkOperationResult> {
3295 if request.task_uuids.is_empty() {
3297 return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3298 }
3299 if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3300 return Err(ThingsError::validation(format!(
3301 "Batch size {} exceeds maximum of {}",
3302 request.task_uuids.len(),
3303 Self::MAX_BULK_BATCH_SIZE
3304 )));
3305 }
3306 if request.project_uuid.is_none() && request.area_uuid.is_none() {
3307 return Err(ThingsError::validation(
3308 "Must specify either project_uuid or area_uuid",
3309 ));
3310 }
3311
3312 if let Some(project_uuid) = &request.project_uuid {
3314 validators::validate_project_exists(&self.pool, project_uuid).await?;
3315 }
3316 if let Some(area_uuid) = &request.area_uuid {
3317 validators::validate_area_exists(&self.pool, area_uuid).await?;
3318 }
3319
3320 let mut tx = self
3322 .pool
3323 .begin()
3324 .await
3325 .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3326
3327 let placeholders = request
3329 .task_uuids
3330 .iter()
3331 .map(|_| "?")
3332 .collect::<Vec<_>>()
3333 .join(",");
3334 let query_str = format!(
3335 "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3336 placeholders
3337 );
3338
3339 let mut query = sqlx::query(&query_str);
3340 for uuid in &request.task_uuids {
3341 query = query.bind(uuid.to_string());
3342 }
3343
3344 let found_uuids: Vec<String> = query
3345 .fetch_all(&mut *tx)
3346 .await
3347 .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3348 .iter()
3349 .map(|row| row.get("uuid"))
3350 .collect();
3351
3352 if found_uuids.len() != request.task_uuids.len() {
3354 for uuid in &request.task_uuids {
3356 if !found_uuids.contains(&uuid.to_string()) {
3357 tx.rollback().await.ok();
3358 return Err(ThingsError::TaskNotFound {
3359 uuid: uuid.to_string(),
3360 });
3361 }
3362 }
3363 }
3364
3365 let now = Utc::now().timestamp() as f64;
3367 let placeholders = request
3368 .task_uuids
3369 .iter()
3370 .map(|_| "?")
3371 .collect::<Vec<_>>()
3372 .join(",");
3373 let query_str = format!(
3374 "UPDATE TMTask SET project = ?, area = ?, userModificationDate = ? WHERE uuid IN ({})",
3375 placeholders
3376 );
3377
3378 let mut query = sqlx::query(&query_str)
3379 .bind(request.project_uuid.map(|u| u.to_string()))
3380 .bind(request.area_uuid.map(|u| u.to_string()))
3381 .bind(now);
3382
3383 for uuid in &request.task_uuids {
3384 query = query.bind(uuid.to_string());
3385 }
3386
3387 query
3388 .execute(&mut *tx)
3389 .await
3390 .map_err(|e| ThingsError::unknown(format!("Failed to bulk move tasks: {e}")))?;
3391
3392 tx.commit()
3394 .await
3395 .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3396
3397 info!("Bulk moved {} task(s)", request.task_uuids.len());
3398 Ok(crate::models::BulkOperationResult {
3399 success: true,
3400 processed_count: request.task_uuids.len(),
3401 message: format!("Successfully moved {} task(s)", request.task_uuids.len()),
3402 })
3403 }
3404
3405 #[instrument(skip(self))]
3418 pub async fn bulk_update_dates(
3419 &self,
3420 request: crate::models::BulkUpdateDatesRequest,
3421 ) -> ThingsResult<crate::models::BulkOperationResult> {
3422 use crate::database::{safe_things_date_to_naive_date, validate_date_range};
3423
3424 if request.task_uuids.is_empty() {
3426 return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3427 }
3428 if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3429 return Err(ThingsError::validation(format!(
3430 "Batch size {} exceeds maximum of {}",
3431 request.task_uuids.len(),
3432 Self::MAX_BULK_BATCH_SIZE
3433 )));
3434 }
3435
3436 if let (Some(start), Some(deadline)) = (request.start_date, request.deadline) {
3438 validate_date_range(Some(start), Some(deadline))?;
3439 }
3440
3441 let mut tx = self
3443 .pool
3444 .begin()
3445 .await
3446 .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3447
3448 let placeholders = request
3450 .task_uuids
3451 .iter()
3452 .map(|_| "?")
3453 .collect::<Vec<_>>()
3454 .join(",");
3455 let query_str = format!(
3456 "SELECT uuid, startDate, deadline FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3457 placeholders
3458 );
3459
3460 let mut query = sqlx::query(&query_str);
3461 for uuid in &request.task_uuids {
3462 query = query.bind(uuid.to_string());
3463 }
3464
3465 let rows = query
3466 .fetch_all(&mut *tx)
3467 .await
3468 .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?;
3469
3470 if rows.len() != request.task_uuids.len() {
3472 let found_uuids: Vec<String> = rows.iter().map(|row| row.get("uuid")).collect();
3474 for uuid in &request.task_uuids {
3475 if !found_uuids.contains(&uuid.to_string()) {
3476 tx.rollback().await.ok();
3477 return Err(ThingsError::TaskNotFound {
3478 uuid: uuid.to_string(),
3479 });
3480 }
3481 }
3482 }
3483
3484 for row in &rows {
3486 let current_start: Option<i64> = row.get("startDate");
3487 let current_deadline: Option<i64> = row.get("deadline");
3488
3489 let final_start = if request.clear_start_date {
3490 None
3491 } else if let Some(new_start) = request.start_date {
3492 Some(new_start)
3493 } else {
3494 current_start.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
3495 };
3496
3497 let final_deadline = if request.clear_deadline {
3498 None
3499 } else if let Some(new_deadline) = request.deadline {
3500 Some(new_deadline)
3501 } else {
3502 current_deadline.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
3503 };
3504
3505 validate_date_range(final_start, final_deadline)?;
3506 }
3507
3508 let now = Utc::now().timestamp() as f64;
3510 let placeholders = request
3511 .task_uuids
3512 .iter()
3513 .map(|_| "?")
3514 .collect::<Vec<_>>()
3515 .join(",");
3516
3517 let start_date_value = if request.clear_start_date {
3518 None
3519 } else {
3520 request.start_date.map(naive_date_to_things_timestamp)
3521 };
3522
3523 let deadline_value = if request.clear_deadline {
3524 None
3525 } else {
3526 request.deadline.map(naive_date_to_things_timestamp)
3527 };
3528
3529 let query_str = format!(
3530 "UPDATE TMTask SET startDate = ?, deadline = ?, userModificationDate = ? WHERE uuid IN ({})",
3531 placeholders
3532 );
3533
3534 let mut query = sqlx::query(&query_str)
3535 .bind(start_date_value)
3536 .bind(deadline_value)
3537 .bind(now);
3538
3539 for uuid in &request.task_uuids {
3540 query = query.bind(uuid.to_string());
3541 }
3542
3543 query
3544 .execute(&mut *tx)
3545 .await
3546 .map_err(|e| ThingsError::unknown(format!("Failed to bulk update dates: {e}")))?;
3547
3548 tx.commit()
3549 .await
3550 .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3551
3552 info!(
3553 "Bulk updated dates for {} task(s)",
3554 request.task_uuids.len()
3555 );
3556 Ok(crate::models::BulkOperationResult {
3557 success: true,
3558 processed_count: request.task_uuids.len(),
3559 message: format!(
3560 "Successfully updated dates for {} task(s)",
3561 request.task_uuids.len()
3562 ),
3563 })
3564 }
3565
3566 #[instrument(skip(self))]
3577 pub async fn bulk_complete(
3578 &self,
3579 request: crate::models::BulkCompleteRequest,
3580 ) -> ThingsResult<crate::models::BulkOperationResult> {
3581 if request.task_uuids.is_empty() {
3583 return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3584 }
3585 if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3586 return Err(ThingsError::validation(format!(
3587 "Batch size {} exceeds maximum of {}",
3588 request.task_uuids.len(),
3589 Self::MAX_BULK_BATCH_SIZE
3590 )));
3591 }
3592
3593 let mut tx = self
3595 .pool
3596 .begin()
3597 .await
3598 .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3599
3600 let placeholders = request
3602 .task_uuids
3603 .iter()
3604 .map(|_| "?")
3605 .collect::<Vec<_>>()
3606 .join(",");
3607 let query_str = format!(
3608 "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3609 placeholders
3610 );
3611
3612 let mut query = sqlx::query(&query_str);
3613 for uuid in &request.task_uuids {
3614 query = query.bind(uuid.to_string());
3615 }
3616
3617 let found_uuids: Vec<String> = query
3618 .fetch_all(&mut *tx)
3619 .await
3620 .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3621 .iter()
3622 .map(|row| row.get("uuid"))
3623 .collect();
3624
3625 if found_uuids.len() != request.task_uuids.len() {
3627 for uuid in &request.task_uuids {
3629 if !found_uuids.contains(&uuid.to_string()) {
3630 tx.rollback().await.ok();
3631 return Err(ThingsError::TaskNotFound {
3632 uuid: uuid.to_string(),
3633 });
3634 }
3635 }
3636 }
3637
3638 let now = Utc::now().timestamp() as f64;
3640 let placeholders = request
3641 .task_uuids
3642 .iter()
3643 .map(|_| "?")
3644 .collect::<Vec<_>>()
3645 .join(",");
3646 let query_str = format!(
3647 "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid IN ({})",
3648 placeholders
3649 );
3650
3651 let mut query = sqlx::query(&query_str).bind(now).bind(now);
3652
3653 for uuid in &request.task_uuids {
3654 query = query.bind(uuid.to_string());
3655 }
3656
3657 query
3658 .execute(&mut *tx)
3659 .await
3660 .map_err(|e| ThingsError::unknown(format!("Failed to bulk complete tasks: {e}")))?;
3661
3662 tx.commit()
3664 .await
3665 .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3666
3667 info!("Bulk completed {} task(s)", request.task_uuids.len());
3668 Ok(crate::models::BulkOperationResult {
3669 success: true,
3670 processed_count: request.task_uuids.len(),
3671 message: format!(
3672 "Successfully completed {} task(s)",
3673 request.task_uuids.len()
3674 ),
3675 })
3676 }
3677
3678 #[instrument(skip(self))]
3689 pub async fn bulk_delete(
3690 &self,
3691 request: crate::models::BulkDeleteRequest,
3692 ) -> ThingsResult<crate::models::BulkOperationResult> {
3693 if request.task_uuids.is_empty() {
3695 return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3696 }
3697 if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3698 return Err(ThingsError::validation(format!(
3699 "Batch size {} exceeds maximum of {}",
3700 request.task_uuids.len(),
3701 Self::MAX_BULK_BATCH_SIZE
3702 )));
3703 }
3704
3705 let mut tx = self
3707 .pool
3708 .begin()
3709 .await
3710 .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3711
3712 let placeholders = request
3714 .task_uuids
3715 .iter()
3716 .map(|_| "?")
3717 .collect::<Vec<_>>()
3718 .join(",");
3719 let query_str = format!(
3720 "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3721 placeholders
3722 );
3723
3724 let mut query = sqlx::query(&query_str);
3725 for uuid in &request.task_uuids {
3726 query = query.bind(uuid.to_string());
3727 }
3728
3729 let found_uuids: Vec<String> = query
3730 .fetch_all(&mut *tx)
3731 .await
3732 .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3733 .iter()
3734 .map(|row| row.get("uuid"))
3735 .collect();
3736
3737 if found_uuids.len() != request.task_uuids.len() {
3739 for uuid in &request.task_uuids {
3741 if !found_uuids.contains(&uuid.to_string()) {
3742 tx.rollback().await.ok();
3743 return Err(ThingsError::TaskNotFound {
3744 uuid: uuid.to_string(),
3745 });
3746 }
3747 }
3748 }
3749
3750 let now = Utc::now().timestamp() as f64;
3752 let placeholders = request
3753 .task_uuids
3754 .iter()
3755 .map(|_| "?")
3756 .collect::<Vec<_>>()
3757 .join(",");
3758 let query_str = format!(
3759 "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid IN ({})",
3760 placeholders
3761 );
3762
3763 let mut query = sqlx::query(&query_str).bind(now);
3764
3765 for uuid in &request.task_uuids {
3766 query = query.bind(uuid.to_string());
3767 }
3768
3769 query
3770 .execute(&mut *tx)
3771 .await
3772 .map_err(|e| ThingsError::unknown(format!("Failed to bulk delete tasks: {e}")))?;
3773
3774 tx.commit()
3776 .await
3777 .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3778
3779 info!("Bulk deleted {} task(s)", request.task_uuids.len());
3780 Ok(crate::models::BulkOperationResult {
3781 success: true,
3782 processed_count: request.task_uuids.len(),
3783 message: format!("Successfully deleted {} task(s)", request.task_uuids.len()),
3784 })
3785 }
3786}
3787
3788#[derive(Debug, Clone, Serialize, Deserialize)]
3790pub struct DatabaseStats {
3791 pub task_count: u64,
3792 pub project_count: u64,
3793 pub area_count: u64,
3794}
3795
3796impl DatabaseStats {
3797 #[must_use]
3798 pub fn total_items(&self) -> u64 {
3799 self.task_count + self.project_count + self.area_count
3800 }
3801}
3802
3803#[must_use]
3815pub fn get_default_database_path() -> PathBuf {
3816 let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
3817 PathBuf::from(format!(
3818 "{home}/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-0Z0Z2/Things Database.thingsdatabase/main.sqlite"
3819 ))
3820}
3821
3822#[cfg(test)]
3823mod tests {
3824 use super::*;
3825 use tempfile::{NamedTempFile, TempDir};
3826
3827 #[tokio::test]
3828 async fn test_database_connection() {
3829 let temp_dir = TempDir::new().unwrap();
3830 let db_path = temp_dir.path().join("test.db");
3831
3832 let result = super::ThingsDatabase::new(&db_path).await;
3835 assert!(result.is_err());
3836 }
3837
3838 #[tokio::test]
3839 async fn test_connection_string() {
3840 let result = super::ThingsDatabase::from_connection_string("sqlite::memory:").await;
3841 assert!(result.is_ok());
3842 }
3843
3844 #[test]
3845 fn test_task_status_from_i32() {
3846 assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
3847 assert_eq!(TaskStatus::from_i32(1), Some(TaskStatus::Completed));
3848 assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
3849 assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Trashed));
3850 assert_eq!(TaskStatus::from_i32(4), None);
3851 assert_eq!(TaskStatus::from_i32(-1), None);
3852 }
3853
3854 #[test]
3855 fn test_task_type_from_i32() {
3856 assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
3857 assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
3858 assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
3859 assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
3860 assert_eq!(TaskType::from_i32(4), None);
3861 assert_eq!(TaskType::from_i32(-1), None);
3862 }
3863
3864 #[test]
3865 fn test_database_stats_total_items() {
3866 let stats = DatabaseStats {
3867 task_count: 10,
3868 project_count: 5,
3869 area_count: 3,
3870 };
3871 assert_eq!(stats.total_items(), 18);
3872
3873 let empty_stats = DatabaseStats {
3874 task_count: 0,
3875 project_count: 0,
3876 area_count: 0,
3877 };
3878 assert_eq!(empty_stats.total_items(), 0);
3879 }
3880
3881 #[test]
3882 fn test_database_pool_config_default() {
3883 let config = DatabasePoolConfig::default();
3884 assert_eq!(config.max_connections, 10);
3885 assert_eq!(config.min_connections, 1);
3886 assert_eq!(config.connect_timeout, Duration::from_secs(30));
3887 assert_eq!(config.idle_timeout, Duration::from_secs(600));
3888 assert_eq!(config.max_lifetime, Duration::from_secs(1800));
3889 assert!(config.test_before_acquire);
3890 }
3891
3892 #[test]
3893 fn test_sqlite_optimizations_default() {
3894 let opts = SqliteOptimizations::default();
3895 assert!(opts.enable_wal_mode);
3896 assert_eq!(opts.cache_size, -20000);
3897 assert_eq!(opts.synchronous_mode, "NORMAL".to_string());
3898 assert_eq!(opts.temp_store, "MEMORY".to_string());
3899 assert_eq!(opts.journal_mode, "WAL".to_string());
3900 assert_eq!(opts.mmap_size, 268_435_456);
3901 assert!(opts.enable_foreign_keys);
3902 assert!(opts.enable_query_planner);
3903 }
3904
3905 #[test]
3906 fn test_pool_health_status_creation() {
3907 let status = PoolHealthStatus {
3908 is_healthy: true,
3909 pool_size: 8,
3910 active_connections: 5,
3911 idle_connections: 3,
3912 max_connections: 10,
3913 min_connections: 1,
3914 connection_timeout: Duration::from_secs(30),
3915 idle_timeout: Some(Duration::from_secs(600)),
3916 max_lifetime: Some(Duration::from_secs(1800)),
3917 };
3918 assert!(status.is_healthy);
3919 assert_eq!(status.active_connections, 5);
3920 assert_eq!(status.idle_connections, 3);
3921 assert_eq!(status.pool_size, 8);
3922 }
3923
3924 #[test]
3925 fn test_pool_metrics_creation() {
3926 let metrics = PoolMetrics {
3927 pool_size: 8,
3928 active_connections: 5,
3929 idle_connections: 3,
3930 max_connections: 10,
3931 min_connections: 1,
3932 utilization_percentage: 80.0,
3933 is_healthy: true,
3934 response_time_ms: 50,
3935 connection_timeout: Duration::from_secs(30),
3936 idle_timeout: Some(Duration::from_secs(600)),
3937 max_lifetime: Some(Duration::from_secs(1800)),
3938 };
3939 assert!(metrics.is_healthy);
3940 assert_eq!(metrics.pool_size, 8);
3941 assert_eq!(metrics.active_connections, 5);
3942 assert_eq!(metrics.idle_connections, 3);
3943 assert!((metrics.utilization_percentage - 80.0).abs() < f64::EPSILON);
3944 assert_eq!(metrics.response_time_ms, 50);
3945 }
3946
3947 #[test]
3948 fn test_comprehensive_health_status_creation() {
3949 let pool_health = PoolHealthStatus {
3950 is_healthy: true,
3951 pool_size: 8,
3952 active_connections: 5,
3953 idle_connections: 3,
3954 max_connections: 10,
3955 min_connections: 1,
3956 connection_timeout: Duration::from_secs(30),
3957 idle_timeout: Some(Duration::from_secs(600)),
3958 max_lifetime: Some(Duration::from_secs(1800)),
3959 };
3960
3961 let pool_metrics = PoolMetrics {
3962 pool_size: 8,
3963 active_connections: 5,
3964 idle_connections: 3,
3965 max_connections: 10,
3966 min_connections: 1,
3967 utilization_percentage: 80.0,
3968 is_healthy: true,
3969 response_time_ms: 50,
3970 connection_timeout: Duration::from_secs(30),
3971 idle_timeout: Some(Duration::from_secs(600)),
3972 max_lifetime: Some(Duration::from_secs(1800)),
3973 };
3974
3975 let db_stats = DatabaseStats {
3976 task_count: 50,
3977 project_count: 10,
3978 area_count: 5,
3979 };
3980
3981 let health_status = ComprehensiveHealthStatus {
3982 overall_healthy: true,
3983 pool_health,
3984 pool_metrics,
3985 database_stats: db_stats,
3986 timestamp: Utc::now(),
3987 };
3988
3989 assert!(health_status.overall_healthy);
3990 assert_eq!(health_status.database_stats.total_items(), 65);
3991 }
3992
3993 #[test]
3994 fn test_safe_timestamp_convert_edge_cases() {
3995 assert_eq!(safe_timestamp_convert(1_609_459_200.0), 1_609_459_200); assert_eq!(safe_timestamp_convert(0.0), 0);
4000
4001 assert_eq!(safe_timestamp_convert(-1.0), 0);
4003
4004 assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
4006
4007 assert_eq!(safe_timestamp_convert(f64::NAN), 0);
4009
4010 assert_eq!(safe_timestamp_convert(5_000_000_000.0), 0);
4012
4013 let max_timestamp = 4_102_444_800_f64; assert_eq!(safe_timestamp_convert(max_timestamp), 4_102_444_800);
4016 }
4017
4018 #[test]
4019 fn test_things_uuid_to_uuid_consistency() {
4020 let things_id = "test-id-123";
4022 let uuid1 = things_uuid_to_uuid(things_id);
4023 let uuid2 = things_uuid_to_uuid(things_id);
4024 assert_eq!(uuid1, uuid2, "UUIDs should be consistent for same input");
4025
4026 let uuid3 = things_uuid_to_uuid("different-id");
4028 assert_ne!(
4029 uuid1, uuid3,
4030 "Different inputs should produce different UUIDs"
4031 );
4032
4033 let uuid_empty = things_uuid_to_uuid("");
4035 assert!(!uuid_empty.to_string().is_empty());
4036
4037 let long_string = "a".repeat(1000);
4039 let uuid_long = things_uuid_to_uuid(&long_string);
4040 assert!(!uuid_long.to_string().is_empty());
4041 }
4042
4043 #[test]
4044 fn test_task_status_from_i32_all_variants() {
4045 assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
4046 assert_eq!(TaskStatus::from_i32(1), Some(TaskStatus::Completed));
4047 assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
4048 assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Trashed));
4049 assert_eq!(TaskStatus::from_i32(999), None);
4050 assert_eq!(TaskStatus::from_i32(-1), None);
4051 }
4052
4053 #[test]
4054 fn test_task_type_from_i32_all_variants() {
4055 assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
4056 assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
4057 assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
4058 assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
4059 assert_eq!(TaskType::from_i32(999), None);
4060 assert_eq!(TaskType::from_i32(-1), None);
4061 }
4062
4063 #[test]
4064 fn test_database_pool_config_default_values() {
4065 let config = DatabasePoolConfig::default();
4066 assert_eq!(config.max_connections, 10);
4067 assert_eq!(config.min_connections, 1);
4068 assert_eq!(config.connect_timeout, Duration::from_secs(30));
4069 assert_eq!(config.idle_timeout, Duration::from_secs(600));
4070 assert_eq!(config.max_lifetime, Duration::from_secs(1800));
4071 assert!(config.test_before_acquire);
4072 }
4073
4074 #[test]
4075 fn test_database_stats_total_items_calculation() {
4076 let stats = DatabaseStats {
4077 task_count: 10,
4078 project_count: 5,
4079 area_count: 3,
4080 };
4081 assert_eq!(stats.total_items(), 18); let empty_stats = DatabaseStats {
4085 task_count: 0,
4086 project_count: 0,
4087 area_count: 0,
4088 };
4089 assert_eq!(empty_stats.total_items(), 0);
4090 }
4091
4092 #[test]
4093 fn test_pool_health_status_creation_comprehensive() {
4094 let status = PoolHealthStatus {
4095 is_healthy: true,
4096 pool_size: 8,
4097 active_connections: 2,
4098 idle_connections: 3,
4099 max_connections: 10,
4100 min_connections: 1,
4101 connection_timeout: Duration::from_secs(30),
4102 idle_timeout: Some(Duration::from_secs(600)),
4103 max_lifetime: Some(Duration::from_secs(1800)),
4104 };
4105 assert!(status.is_healthy);
4106 assert_eq!(status.pool_size, 8);
4107 assert_eq!(status.max_connections, 10);
4108 }
4109
4110 #[test]
4111 fn test_pool_metrics_creation_comprehensive() {
4112 let metrics = PoolMetrics {
4113 pool_size: 8,
4114 active_connections: 5,
4115 idle_connections: 3,
4116 max_connections: 10,
4117 min_connections: 1,
4118 utilization_percentage: 80.0,
4119 is_healthy: true,
4120 response_time_ms: 50,
4121 connection_timeout: Duration::from_secs(30),
4122 idle_timeout: Some(Duration::from_secs(600)),
4123 max_lifetime: Some(Duration::from_secs(1800)),
4124 };
4125 assert_eq!(metrics.pool_size, 8);
4126 assert_eq!(metrics.response_time_ms, 50);
4127 assert!(metrics.is_healthy);
4128 }
4129
4130 #[test]
4131 fn test_comprehensive_health_status_creation_full() {
4132 let pool_health = PoolHealthStatus {
4133 is_healthy: true,
4134 pool_size: 8,
4135 active_connections: 2,
4136 idle_connections: 3,
4137 max_connections: 10,
4138 min_connections: 1,
4139 connection_timeout: Duration::from_secs(30),
4140 idle_timeout: Some(Duration::from_secs(600)),
4141 max_lifetime: Some(Duration::from_secs(1800)),
4142 };
4143
4144 let pool_metrics = PoolMetrics {
4145 pool_size: 8,
4146 active_connections: 5,
4147 idle_connections: 3,
4148 max_connections: 10,
4149 min_connections: 1,
4150 utilization_percentage: 80.0,
4151 is_healthy: true,
4152 response_time_ms: 50,
4153 connection_timeout: Duration::from_secs(30),
4154 idle_timeout: Some(Duration::from_secs(600)),
4155 max_lifetime: Some(Duration::from_secs(1800)),
4156 };
4157
4158 let database_stats = DatabaseStats {
4159 task_count: 100,
4160 project_count: 20,
4161 area_count: 5,
4162 };
4163
4164 let status = ComprehensiveHealthStatus {
4165 overall_healthy: true,
4166 pool_health,
4167 pool_metrics,
4168 database_stats,
4169 timestamp: Utc::now(),
4170 };
4171
4172 assert!(status.overall_healthy);
4173 assert_eq!(status.database_stats.total_items(), 125);
4174 }
4175
4176 #[test]
4177 fn test_sqlite_optimizations_default_values() {
4178 let opts = SqliteOptimizations::default();
4179 assert!(opts.enable_wal_mode);
4180 assert!(opts.enable_foreign_keys);
4181 assert_eq!(opts.cache_size, -20000);
4182 assert_eq!(opts.temp_store, "MEMORY");
4183 assert_eq!(opts.mmap_size, 268_435_456);
4184 assert_eq!(opts.synchronous_mode, "NORMAL");
4185 assert_eq!(opts.journal_mode, "WAL");
4186 }
4187
4188 #[test]
4189 fn test_get_default_database_path_format() {
4190 let path = get_default_database_path();
4191 let path_str = path.to_string_lossy();
4192 assert!(path_str.contains("Things Database.thingsdatabase"));
4193 assert!(path_str.contains("main.sqlite"));
4194 assert!(path_str.contains("Library/Group Containers"));
4195 }
4196
4197 #[tokio::test]
4198 async fn test_database_new_with_config() {
4199 let temp_file = NamedTempFile::new().unwrap();
4200 let db_path = temp_file.path();
4201
4202 crate::test_utils::create_test_database(db_path)
4203 .await
4204 .unwrap();
4205
4206 let config = DatabasePoolConfig {
4207 max_connections: 5,
4208 min_connections: 1,
4209 connect_timeout: Duration::from_secs(10),
4210 idle_timeout: Duration::from_secs(300),
4211 max_lifetime: Duration::from_secs(900),
4212 test_before_acquire: true,
4213 sqlite_optimizations: SqliteOptimizations::default(),
4214 };
4215
4216 let database = ThingsDatabase::new_with_config(db_path, config)
4217 .await
4218 .unwrap();
4219 let pool = database.pool();
4220 assert!(!pool.is_closed());
4221 }
4222
4223 #[tokio::test]
4224 async fn test_database_error_handling_invalid_path() {
4225 let result = ThingsDatabase::new(Path::new("/non/existent/path.db")).await;
4227 assert!(result.is_err(), "Should fail with non-existent path");
4228 }
4229
4230 #[tokio::test]
4231 async fn test_database_get_stats() {
4232 let temp_file = NamedTempFile::new().unwrap();
4233 let db_path = temp_file.path();
4234
4235 crate::test_utils::create_test_database(db_path)
4236 .await
4237 .unwrap();
4238 let database = ThingsDatabase::new(db_path).await.unwrap();
4239
4240 let stats = database.get_stats().await.unwrap();
4241 assert!(stats.task_count > 0, "Should have test tasks");
4242 assert!(stats.area_count > 0, "Should have test areas");
4243 assert!(stats.total_items() > 0, "Should have total items");
4244 }
4245
4246 #[tokio::test]
4247 async fn test_database_comprehensive_health_check() {
4248 let temp_file = NamedTempFile::new().unwrap();
4249 let db_path = temp_file.path();
4250
4251 crate::test_utils::create_test_database(db_path)
4252 .await
4253 .unwrap();
4254 let database = ThingsDatabase::new(db_path).await.unwrap();
4255
4256 let health = database.comprehensive_health_check().await.unwrap();
4257 assert!(health.overall_healthy, "Database should be healthy");
4258 assert!(health.pool_health.is_healthy, "Pool should be healthy");
4259 assert!(
4260 health.pool_metrics.is_healthy,
4261 "Pool metrics should be healthy"
4262 );
4263 }
4264
4265 #[test]
4270 fn test_things_date_negative_returns_none() {
4271 assert_eq!(things_date_to_naive_date(-1), None);
4273 assert_eq!(things_date_to_naive_date(-100), None);
4274 assert_eq!(things_date_to_naive_date(i64::MIN), None);
4275 }
4276
4277 #[test]
4278 fn test_things_date_zero_returns_none() {
4279 assert_eq!(things_date_to_naive_date(0), None);
4281 }
4282
4283 #[test]
4284 fn test_things_date_boundary_2001() {
4285 use chrono::Datelike;
4286 let result = things_date_to_naive_date(1);
4288 assert!(result.is_some());
4289
4290 let date = result.unwrap();
4291 assert_eq!(date.year(), 2001);
4292 assert_eq!(date.month(), 1);
4293 assert_eq!(date.day(), 1);
4294 }
4295
4296 #[test]
4297 fn test_things_date_one_day() {
4298 use chrono::Datelike;
4299 let seconds_per_day = 86400i64;
4301 let result = things_date_to_naive_date(seconds_per_day);
4302 assert!(result.is_some());
4303
4304 let date = result.unwrap();
4305 assert_eq!(date.year(), 2001);
4306 assert_eq!(date.month(), 1);
4307 assert_eq!(date.day(), 2);
4308 }
4309
4310 #[test]
4311 fn test_things_date_one_year() {
4312 use chrono::Datelike;
4313 let seconds_per_year = 365 * 86400i64;
4315 let result = things_date_to_naive_date(seconds_per_year);
4316 assert!(result.is_some());
4317
4318 let date = result.unwrap();
4319 assert_eq!(date.year(), 2002);
4320 }
4321
4322 #[test]
4323 fn test_things_date_current_era() {
4324 use chrono::Datelike;
4325 let days_to_2024 = 8401i64;
4329 let seconds_to_2024 = days_to_2024 * 86400;
4330
4331 let result = things_date_to_naive_date(seconds_to_2024);
4332 assert!(result.is_some());
4333
4334 let date = result.unwrap();
4335 assert_eq!(date.year(), 2024);
4336 }
4337
4338 #[test]
4339 fn test_things_date_leap_year() {
4340 use chrono::{Datelike, TimeZone, Utc};
4341 let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
4344 let target_date = Utc.with_ymd_and_hms(2004, 2, 29, 0, 0, 0).single().unwrap();
4345 let seconds_diff = (target_date - base_date).num_seconds();
4346
4347 let result = things_date_to_naive_date(seconds_diff);
4348 assert!(result.is_some());
4349
4350 let date = result.unwrap();
4351 assert_eq!(date.year(), 2004);
4352 assert_eq!(date.month(), 2);
4353 assert_eq!(date.day(), 29);
4354 }
4355
4356 #[test]
4361 fn test_uuid_conversion_consistency() {
4362 let input = "ABC123";
4364 let uuid1 = things_uuid_to_uuid(input);
4365 let uuid2 = things_uuid_to_uuid(input);
4366
4367 assert_eq!(uuid1, uuid2);
4368 }
4369
4370 #[test]
4371 fn test_uuid_conversion_uniqueness() {
4372 let uuid1 = things_uuid_to_uuid("ABC123");
4374 let uuid2 = things_uuid_to_uuid("ABC124");
4375 let uuid3 = things_uuid_to_uuid("XYZ789");
4376
4377 assert_ne!(uuid1, uuid2);
4378 assert_ne!(uuid1, uuid3);
4379 assert_ne!(uuid2, uuid3);
4380 }
4381
4382 #[test]
4383 fn test_uuid_conversion_empty_string() {
4384 let uuid = things_uuid_to_uuid("");
4386 assert!(!uuid.to_string().is_empty());
4387 }
4388
4389 #[test]
4390 fn test_uuid_conversion_special_characters() {
4391 let uuid1 = things_uuid_to_uuid("test-with-dashes");
4393 let uuid2 = things_uuid_to_uuid("test_with_underscores");
4394 let uuid3 = things_uuid_to_uuid("test.with.dots");
4395
4396 assert_ne!(uuid1, uuid2);
4398 assert_ne!(uuid1, uuid3);
4399 assert_ne!(uuid2, uuid3);
4400 }
4401
4402 #[test]
4407 fn test_safe_timestamp_convert_normal_values() {
4408 let ts = 1_700_000_000.0; let result = safe_timestamp_convert(ts);
4411 assert_eq!(result, 1_700_000_000);
4412 }
4413
4414 #[test]
4415 fn test_safe_timestamp_convert_zero() {
4416 assert_eq!(safe_timestamp_convert(0.0), 0);
4418 }
4419
4420 #[test]
4421 fn test_safe_timestamp_convert_negative() {
4422 assert_eq!(safe_timestamp_convert(-1.0), 0);
4424 assert_eq!(safe_timestamp_convert(-1000.0), 0);
4425 }
4426
4427 #[test]
4428 fn test_safe_timestamp_convert_infinity() {
4429 assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
4431 assert_eq!(safe_timestamp_convert(f64::NEG_INFINITY), 0);
4432 }
4433
4434 #[test]
4435 fn test_safe_timestamp_convert_nan() {
4436 assert_eq!(safe_timestamp_convert(f64::NAN), 0);
4438 }
4439
4440 #[test]
4441 fn test_date_roundtrip_known_dates() {
4442 use chrono::{Datelike, TimeZone, Utc};
4443 let test_cases = vec![
4447 (2001, 1, 2), (2010, 6, 15),
4449 (2020, 12, 31),
4450 (2024, 2, 29), (2025, 7, 4),
4452 ];
4453
4454 for (year, month, day) in test_cases {
4455 let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
4456 let target_date = Utc
4457 .with_ymd_and_hms(year, month, day, 0, 0, 0)
4458 .single()
4459 .unwrap();
4460 let seconds = (target_date - base_date).num_seconds();
4461
4462 let converted = things_date_to_naive_date(seconds);
4463 assert!(
4464 converted.is_some(),
4465 "Failed to convert {}-{:02}-{:02}",
4466 year,
4467 month,
4468 day
4469 );
4470
4471 let result_date = converted.unwrap();
4472 assert_eq!(
4473 result_date.year(),
4474 year,
4475 "Year mismatch for {}-{:02}-{:02}",
4476 year,
4477 month,
4478 day
4479 );
4480 assert_eq!(
4481 result_date.month(),
4482 month,
4483 "Month mismatch for {}-{:02}-{:02}",
4484 year,
4485 month,
4486 day
4487 );
4488 assert_eq!(
4489 result_date.day(),
4490 day,
4491 "Day mismatch for {}-{:02}-{:02}",
4492 year,
4493 month,
4494 day
4495 );
4496 }
4497 }
4498
4499 #[cfg(feature = "advanced-queries")]
4500 mod query_tasks_tests {
4501 use super::*;
4502 use crate::models::TaskFilters;
4503 use crate::query::TaskQueryBuilder;
4504 use tempfile::NamedTempFile;
4505
4506 async fn open_test_db() -> (ThingsDatabase, NamedTempFile) {
4507 let f = NamedTempFile::new().unwrap();
4508 crate::test_utils::create_test_database(f.path())
4509 .await
4510 .unwrap();
4511 let db = ThingsDatabase::new(f.path()).await.unwrap();
4512 (db, f)
4513 }
4514
4515 #[tokio::test]
4516 async fn test_query_tasks_no_filters() {
4517 let (db, _f) = open_test_db().await;
4518 let result = db.query_tasks(&TaskFilters::default()).await;
4519 assert!(result.is_ok());
4520 }
4521
4522 #[tokio::test]
4523 async fn test_query_tasks_status_filter() {
4524 let (db, _f) = open_test_db().await;
4525 let filters = TaskFilters {
4526 status: Some(TaskStatus::Completed),
4527 ..TaskFilters::default()
4528 };
4529 let tasks = db.query_tasks(&filters).await.unwrap();
4530 assert!(tasks.iter().all(|t| t.status == TaskStatus::Completed));
4531 }
4532
4533 #[tokio::test]
4534 async fn test_query_tasks_limit() {
4535 let (db, _f) = open_test_db().await;
4536 let filters = TaskFilters {
4537 limit: Some(1),
4538 ..TaskFilters::default()
4539 };
4540 let tasks = db.query_tasks(&filters).await.unwrap();
4541 assert!(tasks.len() <= 1);
4542 }
4543
4544 #[tokio::test]
4545 async fn test_query_tasks_tag_filter_and_semantics() {
4546 let (db, _f) = open_test_db().await;
4547 let filters = TaskFilters {
4548 tags: Some(vec!["nonexistent-tag-xyz".to_string()]),
4549 ..TaskFilters::default()
4550 };
4551 let tasks = db.query_tasks(&filters).await.unwrap();
4552 assert!(tasks.is_empty());
4553 }
4554
4555 #[tokio::test]
4556 async fn test_query_tasks_search_query() {
4557 let (db, _f) = open_test_db().await;
4558 let filters = TaskFilters {
4559 search_query: Some("zzznomatch".to_string()),
4560 ..TaskFilters::default()
4561 };
4562 let tasks = db.query_tasks(&filters).await.unwrap();
4563 assert!(tasks.is_empty());
4564 }
4565
4566 #[tokio::test]
4567 async fn test_query_tasks_trashed_status() {
4568 use sqlx::SqlitePool;
4569 use uuid::Uuid;
4570
4571 let f = NamedTempFile::new().unwrap();
4575 crate::test_utils::create_test_database(f.path())
4576 .await
4577 .unwrap();
4578 let pool = SqlitePool::connect(&format!("sqlite:{}", f.path().display()))
4579 .await
4580 .unwrap();
4581 let trashed_uuid = Uuid::new_v4().to_string();
4582 sqlx::query(
4583 "INSERT INTO TMTask \
4584 (uuid, title, type, status, trashed, creationDate, userModificationDate) \
4585 VALUES (?, ?, 0, 0, 1, 0, 0)",
4586 )
4587 .bind(&trashed_uuid)
4588 .bind("Trashed Task")
4589 .execute(&pool)
4590 .await
4591 .unwrap();
4592 pool.close().await;
4593
4594 let db = ThingsDatabase::new(f.path()).await.unwrap();
4595
4596 let active = db.query_tasks(&TaskFilters::default()).await.unwrap();
4598 assert!(active.iter().all(|t| t.uuid.to_string() != trashed_uuid));
4599
4600 let trashed = db
4602 .query_tasks(&TaskFilters {
4603 status: Some(TaskStatus::Trashed),
4604 ..TaskFilters::default()
4605 })
4606 .await
4607 .unwrap();
4608 assert!(
4609 trashed.iter().any(|t| t.uuid.to_string() == trashed_uuid),
4610 "expected trashed row to be returned by TaskStatus::Trashed filter"
4611 );
4612 }
4613
4614 #[tokio::test]
4615 async fn test_query_tasks_offset_without_limit() {
4616 let (db, _f) = open_test_db().await;
4618 let all = db.query_tasks(&TaskFilters::default()).await.unwrap();
4619 if all.len() < 2 {
4620 return; }
4622 let filters = TaskFilters {
4623 offset: Some(1),
4624 ..TaskFilters::default()
4625 };
4626 let offset_tasks = db.query_tasks(&filters).await.unwrap();
4627 assert_eq!(offset_tasks.len(), all.len() - 1);
4628 assert_eq!(offset_tasks[0].uuid, all[1].uuid);
4629 }
4630
4631 #[tokio::test]
4632 async fn test_query_tasks_pagination_with_post_filter() {
4633 let (db, _f) = open_test_db().await;
4635 let all_matching = db
4637 .query_tasks(&TaskFilters {
4638 search_query: Some(String::new()),
4639 ..TaskFilters::default()
4640 })
4641 .await
4642 .unwrap();
4643 if all_matching.len() < 2 {
4644 return;
4645 }
4646 let page0 = db
4647 .query_tasks(&TaskFilters {
4648 search_query: Some(String::new()),
4649 limit: Some(1),
4650 offset: Some(0),
4651 ..TaskFilters::default()
4652 })
4653 .await
4654 .unwrap();
4655 let page1 = db
4656 .query_tasks(&TaskFilters {
4657 search_query: Some(String::new()),
4658 limit: Some(1),
4659 offset: Some(1),
4660 ..TaskFilters::default()
4661 })
4662 .await
4663 .unwrap();
4664 assert_eq!(page0.len(), 1);
4665 assert_eq!(page1.len(), 1);
4666 assert_ne!(page0[0].uuid, page1[0].uuid);
4667 }
4668
4669 async fn insert_task(
4672 db: &ThingsDatabase,
4673 title: &str,
4674 notes: Option<&str>,
4675 tags: &[&str],
4676 ) -> Uuid {
4677 let uuid = Uuid::new_v4();
4678 let owned: Vec<String> = tags.iter().map(|s| (*s).to_string()).collect();
4679 let blob = serialize_tags_to_blob(&owned).unwrap();
4680 sqlx::query(
4681 "INSERT INTO TMTask \
4682 (uuid, title, notes, type, status, trashed, creationDate, userModificationDate, cachedTags) \
4683 VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?)",
4684 )
4685 .bind(uuid.to_string())
4686 .bind(title)
4687 .bind(notes)
4688 .bind(blob)
4689 .execute(&db.pool)
4690 .await
4691 .unwrap();
4692 uuid
4693 }
4694
4695 async fn insert_task_with_tags(db: &ThingsDatabase, title: &str, tags: &[&str]) -> Uuid {
4696 insert_task(db, title, None, tags).await
4697 }
4698
4699 async fn open_db_with_tagged_rows() -> (ThingsDatabase, NamedTempFile, Uuid, Uuid, Uuid) {
4700 let (db, f) = open_test_db().await;
4701 let a = insert_task_with_tags(&db, "task-a", &["a"]).await;
4702 let b = insert_task_with_tags(&db, "task-b", &["b"]).await;
4703 let c = insert_task_with_tags(&db, "task-c", &["c"]).await;
4704 (db, f, a, b, c)
4705 }
4706
4707 #[tokio::test]
4708 async fn test_query_tasks_any_tags_or_semantics() {
4709 let (db, _f, a, b, c) = open_db_with_tagged_rows().await;
4710 let tasks = TaskQueryBuilder::new()
4711 .any_tags(vec!["a".to_string(), "b".to_string()])
4712 .execute(&db)
4713 .await
4714 .unwrap();
4715 let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
4716 assert!(uuids.contains(&a));
4717 assert!(uuids.contains(&b));
4718 assert!(!uuids.contains(&c));
4719 }
4720
4721 #[tokio::test]
4722 async fn test_query_tasks_exclude_tags() {
4723 let (db, _f, a, b, c) = open_db_with_tagged_rows().await;
4724 let tasks = TaskQueryBuilder::new()
4725 .exclude_tags(vec!["b".to_string()])
4726 .execute(&db)
4727 .await
4728 .unwrap();
4729 let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
4730 assert!(uuids.contains(&a));
4731 assert!(!uuids.contains(&b));
4732 assert!(uuids.contains(&c));
4733 }
4734
4735 #[tokio::test]
4736 async fn test_query_tasks_tag_count_min() {
4737 let (db, _f) = open_test_db().await;
4738 insert_task_with_tags(&db, "zero-tags", &[]).await;
4739 insert_task_with_tags(&db, "one-tag", &["x"]).await;
4740 let two = insert_task_with_tags(&db, "two-tags", &["x", "y"]).await;
4741 let tasks = TaskQueryBuilder::new()
4742 .tag_count(2)
4743 .execute(&db)
4744 .await
4745 .unwrap();
4746 let uuids: Vec<Uuid> = tasks.iter().map(|t| t.uuid).collect();
4747 assert_eq!(uuids, vec![two]);
4748 }
4749
4750 #[tokio::test]
4751 async fn test_query_tasks_combined_tag_filters() {
4752 let (db, _f) = open_test_db().await;
4753 let target = insert_task_with_tags(&db, "target", &["a", "x"]).await;
4754 let _wrong_required = insert_task_with_tags(&db, "no-a", &["x"]).await;
4755 let _excluded = insert_task_with_tags(&db, "has-z", &["a", "x", "z"]).await;
4756 let _no_any = insert_task_with_tags(&db, "no-x", &["a"]).await;
4757
4758 let tasks = TaskQueryBuilder::new()
4759 .tags(vec!["a".to_string()])
4760 .any_tags(vec!["x".to_string(), "y".to_string()])
4761 .exclude_tags(vec!["z".to_string()])
4762 .execute(&db)
4763 .await
4764 .unwrap();
4765 let uuids: Vec<Uuid> = tasks.iter().map(|t| t.uuid).collect();
4766 assert_eq!(uuids, vec![target]);
4767 }
4768
4769 #[tokio::test]
4770 async fn test_query_tasks_pagination_with_any_tags() {
4771 let (db, _f) = open_test_db().await;
4774 insert_task_with_tags(&db, "a1", &["a"]).await;
4775 insert_task_with_tags(&db, "a2", &["a"]).await;
4776 insert_task_with_tags(&db, "a3", &["a"]).await;
4777 let page0 = TaskQueryBuilder::new()
4778 .any_tags(vec!["a".to_string()])
4779 .limit(1)
4780 .offset(0)
4781 .execute(&db)
4782 .await
4783 .unwrap();
4784 let page1 = TaskQueryBuilder::new()
4785 .any_tags(vec!["a".to_string()])
4786 .limit(1)
4787 .offset(1)
4788 .execute(&db)
4789 .await
4790 .unwrap();
4791 assert_eq!(page0.len(), 1);
4792 assert_eq!(page1.len(), 1);
4793 assert_ne!(page0[0].uuid, page1[0].uuid);
4794 }
4795
4796 #[tokio::test]
4797 async fn test_execute_fuzzy_typo_match() {
4798 let (db, _f) = open_test_db().await;
4799 let groceries = insert_task(&db, "Buy groceries", None, &[]).await;
4800 let tasks = TaskQueryBuilder::new()
4801 .fuzzy_search("grocries")
4802 .execute(&db)
4803 .await
4804 .unwrap();
4805 let uuids: Vec<Uuid> = tasks.iter().map(|t| t.uuid).collect();
4806 assert!(
4807 uuids.contains(&groceries),
4808 "typo 'grocries' should match 'Buy groceries'"
4809 );
4810 }
4811
4812 #[tokio::test]
4813 async fn test_execute_fuzzy_below_threshold_excluded() {
4814 let (db, _f) = open_test_db().await;
4815 insert_task(&db, "Buy groceries", None, &[]).await;
4816 let tasks = TaskQueryBuilder::new()
4817 .fuzzy_search("xyz")
4818 .fuzzy_threshold(0.95)
4819 .execute(&db)
4820 .await
4821 .unwrap();
4822 assert!(
4823 tasks.is_empty(),
4824 "completely unrelated query should return nothing at 0.95 threshold"
4825 );
4826 }
4827
4828 #[tokio::test]
4829 async fn test_execute_ranked_score_ordering() {
4830 let (db, _f) = open_test_db().await;
4831 insert_task(&db, "urgent task", None, &[]).await;
4832 insert_task(&db, "urgntt task", None, &[]).await; insert_task(&db, "completely unrelated xyz abc", None, &[]).await;
4834 let ranked = TaskQueryBuilder::new()
4835 .fuzzy_search("urgent")
4836 .fuzzy_threshold(0.5)
4837 .execute_ranked(&db)
4838 .await
4839 .unwrap();
4840 for pair in ranked.windows(2) {
4842 assert!(
4843 pair[0].score >= pair[1].score,
4844 "results must be sorted by score desc: {} < {}",
4845 pair[0].score,
4846 pair[1].score
4847 );
4848 }
4849 assert!(!ranked.is_empty(), "at least 'urgent task' should match");
4850 }
4851
4852 #[tokio::test]
4853 async fn test_execute_ranked_pagination() {
4854 let (db, _f) = open_test_db().await;
4855 for i in 0..5 {
4856 insert_task(&db, &format!("meeting agenda item {i}"), None, &[]).await;
4857 }
4858 let all = TaskQueryBuilder::new()
4859 .fuzzy_search("agenda")
4860 .execute_ranked(&db)
4861 .await
4862 .unwrap();
4863 let page = TaskQueryBuilder::new()
4864 .fuzzy_search("agenda")
4865 .limit(2)
4866 .offset(1)
4867 .execute_ranked(&db)
4868 .await
4869 .unwrap();
4870 assert_eq!(page.len(), 2);
4871 assert_eq!(page[0].task.uuid, all[1].task.uuid);
4872 assert_eq!(page[1].task.uuid, all[2].task.uuid);
4873 }
4874
4875 #[tokio::test]
4876 async fn test_execute_fuzzy_with_search_collision() {
4877 let (db, _f) = open_test_db().await;
4880 let target = insert_task(&db, "meeting agenda", None, &[]).await;
4881 let tasks = TaskQueryBuilder::new()
4882 .search("zzznomatch")
4883 .fuzzy_search("agenda")
4884 .execute(&db)
4885 .await
4886 .unwrap();
4887 assert_eq!(
4888 tasks.len(),
4889 1,
4890 "only the 'meeting agenda' row should match; substring filter must be suppressed"
4891 );
4892 assert_eq!(
4893 tasks[0].uuid, target,
4894 "fuzzy should win over substring search"
4895 );
4896 }
4897
4898 #[tokio::test]
4899 async fn test_execute_ranked_errors_without_fuzzy_query() {
4900 let (db, _f) = open_test_db().await;
4901 let result = TaskQueryBuilder::new().execute_ranked(&db).await;
4902 assert!(
4903 result.is_err(),
4904 "execute_ranked without fuzzy_search should error"
4905 );
4906 }
4907
4908 #[tokio::test]
4909 async fn test_execute_fuzzy_searches_notes() {
4910 let (db, _f) = open_test_db().await;
4911 let target = insert_task(&db, "Weekly sync", Some("meeting agenda for Q2"), &[]).await;
4912 let tasks = TaskQueryBuilder::new()
4913 .fuzzy_search("agenda")
4914 .execute(&db)
4915 .await
4916 .unwrap();
4917 let uuids: Vec<Uuid> = tasks.iter().map(|t| t.uuid).collect();
4918 assert!(uuids.contains(&target), "fuzzy should match text in notes");
4919 }
4920
4921 async fn insert_task_with_status(
4922 db: &ThingsDatabase,
4923 title: &str,
4924 status: TaskStatus,
4925 ) -> Uuid {
4926 let uuid = Uuid::new_v4();
4927 let blob = serialize_tags_to_blob(&Vec::<String>::new()).unwrap();
4928 let status_n: i64 = match status {
4929 TaskStatus::Incomplete => 0,
4930 TaskStatus::Completed => 1,
4931 TaskStatus::Canceled => 2,
4932 TaskStatus::Trashed => 0,
4933 };
4934 sqlx::query(
4935 "INSERT INTO TMTask \
4936 (uuid, title, notes, type, status, trashed, creationDate, userModificationDate, cachedTags) \
4937 VALUES (?, ?, NULL, 0, ?, 0, 0, 0, ?)",
4938 )
4939 .bind(uuid.to_string())
4940 .bind(title)
4941 .bind(status_n)
4942 .bind(blob)
4943 .execute(&db.pool)
4944 .await
4945 .unwrap();
4946 uuid
4947 }
4948
4949 async fn insert_task_with_type(
4950 db: &ThingsDatabase,
4951 title: &str,
4952 task_type: crate::models::TaskType,
4953 ) -> Uuid {
4954 let uuid = Uuid::new_v4();
4955 let blob = serialize_tags_to_blob(&Vec::<String>::new()).unwrap();
4956 let type_n: i64 = match task_type {
4957 crate::models::TaskType::Todo => 0,
4958 crate::models::TaskType::Project => 1,
4959 crate::models::TaskType::Heading => 2,
4960 crate::models::TaskType::Area => 3,
4961 };
4962 sqlx::query(
4963 "INSERT INTO TMTask \
4964 (uuid, title, notes, type, status, trashed, creationDate, userModificationDate, cachedTags) \
4965 VALUES (?, ?, NULL, ?, 0, 0, 0, 0, ?)",
4966 )
4967 .bind(uuid.to_string())
4968 .bind(title)
4969 .bind(type_n)
4970 .bind(blob)
4971 .execute(&db.pool)
4972 .await
4973 .unwrap();
4974 uuid
4975 }
4976
4977 #[tokio::test]
4978 async fn test_execute_with_where_expr_or_status() {
4979 use crate::filter_expr::FilterExpr;
4980 let (db, _f) = open_test_db().await;
4981 let inc = insert_task_with_status(&db, "inc", TaskStatus::Incomplete).await;
4982 let comp = insert_task_with_status(&db, "comp", TaskStatus::Completed).await;
4983 let canc = insert_task_with_status(&db, "canc", TaskStatus::Canceled).await;
4984
4985 let tasks = TaskQueryBuilder::new()
4986 .where_expr(
4987 FilterExpr::status(TaskStatus::Incomplete)
4988 .or(FilterExpr::status(TaskStatus::Completed)),
4989 )
4990 .execute(&db)
4991 .await
4992 .unwrap();
4993
4994 let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
4995 assert!(uuids.contains(&inc));
4996 assert!(uuids.contains(&comp));
4997 assert!(!uuids.contains(&canc));
4998 }
4999
5000 #[tokio::test]
5001 async fn test_execute_with_where_expr_not_type() {
5002 use crate::filter_expr::FilterExpr;
5003 use crate::models::TaskType;
5004 let (db, _f) = open_test_db().await;
5005 let todo = insert_task_with_type(&db, "todo", TaskType::Todo).await;
5006 let project = insert_task_with_type(&db, "project", TaskType::Project).await;
5007
5008 let tasks = TaskQueryBuilder::new()
5009 .where_expr(FilterExpr::task_type(TaskType::Project).not())
5010 .execute(&db)
5011 .await
5012 .unwrap();
5013
5014 let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
5015 assert!(uuids.contains(&todo));
5016 assert!(!uuids.contains(&project));
5017 }
5018
5019 #[tokio::test]
5020 async fn test_execute_pagination_defers_to_rust_when_where_expr_set() {
5021 use crate::filter_expr::FilterExpr;
5024 let (db, _f) = open_test_db().await;
5025 insert_task_with_status(&db, "inc-1", TaskStatus::Incomplete).await;
5026 insert_task_with_status(&db, "inc-2", TaskStatus::Incomplete).await;
5027 insert_task_with_status(&db, "inc-3", TaskStatus::Incomplete).await;
5028 insert_task_with_status(&db, "comp", TaskStatus::Completed).await;
5029
5030 let page0 = TaskQueryBuilder::new()
5031 .where_expr(FilterExpr::status(TaskStatus::Incomplete))
5032 .limit(1)
5033 .offset(0)
5034 .execute(&db)
5035 .await
5036 .unwrap();
5037 let page1 = TaskQueryBuilder::new()
5038 .where_expr(FilterExpr::status(TaskStatus::Incomplete))
5039 .limit(1)
5040 .offset(1)
5041 .execute(&db)
5042 .await
5043 .unwrap();
5044 assert_eq!(page0.len(), 1);
5045 assert_eq!(page1.len(), 1);
5046 assert_ne!(page0[0].uuid, page1[0].uuid);
5047 assert_eq!(page0[0].status, TaskStatus::Incomplete);
5048 assert_eq!(page1[0].status, TaskStatus::Incomplete);
5049 }
5050
5051 #[tokio::test]
5052 async fn test_execute_combines_where_expr_with_filters_status() {
5053 use crate::filter_expr::FilterExpr;
5055 let (db, _f) = open_test_db().await;
5056 let target = insert_task(&db, "needle", None, &["work"]).await;
5057 insert_task(&db, "decoy", None, &["work"]).await;
5058
5059 let tasks = TaskQueryBuilder::new()
5060 .status(TaskStatus::Incomplete)
5061 .where_expr(FilterExpr::title_contains("needle"))
5062 .execute(&db)
5063 .await
5064 .unwrap();
5065
5066 let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
5067 assert!(uuids.contains(&target));
5068 assert_eq!(tasks.len(), 1);
5069 }
5070
5071 #[tokio::test]
5072 async fn test_execute_combines_where_expr_with_any_tags() {
5073 use crate::filter_expr::FilterExpr;
5076 let (db, _f) = open_test_db().await;
5077 let target = insert_task(&db, "needle-task", None, &["work"]).await;
5078 insert_task(&db, "decoy-task", None, &["work"]).await;
5079 insert_task(&db, "needle-but-wrong-tag", None, &["personal"]).await;
5080
5081 let tasks = TaskQueryBuilder::new()
5082 .any_tags(vec!["work".to_string()])
5083 .where_expr(FilterExpr::title_contains("needle"))
5084 .execute(&db)
5085 .await
5086 .unwrap();
5087
5088 let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
5089 assert!(uuids.contains(&target));
5090 assert_eq!(tasks.len(), 1);
5091 }
5092 }
5093}