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