//! SQLite implementation of the SearchRepository. //! //! Provides full-text search across tasks, emails, and events using SQLite FTS5. //! Search results are ranked by relevance and include preview snippets. //! Supports structured filters like `is:overdue`, `priority:high`, `in:Project`. use async_trait::async_trait; use sqlx::SqlitePool; use uuid::Uuid; use goingson_core::{ search_parser::IsFilter, CoreError, Priority, ProjectId, Result, SearchQuery, SearchRepository, SearchResultItem, SearchResultType, UserId, }; use crate::utils::{escape_like, parse_uuid}; /// SQLite-backed implementation of [`SearchRepository`]. /// /// Provides full-text search via FTS5 across tasks, emails, events, and projects. /// Supports structured filters (`is:overdue`, `priority:high`, `in:ProjectName`) /// combined with text search. pub struct SqliteSearchRepository { pool: SqlitePool, } impl SqliteSearchRepository { /// Creates a new repository instance with the given connection pool. #[tracing::instrument(skip_all)] pub fn new(pool: SqlitePool) -> Self { Self { pool } } } #[async_trait] impl SearchRepository for SqliteSearchRepository { #[tracing::instrument(skip_all)] async fn search(&self, user_id: UserId, query: SearchQuery) -> Result<(Vec, usize)> { // If no text and no filters, return empty let has_text = !query.query.trim().is_empty(); let has_filters = !query.is_filters.is_empty() || query.priority.is_some() || query.project_name.is_some() || query.project_id.is_some() || !query.tags_include.is_empty() || !query.tags_exclude.is_empty() || query.date_from.is_some() || query.date_to.is_some(); if !has_text && !has_filters { return Ok((vec![], 0)); } let user_id_str = user_id.to_string(); let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); let per_type_cap = offset.saturating_add(limit); // Prepare search term for FTS5 (escape special characters and add prefix matching) let search_term = if has_text { Some(prepare_fts5_query(&query.query)) } else { None }; // Determine which types to search let search_tasks = query .types .as_ref() .is_none_or(|t| t.contains(&SearchResultType::Task)); let search_emails = query .types .as_ref() .is_none_or(|t| t.contains(&SearchResultType::Email)) && can_search_emails(&query); let search_projects = query .types .as_ref() .is_none_or(|t| t.contains(&SearchResultType::Project)) && can_search_projects(&query); let search_events = query .types .as_ref() .is_none_or(|t| t.contains(&SearchResultType::Event)) && can_search_events(&query); let search_contacts = query .types .as_ref() .is_none_or(|t| t.contains(&SearchResultType::Contact)) && can_search_contacts(&query); // Run all applicable FTS queries in parallel with capped per-type limits let (task_r, email_r, project_r, event_r, contact_r) = tokio::join!( async { if search_tasks { search_tasks_fts(&self.pool, &user_id_str, &query, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) } }, async { if search_emails { search_emails_fts(&self.pool, &user_id_str, &query, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) } }, async { if search_projects { search_projects_fts(&self.pool, &user_id_str, &query, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) } }, async { if search_events { search_events_fts(&self.pool, &user_id_str, &query, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) } }, async { if search_contacts { search_contacts_fts(&self.pool, &user_id_str, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) } }, ); let mut results = Vec::new(); results.extend(task_r?); results.extend(email_r?); results.extend(project_r?); results.extend(event_r?); results.extend(contact_r?); // Sort by rank (higher is better) results.sort_by(|a, b| { b.rank .partial_cmp(&a.rank) .unwrap_or(std::cmp::Ordering::Equal) }); // Capture total before pagination (approximate — sum of capped per-type counts) let total = results.len(); // Apply pagination let results: Vec<_> = results .into_iter() .skip(offset as usize) .take(limit as usize) .collect(); Ok((results, total)) } } /// Check if the query can apply to emails (doesn't have task-specific filters). fn can_search_emails(query: &SearchQuery) -> bool { // Emails don't have status, priority, or tags, so skip if these filters are set query.is_filters.iter().all(|f| matches!(f, IsFilter::Snoozed)) // emails don't have most is: filters && query.priority.is_none() && query.tags_include.is_empty() && query.tags_exclude.is_empty() // If is_filters contains task-specific filters, skip emails && !query.is_filters.iter().any(|f| { matches!( f, IsFilter::Overdue | IsFilter::Today | IsFilter::Tomorrow | IsFilter::ThisWeek | IsFilter::Pending | IsFilter::Started | IsFilter::Completed | IsFilter::Waiting ) }) } /// Check if the query can apply to projects. fn can_search_projects(query: &SearchQuery) -> bool { // Projects don't have most filters query.is_filters.is_empty() && query.priority.is_none() && query.tags_include.is_empty() && query.tags_exclude.is_empty() && query.project_id.is_none() && query.project_name.is_none() } /// Check if the query can apply to events. fn can_search_events(query: &SearchQuery) -> bool { // Events have date filters but not status/priority/tags query.priority.is_none() && query.tags_include.is_empty() && query.tags_exclude.is_empty() && !query.is_filters.iter().any(|f| { matches!( f, IsFilter::Pending | IsFilter::Started | IsFilter::Completed | IsFilter::Waiting | IsFilter::Snoozed ) }) } /// Prepare a search query for FTS5 fn prepare_fts5_query(query: &str) -> String { // Split into words and add prefix matching query .split_whitespace() .filter_map(|word| { // Escape special FTS5 characters let escaped = word .replace('"', "\"\"") .replace(['*', '(', ')', ':'], ""); if escaped.is_empty() { None } else { Some(format!("\"{}\"*", escaped)) } }) .collect::>() .join(" ") } /// Build WHERE clauses for `is:` filters on tasks. fn build_is_filter_clauses(is_filters: &[IsFilter]) -> Vec { is_filters .iter() .map(|f| { match f { IsFilter::Overdue => "(t.due IS NOT NULL AND datetime(t.due) < datetime('now'))".to_string(), IsFilter::Today => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', 'localtime'))".to_string(), IsFilter::Tomorrow => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', '+1 day', 'localtime'))".to_string(), IsFilter::ThisWeek => { // Due on or before end of this week (Sunday). // weekday 1 = next Monday; < Monday midnight = through Sunday 23:59:59. "(t.due IS NOT NULL AND datetime(t.due) < datetime('now', 'weekday 1'))".to_string() } IsFilter::Snoozed => "(t.snoozed_until IS NOT NULL AND datetime(t.snoozed_until) > datetime('now'))".to_string(), IsFilter::Pending => "t.status = 'Pending'".to_string(), IsFilter::Started => "t.status = 'Started'".to_string(), IsFilter::Completed => "t.status = 'Completed'".to_string(), IsFilter::Waiting => "t.waiting_for_response = 1".to_string(), } }) .collect() } /// Searches tasks via FTS5 MATCH with BM25 relevance ranking, or direct query when /// no search text is provided. Applies optional filters: `is:` status/date, project /// (by ID or name), priority, tag include/exclude, and date range on the due field. async fn search_tasks_fts( pool: &SqlitePool, user_id: &str, query: &SearchQuery, search_term: Option<&str>, per_type_limit: i64, ) -> Result> { #[derive(sqlx::FromRow)] struct Row { id: String, description: String, project_id: Option, project_name: Option, rank: f64, } // Build dynamic query with optional filters // When there's no search term, we use a direct query instead of FTS let (base_sql, uses_fts) = if search_term.is_some() { ( r#" SELECT t.id, t.description, t.project_id, p.name as project_name, bm25(tasks_fts) as rank FROM tasks_fts JOIN tasks t ON tasks_fts.id = t.id LEFT JOIN projects p ON t.project_id = p.id WHERE tasks_fts MATCH $1 AND tasks_fts.user_id = $2 "# .to_string(), true, ) } else { ( r#" SELECT t.id, t.description, t.project_id, p.name as project_name, 0.0 as rank FROM tasks t LEFT JOIN projects p ON t.project_id = p.id WHERE t.user_id = $1 AND t.status != 'Deleted' "# .to_string(), false, ) }; let mut sql = base_sql; let mut params: Vec = Vec::new(); let mut param_idx = if uses_fts { 3 } else { 2 }; // Add is: filter clauses let is_clauses = build_is_filter_clauses(&query.is_filters); for clause in is_clauses { sql.push_str(&format!(" AND {}", clause)); } // Project filter by ID if let Some(pid) = &query.project_id { sql.push_str(&format!(" AND t.project_id = ${}", param_idx)); params.push(pid.to_string()); param_idx += 1; } // Project filter by name (partial match) if let Some(pname) = &query.project_name { sql.push_str(&format!( " AND t.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)", param_idx )); params.push(format!("%{}%", escape_like(pname))); param_idx += 1; } // Priority filter if let Some(priority) = &query.priority { let priority_str = match priority { Priority::High => "High", Priority::Medium => "Medium", Priority::Low => "Low", }; sql.push_str(&format!(" AND t.priority = ${}", param_idx)); params.push(priority_str.to_string()); param_idx += 1; } // Tag filters (include) — tags stored as JSON array in tasks.tags column for tag in &query.tags_include { sql.push_str(&format!(" AND t.tags LIKE ${} ESCAPE '\\'", param_idx)); params.push(format!("%\"{}\"%" , escape_like(tag))); param_idx += 1; } // Tag filters (exclude) for tag in &query.tags_exclude { sql.push_str(&format!(" AND t.tags NOT LIKE ${} ESCAPE '\\'", param_idx)); params.push(format!("%\"{}\"%" , escape_like(tag))); param_idx += 1; } // Date filters if let Some(df) = &query.date_from { sql.push_str(&format!( " AND (t.due IS NULL OR datetime(t.due) >= datetime(${})) ", param_idx )); params.push(df.to_rfc3339()); param_idx += 1; } if let Some(dt) = &query.date_to { sql.push_str(&format!( " AND (t.due IS NULL OR datetime(t.due) <= datetime(${})) ", param_idx )); params.push(dt.to_rfc3339()); } sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit)); // Build and execute query let mut db_query = sqlx::query_as::<_, Row>(&sql); if let Some(term) = search_term { db_query = db_query .bind(term) .bind(user_id); } else { db_query = db_query.bind(user_id); } for param in params { db_query = db_query.bind(param); } let rows: Vec = db_query.fetch_all(pool).await.map_err(CoreError::database)?; rows.into_iter() .map(|row| { Ok(SearchResultItem { id: parse_uuid(&row.id)?, result_type: SearchResultType::Task, title: row.description, snippet: None, project_id: row .project_id .as_ref() .and_then(|s| Uuid::parse_str(s).ok().map(ProjectId::from)), project_name: row.project_name, rank: -row.rank, // BM25 returns negative values, lower is better }) }) .collect() } /// Searches emails by subject and body via FTS5 MATCH with BM25 ranking. /// Requires a search term (returns empty for filter-only queries). Applies optional /// project filter (by ID or name) and date range filter on the email date field. async fn search_emails_fts( pool: &SqlitePool, user_id: &str, query: &SearchQuery, search_term: Option<&str>, per_type_limit: i64, ) -> Result> { #[derive(sqlx::FromRow)] struct Row { id: String, subject: String, body: String, project_id: Option, project_name: Option, rank: f64, } // Email search requires FTS text for now let search_term = match search_term { Some(t) => t, None => return Ok(vec![]), }; // Build dynamic query with optional filters let mut sql = String::from( r#" SELECT e.id, e.subject, e.body, e.project_id, p.name as project_name, bm25(emails_fts) as rank FROM emails_fts JOIN emails e ON emails_fts.id = e.id LEFT JOIN projects p ON e.project_id = p.id WHERE emails_fts MATCH $1 AND emails_fts.user_id = $2 "#, ); let mut params: Vec = Vec::new(); let mut param_idx = 3; // Project filter by ID if let Some(pid) = &query.project_id { sql.push_str(&format!(" AND e.project_id = ${}", param_idx)); params.push(pid.to_string()); param_idx += 1; } // Project filter by name if let Some(pname) = &query.project_name { sql.push_str(&format!( " AND e.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)", param_idx )); params.push(format!("%{}%", escape_like(pname))); param_idx += 1; } // Date filters if let Some(df) = &query.date_from { sql.push_str(&format!( " AND datetime(e.date) >= datetime(${})", param_idx )); params.push(df.to_rfc3339()); param_idx += 1; } if let Some(dt) = &query.date_to { sql.push_str(&format!( " AND datetime(e.date) <= datetime(${})", param_idx )); params.push(dt.to_rfc3339()); } sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit)); let mut db_query = sqlx::query_as::<_, Row>(&sql) .bind(search_term) .bind(user_id); for param in params { db_query = db_query.bind(param); } let rows: Vec = db_query.fetch_all(pool).await.map_err(CoreError::database)?; rows.into_iter() .map(|row| { // Create a snippet from the body let snippet = create_snippet(&row.body, 150); Ok(SearchResultItem { id: parse_uuid(&row.id)?, result_type: SearchResultType::Email, title: row.subject, snippet: Some(snippet), project_id: row .project_id .as_ref() .and_then(|s| Uuid::parse_str(s).ok().map(ProjectId::from)), project_name: row.project_name, rank: -row.rank, }) }) .collect() } /// Searches projects by name and description via FTS5 MATCH with BM25 ranking. /// Requires a search term (returns empty for filter-only queries). Only applies /// date range filters (on `created_at`); skipped entirely when structured filters are present. async fn search_projects_fts( pool: &SqlitePool, user_id: &str, query: &SearchQuery, search_term: Option<&str>, per_type_limit: i64, ) -> Result> { #[derive(sqlx::FromRow)] struct Row { id: String, name: String, description: String, rank: f64, } // Project search requires FTS text let search_term = match search_term { Some(t) => t, None => return Ok(vec![]), }; // Build dynamic query with optional filters let mut sql = String::from( r#" SELECT p.id, p.name, p.description, bm25(projects_fts) as rank FROM projects_fts JOIN projects p ON projects_fts.id = p.id WHERE projects_fts MATCH $1 AND projects_fts.user_id = $2 "#, ); let mut params: Vec = Vec::new(); let mut param_idx = 3; // Date filters (on created_at) if let Some(df) = &query.date_from { sql.push_str(&format!( " AND datetime(p.created_at) >= datetime(${})", param_idx )); params.push(df.to_rfc3339()); param_idx += 1; } if let Some(dt) = &query.date_to { sql.push_str(&format!( " AND datetime(p.created_at) <= datetime(${})", param_idx )); params.push(dt.to_rfc3339()); } sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit)); let mut db_query = sqlx::query_as::<_, Row>(&sql) .bind(search_term) .bind(user_id); for param in params { db_query = db_query.bind(param); } let rows: Vec = db_query.fetch_all(pool).await.map_err(CoreError::database)?; rows.into_iter() .map(|row| { let snippet = if !row.description.is_empty() { Some(create_snippet(&row.description, 150)) } else { None }; Ok(SearchResultItem { id: parse_uuid(&row.id)?, result_type: SearchResultType::Project, title: row.name, snippet, project_id: None, project_name: None, rank: -row.rank, }) }) .collect() } /// Searches events via FTS5 MATCH with BM25 ranking, or direct query for filter-only /// searches. Supports time-based `is:` filters (today, tomorrow, this_week, overdue) on /// `start_time`, project filter (by ID or name), and date range on `start_time`. async fn search_events_fts( pool: &SqlitePool, user_id: &str, query: &SearchQuery, search_term: Option<&str>, per_type_limit: i64, ) -> Result> { #[derive(sqlx::FromRow)] struct Row { id: String, title: String, description: String, project_id: Option, project_name: Option, rank: f64, } // Build query based on whether we have FTS text let (base_sql, uses_fts) = if search_term.is_some() { ( r#" SELECT ev.id, ev.title, ev.description, ev.project_id, p.name as project_name, bm25(events_fts) as rank FROM events_fts JOIN events ev ON events_fts.id = ev.id LEFT JOIN projects p ON ev.project_id = p.id WHERE events_fts MATCH $1 AND events_fts.user_id = $2 "# .to_string(), true, ) } else { // Filter-only search for events (e.g., is:today for events) ( r#" SELECT ev.id, ev.title, ev.description, ev.project_id, p.name as project_name, 0.0 as rank FROM events ev LEFT JOIN projects p ON ev.project_id = p.id WHERE ev.user_id = $1 "# .to_string(), false, ) }; let mut sql = base_sql; let mut params: Vec = Vec::new(); let mut param_idx = if uses_fts { 3 } else { 2 }; // is: filters for events (only time-based ones apply) for f in &query.is_filters { match f { IsFilter::Today => { sql.push_str(" AND date(ev.start_time, 'localtime') = date('now', 'localtime')"); } IsFilter::Tomorrow => { sql.push_str( " AND date(ev.start_time, 'localtime') = date('now', '+1 day', 'localtime')", ); } IsFilter::ThisWeek => { sql.push_str(" AND datetime(ev.start_time) < datetime('now', 'weekday 1')"); } IsFilter::Overdue => { sql.push_str(" AND datetime(ev.start_time) < datetime('now')"); } _ => {} // Other is: filters don't apply to events } } // Project filter by ID if let Some(pid) = &query.project_id { sql.push_str(&format!(" AND ev.project_id = ${}", param_idx)); params.push(pid.to_string()); param_idx += 1; } // Project filter by name if let Some(pname) = &query.project_name { sql.push_str(&format!( " AND ev.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)", param_idx )); params.push(format!("%{}%", escape_like(pname))); param_idx += 1; } // Date filters if let Some(df) = &query.date_from { sql.push_str(&format!( " AND datetime(ev.start_time) >= datetime(${})", param_idx )); params.push(df.to_rfc3339()); param_idx += 1; } if let Some(dt) = &query.date_to { sql.push_str(&format!( " AND datetime(ev.start_time) <= datetime(${})", param_idx )); params.push(dt.to_rfc3339()); } sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit)); let mut db_query = sqlx::query_as::<_, Row>(&sql); if let Some(term) = search_term { db_query = db_query .bind(term) .bind(user_id); } else { db_query = db_query.bind(user_id); } for param in params { db_query = db_query.bind(param); } let rows: Vec = db_query.fetch_all(pool).await.map_err(CoreError::database)?; rows.into_iter() .map(|row| { let snippet = if !row.description.is_empty() { Some(create_snippet(&row.description, 150)) } else { None }; Ok(SearchResultItem { id: parse_uuid(&row.id)?, result_type: SearchResultType::Event, title: row.title, snippet, project_id: row .project_id .as_ref() .and_then(|s| Uuid::parse_str(s).ok().map(ProjectId::from)), project_name: row.project_name, rank: -row.rank, }) }) .collect() } /// Check if the query can apply to contacts. fn can_search_contacts(query: &SearchQuery) -> bool { // Contacts don't have most filters (similar to projects) query.is_filters.is_empty() && query.priority.is_none() && query.tags_include.is_empty() && query.tags_exclude.is_empty() && query.project_id.is_none() && query.project_name.is_none() } /// Searches contacts by display name and company via FTS5 MATCH with BM25 ranking. /// Requires a search term (returns empty for filter-only queries). No additional /// filters are applied; skipped entirely when structured filters are present. async fn search_contacts_fts( pool: &SqlitePool, user_id: &str, search_term: Option<&str>, per_type_limit: i64, ) -> Result> { #[derive(sqlx::FromRow)] struct Row { id: String, display_name: String, company: Option, rank: f64, } // Contact search requires FTS text let search_term = match search_term { Some(t) => t, None => return Ok(vec![]), }; let sql = format!(r#" SELECT c.id, c.display_name, c.company, bm25(contacts_fts) as rank FROM contacts_fts JOIN contacts c ON contacts_fts.id = c.id WHERE contacts_fts MATCH $1 AND contacts_fts.user_id = $2 ORDER BY rank LIMIT {} "#, per_type_limit); let rows: Vec = sqlx::query_as::<_, Row>(&sql) .bind(search_term) .bind(user_id) .fetch_all(pool) .await .map_err(CoreError::database)?; rows.into_iter() .map(|row| { let snippet = row.company.as_deref() .filter(|c| !c.is_empty()) .map(|c| c.to_string()); Ok(SearchResultItem { id: parse_uuid(&row.id)?, result_type: SearchResultType::Contact, title: row.display_name, snippet, project_id: None, project_name: None, rank: -row.rank, }) }) .collect() } /// Create a snippet from text, truncating to max_len characters fn create_snippet(text: &str, max_len: usize) -> String { let text = text.trim(); if text.len() <= max_len { text.to_string() } else { let mut result = text.chars().take(max_len).collect::(); // Try to break at a word boundary if let Some(last_space) = result.rfind(' ') { result.truncate(last_space); } result.push_str("..."); result } }