Skip to main content

robit_agent/
storage.rs

1//! Session and message storage helpers.
2
3use std::path::{Path, PathBuf};
4
5use async_openai::types::chat::{
6    ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage,
7    ChatCompletionRequestToolMessage, ChatCompletionRequestUserMessage,
8    ChatCompletionRequestUserMessageContent,
9};
10use rusqlite::{params, Connection, Result as SqliteResult};
11use serde::{Deserialize, Serialize};
12
13use crate::datetime::current_timestamp;
14use crate::error::Result;
15
16const ROBIT_DIR: &str = ".robit";
17const MEMORY_DIR: &str = "memory";
18const DB_FILE: &str = "robit.db";
19
20/// Resolve the session database path for a working directory and storage scope.
21pub fn resolve_db_path(working_dir: &Path, global_storage: bool) -> Result<PathBuf> {
22    if global_storage {
23        let home = dirs::home_dir().ok_or_else(|| {
24            crate::error::AgentError::InternalError("Cannot determine home directory".to_string())
25        })?;
26        Ok(home.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
27    } else {
28        Ok(working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
29    }
30}
31
32/// Session metadata returned to frontends.
33#[derive(Debug, Clone, Serialize)]
34pub struct SessionInfo {
35    pub id: String,
36    /// Platform chat identifier (None for GUI/TUI, Some for Bot platforms).
37    pub chat_id: Option<String>,
38    pub title: String,
39    pub model: String,
40    /// Which frontend created the session: "gui" | "tui" | "qq" | "feishu".
41    pub source: String,
42    pub status: String, // "idle" | "ready" | "running"
43    pub created_at: String,
44    pub updated_at: String,
45}
46
47/// Message data returned to frontends.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct MessageData {
50    pub id: i64,
51    pub role: String,
52    pub content: String,
53    pub tool_name: Option<String>,
54    pub tool_call_id: Option<String>,
55    pub tool_info: Option<serde_json::Value>,
56    pub created_at: String,
57}
58
59/// Tool call info for storage in message.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolCallInfoData {
62    pub tool_call_id: String,
63    pub name: String,
64    pub arguments: String,
65    pub status: String,
66    pub output: Option<String>,
67    pub requires_confirm: bool,
68}
69
70/// Current schema version. Increment when the schema changes.
71const CURRENT_SCHEMA_VERSION: i32 = 2;
72
73/// Initialize the database: create tables if needed, then run migrations.
74///
75/// This is the single entry point used by all frontends. It auto-detects
76/// fresh databases (version 0) and existing databases, running the migration
77/// chain to bring them up to [`CURRENT_SCHEMA_VERSION`].
78pub fn init_db(conn: &Connection) -> SqliteResult<()> {
79    ensure_meta_table(conn)?;
80
81    let version = read_schema_version(conn)?;
82
83    if version == 0 {
84        // Fresh database — create everything at the current version in one shot.
85        create_all_tables(conn)?;
86        write_schema_version(conn, CURRENT_SCHEMA_VERSION)?;
87        tracing::info!(
88            "Database initialized at schema v{}",
89            CURRENT_SCHEMA_VERSION
90        );
91        return Ok(());
92    }
93
94    migrate(conn, version, CURRENT_SCHEMA_VERSION)?;
95    Ok(())
96}
97
98/// Detect the schema version of an existing database.
99///
100/// Returns:
101/// - `0` for a truly fresh database (no `sessions` table, no recorded version).
102/// - `1` for a legacy v1 database that predates `_schema_meta` versioning
103///   (tables exist but no version row was ever written).
104/// - `N` for a versioned database, read from `_schema_meta`.
105fn read_schema_version(conn: &Connection) -> SqliteResult<i32> {
106    match conn.query_row(
107        "SELECT value FROM _schema_meta WHERE key = 'version'",
108        [],
109        |row| row.get::<_, String>(0),
110    ) {
111        Ok(v) => v.parse().map_err(|_| {
112            rusqlite::Error::InvalidParameterName(format!("Invalid schema version: {}", v))
113        }),
114        Err(rusqlite::Error::QueryReturnedNoRows) => {
115            // No recorded version — is this a fresh DB or a legacy v1 DB?
116            if sessions_table_exists(conn)? {
117                Ok(1) // tables exist but unversioned → legacy v1
118            } else {
119                Ok(0) // nothing exists → fresh
120            }
121        }
122        Err(e) => Err(e),
123    }
124}
125
126/// Check whether the `sessions` table exists.
127fn sessions_table_exists(conn: &Connection) -> SqliteResult<bool> {
128    let count: i64 = conn.query_row(
129        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
130        [],
131        |row| row.get(0),
132    )?;
133    Ok(count > 0)
134}
135
136/// Create all tables and indexes at the current schema version (fresh DBs only).
137fn create_all_tables(conn: &Connection) -> SqliteResult<()> {
138    conn.execute_batch(
139        "CREATE TABLE IF NOT EXISTS sessions (
140            id          TEXT PRIMARY KEY,
141            chat_id     TEXT,
142            title       TEXT NOT NULL,
143            model       TEXT NOT NULL,
144            source      TEXT NOT NULL DEFAULT 'gui',
145            created_at  TEXT NOT NULL,
146            updated_at  TEXT NOT NULL,
147            is_active   INTEGER DEFAULT 1
148        );
149
150        CREATE TABLE IF NOT EXISTS messages (
151            id           INTEGER PRIMARY KEY AUTOINCREMENT,
152            session_id   TEXT NOT NULL REFERENCES sessions(id),
153            role         TEXT NOT NULL,
154            content      TEXT NOT NULL,
155            tool_name    TEXT,
156            tool_call_id TEXT,
157            tool_info    TEXT,
158            tokens       INTEGER,
159            created_at   TEXT NOT NULL
160        );
161
162        CREATE INDEX IF NOT EXISTS idx_messages_session
163            ON messages(session_id);
164        CREATE INDEX IF NOT EXISTS idx_messages_created
165            ON messages(session_id, created_at);
166        CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
167            ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
168    )?;
169    Ok(())
170}
171
172/// Run the migration chain from `from` to `to`, one step at a time.
173fn migrate(conn: &Connection, from: i32, to: i32) -> SqliteResult<()> {
174    let mut current = from;
175    while current < to {
176        tracing::info!("Migrating database: v{} → v{}", current, current + 1);
177        match current {
178            1 => migrate_v1_to_v2(conn)?,
179            other => {
180                return Err(rusqlite::Error::InvalidParameterName(format!(
181                    "Unknown schema version: {}",
182                    other
183                )))
184            }
185        }
186        current += 1;
187        write_schema_version(conn, current)?;
188        tracing::info!("Database migrated to v{}", current);
189    }
190    Ok(())
191}
192
193/// v1 → v2: add `chat_id`, `source` to sessions; ensure `tool_info` on messages;
194/// add the partial unique index on `sessions.chat_id`.
195///
196/// Each ALTER TABLE is wrapped to ignore "duplicate column" errors so the
197/// migration is idempotent (safe to run on a partially migrated DB).
198fn migrate_v1_to_v2(conn: &Connection) -> SqliteResult<()> {
199    let _ = conn.execute("ALTER TABLE sessions ADD COLUMN chat_id TEXT", []);
200    let _ = conn.execute(
201        "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'gui'",
202        [],
203    );
204    let _ = conn.execute("ALTER TABLE messages ADD COLUMN tool_info TEXT", []);
205    conn.execute_batch(
206        "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
207            ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
208    )?;
209    Ok(())
210}
211
212// ============================================================================
213// Schema version helpers (private)
214// ============================================================================
215
216fn ensure_meta_table(conn: &Connection) -> SqliteResult<()> {
217    conn.execute_batch(
218        "CREATE TABLE IF NOT EXISTS _schema_meta (
219            key   TEXT PRIMARY KEY,
220            value TEXT NOT NULL
221        )",
222    )
223}
224
225fn write_schema_version(conn: &Connection, version: i32) -> SqliteResult<()> {
226    conn.execute(
227        "INSERT OR REPLACE INTO _schema_meta (key, value) VALUES ('version', ?1)",
228        rusqlite::params![version.to_string()],
229    )?;
230    Ok(())
231}
232
233// ============================================================================
234// Session and message accessors (public)
235// ============================================================================
236
237/// Insert a new session.
238///
239/// `chat_id` is `Some` only for Bot platforms (the platform chat identifier);
240/// pass `None` for GUI/TUI sessions. `source` records which frontend created
241/// the session (`"gui"`, `"tui"`, `"qq"`, `"feishu"`).
242pub fn insert_session(
243    conn: &Connection,
244    id: &str,
245    chat_id: Option<&str>,
246    title: &str,
247    model: &str,
248    source: &str,
249) -> SqliteResult<()> {
250    let now = current_timestamp();
251    conn.execute(
252        "INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at) \
253         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
254        params![id, chat_id, title, model, source, now, now],
255    )?;
256    Ok(())
257}
258
259/// List all active sessions, ordered by most recently updated.
260///
261/// Pass `Some(source)` to filter by frontend (e.g. `"qq"`); `None` returns
262/// sessions from all sources.
263pub fn list_sessions(
264    conn: &Connection,
265    source_filter: Option<&str>,
266) -> SqliteResult<Vec<SessionInfo>> {
267    let sql = if source_filter.is_some() {
268        "SELECT id, chat_id, title, model, source, created_at, updated_at \
269         FROM sessions WHERE is_active = 1 AND source = ?1 ORDER BY updated_at DESC"
270    } else {
271        "SELECT id, chat_id, title, model, source, created_at, updated_at \
272         FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC"
273    };
274    let mut stmt = conn.prepare(sql)?;
275    let rows = if let Some(source) = source_filter {
276        stmt.query_map(params![source], map_session_row)?
277    } else {
278        stmt.query_map([], map_session_row)?
279    };
280    rows.collect()
281}
282
283/// Find an active session by its platform chat identifier.
284///
285/// Returns `None` for GUI/TUI sessions (where `chat_id` is NULL).
286pub fn find_session_by_chat_id(
287    conn: &Connection,
288    chat_id: &str,
289) -> SqliteResult<Option<SessionInfo>> {
290    let mut stmt = conn.prepare(
291        "SELECT id, chat_id, title, model, source, created_at, updated_at \
292         FROM sessions WHERE chat_id = ?1 AND is_active = 1",
293    )?;
294    let mut rows = stmt.query_map(params![chat_id], map_session_row)?;
295    match rows.next() {
296        Some(Ok(session)) => Ok(Some(session)),
297        _ => Ok(None),
298    }
299}
300
301/// Get a single session by ID.
302pub fn get_session(conn: &Connection, id: &str) -> SqliteResult<Option<SessionInfo>> {
303    let mut stmt = conn.prepare(
304        "SELECT id, chat_id, title, model, source, created_at, updated_at \
305         FROM sessions WHERE id = ?1 AND is_active = 1",
306    )?;
307    let mut rows = stmt.query_map(params![id], map_session_row)?;
308    match rows.next() {
309        Some(Ok(session)) => Ok(Some(session)),
310        _ => Ok(None),
311    }
312}
313
314/// Row mapper shared by all session SELECT queries.
315fn map_session_row(row: &rusqlite::Row<'_>) -> SqliteResult<SessionInfo> {
316    Ok(SessionInfo {
317        id: row.get(0)?,
318        chat_id: row.get(1)?,
319        title: row.get(2)?,
320        model: row.get(3)?,
321        source: row.get(4)?,
322        status: "idle".to_string(),
323        created_at: row.get(5)?,
324        updated_at: row.get(6)?,
325    })
326}
327
328/// Update a session's title.
329pub fn update_session_title(conn: &Connection, id: &str, title: &str) -> SqliteResult<()> {
330    let now = current_timestamp();
331    conn.execute(
332        "UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
333        params![title, now, id],
334    )?;
335    Ok(())
336}
337
338/// Update a session's updated_at timestamp.
339pub fn touch_session(conn: &Connection, id: &str) -> SqliteResult<()> {
340    let now = current_timestamp();
341    conn.execute(
342        "UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
343        params![now, id],
344    )?;
345    Ok(())
346}
347
348/// Soft-delete a session.
349pub fn delete_session(conn: &Connection, id: &str) -> SqliteResult<()> {
350    conn.execute(
351        "UPDATE sessions SET is_active = 0 WHERE id = ?1",
352        params![id],
353    )?;
354    Ok(())
355}
356
357/// Insert a message into a session.
358pub fn insert_message(
359    conn: &Connection,
360    session_id: &str,
361    role: &str,
362    content: &str,
363    tool_name: Option<&str>,
364    tool_call_id: Option<&str>,
365    tool_info: Option<&str>,
366) -> SqliteResult<i64> {
367    let now = current_timestamp();
368    conn.execute(
369        "INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_info, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
370        params![session_id, role, content, tool_name, tool_call_id, tool_info, now],
371    )?;
372    Ok(conn.last_insert_rowid())
373}
374
375/// Get all messages for a session, ordered by creation time.
376pub fn get_messages(conn: &Connection, session_id: &str) -> SqliteResult<Vec<MessageData>> {
377    let mut stmt = conn.prepare(
378        "SELECT id, role, content, tool_name, tool_call_id, tool_info, created_at FROM messages WHERE session_id = ?1 ORDER BY id ASC"
379    )?;
380    let rows = stmt.query_map(params![session_id], |row| {
381        let tool_info_str: Option<String> = row.get(5)?;
382        let tool_info = tool_info_str.and_then(|s| serde_json::from_str(&s).ok());
383        Ok(MessageData {
384            id: row.get(0)?,
385            role: row.get(1)?,
386            content: row.get(2)?,
387            tool_name: row.get(3)?,
388            tool_call_id: row.get(4)?,
389            tool_info,
390            created_at: row.get(6)?,
391        })
392    })?;
393    rows.collect()
394}
395
396/// Update a tool message with result.
397pub fn update_tool_message(
398    conn: &Connection,
399    session_id: &str,
400    tool_call_id: &str,
401    tool_info: &str,
402) -> SqliteResult<()> {
403    conn.execute(
404        "UPDATE messages SET tool_info = ?1 WHERE session_id = ?2 AND tool_call_id = ?3",
405        params![tool_info, session_id, tool_call_id],
406    )?;
407    Ok(())
408}
409
410// ============================================================================
411// Message conversion to/from ChatCompletionRequestMessage (new)
412// ============================================================================
413
414/// Convert stored MessageData to ChatCompletionRequestMessage
415pub fn message_to_chat_message(data: &MessageData) -> Result<ChatCompletionRequestMessage> {
416    match data.role.as_str() {
417        "user" => Ok(ChatCompletionRequestMessage::User(
418            ChatCompletionRequestUserMessage {
419                content: ChatCompletionRequestUserMessageContent::Text(data.content.clone()),
420                name: None,
421            }
422            .into(),
423        )),
424        "assistant" => {
425            // Try to parse tool_calls from tool_info
426            let tool_calls = if let Some(tool_info) = &data.tool_info {
427                if let serde_json::Value::Object(obj) = tool_info {
428                    if let Some(serde_json::Value::Array(arr)) = obj.get("tool_calls") {
429                        use async_openai::types::chat::{
430                            ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
431                        };
432                        let mut calls = Vec::new();
433                        for call_val in arr {
434                            if let Ok(call) =
435                                serde_json::from_value::<ChatCompletionMessageToolCall>(
436                                    call_val.clone(),
437                                )
438                            {
439                                calls.push(ChatCompletionMessageToolCalls::Function(call));
440                            }
441                        }
442                        if !calls.is_empty() {
443                            Some(calls)
444                        } else {
445                            None
446                        }
447                    } else {
448                        None
449                    }
450                } else {
451                    None
452                }
453            } else {
454                None
455            };
456
457            #[allow(deprecated)]
458            Ok(ChatCompletionRequestMessage::Assistant(
459                ChatCompletionRequestAssistantMessage {
460                    content: if data.content.is_empty() {
461                        None
462                    } else {
463                        Some(data.content.clone().into())
464                    },
465                    name: None,
466                    tool_calls,
467                    refusal: None,
468                    audio: None,
469                    function_call: None,
470                }
471                .into(),
472            ))
473        }
474        "tool" => {
475            let tool_call_id = data.tool_call_id.clone().unwrap_or_default();
476            Ok(ChatCompletionRequestMessage::Tool(
477                ChatCompletionRequestToolMessage {
478                    content: data.content.clone().into(),
479                    tool_call_id,
480                }
481                .into(),
482            ))
483        }
484        _ => Err(crate::error::AgentError::InternalError(format!(
485            "Unknown role: {}",
486            data.role
487        ))),
488    }
489}
490
491/// Load all messages for a session and convert to chat messages
492pub fn load_chat_messages(
493    conn: &Connection,
494    session_id: &str,
495) -> Result<Vec<ChatCompletionRequestMessage>> {
496    let messages = get_messages(conn, session_id)?;
497    let mut result = Vec::with_capacity(messages.len());
498    for msg in messages {
499        match message_to_chat_message(&msg) {
500            Ok(chat_msg) => result.push(chat_msg),
501            Err(e) => {
502                tracing::warn!("Failed to convert message: {}", e);
503            }
504        }
505    }
506    Ok(result)
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn resolves_local_db_path() {
515        let working_dir = PathBuf::from("project");
516        let path = resolve_db_path(&working_dir, false).unwrap();
517        assert_eq!(
518            path,
519            working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)
520        );
521    }
522
523    #[test]
524    fn session_crud() {
525        let conn = Connection::open_in_memory().unwrap();
526        init_db(&conn).unwrap();
527
528        insert_session(
529            &conn,
530            "test-123",
531            None,
532            "Test Session",
533            "deepseek/deepseek-chat",
534            "gui",
535        )
536        .unwrap();
537
538        let sessions = list_sessions(&conn, None).unwrap();
539        assert_eq!(sessions.len(), 1);
540        assert_eq!(sessions[0].id, "test-123");
541        assert_eq!(sessions[0].title, "Test Session");
542        assert_eq!(sessions[0].source, "gui");
543        assert_eq!(sessions[0].chat_id, None);
544        assert_eq!(sessions[0].status, "idle");
545
546        let session = get_session(&conn, "test-123").unwrap().unwrap();
547        assert_eq!(session.title, "Test Session");
548        assert_eq!(session.source, "gui");
549
550        update_session_title(&conn, "test-123", "Updated Title").unwrap();
551        let updated = get_session(&conn, "test-123").unwrap().unwrap();
552        assert_eq!(updated.title, "Updated Title");
553
554        delete_session(&conn, "test-123").unwrap();
555        assert!(get_session(&conn, "test-123").unwrap().is_none());
556        assert!(list_sessions(&conn, None).unwrap().is_empty());
557    }
558
559    #[test]
560    fn message_operations() {
561        let conn = Connection::open_in_memory().unwrap();
562        init_db(&conn).unwrap();
563
564        insert_session(&conn, "session-msg", None, "Chat Session", "model", "gui").unwrap();
565        let user_id = insert_message(
566            &conn,
567            "session-msg",
568            "user",
569            "Hello Robit",
570            None,
571            None,
572            None,
573        )
574        .unwrap();
575        let assistant_id = insert_message(
576            &conn,
577            "session-msg",
578            "assistant",
579            "Hello! How can I help?",
580            None,
581            None,
582            None,
583        )
584        .unwrap();
585
586        let messages = get_messages(&conn, "session-msg").unwrap();
587        assert_eq!(messages.len(), 2);
588        assert_eq!(messages[0].id, user_id);
589        assert_eq!(messages[0].role, "user");
590        assert_eq!(messages[0].content, "Hello Robit");
591        assert_eq!(messages[1].id, assistant_id);
592        assert_eq!(messages[1].role, "assistant");
593        assert_eq!(messages[1].content, "Hello! How can I help?");
594    }
595
596    #[test]
597    fn empty_sessions() {
598        let conn = Connection::open_in_memory().unwrap();
599        init_db(&conn).unwrap();
600
601        let sessions = list_sessions(&conn, None).unwrap();
602        assert_eq!(sessions.len(), 0);
603    }
604
605    #[test]
606    fn get_nonexistent_session() {
607        let conn = Connection::open_in_memory().unwrap();
608        init_db(&conn).unwrap();
609
610        let session = get_session(&conn, "nonexistent").unwrap();
611        assert!(session.is_none());
612    }
613
614    #[test]
615    fn tool_message_update() {
616        let conn = Connection::open_in_memory().unwrap();
617        init_db(&conn).unwrap();
618
619        insert_session(&conn, "session-tool", None, "Tool Session", "model", "gui").unwrap();
620        let initial = serde_json::json!({
621            "tool_call_id": "tool-1",
622            "name": "bash",
623            "arguments": "{}",
624            "status": "pending",
625            "requires_confirm": true
626        })
627        .to_string();
628        insert_message(
629            &conn,
630            "session-tool",
631            "tool",
632            "{}",
633            Some("bash"),
634            Some("tool-1"),
635            Some(&initial),
636        )
637        .unwrap();
638
639        let updated = serde_json::json!({
640            "tool_call_id": "tool-1",
641            "status": "success",
642            "output": "done"
643        })
644        .to_string();
645        update_tool_message(&conn, "session-tool", "tool-1", &updated).unwrap();
646
647        let messages = get_messages(&conn, "session-tool").unwrap();
648        assert_eq!(messages.len(), 1);
649        assert_eq!(messages[0].tool_name.as_deref(), Some("bash"));
650        assert_eq!(messages[0].tool_call_id.as_deref(), Some("tool-1"));
651        assert_eq!(messages[0].tool_info.as_ref().unwrap()["status"], "success");
652        assert_eq!(messages[0].tool_info.as_ref().unwrap()["output"], "done");
653    }
654
655    #[test]
656    fn chat_id_lookup_and_source_filter() {
657        let conn = Connection::open_in_memory().unwrap();
658        init_db(&conn).unwrap();
659
660        insert_session(&conn, "gui-1", None, "GUI Session", "model", "gui").unwrap();
661        insert_session(
662            &conn,
663            "qq-1",
664            Some("group:abc"),
665            "技术讨论群",
666            "model",
667            "qq",
668        )
669        .unwrap();
670        insert_session(
671            &conn,
672            "qq-2",
673            Some("private:xyz"),
674            "私聊",
675            "model",
676            "qq",
677        )
678        .unwrap();
679
680        // find_session_by_chat_id
681        let found = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
682        assert_eq!(found.id, "qq-1");
683        assert_eq!(found.source, "qq");
684        assert_eq!(found.chat_id.as_deref(), Some("group:abc"));
685
686        // chat_id lookup returns None for GUI sessions (NULL chat_id)
687        assert!(find_session_by_chat_id(&conn, "does-not-exist")
688            .unwrap()
689            .is_none());
690
691        // source filter
692        let qq_sessions = list_sessions(&conn, Some("qq")).unwrap();
693        assert_eq!(qq_sessions.len(), 2);
694        assert!(qq_sessions.iter().all(|s| s.source == "qq"));
695
696        let gui_sessions = list_sessions(&conn, Some("gui")).unwrap();
697        assert_eq!(gui_sessions.len(), 1);
698        assert_eq!(gui_sessions[0].id, "gui-1");
699
700        // no filter returns all
701        assert_eq!(list_sessions(&conn, None).unwrap().len(), 3);
702    }
703
704    #[test]
705    fn chat_id_unique_per_chat() {
706        let conn = Connection::open_in_memory().unwrap();
707        init_db(&conn).unwrap();
708
709        insert_session(
710            &conn,
711            "qq-1",
712            Some("group:abc"),
713            "First",
714            "model",
715            "qq",
716        )
717        .unwrap();
718        // Inserting a second session with the same chat_id must fail (unique index).
719        let err = insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq");
720        assert!(err.is_err());
721    }
722
723    #[test]
724    fn migrates_legacy_v1_database() {
725        let conn = Connection::open_in_memory().unwrap();
726        // Simulate a legacy v1 database: old schema, no _schema_meta.
727        conn.execute_batch(
728            "CREATE TABLE sessions (
729                id          TEXT PRIMARY KEY,
730                title       TEXT NOT NULL,
731                model       TEXT NOT NULL,
732                created_at  TEXT NOT NULL,
733                updated_at  TEXT NOT NULL,
734                is_active   INTEGER DEFAULT 1
735            );
736            CREATE TABLE messages (
737                id           INTEGER PRIMARY KEY AUTOINCREMENT,
738                session_id   TEXT NOT NULL REFERENCES sessions(id),
739                role         TEXT NOT NULL,
740                content      TEXT NOT NULL,
741                tool_name    TEXT,
742                tool_call_id TEXT,
743                tokens       INTEGER,
744                created_at   TEXT NOT NULL
745            );",
746        )
747        .unwrap();
748        conn.execute(
749            "INSERT INTO sessions (id, title, model, created_at, updated_at) \
750             VALUES ('legacy-1', 'Legacy', 'model', '2020-01-01', '2020-01-01')",
751            [],
752        )
753        .unwrap();
754
755        // Run init_db — it should detect v0 → ... actually no _schema_meta means version 0,
756        // but tables already exist. create_all_tables uses IF NOT EXISTS so it's safe,
757        // and version is written as current. The legacy row's source defaults to 'gui'.
758        init_db(&conn).unwrap();
759
760        // Schema version is now current.
761        let v: i32 = read_schema_version(&conn).unwrap();
762        assert_eq!(v, CURRENT_SCHEMA_VERSION);
763
764        // New columns exist and legacy data is preserved.
765        let session = get_session(&conn, "legacy-1").unwrap().unwrap();
766        assert_eq!(session.title, "Legacy");
767        assert_eq!(session.source, "gui");
768        assert_eq!(session.chat_id, None);
769    }
770
771    #[test]
772    fn init_db_is_idempotent() {
773        let conn = Connection::open_in_memory().unwrap();
774        init_db(&conn).unwrap();
775        // Running again on an already-current DB must not error.
776        init_db(&conn).unwrap();
777        assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
778    }
779}