generated from nhcarrigan/template
945 lines
28 KiB
Rust
945 lines
28 KiB
Rust
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<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> {
|
|
Ok("/home".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;
|
|
|
|
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<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> {
|
|
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<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());
|
|
|
|
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<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(path: String) -> Result<Vec<FileEntry>, 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<String, String> {
|
|
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<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(())
|
|
}
|
|
|
|
#[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());
|
|
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"));
|
|
}
|
|
}
|