tsk/
task_manager.rs

1use crate::assets::layered::LayeredAssetManager;
2use crate::context::{AppContext, file_system::FileSystemOperations};
3use crate::docker::DockerManager;
4use crate::docker::composer::DockerComposer;
5use crate::docker::image_manager::DockerImageManager;
6use crate::docker::template_manager::DockerTemplateManager;
7use crate::git::RepoManager;
8use crate::repo_utils::find_repository_root;
9use crate::storage::XdgDirectories;
10use crate::task::{Task, TaskBuilder, TaskStatus};
11use crate::task_runner::{TaskExecutionError, TaskExecutionResult, TaskRunner};
12use crate::task_storage::{TaskStorage, get_task_storage};
13use std::sync::Arc;
14
15pub struct TaskManager {
16    task_runner: TaskRunner,
17    task_storage: Option<Box<dyn TaskStorage>>,
18    file_system: Arc<dyn FileSystemOperations>,
19    xdg_directories: Arc<XdgDirectories>,
20}
21
22impl TaskManager {
23    pub fn new(ctx: &AppContext) -> Result<Self, String> {
24        let repo_manager = RepoManager::new(
25            ctx.xdg_directories(),
26            ctx.file_system(),
27            ctx.git_operations(),
28        );
29        let docker_manager = DockerManager::new(ctx.docker_client(), ctx.file_system());
30
31        // Create image manager with a default configuration
32        // Individual tasks will create their own image managers with task-specific repos
33        let project_root = find_repository_root(std::path::Path::new(".")).ok();
34        let asset_manager = Arc::new(LayeredAssetManager::new_with_standard_layers(
35            project_root.as_deref(),
36            &ctx.xdg_directories(),
37        ));
38        let template_manager =
39            DockerTemplateManager::new(asset_manager.clone(), ctx.xdg_directories());
40        let composer = DockerComposer::new(DockerTemplateManager::new(
41            asset_manager,
42            ctx.xdg_directories(),
43        ));
44        let image_manager = Arc::new(DockerImageManager::new(
45            ctx.docker_client(),
46            template_manager,
47            composer,
48        ));
49
50        let task_runner = TaskRunner::new(
51            repo_manager,
52            docker_manager,
53            image_manager,
54            ctx.file_system(),
55            ctx.notification_client(),
56        );
57
58        Ok(Self {
59            task_runner,
60            task_storage: None,
61            file_system: ctx.file_system(),
62            xdg_directories: ctx.xdg_directories(),
63        })
64    }
65
66    pub fn with_storage(ctx: &AppContext) -> Result<Self, String> {
67        let repo_manager = RepoManager::new(
68            ctx.xdg_directories(),
69            ctx.file_system(),
70            ctx.git_operations(),
71        );
72        let docker_manager = DockerManager::new(ctx.docker_client(), ctx.file_system());
73
74        // Create image manager with a default configuration
75        // Individual tasks will create their own image managers with task-specific repos
76        let project_root = find_repository_root(std::path::Path::new(".")).ok();
77        let asset_manager = Arc::new(LayeredAssetManager::new_with_standard_layers(
78            project_root.as_deref(),
79            &ctx.xdg_directories(),
80        ));
81        let template_manager =
82            DockerTemplateManager::new(asset_manager.clone(), ctx.xdg_directories());
83        let composer = DockerComposer::new(DockerTemplateManager::new(
84            asset_manager,
85            ctx.xdg_directories(),
86        ));
87        let image_manager = Arc::new(DockerImageManager::new(
88            ctx.docker_client(),
89            template_manager,
90            composer,
91        ));
92
93        let task_runner = TaskRunner::new(
94            repo_manager,
95            docker_manager,
96            image_manager,
97            ctx.file_system(),
98            ctx.notification_client(),
99        );
100
101        Ok(Self {
102            task_runner,
103            task_storage: Some(get_task_storage(ctx.xdg_directories(), ctx.file_system())),
104            file_system: ctx.file_system(),
105            xdg_directories: ctx.xdg_directories(),
106        })
107    }
108
109    /// Execute a task from the queue (with status updates)
110    pub async fn execute_queued_task(
111        &self,
112        task: &Task,
113    ) -> Result<TaskExecutionResult, TaskExecutionError> {
114        // Update task status to running if we have storage
115        if let Some(ref storage) = self.task_storage {
116            let mut running_task = task.clone();
117            running_task.status = TaskStatus::Running;
118            running_task.started_at = Some(chrono::Utc::now());
119
120            if let Err(e) = storage.update_task(running_task.clone()).await {
121                eprintln!("Error updating task status: {e}");
122            }
123        }
124
125        // Execute the task
126        let execution_result = self.task_runner.execute_task(task, false).await;
127
128        match execution_result {
129            Ok(result) => {
130                // Update task status based on the task result if we have storage
131                if let Some(ref storage) = self.task_storage {
132                    let mut updated_task = task.clone();
133                    updated_task.completed_at = Some(chrono::Utc::now());
134                    updated_task.branch_name = result.branch_name.clone();
135
136                    // Check if we have a parsed result from the log processor
137                    if let Some(task_result) = result.task_result.as_ref() {
138                        if task_result.success {
139                            updated_task.status = TaskStatus::Complete;
140                        } else {
141                            updated_task.status = TaskStatus::Failed;
142                            updated_task.error_message = Some(task_result.message.clone());
143                        }
144                    } else {
145                        // Default to complete if no explicit result was found
146                        updated_task.status = TaskStatus::Complete;
147                    }
148
149                    if let Err(e) = storage.update_task(updated_task).await {
150                        eprintln!("Error updating task status: {e}");
151                    }
152                }
153                Ok(result)
154            }
155            Err(e) => {
156                // Update task status to failed if we have storage
157                if let Some(ref storage) = self.task_storage {
158                    let mut failed_task = task.clone();
159                    failed_task.status = TaskStatus::Failed;
160                    failed_task.error_message = Some(e.message.clone());
161                    failed_task.completed_at = Some(chrono::Utc::now());
162
163                    if let Err(storage_err) = storage.update_task(failed_task).await {
164                        eprintln!("Error updating task status: {storage_err}");
165                    }
166                }
167                Err(e)
168            }
169        }
170    }
171
172    /// Delete a specific task and its associated directory
173    pub async fn delete_task(&self, task_id: &str) -> Result<(), String> {
174        // Get task storage to delete from database
175        let storage = match &self.task_storage {
176            Some(s) => s,
177            None => return Err("Task storage not initialized".to_string()),
178        };
179
180        // Get the task to find its directory
181        let task = storage
182            .get_task(task_id)
183            .await
184            .map_err(|e| format!("Error getting task: {e}"))?;
185
186        if task.is_none() {
187            return Err(format!("Task with ID '{task_id}' not found"));
188        }
189
190        // Delete from storage first
191        storage
192            .delete_task(task_id)
193            .await
194            .map_err(|e| format!("Error deleting task from storage: {e}"))?;
195
196        // Delete the task directory
197        let task = task.unwrap();
198        let repo_hash = crate::storage::get_repo_hash(&task.repo_root);
199        let task_dir = self.xdg_directories.task_dir(task_id, &repo_hash);
200        if self.file_system.exists(&task_dir).await.unwrap_or(false) {
201            self.file_system
202                .remove_dir(&task_dir)
203                .await
204                .map_err(|e| format!("Error deleting task directory: {e}"))?;
205        }
206
207        Ok(())
208    }
209
210    /// Delete all completed tasks
211    pub async fn clean_tasks(&self) -> Result<usize, String> {
212        // Get task storage
213        let storage = match &self.task_storage {
214            Some(s) => s,
215            None => return Err("Task storage not initialized".to_string()),
216        };
217
218        // Get all tasks to find directories to delete
219        let all_tasks = storage
220            .list_tasks()
221            .await
222            .map_err(|e| format!("Error listing tasks: {e}"))?;
223
224        // Filter completed tasks
225        let completed_tasks: Vec<&Task> = all_tasks
226            .iter()
227            .filter(|t| t.status == TaskStatus::Complete)
228            .collect();
229
230        // Delete completed tasks directories
231        for task in &completed_tasks {
232            let repo_hash = crate::storage::get_repo_hash(&task.repo_root);
233            let task_dir = self.xdg_directories.task_dir(&task.id, &repo_hash);
234            if self.file_system.exists(&task_dir).await.unwrap_or(false) {
235                if let Err(e) = self.file_system.remove_dir(&task_dir).await {
236                    eprintln!(
237                        "Warning: Failed to delete task directory {}: {}",
238                        task.id, e
239                    );
240                }
241            }
242        }
243
244        // Delete completed tasks from storage
245        let deleted_count = storage
246            .delete_tasks_by_status(vec![TaskStatus::Complete])
247            .await
248            .map_err(|e| format!("Error deleting completed tasks: {e}"))?;
249
250        Ok(deleted_count)
251    }
252
253    /// Retry a task by creating a new task with the same instructions
254    pub async fn retry_task(
255        &self,
256        task_id: &str,
257        edit_instructions: bool,
258        ctx: &AppContext,
259    ) -> Result<String, String> {
260        // Get task storage
261        let storage = match &self.task_storage {
262            Some(s) => s,
263            None => return Err("Task storage not initialized".to_string()),
264        };
265
266        // Retrieve the original task
267        let original_task = storage
268            .get_task(task_id)
269            .await
270            .map_err(|e| format!("Error getting task: {e}"))?;
271
272        let original_task = match original_task {
273            Some(task) => task,
274            None => return Err(format!("Task with ID '{task_id}' not found")),
275        };
276
277        // Validate that the task has been executed (not Queued)
278        if original_task.status == TaskStatus::Queued {
279            return Err("Cannot retry a task that hasn't been executed yet".to_string());
280        }
281
282        // Create a new task name with format: retry-{original_name}
283        let new_task_name = format!("retry-{}", original_task.name);
284
285        // Use TaskBuilder to create the new task, leveraging from_existing
286        let mut builder = TaskBuilder::from_existing(&original_task);
287
288        builder = builder.name(new_task_name).edit(edit_instructions);
289
290        let new_task = builder
291            .build(ctx)
292            .await
293            .map_err(|e| format!("Failed to build retry task: {e}"))?;
294
295        // Store the new task
296        storage
297            .add_task(new_task.clone())
298            .await
299            .map_err(|e| format!("Error adding retry task to storage: {e}"))?;
300
301        Ok(new_task.id)
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::test_utils::FixedResponseDockerClient;
309    use std::path::PathBuf;
310    use std::sync::Arc;
311    use tempfile::TempDir;
312
313    /// Helper function to create a temporary git repository
314    fn create_temp_git_repo() -> (TempDir, PathBuf) {
315        let temp_dir = TempDir::new().unwrap();
316        let repo_root = temp_dir.path().to_path_buf();
317
318        // Create .git directory to make it a valid git repo
319        std::fs::create_dir(repo_root.join(".git")).unwrap();
320
321        (temp_dir, repo_root)
322    }
323
324    #[tokio::test]
325    async fn test_delete_task() {
326        use crate::context::file_system::tests::MockFileSystem;
327        use crate::context::git_operations::tests::MockGitOperations;
328        use std::env;
329
330        // Set up XDG environment variables for testing
331        let temp_dir = std::env::temp_dir();
332        let test_data_dir = temp_dir.join("tsk-test-data");
333        let test_runtime_dir = temp_dir.join("tsk-test-runtime");
334        unsafe {
335            env::set_var("XDG_DATA_HOME", test_data_dir.to_string_lossy().to_string());
336        }
337        unsafe {
338            env::set_var(
339                "XDG_RUNTIME_DIR",
340                test_runtime_dir.to_string_lossy().to_string(),
341            );
342        }
343
344        // Create XdgDirectories instance
345        let xdg = Arc::new(XdgDirectories::new().unwrap());
346
347        // Create a task to test with
348        let (_temp_repo, repo_root) = create_temp_git_repo();
349        let task_id = "test-task-123".to_string();
350        let repo_hash = crate::storage::get_repo_hash(&repo_root);
351
352        // Get XDG paths
353        let task_dir_path = xdg.task_dir(&task_id, &repo_hash);
354        let tasks_json_path = xdg.tasks_file();
355        let data_dir = xdg.data_dir().to_path_buf();
356        let tasks_dir = data_dir.join("tasks");
357
358        // Create mock file system with necessary structure
359        let git_dir = repo_root.join(".git");
360
361        let fs = Arc::new(
362            MockFileSystem::new()
363                .with_dir(&git_dir.to_string_lossy().to_string())
364                .with_dir(&data_dir.to_string_lossy().to_string())
365                .with_dir(&tasks_dir.to_string_lossy().to_string())
366                .with_dir(&task_dir_path.to_string_lossy().to_string())
367                .with_file(&format!("{}/test.txt", task_dir_path.to_string_lossy()), "test content")
368                .with_file(&tasks_json_path.to_string_lossy().to_string(), &format!(r#"[{{"id":"{}","repo_root":"{}","name":"test-task","task_type":"feat","instructions_file":"instructions.md","agent":"claude-code","timeout":30,"status":"QUEUED","created_at":"2024-01-01T00:00:00Z","started_at":null,"completed_at":null,"branch_name":"tsk/{}","error_message":null,"source_commit":"abc123","tech_stack":"default","project":"default","copied_repo_path":"{}"}}]"#, task_id, repo_root.to_string_lossy(), task_id, task_dir_path.to_string_lossy()))
369        );
370
371        let docker_client = Arc::new(FixedResponseDockerClient::default());
372        let git_ops = Arc::new(MockGitOperations::new());
373
374        let ctx = AppContext::builder()
375            .with_docker_client(docker_client)
376            .with_file_system(fs.clone())
377            .with_git_operations(git_ops)
378            .with_xdg_directories(xdg)
379            .build();
380
381        let task_manager = TaskManager::with_storage(&ctx).unwrap();
382
383        // Delete the task
384        let result = task_manager.delete_task(&task_id).await;
385        assert!(result.is_ok(), "Failed to delete task: {:?}", result);
386
387        // Verify task directory is deleted by checking the mock file system
388        let exists = fs.exists(&task_dir_path).await.unwrap();
389        assert!(!exists, "Task directory should have been deleted");
390    }
391
392    #[tokio::test]
393    async fn test_clean_tasks() {
394        use crate::context::file_system::tests::MockFileSystem;
395        use crate::context::git_operations::tests::MockGitOperations;
396        use std::env;
397
398        // Set up XDG environment variables for testing
399        let temp_dir = std::env::temp_dir();
400        let test_data_dir = temp_dir.join("tsk-test-data2");
401        let test_runtime_dir = temp_dir.join("tsk-test-runtime2");
402        unsafe {
403            env::set_var("XDG_DATA_HOME", test_data_dir.to_string_lossy().to_string());
404        }
405        unsafe {
406            env::set_var(
407                "XDG_RUNTIME_DIR",
408                test_runtime_dir.to_string_lossy().to_string(),
409            );
410        }
411
412        // Create XdgDirectories instance
413        let xdg = Arc::new(XdgDirectories::new().unwrap());
414
415        // Create tasks with different statuses
416        let (_temp_repo, repo_root) = create_temp_git_repo();
417        let repo_hash = crate::storage::get_repo_hash(&repo_root);
418        let queued_task_id = "queued-task-123".to_string();
419        let completed_task_id = "completed-task-456".to_string();
420
421        let _queued_task = Task::new(
422            queued_task_id.clone(),
423            repo_root.clone(),
424            "queued-task".to_string(),
425            "feat".to_string(),
426            "instructions.md".to_string(),
427            "claude-code".to_string(),
428            30,
429            format!("tsk/{queued_task_id}"),
430            "abc123".to_string(),
431            "default".to_string(),
432            "default".to_string(),
433            chrono::Local::now(),
434            repo_root.clone(),
435        );
436
437        let mut completed_task = Task::new(
438            completed_task_id.clone(),
439            repo_root.clone(),
440            "completed-task".to_string(),
441            "fix".to_string(),
442            "instructions.md".to_string(),
443            "claude-code".to_string(),
444            30,
445            format!("tsk/{completed_task_id}"),
446            "abc123".to_string(),
447            "default".to_string(),
448            "default".to_string(),
449            chrono::Local::now(),
450            repo_root.clone(),
451        );
452        completed_task.status = TaskStatus::Complete;
453
454        // Get XDG paths
455        let queued_dir_path = xdg.task_dir(&queued_task_id, &repo_hash);
456        let completed_dir_path = xdg.task_dir(&completed_task_id, &repo_hash);
457        let tasks_json_path = xdg.tasks_file();
458        let data_dir = xdg.data_dir().to_path_buf();
459        let tasks_dir = data_dir.join("tasks");
460
461        // Create initial tasks.json with both tasks
462        let tasks_json = format!(
463            r#"[{{"id":"{}","repo_root":"{}","name":"queued-task","task_type":"feat","instructions_file":"instructions.md","agent":"claude-code","timeout":30,"status":"QUEUED","created_at":"2024-01-01T00:00:00Z","started_at":null,"completed_at":null,"branch_name":"tsk/{}","error_message":null,"source_commit":"abc123","tech_stack":"default","project":"default","copied_repo_path":"{}"}},{{"id":"{}","repo_root":"{}","name":"completed-task","task_type":"fix","instructions_file":"instructions.md","agent":"claude-code","timeout":30,"status":"COMPLETE","created_at":"2024-01-01T00:00:00Z","started_at":null,"completed_at":"2024-01-01T01:00:00Z","branch_name":"tsk/{}","error_message":null,"source_commit":"abc123","tech_stack":"default","project":"default","copied_repo_path":"{}"}}]"#,
464            queued_task_id,
465            repo_root.to_string_lossy(),
466            queued_task_id,
467            queued_dir_path.to_string_lossy(),
468            completed_task_id,
469            repo_root.to_string_lossy(),
470            completed_task_id,
471            completed_dir_path.to_string_lossy()
472        );
473
474        // Create mock file system with necessary structure
475        let git_dir = repo_root.join(".git");
476
477        let fs = Arc::new(
478            MockFileSystem::new()
479                .with_dir(&git_dir.to_string_lossy().to_string())
480                .with_dir(&data_dir.to_string_lossy().to_string())
481                .with_dir(&tasks_dir.to_string_lossy().to_string())
482                .with_dir(&queued_dir_path.to_string_lossy().to_string())
483                .with_dir(&completed_dir_path.to_string_lossy().to_string())
484                .with_file(&tasks_json_path.to_string_lossy().to_string(), &tasks_json),
485        );
486
487        let docker_client = Arc::new(FixedResponseDockerClient::default());
488        let git_ops = Arc::new(MockGitOperations::new());
489
490        let ctx = AppContext::builder()
491            .with_docker_client(docker_client)
492            .with_file_system(fs.clone())
493            .with_git_operations(git_ops)
494            .with_xdg_directories(xdg)
495            .build();
496
497        let task_manager = TaskManager::with_storage(&ctx).unwrap();
498
499        // Clean tasks
500        let result = task_manager.clean_tasks().await;
501        assert!(result.is_ok(), "Failed to clean tasks: {:?}", result);
502        let completed_count = result.unwrap();
503        assert_eq!(completed_count, 1);
504
505        // Verify directories are cleaned up
506        let queued_exists = fs.exists(&queued_dir_path).await.unwrap();
507        let completed_exists = fs.exists(&completed_dir_path).await.unwrap();
508        assert!(queued_exists, "Queued task directory should still exist");
509        assert!(
510            !completed_exists,
511            "Completed task directory should be deleted"
512        );
513    }
514
515    #[tokio::test]
516    async fn test_retry_task() {
517        use crate::context::file_system::tests::MockFileSystem;
518        use crate::context::git_operations::tests::MockGitOperations;
519        use std::env;
520
521        // Set up XDG environment variables for testing
522        let temp_dir = std::env::temp_dir();
523        let test_data_dir = temp_dir.join("tsk-test-data-retry");
524        let test_runtime_dir = temp_dir.join("tsk-test-runtime-retry");
525        unsafe {
526            env::set_var("XDG_DATA_HOME", test_data_dir.to_string_lossy().to_string());
527        }
528        unsafe {
529            env::set_var(
530                "XDG_RUNTIME_DIR",
531                test_runtime_dir.to_string_lossy().to_string(),
532            );
533        }
534
535        // Create XdgDirectories instance
536        let xdg = Arc::new(XdgDirectories::new().unwrap());
537
538        // Create a completed task to retry
539        let (_temp_repo, repo_root) = create_temp_git_repo();
540        let repo_hash = crate::storage::get_repo_hash(&repo_root);
541        let task_id = "2024-01-01-1200-generic-original-task".to_string();
542        let mut completed_task = Task::new(
543            task_id.clone(),
544            repo_root.clone(),
545            "original-task".to_string(),
546            "generic".to_string(),
547            format!(
548                "{}/tasks/{}/{}/instructions.md",
549                test_data_dir.to_string_lossy(),
550                repo_hash,
551                task_id
552            ),
553            "claude-code".to_string(),
554            45,
555            format!("tsk/{task_id}"),
556            "abc123".to_string(),
557            "default".to_string(),
558            "default".to_string(),
559            chrono::Local::now(),
560            repo_root.clone(),
561        );
562        completed_task.status = TaskStatus::Complete;
563
564        // Get XDG paths
565        let task_dir_path = xdg.task_dir(&task_id, &repo_hash);
566        let instructions_path = task_dir_path.join("instructions.md");
567        let tasks_json_path = xdg.tasks_file();
568        let data_dir = xdg.data_dir().to_path_buf();
569        let tasks_dir = data_dir.join("tasks");
570
571        // Create tasks.json with the completed task
572        let tasks_json = format!(
573            r#"[{{"id":"{}","repo_root":"{}","name":"original-task","task_type":"generic","instructions_file":"{}","agent":"claude-code","timeout":45,"status":"COMPLETE","created_at":"2024-01-01T12:00:00Z","started_at":"2024-01-01T12:30:00Z","completed_at":"2024-01-01T13:00:00Z","branch_name":"tsk/{}","error_message":null,"source_commit":"abc123","tech_stack":"default","project":"default","copied_repo_path":"{}"}}]"#,
574            task_id,
575            repo_root.to_string_lossy(),
576            instructions_path.to_string_lossy(),
577            task_id,
578            task_dir_path.to_string_lossy()
579        );
580
581        // Create mock file system with necessary structure
582        let git_dir = repo_root.join(".git");
583        let instructions_content =
584            "# Original Task Instructions\n\nThis is the original task content.";
585
586        let fs = Arc::new(
587            MockFileSystem::new()
588                .with_dir(&git_dir.to_string_lossy().to_string())
589                .with_dir(&data_dir.to_string_lossy().to_string())
590                .with_dir(&tasks_dir.to_string_lossy().to_string())
591                .with_dir(&task_dir_path.to_string_lossy().to_string())
592                .with_file(
593                    &instructions_path.to_string_lossy().to_string(),
594                    instructions_content,
595                )
596                .with_file(&tasks_json_path.to_string_lossy().to_string(), &tasks_json),
597        );
598
599        let docker_client = Arc::new(FixedResponseDockerClient::default());
600        let git_ops = Arc::new(MockGitOperations::new());
601
602        let ctx = AppContext::builder()
603            .with_docker_client(docker_client)
604            .with_file_system(fs.clone())
605            .with_git_operations(git_ops)
606            .with_xdg_directories(xdg.clone())
607            .build();
608
609        let task_manager = TaskManager::with_storage(&ctx).unwrap();
610
611        // Retry the task
612        let result = task_manager.retry_task(&task_id, false, &ctx).await;
613        assert!(result.is_ok(), "Failed to retry task: {:?}", result);
614        let new_task_id = result.unwrap();
615
616        // Verify new task ID format
617        assert!(new_task_id.contains("generic-retry-original-task"));
618
619        // Verify task was added to storage
620        let storage = get_task_storage(xdg.clone(), fs.clone());
621        let new_task = storage.get_task(&new_task_id).await.unwrap();
622        assert!(new_task.is_some());
623        let new_task = new_task.unwrap();
624        assert_eq!(new_task.name, "retry-original-task");
625        assert_eq!(new_task.task_type, "generic");
626        assert_eq!(new_task.agent, "claude-code".to_string());
627        assert_eq!(new_task.timeout, 45);
628        assert_eq!(new_task.status, TaskStatus::Queued);
629
630        // Verify instructions file was created
631        let new_task_dir = xdg.task_dir(&new_task_id, &repo_hash);
632        let new_instructions_path = new_task_dir.join("instructions.md");
633        assert!(fs.exists(&new_instructions_path).await.unwrap());
634
635        // Verify instructions content was copied
636        let copied_content = fs.read_file(&new_instructions_path).await.unwrap();
637        assert_eq!(copied_content, instructions_content);
638    }
639
640    #[tokio::test]
641    async fn test_retry_task_not_found() {
642        use crate::context::file_system::tests::MockFileSystem;
643        use crate::context::git_operations::tests::MockGitOperations;
644        use std::env;
645
646        // Set up XDG environment variables for testing
647        let temp_dir = std::env::temp_dir();
648        let test_data_dir = temp_dir.join("tsk-test-data-retry-notfound");
649        let test_runtime_dir = temp_dir.join("tsk-test-runtime-retry-notfound");
650        unsafe {
651            env::set_var("XDG_DATA_HOME", test_data_dir.to_string_lossy().to_string());
652        }
653        unsafe {
654            env::set_var(
655                "XDG_RUNTIME_DIR",
656                test_runtime_dir.to_string_lossy().to_string(),
657            );
658        }
659
660        // Create XdgDirectories instance
661        let xdg = Arc::new(XdgDirectories::new().unwrap());
662
663        // Get XDG paths
664        let (_temp_repo, repo_root) = create_temp_git_repo();
665        let tasks_json_path = xdg.tasks_file();
666        let data_dir = xdg.data_dir().to_path_buf();
667        let tasks_dir = data_dir.join("tasks");
668
669        // Create empty tasks.json
670        let tasks_json = "[]";
671
672        // Create mock file system with necessary structure
673        let git_dir = repo_root.join(".git");
674
675        let fs = Arc::new(
676            MockFileSystem::new()
677                .with_dir(&git_dir.to_string_lossy().to_string())
678                .with_dir(&data_dir.to_string_lossy().to_string())
679                .with_dir(&tasks_dir.to_string_lossy().to_string())
680                .with_file(&tasks_json_path.to_string_lossy().to_string(), tasks_json),
681        );
682
683        let docker_client = Arc::new(FixedResponseDockerClient::default());
684        let git_ops = Arc::new(MockGitOperations::new());
685
686        let ctx = AppContext::builder()
687            .with_docker_client(docker_client)
688            .with_file_system(fs.clone())
689            .with_git_operations(git_ops)
690            .with_xdg_directories(xdg)
691            .build();
692
693        let task_manager = TaskManager::with_storage(&ctx).unwrap();
694
695        // Try to retry a non-existent task
696        let result = task_manager
697            .retry_task("non-existent-task", false, &ctx)
698            .await;
699        assert!(result.is_err());
700        assert!(
701            result
702                .unwrap_err()
703                .contains("Task with ID 'non-existent-task' not found")
704        );
705    }
706
707    #[tokio::test]
708    async fn test_retry_task_queued_error() {
709        use crate::context::file_system::tests::MockFileSystem;
710        use crate::context::git_operations::tests::MockGitOperations;
711        use std::env;
712
713        // Set up XDG environment variables for testing
714        let temp_dir = std::env::temp_dir();
715        let test_data_dir = temp_dir.join("tsk-test-data-retry-queued");
716        let test_runtime_dir = temp_dir.join("tsk-test-runtime-retry-queued");
717        unsafe {
718            env::set_var("XDG_DATA_HOME", test_data_dir.to_string_lossy().to_string());
719        }
720        unsafe {
721            env::set_var(
722                "XDG_RUNTIME_DIR",
723                test_runtime_dir.to_string_lossy().to_string(),
724            );
725        }
726
727        // Create XdgDirectories instance
728        let xdg = Arc::new(XdgDirectories::new().unwrap());
729
730        // Create a queued task (should not be retryable)
731        let (_temp_repo, repo_root) = create_temp_git_repo();
732        let task_id = "2024-01-01-1200-feat-queued-task".to_string();
733
734        // Get XDG paths
735        let tasks_json_path = xdg.tasks_file();
736        let data_dir = xdg.data_dir().to_path_buf();
737        let tasks_dir = data_dir.join("tasks");
738
739        // Create tasks.json with the queued task
740        let tasks_json = format!(
741            r#"[{{"id":"{}","repo_root":"{}","name":"queued-task","task_type":"feat","instructions_file":"instructions.md","agent":"claude-code","timeout":30,"status":"QUEUED","created_at":"2024-01-01T12:00:00Z","started_at":null,"completed_at":null,"branch_name":"tsk/{}","error_message":null,"source_commit":"abc123","tech_stack":"default","project":"default","copied_repo_path":"{}"}}]"#,
742            task_id,
743            repo_root.to_string_lossy(),
744            task_id,
745            repo_root.to_string_lossy()
746        );
747
748        // Create mock file system with necessary structure
749        let git_dir = repo_root.join(".git");
750
751        let fs = Arc::new(
752            MockFileSystem::new()
753                .with_dir(&git_dir.to_string_lossy().to_string())
754                .with_dir(&data_dir.to_string_lossy().to_string())
755                .with_dir(&tasks_dir.to_string_lossy().to_string())
756                .with_file(&tasks_json_path.to_string_lossy().to_string(), &tasks_json),
757        );
758
759        let docker_client = Arc::new(FixedResponseDockerClient::default());
760        let git_ops = Arc::new(MockGitOperations::new());
761
762        let ctx = AppContext::builder()
763            .with_docker_client(docker_client)
764            .with_file_system(fs.clone())
765            .with_git_operations(git_ops)
766            .with_xdg_directories(xdg)
767            .build();
768
769        let task_manager = TaskManager::with_storage(&ctx).unwrap();
770
771        // Try to retry a queued task
772        let result = task_manager.retry_task(&task_id, false, &ctx).await;
773        assert!(result.is_err());
774        assert!(
775            result
776                .unwrap_err()
777                .contains("Cannot retry a task that hasn't been executed yet")
778        );
779    }
780
781    #[tokio::test]
782    async fn test_clean_tasks_with_id_matching() {
783        use crate::context::file_system::tests::MockFileSystem;
784        use crate::context::git_operations::tests::MockGitOperations;
785        use std::env;
786
787        // Set up XDG environment variables for testing
788        let temp_dir = std::env::temp_dir();
789        let test_data_dir = temp_dir.join("tsk-test-data3");
790        let test_runtime_dir = temp_dir.join("tsk-test-runtime3");
791        unsafe {
792            env::set_var("XDG_DATA_HOME", test_data_dir.to_string_lossy().to_string());
793        }
794        unsafe {
795            env::set_var(
796                "XDG_RUNTIME_DIR",
797                test_runtime_dir.to_string_lossy().to_string(),
798            );
799        }
800
801        // Create XdgDirectories instance
802        let xdg = Arc::new(XdgDirectories::new().unwrap());
803
804        // Create a task with a specific ID using new_with_id
805        let (_temp_repo, repo_root) = create_temp_git_repo();
806        let repo_hash = crate::storage::get_repo_hash(&repo_root);
807        let task_id = "2024-01-15-1430-feat-test-feature".to_string();
808        let mut completed_task = Task::new(
809            task_id.clone(),
810            repo_root.clone(),
811            "test-feature".to_string(),
812            "feat".to_string(),
813            "instructions.md".to_string(),
814            "claude-code".to_string(),
815            30,
816            format!("tsk/{task_id}"),
817            "abc123".to_string(),
818            "default".to_string(),
819            "default".to_string(),
820            chrono::Local::now(),
821            repo_root.clone(),
822        );
823        completed_task.status = TaskStatus::Complete;
824
825        // Get XDG paths
826        let task_dir_path = xdg.task_dir(&task_id, &repo_hash);
827        let tasks_json_path = xdg.tasks_file();
828        let data_dir = xdg.data_dir().to_path_buf();
829        let tasks_dir = data_dir.join("tasks");
830
831        // Create tasks.json with the completed task
832        let tasks_json = format!(
833            r#"[{{"id":"{}","repo_root":"{}","name":"test-feature","task_type":"feat","instructions_file":"instructions.md","agent":"claude-code","timeout":30,"status":"COMPLETE","created_at":"2024-01-15T14:30:00Z","started_at":null,"completed_at":"2024-01-15T15:00:00Z","branch_name":"tsk/{}","error_message":null,"source_commit":"abc123","tech_stack":"default","project":"default","copied_repo_path":"{}"}}]"#,
834            task_id,
835            repo_root.to_string_lossy(),
836            task_id,
837            task_dir_path.to_string_lossy()
838        );
839
840        // Create mock file system with necessary structure
841        let git_dir = repo_root.join(".git");
842
843        let fs = Arc::new(
844            MockFileSystem::new()
845                .with_dir(&git_dir.to_string_lossy().to_string())
846                .with_dir(&data_dir.to_string_lossy().to_string())
847                .with_dir(&tasks_dir.to_string_lossy().to_string())
848                .with_dir(&task_dir_path.to_string_lossy().to_string())
849                .with_file(
850                    &format!("{}/instructions.md", task_dir_path.to_string_lossy()),
851                    "Test instructions",
852                )
853                .with_file(&tasks_json_path.to_string_lossy().to_string(), &tasks_json),
854        );
855
856        let docker_client = Arc::new(FixedResponseDockerClient::default());
857        let git_ops = Arc::new(MockGitOperations::new());
858
859        let ctx = AppContext::builder()
860            .with_docker_client(docker_client)
861            .with_file_system(fs.clone())
862            .with_git_operations(git_ops)
863            .with_xdg_directories(xdg)
864            .build();
865
866        let task_manager = TaskManager::with_storage(&ctx).unwrap();
867
868        // Clean tasks
869        let result = task_manager.clean_tasks().await;
870        assert!(result.is_ok(), "Failed to clean tasks: {:?}", result);
871        let completed_count = result.unwrap();
872        assert_eq!(completed_count, 1);
873
874        // Verify directory was deleted
875        let task_dir_exists = fs.exists(&task_dir_path).await.unwrap();
876        assert!(!task_dir_exists, "Task directory should have been deleted");
877    }
878
879    #[tokio::test]
880    async fn test_with_storage_no_git_repo() {
881        use crate::context::file_system::tests::MockFileSystem;
882        use crate::context::git_operations::tests::MockGitOperations;
883        use std::env;
884
885        // Set up XDG environment variables for testing
886        let temp_dir = std::env::temp_dir();
887        let test_data_dir = temp_dir.join("tsk-test-data-no-git");
888        let test_runtime_dir = temp_dir.join("tsk-test-runtime-no-git");
889        unsafe {
890            env::set_var("XDG_DATA_HOME", test_data_dir.to_string_lossy().to_string());
891        }
892        unsafe {
893            env::set_var(
894                "XDG_RUNTIME_DIR",
895                test_runtime_dir.to_string_lossy().to_string(),
896            );
897        }
898
899        // Create XdgDirectories instance
900        let xdg = Arc::new(XdgDirectories::new().unwrap());
901
902        // Get XDG paths
903        let tasks_json_path = xdg.tasks_file();
904        let data_dir = xdg.data_dir().to_path_buf();
905        let tasks_dir = data_dir.join("tasks");
906
907        // Create empty tasks.json
908        let tasks_json = "[]";
909
910        // Create mock file system WITHOUT a .git directory
911        let fs = Arc::new(
912            MockFileSystem::new()
913                .with_dir(&data_dir.to_string_lossy().to_string())
914                .with_dir(&tasks_dir.to_string_lossy().to_string())
915                .with_file(&tasks_json_path.to_string_lossy().to_string(), tasks_json),
916        );
917
918        let docker_client = Arc::new(FixedResponseDockerClient::default());
919        let git_ops = Arc::new(MockGitOperations::new());
920
921        let ctx = AppContext::builder()
922            .with_docker_client(docker_client)
923            .with_file_system(fs.clone())
924            .with_git_operations(git_ops)
925            .with_xdg_directories(xdg)
926            .build();
927
928        // This should succeed even without being in a git repository
929        let result = TaskManager::with_storage(&ctx);
930        assert!(
931            result.is_ok(),
932            "TaskManager::with_storage should work without a git repository"
933        );
934    }
935}