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, 3_601..=86_400 => 220, 86_401..=604_800 => 170, 604_801..=2_592_000 => 110, 2_592_001..=7_776_000 => 60, 7_776_001..=31_536_000 => 25, _ => 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}