diff --git a/src/adapters/AGENTS.md b/src/adapters/AGENTS.md index f25ca55..6856ed5 100644 --- a/src/adapters/AGENTS.md +++ b/src/adapters/AGENTS.md @@ -8,8 +8,8 @@ apply when working here. - The `SourceAdapter` trait in `mod.rs` is the authoritative contract, not the DEVELOPMENT.md example. `id()`, `label()`, `scan()`, and `resume_command()` - are required; `scan_summary()`, `scan_for_sync()`, `prune()`, - `app_command()`, and `usage_parser_version()` are optional overrides. + are required; `scan_for_sync()`, `prune()`, `app_command()`, and + `usage_parser_version()` are optional overrides. - Register new adapters in `all_adapters()` in `mod.rs`. Registration alone wires the adapter into sync, search, the TUI source filter, and the CLI `--source` flag. No schema change is needed — `sessions.source` is a value, diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 119705a..3cfa371 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -24,9 +24,6 @@ pub(crate) trait SourceAdapter { fn id(&self) -> &str; fn label(&self) -> &str; fn scan(&self) -> anyhow::Result>; - fn scan_summary(&self) -> anyhow::Result> { - Ok(None) - } fn usage_parser_version(&self) -> Option { None } @@ -162,13 +159,6 @@ pub(crate) struct SyncScanResult { pub(crate) stats: SyncScanStats, } -pub(crate) struct SourceScanSummary { - pub(crate) sessions: usize, - pub(crate) messages: usize, - pub(crate) oldest_started_at: Option, - pub(crate) newest_started_at: Option, -} - #[derive(Debug, Clone)] pub(crate) struct ResumeCommand { pub(crate) program: String, diff --git a/src/adapters/opencode.rs b/src/adapters/opencode.rs index 7c6841a..e587f70 100644 --- a/src/adapters/opencode.rs +++ b/src/adapters/opencode.rs @@ -6,8 +6,7 @@ use tracing::debug; use crate::adapters::events; use crate::adapters::{ - RawMessage, RawSession, ResumeCommand, SourceAdapter, SourceScanSummary, SyncScanResult, - SyncScanStats, + RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, SyncScanStats, }; use crate::db::store::Store; use crate::types::{RawSessionEvent, RawUsageEvent, Role}; @@ -71,27 +70,6 @@ impl SourceAdapter for OpenCodeAdapter { scan_session_messages(&conn, sessions, true) } - fn scan_summary(&self) -> anyhow::Result> { - let Some(conn) = open_opencode_db()? else { - return Ok(Some(SourceScanSummary { - sessions: 0, - messages: 0, - oldest_started_at: None, - newest_started_at: None, - })); - }; - - let sessions: usize = - conn.query_row("SELECT COUNT(*) FROM session", [], |row| row.get(0))?; - let oldest_started_at = - conn.query_row("SELECT MIN(time_created) FROM session", [], |row| row.get(0))?; - let newest_started_at = - conn.query_row("SELECT MAX(time_created) FROM session", [], |row| row.get(0))?; - let messages = count_total_parsed_messages(&conn)?; - - Ok(Some(SourceScanSummary { sessions, messages, oldest_started_at, newest_started_at })) - } - fn scan_for_sync( &self, store: &Store, @@ -137,16 +115,6 @@ fn count_filtered_sessions(conn: &Connection, since_ts: Option) -> anyhow:: .map_err(Into::into) } -fn count_total_parsed_messages(conn: &Connection) -> anyhow::Result { - let sql = format!( - "SELECT COUNT(*) - FROM message m - JOIN part p ON p.message_id = m.id - WHERE {PARSED_PART_FILTER_SQL}" - ); - conn.query_row(&sql, [], |row| row.get(0)).map_err(Into::into) -} - fn load_session_rows(conn: &Connection, since_ts: Option) -> anyhow::Result> { let sql = if since_ts.is_some() { "SELECT id, directory, time_created, time_updated @@ -880,31 +848,6 @@ mod tests { let _ = std::fs::remove_file(path); } - #[test] - fn summary_reports_counts_without_full_scan() { - let (path, conn) = setup_opencode_db(); - insert_session_with_message(&conn, "s1", 220, 100, "hello"); - insert_session_with_message(&conn, "s2", 250, 200, "world"); - - let summary = SourceScanSummary { - sessions: conn.query_row("SELECT COUNT(*) FROM session", [], |row| row.get(0)).unwrap(), - messages: count_total_parsed_messages(&conn).unwrap(), - oldest_started_at: conn - .query_row("SELECT MIN(time_created) FROM session", [], |row| row.get(0)) - .unwrap(), - newest_started_at: conn - .query_row("SELECT MAX(time_created) FROM session", [], |row| row.get(0)) - .unwrap(), - }; - - assert_eq!(summary.sessions, 2); - assert_eq!(summary.messages, 2); - assert_eq!(summary.oldest_started_at, Some(100)); - assert_eq!(summary.newest_started_at, Some(200)); - drop(conn); - let _ = std::fs::remove_file(path); - } - #[test] fn incremental_scan_tolerates_malformed_json_rows() { let (path, conn) = setup_opencode_db(); diff --git a/src/db/session_store.rs b/src/db/session_store.rs index 3b6404d..d948343 100644 --- a/src/db/session_store.rs +++ b/src/db/session_store.rs @@ -16,6 +16,13 @@ use crate::types::{ Message, ParentLink, RawSessionEvent, RawUsageEvent, Role, Session, SessionTopology, ThreadRole, }; +pub(crate) struct IndexedSourceStats { + pub(crate) sessions: u64, + pub(crate) messages: u64, + pub(crate) oldest_started_at: Option, + pub(crate) newest_started_at: Option, +} + impl Store { pub(crate) fn session_meta( &self, @@ -45,6 +52,27 @@ impl Store { rows.collect::, _>>().map_err(Into::into) } + pub(crate) fn indexed_source_stats(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT source, COUNT(*), COALESCE(SUM(message_count), 0), + MIN(started_at), MAX(started_at) + FROM sessions + GROUP BY source", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get(0)?, + IndexedSourceStats { + sessions: row.get(1)?, + messages: row.get(2)?, + oldest_started_at: row.get(3)?, + newest_started_at: row.get(4)?, + }, + )) + })?; + rows.collect::, _>>().map_err(Into::into) + } + pub(crate) fn imported_source_ids(&self, source: &str) -> Result> { let mut stmt = self .conn @@ -1226,3 +1254,38 @@ mod topology_tests { ); } } + +#[cfg(test)] +mod source_stats_tests { + use super::*; + use crate::db::schema; + + #[test] + fn indexed_source_stats_use_persisted_session_counts() { + schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + store + .conn + .execute_batch( + "INSERT INTO sessions + (id, source, source_id, title, started_at, message_count) + VALUES + ('c1', 'codex', 'raw-c1', 'one', 20, 2), + ('c2', 'codex', 'raw-c2', 'two', 10, 3), + ('o1', 'opencode', 'raw-o1', 'three', 30, 4);", + ) + .unwrap(); + + let stats = store.indexed_source_stats().unwrap(); + + let codex = &stats["codex"]; + assert_eq!(codex.sessions, 2); + assert_eq!(codex.messages, 5); + assert_eq!(codex.oldest_started_at, Some(10)); + assert_eq!(codex.newest_started_at, Some(20)); + + let opencode = &stats["opencode"]; + assert_eq!(opencode.sessions, 1); + assert_eq!(opencode.messages, 4); + } +} diff --git a/src/info.rs b/src/info.rs index 6a3e527..c2d49a1 100644 --- a/src/info.rs +++ b/src/info.rs @@ -1,7 +1,10 @@ +use std::collections::HashMap; + use anyhow::Result; use crate::adapters; use crate::config::AppConfig; +use crate::db::session_store::IndexedSourceStats; use crate::db::store::Store; #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] @@ -14,86 +17,24 @@ pub(crate) enum InfoFormat { struct SourceSummary { label: String, id: String, - sessions: usize, - messages: usize, + sessions: u64, + messages: u64, range: String, error: Option, } pub(crate) fn run(format: InfoFormat) -> Result<()> { - let all = adapters::all_adapters(); let labels = adapters::source_labels(); let mut config = AppConfig::load()?; config.normalize_sources(&labels); let store = Store::open()?; + let source_stats = store.indexed_source_stats()?; let progress = store.semantic_progress().unwrap_or_default(); let worker = store.background_job_status("pipeline").unwrap_or_default(); - let mut rows = Vec::new(); - let mut grand_sessions = 0usize; - let mut grand_messages = 0usize; - - for adapter in &all { - let id = adapter.id(); - let label = - labels.iter().find(|(k, _)| k == id).map(|(_, v)| v.as_str()).unwrap_or(id).to_string(); - - match adapter.scan_summary() { - Ok(Some(summary)) => { - grand_sessions += summary.sessions; - grand_messages += summary.messages; - - rows.push(SourceSummary { - label, - id: id.to_string(), - sessions: summary.sessions, - messages: summary.messages, - range: format_date_range(summary.oldest_started_at, summary.newest_started_at), - error: None, - }); - } - Ok(None) => match adapter.scan() { - Ok(sessions) => { - let session_count = sessions.len(); - let message_count: usize = sessions.iter().map(|s| s.messages.len()).sum(); - let oldest = sessions.iter().map(|s| s.started_at).min(); - let newest = sessions.iter().map(|s| s.started_at).max(); - - grand_sessions += session_count; - grand_messages += message_count; - - rows.push(SourceSummary { - label, - id: id.to_string(), - sessions: session_count, - messages: message_count, - range: format_date_range(oldest, newest), - error: None, - }); - } - Err(e) => { - rows.push(SourceSummary { - label, - id: id.to_string(), - sessions: 0, - messages: 0, - range: "-".to_string(), - error: Some(e.to_string()), - }); - } - }, - Err(e) => { - rows.push(SourceSummary { - label, - id: id.to_string(), - sessions: 0, - messages: 0, - range: "-".to_string(), - error: Some(e.to_string()), - }); - } - } - } + let rows = source_summaries(&labels, &source_stats); + let grand_sessions = rows.iter().map(|row| row.sessions).sum::(); + let grand_messages = rows.iter().map(|row| row.messages).sum::(); if matches!(format, InfoFormat::Json) { println!( @@ -150,7 +91,7 @@ pub(crate) fn run(format: InfoFormat) -> Result<()> { .max("Messages".len()) .max(grand_messages.to_string().len()); - println!("Source Scan"); + println!("Indexed Sources"); println!( " {source:sessions_width$} {messages:>messages_width$} Range", source = "Source", @@ -176,7 +117,7 @@ pub(crate) fn run(format: InfoFormat) -> Result<()> { } println!( " {source:sessions_width$} {messages:>messages_width$}", - source = "Total scanned", + source = "Total indexed", sessions = grand_sessions, messages = grand_messages ); @@ -213,6 +154,38 @@ pub(crate) fn run(format: InfoFormat) -> Result<()> { Ok(()) } +fn source_summaries( + labels: &[(String, String)], + source_stats: &HashMap, +) -> Vec { + let mut sources = labels.to_vec(); + let mut unknown_sources = source_stats + .keys() + .filter(|source| !labels.iter().any(|(id, _)| id == *source)) + .cloned() + .collect::>(); + unknown_sources.sort(); + sources.extend(unknown_sources.into_iter().map(|id| (id.clone(), id))); + + sources + .into_iter() + .map(|(id, label)| { + let stats = source_stats.get(&id); + SourceSummary { + label, + sessions: stats.map_or(0, |stats| stats.sessions), + messages: stats.map_or(0, |stats| stats.messages), + range: format_date_range( + stats.and_then(|stats| stats.oldest_started_at), + stats.and_then(|stats| stats.newest_started_at), + ), + id, + error: None, + } + }) + .collect() +} + fn format_date_range(oldest: Option, newest: Option) -> String { if oldest.is_none() && newest.is_none() { return "-".to_string(); @@ -229,3 +202,46 @@ fn format_date_range(oldest: Option, newest: Option) -> String { format!("{oldest} -> {newest}") } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::db::session_store::IndexedSourceStats; + + #[test] + fn source_summaries_include_unregistered_indexed_sources() { + let labels = vec![("codex".to_string(), "CDX".to_string())]; + let stats = HashMap::from([ + ( + "codex".to_string(), + IndexedSourceStats { + sessions: 1, + messages: 2, + oldest_started_at: Some(10), + newest_started_at: Some(20), + }, + ), + ( + "future-agent".to_string(), + IndexedSourceStats { + sessions: 3, + messages: 4, + oldest_started_at: Some(30), + newest_started_at: Some(40), + }, + ), + ]); + + let rows = source_summaries(&labels, &stats); + + assert_eq!( + rows.iter().map(|row| row.id.as_str()).collect::>(), + ["codex", "future-agent"] + ); + assert_eq!(rows[1].label, "future-agent"); + assert_eq!(rows[1].sessions, 3); + assert_eq!(rows[1].messages, 4); + } +}