intent_engine/
workspace.rs

1use crate::db::models::Task;
2use crate::error::{IntentError, Result};
3use serde::Serialize;
4use sqlx::SqlitePool;
5
6#[derive(Debug, Serialize)]
7pub struct CurrentTaskResponse {
8    pub current_task_id: Option<i64>,
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub task: Option<Task>,
11}
12
13pub struct WorkspaceManager<'a> {
14    pool: &'a SqlitePool,
15}
16
17impl<'a> WorkspaceManager<'a> {
18    pub fn new(pool: &'a SqlitePool) -> Self {
19        Self { pool }
20    }
21
22    /// Get the current task
23    pub async fn get_current_task(&self) -> Result<CurrentTaskResponse> {
24        let current_task_id: Option<String> =
25            sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'current_task_id'")
26                .fetch_optional(self.pool)
27                .await?;
28
29        let current_task_id = current_task_id.and_then(|id| id.parse::<i64>().ok());
30
31        let task = if let Some(id) = current_task_id {
32            sqlx::query_as::<_, Task>(
33                r#"
34                SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at, active_form
35                FROM tasks
36                WHERE id = ?
37                "#,
38            )
39            .bind(id)
40            .fetch_optional(self.pool)
41            .await?
42        } else {
43            None
44        };
45
46        Ok(CurrentTaskResponse {
47            current_task_id,
48            task,
49        })
50    }
51
52    /// Set the current task
53    pub async fn set_current_task(&self, task_id: i64) -> Result<CurrentTaskResponse> {
54        // Check if task exists
55        let task_exists: bool = sqlx::query_scalar(crate::sql_constants::CHECK_TASK_EXISTS)
56            .bind(task_id)
57            .fetch_one(self.pool)
58            .await?;
59
60        if !task_exists {
61            return Err(IntentError::TaskNotFound(task_id));
62        }
63
64        // Set current task
65        sqlx::query(
66            r#"
67            INSERT OR REPLACE INTO workspace_state (key, value)
68            VALUES ('current_task_id', ?)
69            "#,
70        )
71        .bind(task_id.to_string())
72        .execute(self.pool)
73        .await?;
74
75        self.get_current_task().await
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::tasks::TaskManager;
83    use crate::test_utils::test_helpers::TestContext;
84
85    #[tokio::test]
86    async fn test_get_current_task_none() {
87        let ctx = TestContext::new().await;
88        let workspace_mgr = WorkspaceManager::new(ctx.pool());
89
90        let response = workspace_mgr.get_current_task().await.unwrap();
91
92        assert!(response.current_task_id.is_none());
93        assert!(response.task.is_none());
94    }
95
96    #[tokio::test]
97    async fn test_set_current_task() {
98        let ctx = TestContext::new().await;
99        let task_mgr = TaskManager::new(ctx.pool());
100        let workspace_mgr = WorkspaceManager::new(ctx.pool());
101
102        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
103
104        let response = workspace_mgr.set_current_task(task.id).await.unwrap();
105
106        assert_eq!(response.current_task_id, Some(task.id));
107        assert!(response.task.is_some());
108        assert_eq!(response.task.unwrap().id, task.id);
109    }
110
111    #[tokio::test]
112    async fn test_set_current_task_nonexistent() {
113        let ctx = TestContext::new().await;
114        let workspace_mgr = WorkspaceManager::new(ctx.pool());
115
116        let result = workspace_mgr.set_current_task(999).await;
117        assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
118    }
119
120    #[tokio::test]
121    async fn test_update_current_task() {
122        let ctx = TestContext::new().await;
123        let task_mgr = TaskManager::new(ctx.pool());
124        let workspace_mgr = WorkspaceManager::new(ctx.pool());
125
126        let task1 = task_mgr.add_task("Task 1", None, None).await.unwrap();
127        let task2 = task_mgr.add_task("Task 2", None, None).await.unwrap();
128
129        // Set task1 as current
130        workspace_mgr.set_current_task(task1.id).await.unwrap();
131
132        // Update to task2
133        let response = workspace_mgr.set_current_task(task2.id).await.unwrap();
134
135        assert_eq!(response.current_task_id, Some(task2.id));
136        assert_eq!(response.task.unwrap().id, task2.id);
137    }
138
139    #[tokio::test]
140    async fn test_get_current_task_after_set() {
141        let ctx = TestContext::new().await;
142        let task_mgr = TaskManager::new(ctx.pool());
143        let workspace_mgr = WorkspaceManager::new(ctx.pool());
144
145        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
146        workspace_mgr.set_current_task(task.id).await.unwrap();
147
148        let response = workspace_mgr.get_current_task().await.unwrap();
149
150        assert_eq!(response.current_task_id, Some(task.id));
151        assert!(response.task.is_some());
152    }
153
154    #[tokio::test]
155    async fn test_current_task_response_serialization() {
156        let ctx = TestContext::new().await;
157        let task_mgr = TaskManager::new(ctx.pool());
158        let workspace_mgr = WorkspaceManager::new(ctx.pool());
159
160        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
161        let response = workspace_mgr.set_current_task(task.id).await.unwrap();
162
163        // Should serialize to JSON without errors
164        let json = serde_json::to_string(&response).unwrap();
165        assert!(json.contains("current_task_id"));
166        assert!(json.contains("task"));
167    }
168
169    #[tokio::test]
170    async fn test_current_task_response_none_serialization() {
171        let ctx = TestContext::new().await;
172        let workspace_mgr = WorkspaceManager::new(ctx.pool());
173
174        let response = workspace_mgr.get_current_task().await.unwrap();
175
176        // When no task, task field should be omitted (skip_serializing_if)
177        let json = serde_json::to_string(&response).unwrap();
178        assert!(json.contains("current_task_id"));
179        // task field should be omitted when None
180        assert!(!json.contains("\"task\""));
181    }
182
183    #[tokio::test]
184    async fn test_get_current_task_with_invalid_id_in_db() {
185        let ctx = TestContext::new().await;
186
187        // Manually insert invalid task_id (non-numeric string)
188        sqlx::query(
189            "INSERT INTO workspace_state (key, value) VALUES ('current_task_id', 'invalid')",
190        )
191        .execute(ctx.pool())
192        .await
193        .unwrap();
194
195        let workspace_mgr = WorkspaceManager::new(ctx.pool());
196        let response = workspace_mgr.get_current_task().await.unwrap();
197
198        // Should gracefully handle invalid ID by returning None
199        assert!(response.current_task_id.is_none());
200        assert!(response.task.is_none());
201    }
202
203    #[tokio::test]
204    async fn test_get_current_task_with_deleted_task() {
205        let ctx = TestContext::new().await;
206        let task_mgr = TaskManager::new(ctx.pool());
207        let workspace_mgr = WorkspaceManager::new(ctx.pool());
208
209        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
210        workspace_mgr.set_current_task(task.id).await.unwrap();
211
212        // Delete the task
213        task_mgr.delete_task(task.id).await.unwrap();
214
215        let response = workspace_mgr.get_current_task().await.unwrap();
216
217        // current_task_id still exists but task should be None
218        assert_eq!(response.current_task_id, Some(task.id));
219        assert!(response.task.is_none());
220    }
221
222    #[tokio::test]
223    async fn test_set_current_task_returns_complete_task() {
224        let ctx = TestContext::new().await;
225        let task_mgr = TaskManager::new(ctx.pool());
226        let workspace_mgr = WorkspaceManager::new(ctx.pool());
227
228        let task = task_mgr
229            .add_task("Test task", Some("Task spec"), None)
230            .await
231            .unwrap();
232
233        let response = workspace_mgr.set_current_task(task.id).await.unwrap();
234
235        // Verify task object is complete
236        let returned_task = response.task.unwrap();
237        assert_eq!(returned_task.id, task.id);
238        assert_eq!(returned_task.name, "Test task");
239        assert_eq!(returned_task.spec, Some("Task spec".to_string()));
240        assert_eq!(returned_task.status, "todo");
241    }
242
243    #[tokio::test]
244    async fn test_set_same_task_multiple_times() {
245        let ctx = TestContext::new().await;
246        let task_mgr = TaskManager::new(ctx.pool());
247        let workspace_mgr = WorkspaceManager::new(ctx.pool());
248
249        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
250
251        // Set the same task multiple times (idempotent)
252        workspace_mgr.set_current_task(task.id).await.unwrap();
253        workspace_mgr.set_current_task(task.id).await.unwrap();
254        let response = workspace_mgr.set_current_task(task.id).await.unwrap();
255
256        assert_eq!(response.current_task_id, Some(task.id));
257    }
258
259    #[tokio::test]
260    async fn test_workspace_state_insert_or_replace() {
261        let ctx = TestContext::new().await;
262        let task_mgr = TaskManager::new(ctx.pool());
263        let workspace_mgr = WorkspaceManager::new(ctx.pool());
264
265        let task1 = task_mgr.add_task("Task 1", None, None).await.unwrap();
266        let task2 = task_mgr.add_task("Task 2", None, None).await.unwrap();
267
268        workspace_mgr.set_current_task(task1.id).await.unwrap();
269        workspace_mgr.set_current_task(task2.id).await.unwrap();
270
271        // Check that only one row exists in workspace_state for current_task_id
272        let count: i64 = sqlx::query_scalar(
273            "SELECT COUNT(*) FROM workspace_state WHERE key = 'current_task_id'",
274        )
275        .fetch_one(ctx.pool())
276        .await
277        .unwrap();
278
279        assert_eq!(count, 1);
280    }
281
282    #[tokio::test]
283    async fn test_get_current_task_with_changed_status() {
284        let ctx = TestContext::new().await;
285        let task_mgr = TaskManager::new(ctx.pool());
286        let workspace_mgr = WorkspaceManager::new(ctx.pool());
287
288        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
289        workspace_mgr.set_current_task(task.id).await.unwrap();
290
291        // Change task status
292        task_mgr.start_task(task.id, false).await.unwrap();
293
294        let response = workspace_mgr.get_current_task().await.unwrap();
295
296        // Should reflect updated status
297        assert_eq!(response.task.unwrap().status, "doing");
298    }
299}