use crate::error::Result;
use crate::x::cursor::models::*;
use diesel::prelude::*;
use diesel::sql_query;
use diesel::sqlite::SqliteConnection;
use glob::glob;
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
diesel::table! {
#[sql_name = "ItemTable"]
item_table (key) {
key -> Text,
value -> Binary,
}
}
diesel::table! {
#[sql_name = "cursorDiskKV"]
cursor_disk_kv (key) {
key -> Text,
value -> Binary,
}
}
#[derive(QueryableByName, Debug)]
pub struct BubbleRowData {
#[allow(dead_code)]
#[diesel(sql_type = diesel::sql_types::Integer)]
pub rowid: i32,
#[allow(dead_code)]
#[diesel(sql_type = diesel::sql_types::Text)]
pub key: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Binary>)]
pub value: Option<Vec<u8>>,
}
pub struct CursorDatabase {
db_path: PathBuf,
global_db_path: Option<PathBuf>,
}
impl CursorDatabase {
pub fn new(custom_path: Option<&str>) -> Result<Self> {
Self::new_with_options(custom_path, true)
}
pub fn new_without_global(custom_path: Option<&str>) -> Result<Self> {
Self::new_with_options(custom_path, false)
}
fn new_with_options(custom_path: Option<&str>, include_global_db: bool) -> Result<Self> {
let db_path = if let Some(path) = custom_path {
PathBuf::from(path)
} else {
Self::find_latest_workspace_db()?
};
let global_db_path = if include_global_db {
Self::get_global_db_path().ok()
} else {
None
};
println!(
"{}: {}\n",
t!("cursor.database.using_db"),
db_path.display()
);
if let Some(ref global_path) = global_db_path {
println!(
"{}: {}\n",
t!("cursor.database.using_global_db"),
global_path.display()
);
}
Ok(Self {
db_path,
global_db_path,
})
}
pub fn connect(&self) -> Result<SqliteConnection> {
let database_url = format!("file:{}?mode=ro", self.db_path.display());
Ok(SqliteConnection::establish(&database_url)?)
}
pub fn connect_global(&self) -> Result<Option<SqliteConnection>> {
if let Some(ref global_path) = self.global_db_path {
let database_url = format!("file:{}?mode=ro", global_path.display());
Ok(Some(SqliteConnection::establish(&database_url)?))
} else {
Ok(None)
}
}
pub fn get_conversation_summaries(&self) -> Result<Vec<ConversationSummary>> {
let chat_data = self.get_chat_data()?;
let composer_data = self.get_composer_data()?;
let mut summaries = Vec::new();
if let Some(data) = chat_data {
for tab in data.tabs {
summaries.push(ConversationSummary {
key: tab.conversation_key(),
title: tab.get_title(),
last_message_time: tab.get_last_send_time(),
message_count: tab.bubbles.len(),
conversation_type: ConversationType::Traditional,
});
}
}
if let Some(data) = composer_data {
for composer in data.all_composers {
let bubble_count = self.get_composer_bubble_count(&composer.composer_id)?;
summaries.push(ConversationSummary {
key: composer.conversation_key(),
title: composer.get_title(),
last_message_time: composer.get_last_updated_time(),
message_count: bubble_count,
conversation_type: ConversationType::Composer(composer.get_composer_mode()),
});
}
}
summaries.sort_by_key(|summary| Reverse(summary.last_message_time));
Ok(summaries)
}
pub fn get_all_conversations_mixed(&self) -> Result<Vec<ConversationExport>> {
let chat_data = self.get_chat_data()?;
let composer_data = self.get_composer_data()?;
let mut conversations = Vec::new();
if let Some(data) = chat_data {
for tab in data.tabs {
conversations.push(ConversationExport::Traditional(tab));
}
}
if let Some(data) = composer_data {
for composer in data.all_composers {
let bubbles = self.get_composer_bubbles(&composer.composer_id)?;
let full_composer = ComposerWithBubbles {
composer_data: composer,
bubbles,
};
conversations.push(ConversationExport::Composer(full_composer));
}
}
Ok(conversations)
}
pub fn get_conversation_exports_by_keys(
&self,
keys: &[ConversationKey],
) -> Result<Vec<ConversationExport>> {
if keys.is_empty() {
return Ok(Vec::new());
}
let mut composer_ids = HashSet::new();
let mut traditional_keys = HashSet::new();
for key in keys {
match key {
ConversationKey::Composer(id) => {
composer_ids.insert(id.clone());
}
ConversationKey::Traditional(_) => {
traditional_keys.insert(key.clone());
}
}
}
let mut tabs_by_key: HashMap<ConversationKey, ChatTab> = HashMap::new();
if !traditional_keys.is_empty()
&& let Some(chat_data) = self.get_chat_data()?
{
for tab in chat_data.tabs {
let key = tab.conversation_key();
if traditional_keys.contains(&key) {
tabs_by_key.insert(key, tab);
}
}
}
let mut composers_by_id: HashMap<String, ComposerItem> = HashMap::new();
if !composer_ids.is_empty()
&& let Some(composer_data) = self.get_composer_data()?
{
for composer in composer_data.all_composers {
if composer_ids.contains(&composer.composer_id) {
composers_by_id.insert(composer.composer_id.clone(), composer);
}
}
}
let mut exports = Vec::new();
let mut seen = HashSet::new();
for key in keys {
if !seen.insert(key.clone()) {
continue;
}
match key {
ConversationKey::Traditional(_) => {
let tab = tabs_by_key.remove(key).ok_or_else(|| {
crate::error::LlmanError::Custom(
t!(
"cursor.export.selected_conversation_not_found",
id = key.short_id()
)
.to_string(),
)
})?;
exports.push(ConversationExport::Traditional(tab));
}
ConversationKey::Composer(composer_id) => {
let composer = composers_by_id.remove(composer_id).ok_or_else(|| {
crate::error::LlmanError::Custom(
t!(
"cursor.export.selected_conversation_not_found",
id = key.short_id()
)
.to_string(),
)
})?;
let bubbles = self.get_composer_bubbles(&composer.composer_id)?;
exports.push(ConversationExport::Composer(ComposerWithBubbles {
composer_data: composer,
bubbles,
}));
}
}
}
Ok(exports)
}
pub fn search_conversations(&self, search_text: &str) -> Result<Vec<ChatTab>> {
let chat_data = self.get_chat_data()?;
if let Some(data) = chat_data {
let search_lower = search_text.to_lowercase();
let filtered_tabs =
data.tabs
.into_iter()
.filter(|tab| {
if let Some(title) = &tab.chat_title
&& title.to_lowercase().contains(&search_lower)
{
return true;
}
tab.bubbles.iter().any(|bubble| {
if let Some(text) = &bubble.text {
text.to_lowercase().contains(&search_lower)
} else if let Some(terminal_selections) = &bubble.terminal_selections {
terminal_selections.iter().any(|selection| {
selection.get("text").and_then(|v| v.as_str()).is_some_and(
|text| text.to_lowercase().contains(&search_lower),
)
})
} else {
false
}
})
})
.collect();
Ok(filtered_tabs)
} else {
Ok(vec![])
}
}
pub fn find_all_workspaces() -> Result<Vec<WorkspaceInfo>> {
let base_path = Self::get_cursor_workspace_path()?;
if !base_path.exists() {
return Err(crate::error::LlmanError::Custom(
t!(
"cursor.database.workspace_path_not_exist",
path = base_path.display()
)
.to_string(),
));
}
let pattern = base_path.join("*").join("state.vscdb");
let db_files: Vec<PathBuf> = glob(&pattern.to_string_lossy())?
.filter_map(|entry| entry.ok())
.collect();
if db_files.is_empty() {
return Err(crate::error::LlmanError::Custom(
t!("cursor.database.no_workspace_db_found").to_string(),
));
}
let mut workspaces = Vec::new();
for db_path in db_files {
if let Some(parent) = db_path.parent()
&& let Some(_hash_id) = parent.file_name().and_then(|n| n.to_str())
{
let workspace_info =
Self::create_workspace_info(db_path.clone(), parent.to_path_buf())?;
workspaces.push(workspace_info);
}
}
let current_dir = std::env::current_dir().ok();
let (mut current_workspaces, mut other_workspaces): (Vec<_>, Vec<_>) =
workspaces.into_iter().partition(|w| {
if let Some(ref project_path) = w.project_path
&& let Some(ref current) = current_dir
{
return project_path == current;
}
false
});
let sort_key = |w: &WorkspaceInfo| {
let has_chat = if w.has_chat_data { 0 } else { 1 };
let modified_time = fs::metadata(&w.db_path)
.and_then(|m| m.modified())
.map(std::cmp::Reverse)
.unwrap_or(std::cmp::Reverse(std::time::UNIX_EPOCH));
(has_chat, modified_time)
};
current_workspaces.sort_by_key(sort_key);
other_workspaces.sort_by_key(sort_key);
current_workspaces.extend(other_workspaces);
Ok(current_workspaces)
}
fn find_latest_workspace_db() -> Result<PathBuf> {
let base_path = Self::get_cursor_workspace_path()?;
if !base_path.exists() {
return Err(crate::error::LlmanError::Custom(
t!(
"cursor.database.workspace_path_not_exist",
path = base_path.display()
)
.to_string(),
));
}
let pattern = base_path.join("*").join("state.vscdb");
let db_files: Vec<PathBuf> = glob(&pattern.to_string_lossy())?
.filter_map(|entry| entry.ok())
.collect();
if db_files.is_empty() {
return Err(crate::error::LlmanError::Custom(
t!("cursor.database.no_workspace_db_found").to_string(),
));
}
let mut db_files_with_metadata: Vec<(PathBuf, std::time::SystemTime)> = db_files
.into_iter()
.filter_map(|path| {
fs::metadata(&path)
.and_then(|m| m.modified())
.map(|time| (path, time))
.ok()
})
.collect();
db_files_with_metadata.sort_by_key(|entry| Reverse(entry.1));
let chat_db = db_files_with_metadata
.iter()
.find(|(path, _)| Self::has_chat_data(path).unwrap_or(false))
.map(|(path, _)| path.clone());
Ok(chat_db.unwrap_or_else(|| db_files_with_metadata[0].0.clone()))
}
fn has_chat_data(db_path: &Path) -> Result<bool> {
let database_url = format!("file:{}?mode=ro", db_path.display());
let mut connection = match SqliteConnection::establish(&database_url) {
Ok(conn) => conn,
Err(_) => return Ok(false),
};
use crate::x::cursor::database::item_table::dsl::*;
let traditional_chat = item_table
.select(value)
.filter(key.eq("workbench.panel.aichat.view.aichat.chatdata"))
.first::<Vec<u8>>(&mut connection)
.optional()
.unwrap_or(None)
.and_then(|bytes| String::from_utf8(bytes).ok());
let composer_chat = item_table
.select(value)
.filter(key.eq("composer.composerData"))
.first::<Vec<u8>>(&mut connection)
.optional()
.unwrap_or(None)
.and_then(|bytes| String::from_utf8(bytes).ok());
let has_traditional = traditional_chat
.and_then(|json_str| serde_json::from_str::<ChatData>(&json_str).ok())
.is_some_and(|data| !data.tabs.is_empty());
let has_composer = composer_chat
.and_then(|json_str| serde_json::from_str::<ComposerData>(&json_str).ok())
.is_some_and(|data| !data.all_composers.is_empty());
Ok(has_traditional || has_composer)
}
fn get_cursor_workspace_path() -> Result<PathBuf> {
let home_dir = crate::config::try_home_dir().ok_or_else(|| {
crate::error::LlmanError::Custom(t!("cursor.database.home_dir_error").to_string())
})?;
#[cfg(target_os = "windows")]
let cursor_path = home_dir
.join("AppData")
.join("Roaming")
.join("Cursor")
.join("User")
.join("workspaceStorage");
#[cfg(target_os = "macos")]
let cursor_path = home_dir
.join("Library")
.join("Application Support")
.join("Cursor")
.join("User")
.join("workspaceStorage");
#[cfg(target_os = "linux")]
let cursor_path = home_dir
.join(".config")
.join("Cursor")
.join("User")
.join("workspaceStorage");
if !cursor_path.exists() {
return Err(crate::error::LlmanError::Custom(
t!(
"cursor.database.cursor_dir_not_found",
path = cursor_path.display()
)
.to_string(),
));
}
Ok(cursor_path)
}
fn get_chat_data(&self) -> Result<Option<ChatData>> {
let mut connection = self.connect()?;
use crate::x::cursor::database::item_table::dsl::*;
let chat_json = item_table
.select(value)
.filter(key.eq("workbench.panel.aichat.view.aichat.chatdata"))
.first::<Vec<u8>>(&mut connection)
.optional()?
.and_then(|bytes| String::from_utf8(bytes).ok());
if let Some(json_str) = chat_json {
let data: ChatData = serde_json::from_str(&json_str)?;
Ok(Some(data))
} else {
Ok(None)
}
}
fn get_composer_data(&self) -> Result<Option<ComposerData>> {
let mut connection = self.connect()?;
use crate::x::cursor::database::item_table::dsl::*;
let composer_json = item_table
.select(value)
.filter(key.eq("composer.composerData"))
.first::<Vec<u8>>(&mut connection)
.optional()?
.and_then(|bytes| String::from_utf8(bytes).ok());
if let Some(json_str) = composer_json {
let data: ComposerData = serde_json::from_str(&json_str)?;
Ok(Some(data))
} else {
Ok(None)
}
}
fn create_workspace_info(db_path: PathBuf, workspace_dir: PathBuf) -> Result<WorkspaceInfo> {
let has_chat_data = Self::has_chat_data(&db_path).unwrap_or(false);
let workspace_json_path = workspace_dir.join("workspace.json");
let project_info = if workspace_json_path.exists() {
fs::read_to_string(&workspace_json_path)
.ok()
.and_then(|content| serde_json::from_str::<WorkspaceMetadata>(&content).ok())
.and_then(|metadata| Self::map_workspace_folder_to_path(&metadata.folder))
} else {
None
};
let (project_path, project_name) = if let Some((path, name)) = project_info {
(Some(path), name)
} else {
let default_name = workspace_dir
.file_name()
.and_then(|n| n.to_str())
.map(|name| name.to_string())
.unwrap_or_else(|| t!("cursor.workspace.unknown_name").to_string());
(None, default_name)
};
Ok(WorkspaceInfo {
db_path,
project_path,
project_name,
has_chat_data,
})
}
fn map_workspace_folder_to_path(folder: &str) -> Option<(PathBuf, String)> {
if let Some(path_str) = folder.strip_prefix("file://") {
let path = PathBuf::from(path_str);
let name = path.file_name()?.to_str()?.to_string();
Some((path, name))
} else {
None
}
}
pub fn get_global_db_path() -> Result<PathBuf> {
let home_dir = crate::config::try_home_dir().ok_or_else(|| {
crate::error::LlmanError::Custom(t!("cursor.database.home_dir_error").to_string())
})?;
#[cfg(target_os = "windows")]
let global_db_path = home_dir
.join("AppData")
.join("Roaming")
.join("Cursor")
.join("User")
.join("globalStorage")
.join("state.vscdb");
#[cfg(target_os = "macos")]
let global_db_path = home_dir
.join("Library")
.join("Application Support")
.join("Cursor")
.join("User")
.join("globalStorage")
.join("state.vscdb");
#[cfg(target_os = "linux")]
let global_db_path = home_dir
.join(".config")
.join("Cursor")
.join("User")
.join("globalStorage")
.join("state.vscdb");
if !global_db_path.exists() {
return Err(crate::error::LlmanError::Custom(
t!(
"cursor.database.global_db_not_found",
path = global_db_path.display()
)
.to_string(),
));
}
Ok(global_db_path)
}
fn get_composer_bubble_count(&self, composer_id: &str) -> Result<usize> {
if let Some(mut connection) = self.connect_global()? {
use crate::x::cursor::database::cursor_disk_kv::dsl::*;
let pattern = format!("bubbleId:{composer_id}:%");
match cursor_disk_kv
.filter(key.like(pattern))
.count()
.get_result::<i64>(&mut connection)
{
Ok(count) => Ok(count as usize),
Err(_e) => {
Ok(0)
}
}
} else {
Ok(0)
}
}
fn get_composer_bubbles(&self, composer_id: &str) -> Result<Vec<ComposerBubble>> {
if let Some(mut connection) = self.connect_global()? {
let pattern = format!("bubbleId:{composer_id}:%");
let query =
"SELECT rowid, key, value FROM cursorDiskKV WHERE key LIKE ?1 ORDER BY rowid";
let bubble_rows: Vec<BubbleRowData> = sql_query(query)
.bind::<diesel::sql_types::Text, _>(&pattern)
.load(&mut connection)?;
let mut bubbles = Vec::new();
for row in bubble_rows {
if let Ok(json_str) = String::from_utf8(row.value.unwrap_or_default())
&& let Ok(bubble) = serde_json::from_str::<ComposerBubble>(&json_str)
{
bubbles.push(bubble);
}
}
Ok(bubbles)
} else {
Ok(vec![])
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::Connection;
use diesel::RunQueryDsl;
use diesel::sqlite::SqliteConnection;
use tempfile::tempdir;
fn write_item_table(db_path: &Path, chat_json: &str) {
let mut conn =
SqliteConnection::establish(&db_path.to_string_lossy()).expect("establish sqlite");
diesel::sql_query("CREATE TABLE ItemTable (key TEXT PRIMARY KEY NOT NULL, value BLOB);")
.execute(&mut conn)
.expect("create ItemTable");
diesel::sql_query("INSERT INTO ItemTable (key, value) VALUES (?1, ?2);")
.bind::<diesel::sql_types::Text, _>("workbench.panel.aichat.view.aichat.chatdata")
.bind::<diesel::sql_types::Binary, _>(chat_json.as_bytes().to_vec())
.execute(&mut conn)
.expect("insert chatdata");
}
#[test]
fn selected_keys_map_to_correct_export_even_if_summary_is_sorted() {
let dir = tempdir().expect("tempdir");
let db_path = dir.path().join("state.vscdb");
let chat_json = r#"{
"tabs": [
{
"tabId": "tab-old",
"chatTitle": "Old Chat",
"lastSendTime": 1700000000000,
"bubbles": [
{"type": "user", "id": "b1", "messageType": 1, "text": "hi", "createdAt": 1700000000000}
]
},
{
"tabId": "tab-new",
"chatTitle": "New Chat",
"lastSendTime": 1800000000000,
"bubbles": []
}
]
}"#;
write_item_table(&db_path, chat_json);
let db_path_str = db_path.to_string_lossy().to_string();
let db = CursorDatabase::new_without_global(Some(&db_path_str)).expect("db");
let summaries = db.get_conversation_summaries().expect("summaries");
assert_eq!(summaries.len(), 2);
assert_eq!(summaries[0].title, "New Chat");
let selected = vec![summaries[0].key.clone()];
let exports = db
.get_conversation_exports_by_keys(&selected)
.expect("exports");
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].get_title(), "New Chat");
}
#[test]
fn search_results_share_same_key_space_as_exports() {
let dir = tempdir().expect("tempdir");
let db_path = dir.path().join("state.vscdb");
let chat_json = r#"{
"tabs": [
{
"tabId": "tab-old",
"chatTitle": "Old Chat",
"lastSendTime": 1700000000000,
"bubbles": [
{"type": "user", "id": "b1", "messageType": 1, "text": "hi", "createdAt": 1700000000000}
]
},
{
"tabId": "tab-new",
"chatTitle": "New Chat",
"lastSendTime": 1800000000000,
"bubbles": []
}
]
}"#;
write_item_table(&db_path, chat_json);
let db_path_str = db_path.to_string_lossy().to_string();
let db = CursorDatabase::new_without_global(Some(&db_path_str)).expect("db");
let results = db.search_conversations("old").expect("search");
assert_eq!(results.len(), 1);
let key = results[0].conversation_key();
let exports = db
.get_conversation_exports_by_keys(&[key])
.expect("exports");
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].get_title(), "Old Chat");
}
}