pub fn is_cjk_char(c: char) -> bool {
let code = c as u32;
matches!(code,
0x4E00..=0x9FFF |
0x3400..=0x4DBF |
0x20000..=0x2A6DF |
0x2A700..=0x2B73F |
0x2B740..=0x2B81F |
0x2B820..=0x2CEAF |
0x2CEB0..=0x2EBEF |
0x3040..=0x309F |
0x30A0..=0x30FF |
0xAC00..=0xD7AF
)
}
pub fn needs_like_fallback(query: &str) -> bool {
let chars: Vec<char> = query.chars().collect();
if chars.len() == 1 && is_cjk_char(chars[0]) {
return true;
}
if chars.len() == 2 && chars.iter().all(|c| is_cjk_char(*c)) {
return true;
}
false
}
pub fn escape_fts5(query: &str) -> String {
format!("\"{}\"", query.replace('"', "\"\""))
}
use crate::db::models::{Event, PaginatedSearchResults, SearchResult, Task};
use crate::error::Result;
use crate::tasks::TaskManager;
use sqlx::{Row, SqlitePool};
pub struct SearchManager<'a> {
pool: &'a SqlitePool,
}
impl<'a> SearchManager<'a> {
pub fn new(pool: &'a SqlitePool) -> Self {
Self { pool }
}
pub async fn search(
&self,
query: &str,
include_tasks: bool,
include_events: bool,
limit: Option<i64>,
offset: Option<i64>,
sort_by_priority: bool,
) -> Result<PaginatedSearchResults> {
let limit = limit.unwrap_or(20);
let offset = offset.unwrap_or(0);
if query.trim().is_empty() {
return Ok(PaginatedSearchResults {
results: Vec::new(),
total_tasks: 0,
total_events: 0,
has_more: false,
limit,
offset,
});
}
let has_searchable = query.chars().any(|c| c.is_alphanumeric() || is_cjk_char(c));
if !has_searchable {
return Ok(PaginatedSearchResults {
results: Vec::new(),
total_tasks: 0,
total_events: 0,
has_more: false,
limit,
offset,
});
}
let escaped_query = escape_fts5(query);
let mut total_tasks: i64 = 0;
let mut total_events: i64 = 0;
let mut all_results: Vec<(SearchResult, f64)> = Vec::new();
let use_like_fallback = needs_like_fallback(query);
if use_like_fallback {
let like_pattern = format!("%{}%", query);
if include_tasks {
let count_result = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM tasks WHERE name LIKE ? OR spec LIKE ?",
)
.bind(&like_pattern)
.bind(&like_pattern)
.fetch_one(self.pool)
.await?;
total_tasks = count_result;
let order_by = if sort_by_priority {
"ORDER BY COALESCE(priority, 0) ASC, id ASC"
} else {
"ORDER BY id ASC"
};
let task_query = format!(
r#"
SELECT
id,
parent_id,
name,
spec,
status,
complexity,
priority,
first_todo_at,
first_doing_at,
first_done_at,
active_form,
owner,
metadata
FROM tasks
WHERE name LIKE ? OR spec LIKE ?
{}
LIMIT ? OFFSET ?
"#,
order_by
);
let rows = sqlx::query(&task_query)
.bind(&like_pattern)
.bind(&like_pattern)
.bind(limit)
.bind(offset)
.fetch_all(self.pool)
.await?;
for row in rows {
let task = Task {
id: row.get("id"),
parent_id: row.get("parent_id"),
name: row.get("name"),
spec: row.get("spec"),
status: row.get("status"),
complexity: row.get("complexity"),
priority: row.get("priority"),
first_todo_at: row.get("first_todo_at"),
first_doing_at: row.get("first_doing_at"),
first_done_at: row.get("first_done_at"),
active_form: row.get("active_form"),
owner: row.get("owner"),
metadata: row.get("metadata"),
};
let (match_field, match_snippet) = if task.name.contains(query) {
("name".to_string(), task.name.clone())
} else if let Some(ref spec) = task.spec {
if spec.contains(query) {
("spec".to_string(), spec.clone())
} else {
("name".to_string(), task.name.clone())
}
} else {
("name".to_string(), task.name.clone())
};
all_results.push((
SearchResult::Task {
task,
match_snippet,
match_field,
},
1.0, ));
}
}
if include_events {
let count_result = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM events WHERE discussion_data LIKE ?",
)
.bind(&like_pattern)
.fetch_one(self.pool)
.await?;
total_events = count_result;
let rows = sqlx::query(
r#"
SELECT
id,
task_id,
timestamp,
log_type,
discussion_data
FROM events
WHERE discussion_data LIKE ?
ORDER BY id ASC
LIMIT ? OFFSET ?
"#,
)
.bind(&like_pattern)
.bind(limit)
.bind(offset)
.fetch_all(self.pool)
.await?;
let task_mgr = TaskManager::new(self.pool);
for row in rows {
let event = Event {
id: row.get("id"),
task_id: row.get("task_id"),
timestamp: row.get("timestamp"),
log_type: row.get("log_type"),
discussion_data: row.get("discussion_data"),
};
let match_snippet = event.discussion_data.clone();
let task_chain = task_mgr.get_task_ancestry(event.task_id).await?;
all_results.push((
SearchResult::Event {
event,
task_chain,
match_snippet,
},
1.0, ));
}
}
} else {
if include_tasks {
let count_result = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM tasks_fts WHERE tasks_fts MATCH ?",
)
.bind(&escaped_query)
.fetch_one(self.pool)
.await?;
total_tasks = count_result;
let order_by = if sort_by_priority {
"ORDER BY rank ASC, COALESCE(t.priority, 0) ASC, t.id ASC"
} else {
"ORDER BY rank ASC, t.id ASC"
};
let task_query = format!(
r#"
SELECT
t.id,
t.parent_id,
t.name,
t.spec,
t.status,
t.complexity,
t.priority,
t.first_todo_at,
t.first_doing_at,
t.first_done_at,
t.active_form,
t.owner,
t.metadata,
COALESCE(
snippet(tasks_fts, 1, '**', '**', '...', 15),
snippet(tasks_fts, 0, '**', '**', '...', 15)
) as match_snippet,
rank
FROM tasks_fts
INNER JOIN tasks t ON tasks_fts.rowid = t.id
WHERE tasks_fts MATCH ? AND t.deleted_at IS NULL
{}
LIMIT ? OFFSET ?
"#,
order_by
);
let rows = sqlx::query(&task_query)
.bind(&escaped_query)
.bind(limit)
.bind(offset)
.fetch_all(self.pool)
.await?;
for row in rows {
let task = Task {
id: row.get("id"),
parent_id: row.get("parent_id"),
name: row.get("name"),
spec: row.get("spec"),
status: row.get("status"),
complexity: row.get("complexity"),
priority: row.get("priority"),
first_todo_at: row.get("first_todo_at"),
first_doing_at: row.get("first_doing_at"),
first_done_at: row.get("first_done_at"),
active_form: row.get("active_form"),
owner: row.get("owner"),
metadata: row.get("metadata"),
};
let match_snippet: String = row.get("match_snippet");
let rank: f64 = row.get("rank");
let match_field = if task
.spec
.as_ref()
.map(|s| match_snippet.to_lowercase().contains(&s.to_lowercase()))
.unwrap_or(false)
{
"spec".to_string()
} else {
"name".to_string()
};
all_results.push((
SearchResult::Task {
task,
match_snippet,
match_field,
},
rank,
));
}
}
if include_events {
let count_result = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM events_fts WHERE events_fts MATCH ?",
)
.bind(&escaped_query)
.fetch_one(self.pool)
.await?;
total_events = count_result;
let rows = sqlx::query(
r#"
SELECT
e.id,
e.task_id,
e.timestamp,
e.log_type,
e.discussion_data,
snippet(events_fts, 0, '**', '**', '...', 15) as match_snippet,
rank
FROM events_fts
INNER JOIN events e ON events_fts.rowid = e.id
WHERE events_fts MATCH ?
ORDER BY rank ASC, e.id ASC
LIMIT ? OFFSET ?
"#,
)
.bind(&escaped_query)
.bind(limit)
.bind(offset)
.fetch_all(self.pool)
.await?;
let task_mgr = TaskManager::new(self.pool);
for row in rows {
let event = Event {
id: row.get("id"),
task_id: row.get("task_id"),
timestamp: row.get("timestamp"),
log_type: row.get("log_type"),
discussion_data: row.get("discussion_data"),
};
let match_snippet: String = row.get("match_snippet");
let rank: f64 = row.get("rank");
let task_chain = task_mgr.get_task_ancestry(event.task_id).await?;
all_results.push((
SearchResult::Event {
event,
task_chain,
match_snippet,
},
rank,
));
}
}
}
all_results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let results: Vec<SearchResult> =
all_results.into_iter().map(|(result, _)| result).collect();
let total_count = total_tasks + total_events;
let has_more = offset + (results.len() as i64) < total_count;
Ok(PaginatedSearchResults {
results,
total_tasks,
total_events,
has_more,
limit,
offset,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_cjk_char() {
assert!(is_cjk_char('中'));
assert!(is_cjk_char('文'));
assert!(is_cjk_char('认'));
assert!(is_cjk_char('证'));
assert!(is_cjk_char('あ'));
assert!(is_cjk_char('い'));
assert!(is_cjk_char('ア'));
assert!(is_cjk_char('イ'));
assert!(is_cjk_char('가'));
assert!(is_cjk_char('나'));
assert!(!is_cjk_char('a'));
assert!(!is_cjk_char('A'));
assert!(!is_cjk_char('1'));
assert!(!is_cjk_char(' '));
assert!(!is_cjk_char('.'));
}
#[test]
fn test_needs_like_fallback() {
assert!(needs_like_fallback("中"));
assert!(needs_like_fallback("认"));
assert!(needs_like_fallback("あ"));
assert!(needs_like_fallback("가"));
assert!(needs_like_fallback("中文"));
assert!(needs_like_fallback("认证"));
assert!(needs_like_fallback("用户"));
assert!(!needs_like_fallback("用户认"));
assert!(!needs_like_fallback("用户认证"));
assert!(!needs_like_fallback("JWT"));
assert!(!needs_like_fallback("auth"));
assert!(!needs_like_fallback("a"));
assert!(!needs_like_fallback("JWT认证"));
assert!(!needs_like_fallback("API接口"));
}
#[test]
fn test_needs_like_fallback_mixed_cjk_ascii() {
assert!(!needs_like_fallback("中a"));
assert!(!needs_like_fallback("a中"));
assert!(!needs_like_fallback("認1"));
assert!(!needs_like_fallback("中文API"));
assert!(!needs_like_fallback("JWT认证系统"));
assert!(!needs_like_fallback("API中文文档"));
}
#[test]
fn test_needs_like_fallback_edge_cases() {
assert!(!needs_like_fallback(""));
assert!(!needs_like_fallback(" "));
assert!(!needs_like_fallback(" "));
assert!(!needs_like_fallback("1"));
assert!(!needs_like_fallback("@"));
assert!(!needs_like_fallback(" "));
assert!(!needs_like_fallback("ab"));
assert!(!needs_like_fallback("12"));
}
#[test]
fn test_is_cjk_char_extension_ranges() {
assert!(is_cjk_char('\u{3400}')); assert!(is_cjk_char('\u{4DBF}'));
assert!(is_cjk_char('\u{4E00}')); assert!(is_cjk_char('\u{9FFF}'));
assert!(!is_cjk_char('\u{33FF}')); assert!(!is_cjk_char('\u{4DC0}')); assert!(!is_cjk_char('\u{4DFF}')); assert!(!is_cjk_char('\u{A000}')); }
#[test]
fn test_is_cjk_char_japanese() {
assert!(is_cjk_char('\u{3040}')); assert!(is_cjk_char('ひ')); assert!(is_cjk_char('\u{309F}'));
assert!(is_cjk_char('\u{30A0}')); assert!(is_cjk_char('カ')); assert!(is_cjk_char('\u{30FF}'));
assert!(!is_cjk_char('\u{303F}')); assert!(!is_cjk_char('\u{3100}')); }
#[test]
fn test_is_cjk_char_korean() {
assert!(is_cjk_char('\u{AC00}')); assert!(is_cjk_char('한')); assert!(is_cjk_char('\u{D7AF}'));
assert!(!is_cjk_char('\u{ABFF}')); assert!(!is_cjk_char('\u{D7B0}')); }
#[test]
fn test_escape_fts5_basic() {
assert_eq!(escape_fts5("hello world"), "\"hello world\"");
assert_eq!(escape_fts5("JWT authentication"), "\"JWT authentication\"");
assert_eq!(escape_fts5("user's task"), "\"user's task\"");
}
#[test]
fn test_escape_fts5_double_quotes() {
assert_eq!(escape_fts5("\"admin\""), "\"\"\"admin\"\"\"");
assert_eq!(
escape_fts5("\"user\" and \"admin\""),
"\"\"\"user\"\" and \"\"admin\"\"\""
);
assert_eq!(
escape_fts5("start \"middle\" end"),
"\"start \"\"middle\"\" end\""
);
assert_eq!(escape_fts5("\"start"), "\"\"\"start\"");
assert_eq!(escape_fts5("end\""), "\"end\"\"\"");
}
#[test]
fn test_escape_fts5_complex_queries() {
assert_eq!(
escape_fts5("search for \"exact phrase\" here"),
"\"search for \"\"exact phrase\"\" here\""
);
assert_eq!(escape_fts5(""), "\"\"");
assert_eq!(escape_fts5("\""), "\"\"\"\"");
assert_eq!(escape_fts5("\"\""), "\"\"\"\"\"\"");
assert_eq!(escape_fts5("\"\"\""), "\"\"\"\"\"\"\"\"");
}
#[test]
fn test_escape_fts5_special_chars() {
assert_eq!(escape_fts5("#123"), "\"#123\"");
assert_eq!(escape_fts5("task*"), "\"task*\"");
assert_eq!(escape_fts5("+keyword"), "\"+keyword\"");
assert_eq!(escape_fts5("-exclude"), "\"-exclude\"");
assert_eq!(escape_fts5("a AND b"), "\"a AND b\"");
assert_eq!(escape_fts5("a OR b"), "\"a OR b\"");
}
#[test]
fn test_escape_fts5_cjk_with_quotes() {
assert_eq!(
escape_fts5("用户\"管理员\"权限"),
"\"用户\"\"管理员\"\"权限\""
);
assert_eq!(escape_fts5("\"認証\"システム"), "\"\"\"認証\"\"システム\"");
assert_eq!(
escape_fts5("API\"接口\"documentation"),
"\"API\"\"接口\"\"documentation\""
);
}
#[test]
fn test_needs_like_fallback_unicode_normalization() {
assert!(needs_like_fallback("中"));
assert!(needs_like_fallback("日"));
assert!(needs_like_fallback("中日"));
assert!(needs_like_fallback("認證"));
}
}