Skip to main content

nex_core/
search.rs

1use crate::config::SearchMode;
2use crate::model::{normalize_for_search, SearchItem};
3use crate::query_dsl::TimeFilterWindow;
4use std::cmp::Ordering;
5use std::collections::HashMap;
6use std::path::Path;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9const SCORE_EXACT: i64 = 30_000;
10const SCORE_PREFIX: i64 = 24_000;
11const SCORE_SUBSTRING: i64 = 18_000;
12const SCORE_FUZZY: i64 = 12_000;
13
14const SOURCE_APP_BONUS: i64 = 700;
15const SOURCE_LOCAL_FS_BONUS: i64 = 420;
16const SOURCE_ACTION_BONUS: i64 = 350;
17const SOURCE_CLIPBOARD_BONUS: i64 = 300;
18
19const WORD_PREFIX_PRIMARY_BOOST: i64 = 210;
20const WORD_PREFIX_SECONDARY_BOOST: i64 = 140;
21const ACRONYM_EXACT_BOOST: i64 = 290;
22const ACRONYM_PREFIX_BOOST: i64 = 190;
23const MAX_LEXICAL_SIGNAL_BOOST: i64 = 520;
24
25const APP_INTENT_SHORT_QUERY_BONUS: i64 = 320;
26const APP_INTENT_MEDIUM_QUERY_BONUS: i64 = 160;
27const NON_APP_SHORT_QUERY_PENALTY: i64 = 120;
28
29const TOP_HIT_CONFIDENCE_DELTA_SHORT: i64 = 52;
30const TOP_HIT_CONFIDENCE_DELTA_MEDIUM: i64 = 78;
31const TOP_HIT_CONFIDENCE_DELTA_LONG: i64 = 108;
32const TOP_HIT_APP_PREFERENCE_DELTA_SHORT: i64 = 7_000;
33const TOP_HIT_APP_PREFERENCE_DELTA_MEDIUM: i64 = 2_100;
34const TOP_HIT_APP_PREFERENCE_DELTA_LONG: i64 = 780;
35const TOP_HIT_SOURCE_PREFERENCE_DELTA_SHORT: i64 = 420;
36const TOP_HIT_SOURCE_PREFERENCE_DELTA_LONG: i64 = 200;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum TextMatchKind {
40    Exact,
41    Prefix,
42    Substring,
43    Fuzzy,
44}
45
46impl TextMatchKind {
47    fn rank(self) -> u8 {
48        match self {
49            Self::Exact => 0,
50            Self::Prefix => 1,
51            Self::Substring => 2,
52            Self::Fuzzy => 3,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy)]
58struct TextScore {
59    score: i64,
60    kind: TextMatchKind,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct SearchFilter {
65    pub mode: SearchMode,
66    pub kind_filter: Option<String>,
67    pub extension_filter: Option<String>,
68    pub include_files: bool,
69    pub include_folders: bool,
70    pub include_groups: Vec<Vec<String>>,
71    pub exclude_terms: Vec<String>,
72    pub modified_within: Option<TimeFilterWindow>,
73    pub created_within: Option<TimeFilterWindow>,
74}
75
76impl Default for SearchFilter {
77    fn default() -> Self {
78        Self {
79            mode: SearchMode::All,
80            kind_filter: None,
81            extension_filter: None,
82            include_files: true,
83            include_folders: true,
84            include_groups: Vec::new(),
85            exclude_terms: Vec::new(),
86            modified_within: None,
87            created_within: None,
88        }
89    }
90}
91
92pub fn search(items: &[SearchItem], query: &str, limit: usize) -> Vec<SearchItem> {
93    if normalize_for_search(query).is_empty() {
94        return Vec::new();
95    }
96    search_with_filter(items, query, limit, &SearchFilter::default())
97}
98
99pub fn search_with_filter(
100    items: &[SearchItem],
101    query: &str,
102    limit: usize,
103    filter: &SearchFilter,
104) -> Vec<SearchItem> {
105    search_with_filter_with_boosts(items, query, limit, filter, None)
106}
107
108pub fn search_with_filter_with_boosts(
109    items: &[SearchItem],
110    query: &str,
111    limit: usize,
112    filter: &SearchFilter,
113    personalization_boosts: Option<&HashMap<String, i64>>,
114) -> Vec<SearchItem> {
115    if limit == 0 || items.is_empty() {
116        return Vec::new();
117    }
118
119    let normalized_query = normalize_for_search(query);
120    let fast_path = is_default_filter(filter) && !normalized_query.is_empty();
121    let app_intent_query = looks_like_app_intent_query(query, &normalized_query, filter.mode);
122    let now_epoch_secs = now_epoch_secs();
123    let mut scored: Vec<ScoredItem<'_>> = items
124        .iter()
125        .filter(|item| matches_visibility(item, filter))
126        .filter_map(|item| {
127            let personalization_boost = personalization_boosts
128                .and_then(|boosts| boosts.get(item.id.as_str()))
129                .copied()
130                .unwrap_or(0);
131            let score = if fast_path {
132                score_item_fast(
133                    item,
134                    &normalized_query,
135                    now_epoch_secs,
136                    app_intent_query,
137                    personalization_boost,
138                )
139            } else {
140                score_item(
141                    item,
142                    &normalized_query,
143                    now_epoch_secs,
144                    filter,
145                    app_intent_query,
146                    personalization_boost,
147                )
148            };
149            score.map(|score| ScoredItem {
150                source_rank: source_rank(item),
151                score: score.score,
152                match_kind: score.kind,
153                title_len: item.normalized_title().len(),
154                item,
155            })
156        })
157        .collect();
158
159    if scored.len() > limit {
160        scored.select_nth_unstable_by(limit, compare_scored);
161        scored.truncate(limit);
162    }
163    scored.sort_unstable_by(compare_scored);
164    apply_top_hit_confidence_guard(&mut scored, &normalized_query, app_intent_query);
165
166    scored
167        .into_iter()
168        .take(limit)
169        .map(|scored| scored.item.clone())
170        .collect()
171}
172
173#[derive(Debug, Clone, Copy)]
174struct ScoredItem<'a> {
175    source_rank: u8,
176    score: i64,
177    match_kind: TextMatchKind,
178    title_len: usize,
179    item: &'a SearchItem,
180}
181
182fn compare_scored(a: &ScoredItem<'_>, b: &ScoredItem<'_>) -> Ordering {
183    b.score
184        .cmp(&a.score)
185        .then_with(|| a.match_kind.rank().cmp(&b.match_kind.rank()))
186        .then_with(|| a.source_rank.cmp(&b.source_rank))
187        .then_with(|| a.title_len.cmp(&b.title_len))
188        .then_with(|| a.item.normalized_title().cmp(b.item.normalized_title()))
189        .then_with(|| a.item.id.cmp(&b.item.id))
190}
191
192fn score_item_fast(
193    item: &SearchItem,
194    normalized_query: &str,
195    now_epoch_secs: i64,
196    app_intent_query: bool,
197    personalization_boost: i64,
198) -> Option<TextScore> {
199    let text_score = score_text(item.normalized_title(), normalized_query)?;
200    let lexical_signal_bonus = word_boundary_and_acronym_bonus(&item.title, normalized_query);
201    let app_intent_bonus = app_intent_bonus(item, app_intent_query, normalized_query.len());
202    let source_bonus = source_bonus(item);
203    let recency_bonus = recency_bonus(item.last_accessed_epoch_secs, now_epoch_secs);
204    let frequency_bonus = frequency_bonus(item.use_count);
205
206    Some(TextScore {
207        score: text_score.score
208            + lexical_signal_bonus
209            + app_intent_bonus
210            + source_bonus
211            + recency_bonus
212            + frequency_bonus
213            + personalization_boost,
214        kind: text_score.kind,
215    })
216}
217
218fn score_item(
219    item: &SearchItem,
220    normalized_query: &str,
221    now_epoch_secs: i64,
222    filter: &SearchFilter,
223    app_intent_query: bool,
224    personalization_boost: i64,
225) -> Option<TextScore> {
226    if !matches_mode(item, filter.mode) {
227        return None;
228    }
229    if let Some(kind) = &filter.kind_filter {
230        if !matches_kind_filter(item, kind) {
231            return None;
232        }
233    }
234    if let Some(extension) = &filter.extension_filter {
235        if !matches_extension_filter(item, extension) {
236            return None;
237        }
238    }
239    if !matches_term_filters(item, filter) {
240        return None;
241    }
242    if !matches_time_filters(item, filter, now_epoch_secs) {
243        return None;
244    }
245
246    let text_score = if normalized_query.is_empty() {
247        TextScore {
248            score: 0,
249            kind: TextMatchKind::Substring,
250        }
251    } else {
252        score_text(item.normalized_title(), normalized_query).or_else(|| {
253            score_text(item.normalized_search_text(), normalized_query).map(|text_score| {
254                TextScore {
255                    score: text_score.score - 1_500,
256                    kind: text_score.kind,
257                }
258            })
259        })?
260    };
261    let lexical_signal_bonus = word_boundary_and_acronym_bonus(&item.title, normalized_query);
262    let app_intent_bonus = app_intent_bonus(item, app_intent_query, normalized_query.len());
263    let source_bonus = source_bonus(item);
264    let mode_bonus = mode_bonus(item, filter.mode);
265    let recency_bonus = recency_bonus(item.last_accessed_epoch_secs, now_epoch_secs);
266    let frequency_bonus = frequency_bonus(item.use_count);
267
268    Some(TextScore {
269        score: text_score.score
270            + lexical_signal_bonus
271            + app_intent_bonus
272            + source_bonus
273            + mode_bonus
274            + recency_bonus
275            + frequency_bonus
276            + personalization_boost,
277        kind: text_score.kind,
278    })
279}
280
281fn score_text(normalized_title: &str, query: &str) -> Option<TextScore> {
282    if normalized_title.is_empty() || query.is_empty() {
283        return None;
284    }
285
286    let length_penalty = (normalized_title.len() as i64 - query.len() as i64).abs();
287    let compact_bonus = (query.len() as i64) * 45;
288
289    if normalized_title == query {
290        return Some(TextScore {
291            score: SCORE_EXACT + compact_bonus - length_penalty,
292            kind: TextMatchKind::Exact,
293        });
294    }
295
296    if normalized_title.starts_with(query) {
297        return Some(TextScore {
298            score: SCORE_PREFIX + compact_bonus - length_penalty,
299            kind: TextMatchKind::Prefix,
300        });
301    }
302
303    if let Some(position) = normalized_title.find(query) {
304        let position_penalty = (position as i64) * 3;
305        return Some(TextScore {
306            score: SCORE_SUBSTRING + compact_bonus - position_penalty - length_penalty,
307            kind: TextMatchKind::Substring,
308        });
309    }
310
311    let (start_penalty, gap_penalty) = subsequence_penalties(normalized_title, query)?;
312    Some(TextScore {
313        score: SCORE_FUZZY + compact_bonus - gap_penalty * 8 - start_penalty - length_penalty,
314        kind: TextMatchKind::Fuzzy,
315    })
316}
317
318fn recency_bonus(last_accessed_epoch_secs: i64, now_epoch_secs: i64) -> i64 {
319    if last_accessed_epoch_secs <= 0 || now_epoch_secs <= 0 {
320        return 0;
321    }
322
323    let age_secs = if last_accessed_epoch_secs >= now_epoch_secs {
324        0
325    } else {
326        now_epoch_secs - last_accessed_epoch_secs
327    };
328    match age_secs {
329        0..=3_600 => 260,             // within 1 hour
330        3_601..=86_400 => 220,        // within 1 day
331        86_401..=604_800 => 170,      // within 7 days
332        604_801..=2_592_000 => 110,   // within 30 days
333        2_592_001..=7_776_000 => 60,  // within 90 days
334        7_776_001..=31_536_000 => 25, // within 1 year
335        _ => 0,
336    }
337}
338
339fn frequency_bonus(use_count: u32) -> i64 {
340    ((use_count as i64) * 18).clamp(0, 220)
341}
342
343fn mode_bonus(item: &SearchItem, mode: SearchMode) -> i64 {
344    match mode {
345        SearchMode::All => 0,
346        SearchMode::Apps if item.kind.eq_ignore_ascii_case("app") => 550,
347        SearchMode::Files
348            if item.kind.eq_ignore_ascii_case("file")
349                || item.kind.eq_ignore_ascii_case("folder") =>
350        {
351            550
352        }
353        SearchMode::Actions if item.kind.eq_ignore_ascii_case("action") => 550,
354        SearchMode::Clipboard if item.kind.eq_ignore_ascii_case("clipboard") => 550,
355        _ => -2_500,
356    }
357}
358
359fn source_rank(item: &SearchItem) -> u8 {
360    if item.kind.eq_ignore_ascii_case("app") {
361        return 0;
362    }
363
364    if item.kind.eq_ignore_ascii_case("action") {
365        return 1;
366    }
367
368    if (item.kind.eq_ignore_ascii_case("file") || item.kind.eq_ignore_ascii_case("folder"))
369        && is_local_path(&item.path)
370    {
371        return 2;
372    }
373
374    if item.kind.eq_ignore_ascii_case("clipboard") {
375        return 3;
376    }
377
378    4
379}
380
381fn source_bonus(item: &SearchItem) -> i64 {
382    match source_rank(item) {
383        0 => SOURCE_APP_BONUS,
384        1 => SOURCE_ACTION_BONUS,
385        2 => SOURCE_LOCAL_FS_BONUS,
386        3 => SOURCE_CLIPBOARD_BONUS,
387        _ => 0,
388    }
389}
390
391fn apply_top_hit_confidence_guard(
392    scored: &mut [ScoredItem<'_>],
393    normalized_query: &str,
394    app_intent_query: bool,
395) {
396    if scored.len() < 2 || normalized_query.is_empty() {
397        return;
398    }
399
400    let lead = scored[0];
401    let runner_up = scored[1];
402    let score_delta = lead.score.saturating_sub(runner_up.score);
403    let query_len = normalized_query.len();
404    let confidence_delta = match query_len {
405        0..=2 => TOP_HIT_CONFIDENCE_DELTA_SHORT,
406        3..=5 => TOP_HIT_CONFIDENCE_DELTA_MEDIUM,
407        _ => TOP_HIT_CONFIDENCE_DELTA_LONG,
408    };
409
410    let stronger_runner_up_match =
411        runner_up.match_kind.rank() < lead.match_kind.rank() && score_delta <= confidence_delta;
412
413    let app_runner_up_preferred = app_intent_query
414        && !lead.item.kind.eq_ignore_ascii_case("app")
415        && runner_up.item.kind.eq_ignore_ascii_case("app")
416        && score_delta
417            <= match query_len {
418                0..=2 => TOP_HIT_APP_PREFERENCE_DELTA_SHORT,
419                3..=5 => TOP_HIT_APP_PREFERENCE_DELTA_MEDIUM,
420                _ => TOP_HIT_APP_PREFERENCE_DELTA_LONG,
421            };
422
423    let stronger_source_runner_up = lead.match_kind.rank() == runner_up.match_kind.rank()
424        && source_rank(runner_up.item) < source_rank(lead.item)
425        && score_delta
426            <= if query_len <= 2 {
427                TOP_HIT_SOURCE_PREFERENCE_DELTA_SHORT
428            } else {
429                TOP_HIT_SOURCE_PREFERENCE_DELTA_LONG
430            };
431
432    if stronger_runner_up_match || app_runner_up_preferred || stronger_source_runner_up {
433        scored.swap(0, 1);
434    }
435}
436
437fn word_boundary_and_acronym_bonus(title: &str, normalized_query: &str) -> i64 {
438    if title.trim().is_empty() || normalized_query.is_empty() {
439        return 0;
440    }
441
442    let words = normalized_word_tokens(title);
443    if words.is_empty() {
444        return 0;
445    }
446
447    let mut bonus = 0_i64;
448    if words
449        .first()
450        .is_some_and(|word| word.starts_with(normalized_query))
451    {
452        bonus += WORD_PREFIX_PRIMARY_BOOST;
453    } else if words
454        .iter()
455        .skip(1)
456        .any(|word| word.starts_with(normalized_query))
457    {
458        bonus += WORD_PREFIX_SECONDARY_BOOST;
459    }
460
461    let acronym: String = words
462        .iter()
463        .filter_map(|word| word.chars().next())
464        .collect();
465    if normalized_query.len() >= 2 {
466        if acronym == normalized_query {
467            bonus += ACRONYM_EXACT_BOOST;
468        } else if acronym.starts_with(normalized_query) {
469            bonus += ACRONYM_PREFIX_BOOST;
470        }
471    }
472
473    bonus.clamp(0, MAX_LEXICAL_SIGNAL_BOOST)
474}
475
476fn normalized_word_tokens(title: &str) -> Vec<String> {
477    let mut words = Vec::new();
478    let mut current = String::new();
479    let mut previous_was_lower = false;
480
481    for ch in title.chars() {
482        if !ch.is_alphanumeric() {
483            if !current.is_empty() {
484                words.push(std::mem::take(&mut current));
485            }
486            previous_was_lower = false;
487            continue;
488        }
489
490        let is_upper = ch.is_uppercase();
491        if !current.is_empty() && is_upper && previous_was_lower {
492            words.push(std::mem::take(&mut current));
493        }
494
495        for lower in ch.to_lowercase() {
496            current.push(lower);
497        }
498        previous_was_lower = ch.is_lowercase();
499    }
500
501    if !current.is_empty() {
502        words.push(current);
503    }
504
505    words
506}
507
508fn app_intent_bonus(item: &SearchItem, app_intent_query: bool, normalized_query_len: usize) -> i64 {
509    if !app_intent_query || normalized_query_len == 0 {
510        return 0;
511    }
512
513    if item.kind.eq_ignore_ascii_case("app") {
514        if normalized_query_len <= 2 {
515            APP_INTENT_SHORT_QUERY_BONUS
516        } else if normalized_query_len <= 4 {
517            APP_INTENT_MEDIUM_QUERY_BONUS
518        } else {
519            0
520        }
521    } else if (item.kind.eq_ignore_ascii_case("file") || item.kind.eq_ignore_ascii_case("folder"))
522        && normalized_query_len <= 2
523    {
524        -NON_APP_SHORT_QUERY_PENALTY
525    } else {
526        0
527    }
528}
529
530fn looks_like_app_intent_query(raw_query: &str, normalized_query: &str, mode: SearchMode) -> bool {
531    if normalized_query.is_empty() || matches!(mode, SearchMode::Files) {
532        return false;
533    }
534
535    let trimmed = raw_query.trim();
536    if trimmed.is_empty() || trimmed.starts_with('>') {
537        return false;
538    }
539
540    !(trimmed.contains('\\')
541        || trimmed.contains('/')
542        || trimmed.contains(':')
543        || trimmed.contains('.')
544        || trimmed.contains('*')
545        || trimmed.contains('?'))
546}
547
548fn is_default_filter(filter: &SearchFilter) -> bool {
549    filter.mode == SearchMode::All
550        && filter.kind_filter.is_none()
551        && filter.extension_filter.is_none()
552        && filter.include_files
553        && filter.include_folders
554        && filter.include_groups.is_empty()
555        && filter.exclude_terms.is_empty()
556        && filter.modified_within.is_none()
557        && filter.created_within.is_none()
558}
559
560fn matches_mode(item: &SearchItem, mode: SearchMode) -> bool {
561    match mode {
562        SearchMode::All => true,
563        SearchMode::Apps => item.kind.eq_ignore_ascii_case("app"),
564        SearchMode::Files => {
565            item.kind.eq_ignore_ascii_case("file") || item.kind.eq_ignore_ascii_case("folder")
566        }
567        SearchMode::Actions => item.kind.eq_ignore_ascii_case("action"),
568        SearchMode::Clipboard => item.kind.eq_ignore_ascii_case("clipboard"),
569    }
570}
571
572fn matches_kind_filter(item: &SearchItem, kind_filter: &str) -> bool {
573    let normalized = kind_filter.trim().to_ascii_lowercase();
574    if normalized.is_empty() {
575        return true;
576    }
577    if normalized == "app" || normalized == "apps" {
578        return item.kind.eq_ignore_ascii_case("app");
579    }
580    if normalized == "file" || normalized == "files" {
581        return item.kind.eq_ignore_ascii_case("file");
582    }
583    if normalized == "folder" || normalized == "folders" {
584        return item.kind.eq_ignore_ascii_case("folder");
585    }
586    if normalized == "action" || normalized == "actions" {
587        return item.kind.eq_ignore_ascii_case("action");
588    }
589    if normalized == "clipboard" {
590        return item.kind.eq_ignore_ascii_case("clipboard");
591    }
592    item.kind.eq_ignore_ascii_case(&normalized)
593}
594
595fn matches_visibility(item: &SearchItem, filter: &SearchFilter) -> bool {
596    if item.kind.eq_ignore_ascii_case("file") && !filter.include_files {
597        return false;
598    }
599    if item.kind.eq_ignore_ascii_case("folder") && !filter.include_folders {
600        return false;
601    }
602    true
603}
604
605fn matches_extension_filter(item: &SearchItem, extension_filter: &str) -> bool {
606    let normalized = extension_filter
607        .trim()
608        .trim_start_matches('.')
609        .to_ascii_lowercase();
610    if normalized.is_empty() {
611        return true;
612    }
613    if item.kind.eq_ignore_ascii_case("folder") || item.kind.eq_ignore_ascii_case("action") {
614        return false;
615    }
616
617    let path = item.path.trim();
618    if path.is_empty() {
619        return false;
620    }
621
622    let ext = Path::new(path)
623        .extension()
624        .and_then(|value| value.to_str())
625        .map(|value| value.trim_start_matches('.').to_ascii_lowercase())
626        .unwrap_or_default();
627    !ext.is_empty() && ext == normalized
628}
629
630fn matches_term_filters(item: &SearchItem, filter: &SearchFilter) -> bool {
631    let haystack = item.normalized_search_text();
632    if filter
633        .exclude_terms
634        .iter()
635        .any(|term| !term.is_empty() && haystack.contains(term))
636    {
637        return false;
638    }
639
640    if filter.include_groups.is_empty() {
641        return true;
642    }
643
644    filter.include_groups.iter().any(|group| {
645        group
646            .iter()
647            .all(|term| term.is_empty() || haystack.contains(term))
648    })
649}
650
651fn matches_time_filters(item: &SearchItem, filter: &SearchFilter, now_epoch_secs: i64) -> bool {
652    if filter.modified_within.is_none() && filter.created_within.is_none() {
653        return true;
654    }
655
656    let path = Path::new(item.path.trim());
657    let Ok(meta) = std::fs::metadata(path) else {
658        return false;
659    };
660
661    if let Some(window) = filter.modified_within {
662        let Some(modified_secs) = meta
663            .modified()
664            .ok()
665            .and_then(|v| v.duration_since(UNIX_EPOCH).ok())
666            .map(|v| v.as_secs() as i64)
667        else {
668            return false;
669        };
670        if !within_window(modified_secs, now_epoch_secs, window) {
671            return false;
672        }
673    }
674
675    if let Some(window) = filter.created_within {
676        let Some(created_secs) = meta
677            .created()
678            .ok()
679            .and_then(|v| v.duration_since(UNIX_EPOCH).ok())
680            .map(|v| v.as_secs() as i64)
681        else {
682            return false;
683        };
684        if !within_window(created_secs, now_epoch_secs, window) {
685            return false;
686        }
687    }
688
689    true
690}
691
692fn within_window(value_secs: i64, now_secs: i64, window: TimeFilterWindow) -> bool {
693    if value_secs <= 0 || now_secs <= 0 || value_secs > now_secs {
694        return false;
695    }
696    let age = now_secs - value_secs;
697    match window {
698        TimeFilterWindow::Today => age <= 24 * 60 * 60,
699        TimeFilterWindow::Week => age <= 7 * 24 * 60 * 60,
700        TimeFilterWindow::Month => age <= 31 * 24 * 60 * 60,
701    }
702}
703
704fn is_local_path(path: &str) -> bool {
705    let trimmed = path.trim();
706    if trimmed.is_empty() {
707        return false;
708    }
709    if trimmed.contains("://") {
710        return false;
711    }
712    if trimmed.starts_with("\\\\") {
713        return false;
714    }
715
716    let bytes = trimmed.as_bytes();
717    if bytes.len() >= 3 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/') {
718        return true;
719    }
720
721    trimmed.starts_with('/')
722}
723
724fn subsequence_penalties(haystack: &str, needle: &str) -> Option<(i64, i64)> {
725    let mut next_start = 0;
726    let mut start_penalty: Option<i64> = None;
727    let mut previous_position: Option<usize> = None;
728    let mut gap_penalty = 0_i64;
729
730    for needle_char in needle.chars() {
731        let mut found: Option<(usize, usize)> = None;
732        for (offset, hay_char) in haystack[next_start..].char_indices() {
733            if hay_char == needle_char {
734                let absolute = next_start + offset;
735                found = Some((absolute, hay_char.len_utf8()));
736                break;
737            }
738        }
739
740        let (position, char_len) = found?;
741        if start_penalty.is_none() {
742            start_penalty = Some(position as i64);
743        }
744        if let Some(previous) = previous_position {
745            gap_penalty += position.saturating_sub(previous + 1) as i64;
746        }
747        previous_position = Some(position);
748        next_start = position + char_len;
749    }
750
751    Some((start_penalty.unwrap_or(0), gap_penalty))
752}
753
754fn now_epoch_secs() -> i64 {
755    SystemTime::now()
756        .duration_since(UNIX_EPOCH)
757        .map(|value| value.as_secs() as i64)
758        .unwrap_or(0)
759}