use crate::db::row_mapping::row_to_task;
use crate::db::Database;
use crate::models::{Task, TaskStatus};
use crate::utils::{Result, TrackError};
use chrono::Utc;
use rusqlite::{params, OptionalExtension};
pub struct TaskService<'a> {
db: &'a Database,
}
impl<'a> TaskService<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
pub fn create_task(
&self,
name: &str,
description: Option<&str>,
ticket_id: Option<&str>,
ticket_url: Option<&str>,
) -> Result<Task> {
if name.trim().is_empty() {
return Err(TrackError::EmptyTaskName);
}
if let Some(ticket) = ticket_id {
self.validate_ticket_format(ticket)?;
if let Some(existing_id) = self.find_task_by_ticket(ticket)? {
return Err(TrackError::DuplicateTicket(ticket.to_string(), existing_id));
}
}
let now = Utc::now().to_rfc3339();
let conn = self.db.get_connection();
conn.execute(
"INSERT INTO tasks (name, description, status, ticket_id, ticket_url, is_today_task, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![name, description, TaskStatus::Active.as_str(), ticket_id, ticket_url, 0, now],
)?;
let task_id = conn.last_insert_rowid();
self.db.set_current_task_id(task_id)?;
self.get_task(task_id)
}
pub fn get_task(&self, task_id: i64) -> Result<Task> {
let conn = self.db.get_connection();
let mut stmt = conn.prepare(
"SELECT id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at FROM tasks WHERE id = ?1"
)?;
let task = stmt
.query_row(params![task_id], row_to_task)
.map_err(|_| TrackError::TaskNotFound(task_id))?;
Ok(task)
}
pub fn list_tasks(&self, include_archived: bool) -> Result<Vec<Task>> {
let conn = self.db.get_connection();
let query = if include_archived {
"SELECT id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at FROM tasks ORDER BY created_at DESC".to_string()
} else {
format!(
"SELECT id, name, description, status, ticket_id, ticket_url, alias, is_today_task, created_at FROM tasks WHERE status = '{}' ORDER BY created_at DESC",
TaskStatus::ACTIVE
)
};
let mut stmt = conn.prepare(&query)?;
let tasks = stmt
.query_map([], row_to_task)?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(tasks)
}
pub fn switch_task(&self, task_id: i64) -> Result<Task> {
let task = self.get_task(task_id)?;
if task.status == TaskStatus::Archived {
return Err(TrackError::TaskArchived(task_id));
}
self.db.set_current_task_id(task_id)?;
Ok(task)
}
pub fn archive_task(&self, task_id: i64) -> Result<()> {
let task = self.get_task(task_id)?;
if !task.status.can_transition_to(TaskStatus::Archived) {
return Err(TrackError::InvalidStatusTransition {
from: task.status.as_str().to_string(),
to: TaskStatus::Archived.as_str().to_string(),
});
}
let conn = self.db.get_connection();
conn.execute(
"UPDATE tasks SET status = ?1 WHERE id = ?2",
params![TaskStatus::Archived.as_str(), task_id],
)?;
if let Some(current_id) = self.db.get_current_task_id()? {
if current_id == task_id {
self.db.clear_current_task_id()?;
}
}
self.db.increment_rev("task")?;
Ok(())
}
pub fn link_ticket(&self, task_id: i64, ticket_id: &str, url: &str) -> Result<()> {
self.validate_ticket_format(ticket_id)?;
if let Some(existing_id) = self.find_task_by_ticket(ticket_id)? {
if existing_id != task_id {
return Err(TrackError::DuplicateTicket(
ticket_id.to_string(),
existing_id,
));
}
}
let conn = self.db.get_connection();
conn.execute(
"UPDATE tasks SET ticket_id = ?1, ticket_url = ?2 WHERE id = ?3",
params![ticket_id, url, task_id],
)?;
self.db.increment_rev("task")?;
Ok(())
}
pub fn set_description(&self, task_id: i64, description: &str) -> Result<()> {
let task = self.get_task(task_id)?;
if task.status == TaskStatus::Archived {
return Err(TrackError::TaskArchived(task_id));
}
let conn = self.db.get_connection();
conn.execute(
"UPDATE tasks SET description = ?1 WHERE id = ?2",
params![description, task_id],
)?;
self.db.increment_rev("task")?;
Ok(())
}
pub fn resolve_task_id(&self, reference: &str) -> Result<i64> {
if let Some(ticket_id) = reference.strip_prefix("t:") {
return self.find_task_by_ticket(ticket_id)?.ok_or_else(|| {
TrackError::Other(format!("No task found with ticket '{}'", ticket_id))
});
}
if let Ok(task_id) = reference.parse::<i64>() {
return Ok(task_id);
}
if let Some(task_id) = self.get_task_by_alias(reference)? {
return Ok(task_id);
}
Err(TrackError::Other(format!(
"No task found with reference '{}'",
reference
)))
}
pub fn set_alias(&self, task_id: i64, alias: &str, force: bool) -> Result<()> {
self.validate_alias(alias)?;
if let Some(existing_id) = self.get_task_by_alias(alias)? {
if existing_id != task_id {
if force {
let conn = self.db.get_connection();
conn.execute(
"UPDATE tasks SET alias = NULL WHERE id = ?1",
params![existing_id],
)?;
} else {
return Err(TrackError::Other(format!(
"Alias '{}' is already in use by task #{}. Use --force to overwrite.",
alias, existing_id
)));
}
}
}
let conn = self.db.get_connection();
conn.execute(
"UPDATE tasks SET alias = ?1 WHERE id = ?2",
params![alias, task_id],
)?;
self.db.increment_rev("task")?;
Ok(())
}
pub fn remove_alias(&self, task_id: i64) -> Result<()> {
let conn = self.db.get_connection();
conn.execute(
"UPDATE tasks SET alias = NULL WHERE id = ?1",
params![task_id],
)?;
Ok(())
}
fn get_task_by_alias(&self, alias: &str) -> Result<Option<i64>> {
let conn = self.db.get_connection();
let mut stmt = conn.prepare("SELECT id FROM tasks WHERE alias = ?1")?;
let result = stmt
.query_row(params![alias], |row| row.get(0))
.optional()?;
Ok(result)
}
fn validate_alias(&self, alias: &str) -> Result<()> {
if alias.is_empty() || alias.len() > 50 {
return Err(TrackError::Other(
"Alias must be between 1 and 50 characters".to_string(),
));
}
if !alias
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(TrackError::Other(
"Alias can only contain alphanumeric characters, hyphens, and underscores"
.to_string(),
));
}
let reserved = vec![
"new", "list", "current", "status", "switch", "archive", "sync", "todo", "scrap",
"link", "repo", "desc", "ticket", "alias", "help", "webui",
];
if reserved.contains(&alias.to_lowercase().as_str()) {
return Err(TrackError::Other(format!(
"Alias '{}' is a reserved word",
alias
)));
}
Ok(())
}
fn find_task_by_ticket(&self, ticket_id: &str) -> Result<Option<i64>> {
let conn = self.db.get_connection();
let mut stmt = conn.prepare("SELECT id FROM tasks WHERE ticket_id = ?1")?;
let result = stmt
.query_row(params![ticket_id], |row| row.get(0))
.optional()?;
Ok(result)
}
fn validate_ticket_format(&self, ticket_id: &str) -> Result<()> {
if ticket_id.contains('-') && ticket_id.chars().any(|c| c.is_ascii_uppercase()) {
return Ok(());
}
let parts: Vec<&str> = ticket_id.split('/').collect();
if parts.len() == 3 && parts[2].chars().all(|c| c.is_ascii_digit()) {
return Ok(());
}
Err(TrackError::InvalidTicketFormat(ticket_id.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn setup_db() -> Database {
Database::new_in_memory().unwrap()
}
#[test]
fn test_create_task_success() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Test Task", None, None, None).unwrap();
assert_eq!(task.name, "Test Task");
assert_eq!(task.status, TaskStatus::Active);
assert!(task.ticket_id.is_none());
}
#[test]
fn test_create_task_with_ticket() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service
.create_task(
"Test Task",
None,
Some("PROJ-123"),
Some("https://example.com"),
)
.unwrap();
assert_eq!(task.ticket_id, Some("PROJ-123".to_string()));
assert_eq!(task.ticket_url, Some("https://example.com".to_string()));
}
#[test]
fn test_create_task_empty_name() {
let db = setup_db();
let service = TaskService::new(&db);
let result = service.create_task("", None, None, None);
assert!(matches!(result, Err(TrackError::EmptyTaskName)));
}
#[test]
fn test_create_task_duplicate_ticket() {
let db = setup_db();
let service = TaskService::new(&db);
service
.create_task("Task 1", None, Some("PROJ-123"), None)
.unwrap();
let result = service.create_task("Task 2", None, Some("PROJ-123"), None);
assert!(matches!(result, Err(TrackError::DuplicateTicket(_, _))));
}
#[test]
fn test_get_task_success() {
let db = setup_db();
let service = TaskService::new(&db);
let created = service.create_task("Test Task", None, None, None).unwrap();
let retrieved = service.get_task(created.id).unwrap();
assert_eq!(retrieved.id, created.id);
assert_eq!(retrieved.name, "Test Task");
}
#[test]
fn test_get_task_not_found() {
let db = setup_db();
let service = TaskService::new(&db);
let result = service.get_task(999);
assert!(matches!(result, Err(TrackError::TaskNotFound(999))));
}
#[test]
fn test_list_tasks() {
let db = setup_db();
let service = TaskService::new(&db);
service.create_task("Task 1", None, None, None).unwrap();
service.create_task("Task 2", None, None, None).unwrap();
let tasks = service.list_tasks(false).unwrap();
assert_eq!(tasks.len(), 2);
}
#[test]
fn test_list_tasks_exclude_archived() {
let db = setup_db();
let service = TaskService::new(&db);
let task1 = service.create_task("Task 1", None, None, None).unwrap();
service.create_task("Task 2", None, None, None).unwrap();
service.archive_task(task1.id).unwrap();
let tasks = service.list_tasks(false).unwrap();
assert_eq!(tasks.len(), 1);
let all_tasks = service.list_tasks(true).unwrap();
assert_eq!(all_tasks.len(), 2);
}
#[test]
fn test_switch_task_success() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Task 1", None, None, None).unwrap();
let switched = service.switch_task(task.id).unwrap();
assert_eq!(switched.id, task.id);
let current_id = db.get_current_task_id().unwrap();
assert_eq!(current_id, Some(task.id));
}
#[test]
fn test_switch_task_archived() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Task 1", None, None, None).unwrap();
service.archive_task(task.id).unwrap();
let result = service.switch_task(task.id);
assert!(matches!(result, Err(TrackError::TaskArchived(_))));
}
#[test]
fn test_archive_task() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Task 1", None, None, None).unwrap();
service.archive_task(task.id).unwrap();
let retrieved = service.get_task(task.id).unwrap();
assert_eq!(retrieved.status, TaskStatus::Archived);
let current_id = db.get_current_task_id().unwrap();
assert!(current_id.is_none());
}
#[test]
fn test_link_ticket_success() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Task 1", None, None, None).unwrap();
service
.link_ticket(task.id, "PROJ-456", "https://example.com")
.unwrap();
let retrieved = service.get_task(task.id).unwrap();
assert_eq!(retrieved.ticket_id, Some("PROJ-456".to_string()));
}
#[test]
fn test_link_ticket_duplicate() {
let db = setup_db();
let service = TaskService::new(&db);
service
.create_task("Task 1", None, Some("PROJ-123"), None)
.unwrap();
let task2 = service.create_task("Task 2", None, None, None).unwrap();
let result = service.link_ticket(task2.id, "PROJ-123", "https://example.com");
assert!(matches!(result, Err(TrackError::DuplicateTicket(_, _))));
}
#[test]
fn test_resolve_task_id_by_id() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Task 1", None, None, None).unwrap();
let resolved = service.resolve_task_id(&task.id.to_string()).unwrap();
assert_eq!(resolved, task.id);
}
#[test]
fn test_resolve_task_id_by_ticket() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service
.create_task("Task 1", None, Some("PROJ-789"), None)
.unwrap();
let resolved = service.resolve_task_id("t:PROJ-789").unwrap();
assert_eq!(resolved, task.id);
}
#[test]
fn test_validate_ticket_format_jira() {
let db = setup_db();
let service = TaskService::new(&db);
assert!(service.validate_ticket_format("PROJ-123").is_ok());
assert!(service.validate_ticket_format("ABC-999").is_ok());
}
#[test]
fn test_validate_ticket_format_github() {
let db = setup_db();
let service = TaskService::new(&db);
assert!(service.validate_ticket_format("owner/repo/123").is_ok());
}
#[test]
fn test_validate_ticket_format_invalid() {
let db = setup_db();
let service = TaskService::new(&db);
assert!(matches!(
service.validate_ticket_format("invalid"),
Err(TrackError::InvalidTicketFormat(_))
));
}
#[test]
fn test_create_task_with_description() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service
.create_task("Test Task", Some("This is a test description"), None, None)
.unwrap();
assert_eq!(
task.description,
Some("This is a test description".to_string())
);
}
#[test]
fn test_set_description() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Test Task", None, None, None).unwrap();
assert!(task.description.is_none());
service.set_description(task.id, "New description").unwrap();
let updated = service.get_task(task.id).unwrap();
assert_eq!(updated.description, Some("New description".to_string()));
}
#[test]
fn test_set_description_archived_task() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Test Task", None, None, None).unwrap();
service.archive_task(task.id).unwrap();
let result = service.set_description(task.id, "New description");
assert!(matches!(result, Err(TrackError::TaskArchived(_))));
}
#[test]
fn test_description_persists() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service
.create_task("Test Task", Some("Original description"), None, None)
.unwrap();
let task_id = task.id;
let retrieved = service.get_task(task_id).unwrap();
assert_eq!(
retrieved.description,
Some("Original description".to_string())
);
}
#[test]
fn test_resolve_task_id_not_one() {
let db = setup_db();
let service = TaskService::new(&db);
let _ = service.create_task("Task 1", None, None, None).unwrap();
let task2 = service.create_task("Task 2", None, None, None).unwrap();
assert_ne!(task2.id, 1);
let resolved = service.resolve_task_id(&task2.id.to_string()).unwrap();
assert_eq!(resolved, task2.id);
}
#[test]
fn test_validate_ticket_format_edge_cases() {
let db = setup_db();
let service = TaskService::new(&db);
assert!(matches!(
service.validate_ticket_format("proj-123"),
Err(TrackError::InvalidTicketFormat(_))
));
assert!(matches!(
service.validate_ticket_format("PROJ123"),
Err(TrackError::InvalidTicketFormat(_))
));
}
#[test]
fn test_set_alias() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Task 1", None, None, None).unwrap();
service.set_alias(task.id, "my-alias", false).unwrap();
let updated = service.get_task(task.id).unwrap();
assert_eq!(updated.alias, Some("my-alias".to_string()));
}
#[test]
fn test_set_alias_duplicate() {
let db = setup_db();
let service = TaskService::new(&db);
let task1 = service.create_task("Task 1", None, None, None).unwrap();
let task2 = service.create_task("Task 2", None, None, None).unwrap();
service.set_alias(task1.id, "my-alias", false).unwrap();
let result = service.set_alias(task2.id, "my-alias", false);
assert!(result.is_err());
}
#[test]
fn test_set_alias_same_task_twice() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Task 1", None, None, None).unwrap();
service.set_alias(task.id, "my-alias", false).unwrap();
service.set_alias(task.id, "my-alias", false).unwrap();
let updated = service.get_task(task.id).unwrap();
assert_eq!(updated.alias, Some("my-alias".to_string()));
}
#[test]
fn test_remove_alias() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Task 1", None, None, None).unwrap();
service.set_alias(task.id, "my-alias", false).unwrap();
service.remove_alias(task.id).unwrap();
let updated = service.get_task(task.id).unwrap();
assert!(updated.alias.is_none());
}
#[test]
fn test_resolve_task_id_by_alias() {
let db = setup_db();
let service = TaskService::new(&db);
let task = service.create_task("Task 1", None, None, None).unwrap();
service.set_alias(task.id, "my-alias", false).unwrap();
let resolved = service.resolve_task_id("my-alias").unwrap();
assert_eq!(resolved, task.id);
}
#[test]
fn test_resolve_task_id_priority() {
let db = setup_db();
let service = TaskService::new(&db);
let task1 = service.create_task("Task 1", None, None, None).unwrap();
let task2 = service
.create_task("Task 2", None, Some("PROJ-123"), None)
.unwrap();
let task3 = service.create_task("Task 3", None, None, None).unwrap();
service.set_alias(task3.id, "my-alias", false).unwrap();
let resolved = service.resolve_task_id(&task1.id.to_string()).unwrap();
assert_eq!(resolved, task1.id);
let resolved = service.resolve_task_id("t:PROJ-123").unwrap();
assert_eq!(resolved, task2.id);
let resolved = service.resolve_task_id("my-alias").unwrap();
assert_eq!(resolved, task3.id);
}
#[test]
fn test_duplicate_alias() {
let db = setup_db();
let service = TaskService::new(&db);
let task1 = service.create_task("Task 1", None, None, None).unwrap();
let task2 = service.create_task("Task 2", None, None, None).unwrap();
service.set_alias(task1.id, "my-alias", false).unwrap();
let result = service.set_alias(task2.id, "my-alias", false);
assert!(result.is_err());
}
#[test]
fn test_validate_alias_valid() {
let db = setup_db();
let service = TaskService::new(&db);
assert!(service.validate_alias("valid-alias").is_ok());
assert!(service.validate_alias("valid_alias").is_ok());
assert!(service.validate_alias("ValidAlias123").is_ok());
}
#[test]
fn test_validate_alias_invalid_chars() {
let db = setup_db();
let service = TaskService::new(&db);
assert!(service.validate_alias("invalid alias").is_err()); assert!(service.validate_alias("invalid@alias").is_err()); assert!(service.validate_alias("invalid.alias").is_err()); }
#[test]
fn test_validate_alias_length() {
let db = setup_db();
let service = TaskService::new(&db);
assert!(service.validate_alias("").is_err()); assert!(service.validate_alias(&"a".repeat(51)).is_err()); assert!(service.validate_alias(&"a".repeat(50)).is_ok()); }
#[test]
fn test_validate_alias_reserved_words() {
let db = setup_db();
let service = TaskService::new(&db);
assert!(service.validate_alias("new").is_err());
assert!(service.validate_alias("list").is_err());
assert!(service.validate_alias("status").is_err());
assert!(service.validate_alias("NEW").is_err()); }
#[test]
fn test_set_alias_force_overwrite() {
let db = setup_db();
let service = TaskService::new(&db);
let task1 = service.create_task("Task 1", None, None, None).unwrap();
let task2 = service.create_task("Task 2", None, None, None).unwrap();
service.set_alias(task1.id, "my-alias", false).unwrap();
let updated1 = service.get_task(task1.id).unwrap();
assert_eq!(updated1.alias, Some("my-alias".to_string()));
service.set_alias(task2.id, "my-alias", true).unwrap();
let updated2 = service.get_task(task2.id).unwrap();
assert_eq!(updated2.alias, Some("my-alias".to_string()));
let updated1_after = service.get_task(task1.id).unwrap();
assert!(updated1_after.alias.is_none());
}
}