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;
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)
}
}
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()
}
}
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(())
}
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(())
}
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)
}
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)
}
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);
}
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);
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()));
}
}
}
}
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())? {
if let Ok(url) = url::Url::parse(&target_uri)
&& url.scheme() == "vscode-remote"
{
let remote_path = url.path().trim_end_matches('/');
if remote_path == search_path_normalized {
return Ok(Some(entry.path()));
}
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)
}
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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));
}
}