use std::ops::Range;
use uuid::Uuid;
pub const MIN_TOKEN_CHARS: usize = 3;
pub const HIT_CAP: usize = 200;
pub const SNIPPET_BUDGET_CHARS: usize = 160;
const BOUNDARY_SLACK_DIV: usize = 4;
pub fn to_fts_query(input: &str) -> Option<String> {
let query = input
.split_whitespace()
.filter(|t| t.chars().count() >= MIN_TOKEN_CHARS)
.map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" ");
(!query.is_empty()).then_some(query)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Snippet {
pub text: String,
pub matches: Vec<Range<usize>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeedFocus {
pub message: Uuid,
pub query: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SearchHit {
pub message_id: Uuid,
pub role: String,
pub ts: String,
pub snippet: Snippet,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SearchGroup {
pub chat_id: Uuid,
#[doc(alias = "sub_id")]
pub parent: Option<Uuid>,
pub title: String,
pub hits: Vec<SearchHit>,
}
pub fn build_snippet(text: &str, query: &str, budget_chars: usize) -> Snippet {
let chars: Vec<char> = text.chars().collect();
if budget_chars == 0 || chars.is_empty() {
return Snippet::default();
}
let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
let hits = find_matches(&folded, &query_tokens(query));
let (start, end) = snap_to_words(
&chars,
window(chars.len(), hits.first(), budget_chars),
hits.first(),
);
let mut out = String::new();
if start > 0 {
out.push('…');
}
let mut byte_at: Vec<usize> = Vec::with_capacity(end - start + 1);
for &c in &chars[start..end] {
byte_at.push(out.len());
out.push(c);
}
byte_at.push(out.len());
let matches = hits
.iter()
.filter(|m| m.start >= start && m.end <= end)
.map(|m| byte_at[m.start - start]..byte_at[m.end - start])
.collect();
if end < chars.len() {
out.push('…');
}
Snippet { text: out, matches }
}
pub fn match_ranges(haystack: &str, query: &str) -> Vec<Range<usize>> {
let chars: Vec<char> = haystack.chars().collect();
let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
let hits = find_matches(&folded, &query_tokens(query));
if hits.is_empty() {
return Vec::new();
}
let byte_at = byte_offsets(&chars);
hits.iter()
.map(|m| byte_at[m.start]..byte_at[m.end])
.collect()
}
fn byte_offsets(chars: &[char]) -> Vec<usize> {
let mut out = Vec::with_capacity(chars.len() + 1);
let mut at = 0;
for c in chars {
out.push(at);
at += c.len_utf8();
}
out.push(at);
out
}
fn query_tokens(query: &str) -> Vec<Vec<char>> {
query
.split_whitespace()
.filter(|t| t.chars().count() >= MIN_TOKEN_CHARS)
.map(|t| t.chars().map(fold_char).collect())
.collect()
}
fn fold_char(c: char) -> char {
c.to_lowercase().next().unwrap_or(c)
}
fn find_matches(folded: &[char], tokens: &[Vec<char>]) -> Vec<Range<usize>> {
let mut found: Vec<Range<usize>> = Vec::new();
for token in tokens {
if token.is_empty() || token.len() > folded.len() {
continue;
}
let mut i = 0;
while i + token.len() <= folded.len() {
if folded[i..i + token.len()] == token[..] {
found.push(i..i + token.len());
i += token.len();
} else {
i += 1;
}
}
}
found.sort_by_key(|r| (r.start, r.end));
let mut merged: Vec<Range<usize>> = Vec::new();
for r in found {
match merged.last_mut() {
Some(last) if r.start <= last.end => last.end = last.end.max(r.end),
_ => merged.push(r),
}
}
merged
}
fn window(len: usize, first: Option<&Range<usize>>, budget: usize) -> (usize, usize) {
if budget >= len {
return (0, len);
}
let Some(m) = first else {
return (0, budget);
};
let mid = m.start + (m.end - m.start) / 2;
let mut start = mid.saturating_sub(budget / 2).min(len - budget);
if m.start < start {
start = m.start.min(len - budget);
}
(start, start + budget)
}
fn snap_to_words(
chars: &[char],
(start, end): (usize, usize),
protect: Option<&Range<usize>>,
) -> (usize, usize) {
let slack = (end - start) / BOUNDARY_SLACK_DIV;
let keep_from = protect.map_or(end, |m| m.start);
let keep_to = protect.map_or(start, |m| m.end);
let mut s = start;
if s > 0 && !chars[s - 1].is_whitespace() {
let stop = (s + slack).min(end).min(keep_from).max(s);
if let Some(offset) = chars[s..stop].iter().position(|c| c.is_whitespace()) {
s += offset + 1;
}
}
let mut e = end;
if e < chars.len() && !chars[e].is_whitespace() {
let floor = e.saturating_sub(slack).max(s).max(keep_to);
let mut i = e;
while i > floor {
i -= 1;
if chars[i].is_whitespace() {
e = i;
break;
}
}
}
(s, e.max(s))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measured_syntax_failures_become_quoted_literals() {
assert_eq!(to_fts_query("C++").unwrap(), "\"C++\"");
assert_eq!(to_fts_query("cost-benefit").unwrap(), "\"cost-benefit\"");
assert_eq!(to_fts_query("50%").unwrap(), "\"50%\"");
assert_eq!(to_fts_query("AND").unwrap(), "\"AND\"");
assert_eq!(to_fts_query("a:b").unwrap(), "\"a:b\"");
}
#[test]
fn unbalanced_quote_is_doubled_into_a_balanced_literal() {
assert_eq!(to_fts_query("\"quoted").unwrap(), "\"\"\"quoted\"");
assert_eq!(to_fts_query("he\"llo").unwrap(), "\"he\"\"llo\"");
}
#[test]
fn single_character_syntax_token_is_dropped_not_quoted() {
assert_eq!(to_fts_query("("), None);
}
#[test]
fn token_length_counts_characters_not_bytes() {
assert_eq!("мир".len(), 6);
assert_eq!(to_fts_query("мир").unwrap(), "\"мир\"");
assert_eq!("ми".len(), 4);
assert_eq!(to_fts_query("ми"), None);
}
#[test]
fn multiple_tokens_are_quoted_and_space_joined() {
assert_eq!(to_fts_query("hello world").unwrap(), "\"hello\" \"world\"");
}
#[test]
fn short_tokens_are_dropped_and_the_rest_survives() {
assert_eq!(to_fts_query("C++ ok").unwrap(), "\"C++\"");
assert_eq!(to_fts_query("a memory b").unwrap(), "\"memory\"");
}
#[test]
fn nothing_to_search_yields_none() {
assert_eq!(to_fts_query(""), None);
assert_eq!(to_fts_query(" \t \n "), None);
assert_eq!(to_fts_query("a bc d"), None);
}
#[test]
fn surrounding_and_repeated_whitespace_is_normalized() {
assert_eq!(
to_fts_query(" alpha \t\n beta ").unwrap(),
"\"alpha\" \"beta\""
);
}
fn highlighted(s: &Snippet) -> Vec<&str> {
s.matches.iter().map(|r| &s.text[r.clone()]).collect()
}
#[test]
fn snippet_centres_the_window_on_the_first_match() {
let text = format!("{}МАРКЕР{}", "а".repeat(400), "б".repeat(400));
let s = build_snippet(&text, "маркер", 60);
assert!(
s.text.starts_with('…') && s.text.ends_with('…'),
"{}",
s.text
);
assert_eq!(highlighted(&s), vec!["МАРКЕР"]);
let before = s
.text
.chars()
.take_while(|c| *c == 'а' || *c == '…')
.count();
let after = s
.text
.chars()
.rev()
.take_while(|c| *c == 'б' || *c == '…')
.count();
assert!(
before.abs_diff(after) <= 4,
"the match should sit in the middle: {before} vs {after} in {}",
s.text
);
}
#[test]
fn snippet_ranges_every_match_inside_the_window() {
let s = build_snippet("alpha beta alpha gamma alpha", "alpha", 200);
assert_eq!(highlighted(&s), vec!["alpha", "alpha", "alpha"]);
let s = build_snippet("alpha beta gamma", "alpha gamma", 200);
assert_eq!(highlighted(&s), vec!["alpha", "gamma"]);
}
#[test]
fn snippet_ranges_are_valid_byte_boundaries_for_cyrillic() {
let text = "Начало текста, затем СЛОВО, и продолжение фразы дальше";
let s = build_snippet(text, "слово", 24);
assert_eq!(highlighted(&s), vec!["СЛОВО"]);
for r in &s.matches {
assert!(s.text.is_char_boundary(r.start) && s.text.is_char_boundary(r.end));
}
assert!(s.text.chars().count() > 0);
}
#[test]
fn snippet_truncation_marks_both_cut_ends() {
let text = "a".repeat(300);
let s = build_snippet(&text, "zzz", 50);
assert!(s.text.starts_with('a'), "the head is not cut: {}", s.text);
assert!(s.text.ends_with('…'));
let text = format!("{} target", "word ".repeat(100));
let s = build_snippet(&text, "target", 40);
assert!(s.text.starts_with('…'), "{}", s.text);
assert!(!s.text.ends_with('…'), "the tail was reached: {}", s.text);
}
#[test]
fn snippet_without_a_match_falls_back_to_the_head() {
let s = build_snippet("совершенно другой текст сообщения", "нечто", 20);
assert!(s.matches.is_empty());
assert!(s.text.starts_with("совершенно"), "{}", s.text);
assert!(s.text.ends_with('…'));
}
#[test]
fn snippet_shorter_than_the_budget_is_returned_whole() {
let s = build_snippet("short text", "text", 500);
assert_eq!(s.text, "short text");
assert_eq!(highlighted(&s), vec!["text"]);
assert!(!s.text.contains('…'), "nothing was cut, so no ellipsis");
}
#[test]
fn snippet_of_an_empty_query_is_the_head_with_no_matches() {
for query in ["", " ", "ab c"] {
let s = build_snippet("некоторый текст сообщения", query, 100);
assert!(s.matches.is_empty(), "query {query:?}");
assert_eq!(s.text, "некоторый текст сообщения");
}
}
#[test]
fn snippet_matches_case_insensitively_and_as_a_substring() {
let s = build_snippet("Тестовое Сообщение", "ЕСТОВ", 100);
assert_eq!(highlighted(&s), vec!["естов"]);
let s = build_snippet("mixed CASE here", "case", 100);
assert_eq!(highlighted(&s), vec!["CASE"]);
}
#[test]
fn snippet_prefers_word_boundaries_without_losing_the_match() {
let text = "первое второе третье МАРКЕР четвёртое пятое шестое седьмое";
let s = build_snippet(text, "маркер", 30);
assert_eq!(highlighted(&s), vec!["МАРКЕР"], "{}", s.text);
let inner = s.text.trim_matches('…');
assert!(!inner.starts_with(' ') || inner.trim().is_empty());
assert!(
text.contains(inner.trim()),
"the excerpt must be a slice of the source: {inner:?}"
);
}
#[test]
fn snippet_of_a_zero_budget_is_empty_rather_than_a_panic() {
assert_eq!(build_snippet("текст", "текст", 0), Snippet::default());
assert_eq!(build_snippet("", "текст", 50), Snippet::default());
}
fn matched<'a>(haystack: &'a str, query: &str) -> Vec<&'a str> {
match_ranges(haystack, query)
.into_iter()
.map(|r| &haystack[r])
.collect()
}
#[test]
fn match_ranges_agrees_with_build_snippet() {
for (text, query) in [
("alpha beta alpha gamma", "alpha"),
("Тестовое Сообщение здесь", "сообщение"),
("alpha beta gamma", "alpha gamma"),
("совершенно другой текст", "нечто"),
("overlapping tokens: reference", "refer erence"),
] {
let s = build_snippet(text, query, 10_000);
assert_eq!(s.text, text, "precondition: nothing was cut ({query:?})");
assert_eq!(
match_ranges(text, query),
s.matches,
"the feed and the results list must find the same matches in \
{text:?} for {query:?}"
);
}
}
#[test]
fn match_ranges_drops_tokens_below_the_character_floor() {
assert_eq!(matched("мир и мы", "мир"), vec!["мир"]);
assert!(match_ranges("мир и мы", "мы").is_empty());
assert_eq!(matched("a memory b", "a memory b"), vec!["memory"]);
}
#[test]
fn match_ranges_is_case_insensitive_and_matches_substrings() {
assert_eq!(matched("mixed CASE here", "case"), vec!["CASE"]);
assert_eq!(matched("Тестовое", "ЕСТОВ"), vec!["естов"]);
}
#[test]
fn match_ranges_are_valid_byte_boundaries_for_cyrillic() {
let text = "Начало, затем СЛОВО, и продолжение — СЛОВО снова";
assert_eq!(matched(text, "слово"), vec!["СЛОВО", "СЛОВО"]);
for r in match_ranges(text, "слово") {
assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end));
}
}
#[test]
fn match_ranges_are_sorted_and_merged() {
let ranges = match_ranges("reference material", "refer erence ference");
assert_eq!(ranges, vec![0..9]);
let ranges = match_ranges("alpha gamma alpha", "alpha gamma");
assert!(
ranges.windows(2).all(|w| w[0].end <= w[1].start),
"{ranges:?}"
);
}
#[test]
fn match_ranges_of_nothing_searchable_is_empty() {
for query in ["", " ", "ab c"] {
assert!(
match_ranges("некоторый текст", query).is_empty(),
"{query:?}"
);
}
assert!(match_ranges("", "текст").is_empty());
}
}