Skip to main content

things3_core/database/
core.rs

1#[cfg(feature = "advanced-queries")]
2use crate::models::TaskFilters;
3use crate::{
4    database::{mappers::map_task_row, query_builders::TaskUpdateBuilder, validators},
5    error::{Result as ThingsResult, ThingsError},
6    models::{
7        Area, CreateTaskRequest, DeleteChildHandling, Project, Task, TaskStatus, TaskType,
8        UpdateTaskRequest,
9    },
10};
11use chrono::{DateTime, NaiveDate, Utc};
12use serde::{Deserialize, Serialize};
13use sqlx::{pool::PoolOptions, Row, SqlitePool};
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16use tracing::{debug, error, info, instrument};
17use uuid::Uuid;
18
19/// Convert f64 timestamp to i64 safely
20pub(crate) fn safe_timestamp_convert(ts_f64: f64) -> i64 {
21    // Use try_from to avoid clippy warnings about casting
22    if ts_f64.is_finite() && ts_f64 >= 0.0 {
23        // Use a reasonable upper bound for timestamps (year 2100)
24        let max_timestamp = 4_102_444_800_f64; // 2100-01-01 00:00:00 UTC
25        if ts_f64 <= max_timestamp {
26            // Convert via string to avoid precision loss warnings
27            let ts_str = format!("{:.0}", ts_f64.trunc());
28            ts_str.parse::<i64>().unwrap_or(0)
29        } else {
30            0 // Use epoch if too large
31        }
32    } else {
33        0 // Use epoch if invalid
34    }
35}
36
37/// Convert Things 3 date value (seconds since 2001-01-01) to NaiveDate
38pub(crate) fn things_date_to_naive_date(seconds_since_2001: i64) -> Option<chrono::NaiveDate> {
39    use chrono::{TimeZone, Utc};
40
41    if seconds_since_2001 <= 0 {
42        return None;
43    }
44
45    // Base date: 2001-01-01 00:00:00 UTC
46    let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
47
48    // Add seconds to get the actual date
49    let date_time = base_date + chrono::Duration::seconds(seconds_since_2001);
50
51    Some(date_time.date_naive())
52}
53
54/// Convert NaiveDate to Things 3 timestamp (seconds since 2001-01-01)
55pub fn naive_date_to_things_timestamp(date: NaiveDate) -> i64 {
56    use chrono::{NaiveTime, TimeZone, Utc};
57
58    // Base date: 2001-01-01 00:00:00 UTC
59    let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
60
61    // Convert NaiveDate to DateTime at midnight UTC
62    let date_time = date
63        .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
64        .and_local_timezone(Utc)
65        .single()
66        .unwrap();
67
68    // Calculate seconds difference
69    date_time.timestamp() - base_date.timestamp()
70}
71
72/// Serialize tags to Things 3 binary format
73/// Note: This is a simplified implementation using JSON
74/// The actual Things 3 binary format is proprietary
75pub fn serialize_tags_to_blob(tags: &[String]) -> ThingsResult<Vec<u8>> {
76    serde_json::to_vec(tags)
77        .map_err(|e| ThingsError::unknown(format!("Failed to serialize tags: {e}")))
78}
79
80/// Deserialize tags from Things 3 binary format
81pub fn deserialize_tags_from_blob(blob: &[u8]) -> ThingsResult<Vec<String>> {
82    serde_json::from_slice(blob)
83        .map_err(|e| ThingsError::unknown(format!("Failed to deserialize tags: {e}")))
84}
85
86/// Convert Things 3 UUID format to standard UUID
87/// Things 3 uses base64-like strings, we'll generate a UUID from the hash
88pub(crate) fn things_uuid_to_uuid(things_uuid: &str) -> Uuid {
89    // For now, create a deterministic UUID from the Things 3 ID
90    // This ensures consistent mapping between Things 3 IDs and UUIDs
91    use std::collections::hash_map::DefaultHasher;
92    use std::hash::{Hash, Hasher};
93
94    let mut hasher = DefaultHasher::new();
95    things_uuid.hash(&mut hasher);
96    let hash = hasher.finish();
97
98    // Create a UUID from the hash (not cryptographically secure, but consistent)
99    // Use proper byte extraction without truncation warnings
100    let bytes = [
101        ((hash >> 56) & 0xFF) as u8,
102        ((hash >> 48) & 0xFF) as u8,
103        ((hash >> 40) & 0xFF) as u8,
104        ((hash >> 32) & 0xFF) as u8,
105        ((hash >> 24) & 0xFF) as u8,
106        ((hash >> 16) & 0xFF) as u8,
107        ((hash >> 8) & 0xFF) as u8,
108        (hash & 0xFF) as u8,
109        // Fill remaining bytes with a pattern based on the string
110        u8::try_from(things_uuid.len().min(255)).unwrap_or(255),
111        things_uuid.chars().next().unwrap_or('0') as u8,
112        things_uuid.chars().nth(1).unwrap_or('0') as u8,
113        things_uuid.chars().nth(2).unwrap_or('0') as u8,
114        things_uuid.chars().nth(3).unwrap_or('0') as u8,
115        things_uuid.chars().nth(4).unwrap_or('0') as u8,
116        things_uuid.chars().nth(5).unwrap_or('0') as u8,
117        things_uuid.chars().nth(6).unwrap_or('0') as u8,
118    ];
119
120    Uuid::from_bytes(bytes)
121}
122
123impl TaskStatus {
124    fn from_i32(value: i32) -> Option<Self> {
125        match value {
126            0 => Some(TaskStatus::Incomplete),
127            1 => Some(TaskStatus::Completed),
128            2 => Some(TaskStatus::Canceled),
129            3 => Some(TaskStatus::Trashed),
130            _ => None,
131        }
132    }
133}
134
135impl TaskType {
136    fn from_i32(value: i32) -> Option<Self> {
137        match value {
138            0 => Some(TaskType::Todo),
139            1 => Some(TaskType::Project),
140            2 => Some(TaskType::Heading),
141            3 => Some(TaskType::Area),
142            _ => None,
143        }
144    }
145}
146
147/// Database connection pool configuration for optimal performance
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct DatabasePoolConfig {
150    /// Maximum number of connections in the pool
151    pub max_connections: u32,
152    /// Minimum number of connections in the pool
153    pub min_connections: u32,
154    /// Connection timeout
155    pub connect_timeout: Duration,
156    /// Idle timeout for connections
157    pub idle_timeout: Duration,
158    /// Maximum lifetime of a connection
159    pub max_lifetime: Duration,
160    /// Test connections before use
161    pub test_before_acquire: bool,
162    /// SQLite-specific optimizations
163    pub sqlite_optimizations: SqliteOptimizations,
164}
165
166/// SQLite-specific optimization settings
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SqliteOptimizations {
169    /// Enable WAL mode for better concurrency
170    pub enable_wal_mode: bool,
171    /// Set synchronous mode (NORMAL, FULL, OFF)
172    pub synchronous_mode: String,
173    /// Cache size in pages (negative = KB)
174    pub cache_size: i32,
175    /// Enable foreign key constraints
176    pub enable_foreign_keys: bool,
177    /// Set journal mode
178    pub journal_mode: String,
179    /// Set temp store (MEMORY, FILE, DEFAULT)
180    pub temp_store: String,
181    /// Set mmap size for better performance
182    pub mmap_size: i64,
183    /// Enable query planner optimizations
184    pub enable_query_planner: bool,
185}
186
187impl Default for DatabasePoolConfig {
188    fn default() -> Self {
189        Self {
190            max_connections: 10,
191            min_connections: 1,
192            connect_timeout: Duration::from_secs(30),
193            idle_timeout: Duration::from_secs(600), // 10 minutes
194            max_lifetime: Duration::from_secs(1800), // 30 minutes
195            test_before_acquire: true,
196            sqlite_optimizations: SqliteOptimizations::default(),
197        }
198    }
199}
200
201impl Default for SqliteOptimizations {
202    fn default() -> Self {
203        Self {
204            enable_wal_mode: true,
205            synchronous_mode: "NORMAL".to_string(),
206            cache_size: -20000, // 20MB cache
207            enable_foreign_keys: true,
208            journal_mode: "WAL".to_string(),
209            temp_store: "MEMORY".to_string(),
210            mmap_size: 268_435_456, // 256MB
211            enable_query_planner: true,
212        }
213    }
214}
215
216/// Connection pool health status
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct PoolHealthStatus {
219    pub is_healthy: bool,
220    pub pool_size: u32,
221    pub active_connections: u32,
222    pub idle_connections: u32,
223    pub max_connections: u32,
224    pub min_connections: u32,
225    pub connection_timeout: Duration,
226    pub idle_timeout: Option<Duration>,
227    pub max_lifetime: Option<Duration>,
228}
229
230/// Detailed connection pool metrics
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct PoolMetrics {
233    pub pool_size: u32,
234    pub active_connections: u32,
235    pub idle_connections: u32,
236    pub max_connections: u32,
237    pub min_connections: u32,
238    pub utilization_percentage: f64,
239    pub is_healthy: bool,
240    pub response_time_ms: u64,
241    pub connection_timeout: Duration,
242    pub idle_timeout: Option<Duration>,
243    pub max_lifetime: Option<Duration>,
244}
245
246/// Comprehensive health status including pool and database
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct ComprehensiveHealthStatus {
249    pub overall_healthy: bool,
250    pub pool_health: PoolHealthStatus,
251    pub pool_metrics: PoolMetrics,
252    pub database_stats: DatabaseStats,
253    pub timestamp: DateTime<Utc>,
254}
255
256/// SQLx-based database implementation for Things 3 data
257/// This provides async, Send + Sync compatible database access
258#[derive(Debug, Clone)]
259pub struct ThingsDatabase {
260    pool: SqlitePool,
261    config: DatabasePoolConfig,
262}
263
264impl ThingsDatabase {
265    /// Create a new database connection pool with default configuration
266    ///
267    /// # Examples
268    ///
269    /// ```no_run
270    /// use things3_core::{ThingsDatabase, ThingsError};
271    /// use std::path::Path;
272    ///
273    /// # async fn example() -> Result<(), ThingsError> {
274    /// // Connect to Things 3 database
275    /// let db = ThingsDatabase::new(Path::new("/path/to/things.db")).await?;
276    ///
277    /// // Get inbox tasks
278    /// let tasks = db.get_inbox(None).await?;
279    /// println!("Found {} tasks in inbox", tasks.len());
280    /// # Ok(())
281    /// # }
282    /// ```
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the database connection fails or if `SQLite` configuration fails
287    #[instrument]
288    pub async fn new(database_path: &Path) -> ThingsResult<Self> {
289        Self::new_with_config(database_path, DatabasePoolConfig::default()).await
290    }
291
292    /// Create a new database connection pool with custom configuration
293    ///
294    /// # Examples
295    ///
296    /// ```no_run
297    /// use things3_core::{ThingsDatabase, DatabasePoolConfig, ThingsError};
298    /// use std::path::Path;
299    /// use std::time::Duration;
300    ///
301    /// # async fn example() -> Result<(), ThingsError> {
302    /// // Create custom pool configuration
303    /// let config = DatabasePoolConfig {
304    ///     max_connections: 10,
305    ///     min_connections: 2,
306    ///     connect_timeout: Duration::from_secs(5),
307    ///     idle_timeout: Duration::from_secs(300),
308    ///     max_lifetime: Duration::from_secs(3600),
309    ///     test_before_acquire: true,
310    ///     sqlite_optimizations: Default::default(),
311    /// };
312    ///
313    /// // Connect with custom configuration
314    /// let db = ThingsDatabase::new_with_config(
315    ///     Path::new("/path/to/things.db"),
316    ///     config,
317    /// ).await?;
318    /// # Ok(())
319    /// # }
320    /// ```
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if the database connection fails or if `SQLite` configuration fails
325    #[instrument]
326    pub async fn new_with_config(
327        database_path: &Path,
328        config: DatabasePoolConfig,
329    ) -> ThingsResult<Self> {
330        let database_url = format!("sqlite:{}", database_path.display());
331
332        info!(
333            "Connecting to SQLite database at: {} with optimized pool",
334            database_url
335        );
336
337        // Create optimized connection pool
338        let pool = PoolOptions::new()
339            .max_connections(config.max_connections)
340            .min_connections(config.min_connections)
341            .acquire_timeout(config.connect_timeout)
342            .idle_timeout(Some(config.idle_timeout))
343            .max_lifetime(Some(config.max_lifetime))
344            .test_before_acquire(config.test_before_acquire)
345            .connect(&database_url)
346            .await
347            .map_err(|e| ThingsError::unknown(format!("Failed to connect to database: {e}")))?;
348
349        // Apply SQLite optimizations
350        Self::apply_sqlite_optimizations(&pool, &config.sqlite_optimizations).await?;
351
352        info!(
353            "Database connection pool established successfully with {} max connections",
354            config.max_connections
355        );
356
357        Ok(Self { pool, config })
358    }
359
360    /// Apply SQLite-specific optimizations
361    async fn apply_sqlite_optimizations(
362        pool: &SqlitePool,
363        optimizations: &SqliteOptimizations,
364    ) -> ThingsResult<()> {
365        // Set journal mode
366        sqlx::query(&format!(
367            "PRAGMA journal_mode = {}",
368            optimizations.journal_mode
369        ))
370        .execute(pool)
371        .await
372        .map_err(|e| ThingsError::unknown(format!("Failed to set journal mode: {e}")))?;
373
374        // Set synchronous mode
375        sqlx::query(&format!(
376            "PRAGMA synchronous = {}",
377            optimizations.synchronous_mode
378        ))
379        .execute(pool)
380        .await
381        .map_err(|e| ThingsError::unknown(format!("Failed to set synchronous mode: {e}")))?;
382
383        // Set cache size
384        sqlx::query(&format!("PRAGMA cache_size = {}", optimizations.cache_size))
385            .execute(pool)
386            .await
387            .map_err(|e| ThingsError::unknown(format!("Failed to set cache size: {e}")))?;
388
389        // Set foreign keys
390        let fk_setting = if optimizations.enable_foreign_keys {
391            "ON"
392        } else {
393            "OFF"
394        };
395        sqlx::query(&format!("PRAGMA foreign_keys = {fk_setting}"))
396            .execute(pool)
397            .await
398            .map_err(|e| ThingsError::unknown(format!("Failed to set foreign keys: {e}")))?;
399
400        // Set temp store
401        sqlx::query(&format!("PRAGMA temp_store = {}", optimizations.temp_store))
402            .execute(pool)
403            .await
404            .map_err(|e| ThingsError::unknown(format!("Failed to set temp store: {e}")))?;
405
406        // Set mmap size
407        sqlx::query(&format!("PRAGMA mmap_size = {}", optimizations.mmap_size))
408            .execute(pool)
409            .await
410            .map_err(|e| ThingsError::unknown(format!("Failed to set mmap size: {e}")))?;
411
412        // Enable query planner optimizations
413        if optimizations.enable_query_planner {
414            sqlx::query("PRAGMA optimize")
415                .execute(pool)
416                .await
417                .map_err(|e| ThingsError::unknown(format!("Failed to optimize database: {e}")))?;
418        }
419
420        debug!(
421            "Applied SQLite optimizations: WAL={}, sync={}, cache={}KB, fk={}, temp={}, mmap={}MB",
422            optimizations.enable_wal_mode,
423            optimizations.synchronous_mode,
424            optimizations.cache_size.abs() / 1024,
425            optimizations.enable_foreign_keys,
426            optimizations.temp_store,
427            optimizations.mmap_size / 1024 / 1024
428        );
429
430        Ok(())
431    }
432
433    /// Create a new database connection pool from a connection string with default configuration
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the database connection fails or if `SQLite` configuration fails
438    #[instrument]
439    pub async fn from_connection_string(database_url: &str) -> ThingsResult<Self> {
440        Self::from_connection_string_with_config(database_url, DatabasePoolConfig::default()).await
441    }
442
443    /// Create a new database connection pool from a connection string with custom configuration
444    ///
445    /// # Errors
446    ///
447    /// Returns an error if the database connection fails or if `SQLite` configuration fails
448    #[instrument]
449    pub async fn from_connection_string_with_config(
450        database_url: &str,
451        config: DatabasePoolConfig,
452    ) -> ThingsResult<Self> {
453        info!(
454            "Connecting to SQLite database: {} with optimized pool",
455            database_url
456        );
457
458        // Create optimized connection pool
459        let pool = PoolOptions::new()
460            .max_connections(config.max_connections)
461            .min_connections(config.min_connections)
462            .acquire_timeout(config.connect_timeout)
463            .idle_timeout(Some(config.idle_timeout))
464            .max_lifetime(Some(config.max_lifetime))
465            .test_before_acquire(config.test_before_acquire)
466            .connect(database_url)
467            .await
468            .map_err(|e| ThingsError::unknown(format!("Failed to connect to database: {e}")))?;
469
470        // Apply SQLite optimizations
471        Self::apply_sqlite_optimizations(&pool, &config.sqlite_optimizations).await?;
472
473        info!(
474            "Database connection pool established successfully with {} max connections",
475            config.max_connections
476        );
477
478        Ok(Self { pool, config })
479    }
480
481    /// Get the underlying connection pool
482    #[must_use]
483    pub fn pool(&self) -> &SqlitePool {
484        &self.pool
485    }
486
487    /// Check if the database is connected
488    #[instrument]
489    pub async fn is_connected(&self) -> bool {
490        match sqlx::query("SELECT 1").fetch_one(&self.pool).await {
491            Ok(_) => {
492                debug!("Database connection is healthy");
493                true
494            }
495            Err(e) => {
496                error!("Database connection check failed: {}", e);
497                false
498            }
499        }
500    }
501
502    /// Get connection pool health status
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if the health check fails
507    #[instrument]
508    pub async fn get_pool_health(&self) -> ThingsResult<PoolHealthStatus> {
509        let pool_size = self.pool.size();
510        let idle_connections = self.pool.num_idle();
511        let active_connections = pool_size - u32::try_from(idle_connections).unwrap_or(0);
512
513        // Test a simple query to verify connection health
514        let is_healthy = self.is_connected().await;
515
516        Ok(PoolHealthStatus {
517            is_healthy,
518            pool_size,
519            active_connections,
520            idle_connections: u32::try_from(idle_connections).unwrap_or(0),
521            max_connections: self.config.max_connections,
522            min_connections: self.config.min_connections,
523            connection_timeout: self.config.connect_timeout,
524            idle_timeout: Some(self.config.idle_timeout),
525            max_lifetime: Some(self.config.max_lifetime),
526        })
527    }
528
529    /// Get detailed connection pool metrics
530    ///
531    /// # Errors
532    ///
533    /// Returns an error if the metrics collection fails
534    #[instrument]
535    pub async fn get_pool_metrics(&self) -> ThingsResult<PoolMetrics> {
536        let pool_size = self.pool.size();
537        let idle_connections = self.pool.num_idle();
538        let active_connections = pool_size - u32::try_from(idle_connections).unwrap_or(0);
539
540        // Calculate utilization percentage
541        let max_connections = self.config.max_connections;
542        let utilization_percentage = if max_connections > 0 {
543            (f64::from(active_connections) / f64::from(max_connections)) * 100.0
544        } else {
545            0.0
546        };
547
548        // Test connection response time
549        let start_time = std::time::Instant::now();
550        let is_connected = self.is_connected().await;
551        let response_time_ms = u64::try_from(start_time.elapsed().as_millis()).unwrap_or(0);
552
553        Ok(PoolMetrics {
554            pool_size,
555            active_connections,
556            idle_connections: u32::try_from(idle_connections).unwrap_or(0),
557            max_connections,
558            min_connections: self.config.min_connections,
559            utilization_percentage,
560            is_healthy: is_connected,
561            response_time_ms,
562            connection_timeout: self.config.connect_timeout,
563            idle_timeout: Some(self.config.idle_timeout),
564            max_lifetime: Some(self.config.max_lifetime),
565        })
566    }
567
568    /// Perform a comprehensive health check including pool and database
569    ///
570    /// # Errors
571    ///
572    /// Returns an error if the health check fails
573    #[instrument]
574    pub async fn comprehensive_health_check(&self) -> ThingsResult<ComprehensiveHealthStatus> {
575        let pool_health = self.get_pool_health().await?;
576        let pool_metrics = self.get_pool_metrics().await?;
577        let db_stats = self.get_stats().await?;
578
579        let overall_healthy = pool_health.is_healthy && pool_metrics.is_healthy;
580
581        Ok(ComprehensiveHealthStatus {
582            overall_healthy,
583            pool_health,
584            pool_metrics,
585            database_stats: db_stats,
586            timestamp: Utc::now(),
587        })
588    }
589
590    /// Get database statistics
591    ///
592    /// # Errors
593    ///
594    /// Returns an error if the database query fails
595    #[instrument]
596    pub async fn get_stats(&self) -> ThingsResult<DatabaseStats> {
597        let task_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMTask")
598            .fetch_one(&self.pool)
599            .await
600            .map_err(|e| ThingsError::unknown(format!("Failed to get task count: {e}")))?;
601
602        let project_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMTask WHERE type = 1")
603            .fetch_one(&self.pool)
604            .await
605            .map_err(|e| ThingsError::unknown(format!("Failed to get project count: {e}")))?;
606
607        let area_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMArea")
608            .fetch_one(&self.pool)
609            .await
610            .map_err(|e| ThingsError::unknown(format!("Failed to get area count: {e}")))?;
611
612        Ok(DatabaseStats {
613            task_count: task_count.try_into().unwrap_or(0),
614            project_count: project_count.try_into().unwrap_or(0),
615            area_count: area_count.try_into().unwrap_or(0),
616        })
617    }
618
619    /// Get all tasks from the database
620    ///
621    /// # Examples
622    ///
623    /// ```no_run
624    /// use things3_core::{ThingsDatabase, ThingsError};
625    /// use std::path::Path;
626    ///
627    /// # async fn example() -> Result<(), ThingsError> {
628    /// let db = ThingsDatabase::new(Path::new("/path/to/things.db")).await?;
629    ///
630    /// // Get all tasks
631    /// let tasks = db.get_all_tasks().await?;
632    /// println!("Found {} total tasks", tasks.len());
633    ///
634    /// // Filter tasks by status
635    /// let incomplete: Vec<_> = tasks.iter()
636    ///     .filter(|t| t.status == things3_core::TaskStatus::Incomplete)
637    ///     .collect();
638    /// println!("Found {} incomplete tasks", incomplete.len());
639    /// # Ok(())
640    /// # }
641    /// ```
642    ///
643    /// # Errors
644    ///
645    /// Returns an error if the database query fails or if task data is invalid
646    #[instrument]
647    pub async fn get_all_tasks(&self) -> ThingsResult<Vec<Task>> {
648        let rows = sqlx::query(
649            r"
650            SELECT 
651                uuid, title, status, type, 
652                start_date, due_date, 
653                project_uuid, area_uuid, 
654                notes, tags, 
655                created, modified
656            FROM TMTask
657            ORDER BY created DESC
658            ",
659        )
660        .fetch_all(&self.pool)
661        .await
662        .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks: {e}")))?;
663
664        let mut tasks = Vec::new();
665        for row in rows {
666            let task = Task {
667                uuid: Uuid::parse_str(&row.get::<String, _>("uuid"))
668                    .map_err(|e| ThingsError::unknown(format!("Invalid task UUID: {e}")))?,
669                title: row.get("title"),
670                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
671                task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
672                start_date: row
673                    .get::<Option<String>, _>("start_date")
674                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
675                deadline: row
676                    .get::<Option<String>, _>("due_date")
677                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
678                project_uuid: row
679                    .get::<Option<String>, _>("project_uuid")
680                    .and_then(|s| Uuid::parse_str(&s).ok()),
681                area_uuid: row
682                    .get::<Option<String>, _>("area_uuid")
683                    .and_then(|s| Uuid::parse_str(&s).ok()),
684                parent_uuid: None, // Not available in this query
685                notes: row.get("notes"),
686                tags: row
687                    .get::<Option<String>, _>("tags")
688                    .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
689                    .unwrap_or_default(),
690                children: Vec::new(), // Not available in this query
691                created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
692                    .ok()
693                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
694                modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
695                    .ok()
696                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
697                stop_date: None, // Not available in this query context
698            };
699            tasks.push(task);
700        }
701
702        debug!("Fetched {} tasks", tasks.len());
703        Ok(tasks)
704    }
705
706    /// Get all projects (from `TMTask` table where type = 1)
707    ///
708    /// # Errors
709    ///
710    /// Returns an error if the database query fails or if project data is invalid
711    #[instrument]
712    pub async fn get_all_projects(&self) -> ThingsResult<Vec<Project>> {
713        let rows = sqlx::query(
714            r"
715            SELECT 
716                uuid, title, status, 
717                area, notes, 
718                creationDate, userModificationDate,
719                startDate, deadline
720            FROM TMTask
721            WHERE type = 1 AND trashed = 0
722            ORDER BY creationDate DESC
723            ",
724        )
725        .fetch_all(&self.pool)
726        .await
727        .map_err(|e| ThingsError::unknown(format!("Failed to fetch projects: {e}")))?;
728
729        let mut projects = Vec::new();
730        for row in rows {
731            let project = Project {
732                uuid: things_uuid_to_uuid(&row.get::<String, _>("uuid")),
733                title: row.get("title"),
734                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
735                area_uuid: row
736                    .get::<Option<String>, _>("area")
737                    .map(|s| things_uuid_to_uuid(&s)),
738                notes: row.get("notes"),
739                deadline: row
740                    .get::<Option<i64>, _>("deadline")
741                    .and_then(|ts| DateTime::from_timestamp(ts, 0))
742                    .map(|dt| dt.date_naive()),
743                start_date: row
744                    .get::<Option<i64>, _>("startDate")
745                    .and_then(|ts| DateTime::from_timestamp(ts, 0))
746                    .map(|dt| dt.date_naive()),
747                tags: Vec::new(),  // TODO: Load tags separately
748                tasks: Vec::new(), // TODO: Load child tasks separately
749                created: {
750                    let ts_f64 = row.get::<f64, _>("creationDate");
751                    let ts = safe_timestamp_convert(ts_f64);
752                    DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
753                },
754                modified: {
755                    let ts_f64 = row.get::<f64, _>("userModificationDate");
756                    let ts = safe_timestamp_convert(ts_f64);
757                    DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
758                },
759            };
760            projects.push(project);
761        }
762
763        debug!("Fetched {} projects", projects.len());
764        Ok(projects)
765    }
766
767    /// Get all areas
768    ///
769    /// # Errors
770    ///
771    /// Returns an error if the database query fails or if area data is invalid
772    #[instrument]
773    pub async fn get_all_areas(&self) -> ThingsResult<Vec<Area>> {
774        // Get all areas, not just visible ones (MCP clients may want to see all)
775        let rows = sqlx::query(
776            r"
777            SELECT 
778                uuid, title, visible, `index`
779             FROM TMArea 
780            ORDER BY `index` ASC
781            ",
782        )
783        .fetch_all(&self.pool)
784        .await
785        .map_err(|e| ThingsError::unknown(format!("Failed to fetch areas: {e}")))?;
786
787        let mut areas = Vec::new();
788        for row in rows {
789            let uuid_str: String = row.get("uuid");
790            // Try standard UUID first, then fall back to Things UUID format
791            let uuid =
792                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
793
794            let area = Area {
795                uuid,
796                title: row.get("title"),
797                notes: None,          // Notes not stored in TMArea table
798                projects: Vec::new(), // TODO: Load projects separately
799                tags: Vec::new(),     // TODO: Load tags separately
800                created: Utc::now(),  // Creation date not available in TMArea
801                modified: Utc::now(), // Modification date not available in TMArea
802            };
803            areas.push(area);
804        }
805
806        debug!("Fetched {} areas", areas.len());
807        Ok(areas)
808    }
809
810    /// Get tasks by status
811    ///
812    /// # Errors
813    ///
814    /// Returns an error if the database query fails or if task data is invalid
815    #[instrument]
816    pub async fn get_tasks_by_status(&self, status: TaskStatus) -> ThingsResult<Vec<Task>> {
817        let status_value = status as i32;
818        let rows = sqlx::query(
819            r"
820            SELECT 
821                uuid, title, status, type, 
822                start_date, due_date, 
823                project_uuid, area_uuid, 
824                notes, tags, 
825                created, modified
826             FROM TMTask 
827            WHERE status = ?
828            ORDER BY created DESC
829            ",
830        )
831        .bind(status_value)
832        .fetch_all(&self.pool)
833        .await
834        .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks by status: {e}")))?;
835
836        let mut tasks = Vec::new();
837        for row in rows {
838            let task = Task {
839                uuid: Uuid::parse_str(&row.get::<String, _>("uuid"))
840                    .map_err(|e| ThingsError::unknown(format!("Invalid task UUID: {e}")))?,
841                title: row.get("title"),
842                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
843                task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
844                start_date: row
845                    .get::<Option<String>, _>("start_date")
846                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
847                deadline: row
848                    .get::<Option<String>, _>("due_date")
849                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
850                project_uuid: row
851                    .get::<Option<String>, _>("project_uuid")
852                    .and_then(|s| Uuid::parse_str(&s).ok()),
853                area_uuid: row
854                    .get::<Option<String>, _>("area_uuid")
855                    .and_then(|s| Uuid::parse_str(&s).ok()),
856                parent_uuid: None, // Not available in this query
857                notes: row.get("notes"),
858                tags: row
859                    .get::<Option<String>, _>("tags")
860                    .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
861                    .unwrap_or_default(),
862                children: Vec::new(), // Not available in this query
863                created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
864                    .ok()
865                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
866                modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
867                    .ok()
868                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
869                stop_date: None, // Not available in this query context
870            };
871            tasks.push(task);
872        }
873
874        debug!("Fetched {} tasks with status {:?}", tasks.len(), status);
875        Ok(tasks)
876    }
877
878    /// Search tasks by title or notes
879    ///
880    /// # Errors
881    ///
882    /// Returns an error if the database query fails or if task data is invalid
883    #[instrument]
884    pub async fn search_tasks(&self, query: &str) -> ThingsResult<Vec<Task>> {
885        let search_pattern = format!("%{query}%");
886        let rows = sqlx::query(
887            r"
888            SELECT 
889                uuid, title, status, type, 
890                startDate, deadline, stopDate,
891                project, area, heading,
892                notes, cachedTags, 
893                creationDate, userModificationDate
894            FROM TMTask
895            WHERE (title LIKE ? OR notes LIKE ?) AND trashed = 0 AND type = 0
896            ORDER BY creationDate DESC
897            ",
898        )
899        .bind(&search_pattern)
900        .bind(&search_pattern)
901        .fetch_all(&self.pool)
902        .await
903        .map_err(|e| ThingsError::unknown(format!("Failed to search tasks: {e}")))?;
904
905        let tasks = rows
906            .iter()
907            .map(map_task_row)
908            .collect::<ThingsResult<Vec<Task>>>()?;
909
910        debug!("Found {} tasks matching query: {}", tasks.len(), query);
911        Ok(tasks)
912    }
913
914    /// Query tasks using a [`TaskFilters`] struct produced by [`crate::query::TaskQueryBuilder`].
915    ///
916    /// All filter fields are optional and combined with AND semantics in SQL.
917    /// Tag and search-query filters are applied in Rust after the SQL query returns
918    /// (Things 3 stores tags as a BLOB). When those post-filters are active,
919    /// `LIMIT`/`OFFSET` is also applied in Rust so pagination counts only
920    /// matching rows; without post-filters it is applied in SQL for efficiency.
921    ///
922    /// Tag matching via `filters.tags` is case-sensitive.
923    ///
924    /// Filtering by [`TaskStatus::Trashed`] queries rows where `trashed = 1`
925    /// rather than adding a `status` condition, matching Things 3's soft-delete
926    /// semantics (trashed rows keep their original status value).
927    ///
928    /// # Errors
929    ///
930    /// Returns an error if the database query fails or task data cannot be mapped.
931    #[cfg(feature = "advanced-queries")]
932    pub async fn query_tasks(&self, filters: &TaskFilters) -> ThingsResult<Vec<Task>> {
933        const COLS: &str = "uuid, title, type, status, notes, startDate, deadline, stopDate, \
934                            creationDate, userModificationDate, project, area, heading, cachedTags";
935
936        // Things 3 soft-deletes by setting trashed = 1; the status column is unchanged.
937        // Requesting Trashed means "show trashed rows", not a status = 3 predicate.
938        let trashed_val = i32::from(matches!(filters.status, Some(TaskStatus::Trashed)));
939        let mut conditions: Vec<String> = vec![format!("trashed = {trashed_val}")];
940
941        if let Some(status) = filters.status {
942            let n = match status {
943                TaskStatus::Incomplete => Some(0),
944                TaskStatus::Completed => Some(1),
945                TaskStatus::Canceled => Some(2),
946                TaskStatus::Trashed => None, // handled via trashed = 1 above
947            };
948            if let Some(n) = n {
949                conditions.push(format!("status = {n}"));
950            }
951        }
952
953        if let Some(task_type) = filters.task_type {
954            let n = match task_type {
955                TaskType::Todo => 0,
956                TaskType::Project => 1,
957                TaskType::Heading => 2,
958                TaskType::Area => 3,
959            };
960            conditions.push(format!("type = {n}"));
961        }
962
963        if let Some(ref uuid) = filters.project_uuid {
964            conditions.push(format!("project = '{uuid}'"));
965        }
966
967        if let Some(ref uuid) = filters.area_uuid {
968            conditions.push(format!("area = '{uuid}'"));
969        }
970
971        if let Some(from) = filters.start_date_from {
972            conditions.push(format!(
973                "startDate >= {}",
974                naive_date_to_things_timestamp(from)
975            ));
976        }
977        if let Some(to) = filters.start_date_to {
978            conditions.push(format!(
979                "startDate <= {}",
980                naive_date_to_things_timestamp(to)
981            ));
982        }
983
984        if let Some(from) = filters.deadline_from {
985            conditions.push(format!(
986                "deadline >= {}",
987                naive_date_to_things_timestamp(from)
988            ));
989        }
990        if let Some(to) = filters.deadline_to {
991            conditions.push(format!(
992                "deadline <= {}",
993                naive_date_to_things_timestamp(to)
994            ));
995        }
996
997        let where_clause = conditions.join(" AND ");
998        let mut sql =
999            format!("SELECT {COLS} FROM TMTask WHERE {where_clause} ORDER BY creationDate DESC");
1000
1001        // When tags or search_query are active, LIMIT/OFFSET must be applied in Rust
1002        // after post-filtering, because SQL LIMIT would count non-matching rows.
1003        let has_post_filters =
1004            filters.tags.as_ref().is_some_and(|t| !t.is_empty()) || filters.search_query.is_some();
1005
1006        if !has_post_filters {
1007            match (filters.limit, filters.offset) {
1008                (Some(limit), Some(offset)) => {
1009                    sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
1010                }
1011                (Some(limit), None) => {
1012                    sql.push_str(&format!(" LIMIT {limit}"));
1013                }
1014                (None, Some(offset)) => {
1015                    // SQLite requires LIMIT when OFFSET is used; -1 means unlimited
1016                    sql.push_str(&format!(" LIMIT -1 OFFSET {offset}"));
1017                }
1018                (None, None) => {}
1019            }
1020        }
1021
1022        let rows = sqlx::query(&sql)
1023            .fetch_all(&self.pool)
1024            .await
1025            .map_err(|e| ThingsError::unknown(format!("Failed to query tasks: {e}")))?;
1026
1027        let mut tasks = rows
1028            .iter()
1029            .map(map_task_row)
1030            .collect::<ThingsResult<Vec<Task>>>()?;
1031
1032        if let Some(ref filter_tags) = filters.tags {
1033            if !filter_tags.is_empty() {
1034                tasks.retain(|task| filter_tags.iter().all(|f| task.tags.contains(f)));
1035            }
1036        }
1037
1038        if let Some(ref q) = filters.search_query {
1039            let q_lower = q.to_lowercase();
1040            tasks.retain(|task| {
1041                task.title.to_lowercase().contains(&q_lower)
1042                    || task
1043                        .notes
1044                        .as_deref()
1045                        .unwrap_or("")
1046                        .to_lowercase()
1047                        .contains(&q_lower)
1048            });
1049        }
1050
1051        if has_post_filters {
1052            let offset = filters.offset.unwrap_or(0);
1053            tasks = tasks.into_iter().skip(offset).collect();
1054            if let Some(limit) = filters.limit {
1055                tasks.truncate(limit);
1056            }
1057        }
1058
1059        Ok(tasks)
1060    }
1061
1062    /// Search completed tasks in the logbook
1063    ///
1064    /// Returns completed tasks matching the provided filters.
1065    /// All filters are optional and can be combined.
1066    ///
1067    /// # Parameters
1068    ///
1069    /// - `search_text`: Search in task titles and notes (case-insensitive)
1070    /// - `from_date`: Start date for completion date range
1071    /// - `to_date`: End date for completion date range
1072    /// - `project_uuid`: Filter by project UUID
1073    /// - `area_uuid`: Filter by area UUID
1074    /// - `tags`: Filter by tags (all tags must match)
1075    /// - `limit`: Maximum number of results (default: 50)
1076    ///
1077    /// # Errors
1078    ///
1079    /// Returns an error if the database query fails or if task data is invalid
1080    #[allow(clippy::too_many_arguments)]
1081    #[instrument(skip(self))]
1082    pub async fn search_logbook(
1083        &self,
1084        search_text: Option<String>,
1085        from_date: Option<NaiveDate>,
1086        to_date: Option<NaiveDate>,
1087        project_uuid: Option<Uuid>,
1088        area_uuid: Option<Uuid>,
1089        tags: Option<Vec<String>>,
1090        limit: Option<u32>,
1091    ) -> ThingsResult<Vec<Task>> {
1092        // Apply limit
1093        let result_limit = limit.unwrap_or(50).min(500);
1094
1095        // Build and execute query based on filters
1096        let rows = if let Some(ref text) = search_text {
1097            let pattern = format!("%{text}%");
1098            let mut q = String::from(
1099                "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, cachedTags, creationDate, userModificationDate FROM TMTask WHERE status = 1 AND trashed = 0 AND type = 0",
1100            );
1101            q.push_str(" AND (title LIKE ? OR notes LIKE ?)");
1102
1103            if let Some(date) = from_date {
1104                // stopDate is stored as Unix timestamp (seconds since 1970-01-01)
1105                let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1106                let timestamp = date_time.timestamp() as f64;
1107                q.push_str(&format!(" AND stopDate >= {}", timestamp));
1108            }
1109
1110            if let Some(date) = to_date {
1111                // Include tasks completed on to_date by adding 1 day
1112                let end_date = date + chrono::Duration::days(1);
1113                let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1114                let timestamp = date_time.timestamp() as f64;
1115                q.push_str(&format!(" AND stopDate < {}", timestamp));
1116            }
1117
1118            if let Some(uuid) = project_uuid {
1119                q.push_str(&format!(" AND project = '{}'", uuid));
1120            }
1121
1122            if let Some(uuid) = area_uuid {
1123                q.push_str(&format!(" AND area = '{}'", uuid));
1124            }
1125
1126            q.push_str(&format!(" ORDER BY stopDate DESC LIMIT {result_limit}"));
1127
1128            sqlx::query(&q)
1129                .bind(&pattern)
1130                .bind(&pattern)
1131                .fetch_all(&self.pool)
1132                .await
1133                .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
1134        } else {
1135            let mut q = String::from(
1136                "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, cachedTags, creationDate, userModificationDate FROM TMTask WHERE status = 1 AND trashed = 0 AND type = 0",
1137            );
1138
1139            if let Some(date) = from_date {
1140                // stopDate is stored as Unix timestamp (seconds since 1970-01-01)
1141                let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1142                let timestamp = date_time.timestamp() as f64;
1143                q.push_str(&format!(" AND stopDate >= {}", timestamp));
1144            }
1145
1146            if let Some(date) = to_date {
1147                // Include tasks completed on to_date by adding 1 day
1148                let end_date = date + chrono::Duration::days(1);
1149                let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1150                let timestamp = date_time.timestamp() as f64;
1151                q.push_str(&format!(" AND stopDate < {}", timestamp));
1152            }
1153
1154            if let Some(uuid) = project_uuid {
1155                q.push_str(&format!(" AND project = '{}'", uuid));
1156            }
1157
1158            if let Some(uuid) = area_uuid {
1159                q.push_str(&format!(" AND area = '{}'", uuid));
1160            }
1161
1162            q.push_str(&format!(" ORDER BY stopDate DESC LIMIT {result_limit}"));
1163
1164            sqlx::query(&q)
1165                .fetch_all(&self.pool)
1166                .await
1167                .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
1168        };
1169
1170        // Filter by tags if provided
1171        let mut tasks = rows
1172            .iter()
1173            .map(map_task_row)
1174            .collect::<ThingsResult<Vec<Task>>>()?;
1175
1176        if let Some(ref filter_tags) = tags {
1177            if !filter_tags.is_empty() {
1178                tasks.retain(|task| {
1179                    // Check if task has all required tags
1180                    filter_tags
1181                        .iter()
1182                        .all(|filter_tag| task.tags.contains(filter_tag))
1183                });
1184            }
1185        }
1186
1187        debug!("Found {} completed tasks in logbook", tasks.len());
1188        Ok(tasks)
1189    }
1190
1191    /// Get inbox tasks (incomplete tasks without project)
1192    ///
1193    /// # Errors
1194    ///
1195    /// Returns an error if the database query fails or if task data is invalid
1196    #[instrument(skip(self))]
1197    pub async fn get_inbox(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
1198        let query = if let Some(limit) = limit {
1199            format!("SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE type = 0 AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC LIMIT {limit}")
1200        } else {
1201            "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE type = 0 AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC"
1202                .to_string()
1203        };
1204
1205        let rows = sqlx::query(&query)
1206            .fetch_all(&self.pool)
1207            .await
1208            .map_err(|e| ThingsError::unknown(format!("Failed to fetch inbox tasks: {e}")))?;
1209
1210        let tasks = rows
1211            .iter()
1212            .map(map_task_row)
1213            .collect::<ThingsResult<Vec<Task>>>()?;
1214
1215        Ok(tasks)
1216    }
1217
1218    /// Get today's tasks (incomplete tasks due today or started today)
1219    ///
1220    /// # Errors
1221    ///
1222    /// Returns an error if the database query fails or if task data is invalid
1223    ///
1224    /// # Panics
1225    ///
1226    /// Panics if the current date cannot be converted to a valid time with hours, minutes, and seconds
1227    #[instrument(skip(self))]
1228    pub async fn get_today(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
1229        // Things 3 uses the `todayIndex` column to mark tasks that appear in "Today"
1230        // A task is in "Today" if todayIndex IS NOT NULL AND todayIndex != 0
1231        let query = if let Some(limit) = limit {
1232            format!(
1233                "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC LIMIT {limit}"
1234            )
1235        } else {
1236            "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC".to_string()
1237        };
1238
1239        let rows = sqlx::query(&query)
1240            .fetch_all(&self.pool)
1241            .await
1242            .map_err(|e| ThingsError::unknown(format!("Failed to fetch today's tasks: {e}")))?;
1243
1244        let tasks = rows
1245            .iter()
1246            .map(map_task_row)
1247            .collect::<ThingsResult<Vec<Task>>>()?;
1248
1249        Ok(tasks)
1250    }
1251
1252    /// Get all projects (alias for `get_all_projects` for compatibility)
1253    ///
1254    /// # Errors
1255    ///
1256    /// Returns an error if the database query fails or if project data is invalid
1257    #[instrument(skip(self))]
1258    pub async fn get_projects(&self, limit: Option<usize>) -> ThingsResult<Vec<Project>> {
1259        let _ = limit; // Currently unused but kept for API compatibility
1260        self.get_all_projects().await
1261    }
1262
1263    /// Get all areas (alias for `get_all_areas` for compatibility)
1264    ///
1265    /// # Errors
1266    ///
1267    /// Returns an error if the database query fails or if area data is invalid
1268    #[instrument(skip(self))]
1269    pub async fn get_areas(&self) -> ThingsResult<Vec<Area>> {
1270        self.get_all_areas().await
1271    }
1272
1273    /// Create a new task in the database
1274    ///
1275    /// Validates:
1276    /// - Project UUID exists if provided
1277    /// - Area UUID exists if provided
1278    /// - Parent task UUID exists if provided
1279    /// - Date range (deadline >= start_date)
1280    ///
1281    /// Returns the UUID of the created task
1282    ///
1283    /// # Examples
1284    ///
1285    /// ```no_run
1286    /// use things3_core::{ThingsDatabase, CreateTaskRequest, ThingsError};
1287    /// use std::path::Path;
1288    /// use chrono::NaiveDate;
1289    ///
1290    /// # async fn example() -> Result<(), ThingsError> {
1291    /// let db = ThingsDatabase::new(Path::new("/path/to/things.db")).await?;
1292    ///
1293    /// // Create a simple task
1294    /// let request = CreateTaskRequest {
1295    ///     title: "Buy groceries".to_string(),
1296    ///     notes: Some("Milk, eggs, bread".to_string()),
1297    ///     deadline: Some(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()),
1298    ///     start_date: None,
1299    ///     project_uuid: None,
1300    ///     area_uuid: None,
1301    ///     parent_uuid: None,
1302    ///     tags: None,
1303    ///     task_type: None,
1304    ///     status: None,
1305    /// };
1306    ///
1307    /// let task_uuid = db.create_task(request).await?;
1308    /// println!("Created task with UUID: {}", task_uuid);
1309    /// # Ok(())
1310    /// # }
1311    /// ```
1312    ///
1313    /// # Errors
1314    ///
1315    /// Returns an error if validation fails or if the database insert fails
1316    #[instrument(skip(self))]
1317    pub async fn create_task(&self, request: CreateTaskRequest) -> ThingsResult<Uuid> {
1318        // Validate date range (deadline must be >= start_date)
1319        crate::database::validate_date_range(request.start_date, request.deadline)?;
1320
1321        // Generate UUID for new task
1322        let uuid = Uuid::new_v4();
1323        let uuid_str = uuid.to_string();
1324
1325        // Validate referenced entities
1326        if let Some(project_uuid) = &request.project_uuid {
1327            validators::validate_project_exists(&self.pool, project_uuid).await?;
1328        }
1329
1330        if let Some(area_uuid) = &request.area_uuid {
1331            validators::validate_area_exists(&self.pool, area_uuid).await?;
1332        }
1333
1334        if let Some(parent_uuid) = &request.parent_uuid {
1335            validators::validate_task_exists(&self.pool, parent_uuid).await?;
1336        }
1337
1338        // Convert dates to Things 3 format (seconds since 2001-01-01)
1339        let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
1340        let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
1341
1342        // Get current timestamp for creation/modification dates
1343        let now = Utc::now().timestamp() as f64;
1344
1345        // Serialize tags to binary format (if provided)
1346        let cached_tags = request
1347            .tags
1348            .as_ref()
1349            .map(|tags| serialize_tags_to_blob(tags))
1350            .transpose()?;
1351
1352        // Insert into TMTask table
1353        sqlx::query(
1354            r"
1355            INSERT INTO TMTask (
1356                uuid, title, type, status, notes,
1357                startDate, deadline, project, area, heading,
1358                cachedTags, creationDate, userModificationDate,
1359                trashed
1360            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1361            ",
1362        )
1363        .bind(&uuid_str)
1364        .bind(&request.title)
1365        .bind(request.task_type.unwrap_or(TaskType::Todo) as i32)
1366        .bind(request.status.unwrap_or(TaskStatus::Incomplete) as i32)
1367        .bind(request.notes.as_ref())
1368        .bind(start_date_ts)
1369        .bind(deadline_ts)
1370        .bind(request.project_uuid.map(|u| u.to_string()))
1371        .bind(request.area_uuid.map(|u| u.to_string()))
1372        .bind(request.parent_uuid.map(|u| u.to_string()))
1373        .bind(cached_tags)
1374        .bind(now)
1375        .bind(now)
1376        .bind(0) // not trashed
1377        .execute(&self.pool)
1378        .await
1379        .map_err(|e| ThingsError::unknown(format!("Failed to create task: {e}")))?;
1380
1381        info!("Created task with UUID: {}", uuid);
1382        Ok(uuid)
1383    }
1384
1385    /// Create a new project
1386    ///
1387    /// Projects are tasks with type = 1 in the TMTask table
1388    ///
1389    /// # Errors
1390    ///
1391    /// Returns an error if validation fails or the database insert fails
1392    #[instrument(skip(self))]
1393    pub async fn create_project(
1394        &self,
1395        request: crate::models::CreateProjectRequest,
1396    ) -> ThingsResult<Uuid> {
1397        // Validate date range (deadline must be >= start_date)
1398        crate::database::validate_date_range(request.start_date, request.deadline)?;
1399
1400        // Generate UUID for new project
1401        let uuid = Uuid::new_v4();
1402        let uuid_str = uuid.to_string();
1403
1404        // Validate area if provided
1405        if let Some(area_uuid) = &request.area_uuid {
1406            validators::validate_area_exists(&self.pool, area_uuid).await?;
1407        }
1408
1409        // Convert dates to Things 3 format (seconds since 2001-01-01)
1410        let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
1411        let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
1412
1413        // Get current timestamp for creation/modification dates
1414        let now = Utc::now().timestamp() as f64;
1415
1416        // Serialize tags to binary format (if provided)
1417        let cached_tags = request
1418            .tags
1419            .as_ref()
1420            .map(|tags| serialize_tags_to_blob(tags))
1421            .transpose()?;
1422
1423        // Insert into TMTask table with type = 1 (project)
1424        sqlx::query(
1425            r"
1426            INSERT INTO TMTask (
1427                uuid, title, type, status, notes,
1428                startDate, deadline, project, area, heading,
1429                cachedTags, creationDate, userModificationDate,
1430                trashed
1431            ) VALUES (?, ?, 1, 0, ?, ?, ?, NULL, ?, NULL, ?, ?, ?, 0)
1432            ",
1433        )
1434        .bind(&uuid_str)
1435        .bind(&request.title)
1436        .bind(request.notes.as_ref())
1437        .bind(start_date_ts)
1438        .bind(deadline_ts)
1439        .bind(request.area_uuid.map(|u| u.to_string()))
1440        .bind(cached_tags)
1441        .bind(now)
1442        .bind(now)
1443        .execute(&self.pool)
1444        .await
1445        .map_err(|e| ThingsError::unknown(format!("Failed to create project: {e}")))?;
1446
1447        info!("Created project with UUID: {}", uuid);
1448        Ok(uuid)
1449    }
1450
1451    /// Update an existing task
1452    ///
1453    /// Only updates fields that are provided (Some(_))
1454    /// Validates existence of referenced entities
1455    ///
1456    /// # Errors
1457    ///
1458    /// Returns an error if the task doesn't exist, validation fails, or the database update fails
1459    #[instrument(skip(self))]
1460    pub async fn update_task(&self, request: UpdateTaskRequest) -> ThingsResult<()> {
1461        // Verify task exists
1462        validators::validate_task_exists(&self.pool, &request.uuid).await?;
1463
1464        // Validate dates if either is being updated
1465        if request.start_date.is_some() || request.deadline.is_some() {
1466            // Get current task to merge dates
1467            if let Some(current_task) = self.get_task_by_uuid(&request.uuid).await? {
1468                let final_start = request.start_date.or(current_task.start_date);
1469                let final_deadline = request.deadline.or(current_task.deadline);
1470                crate::database::validate_date_range(final_start, final_deadline)?;
1471            }
1472        }
1473
1474        // Validate referenced entities if being updated
1475        if let Some(project_uuid) = &request.project_uuid {
1476            validators::validate_project_exists(&self.pool, project_uuid).await?;
1477        }
1478
1479        if let Some(area_uuid) = &request.area_uuid {
1480            validators::validate_area_exists(&self.pool, area_uuid).await?;
1481        }
1482
1483        // Use the TaskUpdateBuilder to construct the query
1484        let builder = TaskUpdateBuilder::from_request(&request);
1485
1486        // If no fields to update, just return (modification date will still be updated)
1487        if builder.is_empty() {
1488            return Ok(());
1489        }
1490
1491        let query_string = builder.build_query_string();
1492        let mut q = sqlx::query(&query_string);
1493
1494        // Bind values in the same order as the builder added fields
1495        if let Some(title) = &request.title {
1496            q = q.bind(title);
1497        }
1498
1499        if let Some(notes) = &request.notes {
1500            q = q.bind(notes);
1501        }
1502
1503        if let Some(start_date) = request.start_date {
1504            q = q.bind(naive_date_to_things_timestamp(start_date));
1505        }
1506
1507        if let Some(deadline) = request.deadline {
1508            q = q.bind(naive_date_to_things_timestamp(deadline));
1509        }
1510
1511        if let Some(status) = request.status {
1512            q = q.bind(status as i32);
1513        }
1514
1515        if let Some(project_uuid) = request.project_uuid {
1516            q = q.bind(project_uuid.to_string());
1517        }
1518
1519        if let Some(area_uuid) = request.area_uuid {
1520            q = q.bind(area_uuid.to_string());
1521        }
1522
1523        if let Some(tags) = &request.tags {
1524            let cached_tags = serialize_tags_to_blob(tags)?;
1525            q = q.bind(cached_tags);
1526        }
1527
1528        // Bind modification date and UUID (always added by builder)
1529        let now = Utc::now().timestamp() as f64;
1530        q = q.bind(now).bind(request.uuid.to_string());
1531
1532        q.execute(&self.pool)
1533            .await
1534            .map_err(|e| ThingsError::unknown(format!("Failed to update task: {e}")))?;
1535
1536        info!("Updated task with UUID: {}", request.uuid);
1537        Ok(())
1538    }
1539
1540    /// Get a single project by UUID
1541    ///
1542    /// Returns `None` if the project doesn't exist or is trashed
1543    ///
1544    /// # Errors
1545    ///
1546    /// Returns an error if the database query fails
1547    #[instrument(skip(self))]
1548    pub async fn get_project_by_uuid(&self, uuid: &Uuid) -> ThingsResult<Option<Project>> {
1549        let row = sqlx::query(
1550            r"
1551            SELECT 
1552                uuid, title, status, 
1553                area, notes, 
1554                creationDate, userModificationDate,
1555                startDate, deadline,
1556                trashed, type
1557            FROM TMTask
1558            WHERE uuid = ? AND type = 1
1559            ",
1560        )
1561        .bind(uuid.to_string())
1562        .fetch_optional(&self.pool)
1563        .await
1564        .map_err(|e| ThingsError::unknown(format!("Failed to fetch project: {e}")))?;
1565
1566        if let Some(row) = row {
1567            // Check if trashed
1568            let trashed: i64 = row.get("trashed");
1569            if trashed == 1 {
1570                return Ok(None); // Return None for trashed projects
1571            }
1572
1573            let project = Project {
1574                uuid: things_uuid_to_uuid(&row.get::<String, _>("uuid")),
1575                title: row.get("title"),
1576                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
1577                area_uuid: row
1578                    .get::<Option<String>, _>("area")
1579                    .map(|s| things_uuid_to_uuid(&s)),
1580                notes: row.get("notes"),
1581                deadline: row
1582                    .get::<Option<i64>, _>("deadline")
1583                    .and_then(|ts| DateTime::from_timestamp(ts, 0))
1584                    .map(|dt| dt.date_naive()),
1585                start_date: row
1586                    .get::<Option<i64>, _>("startDate")
1587                    .and_then(|ts| DateTime::from_timestamp(ts, 0))
1588                    .map(|dt| dt.date_naive()),
1589                tags: Vec::new(),  // TODO: Load tags separately
1590                tasks: Vec::new(), // TODO: Load child tasks separately
1591                created: {
1592                    let ts_f64 = row.get::<f64, _>("creationDate");
1593                    let ts = safe_timestamp_convert(ts_f64);
1594                    DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
1595                },
1596                modified: {
1597                    let ts_f64 = row.get::<f64, _>("userModificationDate");
1598                    let ts = safe_timestamp_convert(ts_f64);
1599                    DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
1600                },
1601            };
1602            Ok(Some(project))
1603        } else {
1604            Ok(None)
1605        }
1606    }
1607
1608    /// Update an existing project
1609    ///
1610    /// Only updates fields that are provided (Some(_))
1611    /// Validates existence and that the entity is a project (type = 1)
1612    ///
1613    /// # Errors
1614    ///
1615    /// Returns an error if the project doesn't exist, validation fails, or the database update fails
1616    #[instrument(skip(self))]
1617    pub async fn update_project(
1618        &self,
1619        request: crate::models::UpdateProjectRequest,
1620    ) -> ThingsResult<()> {
1621        // Verify project exists (type = 1, trashed = 0)
1622        validators::validate_project_exists(&self.pool, &request.uuid).await?;
1623
1624        // Validate dates if either is being updated
1625        if request.start_date.is_some() || request.deadline.is_some() {
1626            // Fetch current project to merge dates
1627            if let Some(current_project) = self.get_project_by_uuid(&request.uuid).await? {
1628                let final_start = request.start_date.or(current_project.start_date);
1629                let final_deadline = request.deadline.or(current_project.deadline);
1630                crate::database::validate_date_range(final_start, final_deadline)?;
1631            }
1632        }
1633
1634        // Validate area if being updated
1635        if let Some(area_uuid) = &request.area_uuid {
1636            validators::validate_area_exists(&self.pool, area_uuid).await?;
1637        }
1638
1639        // Build dynamic query using TaskUpdateBuilder
1640        let mut builder = TaskUpdateBuilder::new();
1641
1642        // Add fields to update
1643        if request.title.is_some() {
1644            builder = builder.add_field("title");
1645        }
1646        if request.notes.is_some() {
1647            builder = builder.add_field("notes");
1648        }
1649        if request.start_date.is_some() {
1650            builder = builder.add_field("startDate");
1651        }
1652        if request.deadline.is_some() {
1653            builder = builder.add_field("deadline");
1654        }
1655        if request.area_uuid.is_some() {
1656            builder = builder.add_field("area");
1657        }
1658        if request.tags.is_some() {
1659            builder = builder.add_field("cachedTags");
1660        }
1661
1662        // If nothing to update, return early
1663        if builder.is_empty() {
1664            return Ok(());
1665        }
1666
1667        // Build query string
1668        let query_str = builder.build_query_string();
1669        let mut q = sqlx::query(&query_str);
1670
1671        // Bind values in the same order they were added to the builder
1672        if let Some(ref title) = request.title {
1673            q = q.bind(title);
1674        }
1675        if let Some(ref notes) = request.notes {
1676            q = q.bind(notes);
1677        }
1678        if let Some(start_date) = request.start_date {
1679            q = q.bind(naive_date_to_things_timestamp(start_date));
1680        }
1681        if let Some(deadline) = request.deadline {
1682            q = q.bind(naive_date_to_things_timestamp(deadline));
1683        }
1684        if let Some(area_uuid) = request.area_uuid {
1685            q = q.bind(area_uuid.to_string());
1686        }
1687        if let Some(tags) = &request.tags {
1688            let cached_tags = serialize_tags_to_blob(tags)?;
1689            q = q.bind(cached_tags);
1690        }
1691
1692        // Bind modification date and UUID (always added by builder)
1693        let now = Utc::now().timestamp() as f64;
1694        q = q.bind(now).bind(request.uuid.to_string());
1695
1696        q.execute(&self.pool)
1697            .await
1698            .map_err(|e| ThingsError::unknown(format!("Failed to update project: {e}")))?;
1699
1700        info!("Updated project with UUID: {}", request.uuid);
1701        Ok(())
1702    }
1703
1704    /// Get a task by its UUID
1705    ///
1706    /// # Errors
1707    ///
1708    /// Returns an error if the task does not exist or if the database query fails
1709    #[instrument(skip(self))]
1710    pub async fn get_task_by_uuid(&self, uuid: &Uuid) -> ThingsResult<Option<Task>> {
1711        let row = sqlx::query(
1712            r"
1713            SELECT 
1714                uuid, title, status, type, 
1715                startDate, deadline, stopDate,
1716                project, area, heading,
1717                notes, cachedTags, 
1718                creationDate, userModificationDate,
1719                trashed
1720            FROM TMTask
1721            WHERE uuid = ?
1722            ",
1723        )
1724        .bind(uuid.to_string())
1725        .fetch_optional(&self.pool)
1726        .await
1727        .map_err(|e| ThingsError::unknown(format!("Failed to fetch task: {e}")))?;
1728
1729        if let Some(row) = row {
1730            // Check if trashed
1731            let trashed: i64 = row.get("trashed");
1732            if trashed == 1 {
1733                return Ok(None); // Return None for trashed tasks
1734            }
1735
1736            // Use the centralized mapper
1737            let task = map_task_row(&row)?;
1738            Ok(Some(task))
1739        } else {
1740            Ok(None)
1741        }
1742    }
1743
1744    /// Mark a task as completed
1745    ///
1746    /// # Errors
1747    ///
1748    /// Returns an error if the task does not exist or if the database update fails
1749    #[instrument(skip(self))]
1750    pub async fn complete_task(&self, uuid: &Uuid) -> ThingsResult<()> {
1751        // Verify task exists
1752        validators::validate_task_exists(&self.pool, uuid).await?;
1753
1754        let now = Utc::now().timestamp() as f64;
1755
1756        sqlx::query(
1757            "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
1758        )
1759        .bind(now)
1760        .bind(now)
1761        .bind(uuid.to_string())
1762        .execute(&self.pool)
1763        .await
1764        .map_err(|e| ThingsError::unknown(format!("Failed to complete task: {e}")))?;
1765
1766        info!("Completed task with UUID: {}", uuid);
1767        Ok(())
1768    }
1769
1770    /// Mark a completed task as incomplete
1771    ///
1772    /// # Errors
1773    ///
1774    /// Returns an error if the task does not exist or if the database update fails
1775    #[instrument(skip(self))]
1776    pub async fn uncomplete_task(&self, uuid: &Uuid) -> ThingsResult<()> {
1777        // Verify task exists
1778        validators::validate_task_exists(&self.pool, uuid).await?;
1779
1780        let now = Utc::now().timestamp() as f64;
1781
1782        sqlx::query(
1783            "UPDATE TMTask SET status = 0, stopDate = NULL, userModificationDate = ? WHERE uuid = ?",
1784        )
1785        .bind(now)
1786        .bind(uuid.to_string())
1787        .execute(&self.pool)
1788        .await
1789        .map_err(|e| ThingsError::unknown(format!("Failed to uncomplete task: {e}")))?;
1790
1791        info!("Uncompleted task with UUID: {}", uuid);
1792        Ok(())
1793    }
1794
1795    /// Complete a project and optionally handle its child tasks
1796    ///
1797    /// # Errors
1798    ///
1799    /// Returns an error if the project doesn't exist or if the database update fails
1800    #[instrument(skip(self))]
1801    pub async fn complete_project(
1802        &self,
1803        uuid: &Uuid,
1804        child_handling: crate::models::ProjectChildHandling,
1805    ) -> ThingsResult<()> {
1806        // Verify project exists
1807        validators::validate_project_exists(&self.pool, uuid).await?;
1808
1809        let now = Utc::now().timestamp() as f64;
1810
1811        // Handle child tasks based on the handling mode
1812        match child_handling {
1813            crate::models::ProjectChildHandling::Error => {
1814                // Check if project has children
1815                let child_count: i64 = sqlx::query_scalar(
1816                    "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
1817                )
1818                .bind(uuid.to_string())
1819                .fetch_one(&self.pool)
1820                .await
1821                .map_err(|e| {
1822                    ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
1823                })?;
1824
1825                if child_count > 0 {
1826                    return Err(ThingsError::unknown(format!(
1827                        "Project {} has {} child task(s). Use cascade or orphan mode to complete.",
1828                        uuid, child_count
1829                    )));
1830                }
1831            }
1832            crate::models::ProjectChildHandling::Cascade => {
1833                // Complete all child tasks
1834                sqlx::query(
1835                    "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE project = ? AND trashed = 0",
1836                )
1837                .bind(now)
1838                .bind(now)
1839                .bind(uuid.to_string())
1840                .execute(&self.pool)
1841                .await
1842                .map_err(|e| ThingsError::unknown(format!("Failed to complete child tasks: {e}")))?;
1843            }
1844            crate::models::ProjectChildHandling::Orphan => {
1845                // Move child tasks to inbox (set project to NULL)
1846                sqlx::query(
1847                    "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
1848                )
1849                .bind(now)
1850                .bind(uuid.to_string())
1851                .execute(&self.pool)
1852                .await
1853                .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
1854            }
1855        }
1856
1857        // Complete the project
1858        sqlx::query(
1859            "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
1860        )
1861        .bind(now)
1862        .bind(now)
1863        .bind(uuid.to_string())
1864        .execute(&self.pool)
1865        .await
1866        .map_err(|e| ThingsError::unknown(format!("Failed to complete project: {e}")))?;
1867
1868        info!("Completed project with UUID: {}", uuid);
1869        Ok(())
1870    }
1871
1872    /// Soft delete a task (set trashed flag)
1873    ///
1874    /// # Errors
1875    ///
1876    /// Returns an error if the task does not exist, if child handling fails, or if the database update fails
1877    #[instrument(skip(self))]
1878    pub async fn delete_task(
1879        &self,
1880        uuid: &Uuid,
1881        child_handling: DeleteChildHandling,
1882    ) -> ThingsResult<()> {
1883        // Verify task exists
1884        validators::validate_task_exists(&self.pool, uuid).await?;
1885
1886        // Check for child tasks
1887        let children = sqlx::query("SELECT uuid FROM TMTask WHERE heading = ? AND trashed = 0")
1888            .bind(uuid.to_string())
1889            .fetch_all(&self.pool)
1890            .await
1891            .map_err(|e| ThingsError::unknown(format!("Failed to query child tasks: {e}")))?;
1892
1893        let has_children = !children.is_empty();
1894
1895        if has_children {
1896            match child_handling {
1897                DeleteChildHandling::Error => {
1898                    return Err(ThingsError::unknown(format!(
1899                        "Task {} has {} child task(s). Use cascade or orphan mode to delete.",
1900                        uuid,
1901                        children.len()
1902                    )));
1903                }
1904                DeleteChildHandling::Cascade => {
1905                    // Delete all children
1906                    let now = Utc::now().timestamp() as f64;
1907                    for child_row in &children {
1908                        let child_uuid: String = child_row.get("uuid");
1909                        sqlx::query(
1910                            "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?",
1911                        )
1912                        .bind(now)
1913                        .bind(&child_uuid)
1914                        .execute(&self.pool)
1915                        .await
1916                        .map_err(|e| {
1917                            ThingsError::unknown(format!("Failed to delete child task: {e}"))
1918                        })?;
1919                    }
1920                    info!("Cascade deleted {} child task(s)", children.len());
1921                }
1922                DeleteChildHandling::Orphan => {
1923                    // Clear parent reference for children
1924                    let now = Utc::now().timestamp() as f64;
1925                    for child_row in &children {
1926                        let child_uuid: String = child_row.get("uuid");
1927                        sqlx::query(
1928                            "UPDATE TMTask SET heading = NULL, userModificationDate = ? WHERE uuid = ?",
1929                        )
1930                        .bind(now)
1931                        .bind(&child_uuid)
1932                        .execute(&self.pool)
1933                        .await
1934                        .map_err(|e| {
1935                            ThingsError::unknown(format!("Failed to orphan child task: {e}"))
1936                        })?;
1937                    }
1938                    info!("Orphaned {} child task(s)", children.len());
1939                }
1940            }
1941        }
1942
1943        // Delete the parent task
1944        let now = Utc::now().timestamp() as f64;
1945        sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
1946            .bind(now)
1947            .bind(uuid.to_string())
1948            .execute(&self.pool)
1949            .await
1950            .map_err(|e| ThingsError::unknown(format!("Failed to delete task: {e}")))?;
1951
1952        info!("Deleted task with UUID: {}", uuid);
1953        Ok(())
1954    }
1955
1956    /// Soft delete a project and handle its child tasks
1957    ///
1958    /// # Errors
1959    ///
1960    /// Returns an error if the project doesn't exist, if child handling fails, or if the database update fails
1961    #[instrument(skip(self))]
1962    pub async fn delete_project(
1963        &self,
1964        uuid: &Uuid,
1965        child_handling: crate::models::ProjectChildHandling,
1966    ) -> ThingsResult<()> {
1967        // Verify project exists
1968        validators::validate_project_exists(&self.pool, uuid).await?;
1969
1970        let now = Utc::now().timestamp() as f64;
1971
1972        // Handle child tasks based on the handling mode
1973        match child_handling {
1974            crate::models::ProjectChildHandling::Error => {
1975                // Check if project has children
1976                let child_count: i64 = sqlx::query_scalar(
1977                    "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
1978                )
1979                .bind(uuid.to_string())
1980                .fetch_one(&self.pool)
1981                .await
1982                .map_err(|e| {
1983                    ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
1984                })?;
1985
1986                if child_count > 0 {
1987                    return Err(ThingsError::unknown(format!(
1988                        "Project {} has {} child task(s). Use cascade or orphan mode to delete.",
1989                        uuid, child_count
1990                    )));
1991                }
1992            }
1993            crate::models::ProjectChildHandling::Cascade => {
1994                // Delete all child tasks
1995                sqlx::query(
1996                    "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE project = ? AND trashed = 0",
1997                )
1998                .bind(now)
1999                .bind(uuid.to_string())
2000                .execute(&self.pool)
2001                .await
2002                .map_err(|e| ThingsError::unknown(format!("Failed to delete child tasks: {e}")))?;
2003            }
2004            crate::models::ProjectChildHandling::Orphan => {
2005                // Move child tasks to inbox (set project to NULL)
2006                sqlx::query(
2007                    "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
2008                )
2009                .bind(now)
2010                .bind(uuid.to_string())
2011                .execute(&self.pool)
2012                .await
2013                .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
2014            }
2015        }
2016
2017        // Delete the project
2018        sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
2019            .bind(now)
2020            .bind(uuid.to_string())
2021            .execute(&self.pool)
2022            .await
2023            .map_err(|e| ThingsError::unknown(format!("Failed to delete project: {e}")))?;
2024
2025        info!("Deleted project with UUID: {}", uuid);
2026        Ok(())
2027    }
2028
2029    /// Create a new area
2030    ///
2031    /// # Errors
2032    ///
2033    /// Returns an error if the database insert fails
2034    #[instrument(skip(self))]
2035    pub async fn create_area(
2036        &self,
2037        request: crate::models::CreateAreaRequest,
2038    ) -> ThingsResult<Uuid> {
2039        // Generate UUID for new area
2040        let uuid = Uuid::new_v4();
2041        let uuid_str = uuid.to_string();
2042
2043        // Get current timestamp for creation/modification dates
2044        let now = Utc::now().timestamp() as f64;
2045
2046        // Calculate next index (max + 1)
2047        let max_index: Option<i64> = sqlx::query_scalar("SELECT MAX(`index`) FROM TMArea")
2048            .fetch_one(&self.pool)
2049            .await
2050            .map_err(|e| ThingsError::unknown(format!("Failed to get max area index: {e}")))?;
2051
2052        let next_index = max_index.unwrap_or(-1) + 1;
2053
2054        // Insert into TMArea table
2055        sqlx::query(
2056            r"
2057            INSERT INTO TMArea (
2058                uuid, title, visible, `index`,
2059                creationDate, userModificationDate
2060            ) VALUES (?, ?, 1, ?, ?, ?)
2061            ",
2062        )
2063        .bind(&uuid_str)
2064        .bind(&request.title)
2065        .bind(next_index)
2066        .bind(now)
2067        .bind(now)
2068        .execute(&self.pool)
2069        .await
2070        .map_err(|e| ThingsError::unknown(format!("Failed to create area: {e}")))?;
2071
2072        info!("Created area with UUID: {}", uuid);
2073        Ok(uuid)
2074    }
2075
2076    /// Update an existing area
2077    ///
2078    /// # Errors
2079    ///
2080    /// Returns an error if the area doesn't exist or if the database update fails
2081    #[instrument(skip(self))]
2082    pub async fn update_area(&self, request: crate::models::UpdateAreaRequest) -> ThingsResult<()> {
2083        // Verify area exists
2084        validators::validate_area_exists(&self.pool, &request.uuid).await?;
2085
2086        let now = Utc::now().timestamp() as f64;
2087
2088        sqlx::query("UPDATE TMArea SET title = ?, userModificationDate = ? WHERE uuid = ?")
2089            .bind(&request.title)
2090            .bind(now)
2091            .bind(request.uuid.to_string())
2092            .execute(&self.pool)
2093            .await
2094            .map_err(|e| ThingsError::unknown(format!("Failed to update area: {e}")))?;
2095
2096        info!("Updated area with UUID: {}", request.uuid);
2097        Ok(())
2098    }
2099
2100    /// Delete an area
2101    ///
2102    /// Hard delete (areas don't have a trashed field)
2103    /// Orphans all projects in the area by setting their area to NULL
2104    ///
2105    /// # Errors
2106    ///
2107    /// Returns an error if the area doesn't exist or if the database delete fails
2108    #[instrument(skip(self))]
2109    pub async fn delete_area(&self, uuid: &Uuid) -> ThingsResult<()> {
2110        // Verify area exists
2111        validators::validate_area_exists(&self.pool, uuid).await?;
2112
2113        let now = Utc::now().timestamp() as f64;
2114
2115        // Orphan all projects in this area (set area to NULL)
2116        sqlx::query(
2117            "UPDATE TMTask SET area = NULL, userModificationDate = ? WHERE area = ? AND type = 1 AND trashed = 0",
2118        )
2119        .bind(now)
2120        .bind(uuid.to_string())
2121        .execute(&self.pool)
2122        .await
2123        .map_err(|e| ThingsError::unknown(format!("Failed to orphan projects in area: {e}")))?;
2124
2125        // Delete the area (hard delete)
2126        sqlx::query("DELETE FROM TMArea WHERE uuid = ?")
2127            .bind(uuid.to_string())
2128            .execute(&self.pool)
2129            .await
2130            .map_err(|e| ThingsError::unknown(format!("Failed to delete area: {e}")))?;
2131
2132        info!("Deleted area with UUID: {}", uuid);
2133        Ok(())
2134    }
2135
2136    // ========================================================================
2137    // TAG OPERATIONS (with smart duplicate prevention)
2138    // ========================================================================
2139
2140    /// Find a tag by normalized title (exact match, case-insensitive)
2141    ///
2142    /// # Errors
2143    ///
2144    /// Returns an error if the database query fails
2145    #[instrument(skip(self))]
2146    pub async fn find_tag_by_normalized_title(
2147        &self,
2148        normalized: &str,
2149    ) -> ThingsResult<Option<crate::models::Tag>> {
2150        let row = sqlx::query(
2151            "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate 
2152             FROM TMTag 
2153             WHERE LOWER(title) = LOWER(?)",
2154        )
2155        .bind(normalized)
2156        .fetch_optional(&self.pool)
2157        .await
2158        .map_err(|e| ThingsError::unknown(format!("Failed to find tag by title: {e}")))?;
2159
2160        if let Some(row) = row {
2161            let uuid_str: String = row.get("uuid");
2162            let uuid =
2163                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2164            let title: String = row.get("title");
2165            let shortcut: Option<String> = row.get("shortcut");
2166            let parent_str: Option<String> = row.get("parent");
2167            let parent_uuid =
2168                parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2169
2170            let creation_ts: f64 = row.get("creationDate");
2171            let created = {
2172                let ts = safe_timestamp_convert(creation_ts);
2173                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2174            };
2175
2176            let modification_ts: f64 = row.get("userModificationDate");
2177            let modified = {
2178                let ts = safe_timestamp_convert(modification_ts);
2179                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2180            };
2181
2182            let used_ts: Option<f64> = row.get("usedDate");
2183            let last_used = used_ts.and_then(|ts| {
2184                let ts_i64 = safe_timestamp_convert(ts);
2185                DateTime::from_timestamp(ts_i64, 0)
2186            });
2187
2188            // Count usage by querying tasks with this tag
2189            let usage_count: i64 = sqlx::query_scalar(
2190                "SELECT COUNT(*) FROM TMTask 
2191                 WHERE cachedTags IS NOT NULL 
2192                 AND json_extract(cachedTags, '$') LIKE ?
2193                 AND trashed = 0",
2194            )
2195            .bind(format!("%\"{}\"%", title))
2196            .fetch_one(&self.pool)
2197            .await
2198            .unwrap_or(0);
2199
2200            Ok(Some(crate::models::Tag {
2201                uuid,
2202                title,
2203                shortcut,
2204                parent_uuid,
2205                created,
2206                modified,
2207                usage_count: usage_count as u32,
2208                last_used,
2209            }))
2210        } else {
2211            Ok(None)
2212        }
2213    }
2214
2215    /// Find tags similar to the given title using fuzzy matching
2216    ///
2217    /// Returns tags sorted by similarity score (highest first)
2218    ///
2219    /// # Errors
2220    ///
2221    /// Returns an error if the database query fails
2222    #[instrument(skip(self))]
2223    pub async fn find_similar_tags(
2224        &self,
2225        title: &str,
2226        min_similarity: f32,
2227    ) -> ThingsResult<Vec<crate::models::TagMatch>> {
2228        use crate::database::tag_utils::{calculate_similarity, get_match_type};
2229
2230        // Get all tags
2231        let all_tags = self.get_all_tags().await?;
2232
2233        // Calculate similarity for each tag
2234        let mut matches: Vec<crate::models::TagMatch> = all_tags
2235            .into_iter()
2236            .filter_map(|tag| {
2237                let similarity = calculate_similarity(title, &tag.title);
2238                if similarity >= min_similarity {
2239                    let match_type = get_match_type(title, &tag.title, min_similarity);
2240                    Some(crate::models::TagMatch {
2241                        tag,
2242                        similarity_score: similarity,
2243                        match_type,
2244                    })
2245                } else {
2246                    None
2247                }
2248            })
2249            .collect();
2250
2251        // Sort by similarity score (highest first)
2252        matches.sort_by(|a, b| {
2253            b.similarity_score
2254                .partial_cmp(&a.similarity_score)
2255                .unwrap_or(std::cmp::Ordering::Equal)
2256        });
2257
2258        Ok(matches)
2259    }
2260
2261    /// Search tags by partial title match
2262    ///
2263    /// # Errors
2264    ///
2265    /// Returns an error if the database query fails
2266    #[instrument(skip(self))]
2267    pub async fn search_tags(&self, query: &str) -> ThingsResult<Vec<crate::models::Tag>> {
2268        let rows = sqlx::query(
2269            "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate 
2270             FROM TMTag 
2271             WHERE title LIKE ? 
2272             ORDER BY title",
2273        )
2274        .bind(format!("%{}%", query))
2275        .fetch_all(&self.pool)
2276        .await
2277        .map_err(|e| ThingsError::unknown(format!("Failed to search tags: {e}")))?;
2278
2279        let mut tags = Vec::new();
2280        for row in rows {
2281            let uuid_str: String = row.get("uuid");
2282            let uuid =
2283                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2284            let title: String = row.get("title");
2285            let shortcut: Option<String> = row.get("shortcut");
2286            let parent_str: Option<String> = row.get("parent");
2287            let parent_uuid =
2288                parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2289
2290            let creation_ts: f64 = row.get("creationDate");
2291            let created = {
2292                let ts = safe_timestamp_convert(creation_ts);
2293                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2294            };
2295
2296            let modification_ts: f64 = row.get("userModificationDate");
2297            let modified = {
2298                let ts = safe_timestamp_convert(modification_ts);
2299                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2300            };
2301
2302            let used_ts: Option<f64> = row.get("usedDate");
2303            let last_used = used_ts.and_then(|ts| {
2304                let ts_i64 = safe_timestamp_convert(ts);
2305                DateTime::from_timestamp(ts_i64, 0)
2306            });
2307
2308            // Count usage
2309            let usage_count: i64 = sqlx::query_scalar(
2310                "SELECT COUNT(*) FROM TMTask 
2311                 WHERE cachedTags IS NOT NULL 
2312                 AND json_extract(cachedTags, '$') LIKE ?
2313                 AND trashed = 0",
2314            )
2315            .bind(format!("%\"{}\"%", title))
2316            .fetch_one(&self.pool)
2317            .await
2318            .unwrap_or(0);
2319
2320            tags.push(crate::models::Tag {
2321                uuid,
2322                title,
2323                shortcut,
2324                parent_uuid,
2325                created,
2326                modified,
2327                usage_count: usage_count as u32,
2328                last_used,
2329            });
2330        }
2331
2332        Ok(tags)
2333    }
2334
2335    /// Get all tags ordered by title
2336    ///
2337    /// # Errors
2338    ///
2339    /// Returns an error if the database query fails
2340    #[instrument(skip(self))]
2341    pub async fn get_all_tags(&self) -> ThingsResult<Vec<crate::models::Tag>> {
2342        let rows = sqlx::query(
2343            "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate 
2344             FROM TMTag 
2345             ORDER BY title",
2346        )
2347        .fetch_all(&self.pool)
2348        .await
2349        .map_err(|e| ThingsError::unknown(format!("Failed to get all tags: {e}")))?;
2350
2351        let mut tags = Vec::new();
2352        for row in rows {
2353            let uuid_str: String = row.get("uuid");
2354            let uuid =
2355                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2356            let title: String = row.get("title");
2357            let shortcut: Option<String> = row.get("shortcut");
2358            let parent_str: Option<String> = row.get("parent");
2359            let parent_uuid =
2360                parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2361
2362            let creation_ts: f64 = row.get("creationDate");
2363            let created = {
2364                let ts = safe_timestamp_convert(creation_ts);
2365                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2366            };
2367
2368            let modification_ts: f64 = row.get("userModificationDate");
2369            let modified = {
2370                let ts = safe_timestamp_convert(modification_ts);
2371                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2372            };
2373
2374            let used_ts: Option<f64> = row.get("usedDate");
2375            let last_used = used_ts.and_then(|ts| {
2376                let ts_i64 = safe_timestamp_convert(ts);
2377                DateTime::from_timestamp(ts_i64, 0)
2378            });
2379
2380            // Count usage
2381            let usage_count: i64 = sqlx::query_scalar(
2382                "SELECT COUNT(*) FROM TMTask 
2383                 WHERE cachedTags IS NOT NULL 
2384                 AND json_extract(cachedTags, '$') LIKE ?
2385                 AND trashed = 0",
2386            )
2387            .bind(format!("%\"{}\"%", title))
2388            .fetch_one(&self.pool)
2389            .await
2390            .unwrap_or(0);
2391
2392            tags.push(crate::models::Tag {
2393                uuid,
2394                title,
2395                shortcut,
2396                parent_uuid,
2397                created,
2398                modified,
2399                usage_count: usage_count as u32,
2400                last_used,
2401            });
2402        }
2403
2404        Ok(tags)
2405    }
2406
2407    /// Get most frequently used tags
2408    ///
2409    /// # Errors
2410    ///
2411    /// Returns an error if the database query fails
2412    #[instrument(skip(self))]
2413    pub async fn get_popular_tags(&self, limit: usize) -> ThingsResult<Vec<crate::models::Tag>> {
2414        let mut all_tags = self.get_all_tags().await?;
2415
2416        // Sort by usage count (highest first)
2417        all_tags.sort_by_key(|t| std::cmp::Reverse(t.usage_count));
2418
2419        // Take the top N
2420        all_tags.truncate(limit);
2421
2422        Ok(all_tags)
2423    }
2424
2425    /// Get recently used tags
2426    ///
2427    /// # Errors
2428    ///
2429    /// Returns an error if the database query fails
2430    #[instrument(skip(self))]
2431    pub async fn get_recent_tags(&self, limit: usize) -> ThingsResult<Vec<crate::models::Tag>> {
2432        let rows = sqlx::query(
2433            "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate 
2434             FROM TMTag 
2435             WHERE usedDate IS NOT NULL 
2436             ORDER BY usedDate DESC 
2437             LIMIT ?",
2438        )
2439        .bind(limit as i64)
2440        .fetch_all(&self.pool)
2441        .await
2442        .map_err(|e| ThingsError::unknown(format!("Failed to get recent tags: {e}")))?;
2443
2444        let mut tags = Vec::new();
2445        for row in rows {
2446            let uuid_str: String = row.get("uuid");
2447            let uuid =
2448                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2449            let title: String = row.get("title");
2450            let shortcut: Option<String> = row.get("shortcut");
2451            let parent_str: Option<String> = row.get("parent");
2452            let parent_uuid =
2453                parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2454
2455            let creation_ts: f64 = row.get("creationDate");
2456            let created = {
2457                let ts = safe_timestamp_convert(creation_ts);
2458                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2459            };
2460
2461            let modification_ts: f64 = row.get("userModificationDate");
2462            let modified = {
2463                let ts = safe_timestamp_convert(modification_ts);
2464                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2465            };
2466
2467            let used_ts: Option<f64> = row.get("usedDate");
2468            let last_used = used_ts.and_then(|ts| {
2469                let ts_i64 = safe_timestamp_convert(ts);
2470                DateTime::from_timestamp(ts_i64, 0)
2471            });
2472
2473            // Count usage
2474            let usage_count: i64 = sqlx::query_scalar(
2475                "SELECT COUNT(*) FROM TMTask 
2476                 WHERE cachedTags IS NOT NULL 
2477                 AND json_extract(cachedTags, '$') LIKE ?
2478                 AND trashed = 0",
2479            )
2480            .bind(format!("%\"{}\"%", title))
2481            .fetch_one(&self.pool)
2482            .await
2483            .unwrap_or(0);
2484
2485            tags.push(crate::models::Tag {
2486                uuid,
2487                title,
2488                shortcut,
2489                parent_uuid,
2490                created,
2491                modified,
2492                usage_count: usage_count as u32,
2493                last_used,
2494            });
2495        }
2496
2497        Ok(tags)
2498    }
2499
2500    /// Create a tag with smart duplicate detection
2501    ///
2502    /// Returns:
2503    /// - `Created`: New tag was created
2504    /// - `Existing`: Exact match found (case-insensitive)
2505    /// - `SimilarFound`: Similar tags found (user decision needed)
2506    ///
2507    /// # Errors
2508    ///
2509    /// Returns an error if the database operation fails
2510    #[instrument(skip(self))]
2511    pub async fn create_tag_smart(
2512        &self,
2513        request: crate::models::CreateTagRequest,
2514    ) -> ThingsResult<crate::models::TagCreationResult> {
2515        use crate::database::tag_utils::normalize_tag_title;
2516        use crate::models::TagCreationResult;
2517
2518        // 1. Normalize the title
2519        let normalized = normalize_tag_title(&request.title);
2520
2521        // 2. Check for exact match (case-insensitive)
2522        if let Some(existing) = self.find_tag_by_normalized_title(&normalized).await? {
2523            return Ok(TagCreationResult::Existing {
2524                tag: existing,
2525                is_new: false,
2526            });
2527        }
2528
2529        // 3. Find similar tags (fuzzy matching with 80% threshold)
2530        let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2531
2532        // 4. If similar tags found, return them for user decision
2533        if !similar_tags.is_empty() {
2534            return Ok(TagCreationResult::SimilarFound {
2535                similar_tags,
2536                requested_title: request.title,
2537            });
2538        }
2539
2540        // 5. No duplicates, safe to create
2541        let uuid = Uuid::new_v4();
2542        let now = Utc::now().timestamp() as f64;
2543
2544        sqlx::query(
2545            "INSERT INTO TMTag (uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate, `index`) 
2546             VALUES (?, ?, ?, ?, ?, ?, NULL, 0)"
2547        )
2548        .bind(uuid.to_string())
2549        .bind(&request.title)
2550        .bind(request.shortcut.as_ref())
2551        .bind(request.parent_uuid.map(|u| u.to_string()))
2552        .bind(now)
2553        .bind(now)
2554        .execute(&self.pool)
2555        .await
2556        .map_err(|e| ThingsError::unknown(format!("Failed to create tag: {e}")))?;
2557
2558        info!("Created tag with UUID: {}", uuid);
2559        Ok(TagCreationResult::Created { uuid, is_new: true })
2560    }
2561
2562    /// Create tag forcefully (skip duplicate check)
2563    ///
2564    /// # Errors
2565    ///
2566    /// Returns an error if the database operation fails
2567    #[instrument(skip(self))]
2568    pub async fn create_tag_force(
2569        &self,
2570        request: crate::models::CreateTagRequest,
2571    ) -> ThingsResult<Uuid> {
2572        let uuid = Uuid::new_v4();
2573        let now = Utc::now().timestamp() as f64;
2574
2575        sqlx::query(
2576            "INSERT INTO TMTag (uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate, `index`) 
2577             VALUES (?, ?, ?, ?, ?, ?, NULL, 0)"
2578        )
2579        .bind(uuid.to_string())
2580        .bind(&request.title)
2581        .bind(request.shortcut.as_ref())
2582        .bind(request.parent_uuid.map(|u| u.to_string()))
2583        .bind(now)
2584        .bind(now)
2585        .execute(&self.pool)
2586        .await
2587        .map_err(|e| ThingsError::unknown(format!("Failed to create tag: {e}")))?;
2588
2589        info!("Forcefully created tag with UUID: {}", uuid);
2590        Ok(uuid)
2591    }
2592
2593    /// Update a tag
2594    ///
2595    /// # Errors
2596    ///
2597    /// Returns an error if the tag doesn't exist or database operation fails
2598    #[instrument(skip(self))]
2599    pub async fn update_tag(&self, request: crate::models::UpdateTagRequest) -> ThingsResult<()> {
2600        use crate::database::tag_utils::normalize_tag_title;
2601
2602        // Verify tag exists
2603        let existing = self
2604            .find_tag_by_normalized_title(&request.uuid.to_string())
2605            .await?;
2606        if existing.is_none() {
2607            // Try by UUID
2608            let row = sqlx::query("SELECT 1 FROM TMTag WHERE uuid = ?")
2609                .bind(request.uuid.to_string())
2610                .fetch_optional(&self.pool)
2611                .await
2612                .map_err(|e| ThingsError::unknown(format!("Failed to validate tag: {e}")))?;
2613
2614            if row.is_none() {
2615                return Err(ThingsError::unknown(format!(
2616                    "Tag not found: {}",
2617                    request.uuid
2618                )));
2619            }
2620        }
2621
2622        // If renaming, check for duplicates with new name
2623        if let Some(new_title) = &request.title {
2624            let normalized = normalize_tag_title(new_title);
2625            if let Some(duplicate) = self.find_tag_by_normalized_title(&normalized).await? {
2626                if duplicate.uuid != request.uuid {
2627                    return Err(ThingsError::unknown(format!(
2628                        "Tag with title '{}' already exists",
2629                        new_title
2630                    )));
2631                }
2632            }
2633        }
2634
2635        let now = Utc::now().timestamp() as f64;
2636
2637        // Build dynamic UPDATE query
2638        let mut updates = Vec::new();
2639        let mut params: Vec<String> = Vec::new();
2640
2641        if let Some(title) = &request.title {
2642            updates.push("title = ?");
2643            params.push(title.clone());
2644        }
2645        if let Some(shortcut) = &request.shortcut {
2646            updates.push("shortcut = ?");
2647            params.push(shortcut.clone());
2648        }
2649        if let Some(parent_uuid) = request.parent_uuid {
2650            updates.push("parent = ?");
2651            params.push(parent_uuid.to_string());
2652        }
2653
2654        if updates.is_empty() {
2655            return Ok(()); // Nothing to update
2656        }
2657
2658        updates.push("userModificationDate = ?");
2659        params.push(now.to_string());
2660
2661        let sql = format!("UPDATE TMTag SET {} WHERE uuid = ?", updates.join(", "));
2662        params.push(request.uuid.to_string());
2663
2664        let mut query = sqlx::query(&sql);
2665        for param in params {
2666            query = query.bind(param);
2667        }
2668
2669        query
2670            .execute(&self.pool)
2671            .await
2672            .map_err(|e| ThingsError::unknown(format!("Failed to update tag: {e}")))?;
2673
2674        info!("Updated tag with UUID: {}", request.uuid);
2675        Ok(())
2676    }
2677
2678    /// Delete a tag
2679    ///
2680    /// # Arguments
2681    ///
2682    /// * `uuid` - UUID of the tag to delete
2683    /// * `remove_from_tasks` - If true, removes tag from all tasks' cachedTags
2684    ///
2685    /// # Errors
2686    ///
2687    /// Returns an error if the database operation fails
2688    #[instrument(skip(self))]
2689    pub async fn delete_tag(&self, uuid: &Uuid, remove_from_tasks: bool) -> ThingsResult<()> {
2690        // Get the tag title before deletion
2691        let tag = self.find_tag_by_normalized_title(&uuid.to_string()).await?;
2692
2693        if tag.is_none() {
2694            // Try by UUID directly
2695            let row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2696                .bind(uuid.to_string())
2697                .fetch_optional(&self.pool)
2698                .await
2699                .map_err(|e| ThingsError::unknown(format!("Failed to find tag: {e}")))?;
2700
2701            if row.is_none() {
2702                return Err(ThingsError::unknown(format!("Tag not found: {}", uuid)));
2703            }
2704        }
2705
2706        if remove_from_tasks {
2707            // TODO: Implement updating all tasks' cachedTags to remove this tag
2708            // This requires parsing and re-serializing the JSON arrays
2709            info!("Removing tag {} from all tasks (not yet implemented)", uuid);
2710        }
2711
2712        // Delete the tag
2713        sqlx::query("DELETE FROM TMTag WHERE uuid = ?")
2714            .bind(uuid.to_string())
2715            .execute(&self.pool)
2716            .await
2717            .map_err(|e| ThingsError::unknown(format!("Failed to delete tag: {e}")))?;
2718
2719        info!("Deleted tag with UUID: {}", uuid);
2720        Ok(())
2721    }
2722
2723    /// Merge two tags (combine source into target)
2724    ///
2725    /// # Arguments
2726    ///
2727    /// * `source_uuid` - UUID of tag to merge from (will be deleted)
2728    /// * `target_uuid` - UUID of tag to merge into (will remain)
2729    ///
2730    /// # Errors
2731    ///
2732    /// Returns an error if either tag doesn't exist or database operation fails
2733    #[instrument(skip(self))]
2734    pub async fn merge_tags(&self, source_uuid: &Uuid, target_uuid: &Uuid) -> ThingsResult<()> {
2735        // Verify both tags exist
2736        let source_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2737            .bind(source_uuid.to_string())
2738            .fetch_optional(&self.pool)
2739            .await
2740            .map_err(|e| ThingsError::unknown(format!("Failed to find source tag: {e}")))?;
2741
2742        if source_row.is_none() {
2743            return Err(ThingsError::unknown(format!(
2744                "Source tag not found: {}",
2745                source_uuid
2746            )));
2747        }
2748
2749        let target_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2750            .bind(target_uuid.to_string())
2751            .fetch_optional(&self.pool)
2752            .await
2753            .map_err(|e| ThingsError::unknown(format!("Failed to find target tag: {e}")))?;
2754
2755        if target_row.is_none() {
2756            return Err(ThingsError::unknown(format!(
2757                "Target tag not found: {}",
2758                target_uuid
2759            )));
2760        }
2761
2762        // TODO: Implement updating all tasks' cachedTags to replace source tag with target tag
2763        // This requires parsing and re-serializing the JSON arrays
2764        info!(
2765            "Merging tag {} into {} (tag replacement in tasks not yet fully implemented)",
2766            source_uuid, target_uuid
2767        );
2768
2769        // Update usedDate on target if source was used more recently
2770        let now = Utc::now().timestamp() as f64;
2771        sqlx::query("UPDATE TMTag SET userModificationDate = ?, usedDate = ? WHERE uuid = ?")
2772            .bind(now)
2773            .bind(now)
2774            .bind(target_uuid.to_string())
2775            .execute(&self.pool)
2776            .await
2777            .map_err(|e| ThingsError::unknown(format!("Failed to update target tag: {e}")))?;
2778
2779        // Delete source tag
2780        sqlx::query("DELETE FROM TMTag WHERE uuid = ?")
2781            .bind(source_uuid.to_string())
2782            .execute(&self.pool)
2783            .await
2784            .map_err(|e| ThingsError::unknown(format!("Failed to delete source tag: {e}")))?;
2785
2786        info!("Merged tag {} into {}", source_uuid, target_uuid);
2787        Ok(())
2788    }
2789
2790    // ========================================================================
2791    // TAG ASSIGNMENT OPERATIONS
2792    // ========================================================================
2793
2794    /// Add a tag to a task (with duplicate prevention)
2795    ///
2796    /// Returns:
2797    /// - `Assigned`: Tag was successfully assigned
2798    /// - `Suggestions`: Similar tags found (user decision needed)
2799    ///
2800    /// # Errors
2801    ///
2802    /// Returns an error if the task doesn't exist or database operation fails
2803    #[instrument(skip(self))]
2804    pub async fn add_tag_to_task(
2805        &self,
2806        task_uuid: &Uuid,
2807        tag_title: &str,
2808    ) -> ThingsResult<crate::models::TagAssignmentResult> {
2809        use crate::database::tag_utils::normalize_tag_title;
2810        use crate::models::TagAssignmentResult;
2811
2812        // 1. Verify task exists
2813        validators::validate_task_exists(&self.pool, task_uuid).await?;
2814
2815        // 2. Normalize and find tag
2816        let normalized = normalize_tag_title(tag_title);
2817
2818        // 3. Check for exact match first
2819        let tag = if let Some(existing_tag) = self.find_tag_by_normalized_title(&normalized).await?
2820        {
2821            existing_tag
2822        } else {
2823            // 4. Find similar tags
2824            let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2825
2826            if !similar_tags.is_empty() {
2827                return Ok(TagAssignmentResult::Suggestions { similar_tags });
2828            }
2829
2830            // 5. No existing tag found, create new one
2831            let request = crate::models::CreateTagRequest {
2832                title: tag_title.to_string(),
2833                shortcut: None,
2834                parent_uuid: None,
2835            };
2836            let _uuid = self.create_tag_force(request).await?;
2837
2838            // Fetch the newly created tag
2839            self.find_tag_by_normalized_title(&normalized)
2840                .await?
2841                .ok_or_else(|| ThingsError::unknown("Failed to retrieve newly created tag"))?
2842        };
2843
2844        // 6. Get current tags from task
2845        let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
2846            .bind(task_uuid.to_string())
2847            .fetch_one(&self.pool)
2848            .await
2849            .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
2850
2851        let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
2852        let mut tags: Vec<String> = if let Some(blob) = cached_tags_blob {
2853            deserialize_tags_from_blob(&blob)?
2854        } else {
2855            Vec::new()
2856        };
2857
2858        // 7. Add tag if not already present
2859        if !tags.contains(&tag.title) {
2860            tags.push(tag.title.clone());
2861
2862            // 8. Serialize and update
2863            let cached_tags = serialize_tags_to_blob(&tags)?;
2864            let now = Utc::now().timestamp() as f64;
2865
2866            sqlx::query(
2867                "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2868            )
2869            .bind(cached_tags)
2870            .bind(now)
2871            .bind(task_uuid.to_string())
2872            .execute(&self.pool)
2873            .await
2874            .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2875
2876            // 9. Update tag's usedDate
2877            sqlx::query("UPDATE TMTag SET usedDate = ?, userModificationDate = ? WHERE uuid = ?")
2878                .bind(now)
2879                .bind(now)
2880                .bind(tag.uuid.to_string())
2881                .execute(&self.pool)
2882                .await
2883                .map_err(|e| ThingsError::unknown(format!("Failed to update tag usedDate: {e}")))?;
2884
2885            info!("Added tag '{}' to task {}", tag.title, task_uuid);
2886        }
2887
2888        Ok(TagAssignmentResult::Assigned { tag_uuid: tag.uuid })
2889    }
2890
2891    /// Remove a tag from a task
2892    ///
2893    /// # Errors
2894    ///
2895    /// Returns an error if the task doesn't exist or database operation fails
2896    #[instrument(skip(self))]
2897    pub async fn remove_tag_from_task(
2898        &self,
2899        task_uuid: &Uuid,
2900        tag_title: &str,
2901    ) -> ThingsResult<()> {
2902        use crate::database::tag_utils::normalize_tag_title;
2903
2904        // 1. Verify task exists
2905        validators::validate_task_exists(&self.pool, task_uuid).await?;
2906
2907        // 2. Get current tags from task
2908        let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
2909            .bind(task_uuid.to_string())
2910            .fetch_one(&self.pool)
2911            .await
2912            .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
2913
2914        let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
2915        let mut tags: Vec<String> = if let Some(blob) = cached_tags_blob {
2916            deserialize_tags_from_blob(&blob)?
2917        } else {
2918            return Ok(()); // No tags to remove
2919        };
2920
2921        // 3. Normalize and find the tag to remove (case-insensitive)
2922        let normalized = normalize_tag_title(tag_title);
2923        let original_len = tags.len();
2924        tags.retain(|t| normalize_tag_title(t) != normalized);
2925
2926        // 4. If tags were actually removed, update the task
2927        if tags.len() < original_len {
2928            let cached_tags = if tags.is_empty() {
2929                None
2930            } else {
2931                Some(serialize_tags_to_blob(&tags)?)
2932            };
2933
2934            let now = Utc::now().timestamp() as f64;
2935
2936            if let Some(cached_tags_val) = cached_tags {
2937                sqlx::query(
2938                    "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2939                )
2940                .bind(cached_tags_val)
2941                .bind(now)
2942                .bind(task_uuid.to_string())
2943                .execute(&self.pool)
2944                .await
2945                .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2946            } else {
2947                // Set cachedTags to NULL if no tags remain
2948                sqlx::query(
2949                    "UPDATE TMTask SET cachedTags = NULL, userModificationDate = ? WHERE uuid = ?",
2950                )
2951                .bind(now)
2952                .bind(task_uuid.to_string())
2953                .execute(&self.pool)
2954                .await
2955                .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2956            }
2957
2958            info!("Removed tag '{}' from task {}", tag_title, task_uuid);
2959        }
2960
2961        Ok(())
2962    }
2963
2964    /// Replace all tags on a task (with duplicate prevention)
2965    ///
2966    /// Returns any tag titles that had similar matches for user confirmation
2967    ///
2968    /// # Errors
2969    ///
2970    /// Returns an error if the task doesn't exist or database operation fails
2971    #[instrument(skip(self))]
2972    pub async fn set_task_tags(
2973        &self,
2974        task_uuid: &Uuid,
2975        tag_titles: Vec<String>,
2976    ) -> ThingsResult<Vec<crate::models::TagMatch>> {
2977        use crate::database::tag_utils::normalize_tag_title;
2978
2979        // 1. Verify task exists
2980        validators::validate_task_exists(&self.pool, task_uuid).await?;
2981
2982        let mut resolved_tags = Vec::new();
2983        let mut suggestions = Vec::new();
2984
2985        // 2. Resolve each tag title
2986        for title in tag_titles {
2987            let normalized = normalize_tag_title(&title);
2988
2989            // Try to find exact match
2990            if let Some(existing_tag) = self.find_tag_by_normalized_title(&normalized).await? {
2991                resolved_tags.push(existing_tag.title);
2992            } else {
2993                // Check for similar tags
2994                let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2995
2996                if !similar_tags.is_empty() {
2997                    suggestions.extend(similar_tags);
2998                }
2999
3000                // Use the requested title anyway (will create if needed)
3001                resolved_tags.push(title);
3002            }
3003        }
3004
3005        // 3. For any tags that don't exist yet, create them
3006        for title in &resolved_tags {
3007            let normalized = normalize_tag_title(title);
3008            if self
3009                .find_tag_by_normalized_title(&normalized)
3010                .await?
3011                .is_none()
3012            {
3013                let request = crate::models::CreateTagRequest {
3014                    title: title.clone(),
3015                    shortcut: None,
3016                    parent_uuid: None,
3017                };
3018                self.create_tag_force(request).await?;
3019            }
3020        }
3021
3022        // 4. Update task's cachedTags
3023        let cached_tags = if resolved_tags.is_empty() {
3024            None
3025        } else {
3026            Some(serialize_tags_to_blob(&resolved_tags)?)
3027        };
3028
3029        let now = Utc::now().timestamp() as f64;
3030
3031        if let Some(cached_tags_val) = cached_tags {
3032            sqlx::query(
3033                "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
3034            )
3035            .bind(cached_tags_val)
3036            .bind(now)
3037            .bind(task_uuid.to_string())
3038            .execute(&self.pool)
3039            .await
3040            .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
3041        } else {
3042            sqlx::query(
3043                "UPDATE TMTask SET cachedTags = NULL, userModificationDate = ? WHERE uuid = ?",
3044            )
3045            .bind(now)
3046            .bind(task_uuid.to_string())
3047            .execute(&self.pool)
3048            .await
3049            .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
3050        }
3051
3052        // 5. Update usedDate for all tags
3053        for title in &resolved_tags {
3054            let normalized = normalize_tag_title(title);
3055            if let Some(tag) = self.find_tag_by_normalized_title(&normalized).await? {
3056                sqlx::query(
3057                    "UPDATE TMTag SET usedDate = ?, userModificationDate = ? WHERE uuid = ?",
3058                )
3059                .bind(now)
3060                .bind(now)
3061                .bind(tag.uuid.to_string())
3062                .execute(&self.pool)
3063                .await
3064                .map_err(|e| ThingsError::unknown(format!("Failed to update tag usedDate: {e}")))?;
3065            }
3066        }
3067
3068        info!("Set tags on task {} to: {:?}", task_uuid, resolved_tags);
3069        Ok(suggestions)
3070    }
3071
3072    // ========================================================================
3073    // TAG AUTO-COMPLETION & ANALYTICS
3074    // ========================================================================
3075
3076    /// Get tag completions for partial input
3077    ///
3078    /// Returns tags sorted by:
3079    /// 1. Exact prefix matches (prioritized)
3080    /// 2. Contains matches
3081    /// 3. Fuzzy matches
3082    /// Within each category, sorted by usage frequency
3083    ///
3084    /// # Errors
3085    ///
3086    /// Returns an error if the database query fails
3087    #[instrument(skip(self))]
3088    pub async fn get_tag_completions(
3089        &self,
3090        partial_input: &str,
3091        limit: usize,
3092    ) -> ThingsResult<Vec<crate::models::TagCompletion>> {
3093        use crate::database::tag_utils::{calculate_similarity, normalize_tag_title};
3094
3095        let normalized_input = normalize_tag_title(partial_input);
3096        let all_tags = self.get_all_tags().await?;
3097
3098        let mut completions: Vec<crate::models::TagCompletion> = all_tags
3099            .into_iter()
3100            .filter_map(|tag| {
3101                let normalized_tag = normalize_tag_title(&tag.title);
3102
3103                // Calculate score based on match type
3104                let score = if normalized_tag.starts_with(&normalized_input) {
3105                    // Exact prefix match: highest priority
3106                    3.0 + (tag.usage_count as f32 / 100.0)
3107                } else if normalized_tag.contains(&normalized_input) {
3108                    // Contains match: medium priority
3109                    2.0 + (tag.usage_count as f32 / 100.0)
3110                } else {
3111                    // Fuzzy match: lower priority
3112                    let similarity = calculate_similarity(partial_input, &tag.title);
3113                    if similarity >= 0.6 {
3114                        similarity + (tag.usage_count as f32 / 1000.0)
3115                    } else {
3116                        return None; // Not similar enough
3117                    }
3118                };
3119
3120                Some(crate::models::TagCompletion { tag, score })
3121            })
3122            .collect();
3123
3124        // Sort by score (highest first)
3125        completions.sort_by(|a, b| {
3126            b.score
3127                .partial_cmp(&a.score)
3128                .unwrap_or(std::cmp::Ordering::Equal)
3129        });
3130
3131        // Take the top N
3132        completions.truncate(limit);
3133
3134        Ok(completions)
3135    }
3136
3137    /// Get detailed statistics for a tag
3138    ///
3139    /// # Errors
3140    ///
3141    /// Returns an error if the tag doesn't exist or database query fails
3142    #[instrument(skip(self))]
3143    pub async fn get_tag_statistics(
3144        &self,
3145        uuid: &Uuid,
3146    ) -> ThingsResult<crate::models::TagStatistics> {
3147        // Get the tag
3148        let tag_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
3149            .bind(uuid.to_string())
3150            .fetch_optional(&self.pool)
3151            .await
3152            .map_err(|e| ThingsError::unknown(format!("Failed to find tag: {e}")))?;
3153
3154        let title: String = tag_row
3155            .ok_or_else(|| ThingsError::unknown(format!("Tag not found: {}", uuid)))?
3156            .get("title");
3157
3158        // Get all tasks using this tag
3159        // Note: We query cachedTags BLOB which should contain JSON, but handle gracefully if malformed
3160        let task_rows = sqlx::query(
3161            "SELECT uuid, cachedTags FROM TMTask 
3162             WHERE cachedTags IS NOT NULL 
3163             AND trashed = 0",
3164        )
3165        .fetch_all(&self.pool)
3166        .await
3167        .map_err(|e| ThingsError::unknown(format!("Failed to query tasks with tag: {e}")))?;
3168
3169        let mut task_uuids = Vec::new();
3170        for row in task_rows {
3171            let uuid_str: String = row.get("uuid");
3172            let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
3173
3174            // Check if this task actually has the tag
3175            if let Some(blob) = cached_tags_blob {
3176                if let Ok(tags) = deserialize_tags_from_blob(&blob) {
3177                    if tags.iter().any(|t| t.eq_ignore_ascii_case(&title)) {
3178                        let task_uuid = Uuid::parse_str(&uuid_str)
3179                            .unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
3180                        task_uuids.push(task_uuid);
3181                    }
3182                }
3183            }
3184        }
3185
3186        let usage_count = task_uuids.len() as u32;
3187
3188        // Find related tags (tags that frequently appear with this tag)
3189        let mut related_tags: std::collections::HashMap<String, u32> =
3190            std::collections::HashMap::new();
3191
3192        for task_uuid in &task_uuids {
3193            let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
3194                .bind(task_uuid.to_string())
3195                .fetch_optional(&self.pool)
3196                .await
3197                .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
3198
3199            if let Some(row) = row {
3200                let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
3201                if let Some(blob) = cached_tags_blob {
3202                    let tags: Vec<String> = deserialize_tags_from_blob(&blob)?;
3203                    for tag in tags {
3204                        if tag != title {
3205                            *related_tags.entry(tag).or_insert(0) += 1;
3206                        }
3207                    }
3208                }
3209            }
3210        }
3211
3212        // Sort related tags by co-occurrence count
3213        let mut related_vec: Vec<(String, u32)> = related_tags.into_iter().collect();
3214        related_vec.sort_by_key(|r| std::cmp::Reverse(r.1));
3215
3216        Ok(crate::models::TagStatistics {
3217            uuid: *uuid,
3218            title,
3219            usage_count,
3220            task_uuids,
3221            related_tags: related_vec,
3222        })
3223    }
3224
3225    /// Find duplicate or highly similar tags
3226    ///
3227    /// Returns pairs of tags that are similar above the threshold
3228    ///
3229    /// # Errors
3230    ///
3231    /// Returns an error if the database query fails
3232    #[instrument(skip(self))]
3233    pub async fn find_duplicate_tags(
3234        &self,
3235        min_similarity: f32,
3236    ) -> ThingsResult<Vec<crate::models::TagPair>> {
3237        use crate::database::tag_utils::calculate_similarity;
3238
3239        let all_tags = self.get_all_tags().await?;
3240        let mut pairs = Vec::new();
3241
3242        // Compare each tag with every other tag
3243        for i in 0..all_tags.len() {
3244            for j in (i + 1)..all_tags.len() {
3245                let tag1 = &all_tags[i];
3246                let tag2 = &all_tags[j];
3247
3248                let similarity = calculate_similarity(&tag1.title, &tag2.title);
3249
3250                if similarity >= min_similarity {
3251                    pairs.push(crate::models::TagPair {
3252                        tag1: tag1.clone(),
3253                        tag2: tag2.clone(),
3254                        similarity,
3255                    });
3256                }
3257            }
3258        }
3259
3260        // Sort by similarity (highest first)
3261        pairs.sort_by(|a, b| {
3262            b.similarity
3263                .partial_cmp(&a.similarity)
3264                .unwrap_or(std::cmp::Ordering::Equal)
3265        });
3266
3267        Ok(pairs)
3268    }
3269
3270    // ============================================================================
3271    // Bulk Operations
3272    // ============================================================================
3273
3274    /// Maximum number of tasks that can be processed in a single bulk operation
3275    /// This prevents abuse and ensures reasonable transaction sizes
3276    const MAX_BULK_BATCH_SIZE: usize = 1000;
3277
3278    /// Move multiple tasks to a project or area (transactional)
3279    ///
3280    /// All tasks must exist and be valid, or the entire operation will be rolled back.
3281    ///
3282    /// # Errors
3283    ///
3284    /// Returns an error if:
3285    /// - Task UUIDs array is empty
3286    /// - Neither project_uuid nor area_uuid is specified
3287    /// - Target project or area doesn't exist
3288    /// - Any task UUID is invalid or doesn't exist
3289    /// - Database operation fails
3290    #[instrument(skip(self))]
3291    pub async fn bulk_move(
3292        &self,
3293        request: crate::models::BulkMoveRequest,
3294    ) -> ThingsResult<crate::models::BulkOperationResult> {
3295        // Validation
3296        if request.task_uuids.is_empty() {
3297            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3298        }
3299        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3300            return Err(ThingsError::validation(format!(
3301                "Batch size {} exceeds maximum of {}",
3302                request.task_uuids.len(),
3303                Self::MAX_BULK_BATCH_SIZE
3304            )));
3305        }
3306        if request.project_uuid.is_none() && request.area_uuid.is_none() {
3307            return Err(ThingsError::validation(
3308                "Must specify either project_uuid or area_uuid",
3309            ));
3310        }
3311
3312        // Validate target project/area exists
3313        if let Some(project_uuid) = &request.project_uuid {
3314            validators::validate_project_exists(&self.pool, project_uuid).await?;
3315        }
3316        if let Some(area_uuid) = &request.area_uuid {
3317            validators::validate_area_exists(&self.pool, area_uuid).await?;
3318        }
3319
3320        // Begin transaction
3321        let mut tx = self
3322            .pool
3323            .begin()
3324            .await
3325            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3326
3327        // Validate all tasks exist in a single batch query (prevent N+1)
3328        let placeholders = request
3329            .task_uuids
3330            .iter()
3331            .map(|_| "?")
3332            .collect::<Vec<_>>()
3333            .join(",");
3334        let query_str = format!(
3335            "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3336            placeholders
3337        );
3338
3339        let mut query = sqlx::query(&query_str);
3340        for uuid in &request.task_uuids {
3341            query = query.bind(uuid.to_string());
3342        }
3343
3344        let found_uuids: Vec<String> = query
3345            .fetch_all(&mut *tx)
3346            .await
3347            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3348            .iter()
3349            .map(|row| row.get("uuid"))
3350            .collect();
3351
3352        // Check if any UUIDs were not found
3353        if found_uuids.len() != request.task_uuids.len() {
3354            // Find the first missing UUID for error reporting
3355            for uuid in &request.task_uuids {
3356                if !found_uuids.contains(&uuid.to_string()) {
3357                    tx.rollback().await.ok();
3358                    return Err(ThingsError::TaskNotFound {
3359                        uuid: uuid.to_string(),
3360                    });
3361                }
3362            }
3363        }
3364
3365        // Batch update
3366        let now = Utc::now().timestamp() as f64;
3367        let placeholders = request
3368            .task_uuids
3369            .iter()
3370            .map(|_| "?")
3371            .collect::<Vec<_>>()
3372            .join(",");
3373        let query_str = format!(
3374            "UPDATE TMTask SET project = ?, area = ?, userModificationDate = ? WHERE uuid IN ({})",
3375            placeholders
3376        );
3377
3378        let mut query = sqlx::query(&query_str)
3379            .bind(request.project_uuid.map(|u| u.to_string()))
3380            .bind(request.area_uuid.map(|u| u.to_string()))
3381            .bind(now);
3382
3383        for uuid in &request.task_uuids {
3384            query = query.bind(uuid.to_string());
3385        }
3386
3387        query
3388            .execute(&mut *tx)
3389            .await
3390            .map_err(|e| ThingsError::unknown(format!("Failed to bulk move tasks: {e}")))?;
3391
3392        // Commit transaction
3393        tx.commit()
3394            .await
3395            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3396
3397        info!("Bulk moved {} task(s)", request.task_uuids.len());
3398        Ok(crate::models::BulkOperationResult {
3399            success: true,
3400            processed_count: request.task_uuids.len(),
3401            message: format!("Successfully moved {} task(s)", request.task_uuids.len()),
3402        })
3403    }
3404
3405    /// Update dates for multiple tasks with validation (transactional)
3406    ///
3407    /// All tasks must exist and dates must be valid, or the entire operation will be rolled back.
3408    /// Validates that deadline >= start_date for each task after merging with existing dates.
3409    ///
3410    /// # Errors
3411    ///
3412    /// Returns an error if:
3413    /// - Task UUIDs array is empty
3414    /// - Any task UUID is invalid or doesn't exist
3415    /// - Date range validation fails (deadline before start_date)
3416    /// - Database operation fails
3417    #[instrument(skip(self))]
3418    pub async fn bulk_update_dates(
3419        &self,
3420        request: crate::models::BulkUpdateDatesRequest,
3421    ) -> ThingsResult<crate::models::BulkOperationResult> {
3422        use crate::database::{safe_things_date_to_naive_date, validate_date_range};
3423
3424        // Validation
3425        if request.task_uuids.is_empty() {
3426            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3427        }
3428        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3429            return Err(ThingsError::validation(format!(
3430                "Batch size {} exceeds maximum of {}",
3431                request.task_uuids.len(),
3432                Self::MAX_BULK_BATCH_SIZE
3433            )));
3434        }
3435
3436        // Validate date range if both are provided
3437        if let (Some(start), Some(deadline)) = (request.start_date, request.deadline) {
3438            validate_date_range(Some(start), Some(deadline))?;
3439        }
3440
3441        // Begin transaction
3442        let mut tx = self
3443            .pool
3444            .begin()
3445            .await
3446            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3447
3448        // Validate all tasks exist and check merged date validity in a single batch query
3449        let placeholders = request
3450            .task_uuids
3451            .iter()
3452            .map(|_| "?")
3453            .collect::<Vec<_>>()
3454            .join(",");
3455        let query_str = format!(
3456            "SELECT uuid, startDate, deadline FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3457            placeholders
3458        );
3459
3460        let mut query = sqlx::query(&query_str);
3461        for uuid in &request.task_uuids {
3462            query = query.bind(uuid.to_string());
3463        }
3464
3465        let rows = query
3466            .fetch_all(&mut *tx)
3467            .await
3468            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?;
3469
3470        // Check if all UUIDs were found
3471        if rows.len() != request.task_uuids.len() {
3472            // Find the first missing UUID for error reporting
3473            let found_uuids: Vec<String> = rows.iter().map(|row| row.get("uuid")).collect();
3474            for uuid in &request.task_uuids {
3475                if !found_uuids.contains(&uuid.to_string()) {
3476                    tx.rollback().await.ok();
3477                    return Err(ThingsError::TaskNotFound {
3478                        uuid: uuid.to_string(),
3479                    });
3480                }
3481            }
3482        }
3483
3484        // Validate merged dates for all tasks
3485        for row in &rows {
3486            let current_start: Option<i64> = row.get("startDate");
3487            let current_deadline: Option<i64> = row.get("deadline");
3488
3489            let final_start = if request.clear_start_date {
3490                None
3491            } else if let Some(new_start) = request.start_date {
3492                Some(new_start)
3493            } else {
3494                current_start.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
3495            };
3496
3497            let final_deadline = if request.clear_deadline {
3498                None
3499            } else if let Some(new_deadline) = request.deadline {
3500                Some(new_deadline)
3501            } else {
3502                current_deadline.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
3503            };
3504
3505            validate_date_range(final_start, final_deadline)?;
3506        }
3507
3508        // Build and execute batch update
3509        let now = Utc::now().timestamp() as f64;
3510        let placeholders = request
3511            .task_uuids
3512            .iter()
3513            .map(|_| "?")
3514            .collect::<Vec<_>>()
3515            .join(",");
3516
3517        let start_date_value = if request.clear_start_date {
3518            None
3519        } else {
3520            request.start_date.map(naive_date_to_things_timestamp)
3521        };
3522
3523        let deadline_value = if request.clear_deadline {
3524            None
3525        } else {
3526            request.deadline.map(naive_date_to_things_timestamp)
3527        };
3528
3529        let query_str = format!(
3530            "UPDATE TMTask SET startDate = ?, deadline = ?, userModificationDate = ? WHERE uuid IN ({})",
3531            placeholders
3532        );
3533
3534        let mut query = sqlx::query(&query_str)
3535            .bind(start_date_value)
3536            .bind(deadline_value)
3537            .bind(now);
3538
3539        for uuid in &request.task_uuids {
3540            query = query.bind(uuid.to_string());
3541        }
3542
3543        query
3544            .execute(&mut *tx)
3545            .await
3546            .map_err(|e| ThingsError::unknown(format!("Failed to bulk update dates: {e}")))?;
3547
3548        tx.commit()
3549            .await
3550            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3551
3552        info!(
3553            "Bulk updated dates for {} task(s)",
3554            request.task_uuids.len()
3555        );
3556        Ok(crate::models::BulkOperationResult {
3557            success: true,
3558            processed_count: request.task_uuids.len(),
3559            message: format!(
3560                "Successfully updated dates for {} task(s)",
3561                request.task_uuids.len()
3562            ),
3563        })
3564    }
3565
3566    /// Complete multiple tasks (transactional)
3567    ///
3568    /// All tasks must exist, or the entire operation will be rolled back.
3569    ///
3570    /// # Errors
3571    ///
3572    /// Returns an error if:
3573    /// - Task UUIDs array is empty
3574    /// - Any task UUID is invalid or doesn't exist
3575    /// - Database operation fails
3576    #[instrument(skip(self))]
3577    pub async fn bulk_complete(
3578        &self,
3579        request: crate::models::BulkCompleteRequest,
3580    ) -> ThingsResult<crate::models::BulkOperationResult> {
3581        // Validation
3582        if request.task_uuids.is_empty() {
3583            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3584        }
3585        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3586            return Err(ThingsError::validation(format!(
3587                "Batch size {} exceeds maximum of {}",
3588                request.task_uuids.len(),
3589                Self::MAX_BULK_BATCH_SIZE
3590            )));
3591        }
3592
3593        // Begin transaction
3594        let mut tx = self
3595            .pool
3596            .begin()
3597            .await
3598            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3599
3600        // Validate all tasks exist in a single batch query (prevent N+1)
3601        let placeholders = request
3602            .task_uuids
3603            .iter()
3604            .map(|_| "?")
3605            .collect::<Vec<_>>()
3606            .join(",");
3607        let query_str = format!(
3608            "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3609            placeholders
3610        );
3611
3612        let mut query = sqlx::query(&query_str);
3613        for uuid in &request.task_uuids {
3614            query = query.bind(uuid.to_string());
3615        }
3616
3617        let found_uuids: Vec<String> = query
3618            .fetch_all(&mut *tx)
3619            .await
3620            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3621            .iter()
3622            .map(|row| row.get("uuid"))
3623            .collect();
3624
3625        // Check if any UUIDs were not found
3626        if found_uuids.len() != request.task_uuids.len() {
3627            // Find the first missing UUID for error reporting
3628            for uuid in &request.task_uuids {
3629                if !found_uuids.contains(&uuid.to_string()) {
3630                    tx.rollback().await.ok();
3631                    return Err(ThingsError::TaskNotFound {
3632                        uuid: uuid.to_string(),
3633                    });
3634                }
3635            }
3636        }
3637
3638        // Batch update: mark as completed
3639        let now = Utc::now().timestamp() as f64;
3640        let placeholders = request
3641            .task_uuids
3642            .iter()
3643            .map(|_| "?")
3644            .collect::<Vec<_>>()
3645            .join(",");
3646        let query_str = format!(
3647            "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid IN ({})",
3648            placeholders
3649        );
3650
3651        let mut query = sqlx::query(&query_str).bind(now).bind(now);
3652
3653        for uuid in &request.task_uuids {
3654            query = query.bind(uuid.to_string());
3655        }
3656
3657        query
3658            .execute(&mut *tx)
3659            .await
3660            .map_err(|e| ThingsError::unknown(format!("Failed to bulk complete tasks: {e}")))?;
3661
3662        // Commit transaction
3663        tx.commit()
3664            .await
3665            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3666
3667        info!("Bulk completed {} task(s)", request.task_uuids.len());
3668        Ok(crate::models::BulkOperationResult {
3669            success: true,
3670            processed_count: request.task_uuids.len(),
3671            message: format!(
3672                "Successfully completed {} task(s)",
3673                request.task_uuids.len()
3674            ),
3675        })
3676    }
3677
3678    /// Delete multiple tasks (soft delete, transactional)
3679    ///
3680    /// All tasks must exist, or the entire operation will be rolled back.
3681    ///
3682    /// # Errors
3683    ///
3684    /// Returns an error if:
3685    /// - Task UUIDs array is empty
3686    /// - Any task UUID is invalid or doesn't exist
3687    /// - Database operation fails
3688    #[instrument(skip(self))]
3689    pub async fn bulk_delete(
3690        &self,
3691        request: crate::models::BulkDeleteRequest,
3692    ) -> ThingsResult<crate::models::BulkOperationResult> {
3693        // Validation
3694        if request.task_uuids.is_empty() {
3695            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3696        }
3697        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3698            return Err(ThingsError::validation(format!(
3699                "Batch size {} exceeds maximum of {}",
3700                request.task_uuids.len(),
3701                Self::MAX_BULK_BATCH_SIZE
3702            )));
3703        }
3704
3705        // Begin transaction
3706        let mut tx = self
3707            .pool
3708            .begin()
3709            .await
3710            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3711
3712        // Validate all tasks exist in a single batch query (prevent N+1)
3713        let placeholders = request
3714            .task_uuids
3715            .iter()
3716            .map(|_| "?")
3717            .collect::<Vec<_>>()
3718            .join(",");
3719        let query_str = format!(
3720            "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3721            placeholders
3722        );
3723
3724        let mut query = sqlx::query(&query_str);
3725        for uuid in &request.task_uuids {
3726            query = query.bind(uuid.to_string());
3727        }
3728
3729        let found_uuids: Vec<String> = query
3730            .fetch_all(&mut *tx)
3731            .await
3732            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3733            .iter()
3734            .map(|row| row.get("uuid"))
3735            .collect();
3736
3737        // Check if any UUIDs were not found
3738        if found_uuids.len() != request.task_uuids.len() {
3739            // Find the first missing UUID for error reporting
3740            for uuid in &request.task_uuids {
3741                if !found_uuids.contains(&uuid.to_string()) {
3742                    tx.rollback().await.ok();
3743                    return Err(ThingsError::TaskNotFound {
3744                        uuid: uuid.to_string(),
3745                    });
3746                }
3747            }
3748        }
3749
3750        // Batch update: soft delete
3751        let now = Utc::now().timestamp() as f64;
3752        let placeholders = request
3753            .task_uuids
3754            .iter()
3755            .map(|_| "?")
3756            .collect::<Vec<_>>()
3757            .join(",");
3758        let query_str = format!(
3759            "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid IN ({})",
3760            placeholders
3761        );
3762
3763        let mut query = sqlx::query(&query_str).bind(now);
3764
3765        for uuid in &request.task_uuids {
3766            query = query.bind(uuid.to_string());
3767        }
3768
3769        query
3770            .execute(&mut *tx)
3771            .await
3772            .map_err(|e| ThingsError::unknown(format!("Failed to bulk delete tasks: {e}")))?;
3773
3774        // Commit transaction
3775        tx.commit()
3776            .await
3777            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3778
3779        info!("Bulk deleted {} task(s)", request.task_uuids.len());
3780        Ok(crate::models::BulkOperationResult {
3781            success: true,
3782            processed_count: request.task_uuids.len(),
3783            message: format!("Successfully deleted {} task(s)", request.task_uuids.len()),
3784        })
3785    }
3786}
3787
3788/// Database statistics
3789#[derive(Debug, Clone, Serialize, Deserialize)]
3790pub struct DatabaseStats {
3791    pub task_count: u64,
3792    pub project_count: u64,
3793    pub area_count: u64,
3794}
3795
3796impl DatabaseStats {
3797    #[must_use]
3798    pub fn total_items(&self) -> u64 {
3799        self.task_count + self.project_count + self.area_count
3800    }
3801}
3802
3803/// Get the default Things 3 database path
3804///
3805/// # Examples
3806///
3807/// ```
3808/// use things3_core::get_default_database_path;
3809///
3810/// let path = get_default_database_path();
3811/// assert!(!path.to_string_lossy().is_empty());
3812/// assert!(path.to_string_lossy().contains("Library"));
3813/// ```
3814#[must_use]
3815pub fn get_default_database_path() -> PathBuf {
3816    let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
3817    PathBuf::from(format!(
3818        "{home}/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-0Z0Z2/Things Database.thingsdatabase/main.sqlite"
3819    ))
3820}
3821
3822#[cfg(test)]
3823mod tests {
3824    use super::*;
3825    use tempfile::{NamedTempFile, TempDir};
3826
3827    #[tokio::test]
3828    async fn test_database_connection() {
3829        let temp_dir = TempDir::new().unwrap();
3830        let db_path = temp_dir.path().join("test.db");
3831
3832        // This will fail because the database doesn't exist yet
3833        // In a real implementation, we'd need to create the schema first
3834        let result = super::ThingsDatabase::new(&db_path).await;
3835        assert!(result.is_err());
3836    }
3837
3838    #[tokio::test]
3839    async fn test_connection_string() {
3840        let result = super::ThingsDatabase::from_connection_string("sqlite::memory:").await;
3841        assert!(result.is_ok());
3842    }
3843
3844    #[test]
3845    fn test_task_status_from_i32() {
3846        assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
3847        assert_eq!(TaskStatus::from_i32(1), Some(TaskStatus::Completed));
3848        assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
3849        assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Trashed));
3850        assert_eq!(TaskStatus::from_i32(4), None);
3851        assert_eq!(TaskStatus::from_i32(-1), None);
3852    }
3853
3854    #[test]
3855    fn test_task_type_from_i32() {
3856        assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
3857        assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
3858        assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
3859        assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
3860        assert_eq!(TaskType::from_i32(4), None);
3861        assert_eq!(TaskType::from_i32(-1), None);
3862    }
3863
3864    #[test]
3865    fn test_database_stats_total_items() {
3866        let stats = DatabaseStats {
3867            task_count: 10,
3868            project_count: 5,
3869            area_count: 3,
3870        };
3871        assert_eq!(stats.total_items(), 18);
3872
3873        let empty_stats = DatabaseStats {
3874            task_count: 0,
3875            project_count: 0,
3876            area_count: 0,
3877        };
3878        assert_eq!(empty_stats.total_items(), 0);
3879    }
3880
3881    #[test]
3882    fn test_database_pool_config_default() {
3883        let config = DatabasePoolConfig::default();
3884        assert_eq!(config.max_connections, 10);
3885        assert_eq!(config.min_connections, 1);
3886        assert_eq!(config.connect_timeout, Duration::from_secs(30));
3887        assert_eq!(config.idle_timeout, Duration::from_secs(600));
3888        assert_eq!(config.max_lifetime, Duration::from_secs(1800));
3889        assert!(config.test_before_acquire);
3890    }
3891
3892    #[test]
3893    fn test_sqlite_optimizations_default() {
3894        let opts = SqliteOptimizations::default();
3895        assert!(opts.enable_wal_mode);
3896        assert_eq!(opts.cache_size, -20000);
3897        assert_eq!(opts.synchronous_mode, "NORMAL".to_string());
3898        assert_eq!(opts.temp_store, "MEMORY".to_string());
3899        assert_eq!(opts.journal_mode, "WAL".to_string());
3900        assert_eq!(opts.mmap_size, 268_435_456);
3901        assert!(opts.enable_foreign_keys);
3902        assert!(opts.enable_query_planner);
3903    }
3904
3905    #[test]
3906    fn test_pool_health_status_creation() {
3907        let status = PoolHealthStatus {
3908            is_healthy: true,
3909            pool_size: 8,
3910            active_connections: 5,
3911            idle_connections: 3,
3912            max_connections: 10,
3913            min_connections: 1,
3914            connection_timeout: Duration::from_secs(30),
3915            idle_timeout: Some(Duration::from_secs(600)),
3916            max_lifetime: Some(Duration::from_secs(1800)),
3917        };
3918        assert!(status.is_healthy);
3919        assert_eq!(status.active_connections, 5);
3920        assert_eq!(status.idle_connections, 3);
3921        assert_eq!(status.pool_size, 8);
3922    }
3923
3924    #[test]
3925    fn test_pool_metrics_creation() {
3926        let metrics = PoolMetrics {
3927            pool_size: 8,
3928            active_connections: 5,
3929            idle_connections: 3,
3930            max_connections: 10,
3931            min_connections: 1,
3932            utilization_percentage: 80.0,
3933            is_healthy: true,
3934            response_time_ms: 50,
3935            connection_timeout: Duration::from_secs(30),
3936            idle_timeout: Some(Duration::from_secs(600)),
3937            max_lifetime: Some(Duration::from_secs(1800)),
3938        };
3939        assert!(metrics.is_healthy);
3940        assert_eq!(metrics.pool_size, 8);
3941        assert_eq!(metrics.active_connections, 5);
3942        assert_eq!(metrics.idle_connections, 3);
3943        assert!((metrics.utilization_percentage - 80.0).abs() < f64::EPSILON);
3944        assert_eq!(metrics.response_time_ms, 50);
3945    }
3946
3947    #[test]
3948    fn test_comprehensive_health_status_creation() {
3949        let pool_health = PoolHealthStatus {
3950            is_healthy: true,
3951            pool_size: 8,
3952            active_connections: 5,
3953            idle_connections: 3,
3954            max_connections: 10,
3955            min_connections: 1,
3956            connection_timeout: Duration::from_secs(30),
3957            idle_timeout: Some(Duration::from_secs(600)),
3958            max_lifetime: Some(Duration::from_secs(1800)),
3959        };
3960
3961        let pool_metrics = PoolMetrics {
3962            pool_size: 8,
3963            active_connections: 5,
3964            idle_connections: 3,
3965            max_connections: 10,
3966            min_connections: 1,
3967            utilization_percentage: 80.0,
3968            is_healthy: true,
3969            response_time_ms: 50,
3970            connection_timeout: Duration::from_secs(30),
3971            idle_timeout: Some(Duration::from_secs(600)),
3972            max_lifetime: Some(Duration::from_secs(1800)),
3973        };
3974
3975        let db_stats = DatabaseStats {
3976            task_count: 50,
3977            project_count: 10,
3978            area_count: 5,
3979        };
3980
3981        let health_status = ComprehensiveHealthStatus {
3982            overall_healthy: true,
3983            pool_health,
3984            pool_metrics,
3985            database_stats: db_stats,
3986            timestamp: Utc::now(),
3987        };
3988
3989        assert!(health_status.overall_healthy);
3990        assert_eq!(health_status.database_stats.total_items(), 65);
3991    }
3992
3993    #[test]
3994    fn test_safe_timestamp_convert_edge_cases() {
3995        // Test normal timestamp
3996        assert_eq!(safe_timestamp_convert(1_609_459_200.0), 1_609_459_200); // 2021-01-01
3997
3998        // Test zero
3999        assert_eq!(safe_timestamp_convert(0.0), 0);
4000
4001        // Test negative (should return 0)
4002        assert_eq!(safe_timestamp_convert(-1.0), 0);
4003
4004        // Test infinity (should return 0)
4005        assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
4006
4007        // Test NaN (should return 0)
4008        assert_eq!(safe_timestamp_convert(f64::NAN), 0);
4009
4010        // Test very large timestamp (should return 0)
4011        assert_eq!(safe_timestamp_convert(5_000_000_000.0), 0);
4012
4013        // Test max valid timestamp
4014        let max_timestamp = 4_102_444_800_f64; // 2100-01-01
4015        assert_eq!(safe_timestamp_convert(max_timestamp), 4_102_444_800);
4016    }
4017
4018    #[test]
4019    fn test_things_uuid_to_uuid_consistency() {
4020        // Test consistent UUID generation
4021        let things_id = "test-id-123";
4022        let uuid1 = things_uuid_to_uuid(things_id);
4023        let uuid2 = things_uuid_to_uuid(things_id);
4024        assert_eq!(uuid1, uuid2, "UUIDs should be consistent for same input");
4025
4026        // Test different inputs produce different UUIDs
4027        let uuid3 = things_uuid_to_uuid("different-id");
4028        assert_ne!(
4029            uuid1, uuid3,
4030            "Different inputs should produce different UUIDs"
4031        );
4032
4033        // Test empty string
4034        let uuid_empty = things_uuid_to_uuid("");
4035        assert!(!uuid_empty.to_string().is_empty());
4036
4037        // Test very long string
4038        let long_string = "a".repeat(1000);
4039        let uuid_long = things_uuid_to_uuid(&long_string);
4040        assert!(!uuid_long.to_string().is_empty());
4041    }
4042
4043    #[test]
4044    fn test_task_status_from_i32_all_variants() {
4045        assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
4046        assert_eq!(TaskStatus::from_i32(1), Some(TaskStatus::Completed));
4047        assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
4048        assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Trashed));
4049        assert_eq!(TaskStatus::from_i32(999), None);
4050        assert_eq!(TaskStatus::from_i32(-1), None);
4051    }
4052
4053    #[test]
4054    fn test_task_type_from_i32_all_variants() {
4055        assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
4056        assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
4057        assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
4058        assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
4059        assert_eq!(TaskType::from_i32(999), None);
4060        assert_eq!(TaskType::from_i32(-1), None);
4061    }
4062
4063    #[test]
4064    fn test_database_pool_config_default_values() {
4065        let config = DatabasePoolConfig::default();
4066        assert_eq!(config.max_connections, 10);
4067        assert_eq!(config.min_connections, 1);
4068        assert_eq!(config.connect_timeout, Duration::from_secs(30));
4069        assert_eq!(config.idle_timeout, Duration::from_secs(600));
4070        assert_eq!(config.max_lifetime, Duration::from_secs(1800));
4071        assert!(config.test_before_acquire);
4072    }
4073
4074    #[test]
4075    fn test_database_stats_total_items_calculation() {
4076        let stats = DatabaseStats {
4077            task_count: 10,
4078            project_count: 5,
4079            area_count: 3,
4080        };
4081        assert_eq!(stats.total_items(), 18); // 10 + 5 + 3
4082
4083        // Test with zero values
4084        let empty_stats = DatabaseStats {
4085            task_count: 0,
4086            project_count: 0,
4087            area_count: 0,
4088        };
4089        assert_eq!(empty_stats.total_items(), 0);
4090    }
4091
4092    #[test]
4093    fn test_pool_health_status_creation_comprehensive() {
4094        let status = PoolHealthStatus {
4095            is_healthy: true,
4096            pool_size: 8,
4097            active_connections: 2,
4098            idle_connections: 3,
4099            max_connections: 10,
4100            min_connections: 1,
4101            connection_timeout: Duration::from_secs(30),
4102            idle_timeout: Some(Duration::from_secs(600)),
4103            max_lifetime: Some(Duration::from_secs(1800)),
4104        };
4105        assert!(status.is_healthy);
4106        assert_eq!(status.pool_size, 8);
4107        assert_eq!(status.max_connections, 10);
4108    }
4109
4110    #[test]
4111    fn test_pool_metrics_creation_comprehensive() {
4112        let metrics = PoolMetrics {
4113            pool_size: 8,
4114            active_connections: 5,
4115            idle_connections: 3,
4116            max_connections: 10,
4117            min_connections: 1,
4118            utilization_percentage: 80.0,
4119            is_healthy: true,
4120            response_time_ms: 50,
4121            connection_timeout: Duration::from_secs(30),
4122            idle_timeout: Some(Duration::from_secs(600)),
4123            max_lifetime: Some(Duration::from_secs(1800)),
4124        };
4125        assert_eq!(metrics.pool_size, 8);
4126        assert_eq!(metrics.response_time_ms, 50);
4127        assert!(metrics.is_healthy);
4128    }
4129
4130    #[test]
4131    fn test_comprehensive_health_status_creation_full() {
4132        let pool_health = PoolHealthStatus {
4133            is_healthy: true,
4134            pool_size: 8,
4135            active_connections: 2,
4136            idle_connections: 3,
4137            max_connections: 10,
4138            min_connections: 1,
4139            connection_timeout: Duration::from_secs(30),
4140            idle_timeout: Some(Duration::from_secs(600)),
4141            max_lifetime: Some(Duration::from_secs(1800)),
4142        };
4143
4144        let pool_metrics = PoolMetrics {
4145            pool_size: 8,
4146            active_connections: 5,
4147            idle_connections: 3,
4148            max_connections: 10,
4149            min_connections: 1,
4150            utilization_percentage: 80.0,
4151            is_healthy: true,
4152            response_time_ms: 50,
4153            connection_timeout: Duration::from_secs(30),
4154            idle_timeout: Some(Duration::from_secs(600)),
4155            max_lifetime: Some(Duration::from_secs(1800)),
4156        };
4157
4158        let database_stats = DatabaseStats {
4159            task_count: 100,
4160            project_count: 20,
4161            area_count: 5,
4162        };
4163
4164        let status = ComprehensiveHealthStatus {
4165            overall_healthy: true,
4166            pool_health,
4167            pool_metrics,
4168            database_stats,
4169            timestamp: Utc::now(),
4170        };
4171
4172        assert!(status.overall_healthy);
4173        assert_eq!(status.database_stats.total_items(), 125);
4174    }
4175
4176    #[test]
4177    fn test_sqlite_optimizations_default_values() {
4178        let opts = SqliteOptimizations::default();
4179        assert!(opts.enable_wal_mode);
4180        assert!(opts.enable_foreign_keys);
4181        assert_eq!(opts.cache_size, -20000);
4182        assert_eq!(opts.temp_store, "MEMORY");
4183        assert_eq!(opts.mmap_size, 268_435_456);
4184        assert_eq!(opts.synchronous_mode, "NORMAL");
4185        assert_eq!(opts.journal_mode, "WAL");
4186    }
4187
4188    #[test]
4189    fn test_get_default_database_path_format() {
4190        let path = get_default_database_path();
4191        let path_str = path.to_string_lossy();
4192        assert!(path_str.contains("Things Database.thingsdatabase"));
4193        assert!(path_str.contains("main.sqlite"));
4194        assert!(path_str.contains("Library/Group Containers"));
4195    }
4196
4197    #[tokio::test]
4198    async fn test_database_new_with_config() {
4199        let temp_file = NamedTempFile::new().unwrap();
4200        let db_path = temp_file.path();
4201
4202        crate::test_utils::create_test_database(db_path)
4203            .await
4204            .unwrap();
4205
4206        let config = DatabasePoolConfig {
4207            max_connections: 5,
4208            min_connections: 1,
4209            connect_timeout: Duration::from_secs(10),
4210            idle_timeout: Duration::from_secs(300),
4211            max_lifetime: Duration::from_secs(900),
4212            test_before_acquire: true,
4213            sqlite_optimizations: SqliteOptimizations::default(),
4214        };
4215
4216        let database = ThingsDatabase::new_with_config(db_path, config)
4217            .await
4218            .unwrap();
4219        let pool = database.pool();
4220        assert!(!pool.is_closed());
4221    }
4222
4223    #[tokio::test]
4224    async fn test_database_error_handling_invalid_path() {
4225        // Test with non-existent database path
4226        let result = ThingsDatabase::new(Path::new("/non/existent/path.db")).await;
4227        assert!(result.is_err(), "Should fail with non-existent path");
4228    }
4229
4230    #[tokio::test]
4231    async fn test_database_get_stats() {
4232        let temp_file = NamedTempFile::new().unwrap();
4233        let db_path = temp_file.path();
4234
4235        crate::test_utils::create_test_database(db_path)
4236            .await
4237            .unwrap();
4238        let database = ThingsDatabase::new(db_path).await.unwrap();
4239
4240        let stats = database.get_stats().await.unwrap();
4241        assert!(stats.task_count > 0, "Should have test tasks");
4242        assert!(stats.area_count > 0, "Should have test areas");
4243        assert!(stats.total_items() > 0, "Should have total items");
4244    }
4245
4246    #[tokio::test]
4247    async fn test_database_comprehensive_health_check() {
4248        let temp_file = NamedTempFile::new().unwrap();
4249        let db_path = temp_file.path();
4250
4251        crate::test_utils::create_test_database(db_path)
4252            .await
4253            .unwrap();
4254        let database = ThingsDatabase::new(db_path).await.unwrap();
4255
4256        let health = database.comprehensive_health_check().await.unwrap();
4257        assert!(health.overall_healthy, "Database should be healthy");
4258        assert!(health.pool_health.is_healthy, "Pool should be healthy");
4259        assert!(
4260            health.pool_metrics.is_healthy,
4261            "Pool metrics should be healthy"
4262        );
4263    }
4264
4265    // ============================================================================
4266    // Date Conversion Tests - Edge Cases
4267    // ============================================================================
4268
4269    #[test]
4270    fn test_things_date_negative_returns_none() {
4271        // Negative values should return None
4272        assert_eq!(things_date_to_naive_date(-1), None);
4273        assert_eq!(things_date_to_naive_date(-100), None);
4274        assert_eq!(things_date_to_naive_date(i64::MIN), None);
4275    }
4276
4277    #[test]
4278    fn test_things_date_zero_returns_none() {
4279        // Zero should return None (no date set)
4280        assert_eq!(things_date_to_naive_date(0), None);
4281    }
4282
4283    #[test]
4284    fn test_things_date_boundary_2001() {
4285        use chrono::Datelike;
4286        // 1 second after 2001-01-01 00:00:00 should be 2001-01-01
4287        let result = things_date_to_naive_date(1);
4288        assert!(result.is_some());
4289
4290        let date = result.unwrap();
4291        assert_eq!(date.year(), 2001);
4292        assert_eq!(date.month(), 1);
4293        assert_eq!(date.day(), 1);
4294    }
4295
4296    #[test]
4297    fn test_things_date_one_day() {
4298        use chrono::Datelike;
4299        // 86400 seconds = 1 day (60 * 60 * 24), should be 2001-01-02
4300        let seconds_per_day = 86400i64;
4301        let result = things_date_to_naive_date(seconds_per_day);
4302        assert!(result.is_some());
4303
4304        let date = result.unwrap();
4305        assert_eq!(date.year(), 2001);
4306        assert_eq!(date.month(), 1);
4307        assert_eq!(date.day(), 2);
4308    }
4309
4310    #[test]
4311    fn test_things_date_one_year() {
4312        use chrono::Datelike;
4313        // ~365 days should be around 2002-01-01 (365 days * 86400 seconds/day)
4314        let seconds_per_year = 365 * 86400i64;
4315        let result = things_date_to_naive_date(seconds_per_year);
4316        assert!(result.is_some());
4317
4318        let date = result.unwrap();
4319        assert_eq!(date.year(), 2002);
4320    }
4321
4322    #[test]
4323    fn test_things_date_current_era() {
4324        use chrono::Datelike;
4325        // Test a date in the current era (2024)
4326        // Days from 2001-01-01 to 2024-01-01 = ~8401 days
4327        // Calculation: (2024-2001) * 365 + leap days (2004, 2008, 2012, 2016, 2020) = 23 * 365 + 5 = 8400
4328        let days_to_2024 = 8401i64;
4329        let seconds_to_2024 = days_to_2024 * 86400;
4330
4331        let result = things_date_to_naive_date(seconds_to_2024);
4332        assert!(result.is_some());
4333
4334        let date = result.unwrap();
4335        assert_eq!(date.year(), 2024);
4336    }
4337
4338    #[test]
4339    fn test_things_date_leap_year() {
4340        use chrono::{Datelike, TimeZone, Utc};
4341        // Test Feb 29, 2004 (leap year)
4342        // Days from 2001-01-01 to 2004-02-29
4343        let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
4344        let target_date = Utc.with_ymd_and_hms(2004, 2, 29, 0, 0, 0).single().unwrap();
4345        let seconds_diff = (target_date - base_date).num_seconds();
4346
4347        let result = things_date_to_naive_date(seconds_diff);
4348        assert!(result.is_some());
4349
4350        let date = result.unwrap();
4351        assert_eq!(date.year(), 2004);
4352        assert_eq!(date.month(), 2);
4353        assert_eq!(date.day(), 29);
4354    }
4355
4356    // ============================================================================
4357    // UUID Conversion Tests
4358    // ============================================================================
4359
4360    #[test]
4361    fn test_uuid_conversion_consistency() {
4362        // Same input should always produce same UUID
4363        let input = "ABC123";
4364        let uuid1 = things_uuid_to_uuid(input);
4365        let uuid2 = things_uuid_to_uuid(input);
4366
4367        assert_eq!(uuid1, uuid2);
4368    }
4369
4370    #[test]
4371    fn test_uuid_conversion_uniqueness() {
4372        // Different inputs should produce different UUIDs
4373        let uuid1 = things_uuid_to_uuid("ABC123");
4374        let uuid2 = things_uuid_to_uuid("ABC124");
4375        let uuid3 = things_uuid_to_uuid("XYZ789");
4376
4377        assert_ne!(uuid1, uuid2);
4378        assert_ne!(uuid1, uuid3);
4379        assert_ne!(uuid2, uuid3);
4380    }
4381
4382    #[test]
4383    fn test_uuid_conversion_empty_string() {
4384        // Empty string should still produce a valid UUID
4385        let uuid = things_uuid_to_uuid("");
4386        assert!(!uuid.to_string().is_empty());
4387    }
4388
4389    #[test]
4390    fn test_uuid_conversion_special_characters() {
4391        // Special characters should be handled
4392        let uuid1 = things_uuid_to_uuid("test-with-dashes");
4393        let uuid2 = things_uuid_to_uuid("test_with_underscores");
4394        let uuid3 = things_uuid_to_uuid("test.with.dots");
4395
4396        // All should be valid and different
4397        assert_ne!(uuid1, uuid2);
4398        assert_ne!(uuid1, uuid3);
4399        assert_ne!(uuid2, uuid3);
4400    }
4401
4402    // ============================================================================
4403    // Timestamp Conversion Tests
4404    // ============================================================================
4405
4406    #[test]
4407    fn test_safe_timestamp_convert_normal_values() {
4408        // Normal timestamp values should convert correctly
4409        let ts = 1_700_000_000.0; // Around 2023
4410        let result = safe_timestamp_convert(ts);
4411        assert_eq!(result, 1_700_000_000);
4412    }
4413
4414    #[test]
4415    fn test_safe_timestamp_convert_zero() {
4416        // Zero should return zero
4417        assert_eq!(safe_timestamp_convert(0.0), 0);
4418    }
4419
4420    #[test]
4421    fn test_safe_timestamp_convert_negative() {
4422        // Negative values should return zero (safe fallback)
4423        assert_eq!(safe_timestamp_convert(-1.0), 0);
4424        assert_eq!(safe_timestamp_convert(-1000.0), 0);
4425    }
4426
4427    #[test]
4428    fn test_safe_timestamp_convert_infinity() {
4429        // Infinity should return zero (safe fallback)
4430        assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
4431        assert_eq!(safe_timestamp_convert(f64::NEG_INFINITY), 0);
4432    }
4433
4434    #[test]
4435    fn test_safe_timestamp_convert_nan() {
4436        // NaN should return zero (safe fallback)
4437        assert_eq!(safe_timestamp_convert(f64::NAN), 0);
4438    }
4439
4440    #[test]
4441    fn test_date_roundtrip_known_dates() {
4442        use chrono::{Datelike, TimeZone, Utc};
4443        // Test roundtrip conversion for known dates
4444        // Note: Starting from 2001-01-02 because 2001-01-01 is the base date (0 seconds)
4445        // and things_date_to_naive_date returns None for values <= 0
4446        let test_cases = vec![
4447            (2001, 1, 2), // Start from day 2 since day 1 is the base (0 seconds)
4448            (2010, 6, 15),
4449            (2020, 12, 31),
4450            (2024, 2, 29), // Leap year
4451            (2025, 7, 4),
4452        ];
4453
4454        for (year, month, day) in test_cases {
4455            let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
4456            let target_date = Utc
4457                .with_ymd_and_hms(year, month, day, 0, 0, 0)
4458                .single()
4459                .unwrap();
4460            let seconds = (target_date - base_date).num_seconds();
4461
4462            let converted = things_date_to_naive_date(seconds);
4463            assert!(
4464                converted.is_some(),
4465                "Failed to convert {}-{:02}-{:02}",
4466                year,
4467                month,
4468                day
4469            );
4470
4471            let result_date = converted.unwrap();
4472            assert_eq!(
4473                result_date.year(),
4474                year,
4475                "Year mismatch for {}-{:02}-{:02}",
4476                year,
4477                month,
4478                day
4479            );
4480            assert_eq!(
4481                result_date.month(),
4482                month,
4483                "Month mismatch for {}-{:02}-{:02}",
4484                year,
4485                month,
4486                day
4487            );
4488            assert_eq!(
4489                result_date.day(),
4490                day,
4491                "Day mismatch for {}-{:02}-{:02}",
4492                year,
4493                month,
4494                day
4495            );
4496        }
4497    }
4498
4499    #[cfg(feature = "advanced-queries")]
4500    mod query_tasks_tests {
4501        use super::*;
4502        use crate::models::TaskFilters;
4503        use crate::query::TaskQueryBuilder;
4504        use tempfile::NamedTempFile;
4505
4506        async fn open_test_db() -> (ThingsDatabase, NamedTempFile) {
4507            let f = NamedTempFile::new().unwrap();
4508            crate::test_utils::create_test_database(f.path())
4509                .await
4510                .unwrap();
4511            let db = ThingsDatabase::new(f.path()).await.unwrap();
4512            (db, f)
4513        }
4514
4515        #[tokio::test]
4516        async fn test_query_tasks_no_filters() {
4517            let (db, _f) = open_test_db().await;
4518            let result = db.query_tasks(&TaskFilters::default()).await;
4519            assert!(result.is_ok());
4520        }
4521
4522        #[tokio::test]
4523        async fn test_query_tasks_status_filter() {
4524            let (db, _f) = open_test_db().await;
4525            let filters = TaskFilters {
4526                status: Some(TaskStatus::Completed),
4527                ..TaskFilters::default()
4528            };
4529            let tasks = db.query_tasks(&filters).await.unwrap();
4530            assert!(tasks.iter().all(|t| t.status == TaskStatus::Completed));
4531        }
4532
4533        #[tokio::test]
4534        async fn test_query_tasks_limit() {
4535            let (db, _f) = open_test_db().await;
4536            let filters = TaskFilters {
4537                limit: Some(1),
4538                ..TaskFilters::default()
4539            };
4540            let tasks = db.query_tasks(&filters).await.unwrap();
4541            assert!(tasks.len() <= 1);
4542        }
4543
4544        #[tokio::test]
4545        async fn test_query_tasks_tag_filter_and_semantics() {
4546            let (db, _f) = open_test_db().await;
4547            let filters = TaskFilters {
4548                tags: Some(vec!["nonexistent-tag-xyz".to_string()]),
4549                ..TaskFilters::default()
4550            };
4551            let tasks = db.query_tasks(&filters).await.unwrap();
4552            assert!(tasks.is_empty());
4553        }
4554
4555        #[tokio::test]
4556        async fn test_query_tasks_search_query() {
4557            let (db, _f) = open_test_db().await;
4558            let filters = TaskFilters {
4559                search_query: Some("zzznomatch".to_string()),
4560                ..TaskFilters::default()
4561            };
4562            let tasks = db.query_tasks(&filters).await.unwrap();
4563            assert!(tasks.is_empty());
4564        }
4565
4566        #[tokio::test]
4567        async fn test_query_tasks_trashed_status() {
4568            use sqlx::SqlitePool;
4569            use uuid::Uuid;
4570
4571            // Create a DB, insert one soft-deleted row (trashed = 1), then verify:
4572            // - default query (trashed = 0) does NOT return it
4573            // - TaskStatus::Trashed filter DOES return it
4574            let f = NamedTempFile::new().unwrap();
4575            crate::test_utils::create_test_database(f.path())
4576                .await
4577                .unwrap();
4578            let pool = SqlitePool::connect(&format!("sqlite:{}", f.path().display()))
4579                .await
4580                .unwrap();
4581            let trashed_uuid = Uuid::new_v4().to_string();
4582            sqlx::query(
4583                "INSERT INTO TMTask \
4584                 (uuid, title, type, status, trashed, creationDate, userModificationDate) \
4585                 VALUES (?, ?, 0, 0, 1, 0, 0)",
4586            )
4587            .bind(&trashed_uuid)
4588            .bind("Trashed Task")
4589            .execute(&pool)
4590            .await
4591            .unwrap();
4592            pool.close().await;
4593
4594            let db = ThingsDatabase::new(f.path()).await.unwrap();
4595
4596            // Default query must not surface the trashed row
4597            let active = db.query_tasks(&TaskFilters::default()).await.unwrap();
4598            assert!(active.iter().all(|t| t.uuid.to_string() != trashed_uuid));
4599
4600            // Trashed filter must surface it
4601            let trashed = db
4602                .query_tasks(&TaskFilters {
4603                    status: Some(TaskStatus::Trashed),
4604                    ..TaskFilters::default()
4605                })
4606                .await
4607                .unwrap();
4608            assert!(
4609                trashed.iter().any(|t| t.uuid.to_string() == trashed_uuid),
4610                "expected trashed row to be returned by TaskStatus::Trashed filter"
4611            );
4612        }
4613
4614        #[tokio::test]
4615        async fn test_query_tasks_offset_without_limit() {
4616            // Bug fix: offset must not be silently ignored when limit is absent
4617            let (db, _f) = open_test_db().await;
4618            let all = db.query_tasks(&TaskFilters::default()).await.unwrap();
4619            if all.len() < 2 {
4620                return; // not enough rows to test pagination
4621            }
4622            let filters = TaskFilters {
4623                offset: Some(1),
4624                ..TaskFilters::default()
4625            };
4626            let offset_tasks = db.query_tasks(&filters).await.unwrap();
4627            assert_eq!(offset_tasks.len(), all.len() - 1);
4628            assert_eq!(offset_tasks[0].uuid, all[1].uuid);
4629        }
4630
4631        #[tokio::test]
4632        async fn test_query_tasks_pagination_with_post_filter() {
4633            // Bug fix: LIMIT/OFFSET must count post-filter matches, not raw SQL rows
4634            let (db, _f) = open_test_db().await;
4635            // Fetch all tasks matching search (may be 0 in empty test DB — that's fine)
4636            let all_matching = db
4637                .query_tasks(&TaskFilters {
4638                    search_query: Some(String::new()),
4639                    ..TaskFilters::default()
4640                })
4641                .await
4642                .unwrap();
4643            if all_matching.len() < 2 {
4644                return;
4645            }
4646            let page0 = db
4647                .query_tasks(&TaskFilters {
4648                    search_query: Some(String::new()),
4649                    limit: Some(1),
4650                    offset: Some(0),
4651                    ..TaskFilters::default()
4652                })
4653                .await
4654                .unwrap();
4655            let page1 = db
4656                .query_tasks(&TaskFilters {
4657                    search_query: Some(String::new()),
4658                    limit: Some(1),
4659                    offset: Some(1),
4660                    ..TaskFilters::default()
4661                })
4662                .await
4663                .unwrap();
4664            assert_eq!(page0.len(), 1);
4665            assert_eq!(page1.len(), 1);
4666            assert_ne!(page0[0].uuid, page1[0].uuid);
4667        }
4668
4669        /// Insert a TMTask row with optional notes and tags.
4670        /// Used to seed tests; bypasses create_test_database which inserts only untagged rows.
4671        async fn insert_task(
4672            db: &ThingsDatabase,
4673            title: &str,
4674            notes: Option<&str>,
4675            tags: &[&str],
4676        ) -> Uuid {
4677            let uuid = Uuid::new_v4();
4678            let owned: Vec<String> = tags.iter().map(|s| (*s).to_string()).collect();
4679            let blob = serialize_tags_to_blob(&owned).unwrap();
4680            sqlx::query(
4681                "INSERT INTO TMTask \
4682                 (uuid, title, notes, type, status, trashed, creationDate, userModificationDate, cachedTags) \
4683                 VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?)",
4684            )
4685            .bind(uuid.to_string())
4686            .bind(title)
4687            .bind(notes)
4688            .bind(blob)
4689            .execute(&db.pool)
4690            .await
4691            .unwrap();
4692            uuid
4693        }
4694
4695        async fn insert_task_with_tags(db: &ThingsDatabase, title: &str, tags: &[&str]) -> Uuid {
4696            insert_task(db, title, None, tags).await
4697        }
4698
4699        async fn open_db_with_tagged_rows() -> (ThingsDatabase, NamedTempFile, Uuid, Uuid, Uuid) {
4700            let (db, f) = open_test_db().await;
4701            let a = insert_task_with_tags(&db, "task-a", &["a"]).await;
4702            let b = insert_task_with_tags(&db, "task-b", &["b"]).await;
4703            let c = insert_task_with_tags(&db, "task-c", &["c"]).await;
4704            (db, f, a, b, c)
4705        }
4706
4707        #[tokio::test]
4708        async fn test_query_tasks_any_tags_or_semantics() {
4709            let (db, _f, a, b, c) = open_db_with_tagged_rows().await;
4710            let tasks = TaskQueryBuilder::new()
4711                .any_tags(vec!["a".to_string(), "b".to_string()])
4712                .execute(&db)
4713                .await
4714                .unwrap();
4715            let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
4716            assert!(uuids.contains(&a));
4717            assert!(uuids.contains(&b));
4718            assert!(!uuids.contains(&c));
4719        }
4720
4721        #[tokio::test]
4722        async fn test_query_tasks_exclude_tags() {
4723            let (db, _f, a, b, c) = open_db_with_tagged_rows().await;
4724            let tasks = TaskQueryBuilder::new()
4725                .exclude_tags(vec!["b".to_string()])
4726                .execute(&db)
4727                .await
4728                .unwrap();
4729            let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
4730            assert!(uuids.contains(&a));
4731            assert!(!uuids.contains(&b));
4732            assert!(uuids.contains(&c));
4733        }
4734
4735        #[tokio::test]
4736        async fn test_query_tasks_tag_count_min() {
4737            let (db, _f) = open_test_db().await;
4738            insert_task_with_tags(&db, "zero-tags", &[]).await;
4739            insert_task_with_tags(&db, "one-tag", &["x"]).await;
4740            let two = insert_task_with_tags(&db, "two-tags", &["x", "y"]).await;
4741            let tasks = TaskQueryBuilder::new()
4742                .tag_count(2)
4743                .execute(&db)
4744                .await
4745                .unwrap();
4746            let uuids: Vec<Uuid> = tasks.iter().map(|t| t.uuid).collect();
4747            assert_eq!(uuids, vec![two]);
4748        }
4749
4750        #[tokio::test]
4751        async fn test_query_tasks_combined_tag_filters() {
4752            let (db, _f) = open_test_db().await;
4753            let target = insert_task_with_tags(&db, "target", &["a", "x"]).await;
4754            let _wrong_required = insert_task_with_tags(&db, "no-a", &["x"]).await;
4755            let _excluded = insert_task_with_tags(&db, "has-z", &["a", "x", "z"]).await;
4756            let _no_any = insert_task_with_tags(&db, "no-x", &["a"]).await;
4757
4758            let tasks = TaskQueryBuilder::new()
4759                .tags(vec!["a".to_string()])
4760                .any_tags(vec!["x".to_string(), "y".to_string()])
4761                .exclude_tags(vec!["z".to_string()])
4762                .execute(&db)
4763                .await
4764                .unwrap();
4765            let uuids: Vec<Uuid> = tasks.iter().map(|t| t.uuid).collect();
4766            assert_eq!(uuids, vec![target]);
4767        }
4768
4769        #[tokio::test]
4770        async fn test_query_tasks_pagination_with_any_tags() {
4771            // execute() must defer LIMIT/OFFSET to Rust when any_tags is set so
4772            // pages count only matching rows.
4773            let (db, _f) = open_test_db().await;
4774            insert_task_with_tags(&db, "a1", &["a"]).await;
4775            insert_task_with_tags(&db, "a2", &["a"]).await;
4776            insert_task_with_tags(&db, "a3", &["a"]).await;
4777            let page0 = TaskQueryBuilder::new()
4778                .any_tags(vec!["a".to_string()])
4779                .limit(1)
4780                .offset(0)
4781                .execute(&db)
4782                .await
4783                .unwrap();
4784            let page1 = TaskQueryBuilder::new()
4785                .any_tags(vec!["a".to_string()])
4786                .limit(1)
4787                .offset(1)
4788                .execute(&db)
4789                .await
4790                .unwrap();
4791            assert_eq!(page0.len(), 1);
4792            assert_eq!(page1.len(), 1);
4793            assert_ne!(page0[0].uuid, page1[0].uuid);
4794        }
4795
4796        #[tokio::test]
4797        async fn test_execute_fuzzy_typo_match() {
4798            let (db, _f) = open_test_db().await;
4799            let groceries = insert_task(&db, "Buy groceries", None, &[]).await;
4800            let tasks = TaskQueryBuilder::new()
4801                .fuzzy_search("grocries")
4802                .execute(&db)
4803                .await
4804                .unwrap();
4805            let uuids: Vec<Uuid> = tasks.iter().map(|t| t.uuid).collect();
4806            assert!(
4807                uuids.contains(&groceries),
4808                "typo 'grocries' should match 'Buy groceries'"
4809            );
4810        }
4811
4812        #[tokio::test]
4813        async fn test_execute_fuzzy_below_threshold_excluded() {
4814            let (db, _f) = open_test_db().await;
4815            insert_task(&db, "Buy groceries", None, &[]).await;
4816            let tasks = TaskQueryBuilder::new()
4817                .fuzzy_search("xyz")
4818                .fuzzy_threshold(0.95)
4819                .execute(&db)
4820                .await
4821                .unwrap();
4822            assert!(
4823                tasks.is_empty(),
4824                "completely unrelated query should return nothing at 0.95 threshold"
4825            );
4826        }
4827
4828        #[tokio::test]
4829        async fn test_execute_ranked_score_ordering() {
4830            let (db, _f) = open_test_db().await;
4831            insert_task(&db, "urgent task", None, &[]).await;
4832            insert_task(&db, "urgntt task", None, &[]).await; // typo
4833            insert_task(&db, "completely unrelated xyz abc", None, &[]).await;
4834            let ranked = TaskQueryBuilder::new()
4835                .fuzzy_search("urgent")
4836                .fuzzy_threshold(0.5)
4837                .execute_ranked(&db)
4838                .await
4839                .unwrap();
4840            // Verify scores are non-increasing
4841            for pair in ranked.windows(2) {
4842                assert!(
4843                    pair[0].score >= pair[1].score,
4844                    "results must be sorted by score desc: {} < {}",
4845                    pair[0].score,
4846                    pair[1].score
4847                );
4848            }
4849            assert!(!ranked.is_empty(), "at least 'urgent task' should match");
4850        }
4851
4852        #[tokio::test]
4853        async fn test_execute_ranked_pagination() {
4854            let (db, _f) = open_test_db().await;
4855            for i in 0..5 {
4856                insert_task(&db, &format!("meeting agenda item {i}"), None, &[]).await;
4857            }
4858            let all = TaskQueryBuilder::new()
4859                .fuzzy_search("agenda")
4860                .execute_ranked(&db)
4861                .await
4862                .unwrap();
4863            let page = TaskQueryBuilder::new()
4864                .fuzzy_search("agenda")
4865                .limit(2)
4866                .offset(1)
4867                .execute_ranked(&db)
4868                .await
4869                .unwrap();
4870            assert_eq!(page.len(), 2);
4871            assert_eq!(page[0].task.uuid, all[1].task.uuid);
4872            assert_eq!(page[1].task.uuid, all[2].task.uuid);
4873        }
4874
4875        #[tokio::test]
4876        async fn test_execute_fuzzy_with_search_collision() {
4877            // If substring search were applied, "zzznomatch" would filter out the
4878            // target row and tasks would be empty — proving fuzzy suppressed it.
4879            let (db, _f) = open_test_db().await;
4880            let target = insert_task(&db, "meeting agenda", None, &[]).await;
4881            let tasks = TaskQueryBuilder::new()
4882                .search("zzznomatch")
4883                .fuzzy_search("agenda")
4884                .execute(&db)
4885                .await
4886                .unwrap();
4887            assert_eq!(
4888                tasks.len(),
4889                1,
4890                "only the 'meeting agenda' row should match; substring filter must be suppressed"
4891            );
4892            assert_eq!(
4893                tasks[0].uuid, target,
4894                "fuzzy should win over substring search"
4895            );
4896        }
4897
4898        #[tokio::test]
4899        async fn test_execute_ranked_errors_without_fuzzy_query() {
4900            let (db, _f) = open_test_db().await;
4901            let result = TaskQueryBuilder::new().execute_ranked(&db).await;
4902            assert!(
4903                result.is_err(),
4904                "execute_ranked without fuzzy_search should error"
4905            );
4906        }
4907
4908        #[tokio::test]
4909        async fn test_execute_fuzzy_searches_notes() {
4910            let (db, _f) = open_test_db().await;
4911            let target = insert_task(&db, "Weekly sync", Some("meeting agenda for Q2"), &[]).await;
4912            let tasks = TaskQueryBuilder::new()
4913                .fuzzy_search("agenda")
4914                .execute(&db)
4915                .await
4916                .unwrap();
4917            let uuids: Vec<Uuid> = tasks.iter().map(|t| t.uuid).collect();
4918            assert!(uuids.contains(&target), "fuzzy should match text in notes");
4919        }
4920
4921        async fn insert_task_with_status(
4922            db: &ThingsDatabase,
4923            title: &str,
4924            status: TaskStatus,
4925        ) -> Uuid {
4926            let uuid = Uuid::new_v4();
4927            let blob = serialize_tags_to_blob(&Vec::<String>::new()).unwrap();
4928            let status_n: i64 = match status {
4929                TaskStatus::Incomplete => 0,
4930                TaskStatus::Completed => 1,
4931                TaskStatus::Canceled => 2,
4932                TaskStatus::Trashed => 0,
4933            };
4934            sqlx::query(
4935                "INSERT INTO TMTask \
4936                 (uuid, title, notes, type, status, trashed, creationDate, userModificationDate, cachedTags) \
4937                 VALUES (?, ?, NULL, 0, ?, 0, 0, 0, ?)",
4938            )
4939            .bind(uuid.to_string())
4940            .bind(title)
4941            .bind(status_n)
4942            .bind(blob)
4943            .execute(&db.pool)
4944            .await
4945            .unwrap();
4946            uuid
4947        }
4948
4949        async fn insert_task_with_type(
4950            db: &ThingsDatabase,
4951            title: &str,
4952            task_type: crate::models::TaskType,
4953        ) -> Uuid {
4954            let uuid = Uuid::new_v4();
4955            let blob = serialize_tags_to_blob(&Vec::<String>::new()).unwrap();
4956            let type_n: i64 = match task_type {
4957                crate::models::TaskType::Todo => 0,
4958                crate::models::TaskType::Project => 1,
4959                crate::models::TaskType::Heading => 2,
4960                crate::models::TaskType::Area => 3,
4961            };
4962            sqlx::query(
4963                "INSERT INTO TMTask \
4964                 (uuid, title, notes, type, status, trashed, creationDate, userModificationDate, cachedTags) \
4965                 VALUES (?, ?, NULL, ?, 0, 0, 0, 0, ?)",
4966            )
4967            .bind(uuid.to_string())
4968            .bind(title)
4969            .bind(type_n)
4970            .bind(blob)
4971            .execute(&db.pool)
4972            .await
4973            .unwrap();
4974            uuid
4975        }
4976
4977        #[tokio::test]
4978        async fn test_execute_with_where_expr_or_status() {
4979            use crate::filter_expr::FilterExpr;
4980            let (db, _f) = open_test_db().await;
4981            let inc = insert_task_with_status(&db, "inc", TaskStatus::Incomplete).await;
4982            let comp = insert_task_with_status(&db, "comp", TaskStatus::Completed).await;
4983            let canc = insert_task_with_status(&db, "canc", TaskStatus::Canceled).await;
4984
4985            let tasks = TaskQueryBuilder::new()
4986                .where_expr(
4987                    FilterExpr::status(TaskStatus::Incomplete)
4988                        .or(FilterExpr::status(TaskStatus::Completed)),
4989                )
4990                .execute(&db)
4991                .await
4992                .unwrap();
4993
4994            let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
4995            assert!(uuids.contains(&inc));
4996            assert!(uuids.contains(&comp));
4997            assert!(!uuids.contains(&canc));
4998        }
4999
5000        #[tokio::test]
5001        async fn test_execute_with_where_expr_not_type() {
5002            use crate::filter_expr::FilterExpr;
5003            use crate::models::TaskType;
5004            let (db, _f) = open_test_db().await;
5005            let todo = insert_task_with_type(&db, "todo", TaskType::Todo).await;
5006            let project = insert_task_with_type(&db, "project", TaskType::Project).await;
5007
5008            let tasks = TaskQueryBuilder::new()
5009                .where_expr(FilterExpr::task_type(TaskType::Project).not())
5010                .execute(&db)
5011                .await
5012                .unwrap();
5013
5014            let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
5015            assert!(uuids.contains(&todo));
5016            assert!(!uuids.contains(&project));
5017        }
5018
5019        #[tokio::test]
5020        async fn test_execute_pagination_defers_to_rust_when_where_expr_set() {
5021            // Mirror of test_query_tasks_pagination_with_any_tags. With a
5022            // where_expr, limit/offset must count post-filter matches.
5023            use crate::filter_expr::FilterExpr;
5024            let (db, _f) = open_test_db().await;
5025            insert_task_with_status(&db, "inc-1", TaskStatus::Incomplete).await;
5026            insert_task_with_status(&db, "inc-2", TaskStatus::Incomplete).await;
5027            insert_task_with_status(&db, "inc-3", TaskStatus::Incomplete).await;
5028            insert_task_with_status(&db, "comp", TaskStatus::Completed).await;
5029
5030            let page0 = TaskQueryBuilder::new()
5031                .where_expr(FilterExpr::status(TaskStatus::Incomplete))
5032                .limit(1)
5033                .offset(0)
5034                .execute(&db)
5035                .await
5036                .unwrap();
5037            let page1 = TaskQueryBuilder::new()
5038                .where_expr(FilterExpr::status(TaskStatus::Incomplete))
5039                .limit(1)
5040                .offset(1)
5041                .execute(&db)
5042                .await
5043                .unwrap();
5044            assert_eq!(page0.len(), 1);
5045            assert_eq!(page1.len(), 1);
5046            assert_ne!(page0[0].uuid, page1[0].uuid);
5047            assert_eq!(page0[0].status, TaskStatus::Incomplete);
5048            assert_eq!(page1[0].status, TaskStatus::Incomplete);
5049        }
5050
5051        #[tokio::test]
5052        async fn test_execute_combines_where_expr_with_filters_status() {
5053            // filters.status (SQL) AND-combines with where_expr (Rust).
5054            use crate::filter_expr::FilterExpr;
5055            let (db, _f) = open_test_db().await;
5056            let target = insert_task(&db, "needle", None, &["work"]).await;
5057            insert_task(&db, "decoy", None, &["work"]).await;
5058
5059            let tasks = TaskQueryBuilder::new()
5060                .status(TaskStatus::Incomplete)
5061                .where_expr(FilterExpr::title_contains("needle"))
5062                .execute(&db)
5063                .await
5064                .unwrap();
5065
5066            let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
5067            assert!(uuids.contains(&target));
5068            assert_eq!(tasks.len(), 1);
5069        }
5070
5071        #[tokio::test]
5072        async fn test_execute_combines_where_expr_with_any_tags() {
5073            // Both Rust-side post-filters apply. Tag filter narrows by tag,
5074            // expr further narrows by title.
5075            use crate::filter_expr::FilterExpr;
5076            let (db, _f) = open_test_db().await;
5077            let target = insert_task(&db, "needle-task", None, &["work"]).await;
5078            insert_task(&db, "decoy-task", None, &["work"]).await;
5079            insert_task(&db, "needle-but-wrong-tag", None, &["personal"]).await;
5080
5081            let tasks = TaskQueryBuilder::new()
5082                .any_tags(vec!["work".to_string()])
5083                .where_expr(FilterExpr::title_contains("needle"))
5084                .execute(&db)
5085                .await
5086                .unwrap();
5087
5088            let uuids: std::collections::HashSet<_> = tasks.iter().map(|t| t.uuid).collect();
5089            assert!(uuids.contains(&target));
5090            assert_eq!(tasks.len(), 1);
5091        }
5092    }
5093}