|
| 1 | +use crate::workspace_identifier::get_single_folder_workspace_identifier; |
| 2 | +use anyhow::Result; |
| 3 | +use diesel::prelude::*; |
| 4 | +use diesel::sql_query; |
| 5 | +use diesel::sql_types::Text; |
| 6 | +use serde::{Deserialize, Serialize}; |
| 7 | +use std::path::Path; |
| 8 | + |
| 9 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 10 | +pub struct Generation { |
| 11 | + #[serde(rename = "generationUUID")] |
| 12 | + pub generation_uuid: String, |
| 13 | + #[serde(rename = "textDescription")] |
| 14 | + pub text_description: String, |
| 15 | + #[serde(rename = "type")] |
| 16 | + pub generation_type: String, |
| 17 | + #[serde(rename = "unixMs")] |
| 18 | + pub unix_ms: i64, |
| 19 | +} |
| 20 | + |
| 21 | +/// Get the base directory for Cursor workspace storage based on the platform |
| 22 | +fn get_cursor_base_dir(nightly: bool) -> Result<std::path::PathBuf> { |
| 23 | + let cursor_name = if nightly { "Cursor Nightly" } else { "Cursor" }; |
| 24 | + |
| 25 | + #[cfg(target_os = "windows")] |
| 26 | + { |
| 27 | + let appdata = std::env::var("APPDATA") |
| 28 | + .map_err(|_| anyhow::anyhow!("APPDATA environment variable not found"))?; |
| 29 | + Ok(std::path::PathBuf::from(appdata) |
| 30 | + .join(cursor_name) |
| 31 | + .join("User") |
| 32 | + .join("workspaceStorage")) |
| 33 | + } |
| 34 | + |
| 35 | + #[cfg(target_os = "macos")] |
| 36 | + { |
| 37 | + let home = std::env::var("HOME") |
| 38 | + .map_err(|_| anyhow::anyhow!("HOME environment variable not found"))?; |
| 39 | + Ok(std::path::PathBuf::from(home) |
| 40 | + .join("Library") |
| 41 | + .join("Application Support") |
| 42 | + .join(cursor_name) |
| 43 | + .join("User") |
| 44 | + .join("workspaceStorage")) |
| 45 | + } |
| 46 | + |
| 47 | + #[cfg(target_os = "linux")] |
| 48 | + { |
| 49 | + let config_dir = std::env::var("XDG_CONFIG_HOME") |
| 50 | + .map(std::path::PathBuf::from) |
| 51 | + .unwrap_or_else(|_| { |
| 52 | + let home = std::env::var("HOME").unwrap_or_default(); |
| 53 | + std::path::PathBuf::from(home).join(".config") |
| 54 | + }); |
| 55 | + Ok(config_dir |
| 56 | + .join(cursor_name) |
| 57 | + .join("User") |
| 58 | + .join("workspaceStorage")) |
| 59 | + } |
| 60 | + |
| 61 | + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] |
| 62 | + { |
| 63 | + anyhow::bail!("Unsupported platform"); |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +/// Get the path to the Cursor database file for the given repository |
| 68 | +fn get_cursor_db_path(repo_path: &Path, nightly: bool) -> Result<std::path::PathBuf> { |
| 69 | + let base_dir = get_cursor_base_dir(nightly)?; |
| 70 | + let workspace_id = get_single_folder_workspace_identifier(repo_path)?; |
| 71 | + |
| 72 | + Ok(base_dir.join(workspace_id).join("state.vscdb")) |
| 73 | +} |
| 74 | + |
| 75 | +/// Parse the JSON value from the database into a Vec<Generation> |
| 76 | +fn parse_generations_json(json_str: &str) -> Result<Vec<Generation>> { |
| 77 | + let generations: Vec<Generation> = serde_json::from_str(json_str) |
| 78 | + .map_err(|e| anyhow::anyhow!("Failed to parse generations JSON: {}", e))?; |
| 79 | + Ok(generations) |
| 80 | +} |
| 81 | + |
| 82 | +/// Result struct for SQL query |
| 83 | +#[derive(QueryableByName)] |
| 84 | +struct GenerationQueryResult { |
| 85 | + #[diesel(sql_type = Text)] |
| 86 | + value: String, |
| 87 | +} |
| 88 | + |
| 89 | +/// Get AI service generations from the Cursor database for the given repository |
| 90 | +pub fn get_generations(repo_path: &Path, nightly: bool) -> Result<Vec<Generation>> { |
| 91 | + let db_path = get_cursor_db_path(repo_path, nightly)?; |
| 92 | + |
| 93 | + if !db_path.exists() { |
| 94 | + return Ok(Vec::new()); |
| 95 | + } |
| 96 | + |
| 97 | + let db_url = format!("file:{}", db_path.to_string_lossy()); |
| 98 | + let mut conn = SqliteConnection::establish(&db_url) |
| 99 | + .map_err(|e| anyhow::anyhow!("Failed to connect to database at {:?}: {}", db_path, e))?; |
| 100 | + |
| 101 | + let query_result: Result<Vec<GenerationQueryResult>, diesel::result::Error> = |
| 102 | + sql_query("SELECT value FROM ItemTable WHERE key = ?") |
| 103 | + .bind::<Text, _>("aiService.generations") |
| 104 | + .load(&mut conn); |
| 105 | + |
| 106 | + match query_result { |
| 107 | + Ok(results) => { |
| 108 | + if let Some(result) = results.first() { |
| 109 | + parse_generations_json(&result.value) |
| 110 | + } else { |
| 111 | + Ok(Vec::new()) // Key not found |
| 112 | + } |
| 113 | + } |
| 114 | + Err(e) => Err(anyhow::anyhow!("Database query failed: {}", e)), |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +#[cfg(test)] |
| 119 | +mod tests { |
| 120 | + use super::*; |
| 121 | + |
| 122 | + #[test] |
| 123 | + fn test_get_cursor_base_dir_regular() { |
| 124 | + let result = get_cursor_base_dir(false); |
| 125 | + assert!(result.is_ok()); |
| 126 | + let path = result.unwrap(); |
| 127 | + |
| 128 | + #[cfg(target_os = "macos")] |
| 129 | + assert!( |
| 130 | + path.to_string_lossy() |
| 131 | + .contains("Library/Application Support/Cursor") |
| 132 | + ); |
| 133 | + |
| 134 | + #[cfg(target_os = "windows")] |
| 135 | + assert!(path.to_string_lossy().contains("\\Cursor\\")); |
| 136 | + |
| 137 | + #[cfg(target_os = "linux")] |
| 138 | + assert!( |
| 139 | + path.to_string_lossy().contains(".config/Cursor") |
| 140 | + || path.to_string_lossy().contains("Cursor") |
| 141 | + ); |
| 142 | + } |
| 143 | + |
| 144 | + #[test] |
| 145 | + fn test_get_cursor_base_dir_nightly() { |
| 146 | + let result = get_cursor_base_dir(true); |
| 147 | + assert!(result.is_ok()); |
| 148 | + let path = result.unwrap(); |
| 149 | + assert!(path.to_string_lossy().contains("Cursor Nightly")); |
| 150 | + } |
| 151 | + |
| 152 | + #[test] |
| 153 | + fn test_parse_generations_json() { |
| 154 | + let json_str = r#"[{ |
| 155 | + "generationUUID": "ade2d936-9af0-457d-b16a-7293ec309f5f", |
| 156 | + "textDescription": "Add Esteban 6", |
| 157 | + "type": "composer", |
| 158 | + "unixMs": 1758115352488 |
| 159 | + }]"#; |
| 160 | + |
| 161 | + let result = parse_generations_json(json_str); |
| 162 | + if let Err(e) = &result { |
| 163 | + eprintln!("JSON parsing failed: {e}"); |
| 164 | + } |
| 165 | + assert!(result.is_ok()); |
| 166 | + |
| 167 | + let generations = result.unwrap(); |
| 168 | + assert_eq!(generations.len(), 1); |
| 169 | + |
| 170 | + let generation = &generations[0]; |
| 171 | + assert_eq!( |
| 172 | + generation.generation_uuid, |
| 173 | + "ade2d936-9af0-457d-b16a-7293ec309f5f" |
| 174 | + ); |
| 175 | + assert_eq!(generation.text_description, "Add Esteban 6"); |
| 176 | + assert_eq!(generation.generation_type, "composer"); |
| 177 | + assert_eq!(generation.unix_ms, 1758115352488); |
| 178 | + } |
| 179 | + |
| 180 | + #[test] |
| 181 | + fn test_get_cursor_db_path() { |
| 182 | + // Use current directory which should exist |
| 183 | + let repo_path = std::env::current_dir().unwrap(); |
| 184 | + let result = get_cursor_db_path(&repo_path, false); |
| 185 | + if let Err(e) = &result { |
| 186 | + eprintln!("get_cursor_db_path failed: {e}"); |
| 187 | + } |
| 188 | + assert!(result.is_ok()); |
| 189 | + |
| 190 | + let db_path = result.unwrap(); |
| 191 | + assert!(db_path.to_string_lossy().ends_with("state.vscdb")); |
| 192 | + } |
| 193 | + |
| 194 | + #[test] |
| 195 | + fn test_get_generations_nonexistent_db() { |
| 196 | + // Use current directory but the database file won't exist |
| 197 | + let repo_path = std::env::current_dir().unwrap(); |
| 198 | + let result = get_generations(&repo_path, false); |
| 199 | + if let Err(e) = &result { |
| 200 | + eprintln!("get_generations failed: {e}"); |
| 201 | + } |
| 202 | + assert!(result.is_ok()); |
| 203 | + |
| 204 | + let generations = result.unwrap(); |
| 205 | + assert_eq!(generations.len(), 0); |
| 206 | + } |
| 207 | +} |
0 commit comments