cursor-helper 0.3.0

CLI helper for Cursor IDE operations not exposed in the UI
Documentation
//! Shared utilities for commands

use anyhow::{Context, Result};
use fs_extra::dir::{self, CopyOptions};
use percent_encoding::percent_decode_str;
use std::fs;
use std::path::{Path, PathBuf};

use crate::cursor::workspace;

/// Format bytes as human-readable size
pub fn format_size(bytes: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = KB * 1024;
    const GB: u64 = MB * 1024;

    if bytes >= GB {
        format!("{:.1} GB", bytes as f64 / GB as f64)
    } else if bytes >= MB {
        format!("{:.1} MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.1} KB", bytes as f64 / KB as f64)
    } else {
        format!("{} B", bytes)
    }
}

/// Strip Windows extended-length path prefix (\\?\)
///
/// On Windows, `canonicalize()` returns paths like `\\?\C:\path` which don't
/// match Cursor's stored paths and display poorly. This strips the prefix.
pub fn strip_windows_prefix(path: &Path) -> PathBuf {
    let path_str = path.to_string_lossy();
    if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
        PathBuf::from(stripped)
    } else {
        path.to_path_buf()
    }
}

/// Copy a directory recursively using fs_extra
pub fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
    let options = CopyOptions::new().copy_inside(true);
    dir::copy(src, dst, &options)
        .with_context(|| format!("Failed to copy {} to {}", src.display(), dst.display()))?;
    Ok(())
}

/// Copy directory contents into an existing directory (merge)
pub fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
    let options = CopyOptions::new().content_only(true).overwrite(true);
    dir::copy(src, dst, &options).with_context(|| {
        format!(
            "Failed to copy contents of {} to {}",
            src.display(),
            dst.display()
        )
    })?;
    Ok(())
}

/// Count stable sessions when discovery data is available.
pub fn count_chat_sessions_if_available(workspace_dir: &Path) -> Result<Option<usize>> {
    crate::cursor::chat_sessions::count_workspace_sessions_if_available(workspace_dir, false)
}

/// Calculate total size of a directory
pub fn calculate_dir_size(path: &Path) -> Result<u64> {
    let mut total = 0;

    for entry in fs::read_dir(path)?.flatten() {
        let metadata = entry.metadata()?;
        if metadata.is_file() {
            total += metadata.len();
        } else if metadata.is_dir() {
            total += calculate_dir_size(&entry.path()).unwrap_or(0);
        }
    }

    Ok(total)
}

/// Find workspace storage directory for a project path
///
/// Supports both local paths and remote paths:
/// - Local: matches file:// URLs in workspace.json
/// - Remote: if path doesn't exist locally, searches vscode-remote:// URLs for matching path component
pub fn find_workspace_dir(project_path: &Path) -> Result<Option<std::path::PathBuf>> {
    let workspace_storage_dir = crate::config::workspace_storage_dir()?;
    find_workspace_dir_in(&workspace_storage_dir, project_path)
}

fn find_workspace_dir_in(
    workspace_storage_dir: &Path,
    project_path: &Path,
) -> Result<Option<std::path::PathBuf>> {
    if !workspace_storage_dir.exists() {
        return Ok(None);
    }

    // Try local path first
    if project_path.exists() {
        let project_uri = url::Url::from_file_path(project_path)
            .map_err(|_| anyhow::anyhow!("Invalid project path"))?
            .to_string();
        let project_uri_normalized = normalize_uri_for_comparison(&project_uri);

        // Scan workspace storage for matching local project
        for entry in fs::read_dir(workspace_storage_dir)?.flatten() {
            if !entry.file_type()?.is_dir() {
                continue;
            }

            if let Some(target_uri) = workspace::read_workspace_target_uri(&entry.path())? {
                let target_normalized = normalize_uri_for_comparison(&target_uri);
                if target_normalized == project_uri_normalized {
                    return Ok(Some(entry.path()));
                }
            }
        }
    }

    // Path doesn't exist locally - search for matching remote workspace
    // The path might be a remote path like /home/user/project
    let search_path = project_path.to_string_lossy();
    let search_path_normalized = search_path.trim_end_matches('/');

    for entry in fs::read_dir(workspace_storage_dir)?.flatten() {
        if !entry.file_type()?.is_dir() {
            continue;
        }

        if let Some(target_uri) = workspace::read_workspace_target_uri(&entry.path())? {
            // Check if this is a remote URL and extract the path
            if let Ok(url) = url::Url::parse(&target_uri)
                && url.scheme() == "vscode-remote"
            {
                // Extract path from remote URL and compare
                let remote_path = url.path().trim_end_matches('/');
                if remote_path == search_path_normalized {
                    return Ok(Some(entry.path()));
                }
                // Also try matching just the final component (project name)
                if let Some(remote_name) = remote_path.rsplit('/').next()
                    && let Some(search_name) = search_path_normalized.rsplit(['/', '\\']).next()
                    && remote_name == search_name
                    && !remote_name.is_empty()
                {
                    return Ok(Some(entry.path()));
                }
            }
        }
    }

    Ok(None)
}

