Skip to main content

talos_session/todo/
repository.rs

1//! SQLite persistence for session todo state.
2
3use 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/// SQLite repository for session todo state.
16#[derive(Debug)]
17pub struct TodoRepository {
18    conn: Connection,
19    db_path: PathBuf,
20}
21
22impl TodoRepository {
23    /// Open or create a todo database at the given path.
24    ///
25    /// # Errors
26    ///
27    /// Returns an error when the database file cannot be opened.
28    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    /// Return the path to the SQLite database.
41    #[must_use]
42    pub fn db_path(&self) -> &Path {
43        &self.db_path
44    }
45
46    /// Initialize todo tables.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error when SQLite rejects the schema.
51    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    /// Create a todo item, or return an existing item with the same title
82    /// in the same session (idempotent create).
83    ///
84    /// # Errors
85    ///
86    /// Returns an error when the item cannot be persisted or looked up.
87    pub fn create(&self, input: CreateTodo) -> Result<TodoItem, TodoError> {
88        // Idempotency: return existing item with same title in this session.
89        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    /// Create multiple todo items idempotently in one call.
110    ///
111    /// Each item follows the same idempotency rule as [`create`]: if an item
112    /// with the same title already exists in the session, the existing item is
113    /// returned unchanged. Items within the same batch that share a title also
114    /// deduplicate to the first occurrence.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error when any item cannot be persisted or looked up.
119    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    /// Get one todo item by id within a session.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error when SQLite fails.
132    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    /// List todo items for a session.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error when SQLite fails or stored metadata cannot be parsed.
153    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    /// Update mutable todo fields.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`TodoError::NotFound`] when the item does not exist in the session.
172    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    /// Update item status and maintain `completed_at`.
199    ///
200    /// # Errors
201    ///
202    /// Returns [`TodoError::NotFound`] when the item does not exist in the session.
203    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    /// Delete an item and any dependency edges that reference it.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error when SQLite fails.
225    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    /// Add a dependency edge after validating item existence and acyclicity.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`TodoError::DependencyCycle`] if adding the edge would create a cycle.
244    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    /// Remove a dependency edge.
280    ///
281    /// # Errors
282    ///
283    /// Returns an error when SQLite fails.
284    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    /// List all dependency edges for a session.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error when SQLite fails.
306    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    /// Find a todo item by session and exact title match (case-sensitive).
372    /// Used for idempotent create — repeated `todo_create` for the same
373    /// title in the same session returns the existing item.
374    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}