Skip to main content

max / goingson

27.0 KB · 856 lines History Blame Raw
1 //! SQLite implementation of the SearchRepository.
2 //!
3 //! Provides full-text search across tasks, emails, and events using SQLite FTS5.
4 //! Search results are ranked by relevance and include preview snippets.
5 //! Supports structured filters like `is:overdue`, `priority:high`, `in:Project`.
6
7 use async_trait::async_trait;
8 use sqlx::SqlitePool;
9 use uuid::Uuid;
10
11 use goingson_core::{
12 search_parser::IsFilter, CoreError, Priority, ProjectId, Result, SearchQuery,
13 SearchRepository, SearchResultItem, SearchResultType, UserId,
14 };
15
16 use crate::utils::{escape_like, parse_uuid};
17
18 /// SQLite-backed implementation of [`SearchRepository`].
19 ///
20 /// Provides full-text search via FTS5 across tasks, emails, events, and projects.
21 /// Supports structured filters (`is:overdue`, `priority:high`, `in:ProjectName`)
22 /// combined with text search.
23 pub struct SqliteSearchRepository {
24 pool: SqlitePool,
25 }
26
27 impl SqliteSearchRepository {
28 /// Creates a new repository instance with the given connection pool.
29 #[tracing::instrument(skip_all)]
30 pub fn new(pool: SqlitePool) -> Self {
31 Self { pool }
32 }
33 }
34
35 #[async_trait]
36 impl SearchRepository for SqliteSearchRepository {
37 #[tracing::instrument(skip_all)]
38 async fn search(&self, user_id: UserId, query: SearchQuery) -> Result<(Vec<SearchResultItem>, usize)> {
39 // If no text and no filters, return empty
40 let has_text = !query.query.trim().is_empty();
41 let has_filters = !query.is_filters.is_empty()
42 || query.priority.is_some()
43 || query.project_name.is_some()
44 || query.project_id.is_some()
45 || !query.tags_include.is_empty()
46 || !query.tags_exclude.is_empty()
47 || query.date_from.is_some()
48 || query.date_to.is_some();
49
50 if !has_text && !has_filters {
51 return Ok((vec![], 0));
52 }
53
54 let user_id_str = user_id.to_string();
55 let limit = query.limit.unwrap_or(50);
56 let offset = query.offset.unwrap_or(0);
57 let per_type_cap = offset.saturating_add(limit);
58
59 // Prepare search term for FTS5 (escape special characters and add prefix matching)
60 let search_term = if has_text {
61 Some(prepare_fts5_query(&query.query))
62 } else {
63 None
64 };
65
66 // Determine which types to search
67 let search_tasks = query
68 .types
69 .as_ref()
70 .is_none_or(|t| t.contains(&SearchResultType::Task));
71 let search_emails = query
72 .types
73 .as_ref()
74 .is_none_or(|t| t.contains(&SearchResultType::Email))
75 && can_search_emails(&query);
76 let search_projects = query
77 .types
78 .as_ref()
79 .is_none_or(|t| t.contains(&SearchResultType::Project))
80 && can_search_projects(&query);
81 let search_events = query
82 .types
83 .as_ref()
84 .is_none_or(|t| t.contains(&SearchResultType::Event))
85 && can_search_events(&query);
86 let search_contacts = query
87 .types
88 .as_ref()
89 .is_none_or(|t| t.contains(&SearchResultType::Contact))
90 && can_search_contacts(&query);
91
92 // Run all applicable FTS queries in parallel with capped per-type limits
93 let (task_r, email_r, project_r, event_r, contact_r) = tokio::join!(
94 async {
95 if search_tasks { search_tasks_fts(&self.pool, &user_id_str, &query, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) }
96 },
97 async {
98 if search_emails { search_emails_fts(&self.pool, &user_id_str, &query, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) }
99 },
100 async {
101 if search_projects { search_projects_fts(&self.pool, &user_id_str, &query, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) }
102 },
103 async {
104 if search_events { search_events_fts(&self.pool, &user_id_str, &query, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) }
105 },
106 async {
107 if search_contacts { search_contacts_fts(&self.pool, &user_id_str, search_term.as_deref(), per_type_cap).await } else { Ok(vec![]) }
108 },
109 );
110
111 let mut results = Vec::new();
112 results.extend(task_r?);
113 results.extend(email_r?);
114 results.extend(project_r?);
115 results.extend(event_r?);
116 results.extend(contact_r?);
117
118 // Sort by rank (higher is better)
119 results.sort_by(|a, b| {
120 b.rank
121 .partial_cmp(&a.rank)
122 .unwrap_or(std::cmp::Ordering::Equal)
123 });
124
125 // Capture total before pagination (approximate — sum of capped per-type counts)
126 let total = results.len();
127
128 // Apply pagination
129 let results: Vec<_> = results
130 .into_iter()
131 .skip(offset as usize)
132 .take(limit as usize)
133 .collect();
134
135 Ok((results, total))
136 }
137 }
138
139 /// Check if the query can apply to emails (doesn't have task-specific filters).
140 fn can_search_emails(query: &SearchQuery) -> bool {
141 // Emails don't have status, priority, or tags, so skip if these filters are set
142 query.is_filters.iter().all(|f| matches!(f, IsFilter::Snoozed)) // emails don't have most is: filters
143 && query.priority.is_none()
144 && query.tags_include.is_empty()
145 && query.tags_exclude.is_empty()
146 // If is_filters contains task-specific filters, skip emails
147 && !query.is_filters.iter().any(|f| {
148 matches!(
149 f,
150 IsFilter::Overdue
151 | IsFilter::Today
152 | IsFilter::Tomorrow
153 | IsFilter::ThisWeek
154 | IsFilter::Pending
155 | IsFilter::Started
156 | IsFilter::Completed
157 | IsFilter::Waiting
158 )
159 })
160 }
161
162 /// Check if the query can apply to projects.
163 fn can_search_projects(query: &SearchQuery) -> bool {
164 // Projects don't have most filters
165 query.is_filters.is_empty()
166 && query.priority.is_none()
167 && query.tags_include.is_empty()
168 && query.tags_exclude.is_empty()
169 && query.project_id.is_none()
170 && query.project_name.is_none()
171 }
172
173 /// Check if the query can apply to events.
174 fn can_search_events(query: &SearchQuery) -> bool {
175 // Events have date filters but not status/priority/tags
176 query.priority.is_none()
177 && query.tags_include.is_empty()
178 && query.tags_exclude.is_empty()
179 && !query.is_filters.iter().any(|f| {
180 matches!(
181 f,
182 IsFilter::Pending
183 | IsFilter::Started
184 | IsFilter::Completed
185 | IsFilter::Waiting
186 | IsFilter::Snoozed
187 )
188 })
189 }
190
191 /// Prepare a search query for FTS5
192 fn prepare_fts5_query(query: &str) -> String {
193 // Split into words and add prefix matching
194 query
195 .split_whitespace()
196 .filter_map(|word| {
197 // Escape special FTS5 characters
198 let escaped = word
199 .replace('"', "\"\"")
200 .replace(['*', '(', ')', ':'], "");
201 if escaped.is_empty() {
202 None
203 } else {
204 Some(format!("\"{}\"*", escaped))
205 }
206 })
207 .collect::<Vec<_>>()
208 .join(" ")
209 }
210
211 /// Build WHERE clauses for `is:` filters on tasks.
212 fn build_is_filter_clauses(is_filters: &[IsFilter]) -> Vec<String> {
213 is_filters
214 .iter()
215 .map(|f| {
216 match f {
217 IsFilter::Overdue => "(t.due IS NOT NULL AND datetime(t.due) < datetime('now'))".to_string(),
218 IsFilter::Today => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', 'localtime'))".to_string(),
219 IsFilter::Tomorrow => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', '+1 day', 'localtime'))".to_string(),
220 IsFilter::ThisWeek => {
221 // Due on or before end of this week (Sunday).
222 // weekday 1 = next Monday; < Monday midnight = through Sunday 23:59:59.
223 "(t.due IS NOT NULL AND datetime(t.due) < datetime('now', 'weekday 1'))".to_string()
224 }
225 IsFilter::Snoozed => "(t.snoozed_until IS NOT NULL AND datetime(t.snoozed_until) > datetime('now'))".to_string(),
226 IsFilter::Pending => "t.status = 'Pending'".to_string(),
227 IsFilter::Started => "t.status = 'Started'".to_string(),
228 IsFilter::Completed => "t.status = 'Completed'".to_string(),
229 IsFilter::Waiting => "t.waiting_for_response = 1".to_string(),
230 }
231 })
232 .collect()
233 }
234
235 /// Searches tasks via FTS5 MATCH with BM25 relevance ranking, or direct query when
236 /// no search text is provided. Applies optional filters: `is:` status/date, project
237 /// (by ID or name), priority, tag include/exclude, and date range on the due field.
238 async fn search_tasks_fts(
239 pool: &SqlitePool,
240 user_id: &str,
241 query: &SearchQuery,
242 search_term: Option<&str>,
243 per_type_limit: i64,
244 ) -> Result<Vec<SearchResultItem>> {
245 #[derive(sqlx::FromRow)]
246 struct Row {
247 id: String,
248 description: String,
249 project_id: Option<String>,
250 project_name: Option<String>,
251 rank: f64,
252 }
253
254 // Build dynamic query with optional filters
255 // When there's no search term, we use a direct query instead of FTS
256 let (base_sql, uses_fts) = if search_term.is_some() {
257 (
258 r#"
259 SELECT
260 t.id,
261 t.description,
262 t.project_id,
263 p.name as project_name,
264 bm25(tasks_fts) as rank
265 FROM tasks_fts
266 JOIN tasks t ON tasks_fts.id = t.id
267 LEFT JOIN projects p ON t.project_id = p.id
268 WHERE tasks_fts MATCH $1
269 AND tasks_fts.user_id = $2
270 "#
271 .to_string(),
272 true,
273 )
274 } else {
275 (
276 r#"
277 SELECT
278 t.id,
279 t.description,
280 t.project_id,
281 p.name as project_name,
282 0.0 as rank
283 FROM tasks t
284 LEFT JOIN projects p ON t.project_id = p.id
285 WHERE t.user_id = $1
286 AND t.status != 'Deleted'
287 "#
288 .to_string(),
289 false,
290 )
291 };
292
293 let mut sql = base_sql;
294 let mut params: Vec<String> = Vec::new();
295 let mut param_idx = if uses_fts { 3 } else { 2 };
296
297 // Add is: filter clauses
298 let is_clauses = build_is_filter_clauses(&query.is_filters);
299 for clause in is_clauses {
300 sql.push_str(&format!(" AND {}", clause));
301 }
302
303 // Project filter by ID
304 if let Some(pid) = &query.project_id {
305 sql.push_str(&format!(" AND t.project_id = ${}", param_idx));
306 params.push(pid.to_string());
307 param_idx += 1;
308 }
309
310 // Project filter by name (partial match)
311 if let Some(pname) = &query.project_name {
312 sql.push_str(&format!(
313 " AND t.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)",
314 param_idx
315 ));
316 params.push(format!("%{}%", escape_like(pname)));
317 param_idx += 1;
318 }
319
320 // Priority filter
321 if let Some(priority) = &query.priority {
322 let priority_str = match priority {
323 Priority::High => "High",
324 Priority::Medium => "Medium",
325 Priority::Low => "Low",
326 };
327 sql.push_str(&format!(" AND t.priority = ${}", param_idx));
328 params.push(priority_str.to_string());
329 param_idx += 1;
330 }
331
332 // Tag filters (include) — tags stored as JSON array in tasks.tags column
333 for tag in &query.tags_include {
334 sql.push_str(&format!(" AND t.tags LIKE ${} ESCAPE '\\'", param_idx));
335 params.push(format!("%\"{}\"%" , escape_like(tag)));
336 param_idx += 1;
337 }
338
339 // Tag filters (exclude)
340 for tag in &query.tags_exclude {
341 sql.push_str(&format!(" AND t.tags NOT LIKE ${} ESCAPE '\\'", param_idx));
342 params.push(format!("%\"{}\"%" , escape_like(tag)));
343 param_idx += 1;
344 }
345
346 // Date filters
347 if let Some(df) = &query.date_from {
348 sql.push_str(&format!(
349 " AND (t.due IS NULL OR datetime(t.due) >= datetime(${})) ",
350 param_idx
351 ));
352 params.push(df.to_rfc3339());
353 param_idx += 1;
354 }
355 if let Some(dt) = &query.date_to {
356 sql.push_str(&format!(
357 " AND (t.due IS NULL OR datetime(t.due) <= datetime(${})) ",
358 param_idx
359 ));
360 params.push(dt.to_rfc3339());
361 }
362
363 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
364
365 // Build and execute query
366 let mut db_query = sqlx::query_as::<_, Row>(&sql);
367
368 if let Some(term) = search_term {
369 db_query = db_query
370 .bind(term)
371 .bind(user_id);
372 } else {
373 db_query = db_query.bind(user_id);
374 }
375
376 for param in params {
377 db_query = db_query.bind(param);
378 }
379
380 let rows: Vec<Row> = db_query.fetch_all(pool).await.map_err(CoreError::database)?;
381
382 rows.into_iter()
383 .map(|row| {
384 Ok(SearchResultItem {
385 id: parse_uuid(&row.id)?,
386 result_type: SearchResultType::Task,
387 title: row.description,
388 snippet: None,
389 project_id: row
390 .project_id
391 .as_ref()
392 .and_then(|s| Uuid::parse_str(s).ok().map(ProjectId::from)),
393 project_name: row.project_name,
394 rank: -row.rank, // BM25 returns negative values, lower is better
395 })
396 })
397 .collect()
398 }
399
400 /// Searches emails by subject and body via FTS5 MATCH with BM25 ranking.
401 /// Requires a search term (returns empty for filter-only queries). Applies optional
402 /// project filter (by ID or name) and date range filter on the email date field.
403 async fn search_emails_fts(
404 pool: &SqlitePool,
405 user_id: &str,
406 query: &SearchQuery,
407 search_term: Option<&str>,
408 per_type_limit: i64,
409 ) -> Result<Vec<SearchResultItem>> {
410 #[derive(sqlx::FromRow)]
411 struct Row {
412 id: String,
413 subject: String,
414 body: String,
415 project_id: Option<String>,
416 project_name: Option<String>,
417 rank: f64,
418 }
419
420 // Email search requires FTS text for now
421 let search_term = match search_term {
422 Some(t) => t,
423 None => return Ok(vec![]),
424 };
425
426 // Build dynamic query with optional filters
427 let mut sql = String::from(
428 r#"
429 SELECT
430 e.id,
431 e.subject,
432 e.body,
433 e.project_id,
434 p.name as project_name,
435 bm25(emails_fts) as rank
436 FROM emails_fts
437 JOIN emails e ON emails_fts.id = e.id
438 LEFT JOIN projects p ON e.project_id = p.id
439 WHERE emails_fts MATCH $1
440 AND emails_fts.user_id = $2
441 "#,
442 );
443
444 let mut params: Vec<String> = Vec::new();
445 let mut param_idx = 3;
446
447 // Project filter by ID
448 if let Some(pid) = &query.project_id {
449 sql.push_str(&format!(" AND e.project_id = ${}", param_idx));
450 params.push(pid.to_string());
451 param_idx += 1;
452 }
453
454 // Project filter by name
455 if let Some(pname) = &query.project_name {
456 sql.push_str(&format!(
457 " AND e.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)",
458 param_idx
459 ));
460 params.push(format!("%{}%", escape_like(pname)));
461 param_idx += 1;
462 }
463
464 // Date filters
465 if let Some(df) = &query.date_from {
466 sql.push_str(&format!(
467 " AND datetime(e.date) >= datetime(${})",
468 param_idx
469 ));
470 params.push(df.to_rfc3339());
471 param_idx += 1;
472 }
473 if let Some(dt) = &query.date_to {
474 sql.push_str(&format!(
475 " AND datetime(e.date) <= datetime(${})",
476 param_idx
477 ));
478 params.push(dt.to_rfc3339());
479 }
480
481 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
482
483 let mut db_query = sqlx::query_as::<_, Row>(&sql)
484 .bind(search_term)
485 .bind(user_id);
486
487 for param in params {
488 db_query = db_query.bind(param);
489 }
490
491 let rows: Vec<Row> = db_query.fetch_all(pool).await.map_err(CoreError::database)?;
492
493 rows.into_iter()
494 .map(|row| {
495 // Create a snippet from the body
496 let snippet = create_snippet(&row.body, 150);
497
498 Ok(SearchResultItem {
499 id: parse_uuid(&row.id)?,
500 result_type: SearchResultType::Email,
501 title: row.subject,
502 snippet: Some(snippet),
503 project_id: row
504 .project_id
505 .as_ref()
506 .and_then(|s| Uuid::parse_str(s).ok().map(ProjectId::from)),
507 project_name: row.project_name,
508 rank: -row.rank,
509 })
510 })
511 .collect()
512 }
513
514 /// Searches projects by name and description via FTS5 MATCH with BM25 ranking.
515 /// Requires a search term (returns empty for filter-only queries). Only applies
516 /// date range filters (on `created_at`); skipped entirely when structured filters are present.
517 async fn search_projects_fts(
518 pool: &SqlitePool,
519 user_id: &str,
520 query: &SearchQuery,
521 search_term: Option<&str>,
522 per_type_limit: i64,
523 ) -> Result<Vec<SearchResultItem>> {
524 #[derive(sqlx::FromRow)]
525 struct Row {
526 id: String,
527 name: String,
528 description: String,
529 rank: f64,
530 }
531
532 // Project search requires FTS text
533 let search_term = match search_term {
534 Some(t) => t,
535 None => return Ok(vec![]),
536 };
537
538 // Build dynamic query with optional filters
539 let mut sql = String::from(
540 r#"
541 SELECT
542 p.id,
543 p.name,
544 p.description,
545 bm25(projects_fts) as rank
546 FROM projects_fts
547 JOIN projects p ON projects_fts.id = p.id
548 WHERE projects_fts MATCH $1
549 AND projects_fts.user_id = $2
550 "#,
551 );
552
553 let mut params: Vec<String> = Vec::new();
554 let mut param_idx = 3;
555
556 // Date filters (on created_at)
557 if let Some(df) = &query.date_from {
558 sql.push_str(&format!(
559 " AND datetime(p.created_at) >= datetime(${})",
560 param_idx
561 ));
562 params.push(df.to_rfc3339());
563 param_idx += 1;
564 }
565 if let Some(dt) = &query.date_to {
566 sql.push_str(&format!(
567 " AND datetime(p.created_at) <= datetime(${})",
568 param_idx
569 ));
570 params.push(dt.to_rfc3339());
571 }
572
573 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
574
575 let mut db_query = sqlx::query_as::<_, Row>(&sql)
576 .bind(search_term)
577 .bind(user_id);
578
579 for param in params {
580 db_query = db_query.bind(param);
581 }
582
583 let rows: Vec<Row> = db_query.fetch_all(pool).await.map_err(CoreError::database)?;
584
585 rows.into_iter()
586 .map(|row| {
587 let snippet = if !row.description.is_empty() {
588 Some(create_snippet(&row.description, 150))
589 } else {
590 None
591 };
592
593 Ok(SearchResultItem {
594 id: parse_uuid(&row.id)?,
595 result_type: SearchResultType::Project,
596 title: row.name,
597 snippet,
598 project_id: None,
599 project_name: None,
600 rank: -row.rank,
601 })
602 })
603 .collect()
604 }
605
606 /// Searches events via FTS5 MATCH with BM25 ranking, or direct query for filter-only
607 /// searches. Supports time-based `is:` filters (today, tomorrow, this_week, overdue) on
608 /// `start_time`, project filter (by ID or name), and date range on `start_time`.
609 async fn search_events_fts(
610 pool: &SqlitePool,
611 user_id: &str,
612 query: &SearchQuery,
613 search_term: Option<&str>,
614 per_type_limit: i64,
615 ) -> Result<Vec<SearchResultItem>> {
616 #[derive(sqlx::FromRow)]
617 struct Row {
618 id: String,
619 title: String,
620 description: String,
621 project_id: Option<String>,
622 project_name: Option<String>,
623 rank: f64,
624 }
625
626 // Build query based on whether we have FTS text
627 let (base_sql, uses_fts) = if search_term.is_some() {
628 (
629 r#"
630 SELECT
631 ev.id,
632 ev.title,
633 ev.description,
634 ev.project_id,
635 p.name as project_name,
636 bm25(events_fts) as rank
637 FROM events_fts
638 JOIN events ev ON events_fts.id = ev.id
639 LEFT JOIN projects p ON ev.project_id = p.id
640 WHERE events_fts MATCH $1
641 AND events_fts.user_id = $2
642 "#
643 .to_string(),
644 true,
645 )
646 } else {
647 // Filter-only search for events (e.g., is:today for events)
648 (
649 r#"
650 SELECT
651 ev.id,
652 ev.title,
653 ev.description,
654 ev.project_id,
655 p.name as project_name,
656 0.0 as rank
657 FROM events ev
658 LEFT JOIN projects p ON ev.project_id = p.id
659 WHERE ev.user_id = $1
660 "#
661 .to_string(),
662 false,
663 )
664 };
665
666 let mut sql = base_sql;
667 let mut params: Vec<String> = Vec::new();
668 let mut param_idx = if uses_fts { 3 } else { 2 };
669
670 // is: filters for events (only time-based ones apply)
671 for f in &query.is_filters {
672 match f {
673 IsFilter::Today => {
674 sql.push_str(" AND date(ev.start_time, 'localtime') = date('now', 'localtime')");
675 }
676 IsFilter::Tomorrow => {
677 sql.push_str(
678 " AND date(ev.start_time, 'localtime') = date('now', '+1 day', 'localtime')",
679 );
680 }
681 IsFilter::ThisWeek => {
682 sql.push_str(" AND datetime(ev.start_time) < datetime('now', 'weekday 1')");
683 }
684 IsFilter::Overdue => {
685 sql.push_str(" AND datetime(ev.start_time) < datetime('now')");
686 }
687 _ => {} // Other is: filters don't apply to events
688 }
689 }
690
691 // Project filter by ID
692 if let Some(pid) = &query.project_id {
693 sql.push_str(&format!(" AND ev.project_id = ${}", param_idx));
694 params.push(pid.to_string());
695 param_idx += 1;
696 }
697
698 // Project filter by name
699 if let Some(pname) = &query.project_name {
700 sql.push_str(&format!(
701 " AND ev.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)",
702 param_idx
703 ));
704 params.push(format!("%{}%", escape_like(pname)));
705 param_idx += 1;
706 }
707
708 // Date filters
709 if let Some(df) = &query.date_from {
710 sql.push_str(&format!(
711 " AND datetime(ev.start_time) >= datetime(${})",
712 param_idx
713 ));
714 params.push(df.to_rfc3339());
715 param_idx += 1;
716 }
717 if let Some(dt) = &query.date_to {
718 sql.push_str(&format!(
719 " AND datetime(ev.start_time) <= datetime(${})",
720 param_idx
721 ));
722 params.push(dt.to_rfc3339());
723 }
724
725 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
726
727 let mut db_query = sqlx::query_as::<_, Row>(&sql);
728
729 if let Some(term) = search_term {
730 db_query = db_query
731 .bind(term)
732 .bind(user_id);
733 } else {
734 db_query = db_query.bind(user_id);
735 }
736
737 for param in params {
738 db_query = db_query.bind(param);
739 }
740
741 let rows: Vec<Row> = db_query.fetch_all(pool).await.map_err(CoreError::database)?;
742
743 rows.into_iter()
744 .map(|row| {
745 let snippet = if !row.description.is_empty() {
746 Some(create_snippet(&row.description, 150))
747 } else {
748 None
749 };
750
751 Ok(SearchResultItem {
752 id: parse_uuid(&row.id)?,
753 result_type: SearchResultType::Event,
754 title: row.title,
755 snippet,
756 project_id: row
757 .project_id
758 .as_ref()
759 .and_then(|s| Uuid::parse_str(s).ok().map(ProjectId::from)),
760 project_name: row.project_name,
761 rank: -row.rank,
762 })
763 })
764 .collect()
765 }
766
767 /// Check if the query can apply to contacts.
768 fn can_search_contacts(query: &SearchQuery) -> bool {
769 // Contacts don't have most filters (similar to projects)
770 query.is_filters.is_empty()
771 && query.priority.is_none()
772 && query.tags_include.is_empty()
773 && query.tags_exclude.is_empty()
774 && query.project_id.is_none()
775 && query.project_name.is_none()
776 }
777
778 /// Searches contacts by display name and company via FTS5 MATCH with BM25 ranking.
779 /// Requires a search term (returns empty for filter-only queries). No additional
780 /// filters are applied; skipped entirely when structured filters are present.
781 async fn search_contacts_fts(
782 pool: &SqlitePool,
783 user_id: &str,
784 search_term: Option<&str>,
785 per_type_limit: i64,
786 ) -> Result<Vec<SearchResultItem>> {
787 #[derive(sqlx::FromRow)]
788 struct Row {
789 id: String,
790 display_name: String,
791 company: Option<String>,
792 rank: f64,
793 }
794
795 // Contact search requires FTS text
796 let search_term = match search_term {
797 Some(t) => t,
798 None => return Ok(vec![]),
799 };
800
801 let sql = format!(r#"
802 SELECT
803 c.id,
804 c.display_name,
805 c.company,
806 bm25(contacts_fts) as rank
807 FROM contacts_fts
808 JOIN contacts c ON contacts_fts.id = c.id
809 WHERE contacts_fts MATCH $1
810 AND contacts_fts.user_id = $2
811 ORDER BY rank
812 LIMIT {}
813 "#, per_type_limit);
814
815 let rows: Vec<Row> = sqlx::query_as::<_, Row>(&sql)
816 .bind(search_term)
817 .bind(user_id)
818 .fetch_all(pool)
819 .await
820 .map_err(CoreError::database)?;
821
822 rows.into_iter()
823 .map(|row| {
824 let snippet = row.company.as_deref()
825 .filter(|c| !c.is_empty())
826 .map(|c| c.to_string());
827
828 Ok(SearchResultItem {
829 id: parse_uuid(&row.id)?,
830 result_type: SearchResultType::Contact,
831 title: row.display_name,
832 snippet,
833 project_id: None,
834 project_name: None,
835 rank: -row.rank,
836 })
837 })
838 .collect()
839 }
840
841 /// Create a snippet from text, truncating to max_len characters
842 fn create_snippet(text: &str, max_len: usize) -> String {
843 let text = text.trim();
844 if text.len() <= max_len {
845 text.to_string()
846 } else {
847 let mut result = text.chars().take(max_len).collect::<String>();
848 // Try to break at a word boundary
849 if let Some(last_space) = result.rfind(' ') {
850 result.truncate(last_space);
851 }
852 result.push_str("...");
853 result
854 }
855 }
856