tsk/
task.rs

1use crate::assets::{AssetManager, layered::LayeredAssetManager};
2use crate::context::AppContext;
3use crate::git::RepoManager;
4use chrono::{DateTime, Local, Utc};
5use serde::{Deserialize, Serialize};
6use std::error::Error;
7use std::path::{Path, PathBuf};
8
9/// Represents the execution status of a task
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub enum TaskStatus {
12    /// Task is in the queue waiting to be executed
13    #[serde(rename = "QUEUED")]
14    Queued,
15    /// Task is currently being executed
16    #[serde(rename = "RUNNING")]
17    Running,
18    /// Task execution failed
19    #[serde(rename = "FAILED")]
20    Failed,
21    /// Task completed successfully
22    #[serde(rename = "COMPLETE")]
23    Complete,
24}
25
26/// Represents a TSK task with all required fields for execution
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Task {
29    /// Unique identifier for the task (format: YYYY-MM-DD-HHMM-{task_type}-{name})
30    pub id: String,
31    /// Absolute path to the repository root where the task was created
32    pub repo_root: PathBuf,
33    /// Human-readable name for the task
34    pub name: String,
35    /// Type of task (e.g., "feat", "fix", "refactor")
36    pub task_type: String,
37    /// Path to the instructions file containing task details
38    pub instructions_file: String,
39    /// AI agent to use for task execution (e.g., "claude-code")
40    pub agent: String,
41    /// Timeout in minutes for task execution
42    pub timeout: u32,
43    /// Current status of the task
44    pub status: TaskStatus,
45    /// When the task was created
46    pub created_at: DateTime<Local>,
47    /// When the task started execution (if started)
48    pub started_at: Option<DateTime<Utc>>,
49    /// When the task completed (if completed)
50    pub completed_at: Option<DateTime<Utc>>,
51    /// Git branch name for this task (format: tsk/{task-id})
52    pub branch_name: String,
53    /// Error message if task failed
54    pub error_message: Option<String>,
55    /// Git commit SHA from which the task was created
56    pub source_commit: String,
57    /// Technology stack for Docker image selection (e.g., "rust", "python", "default")
58    pub tech_stack: String,
59    /// Project name for Docker image selection (defaults to "default")
60    pub project: String,
61    /// Path to the copied repository for this task
62    pub copied_repo_path: PathBuf,
63}
64
65impl Task {
66    /// Creates a new Task with all required fields
67    #[allow(clippy::too_many_arguments)]
68    pub fn new(
69        id: String,
70        repo_root: PathBuf,
71        name: String,
72        task_type: String,
73        instructions_file: String,
74        agent: String,
75        timeout: u32,
76        branch_name: String,
77        source_commit: String,
78        tech_stack: String,
79        project: String,
80        created_at: DateTime<Local>,
81        copied_repo_path: PathBuf,
82    ) -> Self {
83        Self {
84            id,
85            repo_root,
86            name,
87            task_type,
88            instructions_file,
89            agent,
90            timeout,
91            status: TaskStatus::Queued,
92            created_at,
93            started_at: None,
94            completed_at: None,
95            branch_name,
96            error_message: None,
97            source_commit,
98            tech_stack,
99            project,
100            copied_repo_path,
101        }
102    }
103}
104
105/// Builder for creating tasks with a fluent API
106pub struct TaskBuilder {
107    repo_root: Option<PathBuf>,
108    name: Option<String>,
109    task_type: Option<String>,
110    description: Option<String>,
111    /// Path to a file containing instructions
112    instructions_file_path: Option<PathBuf>,
113    edit: bool,
114    agent: Option<String>,
115    timeout: Option<u32>,
116    tech_stack: Option<String>,
117    project: Option<String>,
118    copied_repo_path: Option<PathBuf>,
119}
120
121impl TaskBuilder {
122    pub fn new() -> Self {
123        Self {
124            repo_root: None,
125            name: None,
126            task_type: None,
127            description: None,
128            instructions_file_path: None,
129            edit: false,
130            agent: None,
131            timeout: None,
132            tech_stack: None,
133            project: None,
134            copied_repo_path: None,
135        }
136    }
137
138    /// Creates a TaskBuilder from an existing task. This is used for retrying tasks.
139    pub fn from_existing(task: &Task) -> Self {
140        let mut builder = Self::new();
141        builder.repo_root = Some(task.repo_root.clone());
142        builder.name = Some(task.name.clone());
143        builder.task_type = Some(task.task_type.clone());
144        builder.agent = Some(task.agent.clone());
145        builder.timeout = Some(task.timeout);
146        builder.tech_stack = Some(task.tech_stack.clone());
147        builder.project = Some(task.project.clone());
148        builder.copied_repo_path = Some(task.copied_repo_path.clone());
149
150        // Copy the instructions file path
151        builder.instructions_file_path = Some(PathBuf::from(&task.instructions_file));
152
153        builder
154    }
155
156    pub fn repo_root(mut self, repo_root: PathBuf) -> Self {
157        self.repo_root = Some(repo_root);
158        self
159    }
160
161    pub fn name(mut self, name: String) -> Self {
162        self.name = Some(name);
163        self
164    }
165
166    pub fn task_type(mut self, task_type: String) -> Self {
167        self.task_type = Some(task_type);
168        self
169    }
170
171    pub fn description(mut self, description: Option<String>) -> Self {
172        self.description = description;
173        self
174    }
175
176    /// Sets the path to a file containing instructions
177    pub fn instructions_file(mut self, path: Option<PathBuf>) -> Self {
178        self.instructions_file_path = path;
179        self
180    }
181
182    pub fn edit(mut self, edit: bool) -> Self {
183        self.edit = edit;
184        self
185    }
186
187    pub fn agent(mut self, agent: Option<String>) -> Self {
188        self.agent = agent;
189        self
190    }
191
192    pub fn timeout(mut self, timeout: u32) -> Self {
193        self.timeout = Some(timeout);
194        self
195    }
196
197    pub fn tech_stack(mut self, tech_stack: Option<String>) -> Self {
198        self.tech_stack = tech_stack;
199        self
200    }
201
202    pub fn project(mut self, project: Option<String>) -> Self {
203        self.project = project;
204        self
205    }
206
207    pub async fn build(self, ctx: &AppContext) -> Result<Task, Box<dyn Error>> {
208        let repo_root = self
209            .repo_root
210            .clone()
211            .ok_or("Repository root is required")?;
212        let name = self.name.clone().ok_or("Task name is required")?;
213        let task_type = self
214            .task_type
215            .clone()
216            .unwrap_or_else(|| "generic".to_string());
217        let timeout = self.timeout.unwrap_or(30);
218
219        // Validate input
220        if self.description.is_none() && self.instructions_file_path.is_none() && !self.edit {
221            return Err(
222                "Either description or instructions file must be provided, or use edit mode".into(),
223            );
224        }
225
226        // Get agent or use default
227        let agent = self
228            .agent
229            .clone()
230            .unwrap_or_else(|| crate::agent::AgentProvider::default_agent().to_string());
231
232        // Validate agent
233        if !crate::agent::AgentProvider::is_valid_agent(&agent) {
234            let available_agents = crate::agent::AgentProvider::list_agents().join(", ");
235            return Err(
236                format!("Unknown agent '{agent}'. Available agents: {available_agents}").into(),
237            );
238        }
239
240        // Validate task type
241        if task_type != "generic" {
242            // Create asset manager for template validation
243            let asset_manager = LayeredAssetManager::new_with_standard_layers(
244                Some(&repo_root),
245                &ctx.xdg_directories(),
246            );
247            let available_templates = asset_manager.list_templates();
248            if !available_templates.contains(&task_type.to_string()) {
249                return Err(format!(
250                    "No template found for task type '{}'. Available templates: {}",
251                    task_type,
252                    available_templates.join(", ")
253                )
254                .into());
255            }
256        }
257
258        // Create task directory in centralized location
259        let now = chrono::Local::now();
260        let timestamp = now.format("%Y-%m-%d-%H%M");
261        let created_at = now;
262        let id = format!("{timestamp}-{task_type}-{name}");
263        let task_dir_name = id.clone();
264        let repo_hash = crate::storage::get_repo_hash(&repo_root);
265        let task_dir = ctx.xdg_directories().task_dir(&task_dir_name, &repo_hash);
266        ctx.file_system().create_dir(&task_dir).await?;
267
268        // Create instructions file
269        let instructions_path = if self.edit {
270            // Create temporary file in repository root for editing
271            let temp_filename = format!(".tsk-edit-{task_dir_name}-instructions.md");
272            let temp_path = repo_root.join(&temp_filename);
273            self.write_instructions_content(&temp_path, &task_type, ctx)
274                .await?;
275
276            // Open editor with the temporary file
277            self.open_editor(temp_path.to_str().ok_or("Invalid path")?)?;
278            self.check_instructions_not_empty(&temp_path, ctx).await?;
279
280            // Move the file to the task directory
281            let final_path = task_dir.join("instructions.md");
282            let content = ctx.file_system().read_file(&temp_path).await?;
283            ctx.file_system().write_file(&final_path, &content).await?;
284            ctx.file_system().remove_file(&temp_path).await?;
285
286            final_path.to_string_lossy().to_string()
287        } else {
288            // Create instructions file directly in task directory
289            let dest_path = task_dir.join("instructions.md");
290            self.write_instructions_content(&dest_path, &task_type, ctx)
291                .await?
292        };
293
294        // Capture the current commit SHA
295        let source_commit = match ctx.git_operations().get_current_commit(&repo_root).await {
296            Ok(commit) => commit,
297            Err(e) => {
298                return Err(format!("Failed to get current commit for task '{name}': {e}").into());
299            }
300        };
301
302        // Auto-detect tech_stack and project if not provided
303        let tech_stack = match self.tech_stack {
304            Some(ts) => {
305                println!("Using tech stack: {ts}");
306                ts
307            }
308            None => match ctx.repository_context().detect_tech_stack(&repo_root).await {
309                Ok(detected) => {
310                    println!("Auto-detected tech stack: {detected}");
311                    detected
312                }
313                Err(e) => {
314                    eprintln!("Warning: Failed to detect tech stack: {e}. Using default.");
315                    "default".to_string()
316                }
317            },
318        };
319
320        let project = match self.project {
321            Some(p) => {
322                println!("Using project: {p}");
323                p
324            }
325            None => {
326                match ctx
327                    .repository_context()
328                    .detect_project_name(&repo_root)
329                    .await
330                {
331                    Ok(detected) => {
332                        println!("Auto-detected project name: {detected}");
333                        detected
334                    }
335                    Err(e) => {
336                        eprintln!("Warning: Failed to detect project name: {e}. Using default.");
337                        "default".to_string()
338                    }
339                }
340            }
341        };
342
343        // Generate branch name from task ID
344        let branch_name = format!("tsk/{task_dir_name}");
345
346        // Copy the repository for the task
347        let repo_manager = RepoManager::new(
348            ctx.xdg_directories(),
349            ctx.file_system(),
350            ctx.git_operations(),
351        );
352
353        let (copied_repo_path, _) = repo_manager
354            .copy_repo(&task_dir_name, &repo_root, Some(&source_commit))
355            .await
356            .map_err(|e| format!("Failed to copy repository: {e}"))?;
357
358        // Create and return the task
359        let task = Task::new(
360            id,
361            repo_root,
362            name,
363            task_type,
364            instructions_path,
365            agent,
366            timeout,
367            branch_name,
368            source_commit,
369            tech_stack,
370            project,
371            created_at,
372            copied_repo_path,
373        );
374
375        Ok(task)
376    }
377
378    async fn write_instructions_content(
379        &self,
380        dest_path: &Path,
381        task_type: &str,
382        ctx: &AppContext,
383    ) -> Result<String, Box<dyn Error>> {
384        let fs = ctx.file_system();
385
386        if let Some(ref file_path) = self.instructions_file_path {
387            // File path provided - read and copy content
388            let content = fs.read_file(file_path).await?;
389            fs.write_file(dest_path, &content).await?;
390        } else if let Some(ref desc) = self.description {
391            // Check if a template exists for this task type
392            let content = if task_type != "generic" {
393                // Create asset manager for template retrieval
394                let asset_manager = LayeredAssetManager::new_with_standard_layers(
395                    self.repo_root.as_deref(),
396                    &ctx.xdg_directories(),
397                );
398                match asset_manager.get_template(task_type) {
399                    Ok(template_content) => template_content.replace("{{DESCRIPTION}}", desc),
400                    Err(e) => {
401                        eprintln!("Warning: Failed to read template: {e}");
402                        desc.clone()
403                    }
404                }
405            } else {
406                desc.clone()
407            };
408
409            fs.write_file(dest_path, &content).await?;
410        } else {
411            // Create empty instructions file for editing
412            let initial_content = if task_type != "generic" {
413                // Create asset manager for template retrieval
414                let asset_manager = LayeredAssetManager::new_with_standard_layers(
415                    self.repo_root.as_deref(),
416                    &ctx.xdg_directories(),
417                );
418                match asset_manager.get_template(task_type) {
419                    Ok(template_content) => template_content.replace(
420                        "{{DESCRIPTION}}",
421                        "<!-- TODO: Add your task description here -->",
422                    ),
423                    Err(_) => String::new(),
424                }
425            } else {
426                String::new()
427            };
428
429            fs.write_file(dest_path, &initial_content).await?;
430        }
431
432        println!("Created instructions file: {}", dest_path.display());
433        Ok(dest_path.to_string_lossy().to_string())
434    }
435
436    fn open_editor(&self, instructions_path: &str) -> Result<(), Box<dyn Error>> {
437        let editor = std::env::var("EDITOR").unwrap_or_else(|_| {
438            if std::env::var("VISUAL").is_ok() {
439                std::env::var("VISUAL").unwrap()
440            } else {
441                "vi".to_string()
442            }
443        });
444
445        println!("Opening instructions file in editor: {editor}");
446
447        let status = std::process::Command::new(&editor)
448            .arg(instructions_path)
449            .status()?;
450
451        if !status.success() {
452            return Err("Editor exited with non-zero status".into());
453        }
454
455        Ok(())
456    }
457
458    async fn check_instructions_not_empty(
459        &self,
460        instructions_path: &Path,
461        ctx: &AppContext,
462    ) -> Result<(), Box<dyn Error>> {
463        // Check if file is empty after editing
464        let content = ctx.file_system().read_file(instructions_path).await?;
465        if content.trim().is_empty() {
466            return Err("Instructions file is empty. Task creation cancelled.".into());
467        }
468        Ok(())
469    }
470}
471
472impl Default for TaskBuilder {
473    fn default() -> Self {
474        Self::new()
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::context::AppContext;
482    use crate::context::file_system::{FileSystemOperations, tests::MockFileSystem};
483    use crate::context::git_operations::tests::MockGitOperations;
484    use std::sync::Arc;
485    use tempfile::TempDir;
486
487    /// Helper to create a standard test context with mocked file system and git operations
488    fn create_test_context() -> (TempDir, PathBuf, AppContext) {
489        let temp_dir = TempDir::new().unwrap();
490        let current_dir = temp_dir.path().to_path_buf();
491        let xdg = crate::storage::XdgDirectories::new_with_paths(
492            temp_dir.path().join("data"),
493            temp_dir.path().join("runtime"),
494            temp_dir.path().join("config"),
495            temp_dir.path().join("cache"),
496        );
497
498        let fs = Arc::new(
499            MockFileSystem::new()
500                .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string())
501                .with_dir(&current_dir.join(".git").to_string_lossy().to_string()),
502        );
503
504        let git_ops = Arc::new(MockGitOperations::new());
505        git_ops.set_get_current_commit_result(Ok("abc123".to_string()));
506        git_ops.set_is_repo_result(Ok(true));
507        git_ops.set_get_tracked_files_result(Ok(vec![]));
508
509        let ctx = AppContext::builder()
510            .with_file_system(fs)
511            .with_git_operations(git_ops)
512            .with_xdg_directories(Arc::new(xdg))
513            .build();
514
515        (temp_dir, current_dir, ctx)
516    }
517
518    /// Helper to create a test task with default values
519    fn create_test_task(id: &str, name: &str, task_type: &str) -> Task {
520        Task::new(
521            id.to_string(),
522            PathBuf::from("/test"),
523            name.to_string(),
524            task_type.to_string(),
525            "instructions.md".to_string(),
526            "claude-code".to_string(),
527            30,
528            format!("tsk/{id}"),
529            "abc123".to_string(),
530            "default".to_string(),
531            "default".to_string(),
532            chrono::Local::now(),
533            PathBuf::from("/test/copied"),
534        )
535    }
536
537    #[tokio::test]
538    async fn test_task_builder_basic() {
539        let (_temp_dir, current_dir, ctx) = create_test_context();
540
541        let task = TaskBuilder::new()
542            .repo_root(current_dir.clone())
543            .name("test-task".to_string())
544            .task_type("generic".to_string())
545            .description(Some("Test description".to_string()))
546            .timeout(60)
547            .build(&ctx)
548            .await
549            .unwrap();
550
551        assert_eq!(task.name, "test-task");
552        assert_eq!(task.task_type, "generic");
553        assert_eq!(task.timeout, 60);
554        assert!(!task.instructions_file.is_empty());
555        assert!(task.id.contains("test-task"));
556    }
557
558    #[tokio::test]
559    async fn test_task_builder_with_template() {
560        let (temp_dir, current_dir, _) = create_test_context();
561        let template_content = "# Feature Template\n\n{{DESCRIPTION}}";
562        let template_dir = current_dir.join(".tsk/templates");
563
564        // Create a new context with template files
565        let xdg = crate::storage::XdgDirectories::new_with_paths(
566            temp_dir.path().join("data"),
567            temp_dir.path().join("runtime"),
568            temp_dir.path().join("config"),
569            temp_dir.path().join("cache"),
570        );
571
572        let fs = Arc::new(
573            MockFileSystem::new()
574                .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string())
575                .with_dir(&template_dir.to_string_lossy().to_string())
576                .with_file(
577                    &template_dir.join("feat.md").to_string_lossy().to_string(),
578                    template_content,
579                )
580                .with_dir(&current_dir.join(".git").to_string_lossy().to_string()),
581        );
582
583        let git_ops = Arc::new(MockGitOperations::new());
584        git_ops.set_get_current_commit_result(Ok("abc123".to_string()));
585        git_ops.set_is_repo_result(Ok(true));
586        git_ops.set_get_tracked_files_result(Ok(vec![]));
587
588        let ctx = AppContext::builder()
589            .with_file_system(fs.clone())
590            .with_git_operations(git_ops)
591            .with_xdg_directories(Arc::new(xdg))
592            .build();
593
594        let task = TaskBuilder::new()
595            .repo_root(current_dir.clone())
596            .name("test-feature".to_string())
597            .task_type("feat".to_string())
598            .description(Some("My feature description".to_string()))
599            .build(&ctx)
600            .await
601            .unwrap();
602
603        assert_eq!(task.task_type, "feat");
604
605        // Verify instructions file was created with template
606        let instructions_path = &task.instructions_file;
607        let content = fs.read_file(Path::new(instructions_path)).await.unwrap();
608        // Check that the template was applied and description was injected
609        assert!(content.contains("My feature description"));
610        assert!(content.contains("Feature"));
611    }
612
613    #[tokio::test]
614    async fn test_task_builder_validation_no_input() {
615        let temp_dir = TempDir::new().unwrap();
616        let current_dir = temp_dir.path().to_path_buf();
617        let fs = Arc::new(
618            MockFileSystem::new()
619                .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string()),
620        );
621
622        let ctx = AppContext::builder().with_file_system(fs).build();
623
624        let result = TaskBuilder::new()
625            .repo_root(current_dir)
626            .name("test-task".to_string())
627            .build(&ctx)
628            .await;
629
630        assert!(result.is_err());
631        let err = result.unwrap_err().to_string();
632        assert!(
633            err.contains("Either description or instructions file")
634                || err.contains("Repository root is required")
635        );
636    }
637
638    #[tokio::test]
639    async fn test_task_builder_with_instructions_file() {
640        let (temp_dir, current_dir, _) = create_test_context();
641        let instructions_content = "# Instructions for task";
642        let instructions_path = current_dir.join("test-instructions.md");
643
644        // Create a new context with instructions file
645        let xdg = crate::storage::XdgDirectories::new_with_paths(
646            temp_dir.path().join("data"),
647            temp_dir.path().join("runtime"),
648            temp_dir.path().join("config"),
649            temp_dir.path().join("cache"),
650        );
651
652        let fs = Arc::new(
653            MockFileSystem::new()
654                .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string())
655                .with_file(
656                    &instructions_path.to_string_lossy().to_string(),
657                    instructions_content,
658                )
659                .with_dir(&current_dir.join(".git").to_string_lossy().to_string()),
660        );
661
662        let git_ops = Arc::new(MockGitOperations::new());
663        git_ops.set_get_current_commit_result(Ok("abc123".to_string()));
664        git_ops.set_is_repo_result(Ok(true));
665        git_ops.set_get_tracked_files_result(Ok(vec![]));
666
667        let ctx = AppContext::builder()
668            .with_file_system(fs.clone())
669            .with_git_operations(git_ops)
670            .with_xdg_directories(Arc::new(xdg))
671            .build();
672
673        let task = TaskBuilder::new()
674            .repo_root(current_dir.clone())
675            .name("test-task".to_string())
676            .instructions_file(Some(instructions_path.clone()))
677            .build(&ctx)
678            .await
679            .unwrap();
680
681        // Verify instructions file was copied
682        let task_instructions_path = &task.instructions_file;
683        let content = fs
684            .read_file(Path::new(task_instructions_path))
685            .await
686            .unwrap();
687        assert_eq!(content, instructions_content);
688    }
689
690    #[tokio::test]
691    async fn test_task_builder_write_instructions_content() {
692        let temp_dir = TempDir::new().unwrap();
693        let current_dir = temp_dir.path().to_path_buf();
694
695        // Test 1: Basic write without template
696        {
697            let fs = Arc::new(
698                MockFileSystem::new()
699                    .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string()),
700            );
701
702            let ctx = AppContext::builder().with_file_system(fs.clone()).build();
703
704            let task_builder = TaskBuilder::new()
705                .name("test-task".to_string())
706                .task_type("generic".to_string())
707                .description(Some("Test description".to_string()));
708
709            let temp_path = Path::new(".tsk-edit-2024-01-01-1200-test-task-instructions.md");
710            let result_path = task_builder
711                .write_instructions_content(temp_path, "generic", &ctx)
712                .await
713                .unwrap();
714
715            assert_eq!(result_path, temp_path.to_string_lossy().to_string());
716            let content = fs.read_file(temp_path).await.unwrap();
717            assert!(content.contains("Test description"));
718        }
719
720        // Test 2: Write with template
721        {
722            let template_content = "# Feature Template\n\n{{DESCRIPTION}}";
723            let template_dir = current_dir.join(".tsk/templates");
724
725            let fs = Arc::new(
726                MockFileSystem::new()
727                    .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string())
728                    .with_dir(&template_dir.to_string_lossy().to_string())
729                    .with_file(
730                        &template_dir.join("feat.md").to_string_lossy().to_string(),
731                        template_content,
732                    ),
733            );
734
735            let ctx = AppContext::builder().with_file_system(fs.clone()).build();
736
737            let task_builder = TaskBuilder::new()
738                .name("test-feature".to_string())
739                .task_type("feat".to_string())
740                .description(Some("My new feature".to_string()));
741
742            let temp_path = Path::new(".tsk-edit-2024-01-01-1200-test-feature-instructions.md");
743            task_builder
744                .write_instructions_content(temp_path, "feat", &ctx)
745                .await
746                .unwrap();
747
748            let content = fs.read_file(temp_path).await.unwrap();
749            assert!(content.contains("My new feature"));
750            assert!(content.contains("Feature"));
751            assert!(!content.contains("{{DESCRIPTION}}"));
752        }
753    }
754
755    #[tokio::test]
756    async fn test_task_builder_captures_source_commit() {
757        let (temp_dir, current_dir, _) = create_test_context();
758
759        // Create context with specific commit SHA
760        let xdg = crate::storage::XdgDirectories::new_with_paths(
761            temp_dir.path().join("data"),
762            temp_dir.path().join("runtime"),
763            temp_dir.path().join("config"),
764            temp_dir.path().join("cache"),
765        );
766
767        let fs = Arc::new(
768            MockFileSystem::new()
769                .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string())
770                .with_dir(&current_dir.join(".git").to_string_lossy().to_string()),
771        );
772
773        let mock_git_ops = Arc::new(MockGitOperations::new());
774        mock_git_ops.set_get_current_commit_result(Ok(
775            "abc123def456789012345678901234567890abcd".to_string()
776        ));
777
778        let ctx = AppContext::builder()
779            .with_file_system(fs.clone())
780            .with_git_operations(mock_git_ops.clone())
781            .with_xdg_directories(Arc::new(xdg))
782            .build();
783
784        let task = TaskBuilder::new()
785            .repo_root(current_dir.clone())
786            .name("test-task".to_string())
787            .task_type("generic".to_string())
788            .description(Some("Test description".to_string()))
789            .build(&ctx)
790            .await
791            .unwrap();
792
793        // Verify the source commit was captured
794        assert_eq!(
795            task.source_commit,
796            "abc123def456789012345678901234567890abcd".to_string()
797        );
798
799        // Verify the git operation was called
800        let calls = mock_git_ops.get_get_current_commit_calls();
801        assert_eq!(calls.len(), 1);
802        assert_eq!(calls[0], current_dir.to_string_lossy().to_string());
803    }
804
805    #[tokio::test]
806    async fn test_task_builder_from_existing() {
807        let temp_dir = TempDir::new().unwrap();
808        let current_dir = temp_dir.path().to_path_buf();
809
810        // Test 1: Successful build from existing task
811        {
812            let instructions_content = "# Task Instructions\n\nOriginal instructions content";
813            let instructions_path = current_dir.join("test-instructions.md");
814
815            let fs = Arc::new(
816                MockFileSystem::new()
817                    .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string())
818                    .with_file(
819                        &instructions_path.to_string_lossy().to_string(),
820                        instructions_content,
821                    ),
822            );
823
824            let git_ops = Arc::new(MockGitOperations::new());
825            git_ops.set_get_current_commit_result(Ok("abc123".to_string()));
826
827            let ctx = AppContext::builder()
828                .with_file_system(fs.clone())
829                .with_git_operations(git_ops.clone())
830                .build();
831
832            // Create an existing task
833            let existing_task = Task::new(
834                "2024-01-01-1200-generic-existing-task".to_string(),
835                current_dir.clone(),
836                "existing-task".to_string(),
837                "generic".to_string(),
838                instructions_path.to_string_lossy().to_string(),
839                "claude-code".to_string(),
840                45,
841                "tsk/2024-01-01-1200-generic-existing-task".to_string(),
842                "abc123".to_string(),
843                "default".to_string(),
844                "default".to_string(),
845                chrono::Local::now(),
846                current_dir.clone(),
847            );
848
849            // Create a builder from the existing task
850            let builder = TaskBuilder::from_existing(&existing_task);
851
852            // Build a new task from it
853            let new_task = builder
854                .name("retry-task".to_string())
855                .build(&ctx)
856                .await
857                .unwrap();
858
859            // Verify the new task has the same properties
860            assert_eq!(new_task.name, "retry-task");
861            assert_eq!(new_task.task_type, "generic");
862            assert_eq!(new_task.agent, "claude-code".to_string());
863            assert_eq!(new_task.timeout, 45);
864            assert!(!new_task.instructions_file.is_empty());
865
866            // Verify the instructions file path was preserved and content was copied
867            let copied_content = fs
868                .read_file(Path::new(&new_task.instructions_file))
869                .await
870                .unwrap();
871            assert_eq!(copied_content, instructions_content);
872        }
873
874        // Test 2: Fail when instructions file is missing
875        {
876            let instructions_path = current_dir.join("missing-instructions.md");
877
878            let fs = Arc::new(
879                MockFileSystem::new()
880                    .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string()),
881            );
882
883            let git_ops = Arc::new(MockGitOperations::new());
884            git_ops.set_get_current_commit_result(Ok("abc123".to_string()));
885
886            let ctx = AppContext::builder()
887                .with_file_system(fs.clone())
888                .with_git_operations(git_ops.clone())
889                .build();
890
891            // Create an existing task with missing instructions file
892            let mut existing_task = create_test_task(
893                "2024-01-01-1200-generic-existing-task",
894                "existing-task",
895                "generic",
896            );
897            existing_task.instructions_file = instructions_path.to_string_lossy().to_string();
898            existing_task.repo_root = current_dir.clone();
899            existing_task.copied_repo_path = current_dir.clone();
900
901            // Create a builder from the existing task
902            let builder = TaskBuilder::from_existing(&existing_task);
903
904            // Build a new task from it - should fail because instructions file doesn't exist
905            let result = builder.name("retry-task".to_string()).build(&ctx).await;
906
907            // Verify it fails with appropriate error
908            assert!(result.is_err());
909            let err = result.unwrap_err().to_string();
910            assert!(err.contains("missing-instructions.md"));
911        }
912    }
913
914    #[tokio::test]
915    async fn test_task_builder_handles_source_commit_error() {
916        let temp_dir = TempDir::new().unwrap();
917        let current_dir = temp_dir.path().to_path_buf();
918        let fs = Arc::new(
919            MockFileSystem::new()
920                .with_dir(&current_dir.join(".tsk/tasks").to_string_lossy().to_string()),
921        );
922
923        let mock_git_ops = Arc::new(MockGitOperations::new());
924        mock_git_ops.set_get_current_commit_result(Err("Not a git repository".to_string()));
925
926        let ctx = AppContext::builder()
927            .with_file_system(fs.clone())
928            .with_git_operations(mock_git_ops.clone())
929            .build();
930
931        let result = TaskBuilder::new()
932            .repo_root(current_dir.clone())
933            .name("test-task".to_string())
934            .task_type("generic".to_string())
935            .description(Some("Test description".to_string()))
936            .build(&ctx)
937            .await;
938
939        // Task creation should fail because getting commit failed
940        assert!(result.is_err());
941        let err = result.unwrap_err().to_string();
942        assert!(err.contains("Failed to get current commit"));
943
944        // Verify the git operation was called
945        let calls = mock_git_ops.get_get_current_commit_calls();
946        assert_eq!(calls.len(), 1);
947    }
948
949    #[tokio::test]
950    async fn test_task_builder_with_docker_config() {
951        let (_temp_dir, current_dir, ctx) = create_test_context();
952
953        let task = TaskBuilder::new()
954            .repo_root(current_dir.clone())
955            .name("test-task".to_string())
956            .task_type("generic".to_string())
957            .description(Some("Test description".to_string()))
958            .tech_stack(Some("rust".to_string()))
959            .project(Some("web-api".to_string()))
960            .timeout(60)
961            .build(&ctx)
962            .await
963            .unwrap();
964
965        assert_eq!(task.name, "test-task");
966        assert_eq!(task.tech_stack, "rust".to_string());
967        assert_eq!(task.project, "web-api".to_string());
968    }
969
970    #[tokio::test]
971    async fn test_task_builder_from_existing_preserves_docker_config() {
972        let temp_dir = TempDir::new().unwrap();
973        let current_dir = temp_dir.path().to_path_buf();
974
975        // Create an existing task with Docker config
976        let mut existing_task = create_test_task(
977            "2024-01-01-1200-generic-existing-task",
978            "existing-task",
979            "generic",
980        );
981        existing_task.repo_root = current_dir.clone();
982        existing_task.tech_stack = "python".to_string();
983        existing_task.project = "ml-service".to_string();
984
985        // Create a builder from the existing task
986        let builder = TaskBuilder::from_existing(&existing_task);
987
988        // Verify Docker config is preserved
989        assert_eq!(builder.tech_stack, Some("python".to_string()));
990        assert_eq!(builder.project, Some("ml-service".to_string()));
991    }
992
993    #[tokio::test]
994    async fn test_task_id_generation_with_task_type() {
995        let (_temp_dir, current_dir, ctx) = create_test_context();
996
997        // Test with "feat" task type
998        let task = TaskBuilder::new()
999            .repo_root(current_dir.clone())
1000            .name("new-feature".to_string())
1001            .task_type("feat".to_string())
1002            .description(Some("Test feature".to_string()))
1003            .build(&ctx)
1004            .await
1005            .unwrap();
1006
1007        // Verify task ID format includes task type
1008        assert!(task.id.contains("-feat-new-feature"));
1009        assert_eq!(task.task_type, "feat");
1010
1011        // Test with "fix" task type
1012        let task2 = TaskBuilder::new()
1013            .repo_root(current_dir.clone())
1014            .name("bug-fix".to_string())
1015            .task_type("fix".to_string())
1016            .description(Some("Test fix".to_string()))
1017            .build(&ctx)
1018            .await
1019            .unwrap();
1020
1021        assert!(task2.id.contains("-fix-bug-fix"));
1022        assert_eq!(task2.task_type, "fix");
1023
1024        // Test branch name generation follows the same pattern
1025        let branch_name = format!("tsk/{}", task.id);
1026        assert!(branch_name.contains("tsk/"));
1027        assert!(branch_name.contains("-feat-new-feature"));
1028    }
1029
1030    #[test]
1031    fn test_task_new_with_id() {
1032        let task = create_test_task("2024-01-01-1200-feat-test-task", "test-task", "feat");
1033
1034        // Verify task ID is set correctly
1035        assert_eq!(task.id, "2024-01-01-1200-feat-test-task");
1036        assert_eq!(task.task_type, "feat");
1037        assert_eq!(task.name, "test-task");
1038    }
1039
1040    #[tokio::test]
1041    async fn test_task_builder_copies_repository() {
1042        let (temp_dir, current_dir, _) = create_test_context();
1043
1044        // Create context with tracked files
1045        let xdg = crate::storage::XdgDirectories::new_with_paths(
1046            temp_dir.path().join("data"),
1047            temp_dir.path().join("runtime"),
1048            temp_dir.path().join("config"),
1049            temp_dir.path().join("cache"),
1050        );
1051
1052        let fs = Arc::new(
1053            MockFileSystem::new()
1054                .with_dir(&current_dir.join(".git").to_string_lossy().to_string())
1055                .with_file(
1056                    &current_dir.join("test.txt").to_string_lossy().to_string(),
1057                    "content",
1058                ),
1059        );
1060
1061        let git_ops = Arc::new(MockGitOperations::new());
1062        git_ops.set_get_current_commit_result(Ok("abc123".to_string()));
1063        git_ops.set_is_repo_result(Ok(true));
1064        git_ops.set_get_tracked_files_result(Ok(vec![PathBuf::from("test.txt")]));
1065
1066        let ctx = AppContext::builder()
1067            .with_file_system(fs.clone())
1068            .with_git_operations(git_ops)
1069            .with_xdg_directories(Arc::new(xdg))
1070            .build();
1071
1072        let task = TaskBuilder::new()
1073            .repo_root(current_dir.clone())
1074            .name("test-task".to_string())
1075            .task_type("generic".to_string())
1076            .description(Some("Test description".to_string()))
1077            .build(&ctx)
1078            .await
1079            .unwrap();
1080
1081        // Verify the task has a copied_repo_path
1082        let copied_path = &task.copied_repo_path;
1083
1084        // Verify the copied path contains the task directory structure
1085        assert!(copied_path.to_string_lossy().contains(&task.id));
1086        assert!(copied_path.to_string_lossy().contains("repo"));
1087    }
1088}