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