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