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
268 .find_task_by_ticket(ticket_id)?
269 .ok_or_else(|| TrackError::TaskReferenceNotFound(format!("t:{ticket_id}")));
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::TaskReferenceNotFound(reference.to_string()))
283 }
284
285 pub fn set_alias(&self, task_id: i64, alias: &str, force: bool) -> Result<()> {
300 self.validate_alias(alias)?;
301
302 if let Some(existing_id) = self.get_task_by_alias(alias)? {
304 if existing_id != task_id {
305 if force {
306 let conn = self.db.get_connection();
308 conn.execute(
309 "UPDATE tasks SET alias = NULL WHERE id = ?1",
310 params![existing_id],
311 )?;
312 } else {
313 return Err(TrackError::AliasInUse {
314 alias: alias.to_string(),
315 task_id: existing_id,
316 });
317 }
318 }
319 }
320
321 let conn = self.db.get_connection();
322 conn.execute(
323 "UPDATE tasks SET alias = ?1 WHERE id = ?2",
324 params![alias, task_id],
325 )?;
326
327 self.db.increment_rev("task")?;
328 Ok(())
329 }
330
331 pub fn remove_alias(&self, task_id: i64) -> Result<()> {
337 let conn = self.db.get_connection();
338 conn.execute(
339 "UPDATE tasks SET alias = NULL WHERE id = ?1",
340 params![task_id],
341 )?;
342 Ok(())
343 }
344
345 fn get_task_by_alias(&self, alias: &str) -> Result<Option<i64>> {
355 let conn = self.db.get_connection();
356 let mut stmt = conn.prepare("SELECT id FROM tasks WHERE alias = ?1")?;
357 let result = stmt
358 .query_row(params![alias], |row| row.get(0))
359 .optional()?;
360 Ok(result)
361 }
362
363 fn validate_alias(&self, alias: &str) -> Result<()> {
376 if alias.is_empty() || alias.len() > 50 {
378 return Err(TrackError::InvalidAlias(
379 "Alias must be between 1 and 50 characters".to_string(),
380 ));
381 }
382
383 if !alias
385 .chars()
386 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
387 {
388 return Err(TrackError::InvalidAlias(
389 "Alias can only contain alphanumeric characters, hyphens, and underscores"
390 .to_string(),
391 ));
392 }
393
394 let reserved = vec![
396 "new", "list", "current", "status", "switch", "archive", "sync", "todo", "scrap",
397 "link", "repo", "desc", "ticket", "alias", "help", "webui",
398 ];
399 if reserved.contains(&alias.to_lowercase().as_str()) {
400 return Err(TrackError::InvalidAlias(format!(
401 "Alias '{alias}' is a reserved word"
402 )));
403 }
404
405 Ok(())
406 }
407
408 fn find_task_by_ticket(&self, ticket_id: &str) -> Result<Option<i64>> {
409 let conn = self.db.get_connection();
410 let mut stmt = conn.prepare("SELECT id FROM tasks WHERE ticket_id = ?1")?;
411 let result = stmt
412 .query_row(params![ticket_id], |row| row.get(0))
413 .optional()?;
414 Ok(result)
415 }
416
417 fn validate_ticket_format(&self, ticket_id: &str) -> Result<()> {
418 if ticket_id.contains('-') && ticket_id.chars().any(|c| c.is_ascii_uppercase()) {
420 return Ok(());
421 }
422
423 let parts: Vec<&str> = ticket_id.split('/').collect();
425 if parts.len() == 3 && parts[2].chars().all(|c| c.is_ascii_digit()) {
426 return Ok(());
427 }
428
429 Err(TrackError::InvalidTicketFormat(ticket_id.to_string()))
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::db::Database;
437
438 fn setup_db() -> Database {
439 Database::new_in_memory().unwrap()
440 }
441
442 #[test]
443 fn test_create_task_success() {
444 let db = setup_db();
445 let service = TaskService::new(&db);
446
447 let task = service.create_task("Test Task", None, None, None).unwrap();
448 assert_eq!(task.name, "Test Task");
449 assert_eq!(task.status, TaskStatus::Active);
450 assert!(task.ticket_id.is_none());
451 }
452
453 #[test]
454 fn test_create_task_with_ticket() {
455 let db = setup_db();
456 let service = TaskService::new(&db);
457
458 let task = service
459 .create_task(
460 "Test Task",
461 None,
462 Some("PROJ-123"),
463 Some("https://example.com"),
464 )
465 .unwrap();
466 assert_eq!(task.ticket_id, Some("PROJ-123".to_string()));
467 assert_eq!(task.ticket_url, Some("https://example.com".to_string()));
468 }
469
470 #[test]
471 fn test_create_task_empty_name() {
472 let db = setup_db();
473 let service = TaskService::new(&db);
474
475 let result = service.create_task("", None, None, None);
476 assert!(matches!(result, Err(TrackError::EmptyTaskName)));
477 }
478
479 #[test]
480 fn test_create_task_duplicate_ticket() {
481 let db = setup_db();
482 let service = TaskService::new(&db);
483
484 service
485 .create_task("Task 1", None, Some("PROJ-123"), None)
486 .unwrap();
487 let result = service.create_task("Task 2", None, Some("PROJ-123"), None);
488 assert!(matches!(result, Err(TrackError::DuplicateTicket(_, _))));
489 }
490
491 #[test]
492 fn test_get_task_success() {
493 let db = setup_db();
494 let service = TaskService::new(&db);
495
496 let created = service.create_task("Test Task", None, None, None).unwrap();
497 let retrieved = service.get_task(created.id).unwrap();
498 assert_eq!(retrieved.id, created.id);
499 assert_eq!(retrieved.name, "Test Task");
500 }
501
502 #[test]
503 fn test_get_task_not_found() {
504 let db = setup_db();
505 let service = TaskService::new(&db);
506
507 let result = service.get_task(999);
508 assert!(matches!(result, Err(TrackError::TaskNotFound(999))));
509 }
510
511 #[test]
512 fn test_list_tasks() {
513 let db = setup_db();
514 let service = TaskService::new(&db);
515
516 service.create_task("Task 1", None, None, None).unwrap();
517 service.create_task("Task 2", None, None, None).unwrap();
518
519 let tasks = service.list_tasks(false).unwrap();
520 assert_eq!(tasks.len(), 2);
521 }
522
523 #[test]
524 fn test_list_tasks_exclude_archived() {
525 let db = setup_db();
526 let service = TaskService::new(&db);
527
528 let task1 = service.create_task("Task 1", None, None, None).unwrap();
529 service.create_task("Task 2", None, None, None).unwrap();
530 service.archive_task(task1.id).unwrap();
531
532 let tasks = service.list_tasks(false).unwrap();
533 assert_eq!(tasks.len(), 1);
534
535 let all_tasks = service.list_tasks(true).unwrap();
536 assert_eq!(all_tasks.len(), 2);
537 }
538
539 #[test]
540 fn test_switch_task_success() {
541 let db = setup_db();
542 let service = TaskService::new(&db);
543
544 let task = service.create_task("Task 1", None, None, None).unwrap();
545 let switched = service.switch_task(task.id).unwrap();
546 assert_eq!(switched.id, task.id);
547
548 let current_id = db.get_current_task_id().unwrap();
549 assert_eq!(current_id, Some(task.id));
550 }
551
552 #[test]
553 fn test_switch_task_archived() {
554 let db = setup_db();
555 let service = TaskService::new(&db);
556
557 let task = service.create_task("Task 1", None, None, None).unwrap();
558 service.archive_task(task.id).unwrap();
559
560 let result = service.switch_task(task.id);
561 assert!(matches!(result, Err(TrackError::TaskArchived(_))));
562 }
563
564 #[test]
565 fn test_archive_task() {
566 let db = setup_db();
567 let service = TaskService::new(&db);
568
569 let task = service.create_task("Task 1", None, None, None).unwrap();
570 service.archive_task(task.id).unwrap();
571
572 let retrieved = service.get_task(task.id).unwrap();
573 assert_eq!(retrieved.status, TaskStatus::Archived);
574
575 let current_id = db.get_current_task_id().unwrap();
577 assert!(current_id.is_none());
578 }
579
580 #[test]
581 fn test_link_ticket_success() {
582 let db = setup_db();
583 let service = TaskService::new(&db);
584
585 let task = service.create_task("Task 1", None, None, None).unwrap();
586 service
587 .link_ticket(task.id, "PROJ-456", "https://example.com")
588 .unwrap();
589
590 let retrieved = service.get_task(task.id).unwrap();
591 assert_eq!(retrieved.ticket_id, Some("PROJ-456".to_string()));
592 }
593
594 #[test]
595 fn test_link_ticket_duplicate() {
596 let db = setup_db();
597 let service = TaskService::new(&db);
598
599 service
600 .create_task("Task 1", None, Some("PROJ-123"), None)
601 .unwrap();
602 let task2 = service.create_task("Task 2", None, None, None).unwrap();
603
604 let result = service.link_ticket(task2.id, "PROJ-123", "https://example.com");
605 assert!(matches!(result, Err(TrackError::DuplicateTicket(_, _))));
606 }
607
608 #[test]
609 fn test_resolve_task_id_by_id() {
610 let db = setup_db();
611 let service = TaskService::new(&db);
612
613 let task = service.create_task("Task 1", None, None, None).unwrap();
614 let resolved = service.resolve_task_id(&task.id.to_string()).unwrap();
615 assert_eq!(resolved, task.id);
616 }
617
618 #[test]
619 fn test_resolve_task_id_by_ticket() {
620 let db = setup_db();
621 let service = TaskService::new(&db);
622
623 let task = service
624 .create_task("Task 1", None, Some("PROJ-789"), None)
625 .unwrap();
626 let resolved = service.resolve_task_id("t:PROJ-789").unwrap();
627 assert_eq!(resolved, task.id);
628 }
629
630 #[test]
631 fn test_validate_ticket_format_jira() {
632 let db = setup_db();
633 let service = TaskService::new(&db);
634
635 assert!(service.validate_ticket_format("PROJ-123").is_ok());
636 assert!(service.validate_ticket_format("ABC-999").is_ok());
637 }
638
639 #[test]
640 fn test_validate_ticket_format_github() {
641 let db = setup_db();
642 let service = TaskService::new(&db);
643
644 assert!(service.validate_ticket_format("owner/repo/123").is_ok());
645 }
646
647 #[test]
648 fn test_validate_ticket_format_invalid() {
649 let db = setup_db();
650 let service = TaskService::new(&db);
651
652 assert!(matches!(
653 service.validate_ticket_format("invalid"),
654 Err(TrackError::InvalidTicketFormat(_))
655 ));
656 }
657
658 #[test]
659 fn test_create_task_with_description() {
660 let db = setup_db();
661 let service = TaskService::new(&db);
662
663 let task = service
664 .create_task("Test Task", Some("This is a test description"), None, None)
665 .unwrap();
666 assert_eq!(
667 task.description,
668 Some("This is a test description".to_string())
669 );
670 }
671
672 #[test]
673 fn test_set_description() {
674 let db = setup_db();
675 let service = TaskService::new(&db);
676
677 let task = service.create_task("Test Task", None, None, None).unwrap();
678 assert!(task.description.is_none());
679
680 service.set_description(task.id, "New description").unwrap();
681 let updated = service.get_task(task.id).unwrap();
682 assert_eq!(updated.description, Some("New description".to_string()));
683 }
684
685 #[test]
686 fn test_set_description_archived_task() {
687 let db = setup_db();
688 let service = TaskService::new(&db);
689
690 let task = service.create_task("Test Task", None, None, None).unwrap();
691 service.archive_task(task.id).unwrap();
692
693 let result = service.set_description(task.id, "New description");
694 assert!(matches!(result, Err(TrackError::TaskArchived(_))));
695 }
696
697 #[test]
698 fn test_description_persists() {
699 let db = setup_db();
700 let service = TaskService::new(&db);
701
702 let task = service
703 .create_task("Test Task", Some("Original description"), None, None)
704 .unwrap();
705 let task_id = task.id;
706
707 let retrieved = service.get_task(task_id).unwrap();
709 assert_eq!(
710 retrieved.description,
711 Some("Original description".to_string())
712 );
713 }
714
715 #[test]
716 fn test_resolve_task_id_not_one() {
717 let db = setup_db();
718 let service = TaskService::new(&db);
719
720 let _ = service.create_task("Task 1", None, None, None).unwrap();
721 let task2 = service.create_task("Task 2", None, None, None).unwrap();
722
723 assert_ne!(task2.id, 1);
725
726 let resolved = service.resolve_task_id(&task2.id.to_string()).unwrap();
727 assert_eq!(resolved, task2.id);
728 }
729
730 #[test]
731 fn test_validate_ticket_format_edge_cases() {
732 let db = setup_db();
733 let service = TaskService::new(&db);
734
735 assert!(matches!(
737 service.validate_ticket_format("proj-123"),
738 Err(TrackError::InvalidTicketFormat(_))
739 ));
740
741 assert!(matches!(
743 service.validate_ticket_format("PROJ123"),
744 Err(TrackError::InvalidTicketFormat(_))
745 ));
746 }
747
748 #[test]
749 fn test_set_alias() {
750 let db = setup_db();
751 let service = TaskService::new(&db);
752
753 let task = service.create_task("Task 1", None, None, None).unwrap();
754 service.set_alias(task.id, "my-alias", false).unwrap();
755
756 let updated = service.get_task(task.id).unwrap();
757 assert_eq!(updated.alias, Some("my-alias".to_string()));
758 }
759
760 #[test]
761 fn test_set_alias_duplicate() {
762 let db = setup_db();
763 let service = TaskService::new(&db);
764
765 let task1 = service.create_task("Task 1", None, None, None).unwrap();
766 let task2 = service.create_task("Task 2", None, None, None).unwrap();
767
768 service.set_alias(task1.id, "my-alias", false).unwrap();
769
770 let result = service.set_alias(task2.id, "my-alias", false);
772 assert!(result.is_err());
773 }
774
775 #[test]
776 fn test_set_alias_same_task_twice() {
777 let db = setup_db();
778 let service = TaskService::new(&db);
779
780 let task = service.create_task("Task 1", None, None, None).unwrap();
781
782 service.set_alias(task.id, "my-alias", false).unwrap();
783
784 service.set_alias(task.id, "my-alias", false).unwrap();
786
787 let updated = service.get_task(task.id).unwrap();
788 assert_eq!(updated.alias, Some("my-alias".to_string()));
789 }
790
791 #[test]
792 fn test_remove_alias() {
793 let db = setup_db();
794 let service = TaskService::new(&db);
795
796 let task = service.create_task("Task 1", None, None, None).unwrap();
797 service.set_alias(task.id, "my-alias", false).unwrap();
798 service.remove_alias(task.id).unwrap();
799
800 let updated = service.get_task(task.id).unwrap();
801 assert!(updated.alias.is_none());
802 }
803
804 #[test]
805 fn test_resolve_task_id_by_alias() {
806 let db = setup_db();
807 let service = TaskService::new(&db);
808
809 let task = service.create_task("Task 1", None, None, None).unwrap();
810 service.set_alias(task.id, "my-alias", false).unwrap();
811
812 let resolved = service.resolve_task_id("my-alias").unwrap();
813 assert_eq!(resolved, task.id);
814 }
815
816 #[test]
817 fn test_resolve_task_id_priority() {
818 let db = setup_db();
819 let service = TaskService::new(&db);
820
821 let task1 = service.create_task("Task 1", None, None, None).unwrap();
823 let task2 = service
824 .create_task("Task 2", None, Some("PROJ-123"), None)
825 .unwrap();
826 let task3 = service.create_task("Task 3", None, None, None).unwrap();
827 service.set_alias(task3.id, "my-alias", false).unwrap();
828
829 let resolved = service.resolve_task_id(&task1.id.to_string()).unwrap();
831 assert_eq!(resolved, task1.id);
832
833 let resolved = service.resolve_task_id("t:PROJ-123").unwrap();
835 assert_eq!(resolved, task2.id);
836
837 let resolved = service.resolve_task_id("my-alias").unwrap();
839 assert_eq!(resolved, task3.id);
840 }
841
842 #[test]
843 fn test_duplicate_alias() {
844 let db = setup_db();
845 let service = TaskService::new(&db);
846
847 let task1 = service.create_task("Task 1", None, None, None).unwrap();
848 let task2 = service.create_task("Task 2", None, None, None).unwrap();
849
850 service.set_alias(task1.id, "my-alias", false).unwrap();
851 let result = service.set_alias(task2.id, "my-alias", false);
852
853 assert!(result.is_err());
854 }
855
856 #[test]
857 fn test_validate_alias_valid() {
858 let db = setup_db();
859 let service = TaskService::new(&db);
860
861 assert!(service.validate_alias("valid-alias").is_ok());
862 assert!(service.validate_alias("valid_alias").is_ok());
863 assert!(service.validate_alias("ValidAlias123").is_ok());
864 }
865
866 #[test]
867 fn test_validate_alias_invalid_chars() {
868 let db = setup_db();
869 let service = TaskService::new(&db);
870
871 assert!(service.validate_alias("invalid alias").is_err()); assert!(service.validate_alias("invalid@alias").is_err()); assert!(service.validate_alias("invalid.alias").is_err()); }
875
876 #[test]
877 fn test_validate_alias_length() {
878 let db = setup_db();
879 let service = TaskService::new(&db);
880
881 assert!(service.validate_alias("").is_err()); assert!(service.validate_alias(&"a".repeat(51)).is_err()); assert!(service.validate_alias(&"a".repeat(50)).is_ok()); }
885
886 #[test]
887 fn test_validate_alias_reserved_words() {
888 let db = setup_db();
889 let service = TaskService::new(&db);
890
891 assert!(service.validate_alias("new").is_err());
892 assert!(service.validate_alias("list").is_err());
893 assert!(service.validate_alias("status").is_err());
894 assert!(service.validate_alias("NEW").is_err()); }
896
897 #[test]
898 fn test_set_alias_force_overwrite() {
899 let db = setup_db();
900 let service = TaskService::new(&db);
901
902 let task1 = service.create_task("Task 1", None, None, None).unwrap();
903 let task2 = service.create_task("Task 2", None, None, None).unwrap();
904
905 service.set_alias(task1.id, "my-alias", false).unwrap();
907
908 let updated1 = service.get_task(task1.id).unwrap();
910 assert_eq!(updated1.alias, Some("my-alias".to_string()));
911
912 service.set_alias(task2.id, "my-alias", true).unwrap();
914
915 let updated2 = service.get_task(task2.id).unwrap();
917 assert_eq!(updated2.alias, Some("my-alias".to_string()));
918
919 let updated1_after = service.get_task(task1.id).unwrap();
921 assert!(updated1_after.alias.is_none());
922 }
923}