1use crate::db::row_mapping::row_to_task;
2use crate::db::Database;
3use crate::models::{Task, TaskStatus};
4use crate::utils::{Result, TrackError};
5use chrono::Utc;
6use rusqlite::{params, OptionalExtension};
7
8pub struct TaskService<'a> {
13 db: &'a Database,
14}
15
16impl<'a> TaskService<'a> {
17 pub fn new(db: &'a Database) -> Self {
23 Self { db }
24 }
25
26 pub fn create_task(
42 &self,
43 name: &str,
44 description: Option<&str>,
45 ticket_id: Option<&str>,
46 ticket_url: Option<&str>,
47 ) -> Result<Task> {
48 if name.trim().is_empty() {
49 return Err(TrackError::EmptyTaskName);
50 }
51
52 if let Some(ticket) = ticket_id {
54 self.validate_ticket_format(ticket)?;
55
56 if let Some(existing_id) = self.find_task_by_ticket(ticket)? {
58 return Err(TrackError::DuplicateTicket(ticket.to_string(), existing_id));
59 }
60 }
61
62 let now = Utc::now().to_rfc3339();
63 let conn = self.db.get_connection();
64
65 conn.execute(
66 "INSERT INTO tasks (name, description, status, ticket_id, ticket_url, is_today_task, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
67 params![name, description, TaskStatus::Active.as_str(), ticket_id, ticket_url, 0, now],
68 )?;
69
70 let task_id = conn.last_insert_rowid();
71
72 self.db.set_current_task_id(task_id)?;
74
75 self.get_task(task_id)
76 }
77
78 pub fn get_task(&self, task_id: i64) -> Result<Task> {
88 let conn = self.db.get_connection();
89 let mut stmt = conn.prepare(
90 "SELECT id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at FROM tasks WHERE id = ?1"
91 )?;
92
93 let task = stmt
94 .query_row(params![task_id], row_to_task)
95 .map_err(|_| TrackError::TaskNotFound(task_id))?;
96
97 Ok(task)
98 }
99
100 pub fn list_tasks(&self, include_archived: bool) -> Result<Vec<Task>> {
110 let conn = self.db.get_connection();
111 let query = if include_archived {
112 "SELECT id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at FROM tasks ORDER BY created_at DESC".to_string()
113 } else {
114 format!(
115 "SELECT id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at FROM tasks WHERE status = '{}' ORDER BY created_at DESC",
116 TaskStatus::ACTIVE
117 )
118 };
119
120 let mut stmt = conn.prepare(&query)?;
121 let tasks = stmt
122 .query_map([], row_to_task)?
123 .collect::<std::result::Result<Vec<_>, _>>()?;
124
125 Ok(tasks)
126 }
127
128 pub fn switch_task(&self, task_id: i64) -> Result<Task> {
140 let task = self.get_task(task_id)?;
141
142 if task.status == TaskStatus::Archived {
143 return Err(TrackError::TaskArchived(task_id));
144 }
145
146 self.db.set_current_task_id(task_id)?;
147 Ok(task)
148 }
149
150 pub fn archive_task(&self, task_id: i64) -> Result<()> {
158 let task = self.get_task(task_id)?;
159 if !task.status.can_transition_to(TaskStatus::Archived) {
160 return Err(TrackError::InvalidStatusTransition {
161 from: task.status.as_str().to_string(),
162 to: TaskStatus::Archived.as_str().to_string(),
163 });
164 }
165
166 let conn = self.db.get_connection();
167
168 conn.execute(
169 "UPDATE tasks SET status = ?1 WHERE id = ?2",
170 params![TaskStatus::Archived.as_str(), task_id],
171 )?;
172
173 if let Some(current_id) = self.db.get_current_task_id()? {
175 if current_id == task_id {
176 self.db.clear_current_task_id()?;
177 }
178 }
179
180 self.db.increment_rev("task")?;
181 Ok(())
182 }
183
184 pub fn link_ticket(&self, task_id: i64, ticket_id: &str, url: &str) -> Result<()> {
198 self.validate_ticket_format(ticket_id)?;
199
200 if let Some(existing_id) = self.find_task_by_ticket(ticket_id)? {
202 if existing_id != task_id {
203 return Err(TrackError::DuplicateTicket(
204 ticket_id.to_string(),
205 existing_id,
206 ));
207 }
208 }
209
210 let conn = self.db.get_connection();
211 conn.execute(
212 "UPDATE tasks SET ticket_id = ?1, ticket_url = ?2 WHERE id = ?3",
213 params![ticket_id, url, task_id],
214 )?;
215
216 self.db.increment_rev("task")?;
217 Ok(())
218 }
219
220 pub fn set_description(&self, task_id: i64, description: &str) -> Result<()> {
233 let task = self.get_task(task_id)?;
235 if task.status == TaskStatus::Archived {
236 return Err(TrackError::TaskArchived(task_id));
237 }
238
239 let conn = self.db.get_connection();
240 conn.execute(
241 "UPDATE tasks SET description = ?1 WHERE id = ?2",
242 params![description, task_id],
243 )?;
244
245 self.db.increment_rev("task")?;
246 Ok(())
247 }
248
249 pub fn resolve_task_id(&self, reference: &str) -> Result<i64> {
265 if let Some(ticket_id) = reference.strip_prefix("t:") {
267 return self.find_task_by_ticket(ticket_id)?.ok_or_else(|| {
268 TrackError::Other(format!("No task found with ticket '{}'", ticket_id))
269 });
270 }
271
272 if let Ok(task_id) = reference.parse::<i64>() {
274 return Ok(task_id);
275 }
276
277 if let Some(task_id) = self.get_task_by_alias(reference)? {
279 return Ok(task_id);
280 }
281
282 Err(TrackError::Other(format!(
283 "No task found with reference '{}'",
284 reference
285 )))
286 }
287
288 pub fn set_alias(&self, task_id: i64, alias: &str, force: bool) -> Result<()> {
303 self.validate_alias(alias)?;
304
305 if let Some(existing_id) = self.get_task_by_alias(alias)? {
307 if existing_id != task_id {
308 if force {
309 let conn = self.db.get_connection();
311 conn.execute(
312 "UPDATE tasks SET alias = NULL WHERE id = ?1",
313 params![existing_id],
314 )?;
315 } else {
316 return Err(TrackError::Other(format!(
317 "Alias '{}' is already in use by task #{}. Use --force to overwrite.",
318 alias, existing_id
319 )));
320 }
321 }
322 }
323
324 let conn = self.db.get_connection();
325 conn.execute(
326 "UPDATE tasks SET alias = ?1 WHERE id = ?2",
327 params![alias, task_id],
328 )?;
329
330 self.db.increment_rev("task")?;
331 Ok(())
332 }
333
334 pub fn remove_alias(&self, task_id: i64) -> Result<()> {
340 let conn = self.db.get_connection();
341 conn.execute(
342 "UPDATE tasks SET alias = NULL WHERE id = ?1",
343 params![task_id],
344 )?;
345 Ok(())
346 }
347
348 fn get_task_by_alias(&self, alias: &str) -> Result<Option<i64>> {
358 let conn = self.db.get_connection();
359 let mut stmt = conn.prepare("SELECT id FROM tasks WHERE alias = ?1")?;
360 let result = stmt
361 .query_row(params![alias], |row| row.get(0))
362 .optional()?;
363 Ok(result)
364 }
365
366 fn validate_alias(&self, alias: &str) -> Result<()> {
379 if alias.is_empty() || alias.len() > 50 {
381 return Err(TrackError::Other(
382 "Alias must be between 1 and 50 characters".to_string(),
383 ));
384 }
385
386 if !alias
388 .chars()
389 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
390 {
391 return Err(TrackError::Other(
392 "Alias can only contain alphanumeric characters, hyphens, and underscores"
393 .to_string(),
394 ));
395 }
396
397 let reserved = vec![
399 "new", "list", "current", "status", "switch", "archive", "sync", "todo", "scrap",
400 "link", "repo", "desc", "ticket", "alias", "help", "webui",
401 ];
402 if reserved.contains(&alias.to_lowercase().as_str()) {
403 return Err(TrackError::Other(format!(
404 "Alias '{}' is a reserved word",
405 alias
406 )));
407 }
408
409 Ok(())
410 }
411
412 fn find_task_by_ticket(&self, ticket_id: &str) -> Result<Option<i64>> {
413 let conn = self.db.get_connection();
414 let mut stmt = conn.prepare("SELECT id FROM tasks WHERE ticket_id = ?1")?;
415 let result = stmt
416 .query_row(params![ticket_id], |row| row.get(0))
417 .optional()?;
418 Ok(result)
419 }
420
421 fn validate_ticket_format(&self, ticket_id: &str) -> Result<()> {
422 if ticket_id.contains('-') && ticket_id.chars().any(|c| c.is_ascii_uppercase()) {
424 return Ok(());
425 }
426
427 let parts: Vec<&str> = ticket_id.split('/').collect();
429 if parts.len() == 3 && parts[2].chars().all(|c| c.is_ascii_digit()) {
430 return Ok(());
431 }
432
433 Err(TrackError::InvalidTicketFormat(ticket_id.to_string()))
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::db::Database;
441
442 fn setup_db() -> Database {
443 Database::new_in_memory().unwrap()
444 }
445
446 #[test]
447 fn test_create_task_success() {
448 let db = setup_db();
449 let service = TaskService::new(&db);
450
451 let task = service.create_task("Test Task", None, None, None).unwrap();
452 assert_eq!(task.name, "Test Task");
453 assert_eq!(task.status, TaskStatus::Active);
454 assert!(task.ticket_id.is_none());
455 }
456
457 #[test]
458 fn test_create_task_with_ticket() {
459 let db = setup_db();
460 let service = TaskService::new(&db);
461
462 let task = service
463 .create_task(
464 "Test Task",
465 None,
466 Some("PROJ-123"),
467 Some("https://example.com"),
468 )
469 .unwrap();
470 assert_eq!(task.ticket_id, Some("PROJ-123".to_string()));
471 assert_eq!(task.ticket_url, Some("https://example.com".to_string()));
472 }
473
474 #[test]
475 fn test_create_task_empty_name() {
476 let db = setup_db();
477 let service = TaskService::new(&db);
478
479 let result = service.create_task("", None, None, None);
480 assert!(matches!(result, Err(TrackError::EmptyTaskName)));
481 }
482
483 #[test]
484 fn test_create_task_duplicate_ticket() {
485 let db = setup_db();
486 let service = TaskService::new(&db);
487
488 service
489 .create_task("Task 1", None, Some("PROJ-123"), None)
490 .unwrap();
491 let result = service.create_task("Task 2", None, Some("PROJ-123"), None);
492 assert!(matches!(result, Err(TrackError::DuplicateTicket(_, _))));
493 }
494
495 #[test]
496 fn test_get_task_success() {
497 let db = setup_db();
498 let service = TaskService::new(&db);
499
500 let created = service.create_task("Test Task", None, None, None).unwrap();
501 let retrieved = service.get_task(created.id).unwrap();
502 assert_eq!(retrieved.id, created.id);
503 assert_eq!(retrieved.name, "Test Task");
504 }
505
506 #[test]
507 fn test_get_task_not_found() {
508 let db = setup_db();
509 let service = TaskService::new(&db);
510
511 let result = service.get_task(999);
512 assert!(matches!(result, Err(TrackError::TaskNotFound(999))));
513 }
514
515 #[test]
516 fn test_list_tasks() {
517 let db = setup_db();
518 let service = TaskService::new(&db);
519
520 service.create_task("Task 1", None, None, None).unwrap();
521 service.create_task("Task 2", None, None, None).unwrap();
522
523 let tasks = service.list_tasks(false).unwrap();
524 assert_eq!(tasks.len(), 2);
525 }
526
527 #[test]
528 fn test_list_tasks_exclude_archived() {
529 let db = setup_db();
530 let service = TaskService::new(&db);
531
532 let task1 = service.create_task("Task 1", None, None, None).unwrap();
533 service.create_task("Task 2", None, None, None).unwrap();
534 service.archive_task(task1.id).unwrap();
535
536 let tasks = service.list_tasks(false).unwrap();
537 assert_eq!(tasks.len(), 1);
538
539 let all_tasks = service.list_tasks(true).unwrap();
540 assert_eq!(all_tasks.len(), 2);
541 }
542
543 #[test]
544 fn test_switch_task_success() {
545 let db = setup_db();
546 let service = TaskService::new(&db);
547
548 let task = service.create_task("Task 1", None, None, None).unwrap();
549 let switched = service.switch_task(task.id).unwrap();
550 assert_eq!(switched.id, task.id);
551
552 let current_id = db.get_current_task_id().unwrap();
553 assert_eq!(current_id, Some(task.id));
554 }
555
556 #[test]
557 fn test_switch_task_archived() {
558 let db = setup_db();
559 let service = TaskService::new(&db);
560
561 let task = service.create_task("Task 1", None, None, None).unwrap();
562 service.archive_task(task.id).unwrap();
563
564 let result = service.switch_task(task.id);
565 assert!(matches!(result, Err(TrackError::TaskArchived(_))));
566 }
567
568 #[test]
569 fn test_archive_task() {
570 let db = setup_db();
571 let service = TaskService::new(&db);
572
573 let task = service.create_task("Task 1", None, None, None).unwrap();
574 service.archive_task(task.id).unwrap();
575
576 let retrieved = service.get_task(task.id).unwrap();
577 assert_eq!(retrieved.status, TaskStatus::Archived);
578
579 let current_id = db.get_current_task_id().unwrap();
581 assert!(current_id.is_none());
582 }
583
584 #[test]
585 fn test_link_ticket_success() {
586 let db = setup_db();
587 let service = TaskService::new(&db);
588
589 let task = service.create_task("Task 1", None, None, None).unwrap();
590 service
591 .link_ticket(task.id, "PROJ-456", "https://example.com")
592 .unwrap();
593
594 let retrieved = service.get_task(task.id).unwrap();
595 assert_eq!(retrieved.ticket_id, Some("PROJ-456".to_string()));
596 }
597
598 #[test]
599 fn test_link_ticket_duplicate() {
600 let db = setup_db();
601 let service = TaskService::new(&db);
602
603 service
604 .create_task("Task 1", None, Some("PROJ-123"), None)
605 .unwrap();
606 let task2 = service.create_task("Task 2", None, None, None).unwrap();
607
608 let result = service.link_ticket(task2.id, "PROJ-123", "https://example.com");
609 assert!(matches!(result, Err(TrackError::DuplicateTicket(_, _))));
610 }
611
612 #[test]
613 fn test_resolve_task_id_by_id() {
614 let db = setup_db();
615 let service = TaskService::new(&db);
616
617 let task = service.create_task("Task 1", None, None, None).unwrap();
618 let resolved = service.resolve_task_id(&task.id.to_string()).unwrap();
619 assert_eq!(resolved, task.id);
620 }
621
622 #[test]
623 fn test_resolve_task_id_by_ticket() {
624 let db = setup_db();
625 let service = TaskService::new(&db);
626
627 let task = service
628 .create_task("Task 1", None, Some("PROJ-789"), None)
629 .unwrap();
630 let resolved = service.resolve_task_id("t:PROJ-789").unwrap();
631 assert_eq!(resolved, task.id);
632 }
633
634 #[test]
635 fn test_validate_ticket_format_jira() {
636 let db = setup_db();
637 let service = TaskService::new(&db);
638
639 assert!(service.validate_ticket_format("PROJ-123").is_ok());
640 assert!(service.validate_ticket_format("ABC-999").is_ok());
641 }
642
643 #[test]
644 fn test_validate_ticket_format_github() {
645 let db = setup_db();
646 let service = TaskService::new(&db);
647
648 assert!(service.validate_ticket_format("owner/repo/123").is_ok());
649 }
650
651 #[test]
652 fn test_validate_ticket_format_invalid() {
653 let db = setup_db();
654 let service = TaskService::new(&db);
655
656 assert!(matches!(
657 service.validate_ticket_format("invalid"),
658 Err(TrackError::InvalidTicketFormat(_))
659 ));
660 }
661
662 #[test]
663 fn test_create_task_with_description() {
664 let db = setup_db();
665 let service = TaskService::new(&db);
666
667 let task = service
668 .create_task("Test Task", Some("This is a test description"), None, None)
669 .unwrap();
670 assert_eq!(
671 task.description,
672 Some("This is a test description".to_string())
673 );
674 }
675
676 #[test]
677 fn test_set_description() {
678 let db = setup_db();
679 let service = TaskService::new(&db);
680
681 let task = service.create_task("Test Task", None, None, None).unwrap();
682 assert!(task.description.is_none());
683
684 service.set_description(task.id, "New description").unwrap();
685 let updated = service.get_task(task.id).unwrap();
686 assert_eq!(updated.description, Some("New description".to_string()));
687 }
688
689 #[test]
690 fn test_set_description_archived_task() {
691 let db = setup_db();
692 let service = TaskService::new(&db);
693
694 let task = service.create_task("Test Task", None, None, None).unwrap();
695 service.archive_task(task.id).unwrap();
696
697 let result = service.set_description(task.id, "New description");
698 assert!(matches!(result, Err(TrackError::TaskArchived(_))));
699 }
700
701 #[test]
702 fn test_description_persists() {
703 let db = setup_db();
704 let service = TaskService::new(&db);
705
706 let task = service
707 .create_task("Test Task", Some("Original description"), None, None)
708 .unwrap();
709 let task_id = task.id;
710
711 let retrieved = service.get_task(task_id).unwrap();
713 assert_eq!(
714 retrieved.description,
715 Some("Original description".to_string())
716 );
717 }
718
719 #[test]
720 fn test_resolve_task_id_not_one() {
721 let db = setup_db();
722 let service = TaskService::new(&db);
723
724 let _ = service.create_task("Task 1", None, None, None).unwrap();
725 let task2 = service.create_task("Task 2", None, None, None).unwrap();
726
727 assert_ne!(task2.id, 1);
729
730 let resolved = service.resolve_task_id(&task2.id.to_string()).unwrap();
731 assert_eq!(resolved, task2.id);
732 }
733
734 #[test]
735 fn test_validate_ticket_format_edge_cases() {
736 let db = setup_db();
737 let service = TaskService::new(&db);
738
739 assert!(matches!(
741 service.validate_ticket_format("proj-123"),
742 Err(TrackError::InvalidTicketFormat(_))
743 ));
744
745 assert!(matches!(
747 service.validate_ticket_format("PROJ123"),
748 Err(TrackError::InvalidTicketFormat(_))
749 ));
750 }
751
752 #[test]
753 fn test_set_alias() {
754 let db = setup_db();
755 let service = TaskService::new(&db);
756
757 let task = service.create_task("Task 1", None, None, None).unwrap();
758 service.set_alias(task.id, "my-alias", false).unwrap();
759
760 let updated = service.get_task(task.id).unwrap();
761 assert_eq!(updated.alias, Some("my-alias".to_string()));
762 }
763
764 #[test]
765 fn test_set_alias_duplicate() {
766 let db = setup_db();
767 let service = TaskService::new(&db);
768
769 let task1 = service.create_task("Task 1", None, None, None).unwrap();
770 let task2 = service.create_task("Task 2", None, None, None).unwrap();
771
772 service.set_alias(task1.id, "my-alias", false).unwrap();
773
774 let result = service.set_alias(task2.id, "my-alias", false);
776 assert!(result.is_err());
777 }
778
779 #[test]
780 fn test_set_alias_same_task_twice() {
781 let db = setup_db();
782 let service = TaskService::new(&db);
783
784 let task = service.create_task("Task 1", None, None, None).unwrap();
785
786 service.set_alias(task.id, "my-alias", false).unwrap();
787
788 service.set_alias(task.id, "my-alias", false).unwrap();
790
791 let updated = service.get_task(task.id).unwrap();
792 assert_eq!(updated.alias, Some("my-alias".to_string()));
793 }
794
795 #[test]
796 fn test_remove_alias() {
797 let db = setup_db();
798 let service = TaskService::new(&db);
799
800 let task = service.create_task("Task 1", None, None, None).unwrap();
801 service.set_alias(task.id, "my-alias", false).unwrap();
802 service.remove_alias(task.id).unwrap();
803
804 let updated = service.get_task(task.id).unwrap();
805 assert!(updated.alias.is_none());
806 }
807
808 #[test]
809 fn test_resolve_task_id_by_alias() {
810 let db = setup_db();
811 let service = TaskService::new(&db);
812
813 let task = service.create_task("Task 1", None, None, None).unwrap();
814 service.set_alias(task.id, "my-alias", false).unwrap();
815
816 let resolved = service.resolve_task_id("my-alias").unwrap();
817 assert_eq!(resolved, task.id);
818 }
819
820 #[test]
821 fn test_resolve_task_id_priority() {
822 let db = setup_db();
823 let service = TaskService::new(&db);
824
825 let task1 = service.create_task("Task 1", None, None, None).unwrap();
827 let task2 = service
828 .create_task("Task 2", None, Some("PROJ-123"), None)
829 .unwrap();
830 let task3 = service.create_task("Task 3", None, None, None).unwrap();
831 service.set_alias(task3.id, "my-alias", false).unwrap();
832
833 let resolved = service.resolve_task_id(&task1.id.to_string()).unwrap();
835 assert_eq!(resolved, task1.id);
836
837 let resolved = service.resolve_task_id("t:PROJ-123").unwrap();
839 assert_eq!(resolved, task2.id);
840
841 let resolved = service.resolve_task_id("my-alias").unwrap();
843 assert_eq!(resolved, task3.id);
844 }
845
846 #[test]
847 fn test_duplicate_alias() {
848 let db = setup_db();
849 let service = TaskService::new(&db);
850
851 let task1 = service.create_task("Task 1", None, None, None).unwrap();
852 let task2 = service.create_task("Task 2", None, None, None).unwrap();
853
854 service.set_alias(task1.id, "my-alias", false).unwrap();
855 let result = service.set_alias(task2.id, "my-alias", false);
856
857 assert!(result.is_err());
858 }
859
860 #[test]
861 fn test_validate_alias_valid() {
862 let db = setup_db();
863 let service = TaskService::new(&db);
864
865 assert!(service.validate_alias("valid-alias").is_ok());
866 assert!(service.validate_alias("valid_alias").is_ok());
867 assert!(service.validate_alias("ValidAlias123").is_ok());
868 }
869
870 #[test]
871 fn test_validate_alias_invalid_chars() {
872 let db = setup_db();
873 let service = TaskService::new(&db);
874
875 assert!(service.validate_alias("invalid alias").is_err()); assert!(service.validate_alias("invalid@alias").is_err()); assert!(service.validate_alias("invalid.alias").is_err()); }
879
880 #[test]
881 fn test_validate_alias_length() {
882 let db = setup_db();
883 let service = TaskService::new(&db);
884
885 assert!(service.validate_alias("").is_err()); assert!(service.validate_alias(&"a".repeat(51)).is_err()); assert!(service.validate_alias(&"a".repeat(50)).is_ok()); }
889
890 #[test]
891 fn test_validate_alias_reserved_words() {
892 let db = setup_db();
893 let service = TaskService::new(&db);
894
895 assert!(service.validate_alias("new").is_err());
896 assert!(service.validate_alias("list").is_err());
897 assert!(service.validate_alias("status").is_err());
898 assert!(service.validate_alias("NEW").is_err()); }
900
901 #[test]
902 fn test_set_alias_force_overwrite() {
903 let db = setup_db();
904 let service = TaskService::new(&db);
905
906 let task1 = service.create_task("Task 1", None, None, None).unwrap();
907 let task2 = service.create_task("Task 2", None, None, None).unwrap();
908
909 service.set_alias(task1.id, "my-alias", false).unwrap();
911
912 let updated1 = service.get_task(task1.id).unwrap();
914 assert_eq!(updated1.alias, Some("my-alias".to_string()));
915
916 service.set_alias(task2.id, "my-alias", true).unwrap();
918
919 let updated2 = service.get_task(task2.id).unwrap();
921 assert_eq!(updated2.alias, Some("my-alias".to_string()));
922
923 let updated1_after = service.get_task(task1.id).unwrap();
925 assert!(updated1_after.alias.is_none());
926 }
927}