Skip to main content

aven_core/query/
search.rs

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;