use crate::db::Database;
use anyhow::{Context, Result};
use chrono::Utc;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub context_id: String,
pub seq: i64,
pub title: String,
pub description: Option<String>,
pub status: String,
pub priority: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskTodo {
pub id: String,
pub task_id: String,
pub seq: i64,
pub text: String,
pub done: bool,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FullTask {
pub task: Task,
pub todos: Vec<TaskTodo>,
}
pub struct TaskManager<'a> {
db: &'a Database,
}
impl<'a> TaskManager<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
pub fn ensure_context(&self, name: &str) -> Result<String> {
let project_path = self.db.project_root.to_string_lossy().to_string();
let now = Utc::now().to_rfc3339();
let new_id = Uuid::new_v4().to_string();
self.db
.conn()
.execute(
"INSERT OR IGNORE INTO contexts (id, name, description, created_at, updated_at, project_path)
VALUES (?1, ?2, NULL, ?3, ?4, ?5)",
params![new_id, name, now, now, project_path],
)
.with_context(|| format!("Failed to ensure context '{name}'"))?;
let id: String = self
.db
.conn()
.query_row(
"SELECT id FROM contexts WHERE name = ?1",
params![name],
|row| row.get(0),
)
.with_context(|| format!("Failed to fetch context id for '{name}'"))?;
Ok(id)
}
fn lookup_context_id(&self, context_name: &str) -> Result<String> {
self.db
.conn()
.query_row(
"SELECT id FROM contexts WHERE name = ?1",
params![context_name],
|row| row.get(0),
)
.optional()
.with_context(|| format!("Failed to query context '{context_name}'"))?
.ok_or_else(|| anyhow::anyhow!("context '{}' not found", context_name))
}
pub fn add_task(
&self,
context_name: &str,
title: &str,
description: Option<&str>,
priority: &str,
) -> Result<Task> {
let context_id = self.ensure_context(context_name)?;
let now = Utc::now().to_rfc3339();
let id = Uuid::new_v4().to_string();
let seq: i64 = self
.db
.conn()
.query_row(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM tasks WHERE context_id = ?1",
params![context_id],
|row| row.get(0),
)
.with_context(|| {
format!("Failed to determine next seq for context '{context_name}'")
})?;
self.db
.conn()
.execute(
"INSERT INTO tasks (id, context_id, seq, title, description, status, priority, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7, ?8)",
params![id, context_id, seq, title, description, priority, now, now],
)
.with_context(|| format!("Failed to insert task '{title}'"))?;
Ok(Task {
id,
context_id,
seq,
title: title.to_string(),
description: description.map(str::to_string),
status: "pending".to_string(),
priority: priority.to_string(),
created_at: now.clone(),
updated_at: now,
})
}
pub fn update_task(
&self,
context_name: &str,
seq: i64,
title: Option<&str>,
description: Option<&str>,
status: Option<&str>,
priority: Option<&str>,
) -> Result<Task> {
let task = self.get_task(context_name, seq)?;
let now = Utc::now().to_rfc3339();
let new_title = title.unwrap_or(&task.title);
let new_description: Option<&str> = match description {
Some(d) => Some(d),
None => task.description.as_deref(),
};
let new_status = status.unwrap_or(&task.status);
let new_priority = priority.unwrap_or(&task.priority);
self.db
.conn()
.execute(
"UPDATE tasks SET title = ?1, description = ?2, status = ?3, priority = ?4, updated_at = ?5
WHERE id = ?6",
params![new_title, new_description, new_status, new_priority, now, task.id],
)
.with_context(|| format!("Failed to update task {seq} in context '{context_name}'"))?;
Ok(Task {
id: task.id,
context_id: task.context_id,
seq: task.seq,
title: new_title.to_string(),
description: new_description.map(str::to_string),
status: new_status.to_string(),
priority: new_priority.to_string(),
created_at: task.created_at,
updated_at: now,
})
}
pub fn get_task(&self, context_name: &str, seq: i64) -> Result<Task> {
let context_id = self.lookup_context_id(context_name)?;
let task = self
.db
.conn()
.query_row(
"SELECT id, context_id, seq, title, description,
status, priority, created_at, updated_at
FROM tasks
WHERE context_id = ?1 AND seq = ?2",
params![context_id, seq],
|row| {
Ok(Task {
id: row.get(0)?,
context_id: row.get(1)?,
seq: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
priority: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
},
)
.optional()
.with_context(|| format!("Failed to query task {seq} in context '{context_name}'"))?
.ok_or_else(|| {
anyhow::anyhow!("task {} not found in context '{}'", seq, context_name)
})?;
Ok(task)
}
pub fn get_full_task(&self, context_name: &str, seq: i64) -> Result<FullTask> {
let task = self.get_task(context_name, seq)?;
let todos = self.get_todos(&task.id)?;
Ok(FullTask { task, todos })
}
pub fn list_tasks(&self, context_name: &str) -> Result<Vec<Task>> {
let context_id = self.lookup_context_id(context_name)?;
let mut stmt = self
.db
.conn()
.prepare(
"SELECT id, context_id, seq, title, description,
status, priority, created_at, updated_at
FROM tasks
WHERE context_id = ?1
ORDER BY seq",
)
.context("Failed to prepare list_tasks statement")?;
let tasks = stmt
.query_map(params![context_id], |row| {
Ok(Task {
id: row.get(0)?,
context_id: row.get(1)?,
seq: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
priority: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(tasks)
}
pub fn add_todo(&self, context_name: &str, task_seq: i64, text: &str) -> Result<TaskTodo> {
let task = self.get_task(context_name, task_seq)?;
let now = Utc::now().to_rfc3339();
let id = Uuid::new_v4().to_string();
let seq: i64 = self
.db
.conn()
.query_row(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM task_todos WHERE task_id = ?1",
params![task.id],
|row| row.get(0),
)
.with_context(|| format!("Failed to determine next todo seq for task {task_seq}"))?;
self.db
.conn()
.execute(
"INSERT INTO task_todos (id, task_id, seq, text, done, created_at)
VALUES (?1, ?2, ?3, ?4, 0, ?5)",
params![id, task.id, seq, text, now],
)
.with_context(|| format!("Failed to insert todo for task {task_seq}"))?;
Ok(TaskTodo {
id,
task_id: task.id,
seq,
text: text.to_string(),
done: false,
created_at: now,
})
}
pub fn mark_todo_done(&self, context_name: &str, task_seq: i64, todo_seq: i64) -> Result<()> {
let task = self.get_task(context_name, task_seq)?;
let rows = self
.db
.conn()
.execute(
"UPDATE task_todos SET done = 1 WHERE task_id = ?1 AND seq = ?2",
params![task.id, todo_seq],
)
.with_context(|| {
format!("Failed to mark todo {todo_seq} done on task {task_seq} in context '{context_name}'")
})?;
if rows == 0 {
anyhow::bail!(
"todo {todo_seq} not found on task {task_seq} in context '{context_name}'"
);
}
Ok(())
}
pub fn get_todos(&self, task_id: &str) -> Result<Vec<TaskTodo>> {
let mut stmt = self
.db
.conn()
.prepare(
"SELECT id, task_id, seq, text, done, created_at
FROM task_todos
WHERE task_id = ?1
ORDER BY seq",
)
.context("Failed to prepare get_todos statement")?;
let todos = stmt
.query_map(params![task_id], |row| {
let done_int: i64 = row.get(4)?;
Ok(TaskTodo {
id: row.get(0)?,
task_id: row.get(1)?,
seq: row.get(2)?,
text: row.get(3)?,
done: done_int != 0,
created_at: row.get(5)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(todos)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::SCHEMA;
use rusqlite::Connection;
fn in_memory_db() -> Database {
let conn = Connection::open_in_memory().expect("in-memory DB");
conn.execute_batch(SCHEMA).expect("schema");
Database::from_parts(conn, std::path::PathBuf::from("/tmp/test"))
}
#[test]
fn test_add_task_seq_increments() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
let t1 = tm
.add_task("my-context", "First task", None, "medium")
.unwrap();
assert_eq!(t1.seq, 1);
let t2 = tm
.add_task("my-context", "Second task", None, "high")
.unwrap();
assert_eq!(t2.seq, 2);
}
#[test]
fn test_add_task_separate_contexts_have_independent_seqs() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
let t1 = tm.add_task("ctx-a", "Task A1", None, "low").unwrap();
assert_eq!(t1.seq, 1);
let t2 = tm.add_task("ctx-b", "Task B1", None, "low").unwrap();
assert_eq!(t2.seq, 1);
let t3 = tm.add_task("ctx-a", "Task A2", None, "low").unwrap();
assert_eq!(t3.seq, 2);
}
#[test]
fn test_update_task_mutates_only_provided_fields() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
tm.add_task("ctx", "Original title", Some("Original desc"), "medium")
.unwrap();
let updated = tm
.update_task("ctx", 1, None, None, Some("in_progress"), None)
.unwrap();
assert_eq!(updated.title, "Original title");
assert_eq!(updated.description.as_deref(), Some("Original desc"));
assert_eq!(updated.status, "in_progress");
assert_eq!(updated.priority, "medium");
}
#[test]
fn test_update_task_not_found_returns_error() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
tm.add_task("ctx", "A task", None, "medium").unwrap();
let result = tm.update_task("ctx", 99, None, None, None, None);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("task") && msg.contains("not found"),
"error should mention task not found: {msg}"
);
}
#[test]
fn test_add_todo_increments_seq() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
tm.add_task("ctx", "A task", None, "medium").unwrap();
let td1 = tm.add_todo("ctx", 1, "First step").unwrap();
assert_eq!(td1.seq, 1);
let td2 = tm.add_todo("ctx", 1, "Second step").unwrap();
assert_eq!(td2.seq, 2);
}
#[test]
fn test_mark_todo_done_flips_done() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
tm.add_task("ctx", "A task", None, "medium").unwrap();
tm.add_todo("ctx", 1, "Do something").unwrap();
let full = tm.get_full_task("ctx", 1).unwrap();
assert!(!full.todos[0].done);
tm.mark_todo_done("ctx", 1, 1).unwrap();
let full = tm.get_full_task("ctx", 1).unwrap();
assert!(full.todos[0].done);
}
#[test]
fn test_mark_todo_done_nonexistent_returns_error() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
tm.add_task("ctx", "A task", None, "medium").unwrap();
let result = tm.mark_todo_done("ctx", 1, 99);
assert!(result.is_err());
}
#[test]
fn test_list_tasks_ordered_by_seq() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
tm.add_task("ctx", "C", None, "low").unwrap();
tm.add_task("ctx", "A", None, "low").unwrap();
tm.add_task("ctx", "B", None, "low").unwrap();
let tasks = tm.list_tasks("ctx").unwrap();
assert_eq!(tasks.len(), 3);
assert_eq!(tasks[0].seq, 1);
assert_eq!(tasks[1].seq, 2);
assert_eq!(tasks[2].seq, 3);
}
#[test]
fn test_get_full_task_includes_todos() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
tm.add_task("ctx", "My task", Some("Details"), "high")
.unwrap();
tm.add_todo("ctx", 1, "Step one").unwrap();
tm.add_todo("ctx", 1, "Step two").unwrap();
let full = tm.get_full_task("ctx", 1).unwrap();
assert_eq!(full.task.title, "My task");
assert_eq!(full.todos.len(), 2);
assert_eq!(full.todos[0].text, "Step one");
assert_eq!(full.todos[1].text, "Step two");
}
#[test]
fn test_get_task_nonexistent_context_reports_context_not_found() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
let result = tm.get_task("no-such-context", 1);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("context") && msg.contains("not found"),
"error should mention context not found: {msg}"
);
}
#[test]
fn test_get_task_nonexistent_seq_reports_task_not_found() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
tm.add_task("ctx", "A task", None, "medium").unwrap();
let result = tm.get_task("ctx", 99);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("task") && msg.contains("not found"),
"error should mention task not found: {msg}"
);
}
#[test]
fn test_list_tasks_nonexistent_context_reports_context_not_found() {
let db = in_memory_db();
let tm = TaskManager::new(&db);
let result = tm.list_tasks("ghost-context");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("context") && msg.contains("not found"),
"error should mention context not found: {msg}"
);
}
}