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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub enum TaskStatus {
12 #[serde(rename = "QUEUED")]
14 Queued,
15 #[serde(rename = "RUNNING")]
17 Running,
18 #[serde(rename = "FAILED")]
20 Failed,
21 #[serde(rename = "COMPLETE")]
23 Complete,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Task {
29 pub id: String,
31 pub repo_root: PathBuf,
33 pub name: String,
35 pub task_type: String,
37 pub instructions_file: String,
39 pub agent: String,
41 pub timeout: u32,
43 pub status: TaskStatus,
45 pub created_at: DateTime<Local>,
47 pub started_at: Option<DateTime<Utc>>,
49 pub completed_at: Option<DateTime<Utc>>,
51 pub branch_name: String,
53 pub error_message: Option<String>,
55 pub source_commit: String,
57 pub tech_stack: String,
59 pub project: String,
61 pub copied_repo_path: PathBuf,
63}
64
65impl Task {
66 #[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
105pub struct TaskBuilder {
107 repo_root: Option<PathBuf>,
108 name: Option<String>,
109 task_type: Option<String>,
110 description: Option<String>,
111 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 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 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 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 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 let agent = self
228 .agent
229 .clone()
230 .unwrap_or_else(|| crate::agent::AgentProvider::default_agent().to_string());
231
232 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 if task_type != "generic" {
242 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 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 let instructions_path = if self.edit {
270 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 self.open_editor(temp_path.to_str().ok_or("Invalid path")?)?;
278 self.check_instructions_not_empty(&temp_path, ctx).await?;
279
280 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 let dest_path = task_dir.join("instructions.md");
290 self.write_instructions_content(&dest_path, &task_type, ctx)
291 .await?
292 };
293
294 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 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 let branch_name = format!("tsk/{task_dir_name}");
345
346 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 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 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 let content = if task_type != "generic" {
393 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 let initial_content = if task_type != "generic" {
413 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 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 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(¤t_dir.join(".tsk/tasks").to_string_lossy().to_string())
501 .with_dir(¤t_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 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 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(¤t_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(¤t_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 let instructions_path = &task.instructions_file;
607 let content = fs.read_file(Path::new(instructions_path)).await.unwrap();
608 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(¤t_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 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(¤t_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(¤t_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 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 {
697 let fs = Arc::new(
698 MockFileSystem::new()
699 .with_dir(¤t_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 {
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(¤t_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 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(¤t_dir.join(".tsk/tasks").to_string_lossy().to_string())
770 .with_dir(¤t_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 assert_eq!(
795 task.source_commit,
796 "abc123def456789012345678901234567890abcd".to_string()
797 );
798
799 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 {
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(¤t_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 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 let builder = TaskBuilder::from_existing(&existing_task);
851
852 let new_task = builder
854 .name("retry-task".to_string())
855 .build(&ctx)
856 .await
857 .unwrap();
858
859 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 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 {
876 let instructions_path = current_dir.join("missing-instructions.md");
877
878 let fs = Arc::new(
879 MockFileSystem::new()
880 .with_dir(¤t_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 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 let builder = TaskBuilder::from_existing(&existing_task);
903
904 let result = builder.name("retry-task".to_string()).build(&ctx).await;
906
907 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(¤t_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 assert!(result.is_err());
941 let err = result.unwrap_err().to_string();
942 assert!(err.contains("Failed to get current commit"));
943
944 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 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 let builder = TaskBuilder::from_existing(&existing_task);
987
988 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 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 assert!(task.id.contains("-feat-new-feature"));
1009 assert_eq!(task.task_type, "feat");
1010
1011 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 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 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 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(¤t_dir.join(".git").to_string_lossy().to_string())
1055 .with_file(
1056 ¤t_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 let copied_path = &task.copied_repo_path;
1083
1084 assert!(copied_path.to_string_lossy().contains(&task.id));
1086 assert!(copied_path.to_string_lossy().contains("repo"));
1087 }
1088}