1use crate::paths::PathResolver;
10use crate::provider::{to_view, to_view_with_resolver};
11use crate::types::Session;
12use toolpath::v1::Path;
13
14#[derive(Debug, Clone, Default)]
16pub struct DeriveConfig {
17 pub project_path: Option<String>,
19 pub no_snapshot_diffs: bool,
22}
23
24pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path {
26 derive_path_with_resolver(session, config, &PathResolver::new())
27}
28
29pub 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
56pub 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 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 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 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 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}