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.find_task_by_ticket(ticket_id)?.ok_or_else(|| {
268                TrackError::Other(format!("No task found with ticket '{}'", ticket_id))
269            });
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::Other(format!(
283            "No task found with reference '{}'",
284            reference
285        )))
286    }
287
288    /// Sets an alias for a task.
289    ///
290    /// # Arguments
291    ///
292    /// * `task_id` - The ID of the task to set the alias for
293    /// * `alias` - The alias to set (must be unique and valid)
294    /// * `force` - If true, removes the alias from any existing task before setting it
295    ///
296    /// # Errors
297    ///
298    /// Returns an error if:
299    /// - The alias format is invalid
300    /// - The alias is already in use by another task (when force is false)
301    /// - The task does not exist
302    pub fn set_alias(&self, task_id: i64, alias: &str, force: bool) -> Result<()> {
303        self.validate_alias(alias)?;
304
305        // Check if alias is already in use
306        if let Some(existing_id) = self.get_task_by_alias(alias)? {
307            if existing_id != task_id {
308                if force {
309                    // Remove the alias from the existing task
310                    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    /// Removes the alias from a task.
335    ///
336    /// # Arguments
337    ///
338    /// * `task_id` - The ID of the task to remove the alias from
339    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    /// Finds a task ID by its alias.
349    ///
350    /// # Arguments
351    ///
352    /// * `alias` - The alias to search for
353    ///
354    /// # Returns
355    ///
356    /// `Some(task_id)` if a task with the alias exists, `None` otherwise.
357    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    /// Validates an alias format.
367    ///
368    /// # Arguments
369    ///
370    /// * `alias` - The alias to validate
371    ///
372    /// # Errors
373    ///
374    /// Returns an error if:
375    /// - The alias is empty or too long (max 50 characters)
376    /// - The alias contains invalid characters (only alphanumeric, hyphens, and underscores allowed)
377    /// - The alias is a reserved word
378    fn validate_alias(&self, alias: &str) -> Result<()> {
379        // Check length
380        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        // Check format: only alphanumeric, hyphens, and underscores
387        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        // Check for reserved words
398        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        // Jira format: PROJECT-123
423        if ticket_id.contains('-') && ticket_id.chars().any(|c| c.is_ascii_uppercase()) {
424            return Ok(());
425        }
426
427        // GitHub/GitLab format: owner/repo/123
428        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        // Current task should be cleared
580        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        // Retrieve again to ensure it persists
712        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        // Ensure ID is not 1
728        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        // Contains dash but no uppercase (should fail)
740        assert!(matches!(
741            service.validate_ticket_format("proj-123"),
742            Err(TrackError::InvalidTicketFormat(_))
743        ));
744
745        // Contains uppercase but no dash (should fail)
746        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        // Try to set the same alias on a different task
775        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        // Setting the same alias on the same task should succeed
789        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        // Create tasks with different reference types
826        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        // Test numeric ID (priority 1)
834        let resolved = service.resolve_task_id(&task1.id.to_string()).unwrap();
835        assert_eq!(resolved, task1.id);
836
837        // Test ticket reference (priority 2)
838        let resolved = service.resolve_task_id("t:PROJ-123").unwrap();
839        assert_eq!(resolved, task2.id);
840
841        // Test alias (priority 3)
842        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()); // space
876        assert!(service.validate_alias("invalid@alias").is_err()); // special char
877        assert!(service.validate_alias("invalid.alias").is_err()); // dot
878    }
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()); // empty
886        assert!(service.validate_alias(&"a".repeat(51)).is_err()); // too long
887        assert!(service.validate_alias(&"a".repeat(50)).is_ok()); // max length
888    }
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()); // case insensitive
899    }
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        // Set alias on task1
910        service.set_alias(task1.id, "my-alias", false).unwrap();
911
912        // Verify task1 has the alias
913        let updated1 = service.get_task(task1.id).unwrap();
914        assert_eq!(updated1.alias, Some("my-alias".to_string()));
915
916        // Try to set the same alias on task2 with force=true
917        service.set_alias(task2.id, "my-alias", true).unwrap();
918
919        // Verify task2 now has the alias
920        let updated2 = service.get_task(task2.id).unwrap();
921        assert_eq!(updated2.alias, Some("my-alias".to_string()));
922
923        // Verify task1 no longer has the alias
924        let updated1_after = service.get_task(task1.id).unwrap();
925        assert!(updated1_after.alias.is_none());
926    }
927}