use crate::paths::PathResolver;
use crate::provider::{to_view, to_view_with_resolver};
use crate::types::Session;
use toolpath::v1::Path;
#[derive(Debug, Clone, Default)]
pub struct DeriveConfig {
pub project_path: Option<String>,
pub no_snapshot_diffs: bool,
}
pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path {
derive_path_with_resolver(session, config, &PathResolver::new())
}
pub fn derive_path_with_resolver(
session: &Session,
config: &DeriveConfig,
resolver: &PathResolver,
) -> Path {
let view = if config.no_snapshot_diffs {
to_view(session)
} else {
to_view_with_resolver(session, resolver)
};
let base_uri = config.project_path.as_ref().map(|p| {
if p.starts_with('/') {
format!("file://{}", p)
} else {
p.clone()
}
});
let cfg = toolpath_convo::DeriveConfig {
base_uri,
title: Some(format!("opencode session: {}", session.title)),
..Default::default()
};
toolpath_convo::derive_path(&view, &cfg)
}
pub fn derive_project(sessions: &[Session], config: &DeriveConfig) -> Vec<Path> {
sessions.iter().map(|s| derive_path(s, config)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::OpencodeConvo;
use rusqlite::Connection;
use std::fs;
use tempfile::TempDir;
use toolpath::v1::Graph;
fn fixture(body_sql: &str) -> (TempDir, OpencodeConvo, PathResolver) {
let temp = TempDir::new().unwrap();
let data_dir = temp.path().join(".local/share/opencode");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("opencode.db");
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch(&format!(
r#"
CREATE TABLE project (
id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text,
icon_url text, icon_color text,
time_created integer NOT NULL, time_updated integer NOT NULL,
time_initialized integer, sandboxes text NOT NULL, commands text
);
CREATE TABLE session (
id text PRIMARY KEY, project_id text NOT NULL, parent_id text,
slug text NOT NULL, directory text NOT NULL, title text NOT NULL,
version text NOT NULL, share_url text,
summary_additions integer, summary_deletions integer,
summary_files integer, summary_diffs text, revert text, permission text,
time_created integer NOT NULL, time_updated integer NOT NULL,
time_compacting integer, time_archived integer, workspace_id text
);
CREATE TABLE message (
id text PRIMARY KEY, session_id text NOT NULL,
time_created integer NOT NULL, time_updated integer NOT NULL,
data text NOT NULL
);
CREATE TABLE part (
id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
time_created integer NOT NULL, time_updated integer NOT NULL,
data text NOT NULL
);
{body_sql}
"#
))
.unwrap();
drop(conn);
let resolver = PathResolver::new()
.with_home(temp.path())
.with_data_dir(&data_dir);
let mgr = OpencodeConvo::with_resolver(resolver.clone());
(temp, mgr, resolver)
}
const BASIC_SQL: &str = r#"
INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES ('proj_sha', '/tmp/proj', 1000, 3000, '[]');
INSERT INTO session (id, project_id, slug, directory, title, version,
time_created, time_updated)
VALUES ('ses_abc123', 'proj_sha', 'pickle-a-thing', '/tmp/proj', 'Pickle a thing', '0.10.0', 1000, 1100);
INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
('m1', 'ses_abc123', 1001, 1001, '{"role":"user","time":{"created":1001},"agent":"build","model":{"providerID":"opencode","modelID":"big-pickle"}}'),
('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"}');
INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
('p1','m1','ses_abc123',1001,1001,'{"type":"text","text":"make a pickle"}'),
('p2','m2','ses_abc123',1002,1002,'{"type":"step-start","snapshot":"snap_a"}'),
('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}}}'),
('p4','m2','ses_abc123',1007,1007,'{"type":"text","text":"done"}'),
('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}');
"#;
#[test]
fn derive_basic_shape() {
let (_t, mgr, resolver) = fixture(BASIC_SQL);
let s = mgr.read_session("ses_abc123").unwrap();
let p = derive_path_with_resolver(
&s,
&DeriveConfig {
no_snapshot_diffs: true,
..Default::default()
},
&resolver,
);
assert!(p.path.id.starts_with("path-opencode-"));
assert_eq!(p.path.base.as_ref().unwrap().uri, "file:///tmp/proj");
assert_eq!(
p.path.base.as_ref().unwrap().ref_str.as_deref(),
Some("proj_sha")
);
assert_eq!(
p.steps
.iter()
.filter(|s| {
s.change.values().any(|c| {
c.structural
.as_ref()
.is_some_and(|sc| sc.change_type == "conversation.append")
})
})
.count(),
2
);
}
#[test]
fn derive_emits_producer() {
let (_t, mgr, resolver) = fixture(BASIC_SQL);
let s = mgr.read_session("ses_abc123").unwrap();
let p = derive_path_with_resolver(
&s,
&DeriveConfig {
no_snapshot_diffs: true,
..Default::default()
},
&resolver,
);
let producer = p.meta.as_ref().unwrap().extra.get("producer").unwrap();
assert_eq!(producer["name"], "opencode");
assert_eq!(producer["version"], "0.10.0");
}
#[test]
fn derive_fallback_file_mutation_from_tool() {
let (_t, mgr, resolver) = fixture(BASIC_SQL);
let s = mgr.read_session("ses_abc123").unwrap();
let p = derive_path_with_resolver(
&s,
&DeriveConfig {
no_snapshot_diffs: true,
..Default::default()
},
&resolver,
);
let file_step = p
.steps
.iter()
.find(|s| s.change.contains_key("/tmp/proj/main.cpp"))
.expect("no step carries the file artifact");
let change = &file_step.change["/tmp/proj/main.cpp"];
let structural = change.structural.as_ref().unwrap();
assert_eq!(structural.change_type, "file.write");
assert_eq!(structural.extra["operation"], "add");
assert_eq!(structural.extra["tool_id"], "c1");
}
#[test]
fn derive_validates_as_single_path_graph() {
let (_t, mgr, resolver) = fixture(BASIC_SQL);
let s = mgr.read_session("ses_abc123").unwrap();
let p = derive_path_with_resolver(
&s,
&DeriveConfig {
no_snapshot_diffs: true,
..Default::default()
},
&resolver,
);
let doc = Graph::from_path(p);
let json = doc.to_json().unwrap();
let parsed = Graph::from_json(&json).unwrap();
let pp = parsed.single_path().expect("single-path graph");
let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head);
assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry");
}
}