1use crate::error::{ConvoError, Result};
5use crate::paths::PathResolver;
6use crate::reader::DbReader;
7use crate::types::{MessageData, PartData, Project, Session, SessionMetadata};
8use chrono::{TimeZone, Utc};
9use std::path::PathBuf;
10
11pub struct ConvoIO {
14 resolver: PathResolver,
15}
16
17impl Default for ConvoIO {
18 fn default() -> Self {
19 Self::new()
20 }
21}
22
23impl ConvoIO {
24 pub fn new() -> Self {
25 Self {
26 resolver: PathResolver::new(),
27 }
28 }
29
30 pub fn with_resolver(resolver: PathResolver) -> Self {
31 Self { resolver }
32 }
33
34 pub fn resolver(&self) -> &PathResolver {
35 &self.resolver
36 }
37
38 pub fn exists(&self) -> bool {
39 self.resolver.db_exists()
40 }
41
42 pub fn db_path(&self) -> Result<PathBuf> {
43 self.resolver.db_path()
44 }
45
46 fn open_db(&self) -> Result<DbReader> {
47 DbReader::open(self.resolver.db_path()?)
48 }
49
50 pub fn list_projects(&self) -> Result<Vec<Project>> {
52 let db = self.open_db()?;
53 db.list_projects()
54 }
55
56 pub fn list_sessions(&self, project_id: Option<&str>) -> Result<Vec<Session>> {
58 let db = self.open_db()?;
59 db.list_sessions(project_id)
60 }
61
62 pub fn list_session_metadata(&self, project_id: Option<&str>) -> Result<Vec<SessionMetadata>> {
66 let db = self.open_db()?;
67 let sessions = db.list_sessions(project_id)?;
68 let mut out = Vec::with_capacity(sessions.len());
69 for s in sessions {
70 let full = match db.load_session(&s.id) {
71 Ok(f) => f,
72 Err(e) => {
73 eprintln!("Warning: skipping {}: {}", s.id, e);
74 continue;
75 }
76 };
77 out.push(SessionMetadata {
78 id: full.id.clone(),
79 project_id: full.project_id.clone(),
80 directory: full.directory.clone(),
81 title: full.title.clone(),
82 version: full.version.clone(),
83 started_at: Utc.timestamp_millis_opt(full.time_created).single(),
84 last_activity: Utc.timestamp_millis_opt(full.time_updated).single(),
85 message_count: full.messages.len(),
86 first_user_message: full.first_user_text(),
87 summary_additions: full.summary_additions,
88 summary_deletions: full.summary_deletions,
89 summary_files: full.summary_files,
90 });
91 }
92 Ok(out)
93 }
94
95 pub fn read_session(&self, session_id: &str) -> Result<Session> {
97 let db = self.open_db()?;
98 db.load_session(session_id)
99 }
100
101 pub fn read_metadata(&self, session_id: &str) -> Result<SessionMetadata> {
103 let db = self.open_db()?;
104 let s = db.load_session(session_id)?;
105 Ok(SessionMetadata {
106 id: s.id.clone(),
107 project_id: s.project_id.clone(),
108 directory: s.directory.clone(),
109 title: s.title.clone(),
110 version: s.version.clone(),
111 started_at: Utc.timestamp_millis_opt(s.time_created).single(),
112 last_activity: Utc.timestamp_millis_opt(s.time_updated).single(),
113 message_count: s.messages.len(),
114 first_user_message: s.first_user_text(),
115 summary_additions: s.summary_additions,
116 summary_deletions: s.summary_deletions,
117 summary_files: s.summary_files,
118 })
119 }
120
121 pub fn session_exists(&self, session_id: &str) -> Result<bool> {
122 match self.open_db() {
123 Ok(db) => Ok(db.get_session(session_id)?.is_some()),
124 Err(ConvoError::DatabaseNotFound(_)) => Ok(false),
125 Err(e) => Err(e),
126 }
127 }
128
129 pub fn part_type_counts(
132 &self,
133 session_id: &str,
134 ) -> Result<std::collections::BTreeMap<String, usize>> {
135 let db = self.open_db()?;
136 let parts = db.list_parts_for_session(session_id)?;
137 let mut counts = std::collections::BTreeMap::new();
138 for p in parts {
139 let key = match p.data {
140 PartData::Unknown => "unknown".to_string(),
141 ref d => d.kind().to_string(),
142 };
143 *counts.entry(key).or_insert(0) += 1;
144 }
145 Ok(counts)
146 }
147
148 pub fn role_counts(
150 &self,
151 session_id: &str,
152 ) -> Result<std::collections::BTreeMap<String, usize>> {
153 let db = self.open_db()?;
154 let msgs = db.list_messages_raw(session_id)?;
155 let mut counts = std::collections::BTreeMap::new();
156 for m in msgs {
157 let k = match m.data {
158 MessageData::User(_) => "user",
159 MessageData::Assistant(_) => "assistant",
160 MessageData::Other => "other",
161 };
162 *counts.entry(k.to_string()).or_insert(0) += 1;
163 }
164 Ok(counts)
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use rusqlite::Connection;
172 use std::fs;
173 use tempfile::TempDir;
174
175 fn fixture() -> (TempDir, ConvoIO) {
176 let temp = TempDir::new().unwrap();
177 let data = temp.path().join(".local/share/opencode");
178 fs::create_dir_all(&data).unwrap();
179 let conn = Connection::open(data.join("opencode.db")).unwrap();
180 conn.execute_batch(
181 r#"
182 CREATE TABLE project (
183 id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text,
184 icon_url text, icon_color text,
185 time_created integer NOT NULL, time_updated integer NOT NULL,
186 time_initialized integer, sandboxes text NOT NULL, commands text
187 );
188 CREATE TABLE session (
189 id text PRIMARY KEY, project_id text NOT NULL, parent_id text,
190 slug text NOT NULL, directory text NOT NULL, title text NOT NULL,
191 version text NOT NULL, share_url text,
192 summary_additions integer, summary_deletions integer,
193 summary_files integer, summary_diffs text, revert text, permission text,
194 time_created integer NOT NULL, time_updated integer NOT NULL,
195 time_compacting integer, time_archived integer, workspace_id text
196 );
197 CREATE TABLE message (
198 id text PRIMARY KEY, session_id text NOT NULL,
199 time_created integer NOT NULL, time_updated integer NOT NULL,
200 data text NOT NULL
201 );
202 CREATE TABLE part (
203 id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
204 time_created integer NOT NULL, time_updated integer NOT NULL,
205 data text NOT NULL
206 );
207 INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
208 VALUES ('p1','/tmp/p',1000,2000,'[]');
209 INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
210 VALUES ('ses_a','p1','slug','/tmp/p','Greeting','1.0.0',1000,2000);
211 INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
212 ('m1','ses_a',1001,1001,'{"role":"user","time":{"created":1001},"agent":"build","model":{"providerID":"p","modelID":"m"}}'),
213 ('m2','ses_a',1002,1002,'{"parentID":"m1","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/tmp/p","root":"/tmp/p"},"cost":0.0,"tokens":{"input":1,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"m","providerID":"p","time":{"created":1002},"finish":"stop"}');
214 INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
215 ('p1x','m1','ses_a',1001,1001,'{"type":"text","text":"hi"}'),
216 ('p2a','m2','ses_a',1002,1002,'{"type":"step-start","snapshot":"abc"}'),
217 ('p2b','m2','ses_a',1002,1002,'{"type":"text","text":"hello back"}'),
218 ('p2c','m2','ses_a',1002,1002,'{"type":"step-finish","reason":"stop","snapshot":"abc","tokens":{"input":1,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"cost":0}');
219 "#,
220 )
221 .unwrap();
222 drop(conn);
223 let resolver = PathResolver::new()
224 .with_home(temp.path())
225 .with_data_dir(&data);
226 (temp, ConvoIO::with_resolver(resolver))
227 }
228
229 #[test]
230 fn lists_projects_and_sessions() {
231 let (_t, io) = fixture();
232 assert_eq!(io.list_projects().unwrap().len(), 1);
233 assert_eq!(io.list_sessions(None).unwrap().len(), 1);
234 assert_eq!(io.list_sessions(Some("p1")).unwrap().len(), 1);
235 assert_eq!(io.list_sessions(Some("nope")).unwrap().len(), 0);
236 }
237
238 #[test]
239 fn session_metadata_populated() {
240 let (_t, io) = fixture();
241 let metas = io.list_session_metadata(None).unwrap();
242 assert_eq!(metas.len(), 1);
243 let m = &metas[0];
244 assert_eq!(m.id, "ses_a");
245 assert_eq!(m.message_count, 2);
246 assert_eq!(m.first_user_message.as_deref(), Some("hi"));
247 }
248
249 #[test]
250 fn part_type_counts_report() {
251 let (_t, io) = fixture();
252 let counts = io.part_type_counts("ses_a").unwrap();
253 assert_eq!(counts["text"], 2);
254 assert_eq!(counts["step-start"], 1);
255 assert_eq!(counts["step-finish"], 1);
256 }
257
258 #[test]
259 fn role_counts_report() {
260 let (_t, io) = fixture();
261 let c = io.role_counts("ses_a").unwrap();
262 assert_eq!(c["user"], 1);
263 assert_eq!(c["assistant"], 1);
264 }
265
266 #[test]
267 fn session_exists_true_false() {
268 let (_t, io) = fixture();
269 assert!(io.session_exists("ses_a").unwrap());
270 assert!(!io.session_exists("ses_missing").unwrap());
271 }
272
273 #[test]
274 fn read_session_and_metadata() {
275 let (_t, io) = fixture();
276 let s = io.read_session("ses_a").unwrap();
277 assert_eq!(s.messages.len(), 2);
278 let m = io.read_metadata("ses_a").unwrap();
279 assert_eq!(m.message_count, 2);
280 }
281}