Skip to main content

toolpath_opencode/
io.rs

1//! Higher-level filesystem + DB operations over [`PathResolver`]
2//! and [`DbReader`].
3
4use 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
11/// The facade most consumers want. Wraps a `PathResolver` and lazily
12/// opens the database on demand.
13pub 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    /// List every project in the database.
51    pub fn list_projects(&self) -> Result<Vec<Project>> {
52        let db = self.open_db()?;
53        db.list_projects()
54    }
55
56    /// List every session, optionally filtered by project id.
57    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    /// Lightweight per-session metadata. One DB query per session
63    /// header + one aggregated query per session for the message
64    /// count — cheap enough for interactive listing.
65    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    /// Load one session fully (messages + parts attached).
96    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    /// Return one session's metadata by id.
102    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    /// Discriminator counts across all parts in a session — useful
130    /// for quick inspection / debugging.
131    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    /// Total message-role counts. Handy for sanity checks.
149    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}