/// Normalize a file URI for comparison
///
/// Handles cross-platform differences:
/// - Trims trailing slashes
/// - Lowercases for case-insensitive comparison (Windows drive letters)
/// - Decodes %3A to : (Cursor's encoding vs Url::from_file_path)
///
/// This normalization is safe to apply on all platforms.
fn normalize_uri_for_comparison(uri: &str) -> String {
    let trimmed = uri.trim_end_matches('/');
    let Some((scheme, authority, path)) = split_uri(trimmed) else {
        return normalize_workspace_path(trimmed);
    };

    let path = normalize_workspace_path(&path);

    if authority.is_empty() {
        format!("{}://{}", scheme.to_ascii_lowercase(), path)
    } else {
        format!("{}://{}{}", scheme.to_ascii_lowercase(), authority, path)
    }
}

fn normalize_workspace_path(path: &str) -> String {
    let trimmed = path
        .trim_end_matches('/')
        .replace("%3A", ":")
        .replace("%3a", ":");
    let decoded = percent_decode_str(&trimmed).decode_utf8_lossy();
    normalize_drive_letter(&decoded)
}

fn split_uri(uri: &str) -> Option<(&str, String, String)> {
    let (scheme, rest) = uri.split_once("://")?;
    let (authority, path) = match rest.find('/') {
        Some(index) => (&rest[..index], &rest[index..]),
        None => (rest, ""),
    };

    Some((scheme, authority.to_string(), path.to_string()))
}

fn normalize_drive_letter(path: &str) -> String {
    let mut chars: Vec<char> = path.chars().collect();

    let drive_index = match chars.as_slice() {
        ['/', drive, ':', '/', ..] if drive.is_ascii_alphabetic() => Some(1),
        [drive, ':', '/', ..] if drive.is_ascii_alphabetic() => Some(0),
        _ => None,
    };

    if let Some(index) = drive_index {
        chars[index] = chars[index].to_ascii_lowercase();
        chars.into_iter().collect()
    } else {
        path.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_format_size() {
        assert_eq!(format_size(0), "0 B");
        assert_eq!(format_size(512), "512 B");
        assert_eq!(format_size(1024), "1.0 KB");
        assert_eq!(format_size(1536), "1.5 KB");
        assert_eq!(format_size(1024 * 1024), "1.0 MB");
        assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
    }

    #[test]
    fn test_strip_windows_prefix() {
        // Extended-length path prefix should be stripped
        let result = strip_windows_prefix(Path::new(r"\\?\C:\path\to\project"));
        assert_eq!(result, PathBuf::from(r"C:\path\to\project"));
    }

    #[test]
    fn test_strip_windows_prefix_no_prefix() {
        // Paths without prefix should be unchanged
        let result = strip_windows_prefix(Path::new(r"C:\path\to\project"));
        assert_eq!(result, PathBuf::from(r"C:\path\to\project"));
    }

    #[test]
    fn test_strip_windows_prefix_unix() {
        // Unix paths should be unchanged
        let result = strip_windows_prefix(Path::new("/path/to/project"));
        assert_eq!(result, PathBuf::from("/path/to/project"));
    }

    #[test]
    fn test_normalize_uri_case_insensitive() {
        // URIs should match regardless of drive letter case
        let upper = normalize_uri_for_comparison("file:///C:/path/to/project");
        let lower = normalize_uri_for_comparison("file:///c:/path/to/project");
        assert_eq!(upper, lower);
    }

    #[test]
    fn test_normalize_uri_percent_encoding() {
        // Cursor stores %3A for colon, Url::from_file_path uses :
        let encoded = normalize_uri_for_comparison("file:///c%3A/path/to/project");
        let decoded = normalize_uri_for_comparison("file:///c:/path/to/project");
        assert_eq!(encoded, decoded);
    }

    #[test]
    fn test_normalize_uri_trailing_slash() {
        let with_slash = normalize_uri_for_comparison("file:///c:/path/");
        let without_slash = normalize_uri_for_comparison("file:///c:/path");
        assert_eq!(with_slash, without_slash);
    }

    #[test]
    fn test_normalize_uri_unix_paths() {
        // Unix paths should preserve case
        let result = normalize_uri_for_comparison("file:///Users/me/project/");
        assert_eq!(result, "file:///Users/me/project");
    }

    #[test]
    fn test_normalize_uri_preserves_case_for_posix_paths() {
        assert_eq!(
            normalize_uri_for_comparison("file:///tmp/Project"),
            "file:///tmp/Project"
        );
        assert_ne!(
            normalize_uri_for_comparison("file:///tmp/Project"),
            normalize_uri_for_comparison("file:///tmp/project")
        );
    }

    #[test]
    fn test_find_workspace_dir_nonexistent() {
        // Non-existent path should return None, not error
        let result = find_workspace_dir(Path::new("/nonexistent/path/that/does/not/exist"));
        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[test]
    fn test_find_workspace_dir_matches_multi_root_workspace_file() {
        let temp_dir = TempDir::new().unwrap();
        let workspace_storage = temp_dir.path().join("workspaceStorage");
        let workspace_dir = workspace_storage.join("abc123");
        let workspace_file = temp_dir.path().join("project.code-workspace");

        fs::create_dir_all(&workspace_dir).unwrap();
        fs::write(&workspace_file, "{}\n").unwrap();
        fs::write(
            workspace_dir.join("workspace.json"),
            format!(
                r#"{{"workspace":"{}"}}"#,
                url::Url::from_file_path(&workspace_file).unwrap()
            ),
        )
        .unwrap();

        let result = find_workspace_dir_in(&workspace_storage, &workspace_file);

        assert_eq!(result.unwrap(), Some(workspace_dir));
    }
}