1use std::path::{Path, PathBuf};
4
5use rusqlite::{params, Connection, Result as SqliteResult};
6use serde::{Deserialize, Serialize};
7
8use crate::datetime::current_timestamp;
9
10const ROBIT_DIR: &str = ".robit";
11const MEMORY_DIR: &str = "memory";
12const DB_FILE: &str = "robit.db";
13
14pub fn resolve_db_path(working_dir: &Path, global_storage: bool) -> Result<PathBuf, String> {
16 if global_storage {
17 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
18 Ok(home.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
19 } else {
20 Ok(working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
21 }
22}
23
24#[derive(Debug, Clone, Serialize)]
26pub struct SessionInfo {
27 pub id: String,
28 pub chat_id: Option<String>,
30 pub title: String,
31 pub model: String,
32 pub source: String,
34 pub status: String, pub created_at: String,
36 pub updated_at: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct MessageData {
42 pub id: i64,
43 pub role: String,
44 pub content: String,
45 pub tool_name: Option<String>,
46 pub tool_call_id: Option<String>,
47 pub tool_info: Option<serde_json::Value>,
48 pub created_at: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ToolCallInfoData {
54 pub tool_call_id: String,
55 pub name: String,
56 pub arguments: String,
57 pub status: String,
58 pub output: Option<String>,
59 pub requires_confirm: bool,
60}
61
62const CURRENT_SCHEMA_VERSION: i32 = 2;
64
65pub fn init_db(conn: &Connection) -> SqliteResult<()> {
71 ensure_meta_table(conn)?;
72
73 let version = read_schema_version(conn)?;
74
75 if version == 0 {
76 create_all_tables(conn)?;
78 write_schema_version(conn, CURRENT_SCHEMA_VERSION)?;
79 tracing::info!(
80 "Database initialized at schema v{}",
81 CURRENT_SCHEMA_VERSION
82 );
83 return Ok(());
84 }
85
86 migrate(conn, version, CURRENT_SCHEMA_VERSION)?;
87 Ok(())
88}
89
90fn read_schema_version(conn: &Connection) -> SqliteResult<i32> {
98 match conn.query_row(
99 "SELECT value FROM _schema_meta WHERE key = 'version'",
100 [],
101 |row| row.get::<_, String>(0),
102 ) {
103 Ok(v) => v.parse().map_err(|_| {
104 rusqlite::Error::InvalidParameterName(format!("Invalid schema version: {}", v))
105 }),
106 Err(rusqlite::Error::QueryReturnedNoRows) => {
107 if sessions_table_exists(conn)? {
109 Ok(1) } else {
111 Ok(0) }
113 }
114 Err(e) => Err(e),
115 }
116}
117
118fn sessions_table_exists(conn: &Connection) -> SqliteResult<bool> {
120 let count: i64 = conn.query_row(
121 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sessions'",
122 [],
123 |row| row.get(0),
124 )?;
125 Ok(count > 0)
126}
127
128fn create_all_tables(conn: &Connection) -> SqliteResult<()> {
130 conn.execute_batch(
131 "CREATE TABLE IF NOT EXISTS sessions (
132 id TEXT PRIMARY KEY,
133 chat_id TEXT,
134 title TEXT NOT NULL,
135 model TEXT NOT NULL,
136 source TEXT NOT NULL DEFAULT 'gui',
137 created_at TEXT NOT NULL,
138 updated_at TEXT NOT NULL,
139 is_active INTEGER DEFAULT 1
140 );
141
142 CREATE TABLE IF NOT EXISTS messages (
143 id INTEGER PRIMARY KEY AUTOINCREMENT,
144 session_id TEXT NOT NULL REFERENCES sessions(id),
145 role TEXT NOT NULL,
146 content TEXT NOT NULL,
147 tool_name TEXT,
148 tool_call_id TEXT,
149 tool_info TEXT,
150 tokens INTEGER,
151 created_at TEXT NOT NULL
152 );
153
154 CREATE INDEX IF NOT EXISTS idx_messages_session
155 ON messages(session_id);
156 CREATE INDEX IF NOT EXISTS idx_messages_created
157 ON messages(session_id, created_at);
158 CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
159 ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
160 )?;
161 Ok(())
162}
163
164fn migrate(conn: &Connection, from: i32, to: i32) -> SqliteResult<()> {
166 let mut current = from;
167 while current < to {
168 tracing::info!("Migrating database: v{} → v{}", current, current + 1);
169 match current {
170 1 => migrate_v1_to_v2(conn)?,
171 other => {
172 return Err(rusqlite::Error::InvalidParameterName(format!(
173 "Unknown schema version: {}",
174 other
175 )))
176 }
177 }
178 current += 1;
179 write_schema_version(conn, current)?;
180 tracing::info!("Database migrated to v{}", current);
181 }
182 Ok(())
183}
184
185fn migrate_v1_to_v2(conn: &Connection) -> SqliteResult<()> {
191 let _ = conn.execute("ALTER TABLE sessions ADD COLUMN chat_id TEXT", ());
192 let _ = conn.execute(
193 "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'gui'",
194 (),
195 );
196 let _ = conn.execute("ALTER TABLE messages ADD COLUMN tool_info TEXT", ());
197 conn.execute_batch(
198 "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
199 ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
200 )?;
201 Ok(())
202}
203
204fn ensure_meta_table(conn: &Connection) -> SqliteResult<()> {
209 conn.execute_batch(
210 "CREATE TABLE IF NOT EXISTS _schema_meta (
211 key TEXT PRIMARY KEY,
212 value TEXT NOT NULL
213 )",
214 )
215}
216
217fn write_schema_version(conn: &Connection, version: i32) -> SqliteResult<()> {
218 conn.execute(
219 "INSERT OR REPLACE INTO _schema_meta (key, value) VALUES ('version', ?1)",
220 rusqlite::params![version.to_string()],
221 )?;
222 Ok(())
223}
224
225pub fn insert_session(
231 conn: &Connection,
232 id: &str,
233 chat_id: Option<&str>,
234 title: &str,
235 model: &str,
236 source: &str,
237) -> SqliteResult<()> {
238 let now = current_timestamp();
239 conn.execute(
240 "INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at) \
241 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
242 params![id, chat_id, title, model, source, now, now],
243 )?;
244 Ok(())
245}
246
247pub fn list_sessions(
252 conn: &Connection,
253 source_filter: Option<&str>,
254) -> SqliteResult<Vec<SessionInfo>> {
255 let sql = if source_filter.is_some() {
256 "SELECT id, chat_id, title, model, source, created_at, updated_at \
257 FROM sessions WHERE is_active = 1 AND source = ?1 ORDER BY updated_at DESC"
258 } else {
259 "SELECT id, chat_id, title, model, source, created_at, updated_at \
260 FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC"
261 };
262 let mut stmt = conn.prepare(sql)?;
263 let rows = if let Some(source) = source_filter {
264 stmt.query_map(params![source], map_session_row)?
265 } else {
266 stmt.query_map([], map_session_row)?
267 };
268 rows.collect()
269}
270
271pub fn find_session_by_chat_id(
275 conn: &Connection,
276 chat_id: &str,
277) -> SqliteResult<Option<SessionInfo>> {
278 let mut stmt = conn.prepare(
279 "SELECT id, chat_id, title, model, source, created_at, updated_at \
280 FROM sessions WHERE chat_id = ?1 AND is_active = 1",
281 )?;
282 let mut rows = stmt.query_map(params![chat_id], map_session_row)?;
283 match rows.next() {
284 Some(Ok(session)) => Ok(Some(session)),
285 _ => Ok(None),
286 }
287}
288
289pub fn get_session(conn: &Connection, id: &str) -> SqliteResult<Option<SessionInfo>> {
291 let mut stmt = conn.prepare(
292 "SELECT id, chat_id, title, model, source, created_at, updated_at \
293 FROM sessions WHERE id = ?1 AND is_active = 1",
294 )?;
295 let mut rows = stmt.query_map(params![id], map_session_row)?;
296 match rows.next() {
297 Some(Ok(session)) => Ok(Some(session)),
298 _ => Ok(None),
299 }
300}
301
302fn map_session_row(row: &rusqlite::Row<'_>) -> SqliteResult<SessionInfo> {
304 Ok(SessionInfo {
305 id: row.get(0)?,
306 chat_id: row.get(1)?,
307 title: row.get(2)?,
308 model: row.get(3)?,
309 source: row.get(4)?,
310 status: "idle".to_string(),
311 created_at: row.get(5)?,
312 updated_at: row.get(6)?,
313 })
314}
315
316pub fn update_session_title(conn: &Connection, id: &str, title: &str) -> SqliteResult<()> {
318 let now = current_timestamp();
319 conn.execute(
320 "UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
321 params![title, now, id],
322 )?;
323 Ok(())
324}
325
326pub fn touch_session(conn: &Connection, id: &str) -> SqliteResult<()> {
328 let now = current_timestamp();
329 conn.execute(
330 "UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
331 params![now, id],
332 )?;
333 Ok(())
334}
335
336pub fn delete_session(conn: &Connection, id: &str) -> SqliteResult<()> {
338 conn.execute(
339 "UPDATE sessions SET is_active = 0 WHERE id = ?1",
340 params![id],
341 )?;
342 Ok(())
343}
344
345pub fn insert_message(
347 conn: &Connection,
348 session_id: &str,
349 role: &str,
350 content: &str,
351 tool_name: Option<&str>,
352 tool_call_id: Option<&str>,
353 tool_info: Option<&str>,
354) -> SqliteResult<i64> {
355 let now = current_timestamp();
356 conn.execute(
357 "INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_info, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
358 params![session_id, role, content, tool_name, tool_call_id, tool_info, now],
359 )?;
360 Ok(conn.last_insert_rowid())
361}
362
363pub fn get_messages(conn: &Connection, session_id: &str) -> SqliteResult<Vec<MessageData>> {
365 let mut stmt = conn.prepare(
366 "SELECT id, role, content, tool_name, tool_call_id, tool_info, created_at FROM messages WHERE session_id = ?1 ORDER BY id ASC"
367 )?;
368 let rows = stmt.query_map(params![session_id], |row| {
369 let tool_info_str: Option<String> = row.get(5)?;
370 let tool_info = tool_info_str.and_then(|s| serde_json::from_str(&s).ok());
371 Ok(MessageData {
372 id: row.get(0)?,
373 role: row.get(1)?,
374 content: row.get(2)?,
375 tool_name: row.get(3)?,
376 tool_call_id: row.get(4)?,
377 tool_info,
378 created_at: row.get(6)?,
379 })
380 })?;
381 rows.collect()
382}
383
384pub fn update_tool_message(
386 conn: &Connection,
387 session_id: &str,
388 tool_call_id: &str,
389 tool_info: &str,
390) -> SqliteResult<()> {
391 conn.execute(
392 "UPDATE messages SET tool_info = ?1 WHERE session_id = ?2 AND tool_call_id = ?3",
393 params![tool_info, session_id, tool_call_id],
394 )?;
395 Ok(())
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn resolves_local_db_path() {
404 let working_dir = PathBuf::from("project");
405 let path = resolve_db_path(&working_dir, false).unwrap();
406 assert_eq!(
407 path,
408 working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)
409 );
410 }
411
412 #[test]
413 fn resolves_global_db_path() {
414 let path = resolve_db_path(&PathBuf::from("project"), true).unwrap();
415 assert_eq!(
416 path.file_name().and_then(|name| name.to_str()),
417 Some(DB_FILE)
418 );
419 assert!(path.ends_with(PathBuf::from(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)));
420 }
421
422 #[test]
423 fn session_crud() {
424 let conn = Connection::open_in_memory().unwrap();
425 init_db(&conn).unwrap();
426
427 insert_session(
428 &conn,
429 "test-123",
430 None,
431 "Test Session",
432 "deepseek/deepseek-chat",
433 "gui",
434 )
435 .unwrap();
436
437 let sessions = list_sessions(&conn, None).unwrap();
438 assert_eq!(sessions.len(), 1);
439 assert_eq!(sessions[0].id, "test-123");
440 assert_eq!(sessions[0].title, "Test Session");
441 assert_eq!(sessions[0].source, "gui");
442 assert_eq!(sessions[0].chat_id, None);
443 assert_eq!(sessions[0].status, "idle");
444
445 let session = get_session(&conn, "test-123").unwrap().unwrap();
446 assert_eq!(session.title, "Test Session");
447 assert_eq!(session.source, "gui");
448
449 update_session_title(&conn, "test-123", "Updated Title").unwrap();
450 let updated = get_session(&conn, "test-123").unwrap().unwrap();
451 assert_eq!(updated.title, "Updated Title");
452
453 delete_session(&conn, "test-123").unwrap();
454 assert!(get_session(&conn, "test-123").unwrap().is_none());
455 assert!(list_sessions(&conn, None).unwrap().is_empty());
456 }
457
458 #[test]
459 fn message_operations() {
460 let conn = Connection::open_in_memory().unwrap();
461 init_db(&conn).unwrap();
462
463 insert_session(&conn, "session-msg", None, "Chat Session", "model", "gui").unwrap();
464 let user_id = insert_message(
465 &conn,
466 "session-msg",
467 "user",
468 "Hello Robit",
469 None,
470 None,
471 None,
472 )
473 .unwrap();
474 let assistant_id = insert_message(
475 &conn,
476 "session-msg",
477 "assistant",
478 "Hello! How can I help?",
479 None,
480 None,
481 None,
482 )
483 .unwrap();
484
485 let messages = get_messages(&conn, "session-msg").unwrap();
486 assert_eq!(messages.len(), 2);
487 assert_eq!(messages[0].id, user_id);
488 assert_eq!(messages[0].role, "user");
489 assert_eq!(messages[0].content, "Hello Robit");
490 assert_eq!(messages[1].id, assistant_id);
491 assert_eq!(messages[1].role, "assistant");
492 assert_eq!(messages[1].content, "Hello! How can I help?");
493 }
494
495 #[test]
496 fn empty_sessions() {
497 let conn = Connection::open_in_memory().unwrap();
498 init_db(&conn).unwrap();
499
500 let sessions = list_sessions(&conn, None).unwrap();
501 assert_eq!(sessions.len(), 0);
502 }
503
504 #[test]
505 fn get_nonexistent_session() {
506 let conn = Connection::open_in_memory().unwrap();
507 init_db(&conn).unwrap();
508
509 let session = get_session(&conn, "nonexistent").unwrap();
510 assert!(session.is_none());
511 }
512
513 #[test]
514 fn tool_message_update() {
515 let conn = Connection::open_in_memory().unwrap();
516 init_db(&conn).unwrap();
517
518 insert_session(&conn, "session-tool", None, "Tool Session", "model", "gui").unwrap();
519 let initial = serde_json::json!({
520 "tool_call_id": "tool-1",
521 "name": "bash",
522 "arguments": "{}",
523 "status": "pending",
524 "requires_confirm": true
525 })
526 .to_string();
527 insert_message(
528 &conn,
529 "session-tool",
530 "tool",
531 "{}",
532 Some("bash"),
533 Some("tool-1"),
534 Some(&initial),
535 )
536 .unwrap();
537
538 let updated = serde_json::json!({
539 "tool_call_id": "tool-1",
540 "status": "success",
541 "output": "done"
542 })
543 .to_string();
544 update_tool_message(&conn, "session-tool", "tool-1", &updated).unwrap();
545
546 let messages = get_messages(&conn, "session-tool").unwrap();
547 assert_eq!(messages.len(), 1);
548 assert_eq!(messages[0].tool_name.as_deref(), Some("bash"));
549 assert_eq!(messages[0].tool_call_id.as_deref(), Some("tool-1"));
550 assert_eq!(messages[0].tool_info.as_ref().unwrap()["status"], "success");
551 assert_eq!(messages[0].tool_info.as_ref().unwrap()["output"], "done");
552 }
553
554 #[test]
555 fn chat_id_lookup_and_source_filter() {
556 let conn = Connection::open_in_memory().unwrap();
557 init_db(&conn).unwrap();
558
559 insert_session(&conn, "gui-1", None, "GUI Session", "model", "gui").unwrap();
560 insert_session(
561 &conn,
562 "qq-1",
563 Some("group:abc"),
564 "技术讨论群",
565 "model",
566 "qq",
567 )
568 .unwrap();
569 insert_session(
570 &conn,
571 "qq-2",
572 Some("private:xyz"),
573 "私聊",
574 "model",
575 "qq",
576 )
577 .unwrap();
578
579 let found = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
581 assert_eq!(found.id, "qq-1");
582 assert_eq!(found.source, "qq");
583 assert_eq!(found.chat_id.as_deref(), Some("group:abc"));
584
585 assert!(find_session_by_chat_id(&conn, "does-not-exist")
587 .unwrap()
588 .is_none());
589
590 let qq_sessions = list_sessions(&conn, Some("qq")).unwrap();
592 assert_eq!(qq_sessions.len(), 2);
593 assert!(qq_sessions.iter().all(|s| s.source == "qq"));
594
595 let gui_sessions = list_sessions(&conn, Some("gui")).unwrap();
596 assert_eq!(gui_sessions.len(), 1);
597 assert_eq!(gui_sessions[0].id, "gui-1");
598
599 assert_eq!(list_sessions(&conn, None).unwrap().len(), 3);
601 }
602
603 #[test]
604 fn chat_id_unique_per_chat() {
605 let conn = Connection::open_in_memory().unwrap();
606 init_db(&conn).unwrap();
607
608 insert_session(
609 &conn,
610 "qq-1",
611 Some("group:abc"),
612 "First",
613 "model",
614 "qq",
615 )
616 .unwrap();
617 let err = insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq");
619 assert!(err.is_err());
620 }
621
622 #[test]
623 fn migrates_legacy_v1_database() {
624 let conn = Connection::open_in_memory().unwrap();
625 conn.execute_batch(
627 "CREATE TABLE sessions (
628 id TEXT PRIMARY KEY,
629 title TEXT NOT NULL,
630 model TEXT NOT NULL,
631 created_at TEXT NOT NULL,
632 updated_at TEXT NOT NULL,
633 is_active INTEGER DEFAULT 1
634 );
635 CREATE TABLE messages (
636 id INTEGER PRIMARY KEY AUTOINCREMENT,
637 session_id TEXT NOT NULL REFERENCES sessions(id),
638 role TEXT NOT NULL,
639 content TEXT NOT NULL,
640 tool_name TEXT,
641 tool_call_id TEXT,
642 tokens INTEGER,
643 created_at TEXT NOT NULL
644 );",
645 )
646 .unwrap();
647 conn.execute(
648 "INSERT INTO sessions (id, title, model, created_at, updated_at) \
649 VALUES ('legacy-1', 'Legacy', 'model', '2020-01-01', '2020-01-01')",
650 [],
651 )
652 .unwrap();
653
654 init_db(&conn).unwrap();
658
659 let v: i32 = read_schema_version(&conn).unwrap();
661 assert_eq!(v, CURRENT_SCHEMA_VERSION);
662
663 let session = get_session(&conn, "legacy-1").unwrap().unwrap();
665 assert_eq!(session.title, "Legacy");
666 assert_eq!(session.source, "gui");
667 assert_eq!(session.chat_id, None);
668 }
669
670 #[test]
671 fn init_db_is_idempotent() {
672 let conn = Connection::open_in_memory().unwrap();
673 init_db(&conn).unwrap();
674 init_db(&conn).unwrap();
676 assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
677 }
678}