Skip to main content

aven_core/query/
search.rs

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