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