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