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, owner
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
103            .add_task("Test task", None, None, None)
104            .await
105            .unwrap();
106
107        let response = workspace_mgr.set_current_task(task.id).await.unwrap();
108
109        assert_eq!(response.current_task_id, Some(task.id));
110        assert!(response.task.is_some());
111        assert_eq!(response.task.unwrap().id, task.id);
112    }
113
114    #[tokio::test]
115    async fn test_set_current_task_nonexistent() {
116        let ctx = TestContext::new().await;
117        let workspace_mgr = WorkspaceManager::new(ctx.pool());
118
119        let result = workspace_mgr.set_current_task(999).await;
120        assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
121    }
122
123    #[tokio::test]
124    async fn test_update_current_task() {
125        let ctx = TestContext::new().await;
126        let task_mgr = TaskManager::new(ctx.pool());
127        let workspace_mgr = WorkspaceManager::new(ctx.pool());
128
129        let task1 = task_mgr.add_task("Task 1", None, None, None).await.unwrap();
130        let task2 = task_mgr.add_task("Task 2", None, None, None).await.unwrap();
131
132        // Set task1 as current
133        workspace_mgr.set_current_task(task1.id).await.unwrap();
134
135        // Update to task2
136        let response = workspace_mgr.set_current_task(task2.id).await.unwrap();
137
138        assert_eq!(response.current_task_id, Some(task2.id));
139        assert_eq!(response.task.unwrap().id, task2.id);
140    }
141
142    #[tokio::test]
143    async fn test_get_current_task_after_set() {
144        let ctx = TestContext::new().await;
145        let task_mgr = TaskManager::new(ctx.pool());
146        let workspace_mgr = WorkspaceManager::new(ctx.pool());
147
148        let task = task_mgr
149            .add_task("Test task", None, None, None)
150            .await
151            .unwrap();
152        workspace_mgr.set_current_task(task.id).await.unwrap();
153
154        let response = workspace_mgr.get_current_task().await.unwrap();
155
156        assert_eq!(response.current_task_id, Some(task.id));
157        assert!(response.task.is_some());
158    }
159
160    #[tokio::test]
161    async fn test_current_task_response_serialization() {
162        let ctx = TestContext::new().await;
163        let task_mgr = TaskManager::new(ctx.pool());
164        let workspace_mgr = WorkspaceManager::new(ctx.pool());
165
166        let task = task_mgr
167            .add_task("Test task", None, None, None)
168            .await
169            .unwrap();
170        let response = workspace_mgr.set_current_task(task.id).await.unwrap();
171
172        // Should serialize to JSON without errors
173        let json = serde_json::to_string(&response).unwrap();
174        assert!(json.contains("current_task_id"));
175        assert!(json.contains("task"));
176    }
177
178    #[tokio::test]
179    async fn test_current_task_response_none_serialization() {
180        let ctx = TestContext::new().await;
181        let workspace_mgr = WorkspaceManager::new(ctx.pool());
182
183        let response = workspace_mgr.get_current_task().await.unwrap();
184
185        // When no task, task field should be omitted (skip_serializing_if)
186        let json = serde_json::to_string(&response).unwrap();
187        assert!(json.contains("current_task_id"));
188        // task field should be omitted when None
189        assert!(!json.contains("\"task\""));
190    }
191
192    #[tokio::test]
193    async fn test_get_current_task_with_invalid_id_in_db() {
194        let ctx = TestContext::new().await;
195
196        // Manually insert invalid task_id (non-numeric string)
197        sqlx::query(
198            "INSERT INTO workspace_state (key, value) VALUES ('current_task_id', 'invalid')",
199        )
200        .execute(ctx.pool())
201        .await
202        .unwrap();
203
204        let workspace_mgr = WorkspaceManager::new(ctx.pool());
205        let response = workspace_mgr.get_current_task().await.unwrap();
206
207        // Should gracefully handle invalid ID by returning None
208        assert!(response.current_task_id.is_none());
209        assert!(response.task.is_none());
210    }
211
212    #[tokio::test]
213    async fn test_get_current_task_with_deleted_task() {
214        let ctx = TestContext::new().await;
215        let task_mgr = TaskManager::new(ctx.pool());
216        let workspace_mgr = WorkspaceManager::new(ctx.pool());
217
218        let task = task_mgr
219            .add_task("Test task", None, None, None)
220            .await
221            .unwrap();
222        workspace_mgr.set_current_task(task.id).await.unwrap();
223
224        // Delete the task
225        task_mgr.delete_task(task.id).await.unwrap();
226
227        let response = workspace_mgr.get_current_task().await.unwrap();
228
229        // current_task_id still exists but task should be None
230        assert_eq!(response.current_task_id, Some(task.id));
231        assert!(response.task.is_none());
232    }
233
234    #[tokio::test]
235    async fn test_set_current_task_returns_complete_task() {
236        let ctx = TestContext::new().await;
237        let task_mgr = TaskManager::new(ctx.pool());
238        let workspace_mgr = WorkspaceManager::new(ctx.pool());
239
240        let task = task_mgr
241            .add_task("Test task", Some("Task spec"), None, None)
242            .await
243            .unwrap();
244
245        let response = workspace_mgr.set_current_task(task.id).await.unwrap();
246
247        // Verify task object is complete
248        let returned_task = response.task.unwrap();
249        assert_eq!(returned_task.id, task.id);
250        assert_eq!(returned_task.name, "Test task");
251        assert_eq!(returned_task.spec, Some("Task spec".to_string()));
252        assert_eq!(returned_task.status, "todo");
253    }
254
255    #[tokio::test]
256    async fn test_set_same_task_multiple_times() {
257        let ctx = TestContext::new().await;
258        let task_mgr = TaskManager::new(ctx.pool());
259        let workspace_mgr = WorkspaceManager::new(ctx.pool());
260
261        let task = task_mgr
262            .add_task("Test task", None, None, None)
263            .await
264            .unwrap();
265
266        // Set the same task multiple times (idempotent)
267        workspace_mgr.set_current_task(task.id).await.unwrap();
268        workspace_mgr.set_current_task(task.id).await.unwrap();
269        let response = workspace_mgr.set_current_task(task.id).await.unwrap();
270
271        assert_eq!(response.current_task_id, Some(task.id));
272    }
273
274    #[tokio::test]
275    async fn test_workspace_state_insert_or_replace() {
276        let ctx = TestContext::new().await;
277        let task_mgr = TaskManager::new(ctx.pool());
278        let workspace_mgr = WorkspaceManager::new(ctx.pool());
279
280        let task1 = task_mgr.add_task("Task 1", None, None, None).await.unwrap();
281        let task2 = task_mgr.add_task("Task 2", None, None, None).await.unwrap();
282
283        workspace_mgr.set_current_task(task1.id).await.unwrap();
284        workspace_mgr.set_current_task(task2.id).await.unwrap();
285
286        // Check that only one row exists in workspace_state for current_task_id
287        let count: i64 = sqlx::query_scalar(
288            "SELECT COUNT(*) FROM workspace_state WHERE key = 'current_task_id'",
289        )
290        .fetch_one(ctx.pool())
291        .await
292        .unwrap();
293
294        assert_eq!(count, 1);
295    }
296
297    #[tokio::test]
298    async fn test_get_current_task_with_changed_status() {
299        let ctx = TestContext::new().await;
300        let task_mgr = TaskManager::new(ctx.pool());
301        let workspace_mgr = WorkspaceManager::new(ctx.pool());
302
303        let task = task_mgr
304            .add_task("Test task", None, None, None)
305            .await
306            .unwrap();
307        workspace_mgr.set_current_task(task.id).await.unwrap();
308
309        // Change task status
310        task_mgr.start_task(task.id, false).await.unwrap();
311
312        let response = workspace_mgr.get_current_task().await.unwrap();
313
314        // Should reflect updated status
315        assert_eq!(response.task.unwrap().status, "doing");
316    }
317}