Skip to main content

tuillem_db/
sessions.rs

1use chrono::{DateTime, Utc};
2use uuid::Uuid;
3
4use crate::{Db, DbError};
5
6#[derive(Debug, Clone)]
7pub struct Session {
8    pub id: String,
9    pub title: String,
10    pub created_at: DateTime<Utc>,
11    pub updated_at: DateTime<Utc>,
12    pub metadata: Option<String>,
13    pub tags: Vec<String>,
14}
15
16impl Db {
17    pub fn create_session(&self, title: &str) -> Result<Session, DbError> {
18        let id = Uuid::new_v4().to_string();
19        let now = Utc::now();
20        let now_str = now.to_rfc3339();
21
22        self.conn.execute(
23            "INSERT INTO sessions (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
24            rusqlite::params![id, title, now_str, now_str],
25        )?;
26
27        Ok(Session {
28            id,
29            title: title.to_string(),
30            created_at: now,
31            updated_at: now,
32            metadata: None,
33            tags: Vec::new(),
34        })
35    }
36
37    pub fn get_session(&self, id: &str) -> Result<Session, DbError> {
38        let mut stmt = self.conn.prepare(
39            "SELECT id, title, created_at, updated_at, metadata FROM sessions WHERE id = ?1",
40        )?;
41
42        let session = stmt
43            .query_row(rusqlite::params![id], |row| {
44                let created_str: String = row.get(2)?;
45                let updated_str: String = row.get(3)?;
46                Ok((
47                    row.get::<_, String>(0)?,
48                    row.get::<_, String>(1)?,
49                    created_str,
50                    updated_str,
51                    row.get::<_, Option<String>>(4)?,
52                ))
53            })
54            .map_err(|e| match e {
55                rusqlite::Error::QueryReturnedNoRows => DbError::NotFound(format!("session {id}")),
56                other => DbError::Sqlite(other),
57            })?;
58
59        let tags = self.get_session_tags(&session.0)?;
60
61        Ok(Session {
62            id: session.0,
63            title: session.1,
64            created_at: DateTime::parse_from_rfc3339(&session.2)
65                .unwrap_or_default()
66                .with_timezone(&Utc),
67            updated_at: DateTime::parse_from_rfc3339(&session.3)
68                .unwrap_or_default()
69                .with_timezone(&Utc),
70            metadata: session.4,
71            tags,
72        })
73    }
74
75    pub fn list_sessions(&self) -> Result<Vec<Session>, DbError> {
76        let mut stmt = self.conn.prepare(
77            "SELECT id, title, created_at, updated_at, metadata FROM sessions ORDER BY updated_at DESC",
78        )?;
79
80        let rows = stmt.query_map([], |row| {
81            let created_str: String = row.get(2)?;
82            let updated_str: String = row.get(3)?;
83            Ok((
84                row.get::<_, String>(0)?,
85                row.get::<_, String>(1)?,
86                created_str,
87                updated_str,
88                row.get::<_, Option<String>>(4)?,
89            ))
90        })?;
91
92        let mut sessions = Vec::new();
93        for row in rows {
94            let r = row?;
95            let tags = self.get_session_tags(&r.0)?;
96            sessions.push(Session {
97                id: r.0,
98                title: r.1,
99                created_at: DateTime::parse_from_rfc3339(&r.2)
100                    .unwrap_or_default()
101                    .with_timezone(&Utc),
102                updated_at: DateTime::parse_from_rfc3339(&r.3)
103                    .unwrap_or_default()
104                    .with_timezone(&Utc),
105                metadata: r.4,
106                tags,
107            });
108        }
109
110        Ok(sessions)
111    }
112
113    pub fn update_session_title(&self, id: &str, title: &str) -> Result<(), DbError> {
114        let now = Utc::now().to_rfc3339();
115        let rows = self.conn.execute(
116            "UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
117            rusqlite::params![title, now, id],
118        )?;
119        if rows == 0 {
120            return Err(DbError::NotFound(format!("session {id}")));
121        }
122        Ok(())
123    }
124
125    pub fn update_session_metadata(&self, id: &str, metadata: &str) -> Result<(), DbError> {
126        let now = Utc::now().to_rfc3339();
127        self.conn.execute(
128            "UPDATE sessions SET metadata = ?1, updated_at = ?2 WHERE id = ?3",
129            rusqlite::params![metadata, now, id],
130        )?;
131        Ok(())
132    }
133
134    /// Get all sessions with their last message preview in a single query.
135    /// Avoids N+1 queries when loading the sidebar.
136    pub fn list_sessions_with_preview(&self) -> Result<Vec<(Session, Option<String>)>, DbError> {
137        let mut stmt = self.conn.prepare(
138            "SELECT s.id, s.title, s.created_at, s.updated_at, s.metadata, \
139             (SELECT m.content FROM messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC LIMIT 1) as last_message \
140             FROM sessions s ORDER BY s.updated_at DESC",
141        )?;
142
143        let rows = stmt.query_map([], |row| {
144            let created_str: String = row.get(2)?;
145            let updated_str: String = row.get(3)?;
146            Ok((
147                row.get::<_, String>(0)?,
148                row.get::<_, String>(1)?,
149                created_str,
150                updated_str,
151                row.get::<_, Option<String>>(4)?,
152                row.get::<_, Option<String>>(5)?,
153            ))
154        })?;
155
156        let mut results = Vec::new();
157        for row in rows {
158            let r = row?;
159            let tags = self.get_session_tags(&r.0)?;
160            let session = Session {
161                id: r.0,
162                title: r.1,
163                created_at: DateTime::parse_from_rfc3339(&r.2)
164                    .unwrap_or_default()
165                    .with_timezone(&Utc),
166                updated_at: DateTime::parse_from_rfc3339(&r.3)
167                    .unwrap_or_default()
168                    .with_timezone(&Utc),
169                metadata: r.4,
170                tags,
171            };
172            results.push((session, r.5));
173        }
174
175        Ok(results)
176    }
177
178    pub fn delete_session(&self, id: &str) -> Result<(), DbError> {
179        let rows = self
180            .conn
181            .execute("DELETE FROM sessions WHERE id = ?1", rusqlite::params![id])?;
182        if rows == 0 {
183            return Err(DbError::NotFound(format!("session {id}")));
184        }
185        Ok(())
186    }
187
188    pub fn add_session_tag(&self, session_id: &str, tag: &str) -> Result<(), DbError> {
189        self.conn.execute(
190            "INSERT OR IGNORE INTO session_tags (session_id, tag) VALUES (?1, ?2)",
191            rusqlite::params![session_id, tag],
192        )?;
193        Ok(())
194    }
195
196    pub fn remove_session_tag(&self, session_id: &str, tag: &str) -> Result<(), DbError> {
197        self.conn.execute(
198            "DELETE FROM session_tags WHERE session_id = ?1 AND tag = ?2",
199            rusqlite::params![session_id, tag],
200        )?;
201        Ok(())
202    }
203
204    fn get_session_tags(&self, session_id: &str) -> Result<Vec<String>, DbError> {
205        let mut stmt = self
206            .conn
207            .prepare("SELECT tag FROM session_tags WHERE session_id = ?1 ORDER BY tag")?;
208        let tags = stmt
209            .query_map(rusqlite::params![session_id], |row| row.get(0))?
210            .collect::<Result<Vec<String>, _>>()?;
211        Ok(tags)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use crate::Db;
218
219    #[test]
220    fn test_create_and_get_session() {
221        let db = Db::open_in_memory().unwrap();
222        let session = db.create_session("Test Session").unwrap();
223        assert_eq!(session.title, "Test Session");
224
225        let fetched = db.get_session(&session.id).unwrap();
226        assert_eq!(fetched.id, session.id);
227        assert_eq!(fetched.title, "Test Session");
228    }
229
230    #[test]
231    fn test_list_sessions_ordered_by_updated() {
232        let db = Db::open_in_memory().unwrap();
233        let s1 = db.create_session("First").unwrap();
234        let _s2 = db.create_session("Second").unwrap();
235
236        // Update first session so it becomes most recently updated
237        db.update_session_title(&s1.id, "First Updated").unwrap();
238
239        let sessions = db.list_sessions().unwrap();
240        assert_eq!(sessions.len(), 2);
241        assert_eq!(sessions[0].title, "First Updated");
242        assert_eq!(sessions[1].title, "Second");
243    }
244
245    #[test]
246    fn test_session_tags() {
247        let db = Db::open_in_memory().unwrap();
248        let session = db.create_session("Tagged").unwrap();
249
250        db.add_session_tag(&session.id, "rust").unwrap();
251        db.add_session_tag(&session.id, "coding").unwrap();
252
253        // Duplicate should be ignored
254        db.add_session_tag(&session.id, "rust").unwrap();
255
256        let fetched = db.get_session(&session.id).unwrap();
257        assert_eq!(fetched.tags.len(), 2);
258        assert!(fetched.tags.contains(&"rust".to_string()));
259        assert!(fetched.tags.contains(&"coding".to_string()));
260
261        // Remove tag
262        db.remove_session_tag(&session.id, "rust").unwrap();
263        let fetched = db.get_session(&session.id).unwrap();
264        assert_eq!(fetched.tags.len(), 1);
265        assert!(fetched.tags.contains(&"coding".to_string()));
266    }
267
268    #[test]
269    fn test_delete_session() {
270        let db = Db::open_in_memory().unwrap();
271        let session = db.create_session("To Delete").unwrap();
272        db.delete_session(&session.id).unwrap();
273
274        let result = db.get_session(&session.id);
275        assert!(result.is_err());
276    }
277
278    #[test]
279    fn test_delete_nonexistent_session() {
280        let db = Db::open_in_memory().unwrap();
281        let result = db.delete_session("nonexistent-id");
282        assert!(result.is_err());
283    }
284}