Skip to main content

toolpath_opencode/
derive.rs

1//! Derive Toolpath documents from opencode sessions.
2//!
3//! Thin wrapper around the shared [`toolpath_convo::derive_path`]. All
4//! opencode-specific work (snapshot git2 tree↔tree diffs, tool-input
5//! fallback for gitignored paths, producer/base population) happens in
6//! [`crate::provider::to_view_with_resolver`]; nothing provider-specific
7//! lives in this module.
8
9use crate::paths::PathResolver;
10use crate::provider::{to_view, to_view_with_resolver};
11use crate::types::Session;
12use toolpath::v1::Path;
13
14/// Configuration for deriving a Toolpath `Path` from an opencode session.
15#[derive(Debug, Clone, Default)]
16pub struct DeriveConfig {
17    /// Override `path.base.uri`. Defaults to `file://<session.directory>`.
18    pub project_path: Option<String>,
19    /// Skip the git2 snapshot-diff IO. Useful for tests with no
20    /// snapshot repo on disk.
21    pub no_snapshot_diffs: bool,
22}
23
24/// Derive a [`Path`] from an opencode [`Session`].
25pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path {
26    derive_path_with_resolver(session, config, &PathResolver::new())
27}
28
29/// Like [`derive_path`] but with a custom `PathResolver` (useful for
30/// tests with a temp data directory).
31pub fn derive_path_with_resolver(
32    session: &Session,
33    config: &DeriveConfig,
34    resolver: &PathResolver,
35) -> Path {
36    let view = if config.no_snapshot_diffs {
37        to_view(session)
38    } else {
39        to_view_with_resolver(session, resolver)
40    };
41    let base_uri = config.project_path.as_ref().map(|p| {
42        if p.starts_with('/') {
43            format!("file://{}", p)
44        } else {
45            p.clone()
46        }
47    });
48    let cfg = toolpath_convo::DeriveConfig {
49        base_uri,
50        title: Some(format!("opencode session: {}", session.title)),
51        ..Default::default()
52    };
53    toolpath_convo::derive_path(&view, &cfg)
54}
55
56/// Derive a `Path` from multiple sessions.
57pub fn derive_project(sessions: &[Session], config: &DeriveConfig) -> Vec<Path> {
58    sessions.iter().map(|s| derive_path(s, config)).collect()
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::OpencodeConvo;
65    use rusqlite::Connection;
66    use std::fs;
67    use tempfile::TempDir;
68    use toolpath::v1::Graph;
69
70    /// Fixture with the real opencode schema (matches what the SQLite
71    /// reader expects) but no snapshot git repo on disk. Tests run with
72    /// `no_snapshot_diffs: true` so the tool-input fallback kicks in.
73    fn fixture(body_sql: &str) -> (TempDir, OpencodeConvo, PathResolver) {
74        let temp = TempDir::new().unwrap();
75        let data_dir = temp.path().join(".local/share/opencode");
76        fs::create_dir_all(&data_dir).unwrap();
77        let db_path = data_dir.join("opencode.db");
78        let conn = Connection::open(&db_path).unwrap();
79        conn.execute_batch(&format!(
80            r#"
81            CREATE TABLE project (
82              id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text,
83              icon_url text, icon_color text,
84              time_created integer NOT NULL, time_updated integer NOT NULL,
85              time_initialized integer, sandboxes text NOT NULL, commands text
86            );
87            CREATE TABLE session (
88              id text PRIMARY KEY, project_id text NOT NULL, parent_id text,
89              slug text NOT NULL, directory text NOT NULL, title text NOT NULL,
90              version text NOT NULL, share_url text,
91              summary_additions integer, summary_deletions integer,
92              summary_files integer, summary_diffs text, revert text, permission text,
93              time_created integer NOT NULL, time_updated integer NOT NULL,
94              time_compacting integer, time_archived integer, workspace_id text
95            );
96            CREATE TABLE message (
97              id text PRIMARY KEY, session_id text NOT NULL,
98              time_created integer NOT NULL, time_updated integer NOT NULL,
99              data text NOT NULL
100            );
101            CREATE TABLE part (
102              id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
103              time_created integer NOT NULL, time_updated integer NOT NULL,
104              data text NOT NULL
105            );
106            {body_sql}
107            "#
108        ))
109        .unwrap();
110        drop(conn);
111        let resolver = PathResolver::new()
112            .with_home(temp.path())
113            .with_data_dir(&data_dir);
114        let mgr = OpencodeConvo::with_resolver(resolver.clone());
115        (temp, mgr, resolver)
116    }
117
118    const BASIC_SQL: &str = r#"
119        INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
120          VALUES ('proj_sha', '/tmp/proj', 1000, 3000, '[]');
121        INSERT INTO session (id, project_id, slug, directory, title, version,
122                             time_created, time_updated)
123          VALUES ('ses_abc123', 'proj_sha', 'pickle-a-thing', '/tmp/proj', 'Pickle a thing', '0.10.0', 1000, 1100);
124        INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
125          ('m1', 'ses_abc123', 1001, 1001, '{"role":"user","time":{"created":1001},"agent":"build","model":{"providerID":"opencode","modelID":"big-pickle"}}'),
126          ('m2', 'ses_abc123', 1002, 1100, '{"parentID":"m1","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/tmp/proj","root":"/tmp/proj"},"cost":0.01,"tokens":{"input":10,"output":5,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"claude-sonnet-4-6","providerID":"anthropic","time":{"created":1002,"completed":1100},"finish":"stop"}');
127        INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
128          ('p1','m1','ses_abc123',1001,1001,'{"type":"text","text":"make a pickle"}'),
129          ('p2','m2','ses_abc123',1002,1002,'{"type":"step-start","snapshot":"snap_a"}'),
130          ('p3','m2','ses_abc123',1005,1005,'{"type":"tool","tool":"write","callID":"c1","state":{"status":"completed","input":{"filePath":"/tmp/proj/main.cpp","content":"int main(){}\n"},"output":"wrote","title":"Write","metadata":{"bytes":13},"time":{"start":1005,"end":1006}}}'),
131          ('p4','m2','ses_abc123',1007,1007,'{"type":"text","text":"done"}'),
132          ('p5','m2','ses_abc123',1010,1010,'{"type":"step-finish","reason":"stop","snapshot":"snap_b","tokens":{"input":10,"output":5,"reasoning":0,"cache":{"read":0,"write":0}},"cost":0.01}');
133    "#;
134
135    #[test]
136    fn derive_basic_shape() {
137        let (_t, mgr, resolver) = fixture(BASIC_SQL);
138        let s = mgr.read_session("ses_abc123").unwrap();
139        let p = derive_path_with_resolver(
140            &s,
141            &DeriveConfig {
142                no_snapshot_diffs: true,
143                ..Default::default()
144            },
145            &resolver,
146        );
147
148        assert!(p.path.id.starts_with("path-opencode-"));
149        assert_eq!(p.path.base.as_ref().unwrap().uri, "file:///tmp/proj");
150        assert_eq!(
151            p.path.base.as_ref().unwrap().ref_str.as_deref(),
152            Some("proj_sha")
153        );
154        // 2 messages → 2 turns (both have content/tool calls).
155        assert_eq!(
156            p.steps
157                .iter()
158                .filter(|s| {
159                    s.change.values().any(|c| {
160                        c.structural
161                            .as_ref()
162                            .is_some_and(|sc| sc.change_type == "conversation.append")
163                    })
164                })
165                .count(),
166            2
167        );
168    }
169
170    #[test]
171    fn derive_emits_producer() {
172        let (_t, mgr, resolver) = fixture(BASIC_SQL);
173        let s = mgr.read_session("ses_abc123").unwrap();
174        let p = derive_path_with_resolver(
175            &s,
176            &DeriveConfig {
177                no_snapshot_diffs: true,
178                ..Default::default()
179            },
180            &resolver,
181        );
182        let producer = p.meta.as_ref().unwrap().extra.get("producer").unwrap();
183        assert_eq!(producer["name"], "opencode");
184        assert_eq!(producer["version"], "0.10.0");
185    }
186
187    #[test]
188    fn derive_fallback_file_mutation_from_tool() {
189        let (_t, mgr, resolver) = fixture(BASIC_SQL);
190        let s = mgr.read_session("ses_abc123").unwrap();
191        let p = derive_path_with_resolver(
192            &s,
193            &DeriveConfig {
194                no_snapshot_diffs: true,
195                ..Default::default()
196            },
197            &resolver,
198        );
199        // The assistant turn's `write` tool produces a sibling `file.write`
200        // entry via the tool-input fallback (no snapshot repo on disk).
201        let file_step = p
202            .steps
203            .iter()
204            .find(|s| s.change.contains_key("/tmp/proj/main.cpp"))
205            .expect("no step carries the file artifact");
206        let change = &file_step.change["/tmp/proj/main.cpp"];
207        let structural = change.structural.as_ref().unwrap();
208        assert_eq!(structural.change_type, "file.write");
209        assert_eq!(structural.extra["operation"], "add");
210        // tool_id links back to the write tool.
211        assert_eq!(structural.extra["tool_id"], "c1");
212    }
213
214    #[test]
215    fn derive_validates_as_single_path_graph() {
216        let (_t, mgr, resolver) = fixture(BASIC_SQL);
217        let s = mgr.read_session("ses_abc123").unwrap();
218        let p = derive_path_with_resolver(
219            &s,
220            &DeriveConfig {
221                no_snapshot_diffs: true,
222                ..Default::default()
223            },
224            &resolver,
225        );
226        let doc = Graph::from_path(p);
227        let json = doc.to_json().unwrap();
228        let parsed = Graph::from_json(&json).unwrap();
229        let pp = parsed.single_path().expect("single-path graph");
230        let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head);
231        assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry");
232    }
233}