1use chrono::{DateTime, Utc};
4use rusqlite::{Connection, OptionalExtension, Result as RusqliteResult, params};
5use serde_json;
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8use uuid::Uuid;
9
10use super::model::{
11 CreateTodo, TodoDependency, TodoError, TodoItem, TodoPriority, TodoQuery, TodoStatus,
12 TodoUpdate,
13};
14
15#[derive(Debug)]
17pub struct TodoRepository {
18 conn: Connection,
19 db_path: PathBuf,
20}
21
22impl TodoRepository {
23 pub fn new(path: &Path) -> Result<Self, TodoError> {
29 if let Some(parent) = path.parent() {
30 std::fs::create_dir_all(parent).map_err(|err| TodoError::Database(err.to_string()))?;
31 }
32 let conn = Connection::open(path)?;
33 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
34 Ok(Self {
35 conn,
36 db_path: path.to_path_buf(),
37 })
38 }
39
40 #[must_use]
42 pub fn db_path(&self) -> &Path {
43 &self.db_path
44 }
45
46 pub fn init_schema(&self) -> Result<(), TodoError> {
52 self.conn.execute_batch(
53 r#"
54 CREATE TABLE IF NOT EXISTS todo_items (
55 id TEXT PRIMARY KEY,
56 session_id TEXT NOT NULL,
57 title TEXT NOT NULL,
58 description TEXT,
59 status TEXT NOT NULL,
60 priority TEXT NOT NULL,
61 created_at TEXT NOT NULL,
62 completed_at TEXT,
63 assigned_to_turn TEXT,
64 tags_json TEXT NOT NULL DEFAULT '[]'
65 );
66
67 CREATE INDEX IF NOT EXISTS idx_todo_items_session_status
68 ON todo_items(session_id, status);
69
70 CREATE TABLE IF NOT EXISTS todo_dependencies (
71 session_id TEXT NOT NULL,
72 parent_id TEXT NOT NULL,
73 child_id TEXT NOT NULL,
74 PRIMARY KEY (session_id, parent_id, child_id)
75 );
76 "#,
77 )?;
78 Ok(())
79 }
80
81 pub fn create(&self, input: CreateTodo) -> Result<TodoItem, TodoError> {
88 if let Some(existing) = self.find_by_title(input.session_id, &input.title)? {
90 return Ok(existing);
91 }
92
93 let item = TodoItem {
94 id: Uuid::new_v4(),
95 session_id: input.session_id,
96 title: input.title,
97 description: input.description,
98 status: TodoStatus::Todo,
99 priority: input.priority,
100 created_at: Utc::now(),
101 completed_at: None,
102 assigned_to_turn: input.assigned_to_turn,
103 tags: normalize_tags(input.tags),
104 };
105 self.insert_item(&item)?;
106 Ok(item)
107 }
108
109 pub fn create_batch(&self, inputs: Vec<CreateTodo>) -> Result<Vec<TodoItem>, TodoError> {
120 let mut results = Vec::with_capacity(inputs.len());
121 for input in inputs {
122 results.push(self.create(input)?);
123 }
124 Ok(results)
125 }
126
127 pub fn get(&self, session_id: Uuid, id: Uuid) -> Result<Option<TodoItem>, TodoError> {
133 self.conn
134 .query_row(
135 r#"
136 SELECT id, session_id, title, description, status, priority, created_at,
137 completed_at, assigned_to_turn, tags_json
138 FROM todo_items
139 WHERE session_id = ?1 AND id = ?2
140 "#,
141 params![session_id.to_string(), id.to_string()],
142 map_todo_item,
143 )
144 .optional()
145 .map_err(TodoError::from)
146 }
147
148 pub fn list(&self, session_id: Uuid, query: TodoQuery) -> Result<Vec<TodoItem>, TodoError> {
154 let mut items = self.list_all(session_id)?;
155 if let Some(status) = query.status {
156 items.retain(|item| item.status == status);
157 }
158 if let Some(priority) = query.priority {
159 items.retain(|item| item.priority == priority);
160 }
161 if let Some(tag) = query.tag {
162 items.retain(|item| item.tags.iter().any(|candidate| candidate == &tag));
163 }
164 Ok(items)
165 }
166
167 pub fn update(
173 &self,
174 session_id: Uuid,
175 id: Uuid,
176 update: TodoUpdate,
177 ) -> Result<TodoItem, TodoError> {
178 let mut item = self.get(session_id, id)?.ok_or(TodoError::NotFound(id))?;
179 if let Some(title) = update.title {
180 item.title = title;
181 }
182 if let Some(description) = update.description {
183 item.description = description;
184 }
185 if let Some(priority) = update.priority {
186 item.priority = priority;
187 }
188 if let Some(assigned_to_turn) = update.assigned_to_turn {
189 item.assigned_to_turn = assigned_to_turn;
190 }
191 if let Some(tags) = update.tags {
192 item.tags = normalize_tags(tags);
193 }
194 self.replace_item(&item)?;
195 Ok(item)
196 }
197
198 pub fn update_status(
204 &self,
205 session_id: Uuid,
206 id: Uuid,
207 status: TodoStatus,
208 ) -> Result<TodoItem, TodoError> {
209 let mut item = self.get(session_id, id)?.ok_or(TodoError::NotFound(id))?;
210 item.status = status;
211 item.completed_at = if status == TodoStatus::Completed {
212 Some(Utc::now())
213 } else {
214 None
215 };
216 self.replace_item(&item)?;
217 Ok(item)
218 }
219
220 pub fn delete(&mut self, session_id: Uuid, id: Uuid) -> Result<bool, TodoError> {
226 let tx = self.conn.transaction()?;
227 tx.execute(
228 "DELETE FROM todo_dependencies WHERE session_id = ?1 AND (parent_id = ?2 OR child_id = ?2)",
229 params![session_id.to_string(), id.to_string()],
230 )?;
231 let deleted = tx.execute(
232 "DELETE FROM todo_items WHERE session_id = ?1 AND id = ?2",
233 params![session_id.to_string(), id.to_string()],
234 )?;
235 tx.commit()?;
236 Ok(deleted > 0)
237 }
238
239 pub fn add_dependency(
245 &self,
246 session_id: Uuid,
247 parent_id: Uuid,
248 child_id: Uuid,
249 ) -> Result<TodoDependency, TodoError> {
250 if parent_id == child_id {
251 return Err(TodoError::SelfDependency(parent_id));
252 }
253 self.require_item(session_id, parent_id)?;
254 self.require_item(session_id, child_id)?;
255 if self.path_exists(session_id, child_id, parent_id)? {
256 return Err(TodoError::DependencyCycle {
257 parent_id,
258 child_id,
259 });
260 }
261 self.conn.execute(
262 r#"
263 INSERT OR IGNORE INTO todo_dependencies (session_id, parent_id, child_id)
264 VALUES (?1, ?2, ?3)
265 "#,
266 params![
267 session_id.to_string(),
268 parent_id.to_string(),
269 child_id.to_string(),
270 ],
271 )?;
272 Ok(TodoDependency {
273 session_id,
274 parent_id,
275 child_id,
276 })
277 }
278
279 pub fn remove_dependency(
285 &self,
286 session_id: Uuid,
287 parent_id: Uuid,
288 child_id: Uuid,
289 ) -> Result<bool, TodoError> {
290 let deleted = self.conn.execute(
291 "DELETE FROM todo_dependencies WHERE session_id = ?1 AND parent_id = ?2 AND child_id = ?3",
292 params![
293 session_id.to_string(),
294 parent_id.to_string(),
295 child_id.to_string(),
296 ],
297 )?;
298 Ok(deleted > 0)
299 }
300
301 pub fn list_dependencies(&self, session_id: Uuid) -> Result<Vec<TodoDependency>, TodoError> {
307 let mut stmt = self.conn.prepare(
308 "SELECT session_id, parent_id, child_id FROM todo_dependencies WHERE session_id = ?1",
309 )?;
310 let deps = stmt
311 .query_map(params![session_id.to_string()], |row| {
312 Ok(TodoDependency {
313 session_id: parse_uuid_column(row.get::<_, String>(0)?, 0)?,
314 parent_id: parse_uuid_column(row.get::<_, String>(1)?, 1)?,
315 child_id: parse_uuid_column(row.get::<_, String>(2)?, 2)?,
316 })
317 })?
318 .collect::<RusqliteResult<Vec<_>>>()?;
319 Ok(deps)
320 }
321
322 fn insert_item(&self, item: &TodoItem) -> Result<(), TodoError> {
323 self.conn.execute(
324 r#"
325 INSERT INTO todo_items (
326 id, session_id, title, description, status, priority, created_at, completed_at,
327 assigned_to_turn, tags_json
328 )
329 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
330 "#,
331 params_for_item(item)?,
332 )?;
333 Ok(())
334 }
335
336 fn replace_item(&self, item: &TodoItem) -> Result<(), TodoError> {
337 self.conn.execute(
338 r#"
339 UPDATE todo_items
340 SET title = ?3,
341 description = ?4,
342 status = ?5,
343 priority = ?6,
344 created_at = ?7,
345 completed_at = ?8,
346 assigned_to_turn = ?9,
347 tags_json = ?10
348 WHERE id = ?1 AND session_id = ?2
349 "#,
350 params_for_item(item)?,
351 )?;
352 Ok(())
353 }
354
355 pub(super) fn list_all(&self, session_id: Uuid) -> Result<Vec<TodoItem>, TodoError> {
356 let mut stmt = self.conn.prepare(
357 r#"
358 SELECT id, session_id, title, description, status, priority, created_at,
359 completed_at, assigned_to_turn, tags_json
360 FROM todo_items
361 WHERE session_id = ?1
362 ORDER BY created_at ASC, id ASC
363 "#,
364 )?;
365 let items = stmt
366 .query_map(params![session_id.to_string()], map_todo_item)?
367 .collect::<RusqliteResult<Vec<_>>>()?;
368 Ok(items)
369 }
370
371 fn find_by_title(&self, session_id: Uuid, title: &str) -> Result<Option<TodoItem>, TodoError> {
375 self.conn
376 .query_row(
377 r#"
378 SELECT id, session_id, title, description, status, priority, created_at,
379 completed_at, assigned_to_turn, tags_json
380 FROM todo_items
381 WHERE session_id = ?1 AND title = ?2
382 "#,
383 params![session_id.to_string(), title],
384 map_todo_item,
385 )
386 .optional()
387 .map_err(TodoError::from)
388 }
389
390 fn require_item(&self, session_id: Uuid, id: Uuid) -> Result<(), TodoError> {
391 if self.get(session_id, id)?.is_some() {
392 Ok(())
393 } else {
394 Err(TodoError::NotFound(id))
395 }
396 }
397
398 fn path_exists(&self, session_id: Uuid, from: Uuid, to: Uuid) -> Result<bool, TodoError> {
399 let deps = self.list_dependencies(session_id)?;
400 let mut graph: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
401 for dep in deps {
402 graph.entry(dep.parent_id).or_default().push(dep.child_id);
403 }
404 let mut stack = vec![from];
405 let mut seen = HashSet::new();
406 while let Some(node) = stack.pop() {
407 if node == to {
408 return Ok(true);
409 }
410 if !seen.insert(node) {
411 continue;
412 }
413 if let Some(children) = graph.get(&node) {
414 stack.extend(children.iter().copied());
415 }
416 }
417 Ok(false)
418 }
419}
420
421fn params_for_item(item: &TodoItem) -> Result<[String; 10], TodoError> {
422 Ok([
423 item.id.to_string(),
424 item.session_id.to_string(),
425 item.title.clone(),
426 item.description.clone().unwrap_or_default(),
427 item.status.as_str().to_string(),
428 item.priority.as_str().to_string(),
429 item.created_at.to_rfc3339(),
430 item.completed_at
431 .map(|completed_at| completed_at.to_rfc3339())
432 .unwrap_or_default(),
433 item.assigned_to_turn.clone().unwrap_or_default(),
434 serde_json::to_string(&item.tags)?,
435 ])
436}
437
438fn map_todo_item(row: &rusqlite::Row<'_>) -> RusqliteResult<TodoItem> {
439 let id = parse_uuid_column(row.get::<_, String>(0)?, 0)?;
440 let session_id = parse_uuid_column(row.get::<_, String>(1)?, 1)?;
441 let created_at = parse_datetime_column(row.get::<_, String>(6)?, 6)?;
442 let completed_at = match row.get::<_, String>(7)?.as_str() {
443 "" => None,
444 value => Some(parse_datetime_column(value.to_string(), 7)?),
445 };
446 let tags_json: String = row.get(9)?;
447 let tags = serde_json::from_str::<Vec<String>>(&tags_json).map_err(|_| {
448 rusqlite::Error::InvalidColumnType(9, tags_json, rusqlite::types::Type::Text)
449 })?;
450 let description = empty_string_to_none(row.get::<_, String>(3)?);
451 let assigned_to_turn = empty_string_to_none(row.get::<_, String>(8)?);
452
453 Ok(TodoItem {
454 id,
455 session_id,
456 title: row.get(2)?,
457 description,
458 status: TodoStatus::from_str(&row.get::<_, String>(4)?),
459 priority: TodoPriority::from_str(&row.get::<_, String>(5)?),
460 created_at,
461 completed_at,
462 assigned_to_turn,
463 tags,
464 })
465}
466
467fn parse_uuid_column(value: String, column: usize) -> RusqliteResult<Uuid> {
468 Uuid::parse_str(&value)
469 .map_err(|_| rusqlite::Error::InvalidColumnType(column, value, rusqlite::types::Type::Text))
470}
471
472fn parse_datetime_column(value: String, column: usize) -> RusqliteResult<DateTime<Utc>> {
473 DateTime::parse_from_rfc3339(&value)
474 .map(|dt| dt.with_timezone(&Utc))
475 .map_err(|_| rusqlite::Error::InvalidColumnType(column, value, rusqlite::types::Type::Text))
476}
477
478fn empty_string_to_none(value: String) -> Option<String> {
479 if value.is_empty() { None } else { Some(value) }
480}
481
482fn normalize_tags(tags: Vec<String>) -> Vec<String> {
483 let mut tags: Vec<String> = tags
484 .into_iter()
485 .map(|tag| tag.trim().to_string())
486 .filter(|tag| !tag.is_empty())
487 .collect();
488 tags.sort();
489 tags.dedup();
490 tags
491}