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, 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;