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