Skip to main content

opensession_local_db/
lib.rs

1pub mod git;
2
3use anyhow::{Context, Result};
4use opensession_api::db::migrations::{LOCAL_MIGRATIONS, MIGRATIONS};
5use opensession_api::{SessionSummary, SortOrder, TimeRange};
6use opensession_core::trace::Session;
7use rusqlite::{params, Connection, OptionalExtension};
8use serde_json::Value;
9use std::collections::HashSet;
10use std::fs;
11use std::path::PathBuf;
12use std::sync::Mutex;
13
14use git::GitContext;
15
16/// A local session row stored in the local SQLite database.
17#[derive(Debug, Clone)]
18pub struct LocalSessionRow {
19    pub id: String,
20    pub source_path: Option<String>,
21    pub sync_status: String,
22    pub last_synced_at: Option<String>,
23    pub user_id: Option<String>,
24    pub nickname: Option<String>,
25    pub team_id: Option<String>,
26    pub tool: String,
27    pub agent_provider: Option<String>,
28    pub agent_model: Option<String>,
29    pub title: Option<String>,
30    pub description: Option<String>,
31    pub tags: Option<String>,
32    pub created_at: String,
33    pub uploaded_at: Option<String>,
34    pub message_count: i64,
35    pub user_message_count: i64,
36    pub task_count: i64,
37    pub event_count: i64,
38    pub duration_seconds: i64,
39    pub total_input_tokens: i64,
40    pub total_output_tokens: i64,
41    pub git_remote: Option<String>,
42    pub git_branch: Option<String>,
43    pub git_commit: Option<String>,
44    pub git_repo_name: Option<String>,
45    pub pr_number: Option<i64>,
46    pub pr_url: Option<String>,
47    pub working_directory: Option<String>,
48    pub files_modified: Option<String>,
49    pub files_read: Option<String>,
50    pub has_errors: bool,
51    pub max_active_agents: i64,
52}
53
54/// A link between a git commit and an AI session.
55#[derive(Debug, Clone)]
56pub struct CommitLink {
57    pub commit_hash: String,
58    pub session_id: String,
59    pub repo_path: Option<String>,
60    pub branch: Option<String>,
61    pub created_at: String,
62}
63
64/// Return true when a cached row corresponds to an OpenCode child session.
65pub fn is_opencode_child_session(row: &LocalSessionRow) -> bool {
66    if row.tool != "opencode" {
67        return false;
68    }
69
70    // Strong heuristic: some orchestration artifacts are generated as sessions
71    // that do not carry a user-visible request surface and only contain system
72    // or agent-only events.
73    if row.user_message_count <= 0
74        && row.message_count <= 4
75        && row.task_count <= 4
76        && row.event_count > 0
77        && row.event_count <= 16
78    {
79        return true;
80    }
81
82    if is_opencode_subagent_source(row.source_path.as_deref()) {
83        return true;
84    }
85
86    let source_path = match row.source_path.as_deref() {
87        Some(path) if !path.trim().is_empty() => path,
88        _ => return false,
89    };
90
91    parse_opencode_parent_session_id(source_path)
92        .is_some_and(|parent_id| !parent_id.trim().is_empty())
93}
94
95/// Return true for likely synthetic Codex rows generated by internal orchestration.
96fn is_codex_internal_session(row: &LocalSessionRow) -> bool {
97    if row.tool != "codex" {
98        return false;
99    }
100
101    row.user_message_count == 0
102}
103
104/// Parse `parentID` / `parentId` from an OpenCode session JSON file.
105pub fn parse_opencode_parent_session_id(source_path: &str) -> Option<String> {
106    let text = fs::read_to_string(source_path).ok()?;
107    let json: Value = serde_json::from_str(&text).ok()?;
108    lookup_parent_session_id(&json)
109}
110
111fn lookup_parent_session_id(value: &Value) -> Option<String> {
112    match value {
113        Value::Object(obj) => {
114            for (key, value) in obj {
115                if is_parent_id_key(key) {
116                    if let Some(parent_id) = value.as_str() {
117                        let parent_id = parent_id.trim();
118                        if !parent_id.is_empty() {
119                            return Some(parent_id.to_string());
120                        }
121                    }
122                }
123                if let Some(parent_id) = lookup_parent_session_id(value) {
124                    return Some(parent_id);
125                }
126            }
127            None
128        }
129        Value::Array(items) => items.iter().find_map(lookup_parent_session_id),
130        _ => None,
131    }
132}
133
134fn is_parent_id_key(key: &str) -> bool {
135    let flat = key
136        .chars()
137        .filter(|c| c.is_ascii_alphanumeric())
138        .map(|c| c.to_ascii_lowercase())
139        .collect::<String>();
140
141    flat == "parentid"
142        || flat == "parentuuid"
143        || flat == "parentsessionid"
144        || flat == "parentsessionuuid"
145        || flat.ends_with("parentsessionid")
146        || (flat.contains("parent") && flat.ends_with("id"))
147        || (flat.contains("parent") && flat.ends_with("uuid"))
148}
149
150/// Remove OpenCode child sessions so only parent sessions remain visible.
151pub fn hide_opencode_child_sessions(mut rows: Vec<LocalSessionRow>) -> Vec<LocalSessionRow> {
152    rows.retain(|row| {
153        !is_opencode_child_session(row)
154            && !is_codex_internal_session(row)
155            && !is_claude_subagent_session(row)
156    });
157    rows
158}
159
160fn is_opencode_subagent_source(source_path: Option<&str>) -> bool {
161    is_subagent_source(source_path)
162}
163
164fn is_claude_subagent_session(row: &LocalSessionRow) -> bool {
165    if row.tool != "claude-code" {
166        return false;
167    }
168
169    is_subagent_source(row.source_path.as_deref())
170}
171
172fn is_subagent_source(source_path: Option<&str>) -> bool {
173    let Some(source_path) = source_path.map(|path| path.to_ascii_lowercase()) else {
174        return false;
175    };
176
177    if source_path.contains("/subagents/") || source_path.contains("\\subagents\\") {
178        return true;
179    }
180
181    let filename = match std::path::Path::new(&source_path).file_name() {
182        Some(name) => name.to_string_lossy(),
183        None => return false,
184    };
185
186    filename.starts_with("agent-")
187        || filename.starts_with("agent_")
188        || filename.starts_with("subagent-")
189        || filename.starts_with("subagent_")
190}
191
192/// Filter for listing sessions from the local DB.
193#[derive(Debug, Clone)]
194pub struct LocalSessionFilter {
195    pub team_id: Option<String>,
196    pub sync_status: Option<String>,
197    pub git_repo_name: Option<String>,
198    pub search: Option<String>,
199    pub tool: Option<String>,
200    pub sort: SortOrder,
201    pub time_range: TimeRange,
202    pub limit: Option<u32>,
203    pub offset: Option<u32>,
204}
205
206impl Default for LocalSessionFilter {
207    fn default() -> Self {
208        Self {
209            team_id: None,
210            sync_status: None,
211            git_repo_name: None,
212            search: None,
213            tool: None,
214            sort: SortOrder::Recent,
215            time_range: TimeRange::All,
216            limit: None,
217            offset: None,
218        }
219    }
220}
221
222/// Extended filter for the `log` command.
223#[derive(Debug, Default)]
224pub struct LogFilter {
225    /// Filter by tool name (exact match).
226    pub tool: Option<String>,
227    /// Filter by model (glob-like, uses LIKE).
228    pub model: Option<String>,
229    /// Filter sessions created after this ISO8601 timestamp.
230    pub since: Option<String>,
231    /// Filter sessions created before this ISO8601 timestamp.
232    pub before: Option<String>,
233    /// Filter sessions that touched this file path (searches files_modified JSON).
234    pub touches: Option<String>,
235    /// Free-text search in title, description, tags.
236    pub grep: Option<String>,
237    /// Only sessions with errors.
238    pub has_errors: Option<bool>,
239    /// Filter by working directory (prefix match).
240    pub working_directory: Option<String>,
241    /// Filter by git repo name.
242    pub git_repo_name: Option<String>,
243    /// Filter sessions linked to this git commit hash.
244    pub commit: Option<String>,
245    /// Maximum number of results.
246    pub limit: Option<u32>,
247    /// Offset for pagination.
248    pub offset: Option<u32>,
249}
250
251/// Base FROM clause for session list queries.
252const FROM_CLAUSE: &str = "\
253FROM sessions s \
254LEFT JOIN session_sync ss ON ss.session_id = s.id \
255LEFT JOIN users u ON u.id = s.user_id";
256
257/// Local SQLite database shared by TUI and Daemon.
258/// Thread-safe: wraps the connection in a Mutex so it can be shared via `Arc<LocalDb>`.
259pub struct LocalDb {
260    conn: Mutex<Connection>,
261}
262
263impl LocalDb {
264    /// Open (or create) the local database at the default path.
265    /// `~/.local/share/opensession/local.db`
266    pub fn open() -> Result<Self> {
267        let path = default_db_path()?;
268        Self::open_path(&path)
269    }
270
271    /// Open (or create) the local database at a specific path.
272    pub fn open_path(path: &PathBuf) -> Result<Self> {
273        if let Some(parent) = path.parent() {
274            std::fs::create_dir_all(parent)
275                .with_context(|| format!("create dir for {}", path.display()))?;
276        }
277        match open_connection_with_latest_schema(path) {
278            Ok(conn) => Ok(Self {
279                conn: Mutex::new(conn),
280            }),
281            Err(err) => {
282                if !is_schema_compat_error(&err) {
283                    return Err(err);
284                }
285
286                // Local DB is a cache. If schema migration cannot safely reconcile
287                // a legacy/corrupted file, rotate it out and recreate with latest schema.
288                rotate_legacy_db(path)?;
289
290                let conn = open_connection_with_latest_schema(path)
291                    .with_context(|| format!("recreate db {}", path.display()))?;
292                Ok(Self {
293                    conn: Mutex::new(conn),
294                })
295            }
296        }
297    }
298
299    fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
300        self.conn.lock().expect("local db mutex poisoned")
301    }
302
303    // ── Upsert local session (parsed from file) ────────────────────────
304
305    pub fn upsert_local_session(
306        &self,
307        session: &Session,
308        source_path: &str,
309        git: &GitContext,
310    ) -> Result<()> {
311        let title = session.context.title.as_deref();
312        let description = session.context.description.as_deref();
313        let tags = if session.context.tags.is_empty() {
314            None
315        } else {
316            Some(session.context.tags.join(","))
317        };
318        let created_at = session.context.created_at.to_rfc3339();
319        let cwd = session
320            .context
321            .attributes
322            .get("cwd")
323            .or_else(|| session.context.attributes.get("working_directory"))
324            .and_then(|v| v.as_str().map(String::from));
325
326        // Extract files_modified, files_read, and has_errors from events
327        let (files_modified, files_read, has_errors) =
328            opensession_core::extract::extract_file_metadata(session);
329        let max_active_agents = opensession_core::agent_metrics::max_active_agents(session) as i64;
330
331        let conn = self.conn();
332        conn.execute(
333            "INSERT INTO sessions \
334             (id, team_id, tool, agent_provider, agent_model, \
335              title, description, tags, created_at, \
336              message_count, user_message_count, task_count, event_count, duration_seconds, \
337              total_input_tokens, total_output_tokens, body_storage_key, \
338              git_remote, git_branch, git_commit, git_repo_name, working_directory, \
339              files_modified, files_read, has_errors, max_active_agents) \
340             VALUES (?1,'personal',?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,'',?16,?17,?18,?19,?20,?21,?22,?23,?24) \
341             ON CONFLICT(id) DO UPDATE SET \
342              tool=excluded.tool, agent_provider=excluded.agent_provider, \
343              agent_model=excluded.agent_model, \
344              title=excluded.title, description=excluded.description, \
345              tags=excluded.tags, \
346              message_count=excluded.message_count, user_message_count=excluded.user_message_count, \
347              task_count=excluded.task_count, \
348              event_count=excluded.event_count, duration_seconds=excluded.duration_seconds, \
349              total_input_tokens=excluded.total_input_tokens, \
350              total_output_tokens=excluded.total_output_tokens, \
351              git_remote=excluded.git_remote, git_branch=excluded.git_branch, \
352              git_commit=excluded.git_commit, git_repo_name=excluded.git_repo_name, \
353              working_directory=excluded.working_directory, \
354              files_modified=excluded.files_modified, files_read=excluded.files_read, \
355              has_errors=excluded.has_errors, \
356              max_active_agents=excluded.max_active_agents",
357            params![
358                &session.session_id,
359                &session.agent.tool,
360                &session.agent.provider,
361                &session.agent.model,
362                title,
363                description,
364                &tags,
365                &created_at,
366                session.stats.message_count as i64,
367                session.stats.user_message_count as i64,
368                session.stats.task_count as i64,
369                session.stats.event_count as i64,
370                session.stats.duration_seconds as i64,
371                session.stats.total_input_tokens as i64,
372                session.stats.total_output_tokens as i64,
373                &git.remote,
374                &git.branch,
375                &git.commit,
376                &git.repo_name,
377                &cwd,
378                &files_modified,
379                &files_read,
380                has_errors,
381                max_active_agents,
382            ],
383        )?;
384
385        conn.execute(
386            "INSERT INTO session_sync (session_id, source_path, sync_status) \
387             VALUES (?1, ?2, 'local_only') \
388             ON CONFLICT(session_id) DO UPDATE SET source_path=excluded.source_path",
389            params![&session.session_id, source_path],
390        )?;
391        Ok(())
392    }
393
394    // ── Upsert remote session (from server sync pull) ──────────────────
395
396    pub fn upsert_remote_session(&self, summary: &SessionSummary) -> Result<()> {
397        let conn = self.conn();
398        conn.execute(
399            "INSERT INTO sessions \
400             (id, user_id, team_id, tool, agent_provider, agent_model, \
401              title, description, tags, created_at, uploaded_at, \
402              message_count, task_count, event_count, duration_seconds, \
403              total_input_tokens, total_output_tokens, body_storage_key, \
404              git_remote, git_branch, git_commit, git_repo_name, \
405              pr_number, pr_url, working_directory, \
406              files_modified, files_read, has_errors, max_active_agents) \
407             VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,'',?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28) \
408             ON CONFLICT(id) DO UPDATE SET \
409              title=excluded.title, description=excluded.description, \
410              tags=excluded.tags, uploaded_at=excluded.uploaded_at, \
411              message_count=excluded.message_count, task_count=excluded.task_count, \
412              event_count=excluded.event_count, duration_seconds=excluded.duration_seconds, \
413              total_input_tokens=excluded.total_input_tokens, \
414              total_output_tokens=excluded.total_output_tokens, \
415              git_remote=excluded.git_remote, git_branch=excluded.git_branch, \
416              git_commit=excluded.git_commit, git_repo_name=excluded.git_repo_name, \
417              pr_number=excluded.pr_number, pr_url=excluded.pr_url, \
418              working_directory=excluded.working_directory, \
419              files_modified=excluded.files_modified, files_read=excluded.files_read, \
420              has_errors=excluded.has_errors, \
421              max_active_agents=excluded.max_active_agents",
422            params![
423                &summary.id,
424                &summary.user_id,
425                &summary.team_id,
426                &summary.tool,
427                &summary.agent_provider,
428                &summary.agent_model,
429                &summary.title,
430                &summary.description,
431                &summary.tags,
432                &summary.created_at,
433                &summary.uploaded_at,
434                summary.message_count,
435                summary.task_count,
436                summary.event_count,
437                summary.duration_seconds,
438                summary.total_input_tokens,
439                summary.total_output_tokens,
440                &summary.git_remote,
441                &summary.git_branch,
442                &summary.git_commit,
443                &summary.git_repo_name,
444                summary.pr_number,
445                &summary.pr_url,
446                &summary.working_directory,
447                &summary.files_modified,
448                &summary.files_read,
449                summary.has_errors,
450                summary.max_active_agents,
451            ],
452        )?;
453
454        conn.execute(
455            "INSERT INTO session_sync (session_id, sync_status) \
456             VALUES (?1, 'remote_only') \
457             ON CONFLICT(session_id) DO UPDATE SET \
458              sync_status = CASE WHEN session_sync.sync_status = 'local_only' THEN 'synced' ELSE session_sync.sync_status END",
459            params![&summary.id],
460        )?;
461        Ok(())
462    }
463
464    // ── List sessions ──────────────────────────────────────────────────
465
466    fn build_local_session_where_clause(
467        filter: &LocalSessionFilter,
468    ) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
469        let mut where_clauses = vec![
470            "1=1".to_string(),
471            "NOT (s.tool = 'codex' AND COALESCE(s.user_message_count, 0) = 0)".to_string(),
472            "NOT (s.tool = 'claude-code' AND (LOWER(COALESCE(ss.source_path, '')) LIKE '%/subagents/%' OR LOWER(COALESCE(ss.source_path, '')) LIKE '%\\\\subagents\\\\%'))".to_string(),
473            "NOT (s.tool = 'opencode' AND (LOWER(COALESCE(ss.source_path, '')) LIKE '%/subagents/%' OR LOWER(COALESCE(ss.source_path, '')) LIKE '%\\\\subagents\\\\%'))".to_string(),
474            "NOT (s.tool = 'opencode' AND COALESCE(s.user_message_count, 0) <= 0 AND COALESCE(s.message_count, 0) <= 4 AND COALESCE(s.task_count, 0) <= 4 AND COALESCE(s.event_count, 0) > 0 AND COALESCE(s.event_count, 0) <= 16)".to_string(),
475        ];
476        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
477        let mut idx = 1u32;
478
479        if let Some(ref team_id) = filter.team_id {
480            where_clauses.push(format!("s.team_id = ?{idx}"));
481            param_values.push(Box::new(team_id.clone()));
482            idx += 1;
483        }
484
485        if let Some(ref sync_status) = filter.sync_status {
486            where_clauses.push(format!("COALESCE(ss.sync_status, 'unknown') = ?{idx}"));
487            param_values.push(Box::new(sync_status.clone()));
488            idx += 1;
489        }
490
491        if let Some(ref repo) = filter.git_repo_name {
492            where_clauses.push(format!("s.git_repo_name = ?{idx}"));
493            param_values.push(Box::new(repo.clone()));
494            idx += 1;
495        }
496
497        if let Some(ref tool) = filter.tool {
498            where_clauses.push(format!("s.tool = ?{idx}"));
499            param_values.push(Box::new(tool.clone()));
500            idx += 1;
501        }
502
503        if let Some(ref search) = filter.search {
504            let like = format!("%{search}%");
505            where_clauses.push(format!(
506                "(s.title LIKE ?{i1} OR s.description LIKE ?{i2} OR s.tags LIKE ?{i3})",
507                i1 = idx,
508                i2 = idx + 1,
509                i3 = idx + 2,
510            ));
511            param_values.push(Box::new(like.clone()));
512            param_values.push(Box::new(like.clone()));
513            param_values.push(Box::new(like));
514            idx += 3;
515        }
516
517        let interval = match filter.time_range {
518            TimeRange::Hours24 => Some("-1 day"),
519            TimeRange::Days7 => Some("-7 days"),
520            TimeRange::Days30 => Some("-30 days"),
521            TimeRange::All => None,
522        };
523        if let Some(interval) = interval {
524            where_clauses.push(format!("datetime(s.created_at) >= datetime('now', ?{idx})"));
525            param_values.push(Box::new(interval.to_string()));
526        }
527
528        (where_clauses.join(" AND "), param_values)
529    }
530
531    pub fn list_sessions(&self, filter: &LocalSessionFilter) -> Result<Vec<LocalSessionRow>> {
532        let (where_str, mut param_values) = Self::build_local_session_where_clause(filter);
533        let order_clause = match filter.sort {
534            SortOrder::Popular => "s.message_count DESC, s.created_at DESC",
535            SortOrder::Longest => "s.duration_seconds DESC, s.created_at DESC",
536            SortOrder::Recent => "s.created_at DESC",
537        };
538
539        let mut sql = format!(
540            "SELECT {LOCAL_SESSION_COLUMNS} \
541             {FROM_CLAUSE} WHERE {where_str} \
542             ORDER BY {order_clause}"
543        );
544
545        if let Some(limit) = filter.limit {
546            sql.push_str(" LIMIT ?");
547            param_values.push(Box::new(limit));
548            if let Some(offset) = filter.offset {
549                sql.push_str(" OFFSET ?");
550                param_values.push(Box::new(offset));
551            }
552        }
553
554        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
555            param_values.iter().map(|p| p.as_ref()).collect();
556        let conn = self.conn();
557        let mut stmt = conn.prepare(&sql)?;
558        let rows = stmt.query_map(param_refs.as_slice(), row_to_local_session)?;
559
560        let mut result = Vec::new();
561        for row in rows {
562            result.push(row?);
563        }
564
565        Ok(hide_opencode_child_sessions(result))
566    }
567
568    /// Count sessions for a given list filter (before UI-level page slicing).
569    pub fn count_sessions_filtered(&self, filter: &LocalSessionFilter) -> Result<i64> {
570        let mut count_filter = filter.clone();
571        count_filter.limit = None;
572        count_filter.offset = None;
573        let (where_str, param_values) = Self::build_local_session_where_clause(&count_filter);
574        let sql = format!("SELECT COUNT(*) {FROM_CLAUSE} WHERE {where_str}");
575        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
576            param_values.iter().map(|p| p.as_ref()).collect();
577        let conn = self.conn();
578        let count = conn.query_row(&sql, param_refs.as_slice(), |row| row.get(0))?;
579        Ok(count)
580    }
581
582    /// List distinct tool names for the current list filter (ignores active tool filter).
583    pub fn list_session_tools(&self, filter: &LocalSessionFilter) -> Result<Vec<String>> {
584        let mut tool_filter = filter.clone();
585        tool_filter.tool = None;
586        tool_filter.limit = None;
587        tool_filter.offset = None;
588        let (where_str, param_values) = Self::build_local_session_where_clause(&tool_filter);
589        let sql = format!(
590            "SELECT DISTINCT s.tool \
591             {FROM_CLAUSE} WHERE {where_str} \
592             ORDER BY s.tool ASC"
593        );
594        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
595            param_values.iter().map(|p| p.as_ref()).collect();
596        let conn = self.conn();
597        let mut stmt = conn.prepare(&sql)?;
598        let rows = stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))?;
599
600        let mut tools = Vec::new();
601        for row in rows {
602            let tool = row?;
603            if !tool.trim().is_empty() {
604                tools.push(tool);
605            }
606        }
607        Ok(tools)
608    }
609
610    // ── Log query ─────────────────────────────────────────────────────
611
612    /// Query sessions with extended filters for the `log` command.
613    pub fn list_sessions_log(&self, filter: &LogFilter) -> Result<Vec<LocalSessionRow>> {
614        let mut where_clauses = vec!["1=1".to_string()];
615        let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
616        let mut idx = 1u32;
617
618        if let Some(ref tool) = filter.tool {
619            where_clauses.push(format!("s.tool = ?{idx}"));
620            param_values.push(Box::new(tool.clone()));
621            idx += 1;
622        }
623
624        if let Some(ref model) = filter.model {
625            let like = model.replace('*', "%");
626            where_clauses.push(format!("s.agent_model LIKE ?{idx}"));
627            param_values.push(Box::new(like));
628            idx += 1;
629        }
630
631        if let Some(ref since) = filter.since {
632            where_clauses.push(format!("s.created_at >= ?{idx}"));
633            param_values.push(Box::new(since.clone()));
634            idx += 1;
635        }
636
637        if let Some(ref before) = filter.before {
638            where_clauses.push(format!("s.created_at < ?{idx}"));
639            param_values.push(Box::new(before.clone()));
640            idx += 1;
641        }
642
643        if let Some(ref touches) = filter.touches {
644            let like = format!("%\"{touches}\"%");
645            where_clauses.push(format!("s.files_modified LIKE ?{idx}"));
646            param_values.push(Box::new(like));
647            idx += 1;
648        }
649
650        if let Some(ref grep) = filter.grep {
651            let like = format!("%{grep}%");
652            where_clauses.push(format!(
653                "(s.title LIKE ?{i1} OR s.description LIKE ?{i2} OR s.tags LIKE ?{i3})",
654                i1 = idx,
655                i2 = idx + 1,
656                i3 = idx + 2,
657            ));
658            param_values.push(Box::new(like.clone()));
659            param_values.push(Box::new(like.clone()));
660            param_values.push(Box::new(like));
661            idx += 3;
662        }
663
664        if let Some(true) = filter.has_errors {
665            where_clauses.push("s.has_errors = 1".to_string());
666        }
667
668        if let Some(ref wd) = filter.working_directory {
669            where_clauses.push(format!("s.working_directory LIKE ?{idx}"));
670            param_values.push(Box::new(format!("{wd}%")));
671            idx += 1;
672        }
673
674        if let Some(ref repo) = filter.git_repo_name {
675            where_clauses.push(format!("s.git_repo_name = ?{idx}"));
676            param_values.push(Box::new(repo.clone()));
677            idx += 1;
678        }
679
680        // Optional JOIN for commit hash filter
681        let mut extra_join = String::new();
682        if let Some(ref commit) = filter.commit {
683            extra_join =
684                " INNER JOIN commit_session_links csl ON csl.session_id = s.id".to_string();
685            where_clauses.push(format!("csl.commit_hash = ?{idx}"));
686            param_values.push(Box::new(commit.clone()));
687            idx += 1;
688        }
689
690        let _ = idx; // suppress unused warning
691
692        let where_str = where_clauses.join(" AND ");
693        let mut sql = format!(
694            "SELECT {LOCAL_SESSION_COLUMNS} \
695             {FROM_CLAUSE}{extra_join} WHERE {where_str} \
696             ORDER BY s.created_at DESC"
697        );
698
699        if let Some(limit) = filter.limit {
700            sql.push_str(" LIMIT ?");
701            param_values.push(Box::new(limit));
702            if let Some(offset) = filter.offset {
703                sql.push_str(" OFFSET ?");
704                param_values.push(Box::new(offset));
705            }
706        }
707
708        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
709            param_values.iter().map(|p| p.as_ref()).collect();
710        let conn = self.conn();
711        let mut stmt = conn.prepare(&sql)?;
712        let rows = stmt.query_map(param_refs.as_slice(), row_to_local_session)?;
713
714        let mut result = Vec::new();
715        for row in rows {
716            result.push(row?);
717        }
718        Ok(hide_opencode_child_sessions(result))
719    }
720
721    /// Get the latest N sessions for a specific tool, ordered by created_at DESC.
722    pub fn get_sessions_by_tool_latest(
723        &self,
724        tool: &str,
725        count: u32,
726    ) -> Result<Vec<LocalSessionRow>> {
727        let sql = format!(
728            "SELECT {LOCAL_SESSION_COLUMNS} \
729             {FROM_CLAUSE} WHERE s.tool = ?1 \
730             ORDER BY s.created_at DESC"
731        );
732        let conn = self.conn();
733        let mut stmt = conn.prepare(&sql)?;
734        let rows = stmt.query_map(params![tool], row_to_local_session)?;
735        let mut result = Vec::new();
736        for row in rows {
737            result.push(row?);
738        }
739
740        let mut filtered = hide_opencode_child_sessions(result);
741        filtered.truncate(count as usize);
742        Ok(filtered)
743    }
744
745    /// Get the latest N sessions across all tools, ordered by created_at DESC.
746    pub fn get_sessions_latest(&self, count: u32) -> Result<Vec<LocalSessionRow>> {
747        let sql = format!(
748            "SELECT {LOCAL_SESSION_COLUMNS} \
749             {FROM_CLAUSE} \
750             ORDER BY s.created_at DESC"
751        );
752        let conn = self.conn();
753        let mut stmt = conn.prepare(&sql)?;
754        let rows = stmt.query_map([], row_to_local_session)?;
755        let mut result = Vec::new();
756        for row in rows {
757            result.push(row?);
758        }
759
760        let mut filtered = hide_opencode_child_sessions(result);
761        filtered.truncate(count as usize);
762        Ok(filtered)
763    }
764
765    /// Get the Nth most recent session for a specific tool (0 = HEAD, 1 = HEAD~1, etc.).
766    pub fn get_session_by_tool_offset(
767        &self,
768        tool: &str,
769        offset: u32,
770    ) -> Result<Option<LocalSessionRow>> {
771        let sql = format!(
772            "SELECT {LOCAL_SESSION_COLUMNS} \
773             {FROM_CLAUSE} WHERE s.tool = ?1 \
774             ORDER BY s.created_at DESC"
775        );
776        let conn = self.conn();
777        let mut stmt = conn.prepare(&sql)?;
778        let rows = stmt.query_map(params![tool], row_to_local_session)?;
779        let filtered = hide_opencode_child_sessions(rows.collect::<Result<Vec<_>, _>>()?);
780        Ok(filtered.into_iter().nth(offset as usize))
781    }
782
783    /// Get the Nth most recent session across all tools (0 = HEAD, 1 = HEAD~1, etc.).
784    pub fn get_session_by_offset(&self, offset: u32) -> Result<Option<LocalSessionRow>> {
785        let sql = format!(
786            "SELECT {LOCAL_SESSION_COLUMNS} \
787             {FROM_CLAUSE} \
788             ORDER BY s.created_at DESC"
789        );
790        let conn = self.conn();
791        let mut stmt = conn.prepare(&sql)?;
792        let rows = stmt.query_map([], row_to_local_session)?;
793        let filtered = hide_opencode_child_sessions(rows.collect::<Result<Vec<_>, _>>()?);
794        Ok(filtered.into_iter().nth(offset as usize))
795    }
796
797    /// Fetch the source path used when the session was last parsed/loaded.
798    pub fn get_session_source_path(&self, session_id: &str) -> Result<Option<String>> {
799        let conn = self.conn();
800        let result = conn
801            .query_row(
802                "SELECT source_path FROM session_sync WHERE session_id = ?1",
803                params![session_id],
804                |row| row.get(0),
805            )
806            .optional()?;
807
808        Ok(result)
809    }
810
811    /// Count total sessions in the local DB.
812    pub fn session_count(&self) -> Result<i64> {
813        let count = self
814            .conn()
815            .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
816        Ok(count)
817    }
818
819    // ── Delete session ─────────────────────────────────────────────────
820
821    pub fn delete_session(&self, session_id: &str) -> Result<()> {
822        let conn = self.conn();
823        conn.execute(
824            "DELETE FROM body_cache WHERE session_id = ?1",
825            params![session_id],
826        )?;
827        conn.execute(
828            "DELETE FROM session_sync WHERE session_id = ?1",
829            params![session_id],
830        )?;
831        conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
832        Ok(())
833    }
834
835    // ── Sync cursor ────────────────────────────────────────────────────
836
837    pub fn get_sync_cursor(&self, team_id: &str) -> Result<Option<String>> {
838        let cursor = self
839            .conn()
840            .query_row(
841                "SELECT cursor FROM sync_cursors WHERE team_id = ?1",
842                params![team_id],
843                |row| row.get(0),
844            )
845            .optional()?;
846        Ok(cursor)
847    }
848
849    pub fn set_sync_cursor(&self, team_id: &str, cursor: &str) -> Result<()> {
850        self.conn().execute(
851            "INSERT INTO sync_cursors (team_id, cursor, updated_at) \
852             VALUES (?1, ?2, datetime('now')) \
853             ON CONFLICT(team_id) DO UPDATE SET cursor=excluded.cursor, updated_at=datetime('now')",
854            params![team_id, cursor],
855        )?;
856        Ok(())
857    }
858
859    // ── Upload tracking ────────────────────────────────────────────────
860
861    /// Get sessions that are local_only and need to be uploaded.
862    pub fn pending_uploads(&self, team_id: &str) -> Result<Vec<LocalSessionRow>> {
863        let sql = format!(
864            "SELECT {LOCAL_SESSION_COLUMNS} \
865             FROM sessions s \
866             INNER JOIN session_sync ss ON ss.session_id = s.id \
867             LEFT JOIN users u ON u.id = s.user_id \
868             WHERE ss.sync_status = 'local_only' AND s.team_id = ?1 \
869             ORDER BY s.created_at ASC"
870        );
871        let conn = self.conn();
872        let mut stmt = conn.prepare(&sql)?;
873        let rows = stmt.query_map(params![team_id], row_to_local_session)?;
874        let mut result = Vec::new();
875        for row in rows {
876            result.push(row?);
877        }
878        Ok(result)
879    }
880
881    pub fn mark_synced(&self, session_id: &str) -> Result<()> {
882        self.conn().execute(
883            "UPDATE session_sync SET sync_status = 'synced', last_synced_at = datetime('now') \
884             WHERE session_id = ?1",
885            params![session_id],
886        )?;
887        Ok(())
888    }
889
890    /// Check if a session was already uploaded (synced or remote_only) since the given modification time.
891    pub fn was_uploaded_after(
892        &self,
893        source_path: &str,
894        modified: &chrono::DateTime<chrono::Utc>,
895    ) -> Result<bool> {
896        let result: Option<String> = self
897            .conn()
898            .query_row(
899                "SELECT last_synced_at FROM session_sync \
900                 WHERE source_path = ?1 AND sync_status = 'synced' AND last_synced_at IS NOT NULL",
901                params![source_path],
902                |row| row.get(0),
903            )
904            .optional()?;
905
906        if let Some(synced_at) = result {
907            if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&synced_at) {
908                return Ok(dt >= *modified);
909            }
910        }
911        Ok(false)
912    }
913
914    // ── Body cache ─────────────────────────────────────────────────────
915
916    pub fn cache_body(&self, session_id: &str, body: &[u8]) -> Result<()> {
917        self.conn().execute(
918            "INSERT INTO body_cache (session_id, body, cached_at) \
919             VALUES (?1, ?2, datetime('now')) \
920             ON CONFLICT(session_id) DO UPDATE SET body=excluded.body, cached_at=datetime('now')",
921            params![session_id, body],
922        )?;
923        Ok(())
924    }
925
926    pub fn get_cached_body(&self, session_id: &str) -> Result<Option<Vec<u8>>> {
927        let body = self
928            .conn()
929            .query_row(
930                "SELECT body FROM body_cache WHERE session_id = ?1",
931                params![session_id],
932                |row| row.get(0),
933            )
934            .optional()?;
935        Ok(body)
936    }
937
938    // ── Migration helper ───────────────────────────────────────────────
939
940    /// Migrate entries from the old state.json UploadState into the local DB.
941    /// Marks them as `synced` with no metadata (we only know the file path was uploaded).
942    pub fn migrate_from_state_json(
943        &self,
944        uploaded: &std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
945    ) -> Result<usize> {
946        let mut count = 0;
947        for (path, uploaded_at) in uploaded {
948            let exists: bool = self
949                .conn()
950                .query_row(
951                    "SELECT COUNT(*) > 0 FROM session_sync WHERE source_path = ?1",
952                    params![path],
953                    |row| row.get(0),
954                )
955                .unwrap_or(false);
956
957            if exists {
958                self.conn().execute(
959                    "UPDATE session_sync SET sync_status = 'synced', last_synced_at = ?1 \
960                     WHERE source_path = ?2 AND sync_status = 'local_only'",
961                    params![uploaded_at.to_rfc3339(), path],
962                )?;
963                count += 1;
964            }
965        }
966        Ok(count)
967    }
968
969    // ── Commit ↔ session linking ────────────────────────────────────
970
971    /// Link a git commit to an AI session.
972    pub fn link_commit_session(
973        &self,
974        commit_hash: &str,
975        session_id: &str,
976        repo_path: Option<&str>,
977        branch: Option<&str>,
978    ) -> Result<()> {
979        self.conn().execute(
980            "INSERT INTO commit_session_links (commit_hash, session_id, repo_path, branch) \
981             VALUES (?1, ?2, ?3, ?4) \
982             ON CONFLICT(commit_hash, session_id) DO NOTHING",
983            params![commit_hash, session_id, repo_path, branch],
984        )?;
985        Ok(())
986    }
987
988    /// Get all sessions linked to a git commit.
989    pub fn get_sessions_by_commit(&self, commit_hash: &str) -> Result<Vec<LocalSessionRow>> {
990        let sql = format!(
991            "SELECT {LOCAL_SESSION_COLUMNS} \
992             {FROM_CLAUSE} \
993             INNER JOIN commit_session_links csl ON csl.session_id = s.id \
994             WHERE csl.commit_hash = ?1 \
995             ORDER BY s.created_at DESC"
996        );
997        let conn = self.conn();
998        let mut stmt = conn.prepare(&sql)?;
999        let rows = stmt.query_map(params![commit_hash], row_to_local_session)?;
1000        let mut result = Vec::new();
1001        for row in rows {
1002            result.push(row?);
1003        }
1004        Ok(result)
1005    }
1006
1007    /// Get all commits linked to a session.
1008    pub fn get_commits_by_session(&self, session_id: &str) -> Result<Vec<CommitLink>> {
1009        let conn = self.conn();
1010        let mut stmt = conn.prepare(
1011            "SELECT commit_hash, session_id, repo_path, branch, created_at \
1012             FROM commit_session_links WHERE session_id = ?1 \
1013             ORDER BY created_at DESC",
1014        )?;
1015        let rows = stmt.query_map(params![session_id], |row| {
1016            Ok(CommitLink {
1017                commit_hash: row.get(0)?,
1018                session_id: row.get(1)?,
1019                repo_path: row.get(2)?,
1020                branch: row.get(3)?,
1021                created_at: row.get(4)?,
1022            })
1023        })?;
1024        let mut result = Vec::new();
1025        for row in rows {
1026            result.push(row?);
1027        }
1028        Ok(result)
1029    }
1030
1031    /// Find the most recently active session for a given repo path.
1032    /// "Active" means the session's working_directory matches the repo path
1033    /// and was created within the last `since_minutes` minutes.
1034    pub fn find_active_session_for_repo(
1035        &self,
1036        repo_path: &str,
1037        since_minutes: u32,
1038    ) -> Result<Option<LocalSessionRow>> {
1039        let sql = format!(
1040            "SELECT {LOCAL_SESSION_COLUMNS} \
1041             {FROM_CLAUSE} \
1042             WHERE s.working_directory LIKE ?1 \
1043             AND s.created_at >= datetime('now', ?2) \
1044             ORDER BY s.created_at DESC LIMIT 1"
1045        );
1046        let since = format!("-{since_minutes} minutes");
1047        let like = format!("{repo_path}%");
1048        let conn = self.conn();
1049        let mut stmt = conn.prepare(&sql)?;
1050        let row = stmt
1051            .query_map(params![like, since], row_to_local_session)?
1052            .next()
1053            .transpose()?;
1054        Ok(row)
1055    }
1056
1057    /// Get all session IDs currently in the local DB.
1058    pub fn existing_session_ids(&self) -> std::collections::HashSet<String> {
1059        let conn = self.conn();
1060        let mut stmt = conn
1061            .prepare("SELECT id FROM sessions")
1062            .unwrap_or_else(|_| panic!("failed to prepare existing_session_ids query"));
1063        let rows = stmt.query_map([], |row| row.get::<_, String>(0));
1064        let mut set = std::collections::HashSet::new();
1065        if let Ok(rows) = rows {
1066            for row in rows.flatten() {
1067                set.insert(row);
1068            }
1069        }
1070        set
1071    }
1072
1073    /// Update only stats fields for an existing session (no git context re-extraction).
1074    pub fn update_session_stats(&self, session: &Session) -> Result<()> {
1075        let title = session.context.title.as_deref();
1076        let description = session.context.description.as_deref();
1077        let (files_modified, files_read, has_errors) =
1078            opensession_core::extract::extract_file_metadata(session);
1079        let max_active_agents = opensession_core::agent_metrics::max_active_agents(session) as i64;
1080
1081        self.conn().execute(
1082            "UPDATE sessions SET \
1083             title=?2, description=?3, \
1084             message_count=?4, user_message_count=?5, task_count=?6, \
1085             event_count=?7, duration_seconds=?8, \
1086             total_input_tokens=?9, total_output_tokens=?10, \
1087             files_modified=?11, files_read=?12, has_errors=?13, \
1088             max_active_agents=?14 \
1089             WHERE id=?1",
1090            params![
1091                &session.session_id,
1092                title,
1093                description,
1094                session.stats.message_count as i64,
1095                session.stats.user_message_count as i64,
1096                session.stats.task_count as i64,
1097                session.stats.event_count as i64,
1098                session.stats.duration_seconds as i64,
1099                session.stats.total_input_tokens as i64,
1100                session.stats.total_output_tokens as i64,
1101                &files_modified,
1102                &files_read,
1103                has_errors,
1104                max_active_agents,
1105            ],
1106        )?;
1107        Ok(())
1108    }
1109
1110    /// Update only sync metadata path for an existing session.
1111    pub fn set_session_sync_path(&self, session_id: &str, source_path: &str) -> Result<()> {
1112        self.conn().execute(
1113            "INSERT INTO session_sync (session_id, source_path) \
1114             VALUES (?1, ?2) \
1115             ON CONFLICT(session_id) DO UPDATE SET source_path = excluded.source_path",
1116            params![session_id, source_path],
1117        )?;
1118        Ok(())
1119    }
1120
1121    /// Get a list of distinct git repo names present in the DB.
1122    pub fn list_repos(&self) -> Result<Vec<String>> {
1123        let conn = self.conn();
1124        let mut stmt = conn.prepare(
1125            "SELECT DISTINCT git_repo_name FROM sessions \
1126             WHERE git_repo_name IS NOT NULL ORDER BY git_repo_name ASC",
1127        )?;
1128        let rows = stmt.query_map([], |row| row.get(0))?;
1129        let mut result = Vec::new();
1130        for row in rows {
1131            result.push(row?);
1132        }
1133        Ok(result)
1134    }
1135}
1136
1137// ── Legacy schema backfill ─────────────────────────────────────────────
1138
1139fn open_connection_with_latest_schema(path: &PathBuf) -> Result<Connection> {
1140    let conn = Connection::open(path).with_context(|| format!("open db {}", path.display()))?;
1141    conn.execute_batch("PRAGMA journal_mode=WAL;")?;
1142
1143    // Disable FK constraints for local DB (it's a cache, not source of truth)
1144    conn.execute_batch("PRAGMA foreign_keys=OFF;")?;
1145
1146    apply_local_migrations(&conn)?;
1147
1148    // Backfill missing columns for legacy local DBs where `sessions` already
1149    // existed before newer fields were introduced.
1150    ensure_sessions_columns(&conn)?;
1151    validate_local_schema(&conn)?;
1152
1153    Ok(conn)
1154}
1155
1156fn apply_local_migrations(conn: &Connection) -> Result<()> {
1157    conn.execute_batch(
1158        "CREATE TABLE IF NOT EXISTS _migrations (
1159            id INTEGER PRIMARY KEY,
1160            name TEXT NOT NULL UNIQUE,
1161            applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1162        );",
1163    )
1164    .context("create _migrations table for local db")?;
1165
1166    for (name, sql) in MIGRATIONS.iter().chain(LOCAL_MIGRATIONS.iter()) {
1167        let already_applied: bool = conn
1168            .query_row(
1169                "SELECT COUNT(*) > 0 FROM _migrations WHERE name = ?1",
1170                [name],
1171                |row| row.get(0),
1172            )
1173            .unwrap_or(false);
1174
1175        if already_applied {
1176            continue;
1177        }
1178
1179        if let Err(e) = conn.execute_batch(sql) {
1180            let msg = e.to_string().to_ascii_lowercase();
1181            if !is_local_migration_compat_error(&msg) {
1182                return Err(e).with_context(|| format!("apply local migration {name}"));
1183            }
1184        }
1185
1186        conn.execute(
1187            "INSERT OR IGNORE INTO _migrations (name) VALUES (?1)",
1188            [name],
1189        )
1190        .with_context(|| format!("record local migration {name}"))?;
1191    }
1192
1193    Ok(())
1194}
1195
1196fn is_local_migration_compat_error(msg: &str) -> bool {
1197    msg.contains("duplicate column name")
1198        || msg.contains("no such column")
1199        || msg.contains("already exists")
1200}
1201
1202fn validate_local_schema(conn: &Connection) -> Result<()> {
1203    let sql = format!("SELECT {LOCAL_SESSION_COLUMNS} {FROM_CLAUSE} WHERE 1=0");
1204    conn.prepare(&sql)
1205        .map(|_| ())
1206        .context("validate local session schema")
1207}
1208
1209fn is_schema_compat_error(err: &anyhow::Error) -> bool {
1210    let msg = format!("{err:#}").to_ascii_lowercase();
1211    msg.contains("no such column")
1212        || msg.contains("no such table")
1213        || msg.contains("cannot add a column")
1214        || msg.contains("already exists")
1215        || msg.contains("views may not be indexed")
1216        || msg.contains("malformed database schema")
1217        || msg.contains("duplicate column name")
1218}
1219
1220fn rotate_legacy_db(path: &PathBuf) -> Result<()> {
1221    if !path.exists() {
1222        return Ok(());
1223    }
1224
1225    let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
1226    let backup_name = format!(
1227        "{}.legacy-{}.bak",
1228        path.file_name()
1229            .and_then(|n| n.to_str())
1230            .unwrap_or("local.db"),
1231        ts
1232    );
1233    let backup_path = path.with_file_name(backup_name);
1234    std::fs::rename(path, &backup_path).with_context(|| {
1235        format!(
1236            "rotate legacy db {} -> {}",
1237            path.display(),
1238            backup_path.display()
1239        )
1240    })?;
1241
1242    let wal = PathBuf::from(format!("{}-wal", path.display()));
1243    let shm = PathBuf::from(format!("{}-shm", path.display()));
1244    let _ = std::fs::remove_file(wal);
1245    let _ = std::fs::remove_file(shm);
1246    Ok(())
1247}
1248
1249const REQUIRED_SESSION_COLUMNS: &[(&str, &str)] = &[
1250    ("user_id", "TEXT"),
1251    ("team_id", "TEXT DEFAULT 'personal'"),
1252    ("tool", "TEXT DEFAULT ''"),
1253    ("agent_provider", "TEXT"),
1254    ("agent_model", "TEXT"),
1255    ("title", "TEXT"),
1256    ("description", "TEXT"),
1257    ("tags", "TEXT"),
1258    ("created_at", "TEXT DEFAULT ''"),
1259    ("uploaded_at", "TEXT DEFAULT ''"),
1260    ("message_count", "INTEGER DEFAULT 0"),
1261    ("user_message_count", "INTEGER DEFAULT 0"),
1262    ("task_count", "INTEGER DEFAULT 0"),
1263    ("event_count", "INTEGER DEFAULT 0"),
1264    ("duration_seconds", "INTEGER DEFAULT 0"),
1265    ("total_input_tokens", "INTEGER DEFAULT 0"),
1266    ("total_output_tokens", "INTEGER DEFAULT 0"),
1267    ("body_storage_key", "TEXT DEFAULT ''"),
1268    ("body_url", "TEXT"),
1269    ("git_remote", "TEXT"),
1270    ("git_branch", "TEXT"),
1271    ("git_commit", "TEXT"),
1272    ("git_repo_name", "TEXT"),
1273    ("pr_number", "INTEGER"),
1274    ("pr_url", "TEXT"),
1275    ("working_directory", "TEXT"),
1276    ("files_modified", "TEXT"),
1277    ("files_read", "TEXT"),
1278    ("has_errors", "BOOLEAN DEFAULT 0"),
1279    ("max_active_agents", "INTEGER DEFAULT 1"),
1280];
1281
1282fn ensure_sessions_columns(conn: &Connection) -> Result<()> {
1283    let mut existing = HashSet::new();
1284    let mut stmt = conn.prepare("PRAGMA table_info(sessions)")?;
1285    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
1286    for row in rows {
1287        existing.insert(row?);
1288    }
1289
1290    for (name, decl) in REQUIRED_SESSION_COLUMNS {
1291        if existing.contains(*name) {
1292            continue;
1293        }
1294        let sql = format!("ALTER TABLE sessions ADD COLUMN {name} {decl};");
1295        conn.execute_batch(&sql)
1296            .with_context(|| format!("add legacy sessions column '{name}'"))?;
1297    }
1298
1299    Ok(())
1300}
1301
1302/// Column list for SELECT queries against sessions + session_sync + users.
1303pub const LOCAL_SESSION_COLUMNS: &str = "\
1304s.id, ss.source_path, COALESCE(ss.sync_status, 'unknown') AS sync_status, ss.last_synced_at, \
1305s.user_id, u.nickname, s.team_id, s.tool, s.agent_provider, s.agent_model, \
1306s.title, s.description, s.tags, s.created_at, s.uploaded_at, \
1307s.message_count, COALESCE(s.user_message_count, 0), s.task_count, s.event_count, s.duration_seconds, \
1308s.total_input_tokens, s.total_output_tokens, \
1309s.git_remote, s.git_branch, s.git_commit, s.git_repo_name, \
1310s.pr_number, s.pr_url, s.working_directory, \
1311s.files_modified, s.files_read, s.has_errors, COALESCE(s.max_active_agents, 1)";
1312
1313fn row_to_local_session(row: &rusqlite::Row) -> rusqlite::Result<LocalSessionRow> {
1314    Ok(LocalSessionRow {
1315        id: row.get(0)?,
1316        source_path: row.get(1)?,
1317        sync_status: row.get(2)?,
1318        last_synced_at: row.get(3)?,
1319        user_id: row.get(4)?,
1320        nickname: row.get(5)?,
1321        team_id: row.get(6)?,
1322        tool: row.get(7)?,
1323        agent_provider: row.get(8)?,
1324        agent_model: row.get(9)?,
1325        title: row.get(10)?,
1326        description: row.get(11)?,
1327        tags: row.get(12)?,
1328        created_at: row.get(13)?,
1329        uploaded_at: row.get(14)?,
1330        message_count: row.get(15)?,
1331        user_message_count: row.get(16)?,
1332        task_count: row.get(17)?,
1333        event_count: row.get(18)?,
1334        duration_seconds: row.get(19)?,
1335        total_input_tokens: row.get(20)?,
1336        total_output_tokens: row.get(21)?,
1337        git_remote: row.get(22)?,
1338        git_branch: row.get(23)?,
1339        git_commit: row.get(24)?,
1340        git_repo_name: row.get(25)?,
1341        pr_number: row.get(26)?,
1342        pr_url: row.get(27)?,
1343        working_directory: row.get(28)?,
1344        files_modified: row.get(29)?,
1345        files_read: row.get(30)?,
1346        has_errors: row.get::<_, i64>(31).unwrap_or(0) != 0,
1347        max_active_agents: row.get(32).unwrap_or(1),
1348    })
1349}
1350
1351fn default_db_path() -> Result<PathBuf> {
1352    let home = std::env::var("HOME")
1353        .or_else(|_| std::env::var("USERPROFILE"))
1354        .context("Could not determine home directory")?;
1355    Ok(PathBuf::from(home)
1356        .join(".local")
1357        .join("share")
1358        .join("opensession")
1359        .join("local.db"))
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365
1366    use std::fs::{create_dir_all, write};
1367    use tempfile::tempdir;
1368
1369    fn test_db() -> LocalDb {
1370        let dir = tempdir().unwrap();
1371        let path = dir.keep().join("test.db");
1372        LocalDb::open_path(&path).unwrap()
1373    }
1374
1375    fn temp_root() -> tempfile::TempDir {
1376        tempdir().unwrap()
1377    }
1378
1379    fn make_row(id: &str, tool: &str, source_path: Option<&str>) -> LocalSessionRow {
1380        LocalSessionRow {
1381            id: id.to_string(),
1382            source_path: source_path.map(String::from),
1383            sync_status: "local_only".to_string(),
1384            last_synced_at: None,
1385            user_id: None,
1386            nickname: None,
1387            team_id: None,
1388            tool: tool.to_string(),
1389            agent_provider: None,
1390            agent_model: None,
1391            title: Some("test".to_string()),
1392            description: None,
1393            tags: None,
1394            created_at: "2024-01-01T00:00:00Z".to_string(),
1395            uploaded_at: None,
1396            message_count: 0,
1397            user_message_count: 0,
1398            task_count: 0,
1399            event_count: 0,
1400            duration_seconds: 0,
1401            total_input_tokens: 0,
1402            total_output_tokens: 0,
1403            git_remote: None,
1404            git_branch: None,
1405            git_commit: None,
1406            git_repo_name: None,
1407            pr_number: None,
1408            pr_url: None,
1409            working_directory: None,
1410            files_modified: None,
1411            files_read: None,
1412            has_errors: false,
1413            max_active_agents: 1,
1414        }
1415    }
1416
1417    #[test]
1418    fn test_open_and_schema() {
1419        let _db = test_db();
1420    }
1421
1422    #[test]
1423    fn test_open_backfills_legacy_sessions_columns() {
1424        let dir = tempfile::tempdir().unwrap();
1425        let path = dir.path().join("legacy.db");
1426        {
1427            let conn = Connection::open(&path).unwrap();
1428            conn.execute_batch(
1429                "CREATE TABLE sessions (id TEXT PRIMARY KEY);
1430                 INSERT INTO sessions (id) VALUES ('legacy-1');",
1431            )
1432            .unwrap();
1433        }
1434
1435        let db = LocalDb::open_path(&path).unwrap();
1436        let rows = db.list_sessions(&LocalSessionFilter::default()).unwrap();
1437        assert_eq!(rows.len(), 1);
1438        assert_eq!(rows[0].id, "legacy-1");
1439        assert_eq!(rows[0].user_message_count, 0);
1440    }
1441
1442    #[test]
1443    fn test_open_rotates_incompatible_legacy_schema() {
1444        let dir = tempfile::tempdir().unwrap();
1445        let path = dir.path().join("broken.db");
1446        {
1447            let conn = Connection::open(&path).unwrap();
1448            conn.execute_batch("CREATE VIEW sessions AS SELECT 'x' AS id;")
1449                .unwrap();
1450        }
1451
1452        let db = LocalDb::open_path(&path).unwrap();
1453        let rows = db.list_sessions(&LocalSessionFilter::default()).unwrap();
1454        assert!(rows.is_empty());
1455
1456        let rotated = std::fs::read_dir(dir.path())
1457            .unwrap()
1458            .filter_map(Result::ok)
1459            .any(|entry| {
1460                let name = entry.file_name();
1461                let name = name.to_string_lossy();
1462                name.starts_with("broken.db.legacy-") && name.ends_with(".bak")
1463            });
1464        assert!(rotated, "expected rotated legacy backup file");
1465    }
1466
1467    #[test]
1468    fn test_is_opencode_child_session() {
1469        let root = temp_root();
1470        let dir = root.path().join("sessions");
1471        create_dir_all(&dir).unwrap();
1472        let parent_session = dir.join("parent.json");
1473        write(
1474            &parent_session,
1475            r#"{"id":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1476        )
1477        .unwrap();
1478        let child_session = dir.join("child.json");
1479        write(
1480            &child_session,
1481            r#"{"id":"ses_child","parentID":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1482        )
1483        .unwrap();
1484
1485        let parent = make_row(
1486            "ses_parent",
1487            "opencode",
1488            Some(parent_session.to_str().unwrap()),
1489        );
1490        let child = make_row(
1491            "ses_child",
1492            "opencode",
1493            Some(child_session.to_str().unwrap()),
1494        );
1495        let codex = make_row("ses_other", "codex", Some(child_session.to_str().unwrap()));
1496
1497        assert!(!is_opencode_child_session(&parent));
1498        assert!(is_opencode_child_session(&child));
1499        assert!(!is_opencode_child_session(&codex));
1500    }
1501
1502    #[test]
1503    fn test_is_opencode_child_session_uses_event_shape_heuristic() {
1504        let child = LocalSessionRow {
1505            id: "sess_child".to_string(),
1506            source_path: None,
1507            sync_status: "local_only".to_string(),
1508            last_synced_at: None,
1509            user_id: None,
1510            nickname: None,
1511            team_id: None,
1512            tool: "opencode".to_string(),
1513            agent_provider: None,
1514            agent_model: None,
1515            title: None,
1516            description: None,
1517            tags: None,
1518            created_at: "2024-01-01T00:00:00Z".to_string(),
1519            uploaded_at: None,
1520            message_count: 1,
1521            user_message_count: 0,
1522            task_count: 4,
1523            event_count: 4,
1524            duration_seconds: 0,
1525            total_input_tokens: 0,
1526            total_output_tokens: 0,
1527            git_remote: None,
1528            git_branch: None,
1529            git_commit: None,
1530            git_repo_name: None,
1531            pr_number: None,
1532            pr_url: None,
1533            working_directory: None,
1534            files_modified: None,
1535            files_read: None,
1536            has_errors: false,
1537            max_active_agents: 1,
1538        };
1539
1540        let parent = LocalSessionRow {
1541            id: "sess_parent".to_string(),
1542            source_path: None,
1543            sync_status: "local_only".to_string(),
1544            last_synced_at: None,
1545            user_id: None,
1546            nickname: None,
1547            team_id: None,
1548            tool: "opencode".to_string(),
1549            agent_provider: None,
1550            agent_model: None,
1551            title: Some("regular".to_string()),
1552            description: None,
1553            tags: None,
1554            created_at: "2024-01-01T00:00:00Z".to_string(),
1555            uploaded_at: None,
1556            message_count: 1,
1557            user_message_count: 1,
1558            task_count: 2,
1559            event_count: 20,
1560            duration_seconds: 0,
1561            total_input_tokens: 0,
1562            total_output_tokens: 0,
1563            git_remote: None,
1564            git_branch: None,
1565            git_commit: None,
1566            git_repo_name: None,
1567            pr_number: None,
1568            pr_url: None,
1569            working_directory: None,
1570            files_modified: None,
1571            files_read: None,
1572            has_errors: false,
1573            max_active_agents: 1,
1574        };
1575
1576        assert!(is_opencode_child_session(&child));
1577        assert!(!is_opencode_child_session(&parent));
1578    }
1579
1580    #[test]
1581    fn test_is_opencode_child_session_with_more_messages_is_hidden_if_task_count_small() {
1582        let child = LocalSessionRow {
1583            id: "sess_child_2".to_string(),
1584            source_path: None,
1585            sync_status: "local_only".to_string(),
1586            last_synced_at: None,
1587            user_id: None,
1588            nickname: None,
1589            team_id: None,
1590            tool: "opencode".to_string(),
1591            agent_provider: None,
1592            agent_model: None,
1593            title: None,
1594            description: None,
1595            tags: None,
1596            created_at: "2024-01-01T00:00:00Z".to_string(),
1597            uploaded_at: None,
1598            message_count: 2,
1599            user_message_count: 0,
1600            task_count: 4,
1601            event_count: 4,
1602            duration_seconds: 0,
1603            total_input_tokens: 0,
1604            total_output_tokens: 0,
1605            git_remote: None,
1606            git_branch: None,
1607            git_commit: None,
1608            git_repo_name: None,
1609            pr_number: None,
1610            pr_url: None,
1611            working_directory: None,
1612            files_modified: None,
1613            files_read: None,
1614            has_errors: false,
1615            max_active_agents: 1,
1616        };
1617
1618        let parent = LocalSessionRow {
1619            id: "sess_parent".to_string(),
1620            source_path: None,
1621            sync_status: "local_only".to_string(),
1622            last_synced_at: None,
1623            user_id: None,
1624            nickname: None,
1625            team_id: None,
1626            tool: "opencode".to_string(),
1627            agent_provider: None,
1628            agent_model: None,
1629            title: Some("regular".to_string()),
1630            description: None,
1631            tags: None,
1632            created_at: "2024-01-01T00:00:00Z".to_string(),
1633            uploaded_at: None,
1634            message_count: 2,
1635            user_message_count: 1,
1636            task_count: 5,
1637            event_count: 20,
1638            duration_seconds: 0,
1639            total_input_tokens: 0,
1640            total_output_tokens: 0,
1641            git_remote: None,
1642            git_branch: None,
1643            git_commit: None,
1644            git_repo_name: None,
1645            pr_number: None,
1646            pr_url: None,
1647            working_directory: None,
1648            files_modified: None,
1649            files_read: None,
1650            has_errors: false,
1651            max_active_agents: 1,
1652        };
1653
1654        assert!(is_opencode_child_session(&child));
1655        assert!(!is_opencode_child_session(&parent));
1656    }
1657
1658    #[test]
1659    fn test_is_opencode_child_session_with_more_messages_but_few_tasks() {
1660        let child = LocalSessionRow {
1661            id: "sess_child_3".to_string(),
1662            source_path: None,
1663            sync_status: "local_only".to_string(),
1664            last_synced_at: None,
1665            user_id: None,
1666            nickname: None,
1667            team_id: None,
1668            tool: "opencode".to_string(),
1669            agent_provider: None,
1670            agent_model: None,
1671            title: None,
1672            description: None,
1673            tags: None,
1674            created_at: "2024-01-01T00:00:00Z".to_string(),
1675            uploaded_at: None,
1676            message_count: 3,
1677            user_message_count: 0,
1678            task_count: 2,
1679            event_count: 6,
1680            duration_seconds: 0,
1681            total_input_tokens: 0,
1682            total_output_tokens: 0,
1683            git_remote: None,
1684            git_branch: None,
1685            git_commit: None,
1686            git_repo_name: None,
1687            pr_number: None,
1688            pr_url: None,
1689            working_directory: None,
1690            files_modified: None,
1691            files_read: None,
1692            has_errors: false,
1693            max_active_agents: 1,
1694        };
1695
1696        assert!(is_opencode_child_session(&child));
1697    }
1698
1699    #[test]
1700    fn test_parse_opencode_parent_session_id_aliases() {
1701        let root = temp_root();
1702        let dir = root.path().join("session-aliases");
1703        create_dir_all(&dir).unwrap();
1704        let child_session = dir.join("child.json");
1705        write(
1706            &child_session,
1707            r#"{"id":"ses_child","parentUUID":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1708        )
1709        .unwrap();
1710        assert_eq!(
1711            parse_opencode_parent_session_id(child_session.to_str().unwrap()).as_deref(),
1712            Some("ses_parent")
1713        );
1714    }
1715
1716    #[test]
1717    fn test_parse_opencode_parent_session_id_nested_metadata() {
1718        let root = temp_root();
1719        let dir = root.path().join("session-nested");
1720        create_dir_all(&dir).unwrap();
1721        let child_session = dir.join("child.json");
1722        write(
1723            &child_session,
1724            r#"{"id":"ses_child","metadata":{"links":{"parentSessionId":"ses_parent","trace":"x"}}}"#,
1725        )
1726        .unwrap();
1727        assert_eq!(
1728            parse_opencode_parent_session_id(child_session.to_str().unwrap()).as_deref(),
1729            Some("ses_parent")
1730        );
1731    }
1732
1733    #[test]
1734    fn test_is_codex_internal_session() {
1735        let hidden = make_row("codex_internal", "codex", None);
1736        assert!(is_codex_internal_session(&hidden));
1737
1738        let mut visible = make_row("codex_visible", "codex", None);
1739        visible.user_message_count = 1;
1740        assert!(!is_codex_internal_session(&visible));
1741    }
1742
1743    #[test]
1744    fn test_is_claude_subagent_session() {
1745        let row = make_row(
1746            "ses_parent",
1747            "claude-code",
1748            Some("/Users/test/.claude/projects/foo/subagents/agent-abc.jsonl"),
1749        );
1750        assert!(!is_opencode_child_session(&row));
1751        assert!(is_claude_subagent_session(&row));
1752        assert!(hide_opencode_child_sessions(vec![row]).is_empty());
1753    }
1754
1755    #[test]
1756    fn test_hide_opencode_child_sessions() {
1757        let root = temp_root();
1758        let dir = root.path().join("sessions");
1759        create_dir_all(&dir).unwrap();
1760        let parent_session = dir.join("parent.json");
1761        let child_session = dir.join("child.json");
1762        let orphan_session = dir.join("orphan.json");
1763
1764        write(
1765            &parent_session,
1766            r#"{"id":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1767        )
1768        .unwrap();
1769        write(
1770            &child_session,
1771            r#"{"id":"ses_child","parentID":"ses_parent","time":{"created":1000,"updated":1000}}"#,
1772        )
1773        .unwrap();
1774        write(
1775            &orphan_session,
1776            r#"{"id":"ses_orphan","time":{"created":1000,"updated":1000}}"#,
1777        )
1778        .unwrap();
1779
1780        let rows = vec![
1781            make_row(
1782                "ses_child",
1783                "opencode",
1784                Some(child_session.to_str().unwrap()),
1785            ),
1786            make_row(
1787                "ses_parent",
1788                "opencode",
1789                Some(parent_session.to_str().unwrap()),
1790            ),
1791            {
1792                let mut row = make_row("ses_other", "codex", None);
1793                row.user_message_count = 1;
1794                row
1795            },
1796            make_row(
1797                "ses_orphan",
1798                "opencode",
1799                Some(orphan_session.to_str().unwrap()),
1800            ),
1801        ];
1802
1803        let filtered = hide_opencode_child_sessions(rows);
1804        assert_eq!(filtered.len(), 3);
1805        assert!(filtered.iter().all(|r| r.id != "ses_child"));
1806    }
1807
1808    #[test]
1809    fn test_sync_cursor() {
1810        let db = test_db();
1811        assert_eq!(db.get_sync_cursor("team1").unwrap(), None);
1812        db.set_sync_cursor("team1", "2024-01-01T00:00:00Z").unwrap();
1813        assert_eq!(
1814            db.get_sync_cursor("team1").unwrap(),
1815            Some("2024-01-01T00:00:00Z".to_string())
1816        );
1817        // Update
1818        db.set_sync_cursor("team1", "2024-06-01T00:00:00Z").unwrap();
1819        assert_eq!(
1820            db.get_sync_cursor("team1").unwrap(),
1821            Some("2024-06-01T00:00:00Z".to_string())
1822        );
1823    }
1824
1825    #[test]
1826    fn test_body_cache() {
1827        let db = test_db();
1828        assert_eq!(db.get_cached_body("s1").unwrap(), None);
1829        db.cache_body("s1", b"hello world").unwrap();
1830        assert_eq!(
1831            db.get_cached_body("s1").unwrap(),
1832            Some(b"hello world".to_vec())
1833        );
1834    }
1835
1836    #[test]
1837    fn test_upsert_remote_session() {
1838        let db = test_db();
1839        let summary = SessionSummary {
1840            id: "remote-1".to_string(),
1841            user_id: Some("u1".to_string()),
1842            nickname: Some("alice".to_string()),
1843            team_id: "t1".to_string(),
1844            tool: "claude-code".to_string(),
1845            agent_provider: None,
1846            agent_model: None,
1847            title: Some("Test session".to_string()),
1848            description: None,
1849            tags: None,
1850            created_at: "2024-01-01T00:00:00Z".to_string(),
1851            uploaded_at: "2024-01-01T01:00:00Z".to_string(),
1852            message_count: 10,
1853            task_count: 2,
1854            event_count: 20,
1855            duration_seconds: 300,
1856            total_input_tokens: 1000,
1857            total_output_tokens: 500,
1858            git_remote: None,
1859            git_branch: None,
1860            git_commit: None,
1861            git_repo_name: None,
1862            pr_number: None,
1863            pr_url: None,
1864            working_directory: None,
1865            files_modified: None,
1866            files_read: None,
1867            has_errors: false,
1868            max_active_agents: 1,
1869        };
1870        db.upsert_remote_session(&summary).unwrap();
1871
1872        let sessions = db.list_sessions(&LocalSessionFilter::default()).unwrap();
1873        assert_eq!(sessions.len(), 1);
1874        assert_eq!(sessions[0].id, "remote-1");
1875        assert_eq!(sessions[0].sync_status, "remote_only");
1876        assert_eq!(sessions[0].nickname, None); // no user in local users table
1877    }
1878
1879    #[test]
1880    fn test_list_filter_by_repo() {
1881        let db = test_db();
1882        // Insert a remote session with team_id
1883        let summary1 = SessionSummary {
1884            id: "s1".to_string(),
1885            user_id: None,
1886            nickname: None,
1887            team_id: "t1".to_string(),
1888            tool: "claude-code".to_string(),
1889            agent_provider: None,
1890            agent_model: None,
1891            title: Some("Session 1".to_string()),
1892            description: None,
1893            tags: None,
1894            created_at: "2024-01-01T00:00:00Z".to_string(),
1895            uploaded_at: "2024-01-01T01:00:00Z".to_string(),
1896            message_count: 5,
1897            task_count: 0,
1898            event_count: 10,
1899            duration_seconds: 60,
1900            total_input_tokens: 100,
1901            total_output_tokens: 50,
1902            git_remote: None,
1903            git_branch: None,
1904            git_commit: None,
1905            git_repo_name: None,
1906            pr_number: None,
1907            pr_url: None,
1908            working_directory: None,
1909            files_modified: None,
1910            files_read: None,
1911            has_errors: false,
1912            max_active_agents: 1,
1913        };
1914        db.upsert_remote_session(&summary1).unwrap();
1915
1916        // Filter by team
1917        let filter = LocalSessionFilter {
1918            team_id: Some("t1".to_string()),
1919            ..Default::default()
1920        };
1921        assert_eq!(db.list_sessions(&filter).unwrap().len(), 1);
1922
1923        let filter = LocalSessionFilter {
1924            team_id: Some("t999".to_string()),
1925            ..Default::default()
1926        };
1927        assert_eq!(db.list_sessions(&filter).unwrap().len(), 0);
1928    }
1929
1930    // ── Helpers for inserting test sessions ────────────────────────────
1931
1932    fn make_summary(id: &str, tool: &str, title: &str, created_at: &str) -> SessionSummary {
1933        SessionSummary {
1934            id: id.to_string(),
1935            user_id: None,
1936            nickname: None,
1937            team_id: "t1".to_string(),
1938            tool: tool.to_string(),
1939            agent_provider: Some("anthropic".to_string()),
1940            agent_model: Some("claude-opus-4-6".to_string()),
1941            title: Some(title.to_string()),
1942            description: None,
1943            tags: None,
1944            created_at: created_at.to_string(),
1945            uploaded_at: created_at.to_string(),
1946            message_count: 5,
1947            task_count: 1,
1948            event_count: 10,
1949            duration_seconds: 300,
1950            total_input_tokens: 1000,
1951            total_output_tokens: 500,
1952            git_remote: None,
1953            git_branch: None,
1954            git_commit: None,
1955            git_repo_name: None,
1956            pr_number: None,
1957            pr_url: None,
1958            working_directory: None,
1959            files_modified: None,
1960            files_read: None,
1961            has_errors: false,
1962            max_active_agents: 1,
1963        }
1964    }
1965
1966    fn seed_sessions(db: &LocalDb) {
1967        // Insert 5 sessions across two tools, ordered by created_at
1968        db.upsert_remote_session(&make_summary(
1969            "s1",
1970            "claude-code",
1971            "First session",
1972            "2024-01-01T00:00:00Z",
1973        ))
1974        .unwrap();
1975        db.upsert_remote_session(&make_summary(
1976            "s2",
1977            "claude-code",
1978            "JWT auth work",
1979            "2024-01-02T00:00:00Z",
1980        ))
1981        .unwrap();
1982        db.upsert_remote_session(&make_summary(
1983            "s3",
1984            "gemini",
1985            "Gemini test",
1986            "2024-01-03T00:00:00Z",
1987        ))
1988        .unwrap();
1989        db.upsert_remote_session(&make_summary(
1990            "s4",
1991            "claude-code",
1992            "Error handling",
1993            "2024-01-04T00:00:00Z",
1994        ))
1995        .unwrap();
1996        db.upsert_remote_session(&make_summary(
1997            "s5",
1998            "claude-code",
1999            "Final polish",
2000            "2024-01-05T00:00:00Z",
2001        ))
2002        .unwrap();
2003    }
2004
2005    // ── list_sessions_log tests ────────────────────────────────────────
2006
2007    #[test]
2008    fn test_log_no_filters() {
2009        let db = test_db();
2010        seed_sessions(&db);
2011        let filter = LogFilter::default();
2012        let results = db.list_sessions_log(&filter).unwrap();
2013        assert_eq!(results.len(), 5);
2014        // Should be ordered by created_at DESC
2015        assert_eq!(results[0].id, "s5");
2016        assert_eq!(results[4].id, "s1");
2017    }
2018
2019    #[test]
2020    fn test_log_filter_by_tool() {
2021        let db = test_db();
2022        seed_sessions(&db);
2023        let filter = LogFilter {
2024            tool: Some("claude-code".to_string()),
2025            ..Default::default()
2026        };
2027        let results = db.list_sessions_log(&filter).unwrap();
2028        assert_eq!(results.len(), 4);
2029        assert!(results.iter().all(|s| s.tool == "claude-code"));
2030    }
2031
2032    #[test]
2033    fn test_log_filter_by_model_wildcard() {
2034        let db = test_db();
2035        seed_sessions(&db);
2036        let filter = LogFilter {
2037            model: Some("claude*".to_string()),
2038            ..Default::default()
2039        };
2040        let results = db.list_sessions_log(&filter).unwrap();
2041        assert_eq!(results.len(), 5); // all have claude-opus model
2042    }
2043
2044    #[test]
2045    fn test_log_filter_since() {
2046        let db = test_db();
2047        seed_sessions(&db);
2048        let filter = LogFilter {
2049            since: Some("2024-01-03T00:00:00Z".to_string()),
2050            ..Default::default()
2051        };
2052        let results = db.list_sessions_log(&filter).unwrap();
2053        assert_eq!(results.len(), 3); // s3, s4, s5
2054    }
2055
2056    #[test]
2057    fn test_log_filter_before() {
2058        let db = test_db();
2059        seed_sessions(&db);
2060        let filter = LogFilter {
2061            before: Some("2024-01-03T00:00:00Z".to_string()),
2062            ..Default::default()
2063        };
2064        let results = db.list_sessions_log(&filter).unwrap();
2065        assert_eq!(results.len(), 2); // s1, s2
2066    }
2067
2068    #[test]
2069    fn test_log_filter_since_and_before() {
2070        let db = test_db();
2071        seed_sessions(&db);
2072        let filter = LogFilter {
2073            since: Some("2024-01-02T00:00:00Z".to_string()),
2074            before: Some("2024-01-04T00:00:00Z".to_string()),
2075            ..Default::default()
2076        };
2077        let results = db.list_sessions_log(&filter).unwrap();
2078        assert_eq!(results.len(), 2); // s2, s3
2079    }
2080
2081    #[test]
2082    fn test_log_filter_grep() {
2083        let db = test_db();
2084        seed_sessions(&db);
2085        let filter = LogFilter {
2086            grep: Some("JWT".to_string()),
2087            ..Default::default()
2088        };
2089        let results = db.list_sessions_log(&filter).unwrap();
2090        assert_eq!(results.len(), 1);
2091        assert_eq!(results[0].id, "s2");
2092    }
2093
2094    #[test]
2095    fn test_log_limit_and_offset() {
2096        let db = test_db();
2097        seed_sessions(&db);
2098        let filter = LogFilter {
2099            limit: Some(2),
2100            offset: Some(1),
2101            ..Default::default()
2102        };
2103        let results = db.list_sessions_log(&filter).unwrap();
2104        assert_eq!(results.len(), 2);
2105        assert_eq!(results[0].id, "s4"); // second most recent
2106        assert_eq!(results[1].id, "s3");
2107    }
2108
2109    #[test]
2110    fn test_log_limit_only() {
2111        let db = test_db();
2112        seed_sessions(&db);
2113        let filter = LogFilter {
2114            limit: Some(3),
2115            ..Default::default()
2116        };
2117        let results = db.list_sessions_log(&filter).unwrap();
2118        assert_eq!(results.len(), 3);
2119    }
2120
2121    #[test]
2122    fn test_list_sessions_limit_offset() {
2123        let db = test_db();
2124        seed_sessions(&db);
2125        let filter = LocalSessionFilter {
2126            limit: Some(2),
2127            offset: Some(1),
2128            ..Default::default()
2129        };
2130        let results = db.list_sessions(&filter).unwrap();
2131        assert_eq!(results.len(), 2);
2132        assert_eq!(results[0].id, "s4");
2133        assert_eq!(results[1].id, "s3");
2134    }
2135
2136    #[test]
2137    fn test_count_sessions_filtered() {
2138        let db = test_db();
2139        seed_sessions(&db);
2140        let count = db
2141            .count_sessions_filtered(&LocalSessionFilter::default())
2142            .unwrap();
2143        assert_eq!(count, 5);
2144    }
2145
2146    #[test]
2147    fn test_list_session_tools() {
2148        let db = test_db();
2149        seed_sessions(&db);
2150        let tools = db
2151            .list_session_tools(&LocalSessionFilter::default())
2152            .unwrap();
2153        assert_eq!(tools, vec!["claude-code".to_string(), "gemini".to_string()]);
2154    }
2155
2156    #[test]
2157    fn test_log_combined_filters() {
2158        let db = test_db();
2159        seed_sessions(&db);
2160        let filter = LogFilter {
2161            tool: Some("claude-code".to_string()),
2162            since: Some("2024-01-03T00:00:00Z".to_string()),
2163            limit: Some(1),
2164            ..Default::default()
2165        };
2166        let results = db.list_sessions_log(&filter).unwrap();
2167        assert_eq!(results.len(), 1);
2168        assert_eq!(results[0].id, "s5"); // most recent claude-code after Jan 3
2169    }
2170
2171    // ── Session offset/latest tests ────────────────────────────────────
2172
2173    #[test]
2174    fn test_get_session_by_offset() {
2175        let db = test_db();
2176        seed_sessions(&db);
2177        let row = db.get_session_by_offset(0).unwrap().unwrap();
2178        assert_eq!(row.id, "s5"); // most recent
2179        let row = db.get_session_by_offset(2).unwrap().unwrap();
2180        assert_eq!(row.id, "s3");
2181        assert!(db.get_session_by_offset(10).unwrap().is_none());
2182    }
2183
2184    #[test]
2185    fn test_get_session_by_tool_offset() {
2186        let db = test_db();
2187        seed_sessions(&db);
2188        let row = db
2189            .get_session_by_tool_offset("claude-code", 0)
2190            .unwrap()
2191            .unwrap();
2192        assert_eq!(row.id, "s5");
2193        let row = db
2194            .get_session_by_tool_offset("claude-code", 1)
2195            .unwrap()
2196            .unwrap();
2197        assert_eq!(row.id, "s4");
2198        let row = db.get_session_by_tool_offset("gemini", 0).unwrap().unwrap();
2199        assert_eq!(row.id, "s3");
2200        assert!(db
2201            .get_session_by_tool_offset("gemini", 1)
2202            .unwrap()
2203            .is_none());
2204    }
2205
2206    #[test]
2207    fn test_get_sessions_latest() {
2208        let db = test_db();
2209        seed_sessions(&db);
2210        let rows = db.get_sessions_latest(3).unwrap();
2211        assert_eq!(rows.len(), 3);
2212        assert_eq!(rows[0].id, "s5");
2213        assert_eq!(rows[1].id, "s4");
2214        assert_eq!(rows[2].id, "s3");
2215    }
2216
2217    #[test]
2218    fn test_get_sessions_by_tool_latest() {
2219        let db = test_db();
2220        seed_sessions(&db);
2221        let rows = db.get_sessions_by_tool_latest("claude-code", 2).unwrap();
2222        assert_eq!(rows.len(), 2);
2223        assert_eq!(rows[0].id, "s5");
2224        assert_eq!(rows[1].id, "s4");
2225    }
2226
2227    #[test]
2228    fn test_get_sessions_latest_more_than_available() {
2229        let db = test_db();
2230        seed_sessions(&db);
2231        let rows = db.get_sessions_by_tool_latest("gemini", 10).unwrap();
2232        assert_eq!(rows.len(), 1); // only 1 gemini session
2233    }
2234
2235    #[test]
2236    fn test_session_count() {
2237        let db = test_db();
2238        assert_eq!(db.session_count().unwrap(), 0);
2239        seed_sessions(&db);
2240        assert_eq!(db.session_count().unwrap(), 5);
2241    }
2242
2243    // ── Commit link tests ─────────────────────────────────────────────
2244
2245    #[test]
2246    fn test_link_commit_session() {
2247        let db = test_db();
2248        seed_sessions(&db);
2249        db.link_commit_session("abc123", "s1", Some("/tmp/repo"), Some("main"))
2250            .unwrap();
2251
2252        let commits = db.get_commits_by_session("s1").unwrap();
2253        assert_eq!(commits.len(), 1);
2254        assert_eq!(commits[0].commit_hash, "abc123");
2255        assert_eq!(commits[0].session_id, "s1");
2256        assert_eq!(commits[0].repo_path.as_deref(), Some("/tmp/repo"));
2257        assert_eq!(commits[0].branch.as_deref(), Some("main"));
2258
2259        let sessions = db.get_sessions_by_commit("abc123").unwrap();
2260        assert_eq!(sessions.len(), 1);
2261        assert_eq!(sessions[0].id, "s1");
2262    }
2263
2264    #[test]
2265    fn test_get_sessions_by_commit() {
2266        let db = test_db();
2267        seed_sessions(&db);
2268        // Link multiple sessions to the same commit
2269        db.link_commit_session("abc123", "s1", None, None).unwrap();
2270        db.link_commit_session("abc123", "s2", None, None).unwrap();
2271        db.link_commit_session("abc123", "s3", None, None).unwrap();
2272
2273        let sessions = db.get_sessions_by_commit("abc123").unwrap();
2274        assert_eq!(sessions.len(), 3);
2275        // Ordered by created_at DESC
2276        assert_eq!(sessions[0].id, "s3");
2277        assert_eq!(sessions[1].id, "s2");
2278        assert_eq!(sessions[2].id, "s1");
2279    }
2280
2281    #[test]
2282    fn test_get_commits_by_session() {
2283        let db = test_db();
2284        seed_sessions(&db);
2285        // Link multiple commits to the same session
2286        db.link_commit_session("aaa111", "s1", Some("/repo"), Some("main"))
2287            .unwrap();
2288        db.link_commit_session("bbb222", "s1", Some("/repo"), Some("main"))
2289            .unwrap();
2290        db.link_commit_session("ccc333", "s1", Some("/repo"), Some("feat"))
2291            .unwrap();
2292
2293        let commits = db.get_commits_by_session("s1").unwrap();
2294        assert_eq!(commits.len(), 3);
2295        // All linked to s1
2296        assert!(commits.iter().all(|c| c.session_id == "s1"));
2297    }
2298
2299    #[test]
2300    fn test_duplicate_link_ignored() {
2301        let db = test_db();
2302        seed_sessions(&db);
2303        db.link_commit_session("abc123", "s1", Some("/repo"), Some("main"))
2304            .unwrap();
2305        // Inserting the same link again should not error
2306        db.link_commit_session("abc123", "s1", Some("/repo"), Some("main"))
2307            .unwrap();
2308
2309        let commits = db.get_commits_by_session("s1").unwrap();
2310        assert_eq!(commits.len(), 1);
2311    }
2312
2313    #[test]
2314    fn test_log_filter_by_commit() {
2315        let db = test_db();
2316        seed_sessions(&db);
2317        db.link_commit_session("abc123", "s2", None, None).unwrap();
2318        db.link_commit_session("abc123", "s4", None, None).unwrap();
2319
2320        let filter = LogFilter {
2321            commit: Some("abc123".to_string()),
2322            ..Default::default()
2323        };
2324        let results = db.list_sessions_log(&filter).unwrap();
2325        assert_eq!(results.len(), 2);
2326        assert_eq!(results[0].id, "s4");
2327        assert_eq!(results[1].id, "s2");
2328
2329        // Non-existent commit returns nothing
2330        let filter = LogFilter {
2331            commit: Some("nonexistent".to_string()),
2332            ..Default::default()
2333        };
2334        let results = db.list_sessions_log(&filter).unwrap();
2335        assert_eq!(results.len(), 0);
2336    }
2337}