use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::types::*;
use super::SessionProvider;
const MAX_SESSION_SIZE: u64 = 500 * 1024 * 1024;
pub(crate) struct OpenCodeProvider {
db_path: PathBuf,
}
impl OpenCodeProvider {
pub(crate) fn detect() -> Option<Self> {
let opencode_dir = if let Ok(override_dir) = std::env::var("SKIM_OPENCODE_DIR") {
PathBuf::from(override_dir)
} else {
walk_up_for_opencode()?
};
find_sqlite_db(&opencode_dir).map(|db_path| Self { db_path })
}
}
fn walk_up_for_opencode() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let candidate = current.join(".opencode");
if candidate.is_dir() {
return Some(candidate);
}
if !current.pop() {
return None;
}
}
}
fn find_sqlite_db(dir: &Path) -> Option<PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext == "db" || ext == "sqlite" || ext == "sqlite3" {
return Some(path);
}
}
}
}
None
}
impl SessionProvider for OpenCodeProvider {
fn agent_kind(&self) -> AgentKind {
AgentKind::OpenCode
}
fn find_sessions(&self, filter: &TimeFilter) -> anyhow::Result<Vec<SessionFile>> {
let conn = rusqlite::Connection::open_with_flags(
&self.db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
conn.busy_timeout(std::time::Duration::from_millis(1000))?;
let mut stmt = conn.prepare(
"SELECT id, title, created_at, updated_at \
FROM conversations \
ORDER BY updated_at DESC \
LIMIT 100",
)?;
let rows = stmt.query_map([], |row| {
Ok(ConversationRow {
id: row.get(0)?,
_title: row.get::<_, Option<String>>(1)?,
_created_at: row.get::<_, Option<String>>(2)?,
updated_at: row.get::<_, Option<String>>(3)?,
})
})?;
let mut sessions = Vec::new();
for row in rows {
let conv = match row {
Ok(c) => c,
Err(_) => continue,
};
let modified = parse_iso_timestamp(conv.updated_at.as_deref().unwrap_or(""))
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
if let Some(since) = filter.since {
if modified < since {
continue;
}
}
sessions.push(SessionFile {
path: self.db_path.clone(),
modified,
agent: AgentKind::OpenCode,
session_id: conv.id,
});
}
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
if filter.latest_only {
sessions.truncate(1);
}
Ok(sessions)
}
fn parse_session(&self, file: &SessionFile) -> anyhow::Result<Vec<ToolInvocation>> {
let file_size = std::fs::metadata(&self.db_path)?.len();
if file_size > MAX_SESSION_SIZE {
anyhow::bail!(
"session database too large ({:.1} MB, limit {:.0} MB): {}",
file_size as f64 / (1024.0 * 1024.0),
MAX_SESSION_SIZE as f64 / (1024.0 * 1024.0),
self.db_path.display()
);
}
let conn = rusqlite::Connection::open_with_flags(
&self.db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
conn.busy_timeout(std::time::Duration::from_millis(1000))?;
let mut stmt = conn.prepare(
"SELECT id, role, content, tool_calls, tool_call_id, created_at \
FROM messages \
WHERE conversation_id = ?1 \
ORDER BY created_at ASC \
LIMIT 10000",
)?;
let rows = stmt.query_map([&file.session_id], |row| {
Ok(MessageRow {
_id: row.get(0)?,
role: row.get(1)?,
content: row.get(2)?,
tool_calls: row.get(3)?,
tool_call_id: row.get(4)?,
created_at: row.get(5)?,
})
})?;
let messages: Vec<MessageRow> = rows.filter_map(|r| r.ok()).collect();
parse_opencode_messages(&messages, &file.session_id)
}
}
struct ConversationRow {
id: String,
_title: Option<String>,
_created_at: Option<String>,
updated_at: Option<String>,
}
struct MessageRow {
_id: String,
role: Option<String>,
content: Option<String>,
tool_calls: Option<String>,
tool_call_id: Option<String>,
created_at: Option<String>,
}
fn parse_opencode_messages(
messages: &[MessageRow],
session_id: &str,
) -> anyhow::Result<Vec<ToolInvocation>> {
let mut invocations = Vec::new();
let mut pending: HashMap<String, usize> = HashMap::new();
for msg in messages {
let role = msg.role.as_deref().unwrap_or("");
let timestamp = msg.created_at.as_deref().unwrap_or("").to_string();
match role {
"assistant" => {
if let Some(tool_calls_json) = &msg.tool_calls {
let tool_calls = parse_tool_calls_json(tool_calls_json);
for tc in tool_calls {
let input = map_opencode_tool(&tc.name, &tc.arguments);
let idx = invocations.len();
invocations.push(ToolInvocation {
tool_name: tc.name.clone(),
input,
timestamp: timestamp.clone(),
session_id: session_id.to_string(),
agent: AgentKind::OpenCode,
result: None,
});
if !tc.id.is_empty() {
pending.insert(tc.id, idx);
}
}
}
}
"tool" => {
if let Some(call_id) = &msg.tool_call_id {
if let Some(&idx) = pending.get(call_id.as_str()) {
let content = msg.content.as_deref().unwrap_or("").to_string();
invocations[idx].result = Some(ToolResult {
content,
is_error: false,
});
pending.remove(call_id.as_str());
}
}
}
_ => {} }
}
Ok(invocations)
}
struct ParsedToolCall {
id: String,
name: String,
arguments: serde_json::Value,
}
fn parse_tool_calls_json(raw: &str) -> Vec<ParsedToolCall> {
let arr: Vec<serde_json::Value> = match serde_json::from_str(raw) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut calls = Vec::new();
for item in &arr {
let func = match item.get("function") {
Some(f) => f,
None => continue,
};
let id = item
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let name = func
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let arguments = func
.get("arguments")
.and_then(|a| {
if let Some(s) = a.as_str() {
serde_json::from_str(s).ok()
} else {
Some(a.clone())
}
})
.unwrap_or(serde_json::Value::Null);
calls.push(ParsedToolCall {
id,
name,
arguments,
});
}
calls
}
fn map_opencode_tool(name: &str, args: &serde_json::Value) -> ToolInput {
match name {
"bash" | "shell" | "execute" => {
let command = args
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
ToolInput::Bash { command }
}
"read_file" | "read" => {
let file_path = args
.get("file_path")
.or_else(|| args.get("path"))
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Read { file_path }
}
"write_file" | "write" | "create_file" => {
let file_path = args
.get("file_path")
.or_else(|| args.get("path"))
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Write { file_path }
}
"edit_file" | "edit" | "patch" => {
let file_path = args
.get("file_path")
.or_else(|| args.get("path"))
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Edit { file_path }
}
"glob" | "list_files" => {
let pattern = args
.get("pattern")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Glob { pattern }
}
"grep" | "search" => {
let pattern = args
.get("pattern")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Grep { pattern }
}
_ => ToolInput::Other {
tool_name: name.to_string(),
raw: args.clone(),
},
}
}
fn parse_iso_timestamp(s: &str) -> Option<std::time::SystemTime> {
let s = s.trim();
if s.len() < 19 {
return None;
}
let year: u64 = s.get(0..4)?.parse().ok()?;
let month: u64 = s.get(5..7)?.parse().ok()?;
let day: u64 = s.get(8..10)?.parse().ok()?;
let hour: u64 = s.get(11..13)?.parse().ok()?;
let minute: u64 = s.get(14..16)?.parse().ok()?;
let second: u64 = s.get(17..19)?.parse().ok()?;
let days_in_year = 365;
let leap_years = (year - 1970 + 1) / 4; let month_days: [u64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut total_days: u64 = (year - 1970) * days_in_year + leap_years;
for m in 0..(month.saturating_sub(1) as usize) {
total_days += month_days.get(m).copied().unwrap_or(30);
}
total_days += day.saturating_sub(1);
let total_secs = total_days * 86400 + hour * 3600 + minute * 60 + second;
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(total_secs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_messages_with_tool_calls() {
let messages = vec![MessageRow {
_id: "msg1".to_string(),
role: Some("assistant".to_string()),
content: None,
tool_calls: Some(
r#"[{"type":"function","id":"call_1","function":{"name":"bash","arguments":"{\"command\":\"cargo test\"}"}}]"#.to_string(),
),
tool_call_id: None,
created_at: Some("2024-01-01T00:00:00Z".to_string()),
}];
let invocations = parse_opencode_messages(&messages, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].tool_name, "bash");
assert!(matches!(
&invocations[0].input,
ToolInput::Bash { command } if command == "cargo test"
));
assert_eq!(invocations[0].agent, AgentKind::OpenCode);
}
#[test]
fn test_map_bash_tool() {
let args = serde_json::json!({"command": "ls -la"});
let input = map_opencode_tool("bash", &args);
assert!(matches!(input, ToolInput::Bash { command } if command == "ls -la"));
let input = map_opencode_tool("shell", &args);
assert!(matches!(input, ToolInput::Bash { command } if command == "ls -la"));
let input = map_opencode_tool("execute", &args);
assert!(matches!(input, ToolInput::Bash { command } if command == "ls -la"));
}
#[test]
fn test_map_read_file_tool() {
let args = serde_json::json!({"file_path": "/tmp/test.rs"});
let input = map_opencode_tool("read_file", &args);
assert!(matches!(
input,
ToolInput::Read { file_path } if file_path == "/tmp/test.rs"
));
let args = serde_json::json!({"path": "/tmp/alt.rs"});
let input = map_opencode_tool("read", &args);
assert!(matches!(
input,
ToolInput::Read { file_path } if file_path == "/tmp/alt.rs"
));
}
#[test]
fn test_correlate_tool_results_by_id() {
let messages = vec![
MessageRow {
_id: "msg1".to_string(),
role: Some("assistant".to_string()),
content: None,
tool_calls: Some(
r#"[{"type":"function","id":"call_42","function":{"name":"read_file","arguments":"{\"file_path\":\"/tmp/test.rs\"}"}}]"#.to_string(),
),
tool_call_id: None,
created_at: Some("2024-01-01T00:00:00Z".to_string()),
},
MessageRow {
_id: "msg2".to_string(),
role: Some("tool".to_string()),
content: Some("fn main() {}".to_string()),
tool_calls: None,
tool_call_id: Some("call_42".to_string()),
created_at: Some("2024-01-01T00:00:01Z".to_string()),
},
];
let invocations = parse_opencode_messages(&messages, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert!(invocations[0].result.is_some());
let result = invocations[0].result.as_ref().unwrap();
assert_eq!(result.content, "fn main() {}");
assert!(!result.is_error);
}
#[test]
fn test_empty_conversations() {
let messages: Vec<MessageRow> = Vec::new();
let invocations = parse_opencode_messages(&messages, "sess1").unwrap();
assert!(invocations.is_empty());
}
#[test]
fn test_malformed_tool_calls_graceful() {
let messages = vec![MessageRow {
_id: "msg1".to_string(),
role: Some("assistant".to_string()),
content: None,
tool_calls: Some("not valid json".to_string()),
tool_call_id: None,
created_at: Some("2024-01-01T00:00:00Z".to_string()),
}];
let invocations = parse_opencode_messages(&messages, "sess1").unwrap();
assert!(invocations.is_empty());
}
#[test]
fn test_walk_up_from_cwd() {
let result = walk_up_for_opencode();
let _ = result;
}
#[test]
fn test_env_override_path() {
std::env::set_var("SKIM_OPENCODE_DIR", "/tmp/nonexistent-opencode-test-dir");
let provider = OpenCodeProvider::detect();
assert!(provider.is_none());
std::env::remove_var("SKIM_OPENCODE_DIR");
}
#[test]
fn test_map_write_file_tool() {
let args = serde_json::json!({"file_path": "/tmp/out.rs"});
let input = map_opencode_tool("write_file", &args);
assert!(matches!(
input,
ToolInput::Write { file_path } if file_path == "/tmp/out.rs"
));
let input = map_opencode_tool("create_file", &args);
assert!(matches!(
input,
ToolInput::Write { file_path } if file_path == "/tmp/out.rs"
));
}
#[test]
fn test_map_edit_file_tool() {
let args = serde_json::json!({"file_path": "/tmp/edit.rs"});
let input = map_opencode_tool("edit_file", &args);
assert!(matches!(
input,
ToolInput::Edit { file_path } if file_path == "/tmp/edit.rs"
));
let input = map_opencode_tool("patch", &args);
assert!(matches!(
input,
ToolInput::Edit { file_path } if file_path == "/tmp/edit.rs"
));
}
#[test]
fn test_map_glob_and_grep_tools() {
let args = serde_json::json!({"pattern": "**/*.rs"});
let input = map_opencode_tool("glob", &args);
assert!(matches!(input, ToolInput::Glob { pattern } if pattern == "**/*.rs"));
let input = map_opencode_tool("list_files", &args);
assert!(matches!(input, ToolInput::Glob { pattern } if pattern == "**/*.rs"));
let args = serde_json::json!({"pattern": "fn main"});
let input = map_opencode_tool("grep", &args);
assert!(matches!(input, ToolInput::Grep { pattern } if pattern == "fn main"));
let input = map_opencode_tool("search", &args);
assert!(matches!(input, ToolInput::Grep { pattern } if pattern == "fn main"));
}
#[test]
fn test_map_unknown_tool() {
let args = serde_json::json!({"foo": "bar"});
let input = map_opencode_tool("custom_tool", &args);
assert!(matches!(
input,
ToolInput::Other { tool_name, .. } if tool_name == "custom_tool"
));
}
#[test]
fn test_parse_tool_calls_json_empty_array() {
let calls = parse_tool_calls_json("[]");
assert!(calls.is_empty());
}
#[test]
fn test_parse_tool_calls_json_multiple() {
let json = r#"[
{"type":"function","id":"call_1","function":{"name":"bash","arguments":"{\"command\":\"ls\"}"}},
{"type":"function","id":"call_2","function":{"name":"read_file","arguments":"{\"file_path\":\"/tmp/a.rs\"}"}}
]"#;
let calls = parse_tool_calls_json(json);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "bash");
assert_eq!(calls[1].name, "read_file");
}
#[test]
fn test_parse_tool_calls_arguments_as_object() {
let json = r#"[{"type":"function","id":"call_1","function":{"name":"bash","arguments":{"command":"cargo test"}}}]"#;
let calls = parse_tool_calls_json(json);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "bash");
assert_eq!(
calls[0].arguments.get("command").and_then(|c| c.as_str()),
Some("cargo test")
);
}
#[test]
fn test_parse_iso_timestamp_valid() {
let ts = parse_iso_timestamp("2024-06-15T10:30:00Z");
assert!(ts.is_some());
assert!(ts.unwrap() > std::time::UNIX_EPOCH);
}
#[test]
fn test_parse_iso_timestamp_with_millis() {
let ts = parse_iso_timestamp("2024-06-15T10:30:00.123Z");
assert!(ts.is_some());
}
#[test]
fn test_parse_iso_timestamp_invalid() {
assert!(parse_iso_timestamp("").is_none());
assert!(parse_iso_timestamp("not-a-date").is_none());
assert!(parse_iso_timestamp("2024").is_none());
}
#[test]
fn test_multiple_tool_calls_in_one_message() {
let messages = vec![MessageRow {
_id: "msg1".to_string(),
role: Some("assistant".to_string()),
content: None,
tool_calls: Some(
r#"[
{"type":"function","id":"call_1","function":{"name":"bash","arguments":"{\"command\":\"ls\"}"}},
{"type":"function","id":"call_2","function":{"name":"read_file","arguments":"{\"file_path\":\"/tmp/a.rs\"}"}}
]"#
.to_string(),
),
tool_call_id: None,
created_at: Some("2024-01-01T00:00:00Z".to_string()),
}];
let invocations = parse_opencode_messages(&messages, "sess1").unwrap();
assert_eq!(invocations.len(), 2);
assert_eq!(invocations[0].tool_name, "bash");
assert_eq!(invocations[1].tool_name, "read_file");
}
#[test]
fn test_user_messages_ignored() {
let messages = vec![MessageRow {
_id: "msg1".to_string(),
role: Some("user".to_string()),
content: Some("Please help me with this code".to_string()),
tool_calls: None,
tool_call_id: None,
created_at: Some("2024-01-01T00:00:00Z".to_string()),
}];
let invocations = parse_opencode_messages(&messages, "sess1").unwrap();
assert!(invocations.is_empty());
}
#[test]
fn test_tool_result_without_matching_call() {
let messages = vec![MessageRow {
_id: "msg1".to_string(),
role: Some("tool".to_string()),
content: Some("some result".to_string()),
tool_calls: None,
tool_call_id: Some("call_nonexistent".to_string()),
created_at: Some("2024-01-01T00:00:00Z".to_string()),
}];
let invocations = parse_opencode_messages(&messages, "sess1").unwrap();
assert!(invocations.is_empty());
}
#[test]
fn test_session_id_propagated() {
let messages = vec![MessageRow {
_id: "msg1".to_string(),
role: Some("assistant".to_string()),
content: None,
tool_calls: Some(
r#"[{"type":"function","id":"call_1","function":{"name":"bash","arguments":"{\"command\":\"echo hi\"}"}}]"#.to_string(),
),
tool_call_id: None,
created_at: Some("2024-01-01T00:00:00Z".to_string()),
}];
let invocations = parse_opencode_messages(&messages, "my-session-42").unwrap();
assert_eq!(invocations[0].session_id, "my-session-42");
}
}