use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchKind {
Task,
Progress,
Checkpoint,
Decision,
}
impl SearchKind {
pub fn as_str(&self) -> &'static str {
match self {
SearchKind::Task => "task",
SearchKind::Progress => "progress",
SearchKind::Checkpoint => "checkpoint",
SearchKind::Decision => "decision",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"task" => Some(SearchKind::Task),
"progress" => Some(SearchKind::Progress),
"checkpoint" => Some(SearchKind::Checkpoint),
"decision" => Some(SearchKind::Decision),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchHit {
pub kind: SearchKind,
pub id: String,
pub display_id: Option<String>,
pub task_id: String,
pub task_display_id: String,
pub task_status: String,
pub branch: Option<String>,
pub snippet: String,
pub score: f64,
pub created_at: String,
}
const FTS5_OPERATORS: [&str; 3] = ["AND", "OR", "NOT"];
pub fn sanitize_fts5_query(query: &str) -> String {
let mut out_tokens: Vec<String> = Vec::new();
let mut chars = query.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
if c == '"' {
let mut phrase = String::from(chars.next().unwrap());
for ch in chars.by_ref() {
phrase.push(ch);
if ch == '"' {
break;
}
}
if chars.peek() == Some(&'*') {
phrase.push(chars.next().unwrap());
}
out_tokens.push(phrase);
continue;
}
let mut token = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
break;
}
token.push(ch);
chars.next();
}
if token.is_empty() {
continue;
}
if FTS5_OPERATORS.contains(&token.as_str()) {
out_tokens.push(token);
continue;
}
let needs_escaping = token
.chars()
.any(|ch| !ch.is_alphanumeric() && ch != '_' && ch != '*');
if !needs_escaping {
out_tokens.push(token);
continue;
}
let trailing_star = token.ends_with('*') && token.len() > 1;
let body = if trailing_star {
&token[..token.len() - 1]
} else {
token.as_str()
};
let escaped_body = body.replace('"', "\"\"");
if trailing_star {
out_tokens.push(format!("\"{escaped_body}\"*"));
} else {
out_tokens.push(format!("\"{escaped_body}\""));
}
}
out_tokens.join(" ")
}
#[cfg(test)]
mod sanitize_tests {
use super::sanitize_fts5_query;
#[test]
fn bare_hyphenated_token_is_quoted() {
assert_eq!(sanitize_fts5_query("aria-owns"), "\"aria-owns\"");
}
#[test]
fn multiple_bare_hyphenated_tokens_each_quoted() {
assert_eq!(
sanitize_fts5_query("aria-owns screen-reader"),
"\"aria-owns\" \"screen-reader\""
);
}
#[test]
fn plain_alphanumeric_token_is_untouched() {
assert_eq!(sanitize_fts5_query("markdown"), "markdown");
}
#[test]
fn already_quoted_phrase_is_untouched() {
assert_eq!(sanitize_fts5_query("\"aria-owns\""), "\"aria-owns\"");
}
#[test]
fn uppercase_boolean_operators_are_preserved() {
assert_eq!(sanitize_fts5_query("term1 OR term2"), "term1 OR term2");
assert_eq!(
sanitize_fts5_query("pointer-events AND cross-origin"),
"\"pointer-events\" AND \"cross-origin\""
);
}
#[test]
fn lowercase_and_or_not_are_treated_as_plain_terms() {
assert_eq!(sanitize_fts5_query("cat or dog"), "cat or dog");
}
#[test]
fn trailing_star_prefix_query_preserved_on_hyphenated_token() {
assert_eq!(sanitize_fts5_query("aria-owns*"), "\"aria-owns\"*");
}
#[test]
fn trailing_star_prefix_query_preserved_on_plain_token() {
assert_eq!(sanitize_fts5_query("mark*"), "mark*");
}
#[test]
fn embedded_quote_in_bare_token_is_doubled() {
assert_eq!(sanitize_fts5_query("foo\"bar-baz"), "\"foo\"\"bar-baz\"");
}
#[test]
fn column_filter_colon_syntax_is_escaped() {
assert_eq!(sanitize_fts5_query("content:plain"), "\"content:plain\"");
}
#[test]
fn empty_query_returns_empty_string() {
assert_eq!(sanitize_fts5_query(""), "");
assert_eq!(sanitize_fts5_query(" "), "");
}
#[test]
fn mixed_quoted_and_bare_tokens() {
assert_eq!(
sanitize_fts5_query("\"exact phrase\" aria-owns"),
"\"exact phrase\" \"aria-owns\""
);
}
}