1pub fn is_cjk_char(c: char) -> bool {
17 let code = c as u32;
18 matches!(code,
19 0x4E00..=0x9FFF |
21 0x3400..=0x4DBF |
23 0x20000..=0x2A6DF |
25 0x2A700..=0x2B73F |
26 0x2B740..=0x2B81F |
27 0x2B820..=0x2CEAF |
28 0x2CEB0..=0x2EBEF |
29 0x3040..=0x309F |
31 0x30A0..=0x30FF |
33 0xAC00..=0xD7AF
35 )
36}
37
38pub fn needs_like_fallback(query: &str) -> bool {
47 let chars: Vec<char> = query.chars().collect();
48
49 if chars.len() == 1 && is_cjk_char(chars[0]) {
51 return true;
52 }
53
54 if chars.len() == 2 && chars.iter().all(|c| is_cjk_char(*c)) {
58 return true;
59 }
60
61 false
62}
63
64pub fn escape_fts5(query: &str) -> String {
84 query.replace('"', "\"\"")
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 #[test]
92 fn test_is_cjk_char() {
93 assert!(is_cjk_char('中'));
95 assert!(is_cjk_char('文'));
96 assert!(is_cjk_char('认'));
97 assert!(is_cjk_char('证'));
98
99 assert!(is_cjk_char('あ'));
101 assert!(is_cjk_char('い'));
102
103 assert!(is_cjk_char('ア'));
105 assert!(is_cjk_char('イ'));
106
107 assert!(is_cjk_char('가'));
109 assert!(is_cjk_char('나'));
110
111 assert!(!is_cjk_char('a'));
113 assert!(!is_cjk_char('A'));
114 assert!(!is_cjk_char('1'));
115 assert!(!is_cjk_char(' '));
116 assert!(!is_cjk_char('.'));
117 }
118
119 #[test]
120 fn test_needs_like_fallback() {
121 assert!(needs_like_fallback("中"));
123 assert!(needs_like_fallback("认"));
124 assert!(needs_like_fallback("あ"));
125 assert!(needs_like_fallback("가"));
126
127 assert!(needs_like_fallback("中文"));
129 assert!(needs_like_fallback("认证"));
130 assert!(needs_like_fallback("用户"));
131
132 assert!(!needs_like_fallback("用户认"));
134 assert!(!needs_like_fallback("用户认证"));
135
136 assert!(!needs_like_fallback("JWT"));
138 assert!(!needs_like_fallback("auth"));
139 assert!(!needs_like_fallback("a")); assert!(!needs_like_fallback("JWT认证"));
143 assert!(!needs_like_fallback("API接口"));
144 }
145
146 #[test]
147 fn test_needs_like_fallback_mixed_cjk_ascii() {
148 assert!(!needs_like_fallback("中a"));
151 assert!(!needs_like_fallback("a中"));
152 assert!(!needs_like_fallback("認1"));
153
154 assert!(!needs_like_fallback("中文API"));
156 assert!(!needs_like_fallback("JWT认证系统"));
157 assert!(!needs_like_fallback("API中文文档"));
158 }
159
160 #[test]
161 fn test_needs_like_fallback_edge_cases() {
162 assert!(!needs_like_fallback(""));
164
165 assert!(!needs_like_fallback(" "));
167 assert!(!needs_like_fallback(" "));
168
169 assert!(!needs_like_fallback("1"));
171 assert!(!needs_like_fallback("@"));
172 assert!(!needs_like_fallback(" "));
173
174 assert!(!needs_like_fallback("ab"));
176 assert!(!needs_like_fallback("12"));
177 }
178
179 #[test]
180 fn test_is_cjk_char_extension_ranges() {
181 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}')); }
195
196 #[test]
197 fn test_is_cjk_char_japanese() {
198 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}')); }
212
213 #[test]
214 fn test_is_cjk_char_korean() {
215 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}')); }
224
225 #[test]
226 fn test_escape_fts5_basic() {
227 assert_eq!(escape_fts5("hello world"), "hello world");
229 assert_eq!(escape_fts5("JWT authentication"), "JWT authentication");
230
231 assert_eq!(escape_fts5("user's task"), "user's task");
233 }
234
235 #[test]
236 fn test_escape_fts5_double_quotes() {
237 assert_eq!(escape_fts5("\"admin\""), "\"\"admin\"\"");
239
240 assert_eq!(
242 escape_fts5("\"user\" and \"admin\""),
243 "\"\"user\"\" and \"\"admin\"\""
244 );
245
246 assert_eq!(
248 escape_fts5("start \"middle\" end"),
249 "start \"\"middle\"\" end"
250 );
251 assert_eq!(escape_fts5("\"start"), "\"\"start");
252 assert_eq!(escape_fts5("end\""), "end\"\"");
253 }
254
255 #[test]
256 fn test_escape_fts5_complex_queries() {
257 assert_eq!(
259 escape_fts5("search for \"exact phrase\" here"),
260 "search for \"\"exact phrase\"\" here"
261 );
262
263 assert_eq!(escape_fts5(""), "");
265
266 assert_eq!(escape_fts5("\""), "\"\"");
268 assert_eq!(escape_fts5("\"\""), "\"\"\"\"");
269 assert_eq!(escape_fts5("\"\"\""), "\"\"\"\"\"\"");
270 }
271
272 #[test]
273 fn test_escape_fts5_cjk_with_quotes() {
274 assert_eq!(escape_fts5("用户\"管理员\"权限"), "用户\"\"管理员\"\"权限");
276 assert_eq!(escape_fts5("\"認証\"システム"), "\"\"認証\"\"システム");
277
278 assert_eq!(
280 escape_fts5("API\"接口\"documentation"),
281 "API\"\"接口\"\"documentation"
282 );
283 }
284
285 #[test]
286 fn test_needs_like_fallback_unicode_normalization() {
287 assert!(needs_like_fallback("中"));
292 assert!(needs_like_fallback("日"));
293
294 assert!(needs_like_fallback("中日"));
296 assert!(needs_like_fallback("認證"));
297 }
298}
299
300use crate::db::models::UnifiedSearchResult;
305use crate::error::Result;
306use crate::events::EventManager;
307use crate::tasks::TaskManager;
308use sqlx::SqlitePool;
309
310pub struct SearchManager<'a> {
311 pool: &'a SqlitePool,
312}
313
314impl<'a> SearchManager<'a> {
315 pub fn new(pool: &'a SqlitePool) -> Self {
316 Self { pool }
317 }
318
319 pub async fn unified_search(
330 &self,
331 query: &str,
332 include_tasks: bool,
333 include_events: bool,
334 limit: Option<i64>,
335 ) -> Result<Vec<UnifiedSearchResult>> {
336 let total_limit = limit.unwrap_or(20);
337 let mut results = Vec::new();
338
339 let (task_limit, event_limit) = match (include_tasks, include_events) {
341 (true, true) => (total_limit / 2, total_limit / 2),
342 (true, false) => (total_limit, 0),
343 (false, true) => (0, total_limit),
344 (false, false) => return Ok(results), };
346
347 if include_tasks && task_limit > 0 {
349 let task_mgr = TaskManager::new(self.pool);
350 let mut task_results = task_mgr.search_tasks(query).await?;
351
352 task_results.truncate(task_limit as usize);
354
355 for task_result in task_results {
356 let match_field = if task_result
358 .match_snippet
359 .to_lowercase()
360 .contains(&task_result.task.name.to_lowercase())
361 {
362 "name".to_string()
363 } else {
364 "spec".to_string()
365 };
366
367 results.push(UnifiedSearchResult::Task {
368 task: task_result.task,
369 match_snippet: task_result.match_snippet,
370 match_field,
371 });
372 }
373 }
374
375 if include_events && event_limit > 0 {
377 let event_mgr = EventManager::new(self.pool);
378 let event_results = event_mgr
379 .search_events_fts5(query, Some(event_limit))
380 .await?;
381
382 let task_mgr = TaskManager::new(self.pool);
383 for event_result in event_results {
384 let task_chain = task_mgr
386 .get_task_ancestry(event_result.event.task_id)
387 .await?;
388
389 results.push(UnifiedSearchResult::Event {
390 event: event_result.event,
391 task_chain,
392 match_snippet: event_result.match_snippet,
393 });
394 }
395 }
396
397 results.truncate(total_limit as usize);
399
400 Ok(results)
401 }
402}
403
404#[cfg(test)]
405mod unified_search_tests {
406 use super::*;
407 use crate::test_utils::test_helpers::TestContext;
408
409 #[tokio::test]
410 async fn test_unified_search_basic() {
411 let ctx = TestContext::new().await;
412 let task_mgr = TaskManager::new(ctx.pool());
413 let event_mgr = EventManager::new(ctx.pool());
414 let search_mgr = SearchManager::new(ctx.pool());
415
416 let task = task_mgr
418 .add_task("JWT Authentication", Some("Implement JWT auth"), None)
419 .await
420 .unwrap();
421
422 event_mgr
424 .add_event(task.id, "decision", "Chose JWT over OAuth")
425 .await
426 .unwrap();
427
428 let results = search_mgr
430 .unified_search("JWT", true, true, None)
431 .await
432 .unwrap();
433
434 assert!(results.len() >= 2);
435
436 let has_task = results
438 .iter()
439 .any(|r| matches!(r, UnifiedSearchResult::Task { .. }));
440 let has_event = results
441 .iter()
442 .any(|r| matches!(r, UnifiedSearchResult::Event { .. }));
443
444 assert!(has_task);
445 assert!(has_event);
446 }
447
448 #[tokio::test]
449 async fn test_unified_search_tasks_only() {
450 let ctx = TestContext::new().await;
451 let task_mgr = TaskManager::new(ctx.pool());
452 let search_mgr = SearchManager::new(ctx.pool());
453
454 task_mgr
456 .add_task("OAuth Implementation", None, None)
457 .await
458 .unwrap();
459
460 let results = search_mgr
462 .unified_search("OAuth", true, false, None)
463 .await
464 .unwrap();
465
466 assert!(!results.is_empty());
467
468 for result in results {
470 assert!(matches!(result, UnifiedSearchResult::Task { .. }));
471 }
472 }
473
474 #[tokio::test]
475 async fn test_unified_search_events_only() {
476 let ctx = TestContext::new().await;
477 let task_mgr = TaskManager::new(ctx.pool());
478 let event_mgr = EventManager::new(ctx.pool());
479 let search_mgr = SearchManager::new(ctx.pool());
480
481 let task = task_mgr.add_task("Test task", None, None).await.unwrap();
483
484 event_mgr
485 .add_event(task.id, "blocker", "OAuth library missing")
486 .await
487 .unwrap();
488
489 let results = search_mgr
491 .unified_search("OAuth", false, true, None)
492 .await
493 .unwrap();
494
495 assert!(!results.is_empty());
496
497 for result in results {
499 assert!(matches!(result, UnifiedSearchResult::Event { .. }));
500 }
501 }
502
503 #[tokio::test]
504 async fn test_unified_search_with_limit() {
505 let ctx = TestContext::new().await;
506 let task_mgr = TaskManager::new(ctx.pool());
507 let search_mgr = SearchManager::new(ctx.pool());
508
509 for i in 0..10 {
511 task_mgr
512 .add_task(&format!("Test task {}", i), None, None)
513 .await
514 .unwrap();
515 }
516
517 let results = search_mgr
519 .unified_search("Test", true, true, Some(3))
520 .await
521 .unwrap();
522
523 assert!(results.len() <= 3);
524 }
525}