use std::path::PathBuf;
use super::types::*;
use super::SessionProvider;
const MAX_DB_SIZE: u64 = 100 * 1024 * 1024;
pub(crate) struct CursorProvider {
db_path: PathBuf,
}
impl CursorProvider {
pub(crate) fn detect() -> Option<Self> {
let db_path = if let Ok(override_path) = std::env::var("SKIM_CURSOR_DB_PATH") {
PathBuf::from(override_path)
} else {
default_db_path()?
};
if db_path.is_file() {
Some(Self { db_path })
} else {
None
}
}
}
fn default_db_path() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
dirs::data_dir().map(|d| d.join("Cursor/User/globalStorage/state.vscdb"))
}
#[cfg(not(target_os = "windows"))]
{
dirs::home_dir().map(|h| {
AgentKind::Cursor
.config_dir(&h)
.join("User/globalStorage/state.vscdb")
})
}
}
impl SessionProvider for CursorProvider {
fn agent_kind(&self) -> AgentKind {
AgentKind::Cursor
}
fn find_sessions(&self, filter: &TimeFilter) -> anyhow::Result<Vec<SessionFile>> {
let rows = match query_composer_keys(&self.db_path) {
Ok(rows) => rows,
Err(e) => {
eprintln!("warning: could not query Cursor database: {e}");
return Ok(Vec::new());
}
};
let file_modified = std::fs::metadata(&self.db_path)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::now());
if let Some(since) = filter.since {
if file_modified < since {
return Ok(Vec::new());
}
}
let mut sessions: Vec<SessionFile> = rows
.into_iter()
.map(|(key, _value)| SessionFile {
path: self.db_path.clone(),
modified: file_modified,
agent: AgentKind::Cursor,
session_id: key,
})
.collect();
sessions.sort_by(|a, b| b.session_id.cmp(&a.session_id));
if filter.latest_only {
sessions.truncate(1);
}
Ok(sessions)
}
fn parse_session(&self, file: &SessionFile) -> anyhow::Result<Vec<ToolInvocation>> {
let db_size = std::fs::metadata(&self.db_path)?.len();
if db_size > MAX_DB_SIZE {
anyhow::bail!(
"database too large ({:.1} MB, limit {:.0} MB): {}",
db_size as f64 / (1024.0 * 1024.0),
MAX_DB_SIZE as f64 / (1024.0 * 1024.0),
self.db_path.display()
);
}
let value = match query_single_key(&self.db_path, &file.session_id) {
Ok(Some(v)) => v,
Ok(None) => return Ok(Vec::new()),
Err(e) => {
eprintln!(
"warning: could not read Cursor session {}: {e}",
file.session_id
);
return Ok(Vec::new());
}
};
parse_cursor_json_value(&value, &file.session_id)
}
}
fn query_composer_keys(db_path: &std::path::Path) -> anyhow::Result<Vec<(String, String)>> {
let conn = rusqlite::Connection::open_with_flags(
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 key, value FROM cursorDiskKV WHERE key LIKE 'composer.%' LIMIT 1000")?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
fn query_single_key(db_path: &std::path::Path, key: &str) -> anyhow::Result<Option<String>> {
let conn = rusqlite::Connection::open_with_flags(
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 value FROM cursorDiskKV WHERE key = ?1 LIMIT 1")?;
let result = stmt
.query_row(rusqlite::params![key], |row| row.get::<_, String>(0))
.ok();
Ok(result)
}
pub(super) fn parse_cursor_json_value(
json_str: &str,
session_id: &str,
) -> anyhow::Result<Vec<ToolInvocation>> {
let root: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| anyhow::anyhow!("invalid JSON in Cursor session: {e}"))?;
let conversations = match root
.get("composerData")
.and_then(|cd| cd.get("conversations"))
.and_then(|c| c.as_array())
{
Some(convs) => convs,
None => return Ok(Vec::new()),
};
let mut invocations = Vec::new();
let mut pending: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for conversation in conversations {
let messages = match conversation.get("messages").and_then(|m| m.as_array()) {
Some(msgs) => msgs,
None => continue,
};
for message in messages {
let role = message.get("role").and_then(|r| r.as_str()).unwrap_or("");
match role {
"assistant" => {
if let Some(tool_calls) = message.get("tool_calls").and_then(|tc| tc.as_array())
{
process_cursor_tool_calls(
tool_calls,
session_id,
&mut invocations,
&mut pending,
);
}
}
"tool" => {
let tool_call_id = message
.get("tool_call_id")
.and_then(|id| id.as_str())
.unwrap_or("");
if let Some(&idx) = pending.get(tool_call_id) {
let content = message
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
invocations[idx].result = Some(ToolResult {
content,
is_error: false,
});
pending.remove(tool_call_id);
}
}
_ => {}
}
}
}
Ok(invocations)
}
fn process_cursor_tool_calls(
tool_calls: &[serde_json::Value],
session_id: &str,
invocations: &mut Vec<ToolInvocation>,
pending: &mut std::collections::HashMap<String, usize>,
) {
for tool_call in tool_calls {
let tc_type = tool_call.get("type").and_then(|t| t.as_str()).unwrap_or("");
if tc_type != "function" {
continue;
}
let function = match tool_call.get("function") {
Some(f) => f,
None => continue,
};
let tool_name = function
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let arguments_str = function
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
let arguments: serde_json::Value = serde_json::from_str(arguments_str).unwrap_or_default();
let input = map_cursor_tool(&tool_name, &arguments);
let tc_id = tool_call
.get("id")
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let idx = invocations.len();
invocations.push(ToolInvocation {
tool_name: tool_name.clone(),
input,
timestamp: String::new(),
session_id: session_id.to_string(),
agent: AgentKind::Cursor,
result: None,
});
if !tc_id.is_empty() {
pending.insert(tc_id, idx);
}
}
}
fn map_cursor_tool(tool_name: &str, arguments: &serde_json::Value) -> ToolInput {
match tool_name {
"run_terminal_command" => {
let command = arguments
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
ToolInput::Bash { command }
}
"read_file" => {
let file_path = arguments
.get("file_path")
.or_else(|| arguments.get("path"))
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Read { file_path }
}
"write_file" => {
let file_path = arguments
.get("file_path")
.or_else(|| arguments.get("path"))
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Write { file_path }
}
"edit_file" => {
let file_path = arguments
.get("file_path")
.or_else(|| arguments.get("path"))
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Edit { file_path }
}
_ => ToolInput::Other {
tool_name: tool_name.to_string(),
raw: arguments.clone(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_json() -> &'static str {
r#"{
"composerData": {
"conversations": [{
"id": "conv-001",
"messages": [
{
"role": "assistant",
"tool_calls": [{
"id": "tc-001",
"type": "function",
"function": {
"name": "run_terminal_command",
"arguments": "{\"command\":\"cargo test\"}"
}
}]
},
{
"role": "tool",
"tool_call_id": "tc-001",
"content": "test result: ok"
}
]
}]
}
}"#
}
#[test]
fn test_parse_cursor_json_value() {
let invocations = parse_cursor_json_value(sample_json(), "sess-1").unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].tool_name, "run_terminal_command");
assert_eq!(invocations[0].agent, AgentKind::Cursor);
assert_eq!(invocations[0].session_id, "sess-1");
}
#[test]
fn test_map_run_terminal_command_to_bash() {
let args = serde_json::json!({"command": "cargo test --nocapture"});
let input = map_cursor_tool("run_terminal_command", &args);
assert!(matches!(
&input,
ToolInput::Bash { command } if command == "cargo test --nocapture"
));
}
#[test]
fn test_map_read_file_to_read() {
let args = serde_json::json!({"file_path": "/tmp/src/main.rs"});
let input = map_cursor_tool("read_file", &args);
assert!(matches!(
&input,
ToolInput::Read { file_path } if file_path == "/tmp/src/main.rs"
));
let args_alt = serde_json::json!({"path": "/tmp/alt.rs"});
let input_alt = map_cursor_tool("read_file", &args_alt);
assert!(matches!(
&input_alt,
ToolInput::Read { file_path } if file_path == "/tmp/alt.rs"
));
}
#[test]
fn test_map_write_file_to_write() {
let args = serde_json::json!({"file_path": "/tmp/out.rs"});
let input = map_cursor_tool("write_file", &args);
assert!(matches!(
&input,
ToolInput::Write { file_path } if file_path == "/tmp/out.rs"
));
}
#[test]
fn test_map_edit_file_to_edit() {
let args = serde_json::json!({"file_path": "/tmp/edit.rs"});
let input = map_cursor_tool("edit_file", &args);
assert!(matches!(
&input,
ToolInput::Edit { file_path } if file_path == "/tmp/edit.rs"
));
}
#[test]
fn test_map_unknown_tool_to_other() {
let args = serde_json::json!({"foo": "bar"});
let input = map_cursor_tool("custom_tool", &args);
assert!(matches!(
&input,
ToolInput::Other { tool_name, .. } if tool_name == "custom_tool"
));
}
#[test]
fn test_correlate_tool_result() {
let invocations = parse_cursor_json_value(sample_json(), "sess-1").unwrap();
assert_eq!(invocations.len(), 1);
let result = invocations[0].result.as_ref().expect("should have result");
assert_eq!(result.content, "test result: ok");
assert!(!result.is_error);
}
#[test]
fn test_empty_conversations() {
let json = r#"{"composerData": {"conversations": []}}"#;
let invocations = parse_cursor_json_value(json, "sess-1").unwrap();
assert!(invocations.is_empty());
}
#[test]
fn test_missing_composer_data() {
let json = r#"{"otherKey": "value"}"#;
let invocations = parse_cursor_json_value(json, "sess-1").unwrap();
assert!(invocations.is_empty());
}
#[test]
fn test_malformed_json_graceful() {
let result = parse_cursor_json_value("not valid json {{{", "sess-1");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid JSON"));
}
#[test]
fn test_malformed_arguments_graceful() {
let json = r#"{
"composerData": {
"conversations": [{
"id": "conv-001",
"messages": [{
"role": "assistant",
"tool_calls": [{
"id": "tc-001",
"type": "function",
"function": {
"name": "run_terminal_command",
"arguments": "not valid json"
}
}]
}]
}]
}
}"#;
let invocations = parse_cursor_json_value(json, "sess-1").unwrap();
assert_eq!(invocations.len(), 1);
assert!(matches!(&invocations[0].input, ToolInput::Bash { command } if command.is_empty()));
}
#[test]
fn test_multiple_tool_calls_in_message() {
let json = r#"{
"composerData": {
"conversations": [{
"id": "conv-001",
"messages": [{
"role": "assistant",
"tool_calls": [
{
"id": "tc-001",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"file_path\":\"/a.rs\"}"
}
},
{
"id": "tc-002",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"file_path\":\"/b.rs\"}"
}
}
]
}]
}]
}
}"#;
let invocations = parse_cursor_json_value(json, "sess-1").unwrap();
assert_eq!(invocations.len(), 2);
}
#[test]
fn test_multiple_conversations() {
let json = r#"{
"composerData": {
"conversations": [
{
"id": "conv-001",
"messages": [{
"role": "assistant",
"tool_calls": [{
"id": "tc-001",
"type": "function",
"function": {
"name": "run_terminal_command",
"arguments": "{\"command\":\"cargo build\"}"
}
}]
}]
},
{
"id": "conv-002",
"messages": [{
"role": "assistant",
"tool_calls": [{
"id": "tc-002",
"type": "function",
"function": {
"name": "run_terminal_command",
"arguments": "{\"command\":\"cargo test\"}"
}
}]
}]
}
]
}
}"#;
let invocations = parse_cursor_json_value(json, "sess-1").unwrap();
assert_eq!(invocations.len(), 2);
}
#[test]
fn test_platform_path_detection() {
let path = default_db_path();
if let Some(p) = path {
let path_str = p.to_string_lossy();
#[cfg(target_os = "macos")]
assert!(
path_str.contains("Library/Application Support/Cursor"),
"macOS path should contain Cursor app support dir, got: {path_str}"
);
#[cfg(target_os = "linux")]
assert!(
path_str.contains(".config/Cursor"),
"Linux path should contain .config/Cursor, got: {path_str}"
);
}
}
#[test]
fn test_env_override_path() {
std::env::set_var("SKIM_CURSOR_DB_PATH", "/tmp/nonexistent_skim_test.vscdb");
let provider = CursorProvider::detect();
assert!(
provider.is_none(),
"detect() should return None for non-existent file"
);
std::env::remove_var("SKIM_CURSOR_DB_PATH");
}
#[test]
fn test_non_function_tool_calls_skipped() {
let json = r#"{
"composerData": {
"conversations": [{
"id": "conv-001",
"messages": [{
"role": "assistant",
"tool_calls": [{
"id": "tc-001",
"type": "code_interpreter",
"function": {
"name": "run_terminal_command",
"arguments": "{\"command\":\"ls\"}"
}
}]
}]
}]
}
}"#;
let invocations = parse_cursor_json_value(json, "sess-1").unwrap();
assert!(
invocations.is_empty(),
"non-function tool calls should be skipped"
);
}
#[test]
fn test_message_without_tool_calls() {
let json = r#"{
"composerData": {
"conversations": [{
"id": "conv-001",
"messages": [{
"role": "assistant",
"content": "Here is the answer"
}]
}]
}
}"#;
let invocations = parse_cursor_json_value(json, "sess-1").unwrap();
assert!(invocations.is_empty());
}
}