1use crate::ids::{ProjectId, TaskId, WorkspaceId};
2use anyhow::Result;
3use chrono::Local;
4use serde::Serialize;
5use sqlx::{QueryBuilder, Row, Sqlite, SqliteConnection};
6use std::collections::HashMap;
7
8use crate::db::task_from_row;
9use crate::projects::resolve_existing_project_in_workspace;
10use crate::refs::DisplayRefContext;
11use crate::task_enrichment::{epic_parents_for_tasks, labels_for_tasks};
12use crate::types::Task;
13
14use super::hydration::{TaskHydration, build_task_list_items};
15use super::{TaskListItem, validate_search_limit};
16
17mod parser;
18
19const SQLITE_BIND_CHUNK_SIZE: usize = 900;
20
21const REF_WEIGHT: i64 = 1_000;
22const TITLE_WEIGHT: i64 = 420;
23const LABEL_WEIGHT: i64 = 240;
24const PROJECT_WEIGHT: i64 = 220;
25const STATUS_WEIGHT: i64 = 160;
26const PRIORITY_WEIGHT: i64 = 150;
27const DESCRIPTION_WEIGHT: i64 = 100;
28const ATTACHMENT_WEIGHT: i64 = 90;
29const NOTE_WEIGHT: i64 = 80;
30const FIELD_MATCH_BONUS: i64 = 35_000;
31const EXTRA_FIELD_BONUS: i64 = 18_000;
32const FIELD_SCORE_DIVISOR: i64 = 5;
33const PRIORITY_BOOST_CAP: i64 = 18_000;
34const RECENCY_BOOST_CAP: i64 = 12_000;
35
36#[derive(Debug, Clone)]
37pub struct TaskSearchQuery {
38 pub text: String,
39 pub project: Option<String>,
40 pub include_deleted: bool,
41 pub limit: usize,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum SearchMatchedField {
47 Ref,
48 Title,
49 Label,
50 Project,
51 Status,
52 Priority,
53 Description,
54 Attachment,
55 Note,
56}
57
58impl SearchMatchedField {
59 pub fn as_str(self) -> &'static str {
60 match self {
61 Self::Ref => "ref",
62 Self::Title => "title",
63 Self::Label => "label",
64 Self::Project => "project",
65 Self::Status => "status",
66 Self::Priority => "priority",
67 Self::Description => "description",
68 Self::Attachment => "attachment",
69 Self::Note => "note",
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
75pub struct TaskSearchResult {
76 pub item: TaskListItem,
77 pub score: i64,
78 pub matched_field: SearchMatchedField,
79 pub snippet: Option<String>,
80}
81
82#[derive(Debug, Clone)]
83pub struct TaskSearchResultSet {
84 pub items: Vec<TaskSearchResult>,
85}
86
87#[derive(Debug, Clone)]
88pub struct TaskSearchPreviewResult {
89 pub task_id: TaskId,
90 pub display_ref: String,
91 pub title: String,
92 pub project_key: String,
93 pub status: String,
94 pub priority: String,
95 pub created_at: String,
96 pub labels: Vec<String>,
97 pub deleted: bool,
98 pub is_epic: bool,
99 pub epic_parent_display_ref: Option<String>,
100 pub score: i64,
101 pub matched_field: SearchMatchedField,
102 pub snippet: Option<String>,
103}
104
105#[derive(Debug, Clone)]
106pub struct TaskSearchPreviewResultSet {
107 pub items: Vec<TaskSearchPreviewResult>,
108 pub total_matches: usize,
109}
110
111struct ScoredSearchResults {
112 items: Vec<ScoredDocument>,
113}
114
115struct SearchDocument {
116 task: Task,
117 display_ref: String,
118 project_name: String,
119 labels_text: String,
120 notes_text: String,
121 attachments_text: String,
122}
123
124struct ScoredDocument {
125 document: SearchDocument,
126 score: i64,
127 matched_field: SearchMatchedField,
128 snippet: Option<String>,
129}
130
131struct FieldEvidence {
132 score: i64,
133 matched_field: SearchMatchedField,
134 snippet: Option<String>,
135}
136
137pub async fn search_task_items_in_workspace(
138 conn: &mut SqliteConnection,
139 workspace_id: &WorkspaceId,
140 query: TaskSearchQuery,
141) -> Result<Vec<TaskSearchResult>> {
142 validate_search_limit(query.limit)?;
143 Ok(
144 search_task_item_set_with_presentation(conn, workspace_id, query, true)
145 .await?
146 .items,
147 )
148}
149
150pub async fn search_task_occurrence_items_in_workspace(
151 conn: &mut SqliteConnection,
152 workspace_id: &WorkspaceId,
153 query: TaskSearchQuery,
154) -> Result<Vec<TaskSearchResult>> {
155 validate_search_limit(query.limit)?;
156 Ok(
157 search_task_item_set_with_presentation(conn, workspace_id, query, false)
158 .await?
159 .items,
160 )
161}
162
163async fn search_task_item_set_with_presentation(
164 conn: &mut SqliteConnection,
165 workspace_id: &WorkspaceId,
166 query: TaskSearchQuery,
167 group_recurring: bool,
168) -> Result<TaskSearchResultSet> {
169 let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
170 let scored = scored_search_documents(conn, workspace_id, &query, &display_refs).await?;
171 let tasks = scored
172 .items
173 .iter()
174 .map(|scored| scored.document.task.clone())
175 .collect::<Vec<_>>();
176 let now_seconds = crate::queue::now_seconds();
177 let items = build_task_list_items(
178 conn,
179 workspace_id,
180 tasks,
181 now_seconds,
182 Local::now().date_naive(),
183 &display_refs,
184 TaskHydration::Detail,
185 )
186 .await?;
187 let by_id = items
188 .into_iter()
189 .map(|item| (item.task.id.clone(), item))
190 .collect::<HashMap<_, _>>();
191 let results = scored
192 .items
193 .into_iter()
194 .filter_map(|scored| {
195 by_id
196 .get(&scored.document.task.id)
197 .cloned()
198 .map(|item| TaskSearchResult {
199 item,
200 score: scored.score,
201 matched_field: scored.matched_field,
202 snippet: scored.snippet,
203 })
204 })
205 .collect::<Vec<_>>();
206 let metadata = results
207 .iter()
208 .map(|result| {
209 (
210 result.item.task.id.clone(),
211 (result.score, result.matched_field, result.snippet.clone()),
212 )
213 })
214 .collect::<HashMap<_, _>>();
215 let grouped = if group_recurring {
216 let at =
217 chrono::DateTime::parse_from_rfc3339(&crate::ids::now())?.with_timezone(&chrono::Utc);
218 super::recurrence::group_search_task_items(
219 conn,
220 workspace_id,
221 results.into_iter().map(|result| result.item).collect(),
222 at,
223 )
224 .await?
225 } else {
226 results.into_iter().map(|result| result.item).collect()
227 };
228 let items = grouped
229 .into_iter()
230 .filter_map(|item| {
231 metadata
232 .get(&item.task.id)
233 .map(|metadata| TaskSearchResult {
234 item,
235 score: metadata.0,
236 matched_field: metadata.1,
237 snippet: metadata.2.clone(),
238 })
239 })
240 .collect();
241 Ok(TaskSearchResultSet { items })
242}
243
244async fn scored_search_documents(
245 conn: &mut SqliteConnection,
246 workspace_id: &WorkspaceId,
247 query: &TaskSearchQuery,
248 display_refs: &DisplayRefContext,
249) -> Result<ScoredSearchResults> {
250 validate_search_limit(query.limit)?;
251 let limit = query.limit;
252 let parsed = parser::parse_task_search_query(&query.text);
253 if parsed.trimmed.is_empty() {
254 return Ok(ScoredSearchResults { items: Vec::new() });
255 }
256 let load_deleted = query.include_deleted || parsed.ref_query.is_some();
257 let project = if let Some(project) = query.project.as_deref() {
258 Some(resolve_existing_project_in_workspace(conn, workspace_id, project).await?)
259 } else {
260 None
261 };
262 let documents = load_candidate_search_documents(
263 conn,
264 workspace_id,
265 project.as_ref().map(|project| &project.id),
266 load_deleted,
267 &parsed,
268 display_refs,
269 )
270 .await?;
271
272 let now_seconds = crate::queue::now_seconds();
273 let mut scored = documents
274 .into_iter()
275 .filter_map(|document| {
276 let is_deleted = document.task.deleted;
277 let ref_strong_enough = parsed
278 .ref_query
279 .as_ref()
280 .is_some_and(|rq| ref_query_matches_display_or_full_id(&document, rq));
281 let scored = score_document(document, &parsed, now_seconds)?;
282 if is_deleted
283 && !query.include_deleted
284 && (scored.matched_field != SearchMatchedField::Ref || !ref_strong_enough)
285 {
286 return None;
287 }
288 Some(scored)
289 })
290 .collect::<Vec<_>>();
291 scored.sort_by(|a, b| {
292 b.score
293 .cmp(&a.score)
294 .then_with(|| b.document.task.updated_at.cmp(&a.document.task.updated_at))
295 .then_with(|| a.document.task.title.cmp(&b.document.task.title))
296 .then_with(|| a.document.task.id.cmp(&b.document.task.id))
297 });
298 scored.truncate(limit);
299 Ok(ScoredSearchResults { items: scored })
300}
301
302pub async fn search_task_preview_set_in_workspace(
303 conn: &mut SqliteConnection,
304 workspace_id: &WorkspaceId,
305 query: TaskSearchQuery,
306) -> Result<TaskSearchPreviewResultSet> {
307 validate_search_limit(query.limit)?;
308 let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
309 let mut scored = scored_search_documents(conn, workspace_id, &query, &display_refs).await?;
310 let task_ids = scored
311 .items
312 .iter()
313 .map(|scored| scored.document.task.id.clone())
314 .collect::<Vec<_>>();
315 let recurrence =
316 super::recurrence::task_recurrence_summaries(conn, workspace_id, &task_ids).await?;
317 let mut seen_series = std::collections::HashSet::new();
318 scored.items.retain(|scored| {
319 recurrence
320 .get(&scored.document.task.id)
321 .is_none_or(|summary| seen_series.insert(summary.series_id.clone()))
322 });
323 let total_matches = scored.items.len();
324 let mut labels_by_task = labels_for_tasks(conn, workspace_id, &task_ids).await?;
325 let mut epic_parents_by_task =
326 epic_parents_for_tasks(conn, workspace_id, &task_ids, &display_refs).await?;
327 let items = scored
328 .items
329 .into_iter()
330 .map(|scored| {
331 let task = scored.document.task;
332 TaskSearchPreviewResult {
333 task_id: task.id.clone(),
334 display_ref: scored.document.display_ref,
335 title: task.title,
336 project_key: task.project_key,
337 status: task.status.as_str().to_string(),
338 priority: task.priority.as_str().to_string(),
339 created_at: task.created_at,
340 labels: labels_by_task.remove(&task.id).unwrap_or_default(),
341 deleted: task.deleted,
342 is_epic: task.is_epic,
343 epic_parent_display_ref: epic_parents_by_task
344 .remove(&task.id)
345 .map(|parent| parent.display_ref),
346 score: scored.score,
347 matched_field: scored.matched_field,
348 snippet: scored.snippet,
349 }
350 })
351 .collect();
352 Ok(TaskSearchPreviewResultSet {
353 items,
354 total_matches,
355 })
356}
357
358async fn attachment_search_text_by_task(
359 conn: &mut SqliteConnection,
360 workspace_id: &str,
361 task_ids: &[String],
362) -> Result<HashMap<String, String>> {
363 let mut by_task: HashMap<String, Vec<String>> = HashMap::new();
364 if task_ids.is_empty() {
365 return Ok(HashMap::new());
366 }
367 for chunk in task_ids.chunks(SQLITE_BIND_CHUNK_SIZE) {
368 if chunk.is_empty() {
369 continue;
370 }
371 let mut query = QueryBuilder::<Sqlite>::new(
372 "SELECT task_id, filename, alt_text FROM task_attachments WHERE workspace_id = ",
373 );
374 query.push_bind(workspace_id);
375 query.push(" AND deleted = 0 AND task_id IN (");
376 {
377 let mut separated = query.separated(", ");
378 for task_id in chunk {
379 separated.push_bind(task_id);
380 }
381 }
382 query.push(") ORDER BY task_id, created_at, attachment_id");
383
384 for row in query.build().fetch_all(&mut *conn).await? {
385 let task_id: String = row.get("task_id");
386 if let Some(filename) = row.get::<Option<String>, _>("filename") {
387 by_task.entry(task_id.clone()).or_default().push(filename);
388 }
389 if let Some(alt_text) = row.get::<Option<String>, _>("alt_text") {
390 by_task.entry(task_id).or_default().push(alt_text);
391 }
392 }
393 }
394 Ok(by_task
395 .into_iter()
396 .map(|(task_id, parts)| (task_id, parts.join(" ")))
397 .collect())
398}
399
400async fn attach_attachment_search_text(
401 conn: &mut SqliteConnection,
402 workspace_id: &str,
403 documents: &mut [SearchDocument],
404) -> Result<()> {
405 let task_ids = documents
406 .iter()
407 .map(|document| document.task.id.to_string())
408 .collect::<Vec<_>>();
409 let mut attachments_by_task =
410 attachment_search_text_by_task(conn, workspace_id, &task_ids).await?;
411 for document in documents {
412 document.attachments_text = attachments_by_task
413 .remove(document.task.id.as_str())
414 .unwrap_or_default();
415 }
416 Ok(())
417}
418
419fn attachment_query_terms(parsed: &parser::ParsedTaskSearchQuery) -> Vec<String> {
420 let mut terms = Vec::new();
421 let trimmed = parsed.trimmed.trim().to_ascii_lowercase();
422 if !trimmed.is_empty() {
423 terms.push(trimmed);
424 }
425 for term in search_terms(parsed) {
426 let term = term.trim().to_ascii_lowercase();
427 if !term.is_empty() && !terms.iter().any(|existing| existing == &term) {
428 terms.push(term);
429 }
430 }
431 terms
432}
433
434async fn load_attachment_text_search_documents(
435 conn: &mut SqliteConnection,
436 workspace_id: &str,
437 project_id: Option<&ProjectId>,
438 include_deleted: bool,
439 parsed: &parser::ParsedTaskSearchQuery,
440 display_refs: &DisplayRefContext,
441) -> Result<Vec<SearchDocument>> {
442 let terms = attachment_query_terms(parsed);
443 if terms.is_empty() {
444 return Ok(Vec::new());
445 }
446 let mut query = QueryBuilder::<Sqlite>::new(
447 "SELECT DISTINCT t.id, t.workspace_id, t.title, t.description, t.project_id,
448 p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix,
449 t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic,
450 '' AS fts_labels, '' AS fts_notes
451 FROM task_attachments ta
452 JOIN tasks t ON t.workspace_id = ta.workspace_id AND t.id = ta.task_id
453 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
454 WHERE ta.workspace_id = ",
455 );
456 query.push_bind(workspace_id);
457 query.push(" AND ta.deleted = 0");
458 if let Some(project_id) = project_id {
459 query.push(" AND t.project_id = ");
460 query.push_bind(project_id);
461 }
462 query.push(" AND (");
463 let mut first = true;
464 for term in terms {
465 if !first {
466 query.push(" OR ");
467 }
468 first = false;
469 let pattern = format!("%{term}%");
470 query.push("(lower(COALESCE(ta.filename, '')) LIKE ");
471 query.push_bind(pattern.clone());
472 query.push(" OR lower(COALESCE(ta.alt_text, '')) LIKE ");
473 query.push_bind(pattern);
474 query.push(")");
475 }
476 query.push(") AND (");
477 query.push_bind(include_deleted);
478 query.push(" OR t.deleted = 0) AND ");
479 query.push(super::fragments::ordinary_task_clause("t"));
480 query.push(" ORDER BY t.updated_at DESC, t.id");
481
482 let rows = query.build().fetch_all(&mut *conn).await?;
483 search_documents_from_rows(rows, display_refs)
484}
485
486async fn load_candidate_search_documents(
487 conn: &mut SqliteConnection,
488 workspace_id: &WorkspaceId,
489 project_id: Option<&ProjectId>,
490 include_deleted: bool,
491 parsed: &parser::ParsedTaskSearchQuery,
492 display_refs: &DisplayRefContext,
493) -> Result<Vec<SearchDocument>> {
494 let mut documents = if let Some(fts_match) = parsed.fts_match.as_deref() {
495 load_fts_search_documents(
496 conn,
497 workspace_id,
498 project_id,
499 include_deleted,
500 fts_match,
501 display_refs,
502 )
503 .await?
504 } else {
505 Vec::new()
506 };
507 if let Some(ref_query) = &parsed.ref_query {
508 let ref_documents = load_ref_search_documents(
509 conn,
510 workspace_id,
511 project_id,
512 include_deleted,
513 ref_query,
514 display_refs,
515 )
516 .await?;
517 merge_search_documents(&mut documents, ref_documents);
518 }
519 let attachment_documents = load_attachment_text_search_documents(
520 conn,
521 workspace_id.as_str(),
522 project_id,
523 include_deleted,
524 parsed,
525 display_refs,
526 )
527 .await?;
528 merge_search_documents(&mut documents, attachment_documents);
529 attach_attachment_search_text(conn, workspace_id.as_str(), &mut documents).await?;
530 Ok(documents)
531}
532
533async fn load_ref_search_documents(
534 conn: &mut SqliteConnection,
535 workspace_id: &WorkspaceId,
536 project_id: Option<&ProjectId>,
537 include_deleted: bool,
538 ref_query: &parser::ParsedRefSearchQuery,
539 display_refs: &DisplayRefContext,
540) -> Result<Vec<SearchDocument>> {
541 let rows = sqlx::query(
542 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
543 p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix,
544 t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic,
545 '' AS fts_labels, '' AS fts_notes
546 FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
547 WHERE t.workspace_id = ? AND (? OR t.deleted = 0)
548 AND (? OR t.project_id = ?) AND NOT EXISTS (
549 SELECT 1 FROM recurrence_occurrences ro
550 JOIN recurrence_series rs ON rs.workspace_id = ro.workspace_id AND rs.id = ro.series_id
551 WHERE ro.workspace_id = t.workspace_id AND ro.task_id = t.id
552 AND (ro.projection_state = 'archived'
553 OR (ro.projection_state = 'projected' AND rs.state = 'paused'))
554 ) AND t.id LIKE ? || '%'
555 ORDER BY t.updated_at DESC, t.id",
556 )
557 .bind(workspace_id)
558 .bind(include_deleted)
559 .bind(project_id.is_none())
560 .bind(project_id)
561 .bind(&ref_query.normalized_suffix)
562 .fetch_all(&mut *conn)
563 .await?;
564 search_documents_from_rows(rows, display_refs)
565}
566
567fn merge_search_documents(documents: &mut Vec<SearchDocument>, incoming: Vec<SearchDocument>) {
568 for document in incoming {
569 if !documents
570 .iter()
571 .any(|existing| existing.task.id == document.task.id)
572 {
573 documents.push(document);
574 }
575 }
576}
577
578async fn load_fts_search_documents(
579 conn: &mut SqliteConnection,
580 workspace_id: &WorkspaceId,
581 project_id: Option<&ProjectId>,
582 include_deleted: bool,
583 raw_fts_match: &str,
584 display_refs: &DisplayRefContext,
585) -> Result<Vec<SearchDocument>> {
586 let fts_match = workspace_scoped_fts_match(workspace_id, raw_fts_match);
587 let rows = sqlx::query(
588 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
589 p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix,
590 t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic,
591 d.labels AS fts_labels, d.notes AS fts_notes
592 FROM task_search_fts f
593 JOIN task_search_documents d ON d.doc_id = f.rowid
594 JOIN tasks t ON t.workspace_id = d.workspace_id AND t.id = d.task_id
595 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
596 WHERE task_search_fts MATCH ? AND d.workspace_id = ? AND (? OR t.deleted = 0)
597 AND (? OR t.project_id = ?)
598 AND NOT EXISTS (
599 SELECT 1 FROM recurrence_occurrences ro
600 JOIN recurrence_series rs ON rs.workspace_id = ro.workspace_id AND rs.id = ro.series_id
601 WHERE ro.workspace_id = t.workspace_id AND ro.task_id = t.id
602 AND (ro.projection_state = 'archived'
603 OR (ro.projection_state = 'projected' AND rs.state = 'paused'))
604 )
605 ORDER BY t.updated_at DESC, t.id",
606 )
607 .bind(&fts_match)
608 .bind(workspace_id)
609 .bind(include_deleted)
610 .bind(project_id.is_none())
611 .bind(project_id)
612 .fetch_all(&mut *conn)
613 .await?;
614 search_documents_from_rows(rows, display_refs)
615}
616
617fn search_documents_from_rows(
618 rows: Vec<sqlx::sqlite::SqliteRow>,
619 display_refs: &DisplayRefContext,
620) -> Result<Vec<SearchDocument>> {
621 let mut tasks = Vec::with_capacity(rows.len());
622 let mut project_names = Vec::with_capacity(rows.len());
623 let mut labels_texts = Vec::with_capacity(rows.len());
624 let mut notes_texts = Vec::with_capacity(rows.len());
625 for row in rows {
626 project_names.push(row.get::<String, _>("project_name"));
627 labels_texts.push(row.get::<String, _>("fts_labels"));
628 notes_texts.push(row.get::<String, _>("fts_notes"));
629 tasks.push(task_from_row(&row)?);
630 }
631 Ok(tasks
632 .into_iter()
633 .zip(project_names)
634 .zip(labels_texts)
635 .zip(notes_texts)
636 .map(|(((task, project_name), labels_text), notes_text)| {
637 let display_ref = display_refs.display_ref(&task);
638 SearchDocument {
639 labels_text,
640 notes_text,
641 attachments_text: String::new(),
642 task,
643 display_ref,
644 project_name,
645 }
646 })
647 .collect())
648}
649
650fn fts_phrase(value: &str) -> String {
651 format!("\"{}\"", value.replace('"', "\"\""))
652}
653
654fn workspace_scoped_fts_match(workspace_id: &WorkspaceId, fts_match: &str) -> String {
655 format!(
656 "workspace_token:{} {}",
657 fts_phrase(workspace_id.as_str()),
658 fts_match
659 )
660}
661
662fn score_document(
663 document: SearchDocument,
664 query: &parser::ParsedTaskSearchQuery,
665 now_seconds: i64,
666) -> Option<ScoredDocument> {
667 let project_text = format!(
668 "{} {} {}",
669 document.task.project_key, document.project_name, document.task.project_prefix
670 );
671 let mut evidence = Vec::new();
672 if let Some(ref_query) = &query.ref_query
673 && let Some(score) = score_ref_lane(&document, ref_query)
674 {
675 evidence.push(FieldEvidence {
676 score,
677 matched_field: SearchMatchedField::Ref,
678 snippet: None,
679 });
680 }
681 for (field, text, weight) in [
682 (
683 SearchMatchedField::Title,
684 document.task.title.as_str(),
685 TITLE_WEIGHT,
686 ),
687 (
688 SearchMatchedField::Label,
689 document.labels_text.as_str(),
690 LABEL_WEIGHT,
691 ),
692 (
693 SearchMatchedField::Project,
694 project_text.as_str(),
695 PROJECT_WEIGHT,
696 ),
697 (
698 SearchMatchedField::Status,
699 document.task.status.as_str(),
700 STATUS_WEIGHT,
701 ),
702 (
703 SearchMatchedField::Priority,
704 document.task.priority.as_str(),
705 PRIORITY_WEIGHT,
706 ),
707 (
708 SearchMatchedField::Description,
709 document.task.description.as_str(),
710 DESCRIPTION_WEIGHT,
711 ),
712 (
713 SearchMatchedField::Attachment,
714 document.attachments_text.as_str(),
715 ATTACHMENT_WEIGHT,
716 ),
717 (
718 SearchMatchedField::Note,
719 document.notes_text.as_str(),
720 NOTE_WEIGHT,
721 ),
722 ] {
723 if let Some((score, span)) = score_text_lane(text, query) {
724 let snippet = if matches!(
725 field,
726 SearchMatchedField::Description | SearchMatchedField::Note
727 ) {
728 snippet(text, span)
729 } else {
730 None
731 };
732 evidence.push(FieldEvidence {
733 score: score * weight,
734 matched_field: field,
735 snippet,
736 });
737 }
738 }
739 if evidence.is_empty() {
740 return None;
741 }
742 let best_index = evidence
743 .iter()
744 .enumerate()
745 .max_by(|(_, left), (_, right)| left.score.cmp(&right.score))
746 .map(|(index, _)| index)
747 .unwrap();
748 let best = evidence.swap_remove(best_index);
749 let extra_score = evidence
750 .iter()
751 .map(|item| item.score / FIELD_SCORE_DIVISOR)
752 .sum::<i64>();
753 let field_bonus = FIELD_MATCH_BONUS + evidence.len() as i64 * EXTRA_FIELD_BONUS;
754 let score = best.score
755 + extra_score
756 + field_bonus
757 + priority_boost(document.task.priority.as_str())
758 + recency_boost(document.task.updated_at.as_str(), now_seconds);
759 Some(ScoredDocument {
760 document,
761 score,
762 matched_field: best.matched_field,
763 snippet: best.snippet,
764 })
765}
766
767fn priority_boost(priority: &str) -> i64 {
768 match priority {
769 "urgent" => PRIORITY_BOOST_CAP,
770 "high" => 12_000,
771 "medium" => 6_000,
772 "low" => 2_000,
773 _ => 0,
774 }
775}
776
777fn recency_boost(updated_at: &str, now_seconds: i64) -> i64 {
778 let Some(updated_seconds) = crate::queue::unix_seconds(updated_at) else {
779 return 0;
780 };
781 let age_days = now_seconds.saturating_sub(updated_seconds).max(0) / 86_400;
782 let decay = age_days.saturating_mul(RECENCY_BOOST_CAP / 30);
783 RECENCY_BOOST_CAP.saturating_sub(decay)
784}
785
786fn score_text_lane(
787 text: &str,
788 query: &parser::ParsedTaskSearchQuery,
789) -> Option<(i64, std::ops::Range<usize>)> {
790 if query.phrases.is_empty() {
791 score_contiguous_text_lane(text, query.trimmed.as_str())
792 .or_else(|| score_term_coverage_lane(text, query))
793 } else {
794 score_parsed_contiguous_text_lane(text, query)
795 .or_else(|| score_term_coverage_lane(text, query))
796 }
797}
798
799fn score_parsed_contiguous_text_lane(
800 text: &str,
801 query: &parser::ParsedTaskSearchQuery,
802) -> Option<(i64, std::ops::Range<usize>)> {
803 search_terms(query)
804 .into_iter()
805 .filter_map(|term| score_contiguous_text_lane(text, term))
806 .max_by_key(|(score, _)| *score)
807}
808
809fn score_contiguous_text_lane(text: &str, query: &str) -> Option<(i64, std::ops::Range<usize>)> {
810 let normalized_text = text.to_ascii_lowercase();
811 let raw_query = query.trim();
812 let normalized_query = raw_query.to_ascii_lowercase();
813 let query = normalized_query.trim();
814 if query.is_empty() || normalized_text.is_empty() {
815 return None;
816 }
817 if let Some(index) = normalized_text.find(query) {
818 let boundary_bonus = if index == 0 || is_boundary(normalized_text.as_bytes()[index - 1]) {
819 200
820 } else {
821 0
822 };
823 let phrase_bonus = if index == 0 { 300 } else { 0 };
824 return Some((
825 1_000 + phrase_bonus + boundary_bonus - index as i64,
826 index..index + query.len(),
827 ));
828 }
829 token_match_span(&normalized_text, query).map(|span| {
830 let boundary_bonus =
831 if span.start == 0 || is_boundary(normalized_text.as_bytes()[span.start - 1]) {
832 120
833 } else {
834 0
835 };
836 let spread = span.end.saturating_sub(span.start + query.len()) as i64;
837 (700 + boundary_bonus - spread * 4 - span.start as i64, span)
838 })
839}
840
841fn score_term_coverage_lane(
842 text: &str,
843 query: &parser::ParsedTaskSearchQuery,
844) -> Option<(i64, std::ops::Range<usize>)> {
845 let terms = search_terms(query);
846 let normalized_text = text.to_ascii_lowercase();
847 if terms.len() < 2 || normalized_text.is_empty() {
848 return None;
849 }
850
851 let mut matched = 0_i64;
852 let mut start = usize::MAX;
853 let mut end = 0_usize;
854 for term in terms {
855 let normalized_term = term.to_ascii_lowercase();
856 if normalized_term.is_empty() {
857 continue;
858 }
859 if let Some(index) = normalized_text.find(&normalized_term) {
860 matched += 1;
861 start = start.min(index);
862 end = end.max(index + normalized_term.len());
863 }
864 }
865 if matched == 0 {
866 return None;
867 }
868
869 let boundary_bonus = if start == 0 || is_boundary(normalized_text.as_bytes()[start - 1]) {
870 120
871 } else {
872 0
873 };
874 let spread = end.saturating_sub(start) as i64;
875 Some((
876 450 + matched * 160 + boundary_bonus - spread * 3 - start as i64,
877 start..end,
878 ))
879}
880
881fn search_terms(query: &parser::ParsedTaskSearchQuery) -> Vec<&str> {
882 query
883 .phrases
884 .iter()
885 .map(String::as_str)
886 .chain(query.tokens.iter().map(String::as_str))
887 .chain(query.active_prefix.as_deref())
888 .collect()
889}
890
891fn score_ref_lane(
892 document: &SearchDocument,
893 ref_query: &parser::ParsedRefSearchQuery,
894) -> Option<i64> {
895 if let Some(prefix) = ref_query.normalized_prefix.as_deref()
896 && normalize_ref_query(&document.task.project_prefix) != prefix
897 {
898 return None;
899 }
900 let normalized_id = normalize_ref_query(&document.task.id);
901 if !normalized_id.starts_with(&ref_query.normalized_suffix) {
902 return None;
903 }
904 let display_suffix_len = document
905 .display_ref
906 .rsplit_once('-')
907 .map(|(_, suffix)| normalize_ref_query(suffix).len())
908 .unwrap_or(0);
909 let exact_bonus = if normalized_id == ref_query.normalized_suffix {
910 700
911 } else {
912 0
913 };
914 let display_bonus = if ref_query.normalized_suffix.len() >= display_suffix_len {
915 300
916 } else {
917 0
918 };
919 let prefix_bonus = if ref_query.normalized_prefix.is_some() {
920 200
921 } else {
922 0
923 };
924 Some(
925 (3_000
926 + exact_bonus
927 + display_bonus
928 + prefix_bonus
929 + ref_query.normalized_suffix.len() as i64)
930 * REF_WEIGHT,
931 )
932}
933
934fn ref_query_matches_display_or_full_id(
935 document: &SearchDocument,
936 ref_query: &parser::ParsedRefSearchQuery,
937) -> bool {
938 let normalized_id = normalize_ref_query(&document.task.id);
939 if normalized_id == ref_query.normalized_suffix {
940 return true;
941 }
942 document
943 .display_ref
944 .rsplit_once('-')
945 .map(|(_, suffix)| ref_query.normalized_suffix.len() >= normalize_ref_query(suffix).len())
946 .unwrap_or(false)
947}
948
949fn token_match_span(text: &str, query: &str) -> Option<std::ops::Range<usize>> {
950 let tokens = query.split_whitespace().collect::<Vec<_>>();
951 if tokens.len() < 2 {
952 return None;
953 }
954 let mut start = usize::MAX;
955 let mut end = 0;
956 for token in tokens {
957 let index = text.find(token)?;
958 start = start.min(index);
959 end = end.max(index + token.len());
960 }
961 Some(start..end)
962}
963
964fn normalize_ref_query(input: &str) -> String {
965 input
966 .chars()
967 .filter(|ch| ch.is_ascii_alphanumeric())
968 .map(|ch| match ch.to_ascii_uppercase() {
969 'O' => '0',
970 'I' | 'L' => '1',
971 ch => ch,
972 })
973 .collect()
974}
975
976fn is_boundary(ch: u8) -> bool {
977 !ch.is_ascii_alphanumeric()
978}
979
980fn snippet(text: &str, span: std::ops::Range<usize>) -> Option<String> {
981 if text.is_empty() {
982 return None;
983 }
984 let start = char_boundary_at_or_before(text, span.start.saturating_sub(40));
985 let end = char_boundary_at_or_after(text, (span.end + 80).min(text.len()));
986 let mut value = text[start..end].replace('\n', " ");
987 value = value.split_whitespace().collect::<Vec<_>>().join(" ");
988 if start > 0 {
989 value.insert_str(0, "...");
990 }
991 if end < text.len() {
992 value.push_str("...");
993 }
994 Some(value)
995}
996
997fn char_boundary_at_or_before(text: &str, mut index: usize) -> usize {
998 index = index.min(text.len());
999 while index > 0 && !text.is_char_boundary(index) {
1000 index -= 1;
1001 }
1002 index
1003}
1004
1005fn char_boundary_at_or_after(text: &str, mut index: usize) -> usize {
1006 index = index.min(text.len());
1007 while index < text.len() && !text.is_char_boundary(index) {
1008 index += 1;
1009 }
1010 index
1011}
1012
1013#[cfg(test)]
1014#[path = "search_tests.rs"]
1015mod tests;