use std::path::PathBuf; use tauri::{AppHandle, 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"; #[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 { 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 { let manager = bridge_manager.lock(); manager.get_working_directory(&conversation_id) } #[tauri::command] pub async fn select_wsl_directory() -> Result { Ok("/home".to_string()) } #[tauri::command] pub async fn get_config(app: AppHandle) -> Result { 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 { 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 { 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, ) -> Result { use std::path::Path; let path = Path::new(&path); // Expand ~ to home directory let expanded_path = if path.starts_with("~") { if let Some(home) = std::env::var_os("HOME") { let home_path = Path::new(&home); if path == Path::new("~") { home_path.to_path_buf() } else { home_path.join(path.strip_prefix("~").unwrap()) } } else { return Err("Could not determine home directory".to_string()); } } else if path.is_relative() { // Handle relative paths (., .., or any relative path) by resolving against current_dir if let Some(ref cwd) = current_dir { Path::new(cwd).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, 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, String> { use std::fs; use std::path::Path; // Get the home directory let home = std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?; let skills_dir = Path::new(&home).join(".claude").join("skills"); // If the skills directory doesn't exist, return empty list if !skills_dir.exists() { return Ok(Vec::new()); } // Read the directory and collect skill names 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(); // Only include directories that contain a SKILL.md file 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()); } } } } // Sort alphabetically 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, } #[derive(Debug, serde::Deserialize)] struct GiteaRelease { tag_name: String, html_url: String, body: Option, prerelease: bool, } #[tauri::command] pub async fn check_for_updates() -> Result { 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 = 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, filename: Option, ) -> Result { 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()); Ok(SavedFileInfo { path: path.to_string_lossy().to_string(), 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, 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 { let mut manager = temp_manager.lock(); manager.cleanup_orphaned_files() } #[tauri::command] pub async fn get_file_size(file_path: String) -> Result { 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(path: String) -> Result, String> { use std::fs; use std::path::Path; let dir_path = Path::new(&path); if !dir_path.exists() { return Err(format!("Directory does not exist: {}", path)); } if !dir_path.is_dir() { return Err(format!("Path is not a directory: {}", path)); } let entries = fs::read_dir(dir_path) .map_err(|e| format!("Failed to read directory: {}", e))?; let mut file_entries = Vec::new(); for entry in entries { let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; let path = entry.path(); let name = entry .file_name() .to_string_lossy() .to_string(); // Skip hidden files by default (can be made configurable later) if name.starts_with('.') { continue; } file_entries.push(FileEntry { name, path: path.to_string_lossy().to_string(), is_directory: path.is_dir(), }); } Ok(file_entries) } #[tauri::command] pub async fn read_file_content(path: String) -> Result { use std::fs; fs::read_to_string(&path) .map_err(|e| format!("Failed to read file: {}", e)) } #[tauri::command] pub async fn write_file_content(path: String, content: String) -> Result<(), String> { use std::fs; fs::write(&path, content) .map_err(|e| format!("Failed to write file: {}", e)) } #[tauri::command] pub async fn create_file(path: String) -> Result<(), String> { 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(()) } #[tauri::command] pub async fn create_directory(path: String) -> Result<(), String> { 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(()) } #[tauri::command] pub async fn delete_file(path: String) -> Result<(), String> { 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(()) } #[tauri::command] pub async fn delete_directory(path: String) -> Result<(), String> { 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(()) } #[tauri::command] pub async fn rename_path(old_path: String, new_path: String) -> Result<(), String> { 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(()) } // ==================== Cost Tracking Commands ==================== const COST_HISTORY_STORE_KEY: &str = "cost_history"; #[tauri::command] pub async fn get_cost_summary(app: AppHandle, days: u32) -> Result { let history = load_cost_history(&app).await; Ok(history.get_summary(days)) } #[tauri::command] pub async fn get_cost_alerts(app: AppHandle) -> Result, 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, weekly: Option, monthly: Option, ) -> 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 { let history = load_cost_history(&app).await; Ok(history.export_csv(days)) } #[tauri::command] pub async fn get_today_cost(app: AppHandle) -> Result { let history = load_cost_history(&app).await; Ok(history.get_today_cost()) } #[tauri::command] pub async fn get_week_cost(app: AppHandle) -> Result { let history = load_cost_history(&app).await; Ok(history.get_week_cost()) } #[tauri::command] pub async fn get_month_cost(app: AppHandle) -> Result { 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>, 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>, 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>, ) -> Result<(), String> { discord_rpc.stop() } #[tauri::command] pub async fn log_discord_rpc( discord_rpc: State<'_, std::sync::Arc>, message: String, ) -> Result<(), String> { discord_rpc.log(&message); 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: 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()); assert_eq!(result.unwrap(), "/home"); } // ==================== 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")); } }