Skip to main content

track/services/
task_service.rs

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
8/// Service for managing development tasks.
9///
10/// TaskService provides operations for creating, retrieving, updating, and archiving tasks.
11/// It handles task lifecycle management, ticket linking, and task switching.
12pub struct TaskService<'a> {
13    db: &'a Database,
14}
15
16impl<'a> TaskService<'a> {
17    /// Creates a new TaskService instance.
18    ///
19    /// # Arguments
20    ///
21    /// * `db` - Reference to the database connection
22    pub fn new(db: &'a Database) -> Self {
23        Self { db }
24    }
25
26    /// Creates a new task and sets it as the current task.
27    ///
28    /// # Arguments
29    ///
30    /// * `name` - Name of the task (cannot be empty)
31    /// * `description` - Optional task description
32    /// * `ticket_id` - Optional ticket ID (e.g., "PROJ-123" or "owner/repo/456")
33    /// * `ticket_url` - Optional URL to the ticket
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if:
38    /// - The task name is empty
39    /// - The ticket ID format is invalid
40    /// - A task with the same ticket ID already exists
41    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        // Validate ticket ID format if provided
53        if let Some(ticket) = ticket_id {
54            self.validate_ticket_format(ticket)?;
55
56            // Check for duplicate ticket
57            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        // Set as current task
73        self.db.set_current_task_id(task_id)?;
74
75        self.get_task(task_id)
76    }
77
78    /// Retrieves a task by its ID.
79    ///
80    /// # Arguments
81    ///
82    /// * `task_id` - The ID of the task to retrieve
83    ///
84    /// # Errors
85    ///
86    /// Returns `TrackError::TaskNotFound` if the task does not exist.
87    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    /// Lists all tasks, optionally including archived tasks.
101    ///
102    /// # Arguments
103    ///
104    /// * `include_archived` - If true, includes archived tasks in the results
105    ///
106    /// # Returns
107    ///
108    /// A vector of tasks ordered by creation date (newest first).
109    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    /// Switches to a different task, making it the current active task.
129    ///
130    /// # Arguments
131    ///
132    /// * `task_id` - The ID of the task to switch to
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if:
137    /// - The task does not exist
138    /// - The task is archived
139    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    /// Archives a task, marking it as completed or abandoned.
151    ///
152    /// If the archived task is the current task, the current task is cleared.
153    ///
154    /// # Arguments
155    ///
156    /// * `task_id` - The ID of the task to archive
157    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        // Clear current task if it's the archived one
174        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    /// Links a ticket to an existing task.
185    ///
186    /// # Arguments
187    ///
188    /// * `task_id` - The ID of the task to link the ticket to
189    /// * `ticket_id` - The ticket ID (e.g., "PROJ-123" or "owner/repo/456")
190    /// * `url` - The URL to the ticket
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if:
195    /// - The ticket ID format is invalid
196    /// - Another task is already linked to this ticket
197    pub fn link_ticket(&self, task_id: i64, ticket_id: &str, url: &str) -> Result<()> {
198        self.validate_ticket_format(ticket_id)?;
199
200        // Check for duplicate ticket (excluding current task)
201        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    /// Sets or updates the description of a task.
221    ///
222    /// # Arguments
223    ///
224    /// * `task_id` - The ID of the task to update
225    /// * `description` - The new description text
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if:
230    /// - The task does not exist
231    /// - The task is archived
232    pub fn set_description(&self, task_id: i64, description: &str) -> Result<()> {
233        // Validate task exists and is active
234        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    /// Resolves a task reference to a task ID.
250    ///
251    /// Accepts a numeric task ID, a ticket reference prefixed with "t:", or an alias.
252    ///
253    /// # Arguments
254    ///
255    /// * `reference` - Either a task ID (e.g., "1"), ticket reference (e.g., "t:PROJ-123"), or alias (e.g., "daily-work")
256    ///
257    /// # Returns
258    ///
259    /// The resolved task ID.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if the reference is invalid or no matching task is found.
264    pub fn resolve_task_id(&self, reference: &str) -> Result<i64> {
265        // Priority 1: If it starts with "t:", it's a ticket reference
266        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        // Priority 2: Try to parse as numeric task ID
273        if let Ok(task_id) = reference.parse::<i64>() {
274            return Ok(task_id);
275        }
276
277        // Priority 3: Try to find by alias
278        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    /// Sets an alias for a task.
286    ///
287    /// # Arguments
288    ///
289    /// * `task_id` - The ID of the task to set the alias for
290    /// * `alias` - The alias to set (must be unique and valid)
291    /// * `force` - If true, removes the alias from any existing task before setting it
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if:
296    /// - The alias format is invalid
297    /// - The alias is already in use by another task (when force is false)
298    /// - The task does not exist
299    pub fn set_alias(&self, task_id: i64, alias: &str, force: bool) -> Result<()> {
300        self.validate_alias(alias)?;
301
302        // Check if alias is already in use
303        if let Some(existing_id) = self.get_task_by_alias(alias)? {
304            if existing_id != task_id {
305                if force {
306                    // Remove the alias from the existing task
307                    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    /// Removes the alias from a task.
332    ///
333    /// # Arguments
334    ///
335    /// * `task_id` - The ID of the task to remove the alias from
336    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    /// Finds a task ID by its alias.
346    ///
347    /// # Arguments
348    ///
349    /// * `alias` - The alias to search for
350    ///
351    /// # Returns
352    ///
353    /// `Some(task_id)` if a task with the alias exists, `None` otherwise.
354    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    /// Validates an alias format.
364    ///
365    /// # Arguments
366    ///
367    /// * `alias` - The alias to validate
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if:
372    /// - The alias is empty or too long (max 50 characters)
373    /// - The alias contains invalid characters (only alphanumeric, hyphens, and underscores allowed)
374    /// - The alias is a reserved word
375    fn validate_alias(&self, alias: &str) -> Result<()> {
376        // Check length
377        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        // Check format: only alphanumeric, hyphens, and underscores
384        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        // Check for reserved words
395        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        // Jira format: PROJECT-123
419        if ticket_id.contains('-') && ticket_id.chars().any(|c| c.is_ascii_uppercase()) {
420            return Ok(());
421        }
422
423        // GitHub/GitLab format: owner/repo/123
424        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        // Current task should be cleared
576        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        // Retrieve again to ensure it persists
708        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        // Ensure ID is not 1
724        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        // Contains dash but no uppercase (should fail)
736        assert!(matches!(
737            service.validate_ticket_format("proj-123"),
738            Err(TrackError::InvalidTicketFormat(_))
739        ));
740
741        // Contains uppercase but no dash (should fail)
742        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        // Try to set the same alias on a different task
771        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        // Setting the same alias on the same task should succeed
785        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        // Create tasks with different reference types
822        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        // Test numeric ID (priority 1)
830        let resolved = service.resolve_task_id(&task1.id.to_string()).unwrap();
831        assert_eq!(resolved, task1.id);
832
833        // Test ticket reference (priority 2)
834        let resolved = service.resolve_task_id("t:PROJ-123").unwrap();
835        assert_eq!(resolved, task2.id);
836
837        // Test alias (priority 3)
838        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()); // space
872        assert!(service.validate_alias("invalid@alias").is_err()); // special char
873        assert!(service.validate_alias("invalid.alias").is_err()); // dot
874    }
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()); // empty
882        assert!(service.validate_alias(&"a".repeat(51)).is_err()); // too long
883        assert!(service.validate_alias(&"a".repeat(50)).is_ok()); // max length
884    }
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()); // case insensitive
895    }
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        // Set alias on task1
906        service.set_alias(task1.id, "my-alias", false).unwrap();
907
908        // Verify task1 has the alias
909        let updated1 = service.get_task(task1.id).unwrap();
910        assert_eq!(updated1.alias, Some("my-alias".to_string()));
911
912        // Try to set the same alias on task2 with force=true
913        service.set_alias(task2.id, "my-alias", true).unwrap();
914
915        // Verify task2 now has the alias
916        let updated2 = service.get_task(task2.id).unwrap();
917        assert_eq!(updated2.alias, Some("my-alias".to_string()));
918
919        // Verify task1 no longer has the alias
920        let updated1_after = service.get_task(task1.id).unwrap();
921        assert!(updated1_after.alias.is_none());
922    }
923}