1use crate::{
2 database::{mappers::map_task_row, query_builders::TaskUpdateBuilder, validators},
3 error::{Result as ThingsResult, ThingsError},
4 models::{
5 Area, CreateTaskRequest, DeleteChildHandling, Project, Task, TaskStatus, TaskType,
6 UpdateTaskRequest,
7 },
8};
9use chrono::{DateTime, NaiveDate, Utc};
10use serde::{Deserialize, Serialize};
11use sqlx::{pool::PoolOptions, Row, SqlitePool};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14use tracing::{debug, error, info, instrument};
15use uuid::Uuid;
16
17pub(crate) fn safe_timestamp_convert(ts_f64: f64) -> i64 {
19 if ts_f64.is_finite() && ts_f64 >= 0.0 {
21 let max_timestamp = 4_102_444_800_f64; if ts_f64 <= max_timestamp {
24 let ts_str = format!("{:.0}", ts_f64.trunc());
26 ts_str.parse::<i64>().unwrap_or(0)
27 } else {
28 0 }
30 } else {
31 0 }
33}
34
35pub(crate) fn things_date_to_naive_date(seconds_since_2001: i64) -> Option<chrono::NaiveDate> {
37 use chrono::{TimeZone, Utc};
38
39 if seconds_since_2001 <= 0 {
40 return None;
41 }
42
43 let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
45
46 let date_time = base_date + chrono::Duration::seconds(seconds_since_2001);
48
49 Some(date_time.date_naive())
50}
51
52pub fn naive_date_to_things_timestamp(date: NaiveDate) -> i64 {
54 use chrono::{NaiveTime, TimeZone, Utc};
55
56 let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
58
59 let date_time = date
61 .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
62 .and_local_timezone(Utc)
63 .single()
64 .unwrap();
65
66 date_time.timestamp() - base_date.timestamp()
68}
69
70pub fn serialize_tags_to_blob(tags: &[String]) -> ThingsResult<Vec<u8>> {
74 serde_json::to_vec(tags)
75 .map_err(|e| ThingsError::unknown(format!("Failed to serialize tags: {e}")))
76}
77
78pub fn deserialize_tags_from_blob(blob: &[u8]) -> ThingsResult<Vec<String>> {
80 serde_json::from_slice(blob)
81 .map_err(|e| ThingsError::unknown(format!("Failed to deserialize tags: {e}")))
82}
83
84pub(crate) fn things_uuid_to_uuid(things_uuid: &str) -> Uuid {
87 use std::collections::hash_map::DefaultHasher;
90 use std::hash::{Hash, Hasher};
91
92 let mut hasher = DefaultHasher::new();
93 things_uuid.hash(&mut hasher);
94 let hash = hasher.finish();
95
96 let bytes = [
99 ((hash >> 56) & 0xFF) as u8,
100 ((hash >> 48) & 0xFF) as u8,
101 ((hash >> 40) & 0xFF) as u8,
102 ((hash >> 32) & 0xFF) as u8,
103 ((hash >> 24) & 0xFF) as u8,
104 ((hash >> 16) & 0xFF) as u8,
105 ((hash >> 8) & 0xFF) as u8,
106 (hash & 0xFF) as u8,
107 u8::try_from(things_uuid.len().min(255)).unwrap_or(255),
109 things_uuid.chars().next().unwrap_or('0') as u8,
110 things_uuid.chars().nth(1).unwrap_or('0') as u8,
111 things_uuid.chars().nth(2).unwrap_or('0') as u8,
112 things_uuid.chars().nth(3).unwrap_or('0') as u8,
113 things_uuid.chars().nth(4).unwrap_or('0') as u8,
114 things_uuid.chars().nth(5).unwrap_or('0') as u8,
115 things_uuid.chars().nth(6).unwrap_or('0') as u8,
116 ];
117
118 Uuid::from_bytes(bytes)
119}
120
121impl TaskStatus {
122 fn from_i32(value: i32) -> Option<Self> {
123 match value {
124 0 => Some(TaskStatus::Incomplete),
125 1 => Some(TaskStatus::Completed),
126 2 => Some(TaskStatus::Canceled),
127 3 => Some(TaskStatus::Trashed),
128 _ => None,
129 }
130 }
131}
132
133impl TaskType {
134 fn from_i32(value: i32) -> Option<Self> {
135 match value {
136 0 => Some(TaskType::Todo),
137 1 => Some(TaskType::Project),
138 2 => Some(TaskType::Heading),
139 3 => Some(TaskType::Area),
140 _ => None,
141 }
142 }
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct DatabasePoolConfig {
148 pub max_connections: u32,
150 pub min_connections: u32,
152 pub connect_timeout: Duration,
154 pub idle_timeout: Duration,
156 pub max_lifetime: Duration,
158 pub test_before_acquire: bool,
160 pub sqlite_optimizations: SqliteOptimizations,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct SqliteOptimizations {
167 pub enable_wal_mode: bool,
169 pub synchronous_mode: String,
171 pub cache_size: i32,
173 pub enable_foreign_keys: bool,
175 pub journal_mode: String,
177 pub temp_store: String,
179 pub mmap_size: i64,
181 pub enable_query_planner: bool,
183}
184
185impl Default for DatabasePoolConfig {
186 fn default() -> Self {
187 Self {
188 max_connections: 10,
189 min_connections: 1,
190 connect_timeout: Duration::from_secs(30),
191 idle_timeout: Duration::from_secs(600), max_lifetime: Duration::from_secs(1800), test_before_acquire: true,
194 sqlite_optimizations: SqliteOptimizations::default(),
195 }
196 }
197}
198
199impl Default for SqliteOptimizations {
200 fn default() -> Self {
201 Self {
202 enable_wal_mode: true,
203 synchronous_mode: "NORMAL".to_string(),
204 cache_size: -20000, enable_foreign_keys: true,
206 journal_mode: "WAL".to_string(),
207 temp_store: "MEMORY".to_string(),
208 mmap_size: 268_435_456, enable_query_planner: true,
210 }
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct PoolHealthStatus {
217 pub is_healthy: bool,
218 pub pool_size: u32,
219 pub active_connections: u32,
220 pub idle_connections: u32,
221 pub max_connections: u32,
222 pub min_connections: u32,
223 pub connection_timeout: Duration,
224 pub idle_timeout: Option<Duration>,
225 pub max_lifetime: Option<Duration>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct PoolMetrics {
231 pub pool_size: u32,
232 pub active_connections: u32,
233 pub idle_connections: u32,
234 pub max_connections: u32,
235 pub min_connections: u32,
236 pub utilization_percentage: f64,
237 pub is_healthy: bool,
238 pub response_time_ms: u64,
239 pub connection_timeout: Duration,
240 pub idle_timeout: Option<Duration>,
241 pub max_lifetime: Option<Duration>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct ComprehensiveHealthStatus {
247 pub overall_healthy: bool,
248 pub pool_health: PoolHealthStatus,
249 pub pool_metrics: PoolMetrics,
250 pub database_stats: DatabaseStats,
251 pub timestamp: DateTime<Utc>,
252}
253
254#[derive(Debug, Clone)]
257pub struct ThingsDatabase {
258 pool: SqlitePool,
259 config: DatabasePoolConfig,
260}
261
262impl ThingsDatabase {
263 #[instrument]
286 pub async fn new(database_path: &Path) -> ThingsResult<Self> {
287 Self::new_with_config(database_path, DatabasePoolConfig::default()).await
288 }
289
290 #[instrument]
324 pub async fn new_with_config(
325 database_path: &Path,
326 config: DatabasePoolConfig,
327 ) -> ThingsResult<Self> {
328 let database_url = format!("sqlite:{}", database_path.display());
329
330 info!(
331 "Connecting to SQLite database at: {} with optimized pool",
332 database_url
333 );
334
335 let pool = PoolOptions::new()
337 .max_connections(config.max_connections)
338 .min_connections(config.min_connections)
339 .acquire_timeout(config.connect_timeout)
340 .idle_timeout(Some(config.idle_timeout))
341 .max_lifetime(Some(config.max_lifetime))
342 .test_before_acquire(config.test_before_acquire)
343 .connect(&database_url)
344 .await
345 .map_err(|e| ThingsError::unknown(format!("Failed to connect to database: {e}")))?;
346
347 Self::apply_sqlite_optimizations(&pool, &config.sqlite_optimizations).await?;
349
350 info!(
351 "Database connection pool established successfully with {} max connections",
352 config.max_connections
353 );
354
355 Ok(Self { pool, config })
356 }
357
358 async fn apply_sqlite_optimizations(
360 pool: &SqlitePool,
361 optimizations: &SqliteOptimizations,
362 ) -> ThingsResult<()> {
363 sqlx::query(&format!(
365 "PRAGMA journal_mode = {}",
366 optimizations.journal_mode
367 ))
368 .execute(pool)
369 .await
370 .map_err(|e| ThingsError::unknown(format!("Failed to set journal mode: {e}")))?;
371
372 sqlx::query(&format!(
374 "PRAGMA synchronous = {}",
375 optimizations.synchronous_mode
376 ))
377 .execute(pool)
378 .await
379 .map_err(|e| ThingsError::unknown(format!("Failed to set synchronous mode: {e}")))?;
380
381 sqlx::query(&format!("PRAGMA cache_size = {}", optimizations.cache_size))
383 .execute(pool)
384 .await
385 .map_err(|e| ThingsError::unknown(format!("Failed to set cache size: {e}")))?;
386
387 let fk_setting = if optimizations.enable_foreign_keys {
389 "ON"
390 } else {
391 "OFF"
392 };
393 sqlx::query(&format!("PRAGMA foreign_keys = {fk_setting}"))
394 .execute(pool)
395 .await
396 .map_err(|e| ThingsError::unknown(format!("Failed to set foreign keys: {e}")))?;
397
398 sqlx::query(&format!("PRAGMA temp_store = {}", optimizations.temp_store))
400 .execute(pool)
401 .await
402 .map_err(|e| ThingsError::unknown(format!("Failed to set temp store: {e}")))?;
403
404 sqlx::query(&format!("PRAGMA mmap_size = {}", optimizations.mmap_size))
406 .execute(pool)
407 .await
408 .map_err(|e| ThingsError::unknown(format!("Failed to set mmap size: {e}")))?;
409
410 if optimizations.enable_query_planner {
412 sqlx::query("PRAGMA optimize")
413 .execute(pool)
414 .await
415 .map_err(|e| ThingsError::unknown(format!("Failed to optimize database: {e}")))?;
416 }
417
418 debug!(
419 "Applied SQLite optimizations: WAL={}, sync={}, cache={}KB, fk={}, temp={}, mmap={}MB",
420 optimizations.enable_wal_mode,
421 optimizations.synchronous_mode,
422 optimizations.cache_size.abs() / 1024,
423 optimizations.enable_foreign_keys,
424 optimizations.temp_store,
425 optimizations.mmap_size / 1024 / 1024
426 );
427
428 Ok(())
429 }
430
431 #[instrument]
437 pub async fn from_connection_string(database_url: &str) -> ThingsResult<Self> {
438 Self::from_connection_string_with_config(database_url, DatabasePoolConfig::default()).await
439 }
440
441 #[instrument]
447 pub async fn from_connection_string_with_config(
448 database_url: &str,
449 config: DatabasePoolConfig,
450 ) -> ThingsResult<Self> {
451 info!(
452 "Connecting to SQLite database: {} with optimized pool",
453 database_url
454 );
455
456 let pool = PoolOptions::new()
458 .max_connections(config.max_connections)
459 .min_connections(config.min_connections)
460 .acquire_timeout(config.connect_timeout)
461 .idle_timeout(Some(config.idle_timeout))
462 .max_lifetime(Some(config.max_lifetime))
463 .test_before_acquire(config.test_before_acquire)
464 .connect(database_url)
465 .await
466 .map_err(|e| ThingsError::unknown(format!("Failed to connect to database: {e}")))?;
467
468 Self::apply_sqlite_optimizations(&pool, &config.sqlite_optimizations).await?;
470
471 info!(
472 "Database connection pool established successfully with {} max connections",
473 config.max_connections
474 );
475
476 Ok(Self { pool, config })
477 }
478
479 #[must_use]
481 pub fn pool(&self) -> &SqlitePool {
482 &self.pool
483 }
484
485 #[instrument]
487 pub async fn is_connected(&self) -> bool {
488 match sqlx::query("SELECT 1").fetch_one(&self.pool).await {
489 Ok(_) => {
490 debug!("Database connection is healthy");
491 true
492 }
493 Err(e) => {
494 error!("Database connection check failed: {}", e);
495 false
496 }
497 }
498 }
499
500 #[instrument]
506 pub async fn get_pool_health(&self) -> ThingsResult<PoolHealthStatus> {
507 let pool_size = self.pool.size();
508 let idle_connections = self.pool.num_idle();
509 let active_connections = pool_size - u32::try_from(idle_connections).unwrap_or(0);
510
511 let is_healthy = self.is_connected().await;
513
514 Ok(PoolHealthStatus {
515 is_healthy,
516 pool_size,
517 active_connections,
518 idle_connections: u32::try_from(idle_connections).unwrap_or(0),
519 max_connections: self.config.max_connections,
520 min_connections: self.config.min_connections,
521 connection_timeout: self.config.connect_timeout,
522 idle_timeout: Some(self.config.idle_timeout),
523 max_lifetime: Some(self.config.max_lifetime),
524 })
525 }
526
527 #[instrument]
533 pub async fn get_pool_metrics(&self) -> ThingsResult<PoolMetrics> {
534 let pool_size = self.pool.size();
535 let idle_connections = self.pool.num_idle();
536 let active_connections = pool_size - u32::try_from(idle_connections).unwrap_or(0);
537
538 let max_connections = self.config.max_connections;
540 let utilization_percentage = if max_connections > 0 {
541 (f64::from(active_connections) / f64::from(max_connections)) * 100.0
542 } else {
543 0.0
544 };
545
546 let start_time = std::time::Instant::now();
548 let is_connected = self.is_connected().await;
549 let response_time_ms = u64::try_from(start_time.elapsed().as_millis()).unwrap_or(0);
550
551 Ok(PoolMetrics {
552 pool_size,
553 active_connections,
554 idle_connections: u32::try_from(idle_connections).unwrap_or(0),
555 max_connections,
556 min_connections: self.config.min_connections,
557 utilization_percentage,
558 is_healthy: is_connected,
559 response_time_ms,
560 connection_timeout: self.config.connect_timeout,
561 idle_timeout: Some(self.config.idle_timeout),
562 max_lifetime: Some(self.config.max_lifetime),
563 })
564 }
565
566 #[instrument]
572 pub async fn comprehensive_health_check(&self) -> ThingsResult<ComprehensiveHealthStatus> {
573 let pool_health = self.get_pool_health().await?;
574 let pool_metrics = self.get_pool_metrics().await?;
575 let db_stats = self.get_stats().await?;
576
577 let overall_healthy = pool_health.is_healthy && pool_metrics.is_healthy;
578
579 Ok(ComprehensiveHealthStatus {
580 overall_healthy,
581 pool_health,
582 pool_metrics,
583 database_stats: db_stats,
584 timestamp: Utc::now(),
585 })
586 }
587
588 #[instrument]
594 pub async fn get_stats(&self) -> ThingsResult<DatabaseStats> {
595 let task_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMTask")
596 .fetch_one(&self.pool)
597 .await
598 .map_err(|e| ThingsError::unknown(format!("Failed to get task count: {e}")))?;
599
600 let project_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMTask WHERE type = 1")
601 .fetch_one(&self.pool)
602 .await
603 .map_err(|e| ThingsError::unknown(format!("Failed to get project count: {e}")))?;
604
605 let area_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMArea")
606 .fetch_one(&self.pool)
607 .await
608 .map_err(|e| ThingsError::unknown(format!("Failed to get area count: {e}")))?;
609
610 Ok(DatabaseStats {
611 task_count: task_count.try_into().unwrap_or(0),
612 project_count: project_count.try_into().unwrap_or(0),
613 area_count: area_count.try_into().unwrap_or(0),
614 })
615 }
616
617 #[instrument]
645 pub async fn get_all_tasks(&self) -> ThingsResult<Vec<Task>> {
646 let rows = sqlx::query(
647 r"
648 SELECT
649 uuid, title, status, type,
650 start_date, due_date,
651 project_uuid, area_uuid,
652 notes, tags,
653 created, modified
654 FROM TMTask
655 ORDER BY created DESC
656 ",
657 )
658 .fetch_all(&self.pool)
659 .await
660 .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks: {e}")))?;
661
662 let mut tasks = Vec::new();
663 for row in rows {
664 let task = Task {
665 uuid: Uuid::parse_str(&row.get::<String, _>("uuid"))
666 .map_err(|e| ThingsError::unknown(format!("Invalid task UUID: {e}")))?,
667 title: row.get("title"),
668 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
669 task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
670 start_date: row
671 .get::<Option<String>, _>("start_date")
672 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
673 deadline: row
674 .get::<Option<String>, _>("due_date")
675 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
676 project_uuid: row
677 .get::<Option<String>, _>("project_uuid")
678 .and_then(|s| Uuid::parse_str(&s).ok()),
679 area_uuid: row
680 .get::<Option<String>, _>("area_uuid")
681 .and_then(|s| Uuid::parse_str(&s).ok()),
682 parent_uuid: None, notes: row.get("notes"),
684 tags: row
685 .get::<Option<String>, _>("tags")
686 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
687 .unwrap_or_default(),
688 children: Vec::new(), created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
690 .ok()
691 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
692 modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
693 .ok()
694 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
695 stop_date: None, };
697 tasks.push(task);
698 }
699
700 debug!("Fetched {} tasks", tasks.len());
701 Ok(tasks)
702 }
703
704 #[instrument]
710 pub async fn get_all_projects(&self) -> ThingsResult<Vec<Project>> {
711 let rows = sqlx::query(
712 r"
713 SELECT
714 uuid, title, status,
715 area, notes,
716 creationDate, userModificationDate,
717 startDate, deadline
718 FROM TMTask
719 WHERE type = 1 AND trashed = 0
720 ORDER BY creationDate DESC
721 ",
722 )
723 .fetch_all(&self.pool)
724 .await
725 .map_err(|e| ThingsError::unknown(format!("Failed to fetch projects: {e}")))?;
726
727 let mut projects = Vec::new();
728 for row in rows {
729 let project = Project {
730 uuid: things_uuid_to_uuid(&row.get::<String, _>("uuid")),
731 title: row.get("title"),
732 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
733 area_uuid: row
734 .get::<Option<String>, _>("area")
735 .map(|s| things_uuid_to_uuid(&s)),
736 notes: row.get("notes"),
737 deadline: row
738 .get::<Option<i64>, _>("deadline")
739 .and_then(|ts| DateTime::from_timestamp(ts, 0))
740 .map(|dt| dt.date_naive()),
741 start_date: row
742 .get::<Option<i64>, _>("startDate")
743 .and_then(|ts| DateTime::from_timestamp(ts, 0))
744 .map(|dt| dt.date_naive()),
745 tags: Vec::new(), tasks: Vec::new(), created: {
748 let ts_f64 = row.get::<f64, _>("creationDate");
749 let ts = safe_timestamp_convert(ts_f64);
750 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
751 },
752 modified: {
753 let ts_f64 = row.get::<f64, _>("userModificationDate");
754 let ts = safe_timestamp_convert(ts_f64);
755 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
756 },
757 };
758 projects.push(project);
759 }
760
761 debug!("Fetched {} projects", projects.len());
762 Ok(projects)
763 }
764
765 #[instrument]
771 pub async fn get_all_areas(&self) -> ThingsResult<Vec<Area>> {
772 let rows = sqlx::query(
774 r"
775 SELECT
776 uuid, title, visible, `index`
777 FROM TMArea
778 ORDER BY `index` ASC
779 ",
780 )
781 .fetch_all(&self.pool)
782 .await
783 .map_err(|e| ThingsError::unknown(format!("Failed to fetch areas: {e}")))?;
784
785 let mut areas = Vec::new();
786 for row in rows {
787 let uuid_str: String = row.get("uuid");
788 let uuid =
790 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
791
792 let area = Area {
793 uuid,
794 title: row.get("title"),
795 notes: None, projects: Vec::new(), tags: Vec::new(), created: Utc::now(), modified: Utc::now(), };
801 areas.push(area);
802 }
803
804 debug!("Fetched {} areas", areas.len());
805 Ok(areas)
806 }
807
808 #[instrument]
814 pub async fn get_tasks_by_status(&self, status: TaskStatus) -> ThingsResult<Vec<Task>> {
815 let status_value = status as i32;
816 let rows = sqlx::query(
817 r"
818 SELECT
819 uuid, title, status, type,
820 start_date, due_date,
821 project_uuid, area_uuid,
822 notes, tags,
823 created, modified
824 FROM TMTask
825 WHERE status = ?
826 ORDER BY created DESC
827 ",
828 )
829 .bind(status_value)
830 .fetch_all(&self.pool)
831 .await
832 .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks by status: {e}")))?;
833
834 let mut tasks = Vec::new();
835 for row in rows {
836 let task = Task {
837 uuid: Uuid::parse_str(&row.get::<String, _>("uuid"))
838 .map_err(|e| ThingsError::unknown(format!("Invalid task UUID: {e}")))?,
839 title: row.get("title"),
840 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
841 task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
842 start_date: row
843 .get::<Option<String>, _>("start_date")
844 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
845 deadline: row
846 .get::<Option<String>, _>("due_date")
847 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
848 project_uuid: row
849 .get::<Option<String>, _>("project_uuid")
850 .and_then(|s| Uuid::parse_str(&s).ok()),
851 area_uuid: row
852 .get::<Option<String>, _>("area_uuid")
853 .and_then(|s| Uuid::parse_str(&s).ok()),
854 parent_uuid: None, notes: row.get("notes"),
856 tags: row
857 .get::<Option<String>, _>("tags")
858 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
859 .unwrap_or_default(),
860 children: Vec::new(), created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
862 .ok()
863 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
864 modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
865 .ok()
866 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
867 stop_date: None, };
869 tasks.push(task);
870 }
871
872 debug!("Fetched {} tasks with status {:?}", tasks.len(), status);
873 Ok(tasks)
874 }
875
876 #[instrument]
882 pub async fn search_tasks(&self, query: &str) -> ThingsResult<Vec<Task>> {
883 let search_pattern = format!("%{query}%");
884 let rows = sqlx::query(
885 r"
886 SELECT
887 uuid, title, status, type,
888 startDate, deadline, stopDate,
889 project, area, heading,
890 notes, cachedTags,
891 creationDate, userModificationDate
892 FROM TMTask
893 WHERE (title LIKE ? OR notes LIKE ?) AND trashed = 0 AND type = 0
894 ORDER BY creationDate DESC
895 ",
896 )
897 .bind(&search_pattern)
898 .bind(&search_pattern)
899 .fetch_all(&self.pool)
900 .await
901 .map_err(|e| ThingsError::unknown(format!("Failed to search tasks: {e}")))?;
902
903 let tasks = rows
904 .iter()
905 .map(map_task_row)
906 .collect::<ThingsResult<Vec<Task>>>()?;
907
908 debug!("Found {} tasks matching query: {}", tasks.len(), query);
909 Ok(tasks)
910 }
911
912 #[allow(clippy::too_many_arguments)]
931 #[instrument(skip(self))]
932 pub async fn search_logbook(
933 &self,
934 search_text: Option<String>,
935 from_date: Option<NaiveDate>,
936 to_date: Option<NaiveDate>,
937 project_uuid: Option<Uuid>,
938 area_uuid: Option<Uuid>,
939 tags: Option<Vec<String>>,
940 limit: Option<u32>,
941 ) -> ThingsResult<Vec<Task>> {
942 let result_limit = limit.unwrap_or(50).min(500);
944
945 let rows = if let Some(ref text) = search_text {
947 let pattern = format!("%{text}%");
948 let mut q = String::from(
949 "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",
950 );
951 q.push_str(" AND (title LIKE ? OR notes LIKE ?)");
952
953 if let Some(date) = from_date {
954 let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
956 let timestamp = date_time.timestamp() as f64;
957 q.push_str(&format!(" AND stopDate >= {}", timestamp));
958 }
959
960 if let Some(date) = to_date {
961 let end_date = date + chrono::Duration::days(1);
963 let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
964 let timestamp = date_time.timestamp() as f64;
965 q.push_str(&format!(" AND stopDate < {}", timestamp));
966 }
967
968 if let Some(uuid) = project_uuid {
969 q.push_str(&format!(" AND project = '{}'", uuid));
970 }
971
972 if let Some(uuid) = area_uuid {
973 q.push_str(&format!(" AND area = '{}'", uuid));
974 }
975
976 q.push_str(&format!(" ORDER BY stopDate DESC LIMIT {result_limit}"));
977
978 sqlx::query(&q)
979 .bind(&pattern)
980 .bind(&pattern)
981 .fetch_all(&self.pool)
982 .await
983 .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
984 } else {
985 let mut q = String::from(
986 "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",
987 );
988
989 if let Some(date) = from_date {
990 let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
992 let timestamp = date_time.timestamp() as f64;
993 q.push_str(&format!(" AND stopDate >= {}", timestamp));
994 }
995
996 if let Some(date) = to_date {
997 let end_date = date + chrono::Duration::days(1);
999 let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1000 let timestamp = date_time.timestamp() as f64;
1001 q.push_str(&format!(" AND stopDate < {}", timestamp));
1002 }
1003
1004 if let Some(uuid) = project_uuid {
1005 q.push_str(&format!(" AND project = '{}'", uuid));
1006 }
1007
1008 if let Some(uuid) = area_uuid {
1009 q.push_str(&format!(" AND area = '{}'", uuid));
1010 }
1011
1012 q.push_str(&format!(" ORDER BY stopDate DESC LIMIT {result_limit}"));
1013
1014 sqlx::query(&q)
1015 .fetch_all(&self.pool)
1016 .await
1017 .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
1018 };
1019
1020 let mut tasks = rows
1022 .iter()
1023 .map(map_task_row)
1024 .collect::<ThingsResult<Vec<Task>>>()?;
1025
1026 if let Some(ref filter_tags) = tags {
1027 if !filter_tags.is_empty() {
1028 tasks.retain(|task| {
1029 filter_tags
1031 .iter()
1032 .all(|filter_tag| task.tags.contains(filter_tag))
1033 });
1034 }
1035 }
1036
1037 debug!("Found {} completed tasks in logbook", tasks.len());
1038 Ok(tasks)
1039 }
1040
1041 #[instrument(skip(self))]
1047 pub async fn get_inbox(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
1048 let query = if let Some(limit) = limit {
1049 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}")
1050 } else {
1051 "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"
1052 .to_string()
1053 };
1054
1055 let rows = sqlx::query(&query)
1056 .fetch_all(&self.pool)
1057 .await
1058 .map_err(|e| ThingsError::unknown(format!("Failed to fetch inbox tasks: {e}")))?;
1059
1060 let tasks = rows
1061 .iter()
1062 .map(map_task_row)
1063 .collect::<ThingsResult<Vec<Task>>>()?;
1064
1065 Ok(tasks)
1066 }
1067
1068 #[instrument(skip(self))]
1078 pub async fn get_today(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
1079 let query = if let Some(limit) = limit {
1082 format!(
1083 "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}"
1084 )
1085 } else {
1086 "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()
1087 };
1088
1089 let rows = sqlx::query(&query)
1090 .fetch_all(&self.pool)
1091 .await
1092 .map_err(|e| ThingsError::unknown(format!("Failed to fetch today's tasks: {e}")))?;
1093
1094 let tasks = rows
1095 .iter()
1096 .map(map_task_row)
1097 .collect::<ThingsResult<Vec<Task>>>()?;
1098
1099 Ok(tasks)
1100 }
1101
1102 #[instrument(skip(self))]
1108 pub async fn get_projects(&self, limit: Option<usize>) -> ThingsResult<Vec<Project>> {
1109 let _ = limit; self.get_all_projects().await
1111 }
1112
1113 #[instrument(skip(self))]
1119 pub async fn get_areas(&self) -> ThingsResult<Vec<Area>> {
1120 self.get_all_areas().await
1121 }
1122
1123 #[instrument(skip(self))]
1167 pub async fn create_task(&self, request: CreateTaskRequest) -> ThingsResult<Uuid> {
1168 crate::database::validate_date_range(request.start_date, request.deadline)?;
1170
1171 let uuid = Uuid::new_v4();
1173 let uuid_str = uuid.to_string();
1174
1175 if let Some(project_uuid) = &request.project_uuid {
1177 validators::validate_project_exists(&self.pool, project_uuid).await?;
1178 }
1179
1180 if let Some(area_uuid) = &request.area_uuid {
1181 validators::validate_area_exists(&self.pool, area_uuid).await?;
1182 }
1183
1184 if let Some(parent_uuid) = &request.parent_uuid {
1185 validators::validate_task_exists(&self.pool, parent_uuid).await?;
1186 }
1187
1188 let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
1190 let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
1191
1192 let now = Utc::now().timestamp() as f64;
1194
1195 let cached_tags = request
1197 .tags
1198 .as_ref()
1199 .map(|tags| serialize_tags_to_blob(tags))
1200 .transpose()?;
1201
1202 sqlx::query(
1204 r"
1205 INSERT INTO TMTask (
1206 uuid, title, type, status, notes,
1207 startDate, deadline, project, area, heading,
1208 cachedTags, creationDate, userModificationDate,
1209 trashed
1210 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1211 ",
1212 )
1213 .bind(&uuid_str)
1214 .bind(&request.title)
1215 .bind(request.task_type.unwrap_or(TaskType::Todo) as i32)
1216 .bind(request.status.unwrap_or(TaskStatus::Incomplete) as i32)
1217 .bind(request.notes.as_ref())
1218 .bind(start_date_ts)
1219 .bind(deadline_ts)
1220 .bind(request.project_uuid.map(|u| u.to_string()))
1221 .bind(request.area_uuid.map(|u| u.to_string()))
1222 .bind(request.parent_uuid.map(|u| u.to_string()))
1223 .bind(cached_tags)
1224 .bind(now)
1225 .bind(now)
1226 .bind(0) .execute(&self.pool)
1228 .await
1229 .map_err(|e| ThingsError::unknown(format!("Failed to create task: {e}")))?;
1230
1231 info!("Created task with UUID: {}", uuid);
1232 Ok(uuid)
1233 }
1234
1235 #[instrument(skip(self))]
1243 pub async fn create_project(
1244 &self,
1245 request: crate::models::CreateProjectRequest,
1246 ) -> ThingsResult<Uuid> {
1247 crate::database::validate_date_range(request.start_date, request.deadline)?;
1249
1250 let uuid = Uuid::new_v4();
1252 let uuid_str = uuid.to_string();
1253
1254 if let Some(area_uuid) = &request.area_uuid {
1256 validators::validate_area_exists(&self.pool, area_uuid).await?;
1257 }
1258
1259 let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
1261 let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
1262
1263 let now = Utc::now().timestamp() as f64;
1265
1266 let cached_tags = request
1268 .tags
1269 .as_ref()
1270 .map(|tags| serialize_tags_to_blob(tags))
1271 .transpose()?;
1272
1273 sqlx::query(
1275 r"
1276 INSERT INTO TMTask (
1277 uuid, title, type, status, notes,
1278 startDate, deadline, project, area, heading,
1279 cachedTags, creationDate, userModificationDate,
1280 trashed
1281 ) VALUES (?, ?, 1, 0, ?, ?, ?, NULL, ?, NULL, ?, ?, ?, 0)
1282 ",
1283 )
1284 .bind(&uuid_str)
1285 .bind(&request.title)
1286 .bind(request.notes.as_ref())
1287 .bind(start_date_ts)
1288 .bind(deadline_ts)
1289 .bind(request.area_uuid.map(|u| u.to_string()))
1290 .bind(cached_tags)
1291 .bind(now)
1292 .bind(now)
1293 .execute(&self.pool)
1294 .await
1295 .map_err(|e| ThingsError::unknown(format!("Failed to create project: {e}")))?;
1296
1297 info!("Created project with UUID: {}", uuid);
1298 Ok(uuid)
1299 }
1300
1301 #[instrument(skip(self))]
1310 pub async fn update_task(&self, request: UpdateTaskRequest) -> ThingsResult<()> {
1311 validators::validate_task_exists(&self.pool, &request.uuid).await?;
1313
1314 if request.start_date.is_some() || request.deadline.is_some() {
1316 if let Some(current_task) = self.get_task_by_uuid(&request.uuid).await? {
1318 let final_start = request.start_date.or(current_task.start_date);
1319 let final_deadline = request.deadline.or(current_task.deadline);
1320 crate::database::validate_date_range(final_start, final_deadline)?;
1321 }
1322 }
1323
1324 if let Some(project_uuid) = &request.project_uuid {
1326 validators::validate_project_exists(&self.pool, project_uuid).await?;
1327 }
1328
1329 if let Some(area_uuid) = &request.area_uuid {
1330 validators::validate_area_exists(&self.pool, area_uuid).await?;
1331 }
1332
1333 let builder = TaskUpdateBuilder::from_request(&request);
1335
1336 if builder.is_empty() {
1338 return Ok(());
1339 }
1340
1341 let query_string = builder.build_query_string();
1342 let mut q = sqlx::query(&query_string);
1343
1344 if let Some(title) = &request.title {
1346 q = q.bind(title);
1347 }
1348
1349 if let Some(notes) = &request.notes {
1350 q = q.bind(notes);
1351 }
1352
1353 if let Some(start_date) = request.start_date {
1354 q = q.bind(naive_date_to_things_timestamp(start_date));
1355 }
1356
1357 if let Some(deadline) = request.deadline {
1358 q = q.bind(naive_date_to_things_timestamp(deadline));
1359 }
1360
1361 if let Some(status) = request.status {
1362 q = q.bind(status as i32);
1363 }
1364
1365 if let Some(project_uuid) = request.project_uuid {
1366 q = q.bind(project_uuid.to_string());
1367 }
1368
1369 if let Some(area_uuid) = request.area_uuid {
1370 q = q.bind(area_uuid.to_string());
1371 }
1372
1373 if let Some(tags) = &request.tags {
1374 let cached_tags = serialize_tags_to_blob(tags)?;
1375 q = q.bind(cached_tags);
1376 }
1377
1378 let now = Utc::now().timestamp() as f64;
1380 q = q.bind(now).bind(request.uuid.to_string());
1381
1382 q.execute(&self.pool)
1383 .await
1384 .map_err(|e| ThingsError::unknown(format!("Failed to update task: {e}")))?;
1385
1386 info!("Updated task with UUID: {}", request.uuid);
1387 Ok(())
1388 }
1389
1390 #[instrument(skip(self))]
1398 pub async fn get_project_by_uuid(&self, uuid: &Uuid) -> ThingsResult<Option<Project>> {
1399 let row = sqlx::query(
1400 r"
1401 SELECT
1402 uuid, title, status,
1403 area, notes,
1404 creationDate, userModificationDate,
1405 startDate, deadline,
1406 trashed, type
1407 FROM TMTask
1408 WHERE uuid = ? AND type = 1
1409 ",
1410 )
1411 .bind(uuid.to_string())
1412 .fetch_optional(&self.pool)
1413 .await
1414 .map_err(|e| ThingsError::unknown(format!("Failed to fetch project: {e}")))?;
1415
1416 if let Some(row) = row {
1417 let trashed: i64 = row.get("trashed");
1419 if trashed == 1 {
1420 return Ok(None); }
1422
1423 let project = Project {
1424 uuid: things_uuid_to_uuid(&row.get::<String, _>("uuid")),
1425 title: row.get("title"),
1426 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
1427 area_uuid: row
1428 .get::<Option<String>, _>("area")
1429 .map(|s| things_uuid_to_uuid(&s)),
1430 notes: row.get("notes"),
1431 deadline: row
1432 .get::<Option<i64>, _>("deadline")
1433 .and_then(|ts| DateTime::from_timestamp(ts, 0))
1434 .map(|dt| dt.date_naive()),
1435 start_date: row
1436 .get::<Option<i64>, _>("startDate")
1437 .and_then(|ts| DateTime::from_timestamp(ts, 0))
1438 .map(|dt| dt.date_naive()),
1439 tags: Vec::new(), tasks: Vec::new(), created: {
1442 let ts_f64 = row.get::<f64, _>("creationDate");
1443 let ts = safe_timestamp_convert(ts_f64);
1444 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
1445 },
1446 modified: {
1447 let ts_f64 = row.get::<f64, _>("userModificationDate");
1448 let ts = safe_timestamp_convert(ts_f64);
1449 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
1450 },
1451 };
1452 Ok(Some(project))
1453 } else {
1454 Ok(None)
1455 }
1456 }
1457
1458 #[instrument(skip(self))]
1467 pub async fn update_project(
1468 &self,
1469 request: crate::models::UpdateProjectRequest,
1470 ) -> ThingsResult<()> {
1471 validators::validate_project_exists(&self.pool, &request.uuid).await?;
1473
1474 if request.start_date.is_some() || request.deadline.is_some() {
1476 if let Some(current_project) = self.get_project_by_uuid(&request.uuid).await? {
1478 let final_start = request.start_date.or(current_project.start_date);
1479 let final_deadline = request.deadline.or(current_project.deadline);
1480 crate::database::validate_date_range(final_start, final_deadline)?;
1481 }
1482 }
1483
1484 if let Some(area_uuid) = &request.area_uuid {
1486 validators::validate_area_exists(&self.pool, area_uuid).await?;
1487 }
1488
1489 let mut builder = TaskUpdateBuilder::new();
1491
1492 if request.title.is_some() {
1494 builder = builder.add_field("title");
1495 }
1496 if request.notes.is_some() {
1497 builder = builder.add_field("notes");
1498 }
1499 if request.start_date.is_some() {
1500 builder = builder.add_field("startDate");
1501 }
1502 if request.deadline.is_some() {
1503 builder = builder.add_field("deadline");
1504 }
1505 if request.area_uuid.is_some() {
1506 builder = builder.add_field("area");
1507 }
1508 if request.tags.is_some() {
1509 builder = builder.add_field("cachedTags");
1510 }
1511
1512 if builder.is_empty() {
1514 return Ok(());
1515 }
1516
1517 let query_str = builder.build_query_string();
1519 let mut q = sqlx::query(&query_str);
1520
1521 if let Some(ref title) = request.title {
1523 q = q.bind(title);
1524 }
1525 if let Some(ref notes) = request.notes {
1526 q = q.bind(notes);
1527 }
1528 if let Some(start_date) = request.start_date {
1529 q = q.bind(naive_date_to_things_timestamp(start_date));
1530 }
1531 if let Some(deadline) = request.deadline {
1532 q = q.bind(naive_date_to_things_timestamp(deadline));
1533 }
1534 if let Some(area_uuid) = request.area_uuid {
1535 q = q.bind(area_uuid.to_string());
1536 }
1537 if let Some(tags) = &request.tags {
1538 let cached_tags = serialize_tags_to_blob(tags)?;
1539 q = q.bind(cached_tags);
1540 }
1541
1542 let now = Utc::now().timestamp() as f64;
1544 q = q.bind(now).bind(request.uuid.to_string());
1545
1546 q.execute(&self.pool)
1547 .await
1548 .map_err(|e| ThingsError::unknown(format!("Failed to update project: {e}")))?;
1549
1550 info!("Updated project with UUID: {}", request.uuid);
1551 Ok(())
1552 }
1553
1554 #[instrument(skip(self))]
1560 pub async fn get_task_by_uuid(&self, uuid: &Uuid) -> ThingsResult<Option<Task>> {
1561 let row = sqlx::query(
1562 r"
1563 SELECT
1564 uuid, title, status, type,
1565 startDate, deadline, stopDate,
1566 project, area, heading,
1567 notes, cachedTags,
1568 creationDate, userModificationDate,
1569 trashed
1570 FROM TMTask
1571 WHERE uuid = ?
1572 ",
1573 )
1574 .bind(uuid.to_string())
1575 .fetch_optional(&self.pool)
1576 .await
1577 .map_err(|e| ThingsError::unknown(format!("Failed to fetch task: {e}")))?;
1578
1579 if let Some(row) = row {
1580 let trashed: i64 = row.get("trashed");
1582 if trashed == 1 {
1583 return Ok(None); }
1585
1586 let task = map_task_row(&row)?;
1588 Ok(Some(task))
1589 } else {
1590 Ok(None)
1591 }
1592 }
1593
1594 #[instrument(skip(self))]
1600 pub async fn complete_task(&self, uuid: &Uuid) -> ThingsResult<()> {
1601 validators::validate_task_exists(&self.pool, uuid).await?;
1603
1604 let now = Utc::now().timestamp() as f64;
1605
1606 sqlx::query(
1607 "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
1608 )
1609 .bind(now)
1610 .bind(now)
1611 .bind(uuid.to_string())
1612 .execute(&self.pool)
1613 .await
1614 .map_err(|e| ThingsError::unknown(format!("Failed to complete task: {e}")))?;
1615
1616 info!("Completed task with UUID: {}", uuid);
1617 Ok(())
1618 }
1619
1620 #[instrument(skip(self))]
1626 pub async fn uncomplete_task(&self, uuid: &Uuid) -> ThingsResult<()> {
1627 validators::validate_task_exists(&self.pool, uuid).await?;
1629
1630 let now = Utc::now().timestamp() as f64;
1631
1632 sqlx::query(
1633 "UPDATE TMTask SET status = 0, stopDate = NULL, userModificationDate = ? WHERE uuid = ?",
1634 )
1635 .bind(now)
1636 .bind(uuid.to_string())
1637 .execute(&self.pool)
1638 .await
1639 .map_err(|e| ThingsError::unknown(format!("Failed to uncomplete task: {e}")))?;
1640
1641 info!("Uncompleted task with UUID: {}", uuid);
1642 Ok(())
1643 }
1644
1645 #[instrument(skip(self))]
1651 pub async fn complete_project(
1652 &self,
1653 uuid: &Uuid,
1654 child_handling: crate::models::ProjectChildHandling,
1655 ) -> ThingsResult<()> {
1656 validators::validate_project_exists(&self.pool, uuid).await?;
1658
1659 let now = Utc::now().timestamp() as f64;
1660
1661 match child_handling {
1663 crate::models::ProjectChildHandling::Error => {
1664 let child_count: i64 = sqlx::query_scalar(
1666 "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
1667 )
1668 .bind(uuid.to_string())
1669 .fetch_one(&self.pool)
1670 .await
1671 .map_err(|e| {
1672 ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
1673 })?;
1674
1675 if child_count > 0 {
1676 return Err(ThingsError::unknown(format!(
1677 "Project {} has {} child task(s). Use cascade or orphan mode to complete.",
1678 uuid, child_count
1679 )));
1680 }
1681 }
1682 crate::models::ProjectChildHandling::Cascade => {
1683 sqlx::query(
1685 "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE project = ? AND trashed = 0",
1686 )
1687 .bind(now)
1688 .bind(now)
1689 .bind(uuid.to_string())
1690 .execute(&self.pool)
1691 .await
1692 .map_err(|e| ThingsError::unknown(format!("Failed to complete child tasks: {e}")))?;
1693 }
1694 crate::models::ProjectChildHandling::Orphan => {
1695 sqlx::query(
1697 "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
1698 )
1699 .bind(now)
1700 .bind(uuid.to_string())
1701 .execute(&self.pool)
1702 .await
1703 .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
1704 }
1705 }
1706
1707 sqlx::query(
1709 "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
1710 )
1711 .bind(now)
1712 .bind(now)
1713 .bind(uuid.to_string())
1714 .execute(&self.pool)
1715 .await
1716 .map_err(|e| ThingsError::unknown(format!("Failed to complete project: {e}")))?;
1717
1718 info!("Completed project with UUID: {}", uuid);
1719 Ok(())
1720 }
1721
1722 #[instrument(skip(self))]
1728 pub async fn delete_task(
1729 &self,
1730 uuid: &Uuid,
1731 child_handling: DeleteChildHandling,
1732 ) -> ThingsResult<()> {
1733 validators::validate_task_exists(&self.pool, uuid).await?;
1735
1736 let children = sqlx::query("SELECT uuid FROM TMTask WHERE heading = ? AND trashed = 0")
1738 .bind(uuid.to_string())
1739 .fetch_all(&self.pool)
1740 .await
1741 .map_err(|e| ThingsError::unknown(format!("Failed to query child tasks: {e}")))?;
1742
1743 let has_children = !children.is_empty();
1744
1745 if has_children {
1746 match child_handling {
1747 DeleteChildHandling::Error => {
1748 return Err(ThingsError::unknown(format!(
1749 "Task {} has {} child task(s). Use cascade or orphan mode to delete.",
1750 uuid,
1751 children.len()
1752 )));
1753 }
1754 DeleteChildHandling::Cascade => {
1755 let now = Utc::now().timestamp() as f64;
1757 for child_row in &children {
1758 let child_uuid: String = child_row.get("uuid");
1759 sqlx::query(
1760 "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?",
1761 )
1762 .bind(now)
1763 .bind(&child_uuid)
1764 .execute(&self.pool)
1765 .await
1766 .map_err(|e| {
1767 ThingsError::unknown(format!("Failed to delete child task: {e}"))
1768 })?;
1769 }
1770 info!("Cascade deleted {} child task(s)", children.len());
1771 }
1772 DeleteChildHandling::Orphan => {
1773 let now = Utc::now().timestamp() as f64;
1775 for child_row in &children {
1776 let child_uuid: String = child_row.get("uuid");
1777 sqlx::query(
1778 "UPDATE TMTask SET heading = NULL, userModificationDate = ? WHERE uuid = ?",
1779 )
1780 .bind(now)
1781 .bind(&child_uuid)
1782 .execute(&self.pool)
1783 .await
1784 .map_err(|e| {
1785 ThingsError::unknown(format!("Failed to orphan child task: {e}"))
1786 })?;
1787 }
1788 info!("Orphaned {} child task(s)", children.len());
1789 }
1790 }
1791 }
1792
1793 let now = Utc::now().timestamp() as f64;
1795 sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
1796 .bind(now)
1797 .bind(uuid.to_string())
1798 .execute(&self.pool)
1799 .await
1800 .map_err(|e| ThingsError::unknown(format!("Failed to delete task: {e}")))?;
1801
1802 info!("Deleted task with UUID: {}", uuid);
1803 Ok(())
1804 }
1805
1806 #[instrument(skip(self))]
1812 pub async fn delete_project(
1813 &self,
1814 uuid: &Uuid,
1815 child_handling: crate::models::ProjectChildHandling,
1816 ) -> ThingsResult<()> {
1817 validators::validate_project_exists(&self.pool, uuid).await?;
1819
1820 let now = Utc::now().timestamp() as f64;
1821
1822 match child_handling {
1824 crate::models::ProjectChildHandling::Error => {
1825 let child_count: i64 = sqlx::query_scalar(
1827 "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
1828 )
1829 .bind(uuid.to_string())
1830 .fetch_one(&self.pool)
1831 .await
1832 .map_err(|e| {
1833 ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
1834 })?;
1835
1836 if child_count > 0 {
1837 return Err(ThingsError::unknown(format!(
1838 "Project {} has {} child task(s). Use cascade or orphan mode to delete.",
1839 uuid, child_count
1840 )));
1841 }
1842 }
1843 crate::models::ProjectChildHandling::Cascade => {
1844 sqlx::query(
1846 "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE project = ? AND trashed = 0",
1847 )
1848 .bind(now)
1849 .bind(uuid.to_string())
1850 .execute(&self.pool)
1851 .await
1852 .map_err(|e| ThingsError::unknown(format!("Failed to delete child tasks: {e}")))?;
1853 }
1854 crate::models::ProjectChildHandling::Orphan => {
1855 sqlx::query(
1857 "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
1858 )
1859 .bind(now)
1860 .bind(uuid.to_string())
1861 .execute(&self.pool)
1862 .await
1863 .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
1864 }
1865 }
1866
1867 sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
1869 .bind(now)
1870 .bind(uuid.to_string())
1871 .execute(&self.pool)
1872 .await
1873 .map_err(|e| ThingsError::unknown(format!("Failed to delete project: {e}")))?;
1874
1875 info!("Deleted project with UUID: {}", uuid);
1876 Ok(())
1877 }
1878
1879 #[instrument(skip(self))]
1885 pub async fn create_area(
1886 &self,
1887 request: crate::models::CreateAreaRequest,
1888 ) -> ThingsResult<Uuid> {
1889 let uuid = Uuid::new_v4();
1891 let uuid_str = uuid.to_string();
1892
1893 let now = Utc::now().timestamp() as f64;
1895
1896 let max_index: Option<i64> = sqlx::query_scalar("SELECT MAX(`index`) FROM TMArea")
1898 .fetch_one(&self.pool)
1899 .await
1900 .map_err(|e| ThingsError::unknown(format!("Failed to get max area index: {e}")))?;
1901
1902 let next_index = max_index.unwrap_or(-1) + 1;
1903
1904 sqlx::query(
1906 r"
1907 INSERT INTO TMArea (
1908 uuid, title, visible, `index`,
1909 creationDate, userModificationDate
1910 ) VALUES (?, ?, 1, ?, ?, ?)
1911 ",
1912 )
1913 .bind(&uuid_str)
1914 .bind(&request.title)
1915 .bind(next_index)
1916 .bind(now)
1917 .bind(now)
1918 .execute(&self.pool)
1919 .await
1920 .map_err(|e| ThingsError::unknown(format!("Failed to create area: {e}")))?;
1921
1922 info!("Created area with UUID: {}", uuid);
1923 Ok(uuid)
1924 }
1925
1926 #[instrument(skip(self))]
1932 pub async fn update_area(&self, request: crate::models::UpdateAreaRequest) -> ThingsResult<()> {
1933 validators::validate_area_exists(&self.pool, &request.uuid).await?;
1935
1936 let now = Utc::now().timestamp() as f64;
1937
1938 sqlx::query("UPDATE TMArea SET title = ?, userModificationDate = ? WHERE uuid = ?")
1939 .bind(&request.title)
1940 .bind(now)
1941 .bind(request.uuid.to_string())
1942 .execute(&self.pool)
1943 .await
1944 .map_err(|e| ThingsError::unknown(format!("Failed to update area: {e}")))?;
1945
1946 info!("Updated area with UUID: {}", request.uuid);
1947 Ok(())
1948 }
1949
1950 #[instrument(skip(self))]
1959 pub async fn delete_area(&self, uuid: &Uuid) -> ThingsResult<()> {
1960 validators::validate_area_exists(&self.pool, uuid).await?;
1962
1963 let now = Utc::now().timestamp() as f64;
1964
1965 sqlx::query(
1967 "UPDATE TMTask SET area = NULL, userModificationDate = ? WHERE area = ? AND type = 1 AND trashed = 0",
1968 )
1969 .bind(now)
1970 .bind(uuid.to_string())
1971 .execute(&self.pool)
1972 .await
1973 .map_err(|e| ThingsError::unknown(format!("Failed to orphan projects in area: {e}")))?;
1974
1975 sqlx::query("DELETE FROM TMArea WHERE uuid = ?")
1977 .bind(uuid.to_string())
1978 .execute(&self.pool)
1979 .await
1980 .map_err(|e| ThingsError::unknown(format!("Failed to delete area: {e}")))?;
1981
1982 info!("Deleted area with UUID: {}", uuid);
1983 Ok(())
1984 }
1985
1986 #[instrument(skip(self))]
1996 pub async fn find_tag_by_normalized_title(
1997 &self,
1998 normalized: &str,
1999 ) -> ThingsResult<Option<crate::models::Tag>> {
2000 let row = sqlx::query(
2001 "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate
2002 FROM TMTag
2003 WHERE LOWER(title) = LOWER(?)",
2004 )
2005 .bind(normalized)
2006 .fetch_optional(&self.pool)
2007 .await
2008 .map_err(|e| ThingsError::unknown(format!("Failed to find tag by title: {e}")))?;
2009
2010 if let Some(row) = row {
2011 let uuid_str: String = row.get("uuid");
2012 let uuid =
2013 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2014 let title: String = row.get("title");
2015 let shortcut: Option<String> = row.get("shortcut");
2016 let parent_str: Option<String> = row.get("parent");
2017 let parent_uuid =
2018 parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2019
2020 let creation_ts: f64 = row.get("creationDate");
2021 let created = {
2022 let ts = safe_timestamp_convert(creation_ts);
2023 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2024 };
2025
2026 let modification_ts: f64 = row.get("userModificationDate");
2027 let modified = {
2028 let ts = safe_timestamp_convert(modification_ts);
2029 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2030 };
2031
2032 let used_ts: Option<f64> = row.get("usedDate");
2033 let last_used = used_ts.and_then(|ts| {
2034 let ts_i64 = safe_timestamp_convert(ts);
2035 DateTime::from_timestamp(ts_i64, 0)
2036 });
2037
2038 let usage_count: i64 = sqlx::query_scalar(
2040 "SELECT COUNT(*) FROM TMTask
2041 WHERE cachedTags IS NOT NULL
2042 AND json_extract(cachedTags, '$') LIKE ?
2043 AND trashed = 0",
2044 )
2045 .bind(format!("%\"{}\"%", title))
2046 .fetch_one(&self.pool)
2047 .await
2048 .unwrap_or(0);
2049
2050 Ok(Some(crate::models::Tag {
2051 uuid,
2052 title,
2053 shortcut,
2054 parent_uuid,
2055 created,
2056 modified,
2057 usage_count: usage_count as u32,
2058 last_used,
2059 }))
2060 } else {
2061 Ok(None)
2062 }
2063 }
2064
2065 #[instrument(skip(self))]
2073 pub async fn find_similar_tags(
2074 &self,
2075 title: &str,
2076 min_similarity: f32,
2077 ) -> ThingsResult<Vec<crate::models::TagMatch>> {
2078 use crate::database::tag_utils::{calculate_similarity, get_match_type};
2079
2080 let all_tags = self.get_all_tags().await?;
2082
2083 let mut matches: Vec<crate::models::TagMatch> = all_tags
2085 .into_iter()
2086 .filter_map(|tag| {
2087 let similarity = calculate_similarity(title, &tag.title);
2088 if similarity >= min_similarity {
2089 let match_type = get_match_type(title, &tag.title, min_similarity);
2090 Some(crate::models::TagMatch {
2091 tag,
2092 similarity_score: similarity,
2093 match_type,
2094 })
2095 } else {
2096 None
2097 }
2098 })
2099 .collect();
2100
2101 matches.sort_by(|a, b| {
2103 b.similarity_score
2104 .partial_cmp(&a.similarity_score)
2105 .unwrap_or(std::cmp::Ordering::Equal)
2106 });
2107
2108 Ok(matches)
2109 }
2110
2111 #[instrument(skip(self))]
2117 pub async fn search_tags(&self, query: &str) -> ThingsResult<Vec<crate::models::Tag>> {
2118 let rows = sqlx::query(
2119 "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate
2120 FROM TMTag
2121 WHERE title LIKE ?
2122 ORDER BY title",
2123 )
2124 .bind(format!("%{}%", query))
2125 .fetch_all(&self.pool)
2126 .await
2127 .map_err(|e| ThingsError::unknown(format!("Failed to search tags: {e}")))?;
2128
2129 let mut tags = Vec::new();
2130 for row in rows {
2131 let uuid_str: String = row.get("uuid");
2132 let uuid =
2133 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2134 let title: String = row.get("title");
2135 let shortcut: Option<String> = row.get("shortcut");
2136 let parent_str: Option<String> = row.get("parent");
2137 let parent_uuid =
2138 parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2139
2140 let creation_ts: f64 = row.get("creationDate");
2141 let created = {
2142 let ts = safe_timestamp_convert(creation_ts);
2143 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2144 };
2145
2146 let modification_ts: f64 = row.get("userModificationDate");
2147 let modified = {
2148 let ts = safe_timestamp_convert(modification_ts);
2149 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2150 };
2151
2152 let used_ts: Option<f64> = row.get("usedDate");
2153 let last_used = used_ts.and_then(|ts| {
2154 let ts_i64 = safe_timestamp_convert(ts);
2155 DateTime::from_timestamp(ts_i64, 0)
2156 });
2157
2158 let usage_count: i64 = sqlx::query_scalar(
2160 "SELECT COUNT(*) FROM TMTask
2161 WHERE cachedTags IS NOT NULL
2162 AND json_extract(cachedTags, '$') LIKE ?
2163 AND trashed = 0",
2164 )
2165 .bind(format!("%\"{}\"%", title))
2166 .fetch_one(&self.pool)
2167 .await
2168 .unwrap_or(0);
2169
2170 tags.push(crate::models::Tag {
2171 uuid,
2172 title,
2173 shortcut,
2174 parent_uuid,
2175 created,
2176 modified,
2177 usage_count: usage_count as u32,
2178 last_used,
2179 });
2180 }
2181
2182 Ok(tags)
2183 }
2184
2185 #[instrument(skip(self))]
2191 pub async fn get_all_tags(&self) -> ThingsResult<Vec<crate::models::Tag>> {
2192 let rows = sqlx::query(
2193 "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate
2194 FROM TMTag
2195 ORDER BY title",
2196 )
2197 .fetch_all(&self.pool)
2198 .await
2199 .map_err(|e| ThingsError::unknown(format!("Failed to get all tags: {e}")))?;
2200
2201 let mut tags = Vec::new();
2202 for row in rows {
2203 let uuid_str: String = row.get("uuid");
2204 let uuid =
2205 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2206 let title: String = row.get("title");
2207 let shortcut: Option<String> = row.get("shortcut");
2208 let parent_str: Option<String> = row.get("parent");
2209 let parent_uuid =
2210 parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2211
2212 let creation_ts: f64 = row.get("creationDate");
2213 let created = {
2214 let ts = safe_timestamp_convert(creation_ts);
2215 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2216 };
2217
2218 let modification_ts: f64 = row.get("userModificationDate");
2219 let modified = {
2220 let ts = safe_timestamp_convert(modification_ts);
2221 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2222 };
2223
2224 let used_ts: Option<f64> = row.get("usedDate");
2225 let last_used = used_ts.and_then(|ts| {
2226 let ts_i64 = safe_timestamp_convert(ts);
2227 DateTime::from_timestamp(ts_i64, 0)
2228 });
2229
2230 let usage_count: i64 = sqlx::query_scalar(
2232 "SELECT COUNT(*) FROM TMTask
2233 WHERE cachedTags IS NOT NULL
2234 AND json_extract(cachedTags, '$') LIKE ?
2235 AND trashed = 0",
2236 )
2237 .bind(format!("%\"{}\"%", title))
2238 .fetch_one(&self.pool)
2239 .await
2240 .unwrap_or(0);
2241
2242 tags.push(crate::models::Tag {
2243 uuid,
2244 title,
2245 shortcut,
2246 parent_uuid,
2247 created,
2248 modified,
2249 usage_count: usage_count as u32,
2250 last_used,
2251 });
2252 }
2253
2254 Ok(tags)
2255 }
2256
2257 #[instrument(skip(self))]
2263 pub async fn get_popular_tags(&self, limit: usize) -> ThingsResult<Vec<crate::models::Tag>> {
2264 let mut all_tags = self.get_all_tags().await?;
2265
2266 all_tags.sort_by(|a, b| b.usage_count.cmp(&a.usage_count));
2268
2269 all_tags.truncate(limit);
2271
2272 Ok(all_tags)
2273 }
2274
2275 #[instrument(skip(self))]
2281 pub async fn get_recent_tags(&self, limit: usize) -> ThingsResult<Vec<crate::models::Tag>> {
2282 let rows = sqlx::query(
2283 "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate
2284 FROM TMTag
2285 WHERE usedDate IS NOT NULL
2286 ORDER BY usedDate DESC
2287 LIMIT ?",
2288 )
2289 .bind(limit as i64)
2290 .fetch_all(&self.pool)
2291 .await
2292 .map_err(|e| ThingsError::unknown(format!("Failed to get recent tags: {e}")))?;
2293
2294 let mut tags = Vec::new();
2295 for row in rows {
2296 let uuid_str: String = row.get("uuid");
2297 let uuid =
2298 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2299 let title: String = row.get("title");
2300 let shortcut: Option<String> = row.get("shortcut");
2301 let parent_str: Option<String> = row.get("parent");
2302 let parent_uuid =
2303 parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2304
2305 let creation_ts: f64 = row.get("creationDate");
2306 let created = {
2307 let ts = safe_timestamp_convert(creation_ts);
2308 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2309 };
2310
2311 let modification_ts: f64 = row.get("userModificationDate");
2312 let modified = {
2313 let ts = safe_timestamp_convert(modification_ts);
2314 DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2315 };
2316
2317 let used_ts: Option<f64> = row.get("usedDate");
2318 let last_used = used_ts.and_then(|ts| {
2319 let ts_i64 = safe_timestamp_convert(ts);
2320 DateTime::from_timestamp(ts_i64, 0)
2321 });
2322
2323 let usage_count: i64 = sqlx::query_scalar(
2325 "SELECT COUNT(*) FROM TMTask
2326 WHERE cachedTags IS NOT NULL
2327 AND json_extract(cachedTags, '$') LIKE ?
2328 AND trashed = 0",
2329 )
2330 .bind(format!("%\"{}\"%", title))
2331 .fetch_one(&self.pool)
2332 .await
2333 .unwrap_or(0);
2334
2335 tags.push(crate::models::Tag {
2336 uuid,
2337 title,
2338 shortcut,
2339 parent_uuid,
2340 created,
2341 modified,
2342 usage_count: usage_count as u32,
2343 last_used,
2344 });
2345 }
2346
2347 Ok(tags)
2348 }
2349
2350 #[instrument(skip(self))]
2361 pub async fn create_tag_smart(
2362 &self,
2363 request: crate::models::CreateTagRequest,
2364 ) -> ThingsResult<crate::models::TagCreationResult> {
2365 use crate::database::tag_utils::normalize_tag_title;
2366 use crate::models::TagCreationResult;
2367
2368 let normalized = normalize_tag_title(&request.title);
2370
2371 if let Some(existing) = self.find_tag_by_normalized_title(&normalized).await? {
2373 return Ok(TagCreationResult::Existing {
2374 tag: existing,
2375 is_new: false,
2376 });
2377 }
2378
2379 let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2381
2382 if !similar_tags.is_empty() {
2384 return Ok(TagCreationResult::SimilarFound {
2385 similar_tags,
2386 requested_title: request.title,
2387 });
2388 }
2389
2390 let uuid = Uuid::new_v4();
2392 let now = Utc::now().timestamp() as f64;
2393
2394 sqlx::query(
2395 "INSERT INTO TMTag (uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate, `index`)
2396 VALUES (?, ?, ?, ?, ?, ?, NULL, 0)"
2397 )
2398 .bind(uuid.to_string())
2399 .bind(&request.title)
2400 .bind(request.shortcut.as_ref())
2401 .bind(request.parent_uuid.map(|u| u.to_string()))
2402 .bind(now)
2403 .bind(now)
2404 .execute(&self.pool)
2405 .await
2406 .map_err(|e| ThingsError::unknown(format!("Failed to create tag: {e}")))?;
2407
2408 info!("Created tag with UUID: {}", uuid);
2409 Ok(TagCreationResult::Created { uuid, is_new: true })
2410 }
2411
2412 #[instrument(skip(self))]
2418 pub async fn create_tag_force(
2419 &self,
2420 request: crate::models::CreateTagRequest,
2421 ) -> ThingsResult<Uuid> {
2422 let uuid = Uuid::new_v4();
2423 let now = Utc::now().timestamp() as f64;
2424
2425 sqlx::query(
2426 "INSERT INTO TMTag (uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate, `index`)
2427 VALUES (?, ?, ?, ?, ?, ?, NULL, 0)"
2428 )
2429 .bind(uuid.to_string())
2430 .bind(&request.title)
2431 .bind(request.shortcut.as_ref())
2432 .bind(request.parent_uuid.map(|u| u.to_string()))
2433 .bind(now)
2434 .bind(now)
2435 .execute(&self.pool)
2436 .await
2437 .map_err(|e| ThingsError::unknown(format!("Failed to create tag: {e}")))?;
2438
2439 info!("Forcefully created tag with UUID: {}", uuid);
2440 Ok(uuid)
2441 }
2442
2443 #[instrument(skip(self))]
2449 pub async fn update_tag(&self, request: crate::models::UpdateTagRequest) -> ThingsResult<()> {
2450 use crate::database::tag_utils::normalize_tag_title;
2451
2452 let existing = self
2454 .find_tag_by_normalized_title(&request.uuid.to_string())
2455 .await?;
2456 if existing.is_none() {
2457 let row = sqlx::query("SELECT 1 FROM TMTag WHERE uuid = ?")
2459 .bind(request.uuid.to_string())
2460 .fetch_optional(&self.pool)
2461 .await
2462 .map_err(|e| ThingsError::unknown(format!("Failed to validate tag: {e}")))?;
2463
2464 if row.is_none() {
2465 return Err(ThingsError::unknown(format!(
2466 "Tag not found: {}",
2467 request.uuid
2468 )));
2469 }
2470 }
2471
2472 if let Some(new_title) = &request.title {
2474 let normalized = normalize_tag_title(new_title);
2475 if let Some(duplicate) = self.find_tag_by_normalized_title(&normalized).await? {
2476 if duplicate.uuid != request.uuid {
2477 return Err(ThingsError::unknown(format!(
2478 "Tag with title '{}' already exists",
2479 new_title
2480 )));
2481 }
2482 }
2483 }
2484
2485 let now = Utc::now().timestamp() as f64;
2486
2487 let mut updates = Vec::new();
2489 let mut params: Vec<String> = Vec::new();
2490
2491 if let Some(title) = &request.title {
2492 updates.push("title = ?");
2493 params.push(title.clone());
2494 }
2495 if let Some(shortcut) = &request.shortcut {
2496 updates.push("shortcut = ?");
2497 params.push(shortcut.clone());
2498 }
2499 if let Some(parent_uuid) = request.parent_uuid {
2500 updates.push("parent = ?");
2501 params.push(parent_uuid.to_string());
2502 }
2503
2504 if updates.is_empty() {
2505 return Ok(()); }
2507
2508 updates.push("userModificationDate = ?");
2509 params.push(now.to_string());
2510
2511 let sql = format!("UPDATE TMTag SET {} WHERE uuid = ?", updates.join(", "));
2512 params.push(request.uuid.to_string());
2513
2514 let mut query = sqlx::query(&sql);
2515 for param in params {
2516 query = query.bind(param);
2517 }
2518
2519 query
2520 .execute(&self.pool)
2521 .await
2522 .map_err(|e| ThingsError::unknown(format!("Failed to update tag: {e}")))?;
2523
2524 info!("Updated tag with UUID: {}", request.uuid);
2525 Ok(())
2526 }
2527
2528 #[instrument(skip(self))]
2539 pub async fn delete_tag(&self, uuid: &Uuid, remove_from_tasks: bool) -> ThingsResult<()> {
2540 let tag = self.find_tag_by_normalized_title(&uuid.to_string()).await?;
2542
2543 if tag.is_none() {
2544 let row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2546 .bind(uuid.to_string())
2547 .fetch_optional(&self.pool)
2548 .await
2549 .map_err(|e| ThingsError::unknown(format!("Failed to find tag: {e}")))?;
2550
2551 if row.is_none() {
2552 return Err(ThingsError::unknown(format!("Tag not found: {}", uuid)));
2553 }
2554 }
2555
2556 if remove_from_tasks {
2557 info!("Removing tag {} from all tasks (not yet implemented)", uuid);
2560 }
2561
2562 sqlx::query("DELETE FROM TMTag WHERE uuid = ?")
2564 .bind(uuid.to_string())
2565 .execute(&self.pool)
2566 .await
2567 .map_err(|e| ThingsError::unknown(format!("Failed to delete tag: {e}")))?;
2568
2569 info!("Deleted tag with UUID: {}", uuid);
2570 Ok(())
2571 }
2572
2573 #[instrument(skip(self))]
2584 pub async fn merge_tags(&self, source_uuid: &Uuid, target_uuid: &Uuid) -> ThingsResult<()> {
2585 let source_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2587 .bind(source_uuid.to_string())
2588 .fetch_optional(&self.pool)
2589 .await
2590 .map_err(|e| ThingsError::unknown(format!("Failed to find source tag: {e}")))?;
2591
2592 if source_row.is_none() {
2593 return Err(ThingsError::unknown(format!(
2594 "Source tag not found: {}",
2595 source_uuid
2596 )));
2597 }
2598
2599 let target_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2600 .bind(target_uuid.to_string())
2601 .fetch_optional(&self.pool)
2602 .await
2603 .map_err(|e| ThingsError::unknown(format!("Failed to find target tag: {e}")))?;
2604
2605 if target_row.is_none() {
2606 return Err(ThingsError::unknown(format!(
2607 "Target tag not found: {}",
2608 target_uuid
2609 )));
2610 }
2611
2612 info!(
2615 "Merging tag {} into {} (tag replacement in tasks not yet fully implemented)",
2616 source_uuid, target_uuid
2617 );
2618
2619 let now = Utc::now().timestamp() as f64;
2621 sqlx::query("UPDATE TMTag SET userModificationDate = ?, usedDate = ? WHERE uuid = ?")
2622 .bind(now)
2623 .bind(now)
2624 .bind(target_uuid.to_string())
2625 .execute(&self.pool)
2626 .await
2627 .map_err(|e| ThingsError::unknown(format!("Failed to update target tag: {e}")))?;
2628
2629 sqlx::query("DELETE FROM TMTag WHERE uuid = ?")
2631 .bind(source_uuid.to_string())
2632 .execute(&self.pool)
2633 .await
2634 .map_err(|e| ThingsError::unknown(format!("Failed to delete source tag: {e}")))?;
2635
2636 info!("Merged tag {} into {}", source_uuid, target_uuid);
2637 Ok(())
2638 }
2639
2640 #[instrument(skip(self))]
2654 pub async fn add_tag_to_task(
2655 &self,
2656 task_uuid: &Uuid,
2657 tag_title: &str,
2658 ) -> ThingsResult<crate::models::TagAssignmentResult> {
2659 use crate::database::tag_utils::normalize_tag_title;
2660 use crate::models::TagAssignmentResult;
2661
2662 validators::validate_task_exists(&self.pool, task_uuid).await?;
2664
2665 let normalized = normalize_tag_title(tag_title);
2667
2668 let tag = if let Some(existing_tag) = self.find_tag_by_normalized_title(&normalized).await?
2670 {
2671 existing_tag
2672 } else {
2673 let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2675
2676 if !similar_tags.is_empty() {
2677 return Ok(TagAssignmentResult::Suggestions { similar_tags });
2678 }
2679
2680 let request = crate::models::CreateTagRequest {
2682 title: tag_title.to_string(),
2683 shortcut: None,
2684 parent_uuid: None,
2685 };
2686 let _uuid = self.create_tag_force(request).await?;
2687
2688 self.find_tag_by_normalized_title(&normalized)
2690 .await?
2691 .ok_or_else(|| ThingsError::unknown("Failed to retrieve newly created tag"))?
2692 };
2693
2694 let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
2696 .bind(task_uuid.to_string())
2697 .fetch_one(&self.pool)
2698 .await
2699 .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
2700
2701 let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
2702 let mut tags: Vec<String> = if let Some(blob) = cached_tags_blob {
2703 deserialize_tags_from_blob(&blob)?
2704 } else {
2705 Vec::new()
2706 };
2707
2708 if !tags.contains(&tag.title) {
2710 tags.push(tag.title.clone());
2711
2712 let cached_tags = serialize_tags_to_blob(&tags)?;
2714 let now = Utc::now().timestamp() as f64;
2715
2716 sqlx::query(
2717 "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2718 )
2719 .bind(cached_tags)
2720 .bind(now)
2721 .bind(task_uuid.to_string())
2722 .execute(&self.pool)
2723 .await
2724 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2725
2726 sqlx::query("UPDATE TMTag SET usedDate = ?, userModificationDate = ? WHERE uuid = ?")
2728 .bind(now)
2729 .bind(now)
2730 .bind(tag.uuid.to_string())
2731 .execute(&self.pool)
2732 .await
2733 .map_err(|e| ThingsError::unknown(format!("Failed to update tag usedDate: {e}")))?;
2734
2735 info!("Added tag '{}' to task {}", tag.title, task_uuid);
2736 }
2737
2738 Ok(TagAssignmentResult::Assigned { tag_uuid: tag.uuid })
2739 }
2740
2741 #[instrument(skip(self))]
2747 pub async fn remove_tag_from_task(
2748 &self,
2749 task_uuid: &Uuid,
2750 tag_title: &str,
2751 ) -> ThingsResult<()> {
2752 use crate::database::tag_utils::normalize_tag_title;
2753
2754 validators::validate_task_exists(&self.pool, task_uuid).await?;
2756
2757 let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
2759 .bind(task_uuid.to_string())
2760 .fetch_one(&self.pool)
2761 .await
2762 .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
2763
2764 let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
2765 let mut tags: Vec<String> = if let Some(blob) = cached_tags_blob {
2766 deserialize_tags_from_blob(&blob)?
2767 } else {
2768 return Ok(()); };
2770
2771 let normalized = normalize_tag_title(tag_title);
2773 let original_len = tags.len();
2774 tags.retain(|t| normalize_tag_title(t) != normalized);
2775
2776 if tags.len() < original_len {
2778 let cached_tags = if tags.is_empty() {
2779 None
2780 } else {
2781 Some(serialize_tags_to_blob(&tags)?)
2782 };
2783
2784 let now = Utc::now().timestamp() as f64;
2785
2786 if let Some(cached_tags_val) = cached_tags {
2787 sqlx::query(
2788 "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2789 )
2790 .bind(cached_tags_val)
2791 .bind(now)
2792 .bind(task_uuid.to_string())
2793 .execute(&self.pool)
2794 .await
2795 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2796 } else {
2797 sqlx::query(
2799 "UPDATE TMTask SET cachedTags = NULL, userModificationDate = ? WHERE uuid = ?",
2800 )
2801 .bind(now)
2802 .bind(task_uuid.to_string())
2803 .execute(&self.pool)
2804 .await
2805 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2806 }
2807
2808 info!("Removed tag '{}' from task {}", tag_title, task_uuid);
2809 }
2810
2811 Ok(())
2812 }
2813
2814 #[instrument(skip(self))]
2822 pub async fn set_task_tags(
2823 &self,
2824 task_uuid: &Uuid,
2825 tag_titles: Vec<String>,
2826 ) -> ThingsResult<Vec<crate::models::TagMatch>> {
2827 use crate::database::tag_utils::normalize_tag_title;
2828
2829 validators::validate_task_exists(&self.pool, task_uuid).await?;
2831
2832 let mut resolved_tags = Vec::new();
2833 let mut suggestions = Vec::new();
2834
2835 for title in tag_titles {
2837 let normalized = normalize_tag_title(&title);
2838
2839 if let Some(existing_tag) = self.find_tag_by_normalized_title(&normalized).await? {
2841 resolved_tags.push(existing_tag.title);
2842 } else {
2843 let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2845
2846 if !similar_tags.is_empty() {
2847 suggestions.extend(similar_tags);
2848 }
2849
2850 resolved_tags.push(title);
2852 }
2853 }
2854
2855 for title in &resolved_tags {
2857 let normalized = normalize_tag_title(title);
2858 if self
2859 .find_tag_by_normalized_title(&normalized)
2860 .await?
2861 .is_none()
2862 {
2863 let request = crate::models::CreateTagRequest {
2864 title: title.clone(),
2865 shortcut: None,
2866 parent_uuid: None,
2867 };
2868 self.create_tag_force(request).await?;
2869 }
2870 }
2871
2872 let cached_tags = if resolved_tags.is_empty() {
2874 None
2875 } else {
2876 Some(serialize_tags_to_blob(&resolved_tags)?)
2877 };
2878
2879 let now = Utc::now().timestamp() as f64;
2880
2881 if let Some(cached_tags_val) = cached_tags {
2882 sqlx::query(
2883 "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2884 )
2885 .bind(cached_tags_val)
2886 .bind(now)
2887 .bind(task_uuid.to_string())
2888 .execute(&self.pool)
2889 .await
2890 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2891 } else {
2892 sqlx::query(
2893 "UPDATE TMTask SET cachedTags = NULL, userModificationDate = ? WHERE uuid = ?",
2894 )
2895 .bind(now)
2896 .bind(task_uuid.to_string())
2897 .execute(&self.pool)
2898 .await
2899 .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2900 }
2901
2902 for title in &resolved_tags {
2904 let normalized = normalize_tag_title(title);
2905 if let Some(tag) = self.find_tag_by_normalized_title(&normalized).await? {
2906 sqlx::query(
2907 "UPDATE TMTag SET usedDate = ?, userModificationDate = ? WHERE uuid = ?",
2908 )
2909 .bind(now)
2910 .bind(now)
2911 .bind(tag.uuid.to_string())
2912 .execute(&self.pool)
2913 .await
2914 .map_err(|e| ThingsError::unknown(format!("Failed to update tag usedDate: {e}")))?;
2915 }
2916 }
2917
2918 info!("Set tags on task {} to: {:?}", task_uuid, resolved_tags);
2919 Ok(suggestions)
2920 }
2921
2922 #[instrument(skip(self))]
2938 pub async fn get_tag_completions(
2939 &self,
2940 partial_input: &str,
2941 limit: usize,
2942 ) -> ThingsResult<Vec<crate::models::TagCompletion>> {
2943 use crate::database::tag_utils::{calculate_similarity, normalize_tag_title};
2944
2945 let normalized_input = normalize_tag_title(partial_input);
2946 let all_tags = self.get_all_tags().await?;
2947
2948 let mut completions: Vec<crate::models::TagCompletion> = all_tags
2949 .into_iter()
2950 .filter_map(|tag| {
2951 let normalized_tag = normalize_tag_title(&tag.title);
2952
2953 let score = if normalized_tag.starts_with(&normalized_input) {
2955 3.0 + (tag.usage_count as f32 / 100.0)
2957 } else if normalized_tag.contains(&normalized_input) {
2958 2.0 + (tag.usage_count as f32 / 100.0)
2960 } else {
2961 let similarity = calculate_similarity(partial_input, &tag.title);
2963 if similarity >= 0.6 {
2964 similarity + (tag.usage_count as f32 / 1000.0)
2965 } else {
2966 return None; }
2968 };
2969
2970 Some(crate::models::TagCompletion { tag, score })
2971 })
2972 .collect();
2973
2974 completions.sort_by(|a, b| {
2976 b.score
2977 .partial_cmp(&a.score)
2978 .unwrap_or(std::cmp::Ordering::Equal)
2979 });
2980
2981 completions.truncate(limit);
2983
2984 Ok(completions)
2985 }
2986
2987 #[instrument(skip(self))]
2993 pub async fn get_tag_statistics(
2994 &self,
2995 uuid: &Uuid,
2996 ) -> ThingsResult<crate::models::TagStatistics> {
2997 let tag_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2999 .bind(uuid.to_string())
3000 .fetch_optional(&self.pool)
3001 .await
3002 .map_err(|e| ThingsError::unknown(format!("Failed to find tag: {e}")))?;
3003
3004 let title: String = tag_row
3005 .ok_or_else(|| ThingsError::unknown(format!("Tag not found: {}", uuid)))?
3006 .get("title");
3007
3008 let task_rows = sqlx::query(
3011 "SELECT uuid, cachedTags FROM TMTask
3012 WHERE cachedTags IS NOT NULL
3013 AND trashed = 0",
3014 )
3015 .fetch_all(&self.pool)
3016 .await
3017 .map_err(|e| ThingsError::unknown(format!("Failed to query tasks with tag: {e}")))?;
3018
3019 let mut task_uuids = Vec::new();
3020 for row in task_rows {
3021 let uuid_str: String = row.get("uuid");
3022 let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
3023
3024 if let Some(blob) = cached_tags_blob {
3026 if let Ok(tags) = deserialize_tags_from_blob(&blob) {
3027 if tags.iter().any(|t| t.eq_ignore_ascii_case(&title)) {
3028 let task_uuid = Uuid::parse_str(&uuid_str)
3029 .unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
3030 task_uuids.push(task_uuid);
3031 }
3032 }
3033 }
3034 }
3035
3036 let usage_count = task_uuids.len() as u32;
3037
3038 let mut related_tags: std::collections::HashMap<String, u32> =
3040 std::collections::HashMap::new();
3041
3042 for task_uuid in &task_uuids {
3043 let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
3044 .bind(task_uuid.to_string())
3045 .fetch_optional(&self.pool)
3046 .await
3047 .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
3048
3049 if let Some(row) = row {
3050 let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
3051 if let Some(blob) = cached_tags_blob {
3052 let tags: Vec<String> = deserialize_tags_from_blob(&blob)?;
3053 for tag in tags {
3054 if tag != title {
3055 *related_tags.entry(tag).or_insert(0) += 1;
3056 }
3057 }
3058 }
3059 }
3060 }
3061
3062 let mut related_vec: Vec<(String, u32)> = related_tags.into_iter().collect();
3064 related_vec.sort_by(|a, b| b.1.cmp(&a.1));
3065
3066 Ok(crate::models::TagStatistics {
3067 uuid: *uuid,
3068 title,
3069 usage_count,
3070 task_uuids,
3071 related_tags: related_vec,
3072 })
3073 }
3074
3075 #[instrument(skip(self))]
3083 pub async fn find_duplicate_tags(
3084 &self,
3085 min_similarity: f32,
3086 ) -> ThingsResult<Vec<crate::models::TagPair>> {
3087 use crate::database::tag_utils::calculate_similarity;
3088
3089 let all_tags = self.get_all_tags().await?;
3090 let mut pairs = Vec::new();
3091
3092 for i in 0..all_tags.len() {
3094 for j in (i + 1)..all_tags.len() {
3095 let tag1 = &all_tags[i];
3096 let tag2 = &all_tags[j];
3097
3098 let similarity = calculate_similarity(&tag1.title, &tag2.title);
3099
3100 if similarity >= min_similarity {
3101 pairs.push(crate::models::TagPair {
3102 tag1: tag1.clone(),
3103 tag2: tag2.clone(),
3104 similarity,
3105 });
3106 }
3107 }
3108 }
3109
3110 pairs.sort_by(|a, b| {
3112 b.similarity
3113 .partial_cmp(&a.similarity)
3114 .unwrap_or(std::cmp::Ordering::Equal)
3115 });
3116
3117 Ok(pairs)
3118 }
3119
3120 const MAX_BULK_BATCH_SIZE: usize = 1000;
3127
3128 #[instrument(skip(self))]
3141 pub async fn bulk_move(
3142 &self,
3143 request: crate::models::BulkMoveRequest,
3144 ) -> ThingsResult<crate::models::BulkOperationResult> {
3145 if request.task_uuids.is_empty() {
3147 return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3148 }
3149 if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3150 return Err(ThingsError::validation(format!(
3151 "Batch size {} exceeds maximum of {}",
3152 request.task_uuids.len(),
3153 Self::MAX_BULK_BATCH_SIZE
3154 )));
3155 }
3156 if request.project_uuid.is_none() && request.area_uuid.is_none() {
3157 return Err(ThingsError::validation(
3158 "Must specify either project_uuid or area_uuid",
3159 ));
3160 }
3161
3162 if let Some(project_uuid) = &request.project_uuid {
3164 validators::validate_project_exists(&self.pool, project_uuid).await?;
3165 }
3166 if let Some(area_uuid) = &request.area_uuid {
3167 validators::validate_area_exists(&self.pool, area_uuid).await?;
3168 }
3169
3170 let mut tx = self
3172 .pool
3173 .begin()
3174 .await
3175 .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3176
3177 let placeholders = request
3179 .task_uuids
3180 .iter()
3181 .map(|_| "?")
3182 .collect::<Vec<_>>()
3183 .join(",");
3184 let query_str = format!(
3185 "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3186 placeholders
3187 );
3188
3189 let mut query = sqlx::query(&query_str);
3190 for uuid in &request.task_uuids {
3191 query = query.bind(uuid.to_string());
3192 }
3193
3194 let found_uuids: Vec<String> = query
3195 .fetch_all(&mut *tx)
3196 .await
3197 .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3198 .iter()
3199 .map(|row| row.get("uuid"))
3200 .collect();
3201
3202 if found_uuids.len() != request.task_uuids.len() {
3204 for uuid in &request.task_uuids {
3206 if !found_uuids.contains(&uuid.to_string()) {
3207 tx.rollback().await.ok();
3208 return Err(ThingsError::TaskNotFound {
3209 uuid: uuid.to_string(),
3210 });
3211 }
3212 }
3213 }
3214
3215 let now = Utc::now().timestamp() as f64;
3217 let placeholders = request
3218 .task_uuids
3219 .iter()
3220 .map(|_| "?")
3221 .collect::<Vec<_>>()
3222 .join(",");
3223 let query_str = format!(
3224 "UPDATE TMTask SET project = ?, area = ?, userModificationDate = ? WHERE uuid IN ({})",
3225 placeholders
3226 );
3227
3228 let mut query = sqlx::query(&query_str)
3229 .bind(request.project_uuid.map(|u| u.to_string()))
3230 .bind(request.area_uuid.map(|u| u.to_string()))
3231 .bind(now);
3232
3233 for uuid in &request.task_uuids {
3234 query = query.bind(uuid.to_string());
3235 }
3236
3237 query
3238 .execute(&mut *tx)
3239 .await
3240 .map_err(|e| ThingsError::unknown(format!("Failed to bulk move tasks: {e}")))?;
3241
3242 tx.commit()
3244 .await
3245 .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3246
3247 info!("Bulk moved {} task(s)", request.task_uuids.len());
3248 Ok(crate::models::BulkOperationResult {
3249 success: true,
3250 processed_count: request.task_uuids.len(),
3251 message: format!("Successfully moved {} task(s)", request.task_uuids.len()),
3252 })
3253 }
3254
3255 #[instrument(skip(self))]
3268 pub async fn bulk_update_dates(
3269 &self,
3270 request: crate::models::BulkUpdateDatesRequest,
3271 ) -> ThingsResult<crate::models::BulkOperationResult> {
3272 use crate::database::{safe_things_date_to_naive_date, validate_date_range};
3273
3274 if request.task_uuids.is_empty() {
3276 return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3277 }
3278 if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3279 return Err(ThingsError::validation(format!(
3280 "Batch size {} exceeds maximum of {}",
3281 request.task_uuids.len(),
3282 Self::MAX_BULK_BATCH_SIZE
3283 )));
3284 }
3285
3286 if let (Some(start), Some(deadline)) = (request.start_date, request.deadline) {
3288 validate_date_range(Some(start), Some(deadline))?;
3289 }
3290
3291 let mut tx = self
3293 .pool
3294 .begin()
3295 .await
3296 .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3297
3298 let placeholders = request
3300 .task_uuids
3301 .iter()
3302 .map(|_| "?")
3303 .collect::<Vec<_>>()
3304 .join(",");
3305 let query_str = format!(
3306 "SELECT uuid, startDate, deadline FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3307 placeholders
3308 );
3309
3310 let mut query = sqlx::query(&query_str);
3311 for uuid in &request.task_uuids {
3312 query = query.bind(uuid.to_string());
3313 }
3314
3315 let rows = query
3316 .fetch_all(&mut *tx)
3317 .await
3318 .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?;
3319
3320 if rows.len() != request.task_uuids.len() {
3322 let found_uuids: Vec<String> = rows.iter().map(|row| row.get("uuid")).collect();
3324 for uuid in &request.task_uuids {
3325 if !found_uuids.contains(&uuid.to_string()) {
3326 tx.rollback().await.ok();
3327 return Err(ThingsError::TaskNotFound {
3328 uuid: uuid.to_string(),
3329 });
3330 }
3331 }
3332 }
3333
3334 for row in &rows {
3336 let current_start: Option<i64> = row.get("startDate");
3337 let current_deadline: Option<i64> = row.get("deadline");
3338
3339 let final_start = if request.clear_start_date {
3340 None
3341 } else if let Some(new_start) = request.start_date {
3342 Some(new_start)
3343 } else {
3344 current_start.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
3345 };
3346
3347 let final_deadline = if request.clear_deadline {
3348 None
3349 } else if let Some(new_deadline) = request.deadline {
3350 Some(new_deadline)
3351 } else {
3352 current_deadline.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
3353 };
3354
3355 validate_date_range(final_start, final_deadline)?;
3356 }
3357
3358 let now = Utc::now().timestamp() as f64;
3360 let placeholders = request
3361 .task_uuids
3362 .iter()
3363 .map(|_| "?")
3364 .collect::<Vec<_>>()
3365 .join(",");
3366
3367 let start_date_value = if request.clear_start_date {
3368 None
3369 } else {
3370 request.start_date.map(naive_date_to_things_timestamp)
3371 };
3372
3373 let deadline_value = if request.clear_deadline {
3374 None
3375 } else {
3376 request.deadline.map(naive_date_to_things_timestamp)
3377 };
3378
3379 let query_str = format!(
3380 "UPDATE TMTask SET startDate = ?, deadline = ?, userModificationDate = ? WHERE uuid IN ({})",
3381 placeholders
3382 );
3383
3384 let mut query = sqlx::query(&query_str)
3385 .bind(start_date_value)
3386 .bind(deadline_value)
3387 .bind(now);
3388
3389 for uuid in &request.task_uuids {
3390 query = query.bind(uuid.to_string());
3391 }
3392
3393 query
3394 .execute(&mut *tx)
3395 .await
3396 .map_err(|e| ThingsError::unknown(format!("Failed to bulk update dates: {e}")))?;
3397
3398 tx.commit()
3399 .await
3400 .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3401
3402 info!(
3403 "Bulk updated dates for {} task(s)",
3404 request.task_uuids.len()
3405 );
3406 Ok(crate::models::BulkOperationResult {
3407 success: true,
3408 processed_count: request.task_uuids.len(),
3409 message: format!(
3410 "Successfully updated dates for {} task(s)",
3411 request.task_uuids.len()
3412 ),
3413 })
3414 }
3415
3416 #[instrument(skip(self))]
3427 pub async fn bulk_complete(
3428 &self,
3429 request: crate::models::BulkCompleteRequest,
3430 ) -> ThingsResult<crate::models::BulkOperationResult> {
3431 if request.task_uuids.is_empty() {
3433 return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3434 }
3435 if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3436 return Err(ThingsError::validation(format!(
3437 "Batch size {} exceeds maximum of {}",
3438 request.task_uuids.len(),
3439 Self::MAX_BULK_BATCH_SIZE
3440 )));
3441 }
3442
3443 let mut tx = self
3445 .pool
3446 .begin()
3447 .await
3448 .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3449
3450 let placeholders = request
3452 .task_uuids
3453 .iter()
3454 .map(|_| "?")
3455 .collect::<Vec<_>>()
3456 .join(",");
3457 let query_str = format!(
3458 "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3459 placeholders
3460 );
3461
3462 let mut query = sqlx::query(&query_str);
3463 for uuid in &request.task_uuids {
3464 query = query.bind(uuid.to_string());
3465 }
3466
3467 let found_uuids: Vec<String> = query
3468 .fetch_all(&mut *tx)
3469 .await
3470 .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3471 .iter()
3472 .map(|row| row.get("uuid"))
3473 .collect();
3474
3475 if found_uuids.len() != request.task_uuids.len() {
3477 for uuid in &request.task_uuids {
3479 if !found_uuids.contains(&uuid.to_string()) {
3480 tx.rollback().await.ok();
3481 return Err(ThingsError::TaskNotFound {
3482 uuid: uuid.to_string(),
3483 });
3484 }
3485 }
3486 }
3487
3488 let now = Utc::now().timestamp() as f64;
3490 let placeholders = request
3491 .task_uuids
3492 .iter()
3493 .map(|_| "?")
3494 .collect::<Vec<_>>()
3495 .join(",");
3496 let query_str = format!(
3497 "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid IN ({})",
3498 placeholders
3499 );
3500
3501 let mut query = sqlx::query(&query_str).bind(now).bind(now);
3502
3503 for uuid in &request.task_uuids {
3504 query = query.bind(uuid.to_string());
3505 }
3506
3507 query
3508 .execute(&mut *tx)
3509 .await
3510 .map_err(|e| ThingsError::unknown(format!("Failed to bulk complete tasks: {e}")))?;
3511
3512 tx.commit()
3514 .await
3515 .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3516
3517 info!("Bulk completed {} task(s)", request.task_uuids.len());
3518 Ok(crate::models::BulkOperationResult {
3519 success: true,
3520 processed_count: request.task_uuids.len(),
3521 message: format!(
3522 "Successfully completed {} task(s)",
3523 request.task_uuids.len()
3524 ),
3525 })
3526 }
3527
3528 #[instrument(skip(self))]
3539 pub async fn bulk_delete(
3540 &self,
3541 request: crate::models::BulkDeleteRequest,
3542 ) -> ThingsResult<crate::models::BulkOperationResult> {
3543 if request.task_uuids.is_empty() {
3545 return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3546 }
3547 if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3548 return Err(ThingsError::validation(format!(
3549 "Batch size {} exceeds maximum of {}",
3550 request.task_uuids.len(),
3551 Self::MAX_BULK_BATCH_SIZE
3552 )));
3553 }
3554
3555 let mut tx = self
3557 .pool
3558 .begin()
3559 .await
3560 .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3561
3562 let placeholders = request
3564 .task_uuids
3565 .iter()
3566 .map(|_| "?")
3567 .collect::<Vec<_>>()
3568 .join(",");
3569 let query_str = format!(
3570 "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3571 placeholders
3572 );
3573
3574 let mut query = sqlx::query(&query_str);
3575 for uuid in &request.task_uuids {
3576 query = query.bind(uuid.to_string());
3577 }
3578
3579 let found_uuids: Vec<String> = query
3580 .fetch_all(&mut *tx)
3581 .await
3582 .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3583 .iter()
3584 .map(|row| row.get("uuid"))
3585 .collect();
3586
3587 if found_uuids.len() != request.task_uuids.len() {
3589 for uuid in &request.task_uuids {
3591 if !found_uuids.contains(&uuid.to_string()) {
3592 tx.rollback().await.ok();
3593 return Err(ThingsError::TaskNotFound {
3594 uuid: uuid.to_string(),
3595 });
3596 }
3597 }
3598 }
3599
3600 let now = Utc::now().timestamp() as f64;
3602 let placeholders = request
3603 .task_uuids
3604 .iter()
3605 .map(|_| "?")
3606 .collect::<Vec<_>>()
3607 .join(",");
3608 let query_str = format!(
3609 "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid IN ({})",
3610 placeholders
3611 );
3612
3613 let mut query = sqlx::query(&query_str).bind(now);
3614
3615 for uuid in &request.task_uuids {
3616 query = query.bind(uuid.to_string());
3617 }
3618
3619 query
3620 .execute(&mut *tx)
3621 .await
3622 .map_err(|e| ThingsError::unknown(format!("Failed to bulk delete tasks: {e}")))?;
3623
3624 tx.commit()
3626 .await
3627 .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3628
3629 info!("Bulk deleted {} task(s)", request.task_uuids.len());
3630 Ok(crate::models::BulkOperationResult {
3631 success: true,
3632 processed_count: request.task_uuids.len(),
3633 message: format!("Successfully deleted {} task(s)", request.task_uuids.len()),
3634 })
3635 }
3636}
3637
3638#[derive(Debug, Clone, Serialize, Deserialize)]
3640pub struct DatabaseStats {
3641 pub task_count: u64,
3642 pub project_count: u64,
3643 pub area_count: u64,
3644}
3645
3646impl DatabaseStats {
3647 #[must_use]
3648 pub fn total_items(&self) -> u64 {
3649 self.task_count + self.project_count + self.area_count
3650 }
3651}
3652
3653#[must_use]
3665pub fn get_default_database_path() -> PathBuf {
3666 let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
3667 PathBuf::from(format!(
3668 "{home}/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-0Z0Z2/Things Database.thingsdatabase/main.sqlite"
3669 ))
3670}
3671
3672#[cfg(test)]
3673mod tests {
3674 use super::*;
3675 use tempfile::{NamedTempFile, TempDir};
3676
3677 #[tokio::test]
3678 async fn test_database_connection() {
3679 let temp_dir = TempDir::new().unwrap();
3680 let db_path = temp_dir.path().join("test.db");
3681
3682 let result = super::ThingsDatabase::new(&db_path).await;
3685 assert!(result.is_err());
3686 }
3687
3688 #[tokio::test]
3689 async fn test_connection_string() {
3690 let result = super::ThingsDatabase::from_connection_string("sqlite::memory:").await;
3691 assert!(result.is_ok());
3692 }
3693
3694 #[test]
3695 fn test_task_status_from_i32() {
3696 assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
3697 assert_eq!(TaskStatus::from_i32(1), Some(TaskStatus::Completed));
3698 assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
3699 assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Trashed));
3700 assert_eq!(TaskStatus::from_i32(4), None);
3701 assert_eq!(TaskStatus::from_i32(-1), None);
3702 }
3703
3704 #[test]
3705 fn test_task_type_from_i32() {
3706 assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
3707 assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
3708 assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
3709 assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
3710 assert_eq!(TaskType::from_i32(4), None);
3711 assert_eq!(TaskType::from_i32(-1), None);
3712 }
3713
3714 #[test]
3715 fn test_database_stats_total_items() {
3716 let stats = DatabaseStats {
3717 task_count: 10,
3718 project_count: 5,
3719 area_count: 3,
3720 };
3721 assert_eq!(stats.total_items(), 18);
3722
3723 let empty_stats = DatabaseStats {
3724 task_count: 0,
3725 project_count: 0,
3726 area_count: 0,
3727 };
3728 assert_eq!(empty_stats.total_items(), 0);
3729 }
3730
3731 #[test]
3732 fn test_database_pool_config_default() {
3733 let config = DatabasePoolConfig::default();
3734 assert_eq!(config.max_connections, 10);
3735 assert_eq!(config.min_connections, 1);
3736 assert_eq!(config.connect_timeout, Duration::from_secs(30));
3737 assert_eq!(config.idle_timeout, Duration::from_secs(600));
3738 assert_eq!(config.max_lifetime, Duration::from_secs(1800));
3739 assert!(config.test_before_acquire);
3740 }
3741
3742 #[test]
3743 fn test_sqlite_optimizations_default() {
3744 let opts = SqliteOptimizations::default();
3745 assert!(opts.enable_wal_mode);
3746 assert_eq!(opts.cache_size, -20000);
3747 assert_eq!(opts.synchronous_mode, "NORMAL".to_string());
3748 assert_eq!(opts.temp_store, "MEMORY".to_string());
3749 assert_eq!(opts.journal_mode, "WAL".to_string());
3750 assert_eq!(opts.mmap_size, 268_435_456);
3751 assert!(opts.enable_foreign_keys);
3752 assert!(opts.enable_query_planner);
3753 }
3754
3755 #[test]
3756 fn test_pool_health_status_creation() {
3757 let status = PoolHealthStatus {
3758 is_healthy: true,
3759 pool_size: 8,
3760 active_connections: 5,
3761 idle_connections: 3,
3762 max_connections: 10,
3763 min_connections: 1,
3764 connection_timeout: Duration::from_secs(30),
3765 idle_timeout: Some(Duration::from_secs(600)),
3766 max_lifetime: Some(Duration::from_secs(1800)),
3767 };
3768 assert!(status.is_healthy);
3769 assert_eq!(status.active_connections, 5);
3770 assert_eq!(status.idle_connections, 3);
3771 assert_eq!(status.pool_size, 8);
3772 }
3773
3774 #[test]
3775 fn test_pool_metrics_creation() {
3776 let metrics = PoolMetrics {
3777 pool_size: 8,
3778 active_connections: 5,
3779 idle_connections: 3,
3780 max_connections: 10,
3781 min_connections: 1,
3782 utilization_percentage: 80.0,
3783 is_healthy: true,
3784 response_time_ms: 50,
3785 connection_timeout: Duration::from_secs(30),
3786 idle_timeout: Some(Duration::from_secs(600)),
3787 max_lifetime: Some(Duration::from_secs(1800)),
3788 };
3789 assert!(metrics.is_healthy);
3790 assert_eq!(metrics.pool_size, 8);
3791 assert_eq!(metrics.active_connections, 5);
3792 assert_eq!(metrics.idle_connections, 3);
3793 assert!((metrics.utilization_percentage - 80.0).abs() < f64::EPSILON);
3794 assert_eq!(metrics.response_time_ms, 50);
3795 }
3796
3797 #[test]
3798 fn test_comprehensive_health_status_creation() {
3799 let pool_health = PoolHealthStatus {
3800 is_healthy: true,
3801 pool_size: 8,
3802 active_connections: 5,
3803 idle_connections: 3,
3804 max_connections: 10,
3805 min_connections: 1,
3806 connection_timeout: Duration::from_secs(30),
3807 idle_timeout: Some(Duration::from_secs(600)),
3808 max_lifetime: Some(Duration::from_secs(1800)),
3809 };
3810
3811 let pool_metrics = PoolMetrics {
3812 pool_size: 8,
3813 active_connections: 5,
3814 idle_connections: 3,
3815 max_connections: 10,
3816 min_connections: 1,
3817 utilization_percentage: 80.0,
3818 is_healthy: true,
3819 response_time_ms: 50,
3820 connection_timeout: Duration::from_secs(30),
3821 idle_timeout: Some(Duration::from_secs(600)),
3822 max_lifetime: Some(Duration::from_secs(1800)),
3823 };
3824
3825 let db_stats = DatabaseStats {
3826 task_count: 50,
3827 project_count: 10,
3828 area_count: 5,
3829 };
3830
3831 let health_status = ComprehensiveHealthStatus {
3832 overall_healthy: true,
3833 pool_health,
3834 pool_metrics,
3835 database_stats: db_stats,
3836 timestamp: Utc::now(),
3837 };
3838
3839 assert!(health_status.overall_healthy);
3840 assert_eq!(health_status.database_stats.total_items(), 65);
3841 }
3842
3843 #[test]
3844 fn test_safe_timestamp_convert_edge_cases() {
3845 assert_eq!(safe_timestamp_convert(1_609_459_200.0), 1_609_459_200); assert_eq!(safe_timestamp_convert(0.0), 0);
3850
3851 assert_eq!(safe_timestamp_convert(-1.0), 0);
3853
3854 assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
3856
3857 assert_eq!(safe_timestamp_convert(f64::NAN), 0);
3859
3860 assert_eq!(safe_timestamp_convert(5_000_000_000.0), 0);
3862
3863 let max_timestamp = 4_102_444_800_f64; assert_eq!(safe_timestamp_convert(max_timestamp), 4_102_444_800);
3866 }
3867
3868 #[test]
3869 fn test_things_uuid_to_uuid_consistency() {
3870 let things_id = "test-id-123";
3872 let uuid1 = things_uuid_to_uuid(things_id);
3873 let uuid2 = things_uuid_to_uuid(things_id);
3874 assert_eq!(uuid1, uuid2, "UUIDs should be consistent for same input");
3875
3876 let uuid3 = things_uuid_to_uuid("different-id");
3878 assert_ne!(
3879 uuid1, uuid3,
3880 "Different inputs should produce different UUIDs"
3881 );
3882
3883 let uuid_empty = things_uuid_to_uuid("");
3885 assert!(!uuid_empty.to_string().is_empty());
3886
3887 let long_string = "a".repeat(1000);
3889 let uuid_long = things_uuid_to_uuid(&long_string);
3890 assert!(!uuid_long.to_string().is_empty());
3891 }
3892
3893 #[test]
3894 fn test_task_status_from_i32_all_variants() {
3895 assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
3896 assert_eq!(TaskStatus::from_i32(1), Some(TaskStatus::Completed));
3897 assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
3898 assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Trashed));
3899 assert_eq!(TaskStatus::from_i32(999), None);
3900 assert_eq!(TaskStatus::from_i32(-1), None);
3901 }
3902
3903 #[test]
3904 fn test_task_type_from_i32_all_variants() {
3905 assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
3906 assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
3907 assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
3908 assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
3909 assert_eq!(TaskType::from_i32(999), None);
3910 assert_eq!(TaskType::from_i32(-1), None);
3911 }
3912
3913 #[test]
3914 fn test_database_pool_config_default_values() {
3915 let config = DatabasePoolConfig::default();
3916 assert_eq!(config.max_connections, 10);
3917 assert_eq!(config.min_connections, 1);
3918 assert_eq!(config.connect_timeout, Duration::from_secs(30));
3919 assert_eq!(config.idle_timeout, Duration::from_secs(600));
3920 assert_eq!(config.max_lifetime, Duration::from_secs(1800));
3921 assert!(config.test_before_acquire);
3922 }
3923
3924 #[test]
3925 fn test_database_stats_total_items_calculation() {
3926 let stats = DatabaseStats {
3927 task_count: 10,
3928 project_count: 5,
3929 area_count: 3,
3930 };
3931 assert_eq!(stats.total_items(), 18); let empty_stats = DatabaseStats {
3935 task_count: 0,
3936 project_count: 0,
3937 area_count: 0,
3938 };
3939 assert_eq!(empty_stats.total_items(), 0);
3940 }
3941
3942 #[test]
3943 fn test_pool_health_status_creation_comprehensive() {
3944 let status = PoolHealthStatus {
3945 is_healthy: true,
3946 pool_size: 8,
3947 active_connections: 2,
3948 idle_connections: 3,
3949 max_connections: 10,
3950 min_connections: 1,
3951 connection_timeout: Duration::from_secs(30),
3952 idle_timeout: Some(Duration::from_secs(600)),
3953 max_lifetime: Some(Duration::from_secs(1800)),
3954 };
3955 assert!(status.is_healthy);
3956 assert_eq!(status.pool_size, 8);
3957 assert_eq!(status.max_connections, 10);
3958 }
3959
3960 #[test]
3961 fn test_pool_metrics_creation_comprehensive() {
3962 let metrics = PoolMetrics {
3963 pool_size: 8,
3964 active_connections: 5,
3965 idle_connections: 3,
3966 max_connections: 10,
3967 min_connections: 1,
3968 utilization_percentage: 80.0,
3969 is_healthy: true,
3970 response_time_ms: 50,
3971 connection_timeout: Duration::from_secs(30),
3972 idle_timeout: Some(Duration::from_secs(600)),
3973 max_lifetime: Some(Duration::from_secs(1800)),
3974 };
3975 assert_eq!(metrics.pool_size, 8);
3976 assert_eq!(metrics.response_time_ms, 50);
3977 assert!(metrics.is_healthy);
3978 }
3979
3980 #[test]
3981 fn test_comprehensive_health_status_creation_full() {
3982 let pool_health = PoolHealthStatus {
3983 is_healthy: true,
3984 pool_size: 8,
3985 active_connections: 2,
3986 idle_connections: 3,
3987 max_connections: 10,
3988 min_connections: 1,
3989 connection_timeout: Duration::from_secs(30),
3990 idle_timeout: Some(Duration::from_secs(600)),
3991 max_lifetime: Some(Duration::from_secs(1800)),
3992 };
3993
3994 let pool_metrics = PoolMetrics {
3995 pool_size: 8,
3996 active_connections: 5,
3997 idle_connections: 3,
3998 max_connections: 10,
3999 min_connections: 1,
4000 utilization_percentage: 80.0,
4001 is_healthy: true,
4002 response_time_ms: 50,
4003 connection_timeout: Duration::from_secs(30),
4004 idle_timeout: Some(Duration::from_secs(600)),
4005 max_lifetime: Some(Duration::from_secs(1800)),
4006 };
4007
4008 let database_stats = DatabaseStats {
4009 task_count: 100,
4010 project_count: 20,
4011 area_count: 5,
4012 };
4013
4014 let status = ComprehensiveHealthStatus {
4015 overall_healthy: true,
4016 pool_health,
4017 pool_metrics,
4018 database_stats,
4019 timestamp: Utc::now(),
4020 };
4021
4022 assert!(status.overall_healthy);
4023 assert_eq!(status.database_stats.total_items(), 125);
4024 }
4025
4026 #[test]
4027 fn test_sqlite_optimizations_default_values() {
4028 let opts = SqliteOptimizations::default();
4029 assert!(opts.enable_wal_mode);
4030 assert!(opts.enable_foreign_keys);
4031 assert_eq!(opts.cache_size, -20000);
4032 assert_eq!(opts.temp_store, "MEMORY");
4033 assert_eq!(opts.mmap_size, 268_435_456);
4034 assert_eq!(opts.synchronous_mode, "NORMAL");
4035 assert_eq!(opts.journal_mode, "WAL");
4036 }
4037
4038 #[test]
4039 fn test_get_default_database_path_format() {
4040 let path = get_default_database_path();
4041 let path_str = path.to_string_lossy();
4042 assert!(path_str.contains("Things Database.thingsdatabase"));
4043 assert!(path_str.contains("main.sqlite"));
4044 assert!(path_str.contains("Library/Group Containers"));
4045 }
4046
4047 #[tokio::test]
4048 async fn test_database_new_with_config() {
4049 let temp_file = NamedTempFile::new().unwrap();
4050 let db_path = temp_file.path();
4051
4052 crate::test_utils::create_test_database(db_path)
4053 .await
4054 .unwrap();
4055
4056 let config = DatabasePoolConfig {
4057 max_connections: 5,
4058 min_connections: 1,
4059 connect_timeout: Duration::from_secs(10),
4060 idle_timeout: Duration::from_secs(300),
4061 max_lifetime: Duration::from_secs(900),
4062 test_before_acquire: true,
4063 sqlite_optimizations: SqliteOptimizations::default(),
4064 };
4065
4066 let database = ThingsDatabase::new_with_config(db_path, config)
4067 .await
4068 .unwrap();
4069 let pool = database.pool();
4070 assert!(!pool.is_closed());
4071 }
4072
4073 #[tokio::test]
4074 async fn test_database_error_handling_invalid_path() {
4075 let result = ThingsDatabase::new(Path::new("/non/existent/path.db")).await;
4077 assert!(result.is_err(), "Should fail with non-existent path");
4078 }
4079
4080 #[tokio::test]
4081 async fn test_database_get_stats() {
4082 let temp_file = NamedTempFile::new().unwrap();
4083 let db_path = temp_file.path();
4084
4085 crate::test_utils::create_test_database(db_path)
4086 .await
4087 .unwrap();
4088 let database = ThingsDatabase::new(db_path).await.unwrap();
4089
4090 let stats = database.get_stats().await.unwrap();
4091 assert!(stats.task_count > 0, "Should have test tasks");
4092 assert!(stats.area_count > 0, "Should have test areas");
4093 assert!(stats.total_items() > 0, "Should have total items");
4094 }
4095
4096 #[tokio::test]
4097 async fn test_database_comprehensive_health_check() {
4098 let temp_file = NamedTempFile::new().unwrap();
4099 let db_path = temp_file.path();
4100
4101 crate::test_utils::create_test_database(db_path)
4102 .await
4103 .unwrap();
4104 let database = ThingsDatabase::new(db_path).await.unwrap();
4105
4106 let health = database.comprehensive_health_check().await.unwrap();
4107 assert!(health.overall_healthy, "Database should be healthy");
4108 assert!(health.pool_health.is_healthy, "Pool should be healthy");
4109 assert!(
4110 health.pool_metrics.is_healthy,
4111 "Pool metrics should be healthy"
4112 );
4113 }
4114
4115 #[test]
4120 fn test_things_date_negative_returns_none() {
4121 assert_eq!(things_date_to_naive_date(-1), None);
4123 assert_eq!(things_date_to_naive_date(-100), None);
4124 assert_eq!(things_date_to_naive_date(i64::MIN), None);
4125 }
4126
4127 #[test]
4128 fn test_things_date_zero_returns_none() {
4129 assert_eq!(things_date_to_naive_date(0), None);
4131 }
4132
4133 #[test]
4134 fn test_things_date_boundary_2001() {
4135 use chrono::Datelike;
4136 let result = things_date_to_naive_date(1);
4138 assert!(result.is_some());
4139
4140 let date = result.unwrap();
4141 assert_eq!(date.year(), 2001);
4142 assert_eq!(date.month(), 1);
4143 assert_eq!(date.day(), 1);
4144 }
4145
4146 #[test]
4147 fn test_things_date_one_day() {
4148 use chrono::Datelike;
4149 let seconds_per_day = 86400i64;
4151 let result = things_date_to_naive_date(seconds_per_day);
4152 assert!(result.is_some());
4153
4154 let date = result.unwrap();
4155 assert_eq!(date.year(), 2001);
4156 assert_eq!(date.month(), 1);
4157 assert_eq!(date.day(), 2);
4158 }
4159
4160 #[test]
4161 fn test_things_date_one_year() {
4162 use chrono::Datelike;
4163 let seconds_per_year = 365 * 86400i64;
4165 let result = things_date_to_naive_date(seconds_per_year);
4166 assert!(result.is_some());
4167
4168 let date = result.unwrap();
4169 assert_eq!(date.year(), 2002);
4170 }
4171
4172 #[test]
4173 fn test_things_date_current_era() {
4174 use chrono::Datelike;
4175 let days_to_2024 = 8401i64;
4179 let seconds_to_2024 = days_to_2024 * 86400;
4180
4181 let result = things_date_to_naive_date(seconds_to_2024);
4182 assert!(result.is_some());
4183
4184 let date = result.unwrap();
4185 assert_eq!(date.year(), 2024);
4186 }
4187
4188 #[test]
4189 fn test_things_date_leap_year() {
4190 use chrono::{Datelike, TimeZone, Utc};
4191 let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
4194 let target_date = Utc.with_ymd_and_hms(2004, 2, 29, 0, 0, 0).single().unwrap();
4195 let seconds_diff = (target_date - base_date).num_seconds();
4196
4197 let result = things_date_to_naive_date(seconds_diff);
4198 assert!(result.is_some());
4199
4200 let date = result.unwrap();
4201 assert_eq!(date.year(), 2004);
4202 assert_eq!(date.month(), 2);
4203 assert_eq!(date.day(), 29);
4204 }
4205
4206 #[test]
4211 fn test_uuid_conversion_consistency() {
4212 let input = "ABC123";
4214 let uuid1 = things_uuid_to_uuid(input);
4215 let uuid2 = things_uuid_to_uuid(input);
4216
4217 assert_eq!(uuid1, uuid2);
4218 }
4219
4220 #[test]
4221 fn test_uuid_conversion_uniqueness() {
4222 let uuid1 = things_uuid_to_uuid("ABC123");
4224 let uuid2 = things_uuid_to_uuid("ABC124");
4225 let uuid3 = things_uuid_to_uuid("XYZ789");
4226
4227 assert_ne!(uuid1, uuid2);
4228 assert_ne!(uuid1, uuid3);
4229 assert_ne!(uuid2, uuid3);
4230 }
4231
4232 #[test]
4233 fn test_uuid_conversion_empty_string() {
4234 let uuid = things_uuid_to_uuid("");
4236 assert!(!uuid.to_string().is_empty());
4237 }
4238
4239 #[test]
4240 fn test_uuid_conversion_special_characters() {
4241 let uuid1 = things_uuid_to_uuid("test-with-dashes");
4243 let uuid2 = things_uuid_to_uuid("test_with_underscores");
4244 let uuid3 = things_uuid_to_uuid("test.with.dots");
4245
4246 assert_ne!(uuid1, uuid2);
4248 assert_ne!(uuid1, uuid3);
4249 assert_ne!(uuid2, uuid3);
4250 }
4251
4252 #[test]
4257 fn test_safe_timestamp_convert_normal_values() {
4258 let ts = 1_700_000_000.0; let result = safe_timestamp_convert(ts);
4261 assert_eq!(result, 1_700_000_000);
4262 }
4263
4264 #[test]
4265 fn test_safe_timestamp_convert_zero() {
4266 assert_eq!(safe_timestamp_convert(0.0), 0);
4268 }
4269
4270 #[test]
4271 fn test_safe_timestamp_convert_negative() {
4272 assert_eq!(safe_timestamp_convert(-1.0), 0);
4274 assert_eq!(safe_timestamp_convert(-1000.0), 0);
4275 }
4276
4277 #[test]
4278 fn test_safe_timestamp_convert_infinity() {
4279 assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
4281 assert_eq!(safe_timestamp_convert(f64::NEG_INFINITY), 0);
4282 }
4283
4284 #[test]
4285 fn test_safe_timestamp_convert_nan() {
4286 assert_eq!(safe_timestamp_convert(f64::NAN), 0);
4288 }
4289
4290 #[test]
4291 fn test_date_roundtrip_known_dates() {
4292 use chrono::{Datelike, TimeZone, Utc};
4293 let test_cases = vec![
4297 (2001, 1, 2), (2010, 6, 15),
4299 (2020, 12, 31),
4300 (2024, 2, 29), (2025, 7, 4),
4302 ];
4303
4304 for (year, month, day) in test_cases {
4305 let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
4306 let target_date = Utc
4307 .with_ymd_and_hms(year, month, day, 0, 0, 0)
4308 .single()
4309 .unwrap();
4310 let seconds = (target_date - base_date).num_seconds();
4311
4312 let converted = things_date_to_naive_date(seconds);
4313 assert!(
4314 converted.is_some(),
4315 "Failed to convert {}-{:02}-{:02}",
4316 year,
4317 month,
4318 day
4319 );
4320
4321 let result_date = converted.unwrap();
4322 assert_eq!(
4323 result_date.year(),
4324 year,
4325 "Year mismatch for {}-{:02}-{:02}",
4326 year,
4327 month,
4328 day
4329 );
4330 assert_eq!(
4331 result_date.month(),
4332 month,
4333 "Month mismatch for {}-{:02}-{:02}",
4334 year,
4335 month,
4336 day
4337 );
4338 assert_eq!(
4339 result_date.day(),
4340 day,
4341 "Day mismatch for {}-{:02}-{:02}",
4342 year,
4343 month,
4344 day
4345 );
4346 }
4347 }
4348}