use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
const CURSOR_HTTP_TIMEOUT: Duration = Duration::from_secs(8);
pub const CURSOR_AUTO_SYNC_FRESHNESS: Duration = Duration::from_secs(5 * 60);
fn build_cursor_http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(CURSOR_HTTP_TIMEOUT)
.build()
.context("Failed to build Cursor HTTP client")
}
fn home_dir() -> Result<PathBuf> {
dirs::home_dir().context("Could not determine home directory")
}
fn cursor_credentials_path(home_dir: &Path) -> PathBuf {
home_dir.join(".config/tokmesh/cursor-credentials.json")
}
fn cursor_cache_dir(home_dir: &Path) -> PathBuf {
home_dir.join(".config/tokmesh/cursor-cache")
}
const USAGE_CSV_ENDPOINT: &str =
"https://cursor.com/api/dashboard/export-usage-events-csv?strategy=tokens";
const USAGE_SUMMARY_ENDPOINT: &str = "https://cursor.com/api/usage-summary";
const CURSOR_SYNC_ATTEMPT_MARKER: &str = "usage.last-sync-attempt";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CursorCredentials {
#[serde(rename = "sessionToken")]
pub session_token: String,
#[serde(rename = "userId", skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CursorCredentialsStore {
pub version: i32,
#[serde(rename = "activeAccountId")]
pub active_account_id: String,
pub accounts: HashMap<String, CursorCredentials>,
}
#[derive(Debug, Serialize)]
pub struct AccountInfo {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(rename = "userId", skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "isActive")]
pub is_active: bool,
}
#[derive(Debug, Serialize)]
pub struct SyncCursorResult {
pub synced: bool,
pub rows: usize,
pub error: Option<String>,
}
pub fn get_cursor_credentials_path() -> Result<PathBuf> {
Ok(cursor_credentials_path(&home_dir()?))
}
pub fn get_cursor_cache_dir() -> Result<PathBuf> {
Ok(cursor_cache_dir(&home_dir()?))
}
fn build_cursor_headers(session_token: &str) -> reqwest::header::HeaderMap {
use reqwest::header::HeaderValue;
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("Accept", HeaderValue::from_static("*/*"));
headers.insert(
"Accept-Language",
HeaderValue::from_static("en-US,en;q=0.9"),
);
if let Ok(cookie) = format!("WorkosCursorSessionToken={}", session_token).parse() {
headers.insert("Cookie", cookie);
}
headers.insert(
"Referer",
HeaderValue::from_static("https://www.cursor.com/settings"),
);
headers.insert(
"User-Agent",
HeaderValue::from_static("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
);
headers
}
fn count_cursor_csv_rows(csv_text: &str) -> usize {
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.from_reader(csv_text.as_bytes());
reader.records().filter_map(|r| r.ok()).count()
}
fn atomic_write_file(path: &std::path::Path, contents: &str) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("Invalid cache path"))?;
if !parent.exists() {
fs::create_dir_all(parent)?;
}
let temp_name = format!(
".tmp-{}-{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("cursor"),
std::process::id()
);
let temp_path = parent.join(temp_name);
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&temp_path)?;
file.write_all(contents.as_bytes())?;
}
#[cfg(not(unix))]
{
fs::write(&temp_path, contents)?;
}
if let Err(err) = fs::rename(&temp_path, path) {
if path.exists() {
match fs::copy(&temp_path, path) {
Ok(_) => {
let _ = fs::remove_file(&temp_path);
}
Err(copy_err) => {
let _ = fs::remove_file(&temp_path);
return Err(anyhow::anyhow!(
"Failed to persist file with rename ({}) and copy fallback ({})",
err,
copy_err
));
}
}
} else {
let _ = fs::remove_file(&temp_path);
return Err(err.into());
}
}
Ok(())
}
fn ensure_config_dir_in_home(home_dir: &Path) -> Result<()> {
let config_dir = home_dir.join(".config/tokmesh");
if !config_dir.exists() {
fs::create_dir_all(&config_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o700))?;
}
}
Ok(())
}
fn extract_user_id_from_session_token(token: &str) -> Option<String> {
let token = token.trim();
if token.contains("%3A%3A") {
let user_id = token.split("%3A%3A").next()?.trim();
if user_id.is_empty() {
return None;
}
return Some(user_id.to_string());
}
if token.contains("::") {
let user_id = token.split("::").next()?.trim();
if user_id.is_empty() {
return None;
}
return Some(user_id.to_string());
}
None
}
pub fn cursor_state_vscdb_candidates(home_dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
#[cfg(target_os = "macos")]
{
paths.push(
home_dir.join("Library/Application Support/Cursor/User/globalStorage/state.vscdb"),
);
}
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
paths.push(
PathBuf::from(appdata)
.join("Cursor")
.join("User/globalStorage/state.vscdb"),
);
}
paths.push(home_dir.join("AppData/Roaming/Cursor/User/globalStorage/state.vscdb"));
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
paths.push(home_dir.join(".config/Cursor/User/globalStorage/state.vscdb"));
}
paths
}
fn find_cursor_state_vscdb(home_dir: &Path) -> Option<PathBuf> {
cursor_state_vscdb_candidates(home_dir)
.into_iter()
.find(|path| path.is_file())
}
pub fn read_access_token_from_state_vscdb(db_path: &Path) -> Result<String> {
use rusqlite::{Connection, OpenFlags};
let uri = format!("file:{}?mode=ro", db_path.display());
let conn = Connection::open_with_flags(
&uri,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)
.with_context(|| format!("Failed to open Cursor state DB at {}", db_path.display()))?;
let token: String = conn
.query_row(
"SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'",
[],
|row| row.get(0),
)
.context("cursorAuth/accessToken not found in Cursor state DB (is Cursor logged in?)")?;
if token.trim().is_empty() {
anyhow::bail!("cursorAuth/accessToken is empty");
}
Ok(token)
}
fn user_id_from_access_token_jwt(access_token: &str) -> Result<String> {
use base64::Engine;
let payload_b64 = access_token
.split('.')
.nth(1)
.ok_or_else(|| anyhow::anyhow!("Invalid Cursor access token JWT"))?;
let padded = match payload_b64.len() % 4 {
2 => format!("{}==", payload_b64),
3 => format!("{}=", payload_b64),
_ => payload_b64.to_string(),
};
let bytes = base64::engine::general_purpose::URL_SAFE
.decode(padded.as_bytes())
.or_else(|_| {
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64.as_bytes())
})
.context("Failed to decode Cursor access token JWT payload")?;
let payload: serde_json::Value =
serde_json::from_slice(&bytes).context("Failed to parse Cursor access token JWT")?;
let sub = payload
.get("sub")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Cursor access token JWT missing sub claim"))?;
if let Some(idx) = sub.find("user_") {
let rest = &sub[idx..];
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
let user_id = &rest[..end];
if user_id.len() > "user_".len() {
return Ok(user_id.to_string());
}
}
anyhow::bail!("Cannot parse Cursor user id from JWT sub: {sub}");
}
pub fn session_token_from_access_token(access_token: &str) -> Result<String> {
let user_id = user_id_from_access_token_jwt(access_token)?;
Ok(format!("{user_id}%3A%3A{access_token}"))
}
pub fn read_local_cursor_session_token() -> Result<String> {
let home = home_dir()?;
let db_path = find_cursor_state_vscdb(&home).ok_or_else(|| {
anyhow::anyhow!("Cursor desktop state.vscdb not found (install Cursor and sign in first)")
})?;
let access_token = read_access_token_from_state_vscdb(&db_path)?;
session_token_from_access_token(&access_token)
}
fn upsert_credentials(token: &str, label: Option<&str>) -> Result<String> {
let account_id = derive_account_id(token);
let user_id = extract_user_id_from_session_token(token);
let mut store = load_credentials_store().unwrap_or_else(|| CursorCredentialsStore {
version: 1,
active_account_id: account_id.clone(),
accounts: HashMap::new(),
});
if let Some(lbl) = label {
let needle = lbl.trim().to_lowercase();
if !needle.is_empty() {
for (id, acct) in &store.accounts {
if id == &account_id {
continue;
}
if let Some(existing_label) = &acct.label {
if existing_label.trim().to_lowercase() == needle {
anyhow::bail!("Cursor account label already exists: {}", lbl);
}
}
}
}
}
let existing = store.accounts.get(&account_id);
let created_at = existing
.map(|c| c.created_at.clone())
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
let resolved_label = label
.map(|s| s.to_string())
.or_else(|| existing.and_then(|c| c.label.clone()));
let credentials = CursorCredentials {
session_token: token.to_string(),
user_id,
created_at,
expires_at: None,
label: resolved_label,
};
store.accounts.insert(account_id.clone(), credentials);
store.active_account_id = account_id.clone();
save_credentials_store(&store)?;
Ok(account_id)
}
fn ensure_credentials_from_local_cursor() -> Result<Option<String>> {
match read_local_cursor_session_token() {
Ok(token) => Ok(Some(upsert_credentials(&token, None)?)),
Err(_) => Ok(None),
}
}
fn derive_account_id(session_token: &str) -> String {
if let Some(user_id) = extract_user_id_from_session_token(session_token) {
return user_id;
}
let mut hasher = Sha256::new();
hasher.update(session_token.as_bytes());
let hash = hasher.finalize();
let hex = format!("{:x}", hash);
format!("anon-{}", &hex[..12])
}
fn sanitize_account_id_for_filename(account_id: &str) -> String {
let sanitized: String = account_id
.trim()
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
c
} else {
'-'
}
})
.collect();
let trimmed = sanitized.trim_matches('-');
let result = if trimmed.len() > 80 {
&trimmed[..80]
} else {
trimmed
};
if result.is_empty() {
"account".to_string()
} else {
result.to_string()
}
}
pub fn load_credentials_store() -> Option<CursorCredentialsStore> {
let home_dir = home_dir().ok()?;
load_credentials_store_from_home(&home_dir)
}
fn load_credentials_store_from_home(home_dir: &Path) -> Option<CursorCredentialsStore> {
let path = cursor_credentials_path(home_dir);
let content = fs::read_to_string(&path).ok()?;
if let Ok(mut store) = serde_json::from_str::<CursorCredentialsStore>(&content) {
if store.version == 1 && !store.accounts.is_empty() {
let mut changed = false;
if !store.accounts.contains_key(&store.active_account_id) {
if let Some(first_id) = store.accounts.keys().next().cloned() {
store.active_account_id = first_id;
changed = true;
}
}
if changed {
let _ = save_credentials_store_in_home(home_dir, &store);
}
return Some(store);
}
}
if let Ok(single) = serde_json::from_str::<CursorCredentials>(&content) {
let account_id = derive_account_id(&single.session_token);
let mut accounts = HashMap::new();
accounts.insert(account_id.clone(), single);
let migrated = CursorCredentialsStore {
version: 1,
active_account_id: account_id,
accounts,
};
let _ = save_credentials_store_in_home(home_dir, &migrated);
return Some(migrated);
}
None
}
pub fn save_credentials_store(store: &CursorCredentialsStore) -> Result<()> {
save_credentials_store_in_home(&home_dir()?, store)
}
fn save_credentials_store_in_home(home_dir: &Path, store: &CursorCredentialsStore) -> Result<()> {
ensure_config_dir_in_home(home_dir)?;
let path = cursor_credentials_path(home_dir);
let json = serde_json::to_string_pretty(store)?;
atomic_write_file(&path, &json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
fn resolve_account_id(store: &CursorCredentialsStore, name_or_id: &str) -> Option<String> {
let needle = name_or_id.trim();
if needle.is_empty() {
return None;
}
if store.accounts.contains_key(needle) {
return Some(needle.to_string());
}
let needle_lower = needle.to_lowercase();
for (id, acct) in &store.accounts {
if let Some(label) = &acct.label {
if label.to_lowercase() == needle_lower {
return Some(id.clone());
}
}
}
None
}
pub fn list_accounts() -> Vec<AccountInfo> {
let store = match load_credentials_store() {
Some(s) => s,
None => return vec![],
};
let mut accounts: Vec<AccountInfo> = store
.accounts
.iter()
.map(|(id, acct)| AccountInfo {
id: id.clone(),
label: acct.label.clone(),
user_id: acct.user_id.clone(),
created_at: acct.created_at.clone(),
is_active: id == &store.active_account_id,
})
.collect();
accounts.sort_by(|a, b| {
if a.is_active != b.is_active {
return if a.is_active {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}
let la = a.label.as_deref().unwrap_or(&a.id).to_lowercase();
let lb = b.label.as_deref().unwrap_or(&b.id).to_lowercase();
la.cmp(&lb)
});
accounts
}
pub fn find_account(name_or_id: &str) -> Option<AccountInfo> {
let store = load_credentials_store()?;
let resolved = resolve_account_id(&store, name_or_id)?;
let acct = store.accounts.get(&resolved)?;
Some(AccountInfo {
id: resolved.clone(),
label: acct.label.clone(),
user_id: acct.user_id.clone(),
created_at: acct.created_at.clone(),
is_active: resolved == store.active_account_id,
})
}
pub fn save_credentials(token: &str, label: Option<&str>) -> Result<String> {
upsert_credentials(token, label)
}
pub fn remove_account(name_or_id: &str, purge_cache: bool) -> Result<()> {
let mut store =
load_credentials_store().ok_or_else(|| anyhow::anyhow!("No saved Cursor accounts"))?;
let resolved = resolve_account_id(&store, name_or_id)
.ok_or_else(|| anyhow::anyhow!("Account not found: {}", name_or_id))?;
let was_active = resolved == store.active_account_id;
let cache_dir = get_cursor_cache_dir()?;
if cache_dir.exists() {
let per_account = cache_dir.join(format!(
"usage.{}.csv",
sanitize_account_id_for_filename(&resolved)
));
if per_account.exists() {
if purge_cache {
let _ = fs::remove_file(&per_account);
} else {
let _ = archive_cache_file(&per_account, &format!("usage.{}", resolved));
}
}
if was_active {
let active_file = cache_dir.join("usage.csv");
if active_file.exists() {
if purge_cache {
let _ = fs::remove_file(&active_file);
} else {
let _ = archive_cache_file(&active_file, &format!("usage.active.{}", resolved));
}
}
}
}
store.accounts.remove(&resolved);
if store.accounts.is_empty() {
let path = get_cursor_credentials_path()?;
if path.exists() {
fs::remove_file(path)?;
}
return Ok(());
}
if was_active {
if let Some(first_id) = store.accounts.keys().next().cloned() {
let new_account_file = cache_dir.join(format!(
"usage.{}.csv",
sanitize_account_id_for_filename(&first_id)
));
let active_file = cache_dir.join("usage.csv");
if new_account_file.exists() {
let _ = fs::rename(&new_account_file, &active_file);
}
store.active_account_id = first_id;
}
}
save_credentials_store(&store)?;
Ok(())
}
pub fn remove_all_accounts(purge_cache: bool) -> Result<()> {
let cache_dir = get_cursor_cache_dir()?;
if cache_dir.exists() {
if let Ok(entries) = fs::read_dir(&cache_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("usage") && name.ends_with(".csv") {
if purge_cache {
let _ = fs::remove_file(entry.path());
} else {
let _ = archive_cache_file(&entry.path(), &format!("usage.all.{}", name));
}
}
}
}
}
let path = get_cursor_credentials_path()?;
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
pub fn set_active_account(name_or_id: &str) -> Result<()> {
let mut store =
load_credentials_store().ok_or_else(|| anyhow::anyhow!("No saved Cursor accounts"))?;
let resolved = resolve_account_id(&store, name_or_id)
.ok_or_else(|| anyhow::anyhow!("Account not found: {}", name_or_id))?;
let old_active_id = store.active_account_id.clone();
if resolved != old_active_id {
let _ = reconcile_cache_files(&old_active_id, &resolved);
}
store.active_account_id = resolved;
save_credentials_store(&store)?;
Ok(())
}
fn reconcile_cache_files(old_account_id: &str, new_account_id: &str) -> Result<()> {
let cache_dir = get_cursor_cache_dir()?;
if !cache_dir.exists() {
return Ok(());
}
let active_file = cache_dir.join("usage.csv");
let old_account_file = cache_dir.join(format!(
"usage.{}.csv",
sanitize_account_id_for_filename(old_account_id)
));
let new_account_file = cache_dir.join(format!(
"usage.{}.csv",
sanitize_account_id_for_filename(new_account_id)
));
if active_file.exists() {
if old_account_file.exists() {
let _ = archive_cache_file(&old_account_file, old_account_id);
}
fs::rename(&active_file, &old_account_file)?;
}
if new_account_file.exists() {
if active_file.exists() {
let _ = archive_cache_file(&active_file, "usage.active");
}
fs::rename(&new_account_file, &active_file)?;
}
Ok(())
}
pub fn load_active_credentials() -> Option<CursorCredentials> {
let store = load_credentials_store()?;
store.accounts.get(&store.active_account_id).cloned()
}
pub fn has_active_credentials_in_home(home_dir: &Path) -> bool {
load_credentials_store_from_home(home_dir)
.and_then(|store| store.accounts.get(&store.active_account_id).cloned())
.is_some()
}
fn is_cursor_usage_csv_filename(name: &str) -> bool {
if name == "usage.csv" {
return true;
}
if !name.starts_with("usage.") || !name.ends_with(".csv") {
return false;
}
if name.starts_with("usage.backup") {
return false;
}
let stem = name.trim_start_matches("usage.").trim_end_matches(".csv");
!stem.is_empty()
&& stem
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
pub fn has_cursor_usage_cache_in_home(home_dir: &Path) -> bool {
let cache_dir = cursor_cache_dir(home_dir);
if !cache_dir.exists() {
return false;
}
match fs::read_dir(cache_dir) {
Ok(entries) => entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().into_string().ok())
.any(|name| is_cursor_usage_csv_filename(&name)),
Err(_) => false,
}
}
pub fn has_cursor_usage_cache() -> bool {
let home_dir = match home_dir() {
Ok(home_dir) => home_dir,
Err(_) => return false,
};
has_cursor_usage_cache_in_home(&home_dir)
}
fn expected_cursor_usage_cache_paths_in(home_dir: &Path) -> Vec<PathBuf> {
let cache_dir = cursor_cache_dir(home_dir);
if let Some(store) = load_credentials_store_from_home(home_dir) {
if !store.accounts.is_empty() {
let mut paths = store
.accounts
.keys()
.map(|account_id| {
if account_id == &store.active_account_id {
cache_dir.join("usage.csv")
} else {
cache_dir.join(format!(
"usage.{}.csv",
sanitize_account_id_for_filename(account_id)
))
}
})
.collect::<Vec<_>>();
paths.sort_unstable();
paths.dedup();
return paths;
}
}
vec![cache_dir.join("usage.csv")]
}
fn cursor_usage_cache_file_is_fresh(path: &Path, max_age: Duration) -> bool {
let Ok(mtime) = path.metadata().and_then(|meta| meta.modified()) else {
return false;
};
match SystemTime::now().duration_since(mtime) {
Ok(age) => age < max_age,
Err(_) => true,
}
}
fn cursor_usage_cache_is_fresh_in(home_dir: &Path, max_age: Duration) -> bool {
let cache_dir = cursor_cache_dir(home_dir);
if !cache_dir.exists() {
return false;
}
let active_path = cache_dir.join("usage.csv");
if !cursor_usage_cache_file_is_fresh(&active_path, max_age) {
return false;
}
let marker_fresh =
cursor_usage_cache_file_is_fresh(&cache_dir.join(CURSOR_SYNC_ATTEMPT_MARKER), max_age);
expected_cursor_usage_cache_paths_in(home_dir)
.iter()
.filter(|p| *p != &active_path)
.all(|p| cursor_usage_cache_file_is_fresh(p, max_age) || marker_fresh)
}
pub fn cursor_usage_cache_is_fresh(max_age: Duration) -> bool {
let Ok(home_dir) = home_dir() else {
return false;
};
cursor_usage_cache_is_fresh_in(&home_dir, max_age)
}
pub fn is_cursor_logged_in() -> bool {
load_active_credentials().is_some()
}
pub fn load_credentials_for(name_or_id: &str) -> Option<CursorCredentials> {
let store = load_credentials_store()?;
let resolved = resolve_account_id(&store, name_or_id)?;
store.accounts.get(&resolved).cloned()
}
#[derive(Debug)]
pub struct ValidateSessionResult {
pub valid: bool,
pub membership_type: Option<String>,
pub error: Option<String>,
}
pub async fn validate_cursor_session(token: &str) -> ValidateSessionResult {
let client = match build_cursor_http_client() {
Ok(client) => client,
Err(e) => {
return ValidateSessionResult {
valid: false,
membership_type: None,
error: Some(format!("Failed to build HTTP client: {}", e)),
};
}
};
let response = match client
.get(USAGE_SUMMARY_ENDPOINT)
.headers(build_cursor_headers(token))
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
return ValidateSessionResult {
valid: false,
membership_type: None,
error: Some(format!("Failed to connect: {}", e)),
};
}
};
if response.status() == reqwest::StatusCode::UNAUTHORIZED
|| response.status() == reqwest::StatusCode::FORBIDDEN
{
return ValidateSessionResult {
valid: false,
membership_type: None,
error: Some("Session token expired or invalid".to_string()),
};
}
if !response.status().is_success() {
return ValidateSessionResult {
valid: false,
membership_type: None,
error: Some(format!("API returned status {}", response.status())),
};
}
let data: serde_json::Value = match response.json().await {
Ok(d) => d,
Err(e) => {
return ValidateSessionResult {
valid: false,
membership_type: None,
error: Some(format!("Failed to parse response: {}", e)),
};
}
};
let has_billing_start = data
.get("billingCycleStart")
.and_then(|v| v.as_str())
.is_some();
let has_billing_end = data
.get("billingCycleEnd")
.and_then(|v| v.as_str())
.is_some();
if has_billing_start && has_billing_end {
let membership_type = data
.get("membershipType")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
ValidateSessionResult {
valid: true,
membership_type,
error: None,
}
} else {
ValidateSessionResult {
valid: false,
membership_type: None,
error: Some("Invalid response format".to_string()),
}
}
}
pub async fn fetch_cursor_usage_csv(session_token: &str) -> Result<String> {
let client = build_cursor_http_client()?;
let response = client
.get(USAGE_CSV_ENDPOINT)
.headers(build_cursor_headers(session_token))
.send()
.await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED
|| response.status() == reqwest::StatusCode::FORBIDDEN
{
anyhow::bail!(
"Cursor session expired. Please run 'tokmesh cursor login' to re-authenticate."
);
}
if !response.status().is_success() {
anyhow::bail!("Cursor API returned status {}", response.status());
}
let text = response.text().await?;
if !text.starts_with("Date,") {
anyhow::bail!("Invalid response from Cursor API - expected CSV format");
}
Ok(text)
}
async fn sync_cursor_cache_with_fetcher<F, Fut>(fetch_usage_csv: F) -> SyncCursorResult
where
F: Fn(String) -> Fut,
Fut: std::future::Future<Output = Result<String>>,
{
let home_dir = match home_dir() {
Ok(home_dir) => home_dir,
Err(e) => {
return SyncCursorResult {
synced: false,
rows: 0,
error: Some(format!("Failed to get home dir: {}", e)),
};
}
};
sync_cursor_cache_with_fetcher_in_home(&home_dir, fetch_usage_csv).await
}
async fn sync_cursor_cache_with_fetcher_in_home<F, Fut>(
home_dir: &Path,
fetch_usage_csv: F,
) -> SyncCursorResult
where
F: Fn(String) -> Fut,
Fut: std::future::Future<Output = Result<String>>,
{
let store = match load_credentials_store_from_home(home_dir) {
Some(s) => s,
None => {
return SyncCursorResult {
synced: false,
rows: 0,
error: Some("Not authenticated".to_string()),
};
}
};
if store.accounts.is_empty() {
return SyncCursorResult {
synced: false,
rows: 0,
error: Some("Not authenticated".to_string()),
};
}
let cache_dir = cursor_cache_dir(home_dir);
if let Err(e) = fs::create_dir_all(&cache_dir) {
return SyncCursorResult {
synced: false,
rows: 0,
error: Some(format!("Failed to create cache dir: {}", e)),
};
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&cache_dir, fs::Permissions::from_mode(0o700));
}
let active_dup = cache_dir.join(format!(
"usage.{}.csv",
sanitize_account_id_for_filename(&store.active_account_id)
));
if active_dup.exists() {
let _ = fs::remove_file(&active_dup);
}
let mut total_rows = 0;
let mut success_count = 0;
let mut errors: Vec<String> = Vec::new();
for (account_id, credentials) in &store.accounts {
let is_active = account_id == &store.active_account_id;
match fetch_usage_csv(credentials.session_token.clone()).await {
Ok(csv_text) => {
let file_path = if is_active {
cache_dir.join("usage.csv")
} else {
cache_dir.join(format!(
"usage.{}.csv",
sanitize_account_id_for_filename(account_id)
))
};
let row_count = count_cursor_csv_rows(&csv_text);
if let Err(e) = atomic_write_file(&file_path, &csv_text) {
errors.push(format!("{}: {}", account_id, e));
} else {
total_rows += row_count;
success_count += 1;
}
}
Err(e) => {
errors.push(format!("{}: {}", account_id, e));
}
}
}
let _ = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(cache_dir.join(CURSOR_SYNC_ATTEMPT_MARKER));
if success_count == 0 {
return SyncCursorResult {
synced: false,
rows: 0,
error: Some(
errors
.first()
.cloned()
.unwrap_or_else(|| "Cursor sync failed".to_string()),
),
};
}
SyncCursorResult {
synced: true,
rows: total_rows,
error: if errors.is_empty() {
None
} else {
Some(format!(
"Some accounts failed to sync ({}/{})",
errors.len(),
store.accounts.len()
))
},
}
}
pub async fn sync_cursor_cache() -> SyncCursorResult {
let _ = ensure_credentials_from_local_cursor();
sync_cursor_cache_with_fetcher(|session_token| async move {
fetch_cursor_usage_csv(&session_token).await
})
.await
}
fn archive_cache_file(file_path: &std::path::Path, label: &str) -> Result<()> {
let cache_dir = get_cursor_cache_dir()?;
let archive_dir = cache_dir.join("archive");
if !archive_dir.exists() {
fs::create_dir_all(&archive_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&archive_dir, fs::Permissions::from_mode(0o700))?;
}
}
let safe_label = sanitize_account_id_for_filename(label);
let ts = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
let dest = archive_dir.join(format!("{}-{}.csv", safe_label, ts));
fs::rename(file_path, dest)?;
Ok(())
}
pub fn run_cursor_login(name: Option<String>) -> Result<()> {
use colored::Colorize;
use tokio::runtime::Runtime;
let rt = Runtime::new()?;
println!("\n {}\n", "Cursor IDE - Login".cyan());
if let Some(ref label) = name {
if find_account(label).is_some() {
println!(
" {}",
format!(
"Account '{}' already exists. Use 'tokmesh cursor logout --name {}' first.",
label, label
)
.yellow()
);
println!();
return Ok(());
}
}
println!(
"{}",
" Checking local Cursor desktop login...".bright_black()
);
let token = match read_local_cursor_session_token() {
Ok(token) => {
if let Some(user_id) = extract_user_id_from_session_token(&token) {
println!(
"{}",
format!(" Found local Cursor session ({user_id}).").bright_black()
);
} else {
println!("{}", " Found local Cursor session.".bright_black());
}
token
}
Err(local_err) => {
println!(
"{}",
format!(" Local desktop login unavailable: {local_err}").bright_black()
);
print!(" Enter Cursor WorkosCursorSessionToken value: ");
std::io::stdout().flush()?;
let pasted = rpassword::read_password().context("Failed to read session token")?;
let pasted = pasted.trim().to_string();
if pasted.is_empty() {
println!("\n {}\n", "No token provided.".yellow());
return Ok(());
}
pasted
}
};
println!();
println!("{}", " Validating session token...".bright_black());
let result = rt.block_on(async { validate_cursor_session(&token).await });
if !result.valid {
let msg = result
.error
.unwrap_or_else(|| "Invalid session token".to_string());
println!(
"\n {}\n",
format!("{}. Please check and try again.", msg).red()
);
std::process::exit(1);
}
let account_id = save_credentials(&token, name.as_deref())?;
let display_name = name.as_deref().unwrap_or(&account_id);
println!(
"\n {}",
format!(
"Successfully logged in to Cursor as {}",
display_name.bold()
)
.green()
);
println!("{}", format!(" Account ID: {}", account_id).bright_black());
println!();
Ok(())
}
pub fn run_cursor_logout(name: Option<String>, all: bool, purge_cache: bool) -> Result<()> {
use colored::Colorize;
if all {
let accounts = list_accounts();
if accounts.is_empty() {
println!("\n {}\n", "No saved Cursor accounts.".yellow());
return Ok(());
}
remove_all_accounts(purge_cache)?;
println!("\n {}\n", "Logged out from all Cursor accounts.".green());
return Ok(());
}
if let Some(ref account_name) = name {
remove_account(account_name, purge_cache)?;
println!(
"\n {}\n",
format!("Logged out from Cursor account '{}'.", account_name).green()
);
return Ok(());
}
let Some(store) = load_credentials_store() else {
println!("\n {}\n", "No saved Cursor accounts.".yellow());
return Ok(());
};
let active_id = store.active_account_id.clone();
let display = store
.accounts
.get(&active_id)
.and_then(|a| a.label.clone())
.unwrap_or_else(|| active_id.clone());
remove_account(&active_id, purge_cache)?;
println!(
"\n {}\n",
format!("Logged out from Cursor account '{}'.", display).green()
);
Ok(())
}
pub fn run_cursor_status(name: Option<String>) -> Result<()> {
use colored::Colorize;
use tokio::runtime::Runtime;
let rt = Runtime::new()?;
let credentials = if let Some(ref account_name) = name {
load_credentials_for(account_name)
} else {
load_active_credentials()
};
let credentials = match credentials {
Some(c) => c,
None => {
if let Some(ref account_name) = name {
println!(
"\n {}\n",
format!("Account not found: {}", account_name).red()
);
} else {
println!("\n {}", "No saved Cursor accounts.".yellow());
println!(
"{}",
" Run 'tokmesh cursor login' to authenticate.\n".bright_black()
);
}
return Ok(());
}
};
println!("\n {}\n", "Cursor IDE - Status".cyan());
let display_name = credentials.label.as_deref().unwrap_or("(no label)");
println!("{}", format!(" Account: {}", display_name).white());
if let Some(ref uid) = credentials.user_id {
println!("{}", format!(" User ID: {}", uid).bright_black());
}
println!("{}", " Validating session...".bright_black());
let result = rt.block_on(async { validate_cursor_session(&credentials.session_token).await });
if result.valid {
println!(" {}", "Session: Valid".green());
if let Some(membership) = result.membership_type {
println!("{}", format!(" Membership: {}", membership).bright_black());
}
} else {
let msg = result
.error
.unwrap_or_else(|| "Invalid / Expired".to_string());
println!(" {}", format!("Session: {}", msg).red());
}
println!();
Ok(())
}
pub fn run_cursor_accounts(json: bool) -> Result<()> {
use colored::Colorize;
let accounts = list_accounts();
if json {
#[derive(Serialize)]
struct Output {
accounts: Vec<AccountInfo>,
}
let output = Output { accounts };
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
if accounts.is_empty() {
println!("\n {}\n", "No saved Cursor accounts.".yellow());
return Ok(());
}
println!("{}", "\n Cursor IDE - Accounts\n".cyan());
for acct in &accounts {
let name = if let Some(ref label) = acct.label {
format!("{} ({})", label, acct.id)
} else {
acct.id.clone()
};
let marker = if acct.is_active { "*" } else { "-" };
let marker_colored = if acct.is_active {
marker.green().to_string()
} else {
marker.bright_black().to_string()
};
println!(" {} {}", marker_colored, name);
}
println!();
Ok(())
}
pub fn run_cursor_sync(json: bool) -> Result<()> {
use colored::Colorize;
use tokio::runtime::Runtime;
let rt = Runtime::new()?;
let result = rt.block_on(sync_cursor_cache());
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
return Ok(());
}
println!("\n {}\n", "Cursor IDE - Sync".cyan());
if result.synced {
println!(
"{}",
format!(" Synced {} Cursor usage event(s).", result.rows).green()
);
if let Some(error) = result.error {
println!("{}", format!(" Warning: {}", error).yellow());
}
} else if let Some(error) = result.error {
println!("{}", format!(" Sync failed: {}", error).red());
} else {
println!("{}", " Sync failed.".red());
}
println!();
Ok(())
}
pub fn run_cursor_switch(name: &str) -> Result<()> {
use colored::Colorize;
set_active_account(name)?;
println!(
"\n {}\n",
format!("Active Cursor account set to {}", name.bold()).green()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
#[test]
fn test_extract_user_id_from_session_token_with_url_encoding() {
assert_eq!(
extract_user_id_from_session_token("user123%3A%3Atoken456"),
Some("user123".to_string())
);
assert_eq!(
extract_user_id_from_session_token(" user123%3A%3Atoken456 "),
Some("user123".to_string())
);
}
#[test]
fn test_extract_user_id_from_session_token_with_double_colon() {
assert_eq!(
extract_user_id_from_session_token("user456::token789"),
Some("user456".to_string())
);
assert_eq!(
extract_user_id_from_session_token(" user456::token789 "),
Some("user456".to_string())
);
}
#[test]
fn test_extract_user_id_from_session_token_invalid() {
assert_eq!(extract_user_id_from_session_token("invalidtoken"), None);
assert_eq!(extract_user_id_from_session_token("%3A%3Atoken"), None);
assert_eq!(extract_user_id_from_session_token("::token"), None);
assert_eq!(extract_user_id_from_session_token(""), None);
assert_eq!(extract_user_id_from_session_token(" "), None);
}
fn make_access_token_jwt(sub: &str) -> String {
use base64::Engine;
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(format!(r#"{{"sub":"{sub}"}}"#).as_bytes());
format!("{header}.{payload}.sig")
}
#[test]
fn test_session_token_from_access_token_builds_cookie_value() {
let access = make_access_token_jwt("auth0|user_01ABCXYZ");
let session = session_token_from_access_token(&access).unwrap();
assert_eq!(session, format!("user_01ABCXYZ%3A%3A{access}"));
assert_eq!(
extract_user_id_from_session_token(&session),
Some("user_01ABCXYZ".to_string())
);
}
#[test]
fn test_read_access_token_from_state_vscdb() -> Result<()> {
let temp_dir = TempDir::new()?;
let db_path = temp_dir.path().join("state.vscdb");
let access = make_access_token_jwt("auth0|user_LOCAL123");
{
let conn = rusqlite::Connection::open(&db_path)?;
conn.execute(
"CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
[],
)?;
conn.execute(
"INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
rusqlite::params!["cursorAuth/accessToken", access],
)?;
}
let read = read_access_token_from_state_vscdb(&db_path)?;
assert_eq!(read, access);
Ok(())
}
#[test]
fn test_derive_account_id_with_user_id() {
let account_id = derive_account_id("user123%3A%3Atoken456");
assert_eq!(account_id, "user123");
let account_id = derive_account_id("user456::token789");
assert_eq!(account_id, "user456");
}
#[test]
fn test_derive_account_id_without_user_id() {
let account_id = derive_account_id("randomtoken");
assert!(account_id.starts_with("anon-"));
assert_eq!(account_id.len(), 17);
let account_id2 = derive_account_id("randomtoken");
assert_eq!(account_id, account_id2);
let account_id3 = derive_account_id("differenttoken");
assert_ne!(account_id, account_id3);
}
#[test]
fn test_sanitize_account_id_for_filename_basic() {
assert_eq!(sanitize_account_id_for_filename("user123"), "user123");
assert_eq!(
sanitize_account_id_for_filename("user.name_123-test"),
"user.name_123-test"
);
}
#[test]
fn test_sanitize_account_id_for_filename_unsafe_chars() {
assert_eq!(
sanitize_account_id_for_filename("user@example.com"),
"user-example.com"
);
assert_eq!(
sanitize_account_id_for_filename("user/name\\test"),
"user-name-test"
);
assert_eq!(sanitize_account_id_for_filename("user name"), "user-name");
}
#[test]
fn test_sanitize_account_id_for_filename_edge_cases() {
assert_eq!(
sanitize_account_id_for_filename("UserName123"),
"username123"
);
assert_eq!(sanitize_account_id_for_filename("---user---"), "user");
assert_eq!(sanitize_account_id_for_filename("@@@"), "account");
assert_eq!(sanitize_account_id_for_filename(""), "account");
assert_eq!(sanitize_account_id_for_filename(" "), "account");
}
#[test]
fn test_sanitize_account_id_for_filename_length_limit() {
let long_id = "a".repeat(100);
let sanitized = sanitize_account_id_for_filename(&long_id);
assert_eq!(sanitized.len(), 80);
assert_eq!(sanitized, "a".repeat(80));
let exactly_80 = "b".repeat(80);
let sanitized = sanitize_account_id_for_filename(&exactly_80);
assert_eq!(sanitized.len(), 80);
}
#[test]
fn test_build_cursor_http_client_applies_timeout() {
let client = build_cursor_http_client().expect("client builds");
assert_eq!(CURSOR_HTTP_TIMEOUT, std::time::Duration::from_secs(8));
let _ = client.get("https://example.invalid").build();
}
#[test]
fn test_cursor_usage_cache_is_fresh_returns_false_when_cache_missing() {
let temp = tempfile::tempdir().unwrap();
assert!(!cursor_usage_cache_is_fresh_in(
temp.path(),
Duration::from_secs(300)
));
}
#[test]
fn test_cursor_usage_cache_is_fresh_returns_false_when_no_csv_files() {
let temp = tempfile::tempdir().unwrap();
let cache_dir = cursor_cache_dir(temp.path());
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("README.txt"), "noise").unwrap();
assert!(!cursor_usage_cache_is_fresh_in(
temp.path(),
Duration::from_secs(300)
));
}
#[test]
fn test_cursor_usage_cache_is_fresh_returns_true_for_recent_file() {
let temp = tempfile::tempdir().unwrap();
let cache_dir = cursor_cache_dir(temp.path());
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("usage.csv"), "Date,Model\n").unwrap();
assert!(cursor_usage_cache_is_fresh_in(
temp.path(),
Duration::from_secs(300)
));
}
#[test]
fn test_cursor_usage_cache_is_fresh_returns_false_for_old_file() {
let temp = tempfile::tempdir().unwrap();
let cache_dir = cursor_cache_dir(temp.path());
fs::create_dir_all(&cache_dir).unwrap();
let path = cache_dir.join("usage.csv");
fs::write(&path, "Date,Model\n").unwrap();
let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
let Ok(()) = f.set_modified(SystemTime::now() - Duration::from_secs(3600)) else {
return;
};
drop(f);
assert!(!cursor_usage_cache_is_fresh_in(
temp.path(),
Duration::from_secs(300)
));
}
#[test]
fn test_cursor_usage_cache_is_fresh_requires_active_usage_csv_when_secondary_is_fresh() {
let temp = tempfile::tempdir().unwrap();
let cache_dir = cursor_cache_dir(temp.path());
fs::create_dir_all(&cache_dir).unwrap();
let stale_path = cache_dir.join("usage.csv");
fs::write(&stale_path, "Date,Model\n").unwrap();
let stale = std::fs::OpenOptions::new()
.write(true)
.open(&stale_path)
.unwrap();
let Ok(()) = stale.set_modified(SystemTime::now() - Duration::from_secs(3600)) else {
return;
};
drop(stale);
fs::write(cache_dir.join("usage.team-a.csv"), "Date,Model\n").unwrap();
assert!(!cursor_usage_cache_is_fresh_in(
temp.path(),
Duration::from_secs(300)
));
}
#[test]
fn test_cursor_usage_cache_is_fresh_returns_false_when_active_cache_missing() {
let temp = tempfile::tempdir().unwrap();
let cache_dir = cursor_cache_dir(temp.path());
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("usage.team-a.csv"), "Date,Model\n").unwrap();
assert!(!cursor_usage_cache_is_fresh_in(
temp.path(),
Duration::from_secs(300)
));
}
#[test]
fn test_cursor_usage_cache_is_fresh_requires_all_expected_account_files() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut accounts = HashMap::new();
accounts.insert(
"active-account".to_string(),
CursorCredentials {
session_token: "token-active".to_string(),
user_id: Some("active-account".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
label: Some("work".to_string()),
},
);
accounts.insert(
"team/account".to_string(),
CursorCredentials {
session_token: "token-secondary".to_string(),
user_id: Some("team/account".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
label: Some("personal".to_string()),
},
);
save_credentials_store_in_home(
temp_dir.path(),
&CursorCredentialsStore {
version: 1,
active_account_id: "active-account".to_string(),
accounts,
},
)?;
let cache_dir = cursor_cache_dir(temp_dir.path());
fs::create_dir_all(&cache_dir)?;
fs::write(cache_dir.join("usage.csv"), "Date,Model\n")?;
assert!(!cursor_usage_cache_is_fresh_in(
temp_dir.path(),
Duration::from_secs(300)
));
fs::write(cache_dir.join("usage.team-account.csv"), "Date,Model\n")?;
assert!(cursor_usage_cache_is_fresh_in(
temp_dir.path(),
Duration::from_secs(300)
));
Ok(())
}
#[test]
fn test_cursor_expected_cache_paths_dedupes_sanitized_account_collisions() {
let temp_dir = TempDir::new().unwrap();
let mut accounts = HashMap::new();
accounts.insert(
"active-account".to_string(),
CursorCredentials {
session_token: "token-active".to_string(),
user_id: Some("active-account".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
label: Some("active".to_string()),
},
);
accounts.insert(
"team/account-a".to_string(),
CursorCredentials {
session_token: "token-team-a".to_string(),
user_id: Some("team/account-a".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
label: Some("team-a".to_string()),
},
);
accounts.insert(
"team@account-a".to_string(),
CursorCredentials {
session_token: "token-team-b".to_string(),
user_id: Some("team@account-a".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
label: Some("team-b".to_string()),
},
);
save_credentials_store_in_home(
temp_dir.path(),
&CursorCredentialsStore {
version: 1,
active_account_id: "active-account".to_string(),
accounts,
},
)
.unwrap();
let paths = expected_cursor_usage_cache_paths_in(temp_dir.path());
let cache_dir = cursor_cache_dir(temp_dir.path());
let expected = vec![
cache_dir.join("usage.csv"),
cache_dir.join("usage.team-account-a.csv"),
];
assert_eq!(paths, expected);
}
#[test]
fn test_count_cursor_csv_rows_valid() {
let csv = "Date,Model,Tokens\n2024-01-01,gpt-4,100\n2024-01-02,gpt-4,200\n";
assert_eq!(count_cursor_csv_rows(csv), 2);
let csv = "Date,Model,Tokens\n2024-01-01,gpt-4,100\n";
assert_eq!(count_cursor_csv_rows(csv), 1);
}
#[test]
fn test_count_cursor_csv_rows_empty() {
let csv = "Date,Model,Tokens\n";
assert_eq!(count_cursor_csv_rows(csv), 0);
let csv = "";
assert_eq!(count_cursor_csv_rows(csv), 0);
}
#[test]
fn test_count_cursor_csv_rows_malformed() {
let csv = "Date,Model,Tokens\n2024-01-01,gpt-4,100\ninvalid,row\n2024-01-02,gpt-4,200\n";
assert_eq!(count_cursor_csv_rows(csv), 3);
}
#[test]
fn test_sync_cursor_cache_writes_active_and_secondary_account_files() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut accounts = HashMap::new();
accounts.insert(
"active-account".to_string(),
CursorCredentials {
session_token: "token-active".to_string(),
user_id: Some("active-account".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
label: Some("work".to_string()),
},
);
accounts.insert(
"team/account".to_string(),
CursorCredentials {
session_token: "token-secondary".to_string(),
user_id: Some("team/account".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
label: Some("personal".to_string()),
},
);
save_credentials_store_in_home(
temp_dir.path(),
&CursorCredentialsStore {
version: 1,
active_account_id: "active-account".to_string(),
accounts,
},
)?;
let runtime = tokio::runtime::Runtime::new()?;
let result = runtime.block_on(sync_cursor_cache_with_fetcher_in_home(
temp_dir.path(),
|session_token| {
let csv = match session_token.as_str() {
"token-active" => "Date,Model,Tokens\n2026-01-01,gpt-5,100\n",
"token-secondary" => {
"Date,Model,Tokens\n2026-01-02,gpt-5,200\n2026-01-03,gpt-5,300\n"
}
_ => "Date,Model,Tokens\n",
}
.to_string();
async move { Ok(csv) }
},
));
assert!(result.synced);
assert_eq!(result.rows, 3);
assert_eq!(result.error, None);
let cache_dir = cursor_cache_dir(temp_dir.path());
assert_eq!(
fs::read_to_string(cache_dir.join("usage.csv"))?,
"Date,Model,Tokens\n2026-01-01,gpt-5,100\n"
);
assert_eq!(
fs::read_to_string(cache_dir.join("usage.team-account.csv"))?,
"Date,Model,Tokens\n2026-01-02,gpt-5,200\n2026-01-03,gpt-5,300\n"
);
assert!(!cache_dir.join("usage.active-account.csv").exists());
Ok(())
}
#[test]
fn test_atomic_write_file_basic() -> Result<()> {
let temp_dir = TempDir::new()?;
let file_path = temp_dir.path().join("test.txt");
let contents = "Hello, world!";
atomic_write_file(&file_path, contents)?;
assert!(file_path.exists());
let read_contents = fs::read_to_string(&file_path)?;
assert_eq!(read_contents, contents);
Ok(())
}
#[test]
fn test_atomic_write_file_creates_parent_dirs() -> Result<()> {
let temp_dir = TempDir::new()?;
let nested_path = temp_dir
.path()
.join("a")
.join("b")
.join("c")
.join("test.txt");
let contents = "Nested file";
atomic_write_file(&nested_path, contents)?;
assert!(nested_path.exists());
let read_contents = fs::read_to_string(&nested_path)?;
assert_eq!(read_contents, contents);
Ok(())
}
#[test]
fn test_atomic_write_file_overwrites_existing() -> Result<()> {
let temp_dir = TempDir::new()?;
let file_path = temp_dir.path().join("test.txt");
atomic_write_file(&file_path, "Initial")?;
assert_eq!(fs::read_to_string(&file_path)?, "Initial");
atomic_write_file(&file_path, "Updated")?;
assert_eq!(fs::read_to_string(&file_path)?, "Updated");
Ok(())
}
#[test]
#[cfg(unix)]
fn test_atomic_write_file_permissions() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new()?;
let file_path = temp_dir.path().join("test.txt");
atomic_write_file(&file_path, "Secret")?;
let metadata = fs::metadata(&file_path)?;
let permissions = metadata.permissions();
assert_eq!(permissions.mode() & 0o777, 0o600);
Ok(())
}
fn setup_two_account_store(home_dir: &std::path::Path) -> Result<()> {
let mut accounts = HashMap::new();
accounts.insert(
"active-account".to_string(),
CursorCredentials {
session_token: "token-active".to_string(),
user_id: Some("active-account".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
label: Some("work".to_string()),
},
);
accounts.insert(
"team/account".to_string(),
CursorCredentials {
session_token: "token-secondary".to_string(),
user_id: Some("team/account".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
label: Some("personal".to_string()),
},
);
save_credentials_store_in_home(
home_dir,
&CursorCredentialsStore {
version: 1,
active_account_id: "active-account".to_string(),
accounts,
},
)
}
fn backdate_file(path: &std::path::Path, secs: u64) -> bool {
let f = match std::fs::OpenOptions::new().write(true).open(path) {
Ok(f) => f,
Err(_) => return false,
};
f.set_modified(SystemTime::now() - Duration::from_secs(secs))
.is_ok()
}
#[test]
fn test_freshness_gate_passes_when_active_fresh_and_marker_fresh_despite_stale_secondary(
) -> Result<()> {
let temp_dir = TempDir::new()?;
setup_two_account_store(temp_dir.path())?;
let cache_dir = cursor_cache_dir(temp_dir.path());
fs::create_dir_all(&cache_dir)?;
fs::write(cache_dir.join("usage.csv"), "Date,Model\n")?;
let secondary = cache_dir.join("usage.team-account.csv");
fs::write(&secondary, "Date,Model\n")?;
if !backdate_file(&secondary, 3600) {
return Ok(()); }
fs::write(cache_dir.join(CURSOR_SYNC_ATTEMPT_MARKER), "")?;
assert!(
cursor_usage_cache_is_fresh_in(temp_dir.path(), Duration::from_secs(300)),
"fresh marker should short-circuit stale secondary"
);
Ok(())
}
#[test]
fn test_freshness_gate_fails_when_active_fresh_but_no_marker_and_stale_secondary() -> Result<()>
{
let temp_dir = TempDir::new()?;
setup_two_account_store(temp_dir.path())?;
let cache_dir = cursor_cache_dir(temp_dir.path());
fs::create_dir_all(&cache_dir)?;
fs::write(cache_dir.join("usage.csv"), "Date,Model\n")?;
let secondary = cache_dir.join("usage.team-account.csv");
fs::write(&secondary, "Date,Model\n")?;
if !backdate_file(&secondary, 3600) {
return Ok(());
}
assert!(
!cursor_usage_cache_is_fresh_in(temp_dir.path(), Duration::from_secs(300)),
"without marker, stale secondary should trigger sync"
);
Ok(())
}
#[test]
fn test_freshness_gate_fails_when_active_stale_even_with_fresh_marker() -> Result<()> {
let temp_dir = TempDir::new()?;
setup_two_account_store(temp_dir.path())?;
let cache_dir = cursor_cache_dir(temp_dir.path());
fs::create_dir_all(&cache_dir)?;
let active = cache_dir.join("usage.csv");
fs::write(&active, "Date,Model\n")?;
if !backdate_file(&active, 3600) {
return Ok(());
}
fs::write(cache_dir.join("usage.team-account.csv"), "Date,Model\n")?;
fs::write(cache_dir.join(CURSOR_SYNC_ATTEMPT_MARKER), "")?;
assert!(
!cursor_usage_cache_is_fresh_in(temp_dir.path(), Duration::from_secs(300)),
"stale active cache must always trigger sync regardless of marker"
);
Ok(())
}
#[test]
fn test_sync_writes_attempt_marker() -> Result<()> {
let temp_dir = TempDir::new()?;
setup_two_account_store(temp_dir.path())?;
let runtime = tokio::runtime::Runtime::new()?;
let _result = runtime.block_on(sync_cursor_cache_with_fetcher_in_home(
temp_dir.path(),
|session_token| {
let result: Result<String> = match session_token.as_str() {
"token-active" => Ok("Date,Model,Tokens\n2026-01-01,gpt-5,10\n".to_string()),
_ => Err(anyhow::anyhow!("simulated fetch failure")),
};
async move { result }
},
));
let cache_dir = cursor_cache_dir(temp_dir.path());
assert!(
cache_dir.join(CURSOR_SYNC_ATTEMPT_MARKER).exists(),
"marker must be written even when a secondary account fetch fails"
);
Ok(())
}
}