intent_engine/
tasks.rs

1use crate::db::models::{
2    DoneTaskResponse, Event, EventsSummary, NextStepSuggestion, Task, TaskSearchResult,
3    TaskWithEvents, WorkspaceStatus,
4};
5use crate::error::{IntentError, Result};
6use chrono::Utc;
7use sqlx::{Row, SqlitePool};
8
9pub struct TaskManager<'a> {
10    pool: &'a SqlitePool,
11}
12
13impl<'a> TaskManager<'a> {
14    pub fn new(pool: &'a SqlitePool) -> Self {
15        Self { pool }
16    }
17
18    /// Add a new task
19    pub async fn add_task(
20        &self,
21        name: &str,
22        spec: Option<&str>,
23        parent_id: Option<i64>,
24    ) -> Result<Task> {
25        // Check for circular dependency if parent_id is provided
26        if let Some(pid) = parent_id {
27            self.check_task_exists(pid).await?;
28        }
29
30        let now = Utc::now();
31
32        let result = sqlx::query(
33            r#"
34            INSERT INTO tasks (name, spec, parent_id, status, first_todo_at)
35            VALUES (?, ?, ?, 'todo', ?)
36            "#,
37        )
38        .bind(name)
39        .bind(spec)
40        .bind(parent_id)
41        .bind(now)
42        .execute(self.pool)
43        .await?;
44
45        let id = result.last_insert_rowid();
46        self.get_task(id).await
47    }
48
49    /// Get a task by ID
50    pub async fn get_task(&self, id: i64) -> Result<Task> {
51        let task = sqlx::query_as::<_, Task>(
52            r#"
53            SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at
54            FROM tasks
55            WHERE id = ?
56            "#,
57        )
58        .bind(id)
59        .fetch_optional(self.pool)
60        .await?
61        .ok_or(IntentError::TaskNotFound(id))?;
62
63        Ok(task)
64    }
65
66    /// Get a task with events summary
67    pub async fn get_task_with_events(&self, id: i64) -> Result<TaskWithEvents> {
68        let task = self.get_task(id).await?;
69        let events_summary = self.get_events_summary(id).await?;
70
71        Ok(TaskWithEvents {
72            task,
73            events_summary: Some(events_summary),
74        })
75    }
76
77    /// Get events summary for a task
78    async fn get_events_summary(&self, task_id: i64) -> Result<EventsSummary> {
79        let total_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE task_id = ?")
80            .bind(task_id)
81            .fetch_one(self.pool)
82            .await?;
83
84        let recent_events = sqlx::query_as::<_, Event>(
85            r#"
86            SELECT id, task_id, timestamp, log_type, discussion_data
87            FROM events
88            WHERE task_id = ?
89            ORDER BY timestamp DESC
90            LIMIT 10
91            "#,
92        )
93        .bind(task_id)
94        .fetch_all(self.pool)
95        .await?;
96
97        Ok(EventsSummary {
98            total_count,
99            recent_events,
100        })
101    }
102
103    /// Update a task
104    #[allow(clippy::too_many_arguments)]
105    pub async fn update_task(
106        &self,
107        id: i64,
108        name: Option<&str>,
109        spec: Option<&str>,
110        parent_id: Option<Option<i64>>,
111        status: Option<&str>,
112        complexity: Option<i32>,
113        priority: Option<i32>,
114    ) -> Result<Task> {
115        // Check task exists
116        let task = self.get_task(id).await?;
117
118        // Validate status if provided
119        if let Some(s) = status {
120            if !["todo", "doing", "done"].contains(&s) {
121                return Err(IntentError::InvalidInput(format!("Invalid status: {}", s)));
122            }
123        }
124
125        // Check for circular dependency if parent_id is being changed
126        if let Some(Some(pid)) = parent_id {
127            if pid == id {
128                return Err(IntentError::CircularDependency);
129            }
130            self.check_task_exists(pid).await?;
131            self.check_circular_dependency(id, pid).await?;
132        }
133
134        // Build dynamic update query
135        let mut query = String::from("UPDATE tasks SET ");
136        let mut updates = Vec::new();
137
138        if let Some(n) = name {
139            updates.push(format!("name = '{}'", n.replace('\'', "''")));
140        }
141
142        if let Some(s) = spec {
143            updates.push(format!("spec = '{}'", s.replace('\'', "''")));
144        }
145
146        if let Some(pid) = parent_id {
147            match pid {
148                Some(p) => updates.push(format!("parent_id = {}", p)),
149                None => updates.push("parent_id = NULL".to_string()),
150            }
151        }
152
153        if let Some(c) = complexity {
154            updates.push(format!("complexity = {}", c));
155        }
156
157        if let Some(p) = priority {
158            updates.push(format!("priority = {}", p));
159        }
160
161        if let Some(s) = status {
162            updates.push(format!("status = '{}'", s));
163
164            // Update timestamp fields based on status
165            let now = Utc::now();
166            match s {
167                "todo" if task.first_todo_at.is_none() => {
168                    updates.push(format!("first_todo_at = '{}'", now.to_rfc3339()));
169                }
170                "doing" if task.first_doing_at.is_none() => {
171                    updates.push(format!("first_doing_at = '{}'", now.to_rfc3339()));
172                }
173                "done" if task.first_done_at.is_none() => {
174                    updates.push(format!("first_done_at = '{}'", now.to_rfc3339()));
175                }
176                _ => {}
177            }
178        }
179
180        if updates.is_empty() {
181            return Ok(task);
182        }
183
184        query.push_str(&updates.join(", "));
185        query.push_str(&format!(" WHERE id = {}", id));
186
187        sqlx::query(&query).execute(self.pool).await?;
188
189        self.get_task(id).await
190    }
191
192    /// Delete a task
193    pub async fn delete_task(&self, id: i64) -> Result<()> {
194        self.check_task_exists(id).await?;
195
196        sqlx::query("DELETE FROM tasks WHERE id = ?")
197            .bind(id)
198            .execute(self.pool)
199            .await?;
200
201        Ok(())
202    }
203
204    /// Find tasks with optional filters
205    pub async fn find_tasks(
206        &self,
207        status: Option<&str>,
208        parent_id: Option<Option<i64>>,
209    ) -> Result<Vec<Task>> {
210        let mut query = String::from(
211            "SELECT id, parent_id, name, NULL as spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at FROM tasks WHERE 1=1"
212        );
213        let mut conditions = Vec::new();
214
215        if let Some(s) = status {
216            query.push_str(" AND status = ?");
217            conditions.push(s.to_string());
218        }
219
220        if let Some(pid) = parent_id {
221            if let Some(p) = pid {
222                query.push_str(" AND parent_id = ?");
223                conditions.push(p.to_string());
224            } else {
225                query.push_str(" AND parent_id IS NULL");
226            }
227        }
228
229        query.push_str(" ORDER BY id");
230
231        let mut q = sqlx::query_as::<_, Task>(&query);
232        for cond in conditions {
233            q = q.bind(cond);
234        }
235
236        let tasks = q.fetch_all(self.pool).await?;
237        Ok(tasks)
238    }
239
240    /// Search tasks using full-text search (FTS5)
241    /// Returns tasks with match snippets showing highlighted keywords
242    pub async fn search_tasks(&self, query: &str) -> Result<Vec<TaskSearchResult>> {
243        // Escape special FTS5 characters in the query
244        let escaped_query = self.escape_fts_query(query);
245
246        // Use FTS5 to search and get snippets
247        // snippet(table, column, start_mark, end_mark, ellipsis, max_tokens)
248        // We search in both name (column 0) and spec (column 1)
249        let results = sqlx::query(
250            r#"
251            SELECT
252                t.id,
253                t.parent_id,
254                t.name,
255                t.spec,
256                t.status,
257                t.complexity,
258                t.priority,
259                t.first_todo_at,
260                t.first_doing_at,
261                t.first_done_at,
262                COALESCE(
263                    snippet(tasks_fts, 1, '**', '**', '...', 15),
264                    snippet(tasks_fts, 0, '**', '**', '...', 15)
265                ) as match_snippet
266            FROM tasks_fts
267            INNER JOIN tasks t ON tasks_fts.rowid = t.id
268            WHERE tasks_fts MATCH ?
269            ORDER BY rank
270            "#,
271        )
272        .bind(&escaped_query)
273        .fetch_all(self.pool)
274        .await?;
275
276        let mut search_results = Vec::new();
277        for row in results {
278            let task = Task {
279                id: row.get("id"),
280                parent_id: row.get("parent_id"),
281                name: row.get("name"),
282                spec: row.get("spec"),
283                status: row.get("status"),
284                complexity: row.get("complexity"),
285                priority: row.get("priority"),
286                first_todo_at: row.get("first_todo_at"),
287                first_doing_at: row.get("first_doing_at"),
288                first_done_at: row.get("first_done_at"),
289            };
290            let match_snippet: String = row.get("match_snippet");
291
292            search_results.push(TaskSearchResult {
293                task,
294                match_snippet,
295            });
296        }
297
298        Ok(search_results)
299    }
300
301    /// Escape FTS5 special characters in query
302    fn escape_fts_query(&self, query: &str) -> String {
303        // FTS5 queries are passed through as-is to support advanced syntax
304        // Users can use operators like AND, OR, NOT, *, "phrase search", etc.
305        // We only need to handle basic escaping for quotes
306        query.replace('"', "\"\"")
307    }
308
309    /// Start a task (atomic: update status + set current)
310    pub async fn start_task(&self, id: i64, with_events: bool) -> Result<TaskWithEvents> {
311        let mut tx = self.pool.begin().await?;
312
313        let now = Utc::now();
314
315        // Update task status to doing
316        sqlx::query(
317            r#"
318            UPDATE tasks
319            SET status = 'doing', first_doing_at = COALESCE(first_doing_at, ?)
320            WHERE id = ?
321            "#,
322        )
323        .bind(now)
324        .bind(id)
325        .execute(&mut *tx)
326        .await?;
327
328        // Set as current task
329        sqlx::query(
330            r#"
331            INSERT OR REPLACE INTO workspace_state (key, value)
332            VALUES ('current_task_id', ?)
333            "#,
334        )
335        .bind(id.to_string())
336        .execute(&mut *tx)
337        .await?;
338
339        tx.commit().await?;
340
341        if with_events {
342            self.get_task_with_events(id).await
343        } else {
344            let task = self.get_task(id).await?;
345            Ok(TaskWithEvents {
346                task,
347                events_summary: None,
348            })
349        }
350    }
351
352    /// Complete the current focused task (atomic: check children + update status + clear current)
353    /// This command only operates on the current_task_id.
354    /// Prerequisites: A task must be set as current
355    pub async fn done_task(&self) -> Result<DoneTaskResponse> {
356        let mut tx = self.pool.begin().await?;
357
358        // Get the current task ID
359        let current_task_id: Option<String> =
360            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
361                .fetch_optional(&mut *tx)
362                .await?;
363
364        let id = current_task_id.and_then(|s| s.parse::<i64>().ok()).ok_or(
365            IntentError::InvalidInput(
366                "No current task is set. Use 'current --set <ID>' to set a task first.".to_string(),
367            ),
368        )?;
369
370        // Get the task info before completing it
371        let task_info: (String, Option<i64>) =
372            sqlx::query_as("SELECT name, parent_id FROM tasks WHERE id = ?")
373                .bind(id)
374                .fetch_one(&mut *tx)
375                .await?;
376        let (task_name, parent_id) = task_info;
377
378        // Check if all children are done
379        let uncompleted_children: i64 = sqlx::query_scalar(
380            "SELECT COUNT(*) FROM tasks WHERE parent_id = ? AND status != 'done'",
381        )
382        .bind(id)
383        .fetch_one(&mut *tx)
384        .await?;
385
386        if uncompleted_children > 0 {
387            return Err(IntentError::UncompletedChildren);
388        }
389
390        let now = Utc::now();
391
392        // Update task status to done
393        sqlx::query(
394            r#"
395            UPDATE tasks
396            SET status = 'done', first_done_at = COALESCE(first_done_at, ?)
397            WHERE id = ?
398            "#,
399        )
400        .bind(now)
401        .bind(id)
402        .execute(&mut *tx)
403        .await?;
404
405        // Clear the current task
406        sqlx::query("DELETE FROM workspace_state WHERE key = 'current_task_id'")
407            .execute(&mut *tx)
408            .await?;
409
410        // Determine next step suggestion based on context
411        let next_step_suggestion = if let Some(parent_task_id) = parent_id {
412            // Task has a parent - check sibling status
413            let remaining_siblings: i64 = sqlx::query_scalar(
414                "SELECT COUNT(*) FROM tasks WHERE parent_id = ? AND status != 'done' AND id != ?",
415            )
416            .bind(parent_task_id)
417            .bind(id)
418            .fetch_one(&mut *tx)
419            .await?;
420
421            if remaining_siblings == 0 {
422                // All siblings are done - parent is ready
423                let parent_name: String = sqlx::query_scalar("SELECT name FROM tasks WHERE id = ?")
424                    .bind(parent_task_id)
425                    .fetch_one(&mut *tx)
426                    .await?;
427
428                NextStepSuggestion::ParentIsReady {
429                    message: format!(
430                        "All sub-tasks of parent #{} '{}' are now complete. The parent task is ready for your attention.",
431                        parent_task_id, parent_name
432                    ),
433                    parent_task_id,
434                    parent_task_name: parent_name,
435                }
436            } else {
437                // Siblings remain
438                let parent_name: String = sqlx::query_scalar("SELECT name FROM tasks WHERE id = ?")
439                    .bind(parent_task_id)
440                    .fetch_one(&mut *tx)
441                    .await?;
442
443                NextStepSuggestion::SiblingTasksRemain {
444                    message: format!(
445                        "Task #{} completed. Parent task #{} '{}' has other sub-tasks remaining.",
446                        id, parent_task_id, parent_name
447                    ),
448                    parent_task_id,
449                    parent_task_name: parent_name,
450                    remaining_siblings_count: remaining_siblings,
451                }
452            }
453        } else {
454            // No parent - check if this was a top-level task with children or standalone
455            let child_count: i64 =
456                sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE parent_id = ?")
457                    .bind(id)
458                    .fetch_one(&mut *tx)
459                    .await?;
460
461            if child_count > 0 {
462                // Top-level task with children completed
463                NextStepSuggestion::TopLevelTaskCompleted {
464                    message: format!(
465                        "Top-level task #{} '{}' has been completed. Well done!",
466                        id, task_name
467                    ),
468                    completed_task_id: id,
469                    completed_task_name: task_name.clone(),
470                }
471            } else {
472                // Check if workspace is clear
473                let remaining_tasks: i64 = sqlx::query_scalar(
474                    "SELECT COUNT(*) FROM tasks WHERE status != 'done' AND id != ?",
475                )
476                .bind(id)
477                .fetch_one(&mut *tx)
478                .await?;
479
480                if remaining_tasks == 0 {
481                    NextStepSuggestion::WorkspaceIsClear {
482                        message: format!(
483                            "Project complete! Task #{} was the last remaining task. There are no more 'todo' or 'doing' tasks.",
484                            id
485                        ),
486                        completed_task_id: id,
487                    }
488                } else {
489                    NextStepSuggestion::NoParentContext {
490                        message: format!("Task #{} '{}' has been completed.", id, task_name),
491                        completed_task_id: id,
492                        completed_task_name: task_name.clone(),
493                    }
494                }
495            }
496        };
497
498        tx.commit().await?;
499
500        let completed_task = self.get_task(id).await?;
501
502        Ok(DoneTaskResponse {
503            completed_task,
504            workspace_status: WorkspaceStatus {
505                current_task_id: None,
506            },
507            next_step_suggestion,
508        })
509    }
510
511    /// Check if a task exists
512    async fn check_task_exists(&self, id: i64) -> Result<()> {
513        let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tasks WHERE id = ?)")
514            .bind(id)
515            .fetch_one(self.pool)
516            .await?;
517
518        if !exists {
519            return Err(IntentError::TaskNotFound(id));
520        }
521
522        Ok(())
523    }
524
525    /// Check for circular dependencies
526    async fn check_circular_dependency(&self, task_id: i64, new_parent_id: i64) -> Result<()> {
527        let mut current_id = new_parent_id;
528
529        loop {
530            if current_id == task_id {
531                return Err(IntentError::CircularDependency);
532            }
533
534            let parent: Option<i64> =
535                sqlx::query_scalar("SELECT parent_id FROM tasks WHERE id = ?")
536                    .bind(current_id)
537                    .fetch_optional(self.pool)
538                    .await?;
539
540            match parent {
541                Some(pid) => current_id = pid,
542                None => break,
543            }
544        }
545
546        Ok(())
547    }
548
549    /// Switch to a specific task (atomic: update status to doing + set as current)
550    /// If the task is not in 'doing' status, it will be transitioned to 'doing'
551    pub async fn switch_to_task(&self, id: i64) -> Result<TaskWithEvents> {
552        // Verify task exists
553        self.check_task_exists(id).await?;
554
555        let mut tx = self.pool.begin().await?;
556        let now = Utc::now();
557
558        // Update task to doing status if not already
559        sqlx::query(
560            r#"
561            UPDATE tasks
562            SET status = 'doing',
563                first_doing_at = COALESCE(first_doing_at, ?)
564            WHERE id = ? AND status != 'doing'
565            "#,
566        )
567        .bind(now)
568        .bind(id)
569        .execute(&mut *tx)
570        .await?;
571
572        // Set as current task
573        sqlx::query(
574            r#"
575            INSERT OR REPLACE INTO workspace_state (key, value)
576            VALUES ('current_task_id', ?)
577            "#,
578        )
579        .bind(id.to_string())
580        .execute(&mut *tx)
581        .await?;
582
583        tx.commit().await?;
584
585        // Return task with events
586        self.get_task_with_events(id).await
587    }
588
589    /// Create a subtask under the current task and switch to it (atomic operation)
590    /// Returns error if there is no current task
591    pub async fn spawn_subtask(&self, name: &str, spec: Option<&str>) -> Result<Task> {
592        // Get current task
593        let current_task_id: Option<String> =
594            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
595                .fetch_optional(self.pool)
596                .await?;
597
598        let parent_id = current_task_id.and_then(|s| s.parse::<i64>().ok()).ok_or(
599            IntentError::InvalidInput("No current task to create subtask under".to_string()),
600        )?;
601
602        // Create the subtask
603        let subtask = self.add_task(name, spec, Some(parent_id)).await?;
604
605        // Switch to the new subtask (returns updated task with status "doing")
606        let task_with_events = self.switch_to_task(subtask.id).await?;
607
608        Ok(task_with_events.task)
609    }
610
611    /// Intelligently pick tasks from 'todo' and transition them to 'doing'
612    /// Returns tasks that were successfully transitioned
613    ///
614    /// # Arguments
615    /// * `max_count` - Maximum number of tasks to pick
616    /// * `capacity_limit` - Maximum total number of tasks allowed in 'doing' status
617    ///
618    /// # Logic
619    /// 1. Check current 'doing' task count
620    /// 2. Calculate available capacity
621    /// 3. Select tasks from 'todo' (prioritized by: priority DESC, complexity ASC)
622    /// 4. Transition selected tasks to 'doing'
623    pub async fn pick_next_tasks(
624        &self,
625        max_count: usize,
626        capacity_limit: usize,
627    ) -> Result<Vec<Task>> {
628        let mut tx = self.pool.begin().await?;
629
630        // Get current doing count
631        let doing_count: i64 =
632            sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE status = 'doing'")
633                .fetch_one(&mut *tx)
634                .await?;
635
636        // Calculate available capacity
637        let available = capacity_limit.saturating_sub(doing_count as usize);
638        if available == 0 {
639            return Ok(vec![]);
640        }
641
642        let limit = std::cmp::min(max_count, available);
643
644        // Select tasks from todo, prioritizing by priority DESC, complexity ASC
645        let todo_tasks = sqlx::query_as::<_, Task>(
646            r#"
647            SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at
648            FROM tasks
649            WHERE status = 'todo'
650            ORDER BY
651                COALESCE(priority, 0) DESC,
652                COALESCE(complexity, 5) ASC,
653                id ASC
654            LIMIT ?
655            "#,
656        )
657        .bind(limit as i64)
658        .fetch_all(&mut *tx)
659        .await?;
660
661        if todo_tasks.is_empty() {
662            return Ok(vec![]);
663        }
664
665        let now = Utc::now();
666
667        // Transition selected tasks to 'doing'
668        for task in &todo_tasks {
669            sqlx::query(
670                r#"
671                UPDATE tasks
672                SET status = 'doing',
673                    first_doing_at = COALESCE(first_doing_at, ?)
674                WHERE id = ?
675                "#,
676            )
677            .bind(now)
678            .bind(task.id)
679            .execute(&mut *tx)
680            .await?;
681        }
682
683        tx.commit().await?;
684
685        // Fetch and return updated tasks in the same order
686        let task_ids: Vec<i64> = todo_tasks.iter().map(|t| t.id).collect();
687        let placeholders = vec!["?"; task_ids.len()].join(",");
688        let query = format!(
689            "SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at
690             FROM tasks WHERE id IN ({})
691             ORDER BY
692                 COALESCE(priority, 0) DESC,
693                 COALESCE(complexity, 5) ASC,
694                 id ASC",
695            placeholders
696        );
697
698        let mut q = sqlx::query_as::<_, Task>(&query);
699        for id in task_ids {
700            q = q.bind(id);
701        }
702
703        let updated_tasks = q.fetch_all(self.pool).await?;
704        Ok(updated_tasks)
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use crate::test_utils::test_helpers::TestContext;
712
713    #[tokio::test]
714    async fn test_add_task() {
715        let ctx = TestContext::new().await;
716        let manager = TaskManager::new(ctx.pool());
717
718        let task = manager.add_task("Test task", None, None).await.unwrap();
719
720        assert_eq!(task.name, "Test task");
721        assert_eq!(task.status, "todo");
722        assert!(task.first_todo_at.is_some());
723        assert!(task.first_doing_at.is_none());
724        assert!(task.first_done_at.is_none());
725    }
726
727    #[tokio::test]
728    async fn test_add_task_with_spec() {
729        let ctx = TestContext::new().await;
730        let manager = TaskManager::new(ctx.pool());
731
732        let spec = "This is a task specification";
733        let task = manager
734            .add_task("Test task", Some(spec), None)
735            .await
736            .unwrap();
737
738        assert_eq!(task.name, "Test task");
739        assert_eq!(task.spec.as_deref(), Some(spec));
740    }
741
742    #[tokio::test]
743    async fn test_add_task_with_parent() {
744        let ctx = TestContext::new().await;
745        let manager = TaskManager::new(ctx.pool());
746
747        let parent = manager.add_task("Parent task", None, None).await.unwrap();
748        let child = manager
749            .add_task("Child task", None, Some(parent.id))
750            .await
751            .unwrap();
752
753        assert_eq!(child.parent_id, Some(parent.id));
754    }
755
756    #[tokio::test]
757    async fn test_get_task() {
758        let ctx = TestContext::new().await;
759        let manager = TaskManager::new(ctx.pool());
760
761        let created = manager.add_task("Test task", None, None).await.unwrap();
762        let retrieved = manager.get_task(created.id).await.unwrap();
763
764        assert_eq!(created.id, retrieved.id);
765        assert_eq!(created.name, retrieved.name);
766    }
767
768    #[tokio::test]
769    async fn test_get_task_not_found() {
770        let ctx = TestContext::new().await;
771        let manager = TaskManager::new(ctx.pool());
772
773        let result = manager.get_task(999).await;
774        assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
775    }
776
777    #[tokio::test]
778    async fn test_update_task_name() {
779        let ctx = TestContext::new().await;
780        let manager = TaskManager::new(ctx.pool());
781
782        let task = manager.add_task("Original name", None, None).await.unwrap();
783        let updated = manager
784            .update_task(task.id, Some("New name"), None, None, None, None, None)
785            .await
786            .unwrap();
787
788        assert_eq!(updated.name, "New name");
789    }
790
791    #[tokio::test]
792    async fn test_update_task_status() {
793        let ctx = TestContext::new().await;
794        let manager = TaskManager::new(ctx.pool());
795
796        let task = manager.add_task("Test task", None, None).await.unwrap();
797        let updated = manager
798            .update_task(task.id, None, None, None, Some("doing"), None, None)
799            .await
800            .unwrap();
801
802        assert_eq!(updated.status, "doing");
803        assert!(updated.first_doing_at.is_some());
804    }
805
806    #[tokio::test]
807    async fn test_delete_task() {
808        let ctx = TestContext::new().await;
809        let manager = TaskManager::new(ctx.pool());
810
811        let task = manager.add_task("Test task", None, None).await.unwrap();
812        manager.delete_task(task.id).await.unwrap();
813
814        let result = manager.get_task(task.id).await;
815        assert!(result.is_err());
816    }
817
818    #[tokio::test]
819    async fn test_find_tasks_by_status() {
820        let ctx = TestContext::new().await;
821        let manager = TaskManager::new(ctx.pool());
822
823        manager.add_task("Todo task", None, None).await.unwrap();
824        let doing_task = manager.add_task("Doing task", None, None).await.unwrap();
825        manager
826            .update_task(doing_task.id, None, None, None, Some("doing"), None, None)
827            .await
828            .unwrap();
829
830        let todo_tasks = manager.find_tasks(Some("todo"), None).await.unwrap();
831        let doing_tasks = manager.find_tasks(Some("doing"), None).await.unwrap();
832
833        assert_eq!(todo_tasks.len(), 1);
834        assert_eq!(doing_tasks.len(), 1);
835        assert_eq!(doing_tasks[0].status, "doing");
836    }
837
838    #[tokio::test]
839    async fn test_find_tasks_by_parent() {
840        let ctx = TestContext::new().await;
841        let manager = TaskManager::new(ctx.pool());
842
843        let parent = manager.add_task("Parent", None, None).await.unwrap();
844        manager
845            .add_task("Child 1", None, Some(parent.id))
846            .await
847            .unwrap();
848        manager
849            .add_task("Child 2", None, Some(parent.id))
850            .await
851            .unwrap();
852
853        let children = manager
854            .find_tasks(None, Some(Some(parent.id)))
855            .await
856            .unwrap();
857
858        assert_eq!(children.len(), 2);
859    }
860
861    #[tokio::test]
862    async fn test_start_task() {
863        let ctx = TestContext::new().await;
864        let manager = TaskManager::new(ctx.pool());
865
866        let task = manager.add_task("Test task", None, None).await.unwrap();
867        let started = manager.start_task(task.id, false).await.unwrap();
868
869        assert_eq!(started.task.status, "doing");
870        assert!(started.task.first_doing_at.is_some());
871
872        // Verify it's set as current task
873        let current: Option<String> =
874            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
875                .fetch_optional(ctx.pool())
876                .await
877                .unwrap();
878
879        assert_eq!(current, Some(task.id.to_string()));
880    }
881
882    #[tokio::test]
883    async fn test_start_task_with_events() {
884        let ctx = TestContext::new().await;
885        let manager = TaskManager::new(ctx.pool());
886
887        let task = manager.add_task("Test task", None, None).await.unwrap();
888
889        // Add an event
890        sqlx::query("INSERT INTO events (task_id, log_type, discussion_data) VALUES (?, ?, ?)")
891            .bind(task.id)
892            .bind("test")
893            .bind("test event")
894            .execute(ctx.pool())
895            .await
896            .unwrap();
897
898        let started = manager.start_task(task.id, true).await.unwrap();
899
900        assert!(started.events_summary.is_some());
901        let summary = started.events_summary.unwrap();
902        assert_eq!(summary.total_count, 1);
903    }
904
905    #[tokio::test]
906    async fn test_done_task() {
907        let ctx = TestContext::new().await;
908        let manager = TaskManager::new(ctx.pool());
909
910        let task = manager.add_task("Test task", None, None).await.unwrap();
911        manager.start_task(task.id, false).await.unwrap();
912        let response = manager.done_task().await.unwrap();
913
914        assert_eq!(response.completed_task.status, "done");
915        assert!(response.completed_task.first_done_at.is_some());
916        assert_eq!(response.workspace_status.current_task_id, None);
917
918        // Should be WORKSPACE_IS_CLEAR since it's the only task
919        match response.next_step_suggestion {
920            NextStepSuggestion::WorkspaceIsClear { .. } => {}
921            _ => panic!("Expected WorkspaceIsClear suggestion"),
922        }
923
924        // Verify current task is cleared
925        let current: Option<String> =
926            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
927                .fetch_optional(ctx.pool())
928                .await
929                .unwrap();
930
931        assert!(current.is_none());
932    }
933
934    #[tokio::test]
935    async fn test_done_task_with_uncompleted_children() {
936        let ctx = TestContext::new().await;
937        let manager = TaskManager::new(ctx.pool());
938
939        let parent = manager.add_task("Parent", None, None).await.unwrap();
940        manager
941            .add_task("Child", None, Some(parent.id))
942            .await
943            .unwrap();
944
945        // Set parent as current task
946        manager.start_task(parent.id, false).await.unwrap();
947
948        let result = manager.done_task().await;
949        assert!(matches!(result, Err(IntentError::UncompletedChildren)));
950    }
951
952    #[tokio::test]
953    async fn test_done_task_with_completed_children() {
954        let ctx = TestContext::new().await;
955        let manager = TaskManager::new(ctx.pool());
956
957        let parent = manager.add_task("Parent", None, None).await.unwrap();
958        let child = manager
959            .add_task("Child", None, Some(parent.id))
960            .await
961            .unwrap();
962
963        // Complete child first
964        manager.start_task(child.id, false).await.unwrap();
965        let child_response = manager.done_task().await.unwrap();
966
967        // Child completion should suggest parent is ready
968        match child_response.next_step_suggestion {
969            NextStepSuggestion::ParentIsReady { parent_task_id, .. } => {
970                assert_eq!(parent_task_id, parent.id);
971            }
972            _ => panic!("Expected ParentIsReady suggestion"),
973        }
974
975        // Now parent can be completed
976        manager.start_task(parent.id, false).await.unwrap();
977        let parent_response = manager.done_task().await.unwrap();
978        assert_eq!(parent_response.completed_task.status, "done");
979
980        // Parent completion should indicate top-level task completed (since it had children)
981        match parent_response.next_step_suggestion {
982            NextStepSuggestion::TopLevelTaskCompleted { .. } => {}
983            _ => panic!("Expected TopLevelTaskCompleted suggestion"),
984        }
985    }
986
987    #[tokio::test]
988    async fn test_circular_dependency() {
989        let ctx = TestContext::new().await;
990        let manager = TaskManager::new(ctx.pool());
991
992        let task1 = manager.add_task("Task 1", None, None).await.unwrap();
993        let task2 = manager
994            .add_task("Task 2", None, Some(task1.id))
995            .await
996            .unwrap();
997
998        // Try to make task1 a child of task2 (circular)
999        let result = manager
1000            .update_task(task1.id, None, None, Some(Some(task2.id)), None, None, None)
1001            .await;
1002
1003        assert!(matches!(result, Err(IntentError::CircularDependency)));
1004    }
1005
1006    #[tokio::test]
1007    async fn test_invalid_parent_id() {
1008        let ctx = TestContext::new().await;
1009        let manager = TaskManager::new(ctx.pool());
1010
1011        let result = manager.add_task("Test", None, Some(999)).await;
1012        assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
1013    }
1014
1015    #[tokio::test]
1016    async fn test_update_task_complexity_and_priority() {
1017        let ctx = TestContext::new().await;
1018        let manager = TaskManager::new(ctx.pool());
1019
1020        let task = manager.add_task("Test task", None, None).await.unwrap();
1021        let updated = manager
1022            .update_task(task.id, None, None, None, None, Some(8), Some(10))
1023            .await
1024            .unwrap();
1025
1026        assert_eq!(updated.complexity, Some(8));
1027        assert_eq!(updated.priority, Some(10));
1028    }
1029
1030    #[tokio::test]
1031    async fn test_switch_to_task() {
1032        let ctx = TestContext::new().await;
1033        let manager = TaskManager::new(ctx.pool());
1034
1035        // Create a task
1036        let task = manager.add_task("Test task", None, None).await.unwrap();
1037        assert_eq!(task.status, "todo");
1038
1039        // Switch to it
1040        let switched = manager.switch_to_task(task.id).await.unwrap();
1041        assert_eq!(switched.task.status, "doing");
1042        assert!(switched.task.first_doing_at.is_some());
1043
1044        // Verify it's set as current task
1045        let current: Option<String> =
1046            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
1047                .fetch_optional(ctx.pool())
1048                .await
1049                .unwrap();
1050
1051        assert_eq!(current, Some(task.id.to_string()));
1052    }
1053
1054    #[tokio::test]
1055    async fn test_switch_to_task_already_doing() {
1056        let ctx = TestContext::new().await;
1057        let manager = TaskManager::new(ctx.pool());
1058
1059        // Create and start a task
1060        let task = manager.add_task("Test task", None, None).await.unwrap();
1061        manager.start_task(task.id, false).await.unwrap();
1062
1063        // Switch to it again (should be idempotent)
1064        let switched = manager.switch_to_task(task.id).await.unwrap();
1065        assert_eq!(switched.task.status, "doing");
1066    }
1067
1068    #[tokio::test]
1069    async fn test_spawn_subtask() {
1070        let ctx = TestContext::new().await;
1071        let manager = TaskManager::new(ctx.pool());
1072
1073        // Create and start a parent task
1074        let parent = manager.add_task("Parent task", None, None).await.unwrap();
1075        manager.start_task(parent.id, false).await.unwrap();
1076
1077        // Spawn a subtask
1078        let subtask = manager
1079            .spawn_subtask("Child task", Some("Details"))
1080            .await
1081            .unwrap();
1082
1083        assert_eq!(subtask.parent_id, Some(parent.id));
1084        assert_eq!(subtask.name, "Child task");
1085        assert_eq!(subtask.spec.as_deref(), Some("Details"));
1086
1087        // Verify subtask is now the current task
1088        let current: Option<String> =
1089            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
1090                .fetch_optional(ctx.pool())
1091                .await
1092                .unwrap();
1093
1094        assert_eq!(current, Some(subtask.id.to_string()));
1095
1096        // Verify subtask is in doing status
1097        let retrieved = manager.get_task(subtask.id).await.unwrap();
1098        assert_eq!(retrieved.status, "doing");
1099    }
1100
1101    #[tokio::test]
1102    async fn test_spawn_subtask_no_current_task() {
1103        let ctx = TestContext::new().await;
1104        let manager = TaskManager::new(ctx.pool());
1105
1106        // Try to spawn subtask without a current task
1107        let result = manager.spawn_subtask("Child", None).await;
1108        assert!(result.is_err());
1109    }
1110
1111    #[tokio::test]
1112    async fn test_pick_next_tasks_basic() {
1113        let ctx = TestContext::new().await;
1114        let manager = TaskManager::new(ctx.pool());
1115
1116        // Create 10 todo tasks
1117        for i in 1..=10 {
1118            manager
1119                .add_task(&format!("Task {}", i), None, None)
1120                .await
1121                .unwrap();
1122        }
1123
1124        // Pick 5 tasks with capacity limit of 5
1125        let picked = manager.pick_next_tasks(5, 5).await.unwrap();
1126
1127        assert_eq!(picked.len(), 5);
1128        for task in &picked {
1129            assert_eq!(task.status, "doing");
1130            assert!(task.first_doing_at.is_some());
1131        }
1132
1133        // Verify total doing count
1134        let doing_count: i64 =
1135            sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE status = 'doing'")
1136                .fetch_one(ctx.pool())
1137                .await
1138                .unwrap();
1139
1140        assert_eq!(doing_count, 5);
1141    }
1142
1143    #[tokio::test]
1144    async fn test_pick_next_tasks_with_existing_doing() {
1145        let ctx = TestContext::new().await;
1146        let manager = TaskManager::new(ctx.pool());
1147
1148        // Create 10 todo tasks
1149        for i in 1..=10 {
1150            manager
1151                .add_task(&format!("Task {}", i), None, None)
1152                .await
1153                .unwrap();
1154        }
1155
1156        // Start 2 tasks
1157        let tasks = manager.find_tasks(Some("todo"), None).await.unwrap();
1158        manager.start_task(tasks[0].id, false).await.unwrap();
1159        manager.start_task(tasks[1].id, false).await.unwrap();
1160
1161        // Pick more tasks with capacity limit of 5
1162        let picked = manager.pick_next_tasks(10, 5).await.unwrap();
1163
1164        // Should only pick 3 more (5 - 2 = 3)
1165        assert_eq!(picked.len(), 3);
1166
1167        // Verify total doing count
1168        let doing_count: i64 =
1169            sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE status = 'doing'")
1170                .fetch_one(ctx.pool())
1171                .await
1172                .unwrap();
1173
1174        assert_eq!(doing_count, 5);
1175    }
1176
1177    #[tokio::test]
1178    async fn test_pick_next_tasks_at_capacity() {
1179        let ctx = TestContext::new().await;
1180        let manager = TaskManager::new(ctx.pool());
1181
1182        // Create 10 tasks
1183        for i in 1..=10 {
1184            manager
1185                .add_task(&format!("Task {}", i), None, None)
1186                .await
1187                .unwrap();
1188        }
1189
1190        // Fill capacity
1191        let first_batch = manager.pick_next_tasks(5, 5).await.unwrap();
1192        assert_eq!(first_batch.len(), 5);
1193
1194        // Try to pick more (should return empty)
1195        let second_batch = manager.pick_next_tasks(5, 5).await.unwrap();
1196        assert_eq!(second_batch.len(), 0);
1197    }
1198
1199    #[tokio::test]
1200    async fn test_pick_next_tasks_priority_ordering() {
1201        let ctx = TestContext::new().await;
1202        let manager = TaskManager::new(ctx.pool());
1203
1204        // Create tasks with different priorities
1205        let low = manager.add_task("Low priority", None, None).await.unwrap();
1206        manager
1207            .update_task(low.id, None, None, None, None, None, Some(1))
1208            .await
1209            .unwrap();
1210
1211        let high = manager.add_task("High priority", None, None).await.unwrap();
1212        manager
1213            .update_task(high.id, None, None, None, None, None, Some(10))
1214            .await
1215            .unwrap();
1216
1217        let medium = manager
1218            .add_task("Medium priority", None, None)
1219            .await
1220            .unwrap();
1221        manager
1222            .update_task(medium.id, None, None, None, None, None, Some(5))
1223            .await
1224            .unwrap();
1225
1226        // Pick tasks
1227        let picked = manager.pick_next_tasks(3, 5).await.unwrap();
1228
1229        // Should be ordered by priority DESC
1230        assert_eq!(picked.len(), 3);
1231        assert_eq!(picked[0].priority, Some(10)); // high
1232        assert_eq!(picked[1].priority, Some(5)); // medium
1233        assert_eq!(picked[2].priority, Some(1)); // low
1234    }
1235
1236    #[tokio::test]
1237    async fn test_pick_next_tasks_complexity_ordering() {
1238        let ctx = TestContext::new().await;
1239        let manager = TaskManager::new(ctx.pool());
1240
1241        // Create tasks with different complexities (same priority)
1242        let complex = manager.add_task("Complex", None, None).await.unwrap();
1243        manager
1244            .update_task(complex.id, None, None, None, None, Some(9), Some(5))
1245            .await
1246            .unwrap();
1247
1248        let simple = manager.add_task("Simple", None, None).await.unwrap();
1249        manager
1250            .update_task(simple.id, None, None, None, None, Some(1), Some(5))
1251            .await
1252            .unwrap();
1253
1254        let medium = manager.add_task("Medium", None, None).await.unwrap();
1255        manager
1256            .update_task(medium.id, None, None, None, None, Some(5), Some(5))
1257            .await
1258            .unwrap();
1259
1260        // Pick tasks
1261        let picked = manager.pick_next_tasks(3, 5).await.unwrap();
1262
1263        // Should be ordered by complexity ASC (simple first)
1264        assert_eq!(picked.len(), 3);
1265        assert_eq!(picked[0].complexity, Some(1)); // simple
1266        assert_eq!(picked[1].complexity, Some(5)); // medium
1267        assert_eq!(picked[2].complexity, Some(9)); // complex
1268    }
1269
1270    #[tokio::test]
1271    async fn test_done_task_sibling_tasks_remain() {
1272        let ctx = TestContext::new().await;
1273        let manager = TaskManager::new(ctx.pool());
1274
1275        // Create parent with multiple children
1276        let parent = manager.add_task("Parent Task", None, None).await.unwrap();
1277        let child1 = manager
1278            .add_task("Child 1", None, Some(parent.id))
1279            .await
1280            .unwrap();
1281        let child2 = manager
1282            .add_task("Child 2", None, Some(parent.id))
1283            .await
1284            .unwrap();
1285        let _child3 = manager
1286            .add_task("Child 3", None, Some(parent.id))
1287            .await
1288            .unwrap();
1289
1290        // Complete first child
1291        manager.start_task(child1.id, false).await.unwrap();
1292        let response = manager.done_task().await.unwrap();
1293
1294        // Should indicate siblings remain
1295        match response.next_step_suggestion {
1296            NextStepSuggestion::SiblingTasksRemain {
1297                parent_task_id,
1298                remaining_siblings_count,
1299                ..
1300            } => {
1301                assert_eq!(parent_task_id, parent.id);
1302                assert_eq!(remaining_siblings_count, 2); // child2 and child3
1303            }
1304            _ => panic!("Expected SiblingTasksRemain suggestion"),
1305        }
1306
1307        // Complete second child
1308        manager.start_task(child2.id, false).await.unwrap();
1309        let response2 = manager.done_task().await.unwrap();
1310
1311        // Should still indicate siblings remain
1312        match response2.next_step_suggestion {
1313            NextStepSuggestion::SiblingTasksRemain {
1314                remaining_siblings_count,
1315                ..
1316            } => {
1317                assert_eq!(remaining_siblings_count, 1); // only child3
1318            }
1319            _ => panic!("Expected SiblingTasksRemain suggestion"),
1320        }
1321    }
1322
1323    #[tokio::test]
1324    async fn test_done_task_top_level_with_children() {
1325        let ctx = TestContext::new().await;
1326        let manager = TaskManager::new(ctx.pool());
1327
1328        // Create top-level task with children
1329        let parent = manager.add_task("Epic Task", None, None).await.unwrap();
1330        let child = manager
1331            .add_task("Sub Task", None, Some(parent.id))
1332            .await
1333            .unwrap();
1334
1335        // Complete child first
1336        manager.start_task(child.id, false).await.unwrap();
1337        manager.done_task().await.unwrap();
1338
1339        // Complete parent
1340        manager.start_task(parent.id, false).await.unwrap();
1341        let response = manager.done_task().await.unwrap();
1342
1343        // Should be TOP_LEVEL_TASK_COMPLETED
1344        match response.next_step_suggestion {
1345            NextStepSuggestion::TopLevelTaskCompleted {
1346                completed_task_id,
1347                completed_task_name,
1348                ..
1349            } => {
1350                assert_eq!(completed_task_id, parent.id);
1351                assert_eq!(completed_task_name, "Epic Task");
1352            }
1353            _ => panic!("Expected TopLevelTaskCompleted suggestion"),
1354        }
1355    }
1356
1357    #[tokio::test]
1358    async fn test_done_task_no_parent_context() {
1359        let ctx = TestContext::new().await;
1360        let manager = TaskManager::new(ctx.pool());
1361
1362        // Create multiple standalone tasks
1363        let task1 = manager
1364            .add_task("Standalone Task 1", None, None)
1365            .await
1366            .unwrap();
1367        let _task2 = manager
1368            .add_task("Standalone Task 2", None, None)
1369            .await
1370            .unwrap();
1371
1372        // Complete first task
1373        manager.start_task(task1.id, false).await.unwrap();
1374        let response = manager.done_task().await.unwrap();
1375
1376        // Should be NO_PARENT_CONTEXT since task2 is still pending
1377        match response.next_step_suggestion {
1378            NextStepSuggestion::NoParentContext {
1379                completed_task_id,
1380                completed_task_name,
1381                ..
1382            } => {
1383                assert_eq!(completed_task_id, task1.id);
1384                assert_eq!(completed_task_name, "Standalone Task 1");
1385            }
1386            _ => panic!("Expected NoParentContext suggestion"),
1387        }
1388    }
1389
1390    #[tokio::test]
1391    async fn test_search_tasks_by_name() {
1392        let ctx = TestContext::new().await;
1393        let manager = TaskManager::new(ctx.pool());
1394
1395        // Create tasks with different names
1396        manager
1397            .add_task("Authentication bug fix", Some("Fix login issue"), None)
1398            .await
1399            .unwrap();
1400        manager
1401            .add_task("Database migration", Some("Migrate to PostgreSQL"), None)
1402            .await
1403            .unwrap();
1404        manager
1405            .add_task("Authentication feature", Some("Add OAuth2 support"), None)
1406            .await
1407            .unwrap();
1408
1409        // Search for "authentication"
1410        let results = manager.search_tasks("authentication").await.unwrap();
1411
1412        assert_eq!(results.len(), 2);
1413        assert!(results[0]
1414            .task
1415            .name
1416            .to_lowercase()
1417            .contains("authentication"));
1418        assert!(results[1]
1419            .task
1420            .name
1421            .to_lowercase()
1422            .contains("authentication"));
1423
1424        // Check that match_snippet is present
1425        assert!(!results[0].match_snippet.is_empty());
1426    }
1427
1428    #[tokio::test]
1429    async fn test_search_tasks_by_spec() {
1430        let ctx = TestContext::new().await;
1431        let manager = TaskManager::new(ctx.pool());
1432
1433        // Create tasks
1434        manager
1435            .add_task("Task 1", Some("Implement JWT authentication"), None)
1436            .await
1437            .unwrap();
1438        manager
1439            .add_task("Task 2", Some("Add user registration"), None)
1440            .await
1441            .unwrap();
1442        manager
1443            .add_task("Task 3", Some("JWT token refresh"), None)
1444            .await
1445            .unwrap();
1446
1447        // Search for "JWT"
1448        let results = manager.search_tasks("JWT").await.unwrap();
1449
1450        assert_eq!(results.len(), 2);
1451        for result in &results {
1452            assert!(result
1453                .task
1454                .spec
1455                .as_ref()
1456                .unwrap()
1457                .to_uppercase()
1458                .contains("JWT"));
1459        }
1460    }
1461
1462    #[tokio::test]
1463    async fn test_search_tasks_with_advanced_query() {
1464        let ctx = TestContext::new().await;
1465        let manager = TaskManager::new(ctx.pool());
1466
1467        // Create tasks
1468        manager
1469            .add_task("Bug fix", Some("Fix critical authentication bug"), None)
1470            .await
1471            .unwrap();
1472        manager
1473            .add_task("Feature", Some("Add authentication feature"), None)
1474            .await
1475            .unwrap();
1476        manager
1477            .add_task("Bug report", Some("Report critical database bug"), None)
1478            .await
1479            .unwrap();
1480
1481        // Search with AND operator
1482        let results = manager
1483            .search_tasks("authentication AND bug")
1484            .await
1485            .unwrap();
1486
1487        assert_eq!(results.len(), 1);
1488        assert!(results[0]
1489            .task
1490            .spec
1491            .as_ref()
1492            .unwrap()
1493            .contains("authentication"));
1494        assert!(results[0].task.spec.as_ref().unwrap().contains("bug"));
1495    }
1496
1497    #[tokio::test]
1498    async fn test_search_tasks_no_results() {
1499        let ctx = TestContext::new().await;
1500        let manager = TaskManager::new(ctx.pool());
1501
1502        // Create tasks
1503        manager
1504            .add_task("Task 1", Some("Some description"), None)
1505            .await
1506            .unwrap();
1507
1508        // Search for non-existent term
1509        let results = manager.search_tasks("nonexistent").await.unwrap();
1510
1511        assert_eq!(results.len(), 0);
1512    }
1513
1514    #[tokio::test]
1515    async fn test_search_tasks_snippet_highlighting() {
1516        let ctx = TestContext::new().await;
1517        let manager = TaskManager::new(ctx.pool());
1518
1519        // Create task with keyword in spec
1520        manager
1521            .add_task(
1522                "Test task",
1523                Some("This is a description with the keyword authentication in the middle"),
1524                None,
1525            )
1526            .await
1527            .unwrap();
1528
1529        // Search for "authentication"
1530        let results = manager.search_tasks("authentication").await.unwrap();
1531
1532        assert_eq!(results.len(), 1);
1533        // Check that snippet contains highlighted keyword (marked with **)
1534        assert!(results[0].match_snippet.contains("**authentication**"));
1535    }
1536}