1use super::Database;
4use anyhow::Result;
5use rusqlite::params;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SearchResult {
11 pub task_id: String,
13 pub title: String,
15 pub description: Option<String>,
17 pub status: String,
19 pub score: f64,
21 pub title_snippet: String,
23 pub description_snippet: Option<String>,
25 #[serde(skip_serializing_if = "Vec::is_empty")]
27 pub attachment_matches: Vec<AttachmentMatch>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AttachmentMatch {
33 pub attachment_type: String,
35 pub sequence: i32,
37 pub name: String,
39 pub content_snippet: String,
41}
42
43impl Database {
44 pub fn search_tasks(
59 &self,
60 query: &str,
61 limit: Option<i32>,
62 offset: i32,
63 include_attachments: bool,
64 status_filter: Option<&str>,
65 ) -> Result<Vec<SearchResult>> {
66 let limit = limit.unwrap_or(20).min(100);
67
68 self.with_conn(|conn| {
69 let mut sql = String::from(
71 "SELECT
72 fts.task_id,
73 t.title,
74 t.description,
75 t.status,
76 bm25(tasks_fts) as score,
77 snippet(tasks_fts, 1, '<mark>', '</mark>', '...', 32) as title_snippet,
78 snippet(tasks_fts, 2, '<mark>', '</mark>', '...', 64) as description_snippet
79 FROM tasks_fts fts
80 INNER JOIN tasks t ON fts.task_id = t.id
81 WHERE tasks_fts MATCH ?1",
82 );
83
84 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
85 params_vec.push(Box::new(query.to_string()));
86
87 if let Some(status) = status_filter {
88 sql.push_str(" AND t.status = ?2");
89 params_vec.push(Box::new(status.to_string()));
90 }
91
92 sql.push_str(" ORDER BY score LIMIT ?");
93 params_vec.push(Box::new(limit));
94
95 if offset > 0 {
96 sql.push_str(" OFFSET ?");
97 params_vec.push(Box::new(offset));
98 }
99
100 let params_refs: Vec<&dyn rusqlite::ToSql> =
101 params_vec.iter().map(|b| b.as_ref()).collect();
102
103 let mut stmt = conn.prepare(&sql)?;
104 let mut results: Vec<SearchResult> = stmt
105 .query_map(params_refs.as_slice(), |row| {
106 Ok(SearchResult {
107 task_id: row.get(0)?,
108 title: row.get(1)?,
109 description: row.get(2)?,
110 status: row.get(3)?,
111 score: row.get(4)?,
112 title_snippet: row.get(5)?,
113 description_snippet: row.get(6)?,
114 attachment_matches: Vec::new(),
115 })
116 })?
117 .filter_map(|r| r.ok())
118 .collect();
119
120 if include_attachments {
122 let attachment_sql = "SELECT
124 afts.task_id,
125 afts.attachment_type,
126 afts.sequence,
127 afts.name,
128 snippet(attachments_fts, 4, '<mark>', '</mark>', '...', 64) as content_snippet
129 FROM attachments_fts afts
130 WHERE attachments_fts MATCH ?1
131 ORDER BY bm25(attachments_fts)
132 LIMIT ?2";
133
134 let mut att_stmt = conn.prepare(attachment_sql)?;
135 let att_matches: Vec<(String, String, i32, String, String)> = att_stmt
136 .query_map(params![query, limit * 3], |row| {
137 Ok((
138 row.get::<_, String>(0)?,
139 row.get::<_, String>(1)?,
140 row.get::<_, i32>(2)?,
141 row.get::<_, String>(3)?,
142 row.get::<_, String>(4)?,
143 ))
144 })?
145 .filter_map(|r| r.ok())
146 .collect();
147
148 for (task_id, attachment_type, sequence, name, content_snippet) in att_matches {
150 if let Some(result) = results.iter_mut().find(|r| r.task_id == task_id) {
152 result.attachment_matches.push(AttachmentMatch {
153 attachment_type,
154 sequence,
155 name,
156 content_snippet,
157 });
158 } else {
159 let task_sql = if status_filter.is_some() {
162 "SELECT id, title, description, status FROM tasks WHERE id = ?1 AND status = ?2"
163 } else {
164 "SELECT id, title, description, status FROM tasks WHERE id = ?1"
165 };
166
167 let task_result: Option<(String, String, Option<String>, String)> =
168 if let Some(status) = status_filter {
169 conn.query_row(task_sql, params![&task_id, status], |row| {
170 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
171 })
172 .ok()
173 } else {
174 conn.query_row(task_sql, params![&task_id], |row| {
175 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
176 })
177 .ok()
178 };
179
180 if let Some((id, title, description, status)) = task_result {
181 results.push(SearchResult {
182 task_id: id.clone(),
183 title: title.clone(),
184 description: description.clone(),
185 status,
186 score: 999.0, title_snippet: title,
188 description_snippet: description,
189 attachment_matches: vec![AttachmentMatch {
190 attachment_type,
191 sequence,
192 name,
193 content_snippet,
194 }],
195 });
196 }
197 }
198 }
199 }
200
201 results.sort_by(|a, b| a.score.partial_cmp(&b.score).unwrap());
203 results.truncate(limit as usize);
204
205 Ok(results)
206 })
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::config::{IdsConfig, StatesConfig};
214
215 fn states() -> StatesConfig {
216 StatesConfig::default()
217 }
218
219 #[test]
220 fn test_search_empty_db() {
221 let db = Database::open_in_memory().unwrap();
222 let results = db.search_tasks("test", None, 0, false, None).unwrap();
223 assert!(results.is_empty());
224 }
225
226 #[test]
227 fn test_fts_insert_trigger_indexes_new_tasks() {
228 let db = Database::open_in_memory().unwrap();
229
230 let task = db
232 .create_task(
233 None,
234 "Test FTS indexing with keywords".to_string(),
235 None,
236 None,
237 None, None,
239 None,
240 None,
241 None,
242 None,
243 None,
244 &states(),
245 &IdsConfig::default(),
246 )
247 .unwrap();
248
249 let results = db.search_tasks("indexing", None, 0, false, None).unwrap();
251 assert_eq!(results.len(), 1);
252 assert_eq!(results[0].task_id, task.id);
253 }
254
255 #[test]
256 fn test_fts_update_trigger_reindexes_modified_tasks() {
257 let db = Database::open_in_memory().unwrap();
258
259 let task = db
261 .create_task(
262 None,
263 "Original title original".to_string(),
264 None,
265 None,
266 None, None,
268 None,
269 None,
270 None,
271 None,
272 None,
273 &states(),
274 &IdsConfig::default(),
275 )
276 .unwrap();
277
278 let results = db.search_tasks("Original", None, 0, false, None).unwrap();
280 assert_eq!(results.len(), 1);
281
282 db.update_task(
284 &task.id,
285 Some("Updated title with newkeyword".to_string()),
286 Some(Some("Updated description".to_string())),
287 None,
288 None,
289 None,
290 None,
291 &states(),
292 )
293 .unwrap();
294
295 let results = db.search_tasks("newkeyword", None, 0, false, None).unwrap();
297 assert_eq!(results.len(), 1);
298 assert_eq!(results[0].task_id, task.id);
299
300 let results = db.search_tasks("Updated", None, 0, false, None).unwrap();
302 assert_eq!(results.len(), 1);
303 }
304
305 #[test]
306 fn test_fts_delete_trigger_removes_from_index() {
307 let db = Database::open_in_memory().unwrap();
308
309 let task = db
311 .create_task(
312 None,
313 "Deletable task content".to_string(),
314 None,
315 None,
316 None, None,
318 None,
319 None,
320 None,
321 None,
322 None,
323 &states(),
324 &IdsConfig::default(),
325 )
326 .unwrap();
327
328 let results = db.search_tasks("Deletable", None, 0, false, None).unwrap();
330 assert_eq!(results.len(), 1);
331
332 db.delete_task(&task.id, "test-worker", false, None, true, true)
334 .unwrap();
335
336 let results = db.search_tasks("Deletable", None, 0, false, None).unwrap();
338 assert!(results.is_empty());
339 }
340
341 #[test]
342 fn test_fts_search_with_bm25_ranking() {
343 let db = Database::open_in_memory().unwrap();
344
345 db.create_task(
347 None,
348 "Bug fix for minor bug".to_string(),
349 None,
350 None,
351 None, None,
353 None,
354 None,
355 None,
356 None,
357 None,
358 &states(),
359 &IdsConfig::default(),
360 )
361 .unwrap();
362 db.create_task(
363 None,
364 "Bug bug bug multiple bugs".to_string(),
365 None,
366 None,
367 None, None,
369 None,
370 None,
371 None,
372 None,
373 None,
374 &states(),
375 &IdsConfig::default(),
376 )
377 .unwrap();
378 db.create_task(
379 None,
380 "Feature implementation".to_string(),
381 None,
382 None,
383 None, None,
385 None,
386 None,
387 None,
388 None,
389 None,
390 &states(),
391 &IdsConfig::default(),
392 )
393 .unwrap();
394
395 let results = db.search_tasks("bug", None, 0, false, None).unwrap();
397 assert_eq!(results.len(), 2);
398 assert!(results[0].score <= results[1].score);
400 }
401
402 #[test]
403 fn test_fts_attachment_trigger_indexes_text_content() {
404 let db = Database::open_in_memory().unwrap();
405
406 let task = db
408 .create_task(
409 None,
410 "Task with attachment".to_string(),
411 None,
412 None,
413 None, None,
415 None,
416 None,
417 None,
418 None,
419 None,
420 &states(),
421 &IdsConfig::default(),
422 )
423 .unwrap();
424
425 db.add_attachment(
427 &task.id,
428 "notes".to_string(),
429 String::new(),
430 "Important searchable content here".to_string(),
431 Some("text/plain".to_string()),
432 None,
433 )
434 .unwrap();
435
436 let results = db.search_tasks("searchable", None, 0, true, None).unwrap();
438 assert_eq!(results.len(), 1);
439 assert_eq!(results[0].task_id, task.id);
440 assert_eq!(results[0].attachment_matches.len(), 1);
441 assert_eq!(results[0].attachment_matches[0].attachment_type, "notes");
442 }
443}