generated from nhcarrigan/template
1d94bdfbb0
Implemented a confirmation modal when users try to close the application: - Modal always shows with three options: Cancel, Minimize to Tray, Close Application - Detects if Claude is actively running and shows appropriate warning message - Removed minimize_to_tray config setting (no longer needed) - Added core:window:allow-hide permission for window hiding - Created CloseAppConfirmModal component with keyboard shortcuts (Escape to cancel) - Added close_application command to properly exit the app - Backend emits window-close-requested event for frontend to handle This provides better UX by giving users clear choices every time they close, preventing accidental closures during active work sessions.
1432 lines
44 KiB
Rust
1432 lines
44 KiB
Rust
use std::path::PathBuf;
|
|
use tauri::{AppHandle, Manager, State};
|
|
use tauri_plugin_http::reqwest;
|
|
use tauri_plugin_store::StoreExt;
|
|
|
|
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
|
|
use crate::bridge_manager::SharedBridgeManager;
|
|
use crate::config::{ClaudeStartOptions, HikariConfig};
|
|
use crate::stats::UsageStats;
|
|
use crate::temp_manager::SharedTempFileManager;
|
|
|
|
const CONFIG_STORE_KEY: &str = "config";
|
|
|
|
/// Convert a Windows path to a WSL path
|
|
/// Example: C:\Users\accou\Documents\item.txt -> /mnt/c/Users/accou/Documents/item.txt
|
|
fn windows_path_to_wsl(windows_path: &str) -> Option<String> {
|
|
// Check if it's a Windows path (has drive letter like C:\)
|
|
if windows_path.len() >= 3 && windows_path.chars().nth(1) == Some(':') {
|
|
let drive_letter = windows_path.chars().next()?.to_lowercase().to_string();
|
|
let path_without_drive = &windows_path[2..]; // Remove "C:"
|
|
|
|
// Replace backslashes with forward slashes and convert to WSL mount point
|
|
let wsl_path = path_without_drive.replace('\\', "/");
|
|
Some(format!("/mnt/{}{}", drive_letter, wsl_path))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Convert a WSL path to a Windows path
|
|
/// Example: /mnt/c/Users/accou/Documents/item.txt -> C:\Users\accou\Documents\item.txt
|
|
#[allow(dead_code)]
|
|
fn wsl_path_to_windows(wsl_path: &str) -> Option<String> {
|
|
// Check if it's a WSL mount point path
|
|
if wsl_path.starts_with("/mnt/") && wsl_path.len() > 6 {
|
|
let rest = &wsl_path[5..]; // Remove "/mnt/"
|
|
if let Some(drive_letter) = rest.chars().next() {
|
|
let path_after_drive = &rest[1..]; // Remove drive letter
|
|
|
|
// Convert to Windows path with backslashes
|
|
let windows_path = path_after_drive.replace('/', "\\");
|
|
Some(format!("{}:{}", drive_letter.to_uppercase(), windows_path))
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn start_claude(
|
|
bridge_manager: State<'_, SharedBridgeManager>,
|
|
conversation_id: String,
|
|
options: ClaudeStartOptions,
|
|
) -> Result<(), String> {
|
|
let mut manager = bridge_manager.lock();
|
|
manager.start_claude(&conversation_id, options)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stop_claude(
|
|
bridge_manager: State<'_, SharedBridgeManager>,
|
|
conversation_id: String,
|
|
) -> Result<(), String> {
|
|
let mut manager = bridge_manager.lock();
|
|
manager.stop_claude(&conversation_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn interrupt_claude(
|
|
bridge_manager: State<'_, SharedBridgeManager>,
|
|
conversation_id: String,
|
|
) -> Result<(), String> {
|
|
let mut manager = bridge_manager.lock();
|
|
manager.interrupt_claude(&conversation_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn send_prompt(
|
|
bridge_manager: State<'_, SharedBridgeManager>,
|
|
conversation_id: String,
|
|
message: String,
|
|
) -> Result<(), String> {
|
|
let mut manager = bridge_manager.lock();
|
|
manager.send_prompt(&conversation_id, message)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn is_claude_running(
|
|
bridge_manager: State<'_, SharedBridgeManager>,
|
|
conversation_id: String,
|
|
) -> Result<bool, String> {
|
|
let manager = bridge_manager.lock();
|
|
Ok(manager.is_claude_running(&conversation_id))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_working_directory(
|
|
bridge_manager: State<'_, SharedBridgeManager>,
|
|
conversation_id: String,
|
|
) -> Result<String, String> {
|
|
let manager = bridge_manager.lock();
|
|
manager.get_working_directory(&conversation_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn select_wsl_directory() -> Result<String, String> {
|
|
// Return the user's home directory cross-platform
|
|
dirs::home_dir()
|
|
.ok_or_else(|| "Could not determine home directory".to_string())
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
|
|
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
|
|
|
|
match store.get(CONFIG_STORE_KEY) {
|
|
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
|
None => Ok(HikariConfig::default()),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
|
|
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
|
|
|
|
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
|
|
store.set(CONFIG_STORE_KEY, value);
|
|
store.save().map_err(|e| e.to_string())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_usage_stats(
|
|
bridge_manager: State<'_, SharedBridgeManager>,
|
|
conversation_id: String,
|
|
) -> Result<UsageStats, String> {
|
|
let manager = bridge_manager.lock();
|
|
manager.get_usage_stats(&conversation_id)
|
|
}
|
|
|
|
/// Load persisted lifetime stats from store (no bridge required)
|
|
#[tauri::command]
|
|
pub async fn get_persisted_stats(app: AppHandle) -> Result<UsageStats, String> {
|
|
let mut stats = UsageStats::new();
|
|
|
|
// Load persisted stats if available
|
|
if let Some(persisted) = crate::stats::load_stats(&app).await {
|
|
stats.apply_persisted(persisted);
|
|
}
|
|
|
|
Ok(stats)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn validate_directory(
|
|
path: String,
|
|
current_dir: Option<String>,
|
|
) -> Result<String, String> {
|
|
use std::path::{Path, PathBuf};
|
|
|
|
// Detect if we're dealing with a WSL path (starts with / on Windows, or current_dir is a WSL path)
|
|
let is_wsl_path = cfg!(windows) && (path.starts_with('/') || current_dir.as_ref().is_some_and(|p| p.starts_with('/')));
|
|
|
|
if is_wsl_path {
|
|
// WSL path - handle as Unix-style path without filesystem validation
|
|
// since the Windows binary can't validate WSL filesystem paths
|
|
let resolved = if path.starts_with('/') {
|
|
// Absolute WSL path - use as-is
|
|
path
|
|
} else if let Some(ref cwd) = current_dir {
|
|
// Relative path - resolve manually using Unix path logic
|
|
if path == "." {
|
|
cwd.clone()
|
|
} else if path == ".." {
|
|
// Go up one directory
|
|
cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/").to_string()
|
|
} else if path.starts_with("../") {
|
|
// Handle ../ prefix
|
|
let parent = cwd.rsplit_once('/').map(|x| x.0).unwrap_or("/");
|
|
let remainder = path.strip_prefix("../").unwrap();
|
|
if remainder.is_empty() {
|
|
parent.to_string()
|
|
} else {
|
|
format!("{}/{}", parent, remainder)
|
|
}
|
|
} else if path.starts_with("./") {
|
|
// Handle ./ prefix
|
|
format!("{}/{}", cwd, path.strip_prefix("./").unwrap())
|
|
} else {
|
|
// Regular relative path
|
|
format!("{}/{}", cwd, path)
|
|
}
|
|
} else {
|
|
return Err("Cannot resolve relative WSL path without current directory".to_string());
|
|
};
|
|
|
|
// Normalize the path (remove duplicate slashes, etc.)
|
|
let normalized = resolved.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("/");
|
|
Ok(if normalized.is_empty() { "/".to_string() } else { format!("/{}", normalized) })
|
|
} else {
|
|
// Native path (Windows on Windows, Unix on Unix) - validate normally
|
|
let path = Path::new(&path);
|
|
|
|
let expanded_path = if path.starts_with("~") {
|
|
if let Some(home) = dirs::home_dir() {
|
|
if path == Path::new("~") {
|
|
home
|
|
} else {
|
|
home.join(path.strip_prefix("~").unwrap())
|
|
}
|
|
} else {
|
|
return Err("Could not determine home directory".to_string());
|
|
}
|
|
} else if path.is_relative() {
|
|
if let Some(ref cwd) = current_dir {
|
|
let cwd_path = PathBuf::from(cwd);
|
|
cwd_path.join(path)
|
|
} else {
|
|
path.to_path_buf()
|
|
}
|
|
} else {
|
|
path.to_path_buf()
|
|
};
|
|
|
|
// Check if the path exists and is a directory
|
|
if !expanded_path.exists() {
|
|
return Err(format!(
|
|
"Directory does not exist: {}",
|
|
expanded_path.display()
|
|
));
|
|
}
|
|
|
|
if !expanded_path.is_dir() {
|
|
return Err(format!(
|
|
"Path is not a directory: {}",
|
|
expanded_path.display()
|
|
));
|
|
}
|
|
|
|
// Return the canonicalized (absolute) path
|
|
expanded_path
|
|
.canonicalize()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.map_err(|e| format!("Failed to resolve path: {}", e))
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn load_saved_achievements(
|
|
app: AppHandle,
|
|
) -> Result<Vec<AchievementUnlockedEvent>, String> {
|
|
use chrono::Utc;
|
|
|
|
// Load achievements from persistent store
|
|
let progress = load_achievements(&app).await;
|
|
|
|
// Create events for all previously unlocked achievements
|
|
let mut events = Vec::new();
|
|
for achievement_id in &progress.unlocked {
|
|
let mut info = get_achievement_info(achievement_id);
|
|
info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now
|
|
events.push(AchievementUnlockedEvent { achievement: info });
|
|
}
|
|
|
|
Ok(events)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn answer_question(
|
|
bridge_manager: State<'_, SharedBridgeManager>,
|
|
conversation_id: String,
|
|
tool_use_id: String,
|
|
answers: serde_json::Value,
|
|
) -> Result<(), String> {
|
|
let mut manager = bridge_manager.lock();
|
|
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_skills() -> Result<Vec<String>, String> {
|
|
// On Windows, we need to use WSL to access the skills directory
|
|
// since skills are stored in the WSL home directory
|
|
if cfg!(windows) {
|
|
return list_skills_via_wsl().await;
|
|
}
|
|
|
|
// On Unix systems, use the native filesystem
|
|
use std::fs;
|
|
|
|
let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?;
|
|
let skills_dir = home.join(".claude").join("skills");
|
|
|
|
if !skills_dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut skills = Vec::new();
|
|
let entries =
|
|
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
|
|
|
|
for entry in entries {
|
|
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
|
let path = entry.path();
|
|
|
|
if path.is_dir() {
|
|
let skill_file = path.join("SKILL.md");
|
|
if skill_file.exists() {
|
|
if let Some(name) = path.file_name() {
|
|
skills.push(name.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
skills.sort();
|
|
Ok(skills)
|
|
}
|
|
|
|
/// List skills by executing commands through WSL (for Windows)
|
|
#[allow(dead_code)]
|
|
async fn list_skills_via_wsl() -> Result<Vec<String>, String> {
|
|
use std::process::Command;
|
|
|
|
// Use WSL to list directories in ~/.claude/skills that contain SKILL.md
|
|
let output = Command::new("wsl")
|
|
.args([
|
|
"-e",
|
|
"sh",
|
|
"-c",
|
|
"if [ -d ~/.claude/skills ]; then for d in ~/.claude/skills/*/; do [ -f \"${d}SKILL.md\" ] && basename \"$d\"; done; fi",
|
|
])
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
if stderr.contains("not found") || stderr.contains("No such file") {
|
|
return Ok(Vec::new());
|
|
}
|
|
return Err(format!("WSL command failed: {}", stderr));
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let mut skills: Vec<String> = stdout
|
|
.lines()
|
|
.filter(|line| !line.is_empty())
|
|
.map(|line| line.to_string())
|
|
.collect();
|
|
|
|
skills.sort();
|
|
Ok(skills)
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct UpdateInfo {
|
|
pub current_version: String,
|
|
pub latest_version: String,
|
|
pub has_update: bool,
|
|
pub release_url: String,
|
|
pub release_notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct GiteaRelease {
|
|
tag_name: String,
|
|
html_url: String,
|
|
body: Option<String>,
|
|
prerelease: bool,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
|
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
const RELEASES_API: &str =
|
|
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
|
|
|
|
// Fetch releases from Gitea API
|
|
let client = reqwest::Client::new();
|
|
let response = client
|
|
.get(RELEASES_API)
|
|
.header("Accept", "application/json")
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!("API returned status: {}", response.status()));
|
|
}
|
|
|
|
let text = response
|
|
.text()
|
|
.await
|
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
|
|
|
let releases: Vec<GiteaRelease> =
|
|
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
|
|
|
|
// Find the latest non-prerelease, or fall back to latest prerelease
|
|
let latest = releases
|
|
.iter()
|
|
.find(|r| !r.prerelease)
|
|
.or_else(|| releases.first());
|
|
|
|
let latest = match latest {
|
|
Some(r) => r,
|
|
None => return Err("No releases found".to_string()),
|
|
};
|
|
|
|
// Parse version strings (remove 'v' prefix if present)
|
|
let current = semver::Version::parse(CURRENT_VERSION)
|
|
.map_err(|e| format!("Failed to parse current version: {}", e))?;
|
|
|
|
let latest_tag = latest.tag_name.trim_start_matches('v');
|
|
let latest_ver = semver::Version::parse(latest_tag)
|
|
.map_err(|e| format!("Failed to parse latest version: {}", e))?;
|
|
|
|
Ok(UpdateInfo {
|
|
current_version: CURRENT_VERSION.to_string(),
|
|
latest_version: latest.tag_name.clone(),
|
|
has_update: latest_ver > current,
|
|
release_url: latest.html_url.clone(),
|
|
release_notes: latest.body.clone(),
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct SavedFileInfo {
|
|
pub path: String,
|
|
pub filename: String,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn save_temp_file(
|
|
temp_manager: State<'_, SharedTempFileManager>,
|
|
conversation_id: String,
|
|
data: Vec<u8>,
|
|
filename: Option<String>,
|
|
) -> Result<SavedFileInfo, String> {
|
|
let mut manager = temp_manager.lock();
|
|
let path = manager.save_file(&conversation_id, &data, filename.as_deref())?;
|
|
|
|
let filename = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
|
|
let path_string = path.to_string_lossy().to_string();
|
|
|
|
// On Windows, convert the path to WSL format if needed
|
|
// so Claude Code (running in WSL) can access it via /mnt/c/...
|
|
let final_path = if cfg!(windows) {
|
|
windows_path_to_wsl(&path_string).unwrap_or(path_string)
|
|
} else {
|
|
path_string
|
|
};
|
|
|
|
Ok(SavedFileInfo {
|
|
path: final_path,
|
|
filename,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn register_temp_file(
|
|
temp_manager: State<'_, SharedTempFileManager>,
|
|
conversation_id: String,
|
|
file_path: String,
|
|
) -> Result<(), String> {
|
|
let mut manager = temp_manager.lock();
|
|
manager.register_file(&conversation_id, PathBuf::from(file_path));
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_temp_files(
|
|
temp_manager: State<'_, SharedTempFileManager>,
|
|
conversation_id: String,
|
|
) -> Result<Vec<String>, String> {
|
|
let manager = temp_manager.lock();
|
|
let files = manager.get_files_for_conversation(&conversation_id);
|
|
Ok(files.iter().map(|p| p.to_string_lossy().to_string()).collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cleanup_temp_files(
|
|
temp_manager: State<'_, SharedTempFileManager>,
|
|
conversation_id: String,
|
|
) -> Result<(), String> {
|
|
let mut manager = temp_manager.lock();
|
|
manager.cleanup_conversation(&conversation_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cleanup_all_temp_files(
|
|
temp_manager: State<'_, SharedTempFileManager>,
|
|
) -> Result<(), String> {
|
|
let mut manager = temp_manager.lock();
|
|
manager.cleanup_all()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cleanup_orphaned_temp_files(
|
|
temp_manager: State<'_, SharedTempFileManager>,
|
|
) -> Result<usize, String> {
|
|
let mut manager = temp_manager.lock();
|
|
manager.cleanup_orphaned_files()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
|
|
let metadata = std::fs::metadata(&file_path)
|
|
.map_err(|e| format!("Failed to get file metadata: {}", e))?;
|
|
Ok(metadata.len())
|
|
}
|
|
|
|
// ==================== Editor File Operations ====================
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct FileEntry {
|
|
pub name: String,
|
|
pub path: String,
|
|
#[serde(rename = "isDirectory")]
|
|
pub is_directory: bool,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_directory(app: AppHandle, path: String) -> Result<Vec<FileEntry>, String> {
|
|
// Set up logging
|
|
let log_path = if let Ok(app_data_dir) = app.path().app_data_dir() {
|
|
let _ = std::fs::create_dir_all(&app_data_dir);
|
|
app_data_dir.join("hikari_editor_debug.log")
|
|
} else {
|
|
PathBuf::from("hikari_editor_debug.log")
|
|
};
|
|
|
|
let mut log_file = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&log_path)
|
|
.ok();
|
|
|
|
let mut log = |msg: String| {
|
|
if let Some(ref mut file) = log_file {
|
|
use std::io::Write;
|
|
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
|
let _ = writeln!(file, "[{}] {}", timestamp, msg);
|
|
}
|
|
};
|
|
|
|
log(format!("list_directory called with path: {}", path));
|
|
log(format!("cfg!(windows) = {}", cfg!(windows)));
|
|
log(format!("path.starts_with('/') = {}", path.starts_with('/')));
|
|
|
|
// On Windows with a WSL path (starts with /), use WSL to list the directory
|
|
if cfg!(windows) && path.starts_with('/') {
|
|
log("Using WSL path".to_string());
|
|
return list_directory_via_wsl(&path).await;
|
|
}
|
|
|
|
log("Using native filesystem access".to_string());
|
|
|
|
// Native filesystem access
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
let dir_path = Path::new(&path);
|
|
|
|
if !dir_path.exists() {
|
|
let err = format!("Directory does not exist: {}", path);
|
|
log(format!("ERROR: {}", err));
|
|
return Err(err);
|
|
}
|
|
|
|
if !dir_path.is_dir() {
|
|
let err = format!("Path is not a directory: {}", path);
|
|
log(format!("ERROR: {}", err));
|
|
return Err(err);
|
|
}
|
|
|
|
let entries = fs::read_dir(dir_path)
|
|
.map_err(|e| {
|
|
let err = format!("Failed to read directory: {}", e);
|
|
log(format!("ERROR: {}", err));
|
|
err
|
|
})?;
|
|
|
|
let mut file_entries = Vec::new();
|
|
|
|
for entry in entries {
|
|
let entry = entry.map_err(|e| {
|
|
let err = format!("Failed to read entry: {}", e);
|
|
log(format!("ERROR: {}", err));
|
|
err
|
|
})?;
|
|
let path = entry.path();
|
|
let name = entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
|
|
file_entries.push(FileEntry {
|
|
name: name.clone(),
|
|
path: path.to_string_lossy().to_string(),
|
|
is_directory: path.is_dir(),
|
|
});
|
|
}
|
|
|
|
log(format!("Successfully listed {} entries", file_entries.len()));
|
|
Ok(file_entries)
|
|
}
|
|
|
|
/// List directory contents via WSL (for Windows with WSL paths)
|
|
#[allow(dead_code)]
|
|
async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
|
|
use std::process::Command;
|
|
|
|
// Use WSL to list directory contents
|
|
// Output format: type<tab>name (d for directory, f for file)
|
|
let script = format!(
|
|
r#"if [ -d '{}' ]; then for f in '{}'/* '{}'/.* ; do [ -e "$f" ] || continue; name=$(basename "$f"); if [ "$name" = "." ] || [ "$name" = ".." ]; then continue; fi; if [ -d "$f" ]; then echo "d $name"; else echo "f $name"; fi; done; else echo "ERROR: Directory does not exist"; exit 1; fi"#,
|
|
path, path, path
|
|
);
|
|
|
|
let output = Command::new("wsl")
|
|
.args(["-e", "sh", "-c", &script])
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
if !output.status.success() || stdout.starts_with("ERROR:") {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
if stdout.starts_with("ERROR:") {
|
|
return Err(stdout.trim().to_string());
|
|
}
|
|
return Err(format!("WSL command failed: {}", stderr));
|
|
}
|
|
|
|
let mut file_entries = Vec::new();
|
|
|
|
for line in stdout.lines() {
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let parts: Vec<&str> = line.splitn(2, '\t').collect();
|
|
if parts.len() != 2 {
|
|
continue;
|
|
}
|
|
|
|
let is_directory = parts[0] == "d";
|
|
let name = parts[1].to_string();
|
|
let entry_path = if path == "/" {
|
|
format!("/{}", name)
|
|
} else {
|
|
format!("{}/{}", path, name)
|
|
};
|
|
|
|
file_entries.push(FileEntry {
|
|
name,
|
|
path: entry_path,
|
|
is_directory,
|
|
});
|
|
}
|
|
|
|
Ok(file_entries)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn read_file_content(path: String) -> Result<String, String> {
|
|
// On Windows with a WSL path, use WSL to read the file
|
|
if cfg!(windows) && path.starts_with('/') {
|
|
return read_file_via_wsl(&path).await;
|
|
}
|
|
|
|
use std::fs;
|
|
fs::read_to_string(&path)
|
|
.map_err(|e| format!("Failed to read file: {}", e))
|
|
}
|
|
|
|
/// Read file content via WSL (for Windows with WSL paths)
|
|
#[allow(dead_code)]
|
|
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
|
use std::process::Command;
|
|
|
|
let output = Command::new("wsl")
|
|
.args(["-e", "cat", path])
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("Failed to read file: {}", stderr));
|
|
}
|
|
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn write_file_content(path: String, content: String) -> Result<(), String> {
|
|
// On Windows with a WSL path, use WSL to write the file
|
|
if cfg!(windows) && path.starts_with('/') {
|
|
return write_file_via_wsl(&path, &content).await;
|
|
}
|
|
|
|
use std::fs;
|
|
fs::write(&path, content)
|
|
.map_err(|e| format!("Failed to write file: {}", e))
|
|
}
|
|
|
|
/// Write file content via WSL (for Windows with WSL paths)
|
|
#[allow(dead_code)]
|
|
async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> {
|
|
use std::io::Write;
|
|
use std::process::{Command, Stdio};
|
|
|
|
let mut child = Command::new("wsl")
|
|
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
|
|
.stdin(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if let Some(mut stdin) = child.stdin.take() {
|
|
stdin.write_all(content.as_bytes())
|
|
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
|
}
|
|
|
|
let status = child.wait()
|
|
.map_err(|e| format!("Failed to wait for WSL command: {}", e))?;
|
|
|
|
if !status.success() {
|
|
return Err("Failed to write file via WSL".to_string());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_file(path: String) -> Result<(), String> {
|
|
// On Windows with a WSL path, use WSL to create the file
|
|
if cfg!(windows) && path.starts_with('/') {
|
|
return create_file_via_wsl(&path).await;
|
|
}
|
|
|
|
use std::fs::File;
|
|
use std::path::Path;
|
|
|
|
let file_path = Path::new(&path);
|
|
|
|
if file_path.exists() {
|
|
return Err("File already exists".to_string());
|
|
}
|
|
|
|
File::create(file_path).map_err(|e| format!("Failed to create file: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create file via WSL (for Windows with WSL paths)
|
|
#[allow(dead_code)]
|
|
async fn create_file_via_wsl(path: &str) -> Result<(), String> {
|
|
use std::process::Command;
|
|
|
|
// Check if file exists first
|
|
let check = Command::new("wsl")
|
|
.args(["-e", "test", "-e", path])
|
|
.status()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if check.success() {
|
|
return Err("File already exists".to_string());
|
|
}
|
|
|
|
let output = Command::new("wsl")
|
|
.args(["-e", "touch", path])
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("Failed to create file: {}", stderr));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_directory(path: String) -> Result<(), String> {
|
|
// On Windows with a WSL path, use WSL to create the directory
|
|
if cfg!(windows) && path.starts_with('/') {
|
|
return create_directory_via_wsl(&path).await;
|
|
}
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
let dir_path = Path::new(&path);
|
|
|
|
if dir_path.exists() {
|
|
return Err("Directory already exists".to_string());
|
|
}
|
|
|
|
fs::create_dir_all(dir_path).map_err(|e| format!("Failed to create directory: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create directory via WSL (for Windows with WSL paths)
|
|
#[allow(dead_code)]
|
|
async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
|
|
use std::process::Command;
|
|
|
|
// Check if directory exists first
|
|
let check = Command::new("wsl")
|
|
.args(["-e", "test", "-e", path])
|
|
.status()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if check.success() {
|
|
return Err("Directory already exists".to_string());
|
|
}
|
|
|
|
let output = Command::new("wsl")
|
|
.args(["-e", "mkdir", "-p", path])
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("Failed to create directory: {}", stderr));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_file(path: String) -> Result<(), String> {
|
|
// On Windows with a WSL path, use WSL to delete the file
|
|
if cfg!(windows) && path.starts_with('/') {
|
|
return delete_file_via_wsl(&path).await;
|
|
}
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
let file_path = Path::new(&path);
|
|
|
|
if !file_path.exists() {
|
|
return Err("File does not exist".to_string());
|
|
}
|
|
|
|
if file_path.is_dir() {
|
|
return Err("Path is a directory, use delete_directory instead".to_string());
|
|
}
|
|
|
|
fs::remove_file(file_path).map_err(|e| format!("Failed to delete file: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete file via WSL (for Windows with WSL paths)
|
|
#[allow(dead_code)]
|
|
async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
|
|
use std::process::Command;
|
|
|
|
// Check if path exists
|
|
let check_exists = Command::new("wsl")
|
|
.args(["-e", "test", "-e", path])
|
|
.status()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !check_exists.success() {
|
|
return Err("File does not exist".to_string());
|
|
}
|
|
|
|
// Check if path is a directory
|
|
let check_dir = Command::new("wsl")
|
|
.args(["-e", "test", "-d", path])
|
|
.status()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if check_dir.success() {
|
|
return Err("Path is a directory, use delete_directory instead".to_string());
|
|
}
|
|
|
|
let output = Command::new("wsl")
|
|
.args(["-e", "rm", path])
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("Failed to delete file: {}", stderr));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_directory(path: String) -> Result<(), String> {
|
|
// On Windows with a WSL path, use WSL to delete the directory
|
|
if cfg!(windows) && path.starts_with('/') {
|
|
return delete_directory_via_wsl(&path).await;
|
|
}
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
let dir_path = Path::new(&path);
|
|
|
|
if !dir_path.exists() {
|
|
return Err("Directory does not exist".to_string());
|
|
}
|
|
|
|
if !dir_path.is_dir() {
|
|
return Err("Path is not a directory".to_string());
|
|
}
|
|
|
|
fs::remove_dir_all(dir_path).map_err(|e| format!("Failed to delete directory: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete directory via WSL (for Windows with WSL paths)
|
|
#[allow(dead_code)]
|
|
async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
|
|
use std::process::Command;
|
|
|
|
// Check if path exists
|
|
let check_exists = Command::new("wsl")
|
|
.args(["-e", "test", "-e", path])
|
|
.status()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !check_exists.success() {
|
|
return Err("Directory does not exist".to_string());
|
|
}
|
|
|
|
// Check if path is a directory
|
|
let check_dir = Command::new("wsl")
|
|
.args(["-e", "test", "-d", path])
|
|
.status()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !check_dir.success() {
|
|
return Err("Path is not a directory".to_string());
|
|
}
|
|
|
|
let output = Command::new("wsl")
|
|
.args(["-e", "rm", "-rf", path])
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("Failed to delete directory: {}", stderr));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn rename_path(old_path: String, new_path: String) -> Result<(), String> {
|
|
// On Windows with WSL paths, use WSL to rename
|
|
if cfg!(windows) && old_path.starts_with('/') {
|
|
return rename_path_via_wsl(&old_path, &new_path).await;
|
|
}
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
let old = Path::new(&old_path);
|
|
let new = Path::new(&new_path);
|
|
|
|
if !old.exists() {
|
|
return Err("Path does not exist".to_string());
|
|
}
|
|
|
|
if new.exists() {
|
|
return Err("Destination already exists".to_string());
|
|
}
|
|
|
|
fs::rename(old, new).map_err(|e| format!("Failed to rename: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Rename path via WSL (for Windows with WSL paths)
|
|
#[allow(dead_code)]
|
|
async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), String> {
|
|
use std::process::Command;
|
|
|
|
// Check if old path exists
|
|
let check_old = Command::new("wsl")
|
|
.args(["-e", "test", "-e", old_path])
|
|
.status()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !check_old.success() {
|
|
return Err("Path does not exist".to_string());
|
|
}
|
|
|
|
// Check if new path already exists
|
|
let check_new = Command::new("wsl")
|
|
.args(["-e", "test", "-e", new_path])
|
|
.status()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if check_new.success() {
|
|
return Err("Destination already exists".to_string());
|
|
}
|
|
|
|
let output = Command::new("wsl")
|
|
.args(["-e", "mv", old_path, new_path])
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("Failed to rename: {}", stderr));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ==================== Cost Tracking Commands ====================
|
|
|
|
const COST_HISTORY_STORE_KEY: &str = "cost_history";
|
|
|
|
#[tauri::command]
|
|
pub async fn get_cost_summary(app: AppHandle, days: u32) -> Result<crate::cost_tracking::CostSummary, String> {
|
|
let history = load_cost_history(&app).await;
|
|
Ok(history.get_summary(days))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_cost_alerts(app: AppHandle) -> Result<Vec<crate::cost_tracking::CostAlert>, String> {
|
|
let mut history = load_cost_history(&app).await;
|
|
let alerts = history.check_alerts();
|
|
|
|
// Save updated alert state
|
|
save_cost_history(&app, &history).await?;
|
|
|
|
Ok(alerts)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn set_cost_alert_thresholds(
|
|
app: AppHandle,
|
|
daily: Option<f64>,
|
|
weekly: Option<f64>,
|
|
monthly: Option<f64>,
|
|
) -> Result<(), String> {
|
|
let mut history = load_cost_history(&app).await;
|
|
history.set_alert_thresholds(daily, weekly, monthly);
|
|
save_cost_history(&app, &history).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn export_cost_csv(app: AppHandle, days: u32) -> Result<String, String> {
|
|
let history = load_cost_history(&app).await;
|
|
Ok(history.export_csv(days))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_today_cost(app: AppHandle) -> Result<f64, String> {
|
|
let history = load_cost_history(&app).await;
|
|
Ok(history.get_today_cost())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_week_cost(app: AppHandle) -> Result<f64, String> {
|
|
let history = load_cost_history(&app).await;
|
|
Ok(history.get_week_cost())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_month_cost(app: AppHandle) -> Result<f64, String> {
|
|
let history = load_cost_history(&app).await;
|
|
Ok(history.get_month_cost())
|
|
}
|
|
|
|
/// Add cost to history (called internally when stats are updated)
|
|
pub async fn record_cost(app: &AppHandle, input_tokens: u64, output_tokens: u64, cost_usd: f64) {
|
|
let mut history = load_cost_history(app).await;
|
|
history.add_cost(input_tokens, output_tokens, cost_usd);
|
|
let _ = save_cost_history(app, &history).await;
|
|
}
|
|
|
|
/// Record a new session
|
|
pub async fn record_session(app: &AppHandle) {
|
|
let mut history = load_cost_history(app).await;
|
|
history.increment_sessions();
|
|
let _ = save_cost_history(app, &history).await;
|
|
}
|
|
|
|
async fn load_cost_history(app: &AppHandle) -> crate::cost_tracking::CostHistory {
|
|
let store = match app.store("hikari-cost-history.json") {
|
|
Ok(s) => s,
|
|
Err(_) => return crate::cost_tracking::CostHistory::new(),
|
|
};
|
|
|
|
match store.get(COST_HISTORY_STORE_KEY) {
|
|
Some(value) => serde_json::from_value(value.clone()).unwrap_or_default(),
|
|
None => crate::cost_tracking::CostHistory::new(),
|
|
}
|
|
}
|
|
|
|
async fn save_cost_history(app: &AppHandle, history: &crate::cost_tracking::CostHistory) -> Result<(), String> {
|
|
let store = app.store("hikari-cost-history.json").map_err(|e| e.to_string())?;
|
|
let value = serde_json::to_value(history).map_err(|e| e.to_string())?;
|
|
store.set(COST_HISTORY_STORE_KEY, value);
|
|
store.save().map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn init_discord_rpc(
|
|
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
|
session_name: String,
|
|
model: String,
|
|
started_at: i64,
|
|
) -> Result<(), String> {
|
|
discord_rpc.init(session_name, model, started_at)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_discord_rpc(
|
|
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
|
session_name: String,
|
|
model: String,
|
|
started_at: i64,
|
|
) -> Result<(), String> {
|
|
discord_rpc.update(session_name, model, started_at)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stop_discord_rpc(
|
|
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
|
) -> Result<(), String> {
|
|
discord_rpc.stop()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn log_discord_rpc(
|
|
discord_rpc: State<'_, std::sync::Arc<crate::discord_rpc::DiscordRpcManager>>,
|
|
message: String,
|
|
) -> Result<(), String> {
|
|
discord_rpc.log(&message);
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
|
|
// Get the main window
|
|
if let Some(window) = app_handle.get_webview_window("main") {
|
|
// Hide the window first for a smoother close
|
|
let _ = window.hide();
|
|
}
|
|
|
|
// Exit the application
|
|
app_handle.exit(0);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs::{self, File};
|
|
use std::io::Write;
|
|
use tempfile::TempDir;
|
|
|
|
// Helper to run async tests
|
|
fn run_async<F: std::future::Future>(f: F) -> F::Output {
|
|
tokio::runtime::Runtime::new().unwrap().block_on(f)
|
|
}
|
|
|
|
// ==================== validate_directory tests ====================
|
|
|
|
#[test]
|
|
fn test_validate_directory_absolute_path_exists() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let path = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = run_async(validate_directory(path.clone(), None));
|
|
assert!(result.is_ok());
|
|
// Canonicalized path should be returned
|
|
assert!(result.unwrap().contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_directory_path_not_exists() {
|
|
let result = run_async(validate_directory(
|
|
"/nonexistent/path/that/does/not/exist".to_string(),
|
|
None,
|
|
));
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("does not exist"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_directory_path_is_file() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let file_path = temp_dir.path().join("test_file.txt");
|
|
File::create(&file_path).unwrap();
|
|
|
|
let result = run_async(validate_directory(
|
|
file_path.to_string_lossy().to_string(),
|
|
None,
|
|
));
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("not a directory"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_directory_home_expansion() {
|
|
// This test assumes HOME is set (which it should be on most systems)
|
|
if std::env::var_os("HOME").is_some() {
|
|
let result = run_async(validate_directory("~".to_string(), None));
|
|
assert!(result.is_ok());
|
|
// Should not contain ~ after expansion
|
|
assert!(!result.unwrap().contains("~"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_directory_home_subpath_expansion() {
|
|
// This test assumes HOME is set and has some subdirectory
|
|
if let Some(home) = std::env::var_os("HOME") {
|
|
let home_path = std::path::Path::new(&home);
|
|
// Find any subdirectory in home
|
|
if let Ok(entries) = fs::read_dir(home_path) {
|
|
for entry in entries.flatten() {
|
|
if entry.path().is_dir() {
|
|
let subdir_name = entry.file_name().to_string_lossy().to_string();
|
|
let tilde_path = format!("~/{}", subdir_name);
|
|
let result = run_async(validate_directory(tilde_path, None));
|
|
assert!(result.is_ok());
|
|
assert!(!result.unwrap().contains("~"));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_directory_relative_path_with_current_dir() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let subdir = temp_dir.path().join("subdir");
|
|
fs::create_dir(&subdir).unwrap();
|
|
|
|
let result = run_async(validate_directory(
|
|
"subdir".to_string(),
|
|
Some(temp_dir.path().to_string_lossy().to_string()),
|
|
));
|
|
assert!(result.is_ok());
|
|
assert!(result.unwrap().contains("subdir"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_directory_dot_path() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
|
|
let result = run_async(validate_directory(
|
|
".".to_string(),
|
|
Some(temp_dir.path().to_string_lossy().to_string()),
|
|
));
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_directory_dotdot_path() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let subdir = temp_dir.path().join("subdir");
|
|
fs::create_dir(&subdir).unwrap();
|
|
|
|
let result = run_async(validate_directory(
|
|
"..".to_string(),
|
|
Some(subdir.to_string_lossy().to_string()),
|
|
));
|
|
assert!(result.is_ok());
|
|
// Should resolve to parent
|
|
let resolved = result.unwrap();
|
|
assert!(resolved.contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_directory_relative_without_current_dir() {
|
|
// Relative path without current_dir - should fail since relative path likely won't exist
|
|
let result = run_async(validate_directory(
|
|
"some_random_nonexistent_relative_path".to_string(),
|
|
None,
|
|
));
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
// ==================== get_file_size tests ====================
|
|
|
|
#[test]
|
|
fn test_get_file_size_empty_file() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let file_path = temp_dir.path().join("empty.txt");
|
|
File::create(&file_path).unwrap();
|
|
|
|
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_file_size_with_content() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let file_path = temp_dir.path().join("content.txt");
|
|
let mut file = File::create(&file_path).unwrap();
|
|
file.write_all(b"Hello, Hikari!").unwrap();
|
|
|
|
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), 14); // "Hello, Hikari!" is 14 bytes
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_file_size_larger_file() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let file_path = temp_dir.path().join("large.txt");
|
|
let mut file = File::create(&file_path).unwrap();
|
|
// Write 1000 bytes
|
|
let data = vec![b'x'; 1000];
|
|
file.write_all(&data).unwrap();
|
|
|
|
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_file_size_nonexistent_file() {
|
|
let result = run_async(get_file_size(
|
|
"/nonexistent/path/file.txt".to_string(),
|
|
));
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Failed to get file metadata"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_file_size_directory() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
|
|
// Getting "size" of a directory should work but return directory metadata
|
|
// This is actually valid - directories have metadata too
|
|
let result = run_async(get_file_size(temp_dir.path().to_string_lossy().to_string()));
|
|
assert!(result.is_ok());
|
|
// Directory size is platform-dependent, just check it returns something
|
|
}
|
|
|
|
// ==================== list_skills tests ====================
|
|
|
|
#[test]
|
|
fn test_list_skills_no_skills_dir() {
|
|
// This test is tricky because it depends on HOME being set
|
|
// and potentially affecting real user data, so we'll just
|
|
// verify the function doesn't panic
|
|
let result = run_async(list_skills());
|
|
// Should either return Ok with a list or Ok with empty vec
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
// ==================== select_wsl_directory tests ====================
|
|
|
|
#[test]
|
|
fn test_select_wsl_directory_returns_home() {
|
|
let result = run_async(select_wsl_directory());
|
|
assert!(result.is_ok());
|
|
|
|
// Should return the user's home directory
|
|
let home_dir = result.unwrap();
|
|
assert!(home_dir.starts_with("/home/") || home_dir == "/root");
|
|
}
|
|
|
|
// ==================== UpdateInfo struct tests ====================
|
|
|
|
#[test]
|
|
fn test_update_info_serialization() {
|
|
let info = UpdateInfo {
|
|
current_version: "1.0.0".to_string(),
|
|
latest_version: "0.4.0".to_string(),
|
|
has_update: true,
|
|
release_url: "https://example.com/release".to_string(),
|
|
release_notes: Some("New features!".to_string()),
|
|
};
|
|
|
|
let json = serde_json::to_string(&info).unwrap();
|
|
assert!(json.contains("1.0.0"));
|
|
assert!(json.contains("0.4.0"));
|
|
assert!(json.contains("true"));
|
|
assert!(json.contains("New features!"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_info_without_notes() {
|
|
let info = UpdateInfo {
|
|
current_version: "1.0.0".to_string(),
|
|
latest_version: "1.0.0".to_string(),
|
|
has_update: false,
|
|
release_url: "https://example.com/release".to_string(),
|
|
release_notes: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&info).unwrap();
|
|
assert!(json.contains("null") || json.contains("release_notes"));
|
|
}
|
|
|
|
// ==================== SavedFileInfo struct tests ====================
|
|
|
|
#[test]
|
|
fn test_saved_file_info_serialization() {
|
|
let info = SavedFileInfo {
|
|
path: "/tmp/test.txt".to_string(),
|
|
filename: "test.txt".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&info).unwrap();
|
|
assert!(json.contains("/tmp/test.txt"));
|
|
assert!(json.contains("test.txt"));
|
|
}
|
|
}
|