From 97b0ce1b5807dea518b70ab7e8364e35d9b52d9a Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 27 Jan 2026 14:45:28 +0000 Subject: [PATCH] Wrote some tests. --- README.md | 66 ++- src-tauri/.config/nextest.toml | 31 ++ src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/commands/fs.rs | 752 ++++++++++++++++++++++++++++--- src-tauri/src/commands/search.rs | 295 +++++++++++- src-tauri/src/commands/shell.rs | 250 +++++++++- src-tauri/src/lib.rs | 3 + src-tauri/src/llm/prompts.rs | 145 ++++++ src-tauri/src/llm/types.rs | 278 ++++++++++++ src-tauri/src/state.rs | 195 ++++++++ src-tauri/src/test_utils.rs | 107 +++++ 12 files changed, 2029 insertions(+), 97 deletions(-) create mode 100644 src-tauri/.config/nextest.toml create mode 100644 src-tauri/src/test_utils.rs diff --git a/README.md b/README.md index 102e366..08f2b0d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,68 @@ This template should help get you started developing with Tauri, React and Typescript in Vite. -## Recommended IDE Setup +## Testing -- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +This project uses **nextest** for running tests and **cargo-llvm-cov** for code coverage. + +### Install Tools + +```bash +cargo install cargo-nextest cargo-llvm-cov +``` + +### Run Tests + +```bash +# Run all tests +cargo nextest run + +# Run specific module +cargo nextest run search_files + +# Run with verbose output +cargo nextest run --no-capture +``` + +### Generate Coverage + +```bash +# HTML report (opens in browser) +cargo llvm-cov nextest --html --open + +# Terminal output +cargo llvm-cov nextest + +# LCOV format (for CI) +cargo llvm-cov nextest --lcov --output-path lcov.info + +# Clean coverage data +cargo llvm-cov clean +``` + +### Configuration + +- **Nextest config**: `.config/nextest.toml` +- **Coverage output**: `target/llvm-cov/html/index.html` + +## Current Coverage (search_files module) + +``` +Module: commands/search.rs +├── Region Coverage: 75.36% +├── Function Coverage: 69.05% +└── Line Coverage: 72.55% +``` + +### Available Test Profiles + +```bash +# Development (default) +cargo nextest run + +# CI with retries +cargo nextest run --profile ci + +# Coverage optimized +cargo nextest run --profile coverage +``` diff --git a/src-tauri/.config/nextest.toml b/src-tauri/.config/nextest.toml new file mode 100644 index 0000000..4d0581d --- /dev/null +++ b/src-tauri/.config/nextest.toml @@ -0,0 +1,31 @@ +# Nextest configuration for living-spec-standalone +# See https://nexte.st/book/configuration.html for more details + +[profile.default] +# Show output for failing tests +failure-output = "immediate" +# Show output for passing tests as well +success-output = "never" +# Cancel test run on the first failure +fail-fast = false +# Number of retries for failing tests +retries = 0 + +[profile.ci] +# CI-specific profile +failure-output = "immediate-final" +success-output = "never" +fail-fast = false +# Retry flaky tests once in CI +retries = 1 + +[profile.coverage] +# Profile specifically for code coverage runs +failure-output = "immediate-final" +success-output = "never" +fail-fast = false +retries = 0 + +# Test groups configuration +[test-groups.integration] +max-threads = 1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8c15d73..7bc720c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2082,6 +2082,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-store", + "tempfile", "tokio", "uuid", "walkdir", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ebabda7..351934f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,3 +33,6 @@ async-trait = "0.1.89" tauri-plugin-store = "2.4.2" tokio = { version = "1", features = ["sync"] } eventsource-stream = "0.2.3" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 18574ed..9a3b3cc 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -1,4 +1,6 @@ use crate::state::SessionState; +#[cfg(test)] +use crate::test_utils::MockStore; use serde::Serialize; use serde_json::json; use std::fs; @@ -10,18 +12,54 @@ const STORE_PATH: &str = "store.json"; const KEY_LAST_PROJECT: &str = "last_project_path"; const KEY_SELECTED_MODEL: &str = "selected_model"; +// ----------------------------------------------------------------------------- +// Store Abstraction +// ----------------------------------------------------------------------------- + +/// Trait to abstract store operations for testing +pub trait StoreOps: Send + Sync { + fn get(&self, key: &str) -> Option; + fn set(&self, key: &str, value: serde_json::Value); + fn delete(&self, key: &str); + fn save(&self) -> Result<(), String>; +} + +// ----------------------------------------------------------------------------- +// Store Wrapper for Production +// ----------------------------------------------------------------------------- + +/// Wrapper for Tauri Store that implements StoreOps +struct TauriStoreWrapper<'a> { + store: &'a tauri_plugin_store::Store, +} + +impl<'a> StoreOps for TauriStoreWrapper<'a> { + fn get(&self, key: &str) -> Option { + self.store.get(key) + } + + fn set(&self, key: &str, value: serde_json::Value) { + self.store.set(key, value); + } + + fn delete(&self, key: &str) { + self.store.delete(key); + } + + fn save(&self) -> Result<(), String> { + self.store + .save() + .map_err(|e| format!("Failed to save store: {}", e)) + } +} + // ----------------------------------------------------------------------------- // Helper Functions // ----------------------------------------------------------------------------- -/// Resolves a relative path against the active project root. -/// Returns error if no project is open or if path attempts traversal (..). -fn resolve_path(state: &State<'_, SessionState>, relative_path: &str) -> Result { - let root_guard = state.project_root.lock().map_err(|e| e.to_string())?; - let root = root_guard - .as_ref() - .ok_or_else(|| "No project is currently open.".to_string())?; - +/// Resolves a relative path against the active project root (pure function for testing). +/// Returns error if path attempts traversal (..). +fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result { // specific check for traversal if relative_path.contains("..") { return Err("Security Violation: Directory traversal ('..') is not allowed.".to_string()); @@ -33,50 +71,71 @@ fn resolve_path(state: &State<'_, SessionState>, relative_path: &str) -> Result< Ok(full_path) } +/// Resolves a relative path against the active project root. +/// Returns error if no project is open or if path attempts traversal (..). +fn resolve_path(state: &State<'_, SessionState>, relative_path: &str) -> Result { + let root = state.inner().get_project_root()?; + resolve_path_impl(root, relative_path) +} + // ----------------------------------------------------------------------------- // Commands // ----------------------------------------------------------------------------- +/// Validate that a path exists and is a directory (pure function for testing) +async fn validate_project_path(path: PathBuf) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + if !path.exists() { + return Err(format!("Path does not exist: {}", path.display())); + } + if !path.is_dir() { + return Err(format!("Path is not a directory: {}", path.display())); + } + Ok(()) + }) + .await + .map_err(|e| format!("Task failed: {}", e))? +} + +/// Open project implementation (testable with store abstraction) +async fn open_project_impl( + path: String, + state: &SessionState, + store: &dyn StoreOps, +) -> Result { + let p = PathBuf::from(&path); + + // Validate path + validate_project_path(p.clone()).await?; + + // Update session state + { + let mut root = state.project_root.lock().map_err(|e| e.to_string())?; + *root = Some(p.clone()); + } + + // Persist to store + store.set(KEY_LAST_PROJECT, json!(path)); + store.save()?; + + Ok(path) +} + #[tauri::command] pub async fn open_project( app: AppHandle, path: String, state: State<'_, SessionState>, ) -> Result { - let p = PathBuf::from(&path); - - // Validate path existence in blocking thread - let p_clone = p.clone(); - tauri::async_runtime::spawn_blocking(move || { - if !p_clone.exists() { - return Err(format!("Path does not exist: {}", p_clone.display())); - } - if !p_clone.is_dir() { - return Err(format!("Path is not a directory: {}", p_clone.display())); - } - Ok(()) - }) - .await - .map_err(|e| format!("Task failed: {}", e))??; - - { - let mut root = state.project_root.lock().map_err(|e| e.to_string())?; - *root = Some(p.clone()); - } - - // Persist to store let store = app .store(STORE_PATH) .map_err(|e| format!("Failed to access store: {}", e))?; - store.set(KEY_LAST_PROJECT, json!(path)); - let _ = store.save(); - - println!("Project opened: {:?}", p); - Ok(path) + let wrapper = TauriStoreWrapper { store: &store }; + open_project_impl(path, state.inner(), &wrapper).await } -#[tauri::command] -pub async fn close_project(app: AppHandle, state: State<'_, SessionState>) -> Result<(), String> { +/// Close project implementation (testable with store abstraction) +fn close_project_impl(state: &SessionState, store: &dyn StoreOps) -> Result<(), String> { // Clear session state { let mut root = state.project_root.lock().map_err(|e| e.to_string())?; @@ -84,19 +143,25 @@ pub async fn close_project(app: AppHandle, state: State<'_, SessionState>) -> Re } // Clear from store - let store = app - .store(STORE_PATH) - .map_err(|e| format!("Failed to access store: {}", e))?; store.delete(KEY_LAST_PROJECT); - let _ = store.save(); + store.save()?; Ok(()) } #[tauri::command] -pub async fn get_current_project( - app: AppHandle, - state: State<'_, SessionState>, +pub async fn close_project(app: AppHandle, state: State<'_, SessionState>) -> Result<(), String> { + let store = app + .store(STORE_PATH) + .map_err(|e| format!("Failed to access store: {}", e))?; + let wrapper = TauriStoreWrapper { store: &store }; + close_project_impl(state.inner(), &wrapper) +} + +/// Get current project implementation (testable with store abstraction) +fn get_current_project_impl( + state: &SessionState, + store: &dyn StoreOps, ) -> Result, String> { // 1. Check in-memory state { @@ -107,10 +172,6 @@ pub async fn get_current_project( } // 2. Check store - let store = app - .store(STORE_PATH) - .map_err(|e| format!("Failed to access store: {}", e))?; - if let Some(path_str) = store .get(KEY_LAST_PROJECT) .as_ref() @@ -129,11 +190,19 @@ pub async fn get_current_project( } #[tauri::command] -pub async fn get_model_preference(app: AppHandle) -> Result, String> { +pub async fn get_current_project( + app: AppHandle, + state: State<'_, SessionState>, +) -> Result, String> { let store = app .store(STORE_PATH) .map_err(|e| format!("Failed to access store: {}", e))?; + let wrapper = TauriStoreWrapper { store: &store }; + get_current_project_impl(state.inner(), &wrapper) +} +/// Get model preference implementation (testable with store abstraction) +fn get_model_preference_impl(store: &dyn StoreOps) -> Result, String> { if let Some(model) = store .get(KEY_SELECTED_MODEL) .as_ref() @@ -145,19 +214,32 @@ pub async fn get_model_preference(app: AppHandle) -> Result, Stri } #[tauri::command] -pub async fn set_model_preference(app: AppHandle, model: String) -> Result<(), String> { +pub async fn get_model_preference(app: AppHandle) -> Result, String> { let store = app .store(STORE_PATH) .map_err(|e| format!("Failed to access store: {}", e))?; + let wrapper = TauriStoreWrapper { store: &store }; + get_model_preference_impl(&wrapper) +} +/// Set model preference implementation (testable with store abstraction) +fn set_model_preference_impl(model: String, store: &dyn StoreOps) -> Result<(), String> { store.set(KEY_SELECTED_MODEL, json!(model)); - let _ = store.save(); + store.save()?; Ok(()) } #[tauri::command] -pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result { - let full_path = resolve_path(&state, &path)?; +pub async fn set_model_preference(app: AppHandle, model: String) -> Result<(), String> { + let store = app + .store(STORE_PATH) + .map_err(|e| format!("Failed to access store: {}", e))?; + let wrapper = TauriStoreWrapper { store: &store }; + set_model_preference_impl(model, &wrapper) +} + +/// Read file implementation (pure function for testing) +async fn read_file_impl(full_path: PathBuf) -> Result { tauri::async_runtime::spawn_blocking(move || { fs::read_to_string(&full_path).map_err(|e| format!("Failed to read file: {}", e)) }) @@ -166,13 +248,13 @@ pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result, -) -> Result<(), String> { +pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result { let full_path = resolve_path(&state, &path)?; + read_file_impl(full_path).await +} +/// Write file implementation (pure function for testing) +async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { // Ensure parent directory exists if let Some(parent) = full_path.parent() { @@ -186,19 +268,24 @@ pub async fn write_file( .map_err(|e| format!("Task failed: {}", e))? } -#[derive(Serialize)] +#[tauri::command] +pub async fn write_file( + path: String, + content: String, + state: State<'_, SessionState>, +) -> Result<(), String> { + let full_path = resolve_path(&state, &path)?; + write_file_impl(full_path, content).await +} + +#[derive(Serialize, Debug)] pub struct FileEntry { name: String, kind: String, // "file" | "dir" } -#[tauri::command] -pub async fn list_directory( - path: String, - state: State<'_, SessionState>, -) -> Result, String> { - let full_path = resolve_path(&state, &path)?; - +/// List directory implementation (pure function for testing) +async fn list_directory_impl(full_path: PathBuf) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { let entries = fs::read_dir(&full_path).map_err(|e| format!("Failed to read dir: {}", e))?; @@ -230,3 +317,536 @@ pub async fn list_directory( .await .map_err(|e| format!("Task failed: {}", e))? } + +#[tauri::command] +pub async fn list_directory( + path: String, + state: State<'_, SessionState>, +) -> Result, String> { + let full_path = resolve_path(&state, &path)?; + list_directory_impl(full_path).await +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::Mutex; + use tempfile::TempDir; + + /// Helper to create a test SessionState with a given root path + fn create_test_state(root: Option) -> SessionState { + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + SessionState { + project_root: Mutex::new(root), + cancel_tx, + cancel_rx, + } + } + + // Tests for validate_project_path function + mod validate_project_path_tests { + use super::*; + + #[tokio::test] + async fn test_validate_project_path_valid_directory() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().to_path_buf(); + + let result = validate_project_path(path).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_validate_project_path_not_exists() { + let path = PathBuf::from("/nonexistent/path/xyz"); + + let result = validate_project_path(path).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Path does not exist")); + } + + #[tokio::test] + async fn test_validate_project_path_is_file() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + fs::write(&file_path, "content").unwrap(); + + let result = validate_project_path(file_path).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not a directory")); + } + + #[tokio::test] + async fn test_validate_project_path_nested_directory() { + let temp_dir = TempDir::new().unwrap(); + let nested = temp_dir.path().join("nested/dir"); + fs::create_dir_all(&nested).unwrap(); + + let result = validate_project_path(nested).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_validate_project_path_empty_directory() { + let temp_dir = TempDir::new().unwrap(); + let empty_dir = temp_dir.path().join("empty"); + fs::create_dir(&empty_dir).unwrap(); + + let result = validate_project_path(empty_dir).await; + + assert!(result.is_ok()); + } + } + + // Tests for open_project_impl + mod open_project_tests { + use super::*; + + #[tokio::test] + async fn test_open_project_impl_success() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().to_string_lossy().to_string(); + let state = create_test_state(None); + let store = MockStore::new(); + + let result = open_project_impl(path.clone(), &state, &store).await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), path); + + // Verify state was updated + let root = state.project_root.lock().unwrap(); + assert!(root.is_some()); + + // Verify store was updated + assert!(store.get(KEY_LAST_PROJECT).is_some()); + } + + #[tokio::test] + async fn test_open_project_impl_invalid_path() { + let state = create_test_state(None); + let store = MockStore::new(); + + let result = open_project_impl("/nonexistent/path".to_string(), &state, &store).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not exist")); + } + + #[tokio::test] + async fn test_open_project_impl_file_not_directory() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("file.txt"); + fs::write(&file_path, "content").unwrap(); + let path = file_path.to_string_lossy().to_string(); + + let state = create_test_state(None); + let store = MockStore::new(); + + let result = open_project_impl(path, &state, &store).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not a directory")); + } + } + + // Tests for close_project_impl + mod close_project_tests { + use super::*; + + #[test] + fn test_close_project_impl() { + let temp_dir = TempDir::new().unwrap(); + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + let store = MockStore::new(); + store.set(KEY_LAST_PROJECT, json!("/some/path")); + + let result = close_project_impl(&state, &store); + + assert!(result.is_ok()); + + // Verify state was cleared + let root = state.project_root.lock().unwrap(); + assert!(root.is_none()); + + // Verify store was cleared + assert!(store.get(KEY_LAST_PROJECT).is_none()); + } + } + + // Tests for get_current_project_impl + mod get_current_project_tests { + use super::*; + + #[test] + fn test_get_current_project_impl_from_memory() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().to_path_buf(); + let state = create_test_state(Some(path.clone())); + let store = MockStore::new(); + + let result = get_current_project_impl(&state, &store); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Some(path.to_string_lossy().to_string())); + } + + #[test] + fn test_get_current_project_impl_from_store() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().to_string_lossy().to_string(); + let state = create_test_state(None); + let store = MockStore::new(); + store.set(KEY_LAST_PROJECT, json!(path.clone())); + + let result = get_current_project_impl(&state, &store); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Some(path)); + + // Verify state was updated + let root = state.project_root.lock().unwrap(); + assert!(root.is_some()); + } + + #[test] + fn test_get_current_project_impl_no_project() { + let state = create_test_state(None); + let store = MockStore::new(); + + let result = get_current_project_impl(&state, &store); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), None); + } + + #[test] + fn test_get_current_project_impl_store_path_invalid() { + let state = create_test_state(None); + let store = MockStore::new(); + store.set(KEY_LAST_PROJECT, json!("/nonexistent/path")); + + let result = get_current_project_impl(&state, &store); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), None); + } + } + + // Tests for model preference functions + mod model_preference_tests { + use super::*; + + #[test] + fn test_get_model_preference_impl_exists() { + let store = MockStore::new(); + store.set(KEY_SELECTED_MODEL, json!("gpt-4")); + + let result = get_model_preference_impl(&store); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Some("gpt-4".to_string())); + } + + #[test] + fn test_get_model_preference_impl_not_exists() { + let store = MockStore::new(); + + let result = get_model_preference_impl(&store); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), None); + } + + #[test] + fn test_set_model_preference_impl() { + let store = MockStore::new(); + + let result = set_model_preference_impl("claude-3".to_string(), &store); + + assert!(result.is_ok()); + assert_eq!(store.get(KEY_SELECTED_MODEL), Some(json!("claude-3"))); + } + } + + // Tests for resolve_path helper function + mod resolve_path_tests { + use super::*; + + #[test] + fn test_resolve_path_no_project_open() { + let state = create_test_state(None); + + let result = state.get_project_root(); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "No project is currently open."); + } + + #[test] + fn test_resolve_path_valid() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().to_path_buf(); + + let result = resolve_path_impl(root.clone(), "test.txt"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), root.join("test.txt")); + } + + #[test] + fn test_resolve_path_blocks_traversal() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().to_path_buf(); + + let result = resolve_path_impl(root, "../etc/passwd"); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Directory traversal")); + } + + #[test] + fn test_resolve_path_nested() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().to_path_buf(); + + let result = resolve_path_impl(root.clone(), "src/main.rs"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), root.join("src/main.rs")); + } + } + + // Tests for read_file command + mod read_file_tests { + use super::*; + + #[tokio::test] + async fn test_read_file_success() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + fs::write(&file_path, "Hello, World!").unwrap(); + + let result = read_file_impl(file_path).await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "Hello, World!"); + } + + #[tokio::test] + async fn test_read_file_not_found() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("nonexistent.txt"); + + let result = read_file_impl(file_path).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to read file")); + } + + #[tokio::test] + async fn test_read_file_no_project() { + let state = create_test_state(None); + + let result = state.get_project_root(); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "No project is currently open."); + } + + #[tokio::test] + async fn test_read_file_blocks_traversal() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().to_path_buf(); + + let result = resolve_path_impl(root, "../etc/passwd"); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Directory traversal")); + } + + #[tokio::test] + async fn test_read_file_nested_path() { + let temp_dir = TempDir::new().unwrap(); + let nested_dir = temp_dir.path().join("src"); + fs::create_dir(&nested_dir).unwrap(); + let file_path = nested_dir.join("lib.rs"); + fs::write(&file_path, "pub fn main() {}").unwrap(); + + let result = read_file_impl(file_path).await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "pub fn main() {}"); + } + } + + // Tests for write_file command + mod write_file_tests { + use super::*; + + #[tokio::test] + async fn test_write_file_success() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + + let result = write_file_impl(file_path.clone(), "Hello, World!".to_string()).await; + + assert!(result.is_ok()); + let content = fs::read_to_string(file_path).unwrap(); + assert_eq!(content, "Hello, World!"); + } + + #[tokio::test] + async fn test_write_file_creates_parent_dirs() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("src/nested/test.txt"); + + let result = write_file_impl(file_path.clone(), "content".to_string()).await; + + assert!(result.is_ok()); + assert!(file_path.exists()); + let content = fs::read_to_string(file_path).unwrap(); + assert_eq!(content, "content"); + } + + #[tokio::test] + async fn test_write_file_overwrites_existing() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + fs::write(&file_path, "old content").unwrap(); + + let result = write_file_impl(file_path.clone(), "new content".to_string()).await; + + assert!(result.is_ok()); + let content = fs::read_to_string(file_path).unwrap(); + assert_eq!(content, "new content"); + } + + #[tokio::test] + async fn test_write_file_no_project() { + let state = create_test_state(None); + + let result = state.get_project_root(); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "No project is currently open."); + } + + #[tokio::test] + async fn test_write_file_blocks_traversal() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().to_path_buf(); + + let result = resolve_path_impl(root, "../etc/passwd"); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Directory traversal")); + } + } + + // Tests for list_directory command + mod list_directory_tests { + use super::*; + + #[tokio::test] + async fn test_list_directory_success() { + let temp_dir = TempDir::new().unwrap(); + fs::write(temp_dir.path().join("file1.txt"), "").unwrap(); + fs::write(temp_dir.path().join("file2.txt"), "").unwrap(); + fs::create_dir(temp_dir.path().join("dir1")).unwrap(); + + let result = list_directory_impl(temp_dir.path().to_path_buf()).await; + + assert!(result.is_ok()); + let entries = result.unwrap(); + assert_eq!(entries.len(), 3); + + // Check that directories come first + assert_eq!(entries[0].kind, "dir"); + assert_eq!(entries[0].name, "dir1"); + + // Files should be sorted alphabetically after directories + assert_eq!(entries[1].kind, "file"); + assert_eq!(entries[2].kind, "file"); + } + + #[tokio::test] + async fn test_list_directory_empty() { + let temp_dir = TempDir::new().unwrap(); + let empty_dir = temp_dir.path().join("empty"); + fs::create_dir(&empty_dir).unwrap(); + + let result = list_directory_impl(empty_dir).await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 0); + } + + #[tokio::test] + async fn test_list_directory_not_found() { + let temp_dir = TempDir::new().unwrap(); + let nonexistent = temp_dir.path().join("nonexistent"); + + let result = list_directory_impl(nonexistent).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to read dir")); + } + + #[tokio::test] + async fn test_list_directory_no_project() { + let state = create_test_state(None); + + let result = state.get_project_root(); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "No project is currently open."); + } + + #[tokio::test] + async fn test_list_directory_blocks_traversal() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().to_path_buf(); + + let result = resolve_path_impl(root, "../etc"); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Directory traversal")); + } + + #[tokio::test] + async fn test_list_directory_sorting() { + let temp_dir = TempDir::new().unwrap(); + fs::write(temp_dir.path().join("zebra.txt"), "").unwrap(); + fs::write(temp_dir.path().join("apple.txt"), "").unwrap(); + fs::create_dir(temp_dir.path().join("zoo")).unwrap(); + fs::create_dir(temp_dir.path().join("animal")).unwrap(); + + let result = list_directory_impl(temp_dir.path().to_path_buf()).await; + + assert!(result.is_ok()); + let entries = result.unwrap(); + + // Directories first (alphabetically) + assert_eq!(entries[0].name, "animal"); + assert_eq!(entries[0].kind, "dir"); + assert_eq!(entries[1].name, "zoo"); + assert_eq!(entries[1].kind, "dir"); + + // Files next (alphabetically) + assert_eq!(entries[2].name, "apple.txt"); + assert_eq!(entries[2].kind, "file"); + assert_eq!(entries[3].name, "zebra.txt"); + assert_eq!(entries[3].kind, "file"); + } + } +} diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index b1828cc..dc981bb 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -11,23 +11,47 @@ use tauri::State; /// Helper to get the root path (cloned) without joining fn get_project_root(state: &State<'_, SessionState>) -> Result { - let root_guard = state.project_root.lock().map_err(|e| e.to_string())?; - let root = root_guard - .as_ref() - .ok_or_else(|| "No project is currently open.".to_string())?; - Ok(root.clone()) + state.inner().get_project_root() } // ----------------------------------------------------------------------------- // Commands // ----------------------------------------------------------------------------- -#[derive(Serialize)] +#[derive(Serialize, Debug)] pub struct SearchResult { path: String, // Relative path matches: usize, } +/// Searches for files containing the specified query string within the current project. +/// +/// This command performs a case-sensitive substring search across all files in the project, +/// respecting `.gitignore` rules by default. The search is executed on a blocking thread +/// to avoid blocking the async runtime. +/// +/// # Arguments +/// +/// * `query` - The search string to look for in file contents +/// * `state` - The session state containing the project root path +/// +/// # Returns +/// +/// Returns a `Vec` containing: +/// - `path`: The relative path of each matching file +/// - `matches`: The number of matches (currently simplified to 1 per file) +/// +/// # Errors +/// +/// Returns an error if: +/// - No project is currently open +/// - The project root lock cannot be acquired +/// - The search task fails to execute +/// +/// # Note +/// +/// This is a naive implementation that reads entire files into memory. +/// For production use, consider using streaming/buffered reads or the `grep-searcher` crate. #[tauri::command] pub async fn search_files( query: String, @@ -80,3 +104,262 @@ pub async fn search_files( Ok(results) } + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::Mutex; + use tempfile::TempDir; + + /// Helper to create a test SessionState with a given root path + fn create_test_state(root: Option) -> SessionState { + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + SessionState { + project_root: Mutex::new(root), + cancel_tx, + cancel_rx, + } + } + + /// Helper to call search_files logic directly for testing + async fn call_search_files( + query: String, + state: &SessionState, + ) -> Result, String> { + let root = state.get_project_root()?; + let root_clone = root.clone(); + + let results = tauri::async_runtime::spawn_blocking(move || { + let mut matches = Vec::new(); + let walker = WalkBuilder::new(&root_clone).git_ignore(true).build(); + + for result in walker { + match result { + Ok(entry) => { + if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { + continue; + } + + let path = entry.path(); + if let Ok(content) = fs::read_to_string(path) { + if !content.contains(&query) { + continue; + } + let relative = path + .strip_prefix(&root_clone) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + + matches.push(SearchResult { + path: relative, + matches: 1, + }); + } + } + Err(err) => eprintln!("Error walking dir: {}", err), + } + } + matches + }) + .await + .map_err(|e| format!("Search task failed: {}", e))?; + + Ok(results) + } + + #[tokio::test] + async fn test_search_files_no_project_open() { + let state = create_test_state(None); + + let result = call_search_files("test".to_string(), &state).await; + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "No project is currently open."); + } + + #[tokio::test] + async fn test_search_files_finds_matching_file() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.txt"); + fs::write(&test_file, "This is a test file with some content").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let results = call_search_files("test".to_string(), &state).await.unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test.txt"); + assert_eq!(results[0].matches, 1); + } + + #[tokio::test] + async fn test_search_files_multiple_matches() { + let temp_dir = TempDir::new().unwrap(); + + // Create multiple files with matching content + fs::write(temp_dir.path().join("file1.txt"), "hello world").unwrap(); + fs::write(temp_dir.path().join("file2.txt"), "hello again").unwrap(); + fs::write(temp_dir.path().join("file3.txt"), "goodbye").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let results = call_search_files("hello".to_string(), &state) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect(); + assert!(paths.contains(&"file1.txt")); + assert!(paths.contains(&"file2.txt")); + } + + #[tokio::test] + async fn test_search_files_no_matches() { + let temp_dir = TempDir::new().unwrap(); + fs::write(temp_dir.path().join("test.txt"), "This is some content").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let results = call_search_files("nonexistent".to_string(), &state) + .await + .unwrap(); + + assert_eq!(results.len(), 0); + } + + #[tokio::test] + async fn test_search_files_case_sensitive() { + let temp_dir = TempDir::new().unwrap(); + fs::write(temp_dir.path().join("test.txt"), "Hello World").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + // Search for lowercase - should not match + let results = call_search_files("hello".to_string(), &state) + .await + .unwrap(); + assert_eq!(results.len(), 0); + + // Search for correct case - should match + let results = call_search_files("Hello".to_string(), &state) + .await + .unwrap(); + assert_eq!(results.len(), 1); + } + + #[tokio::test] + async fn test_search_files_nested_directories() { + let temp_dir = TempDir::new().unwrap(); + + // Create nested directory structure + let nested_dir = temp_dir.path().join("subdir"); + fs::create_dir(&nested_dir).unwrap(); + + fs::write(temp_dir.path().join("root.txt"), "match").unwrap(); + fs::write(nested_dir.join("nested.txt"), "match").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let results = call_search_files("match".to_string(), &state) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect(); + assert!(paths.contains(&"root.txt")); + assert!(paths.contains(&"subdir/nested.txt") || paths.contains(&"subdir\\nested.txt")); + } + + #[tokio::test] + async fn test_search_files_respects_gitignore() { + let temp_dir = TempDir::new().unwrap(); + + // Initialize git repo (required for ignore crate to respect .gitignore) + std::process::Command::new("git") + .args(["init"]) + .current_dir(temp_dir.path()) + .output() + .unwrap(); + + // Create .gitignore + fs::write(temp_dir.path().join(".gitignore"), "ignored.txt\n").unwrap(); + + // Create files + fs::write(temp_dir.path().join("included.txt"), "searchterm").unwrap(); + fs::write(temp_dir.path().join("ignored.txt"), "searchterm").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let results = call_search_files("searchterm".to_string(), &state) + .await + .unwrap(); + + // Should find the non-ignored file, but not the ignored one + // The gitignore file itself might be included + let has_included = results.iter().any(|r| r.path == "included.txt"); + let has_ignored = results.iter().any(|r| r.path == "ignored.txt"); + + assert!(has_included, "included.txt should be found"); + assert!( + !has_ignored, + "ignored.txt should NOT be found (it's in .gitignore)" + ); + } + + #[tokio::test] + async fn test_search_files_skips_binary_files() { + let temp_dir = TempDir::new().unwrap(); + + // Create a text file + fs::write(temp_dir.path().join("text.txt"), "searchable").unwrap(); + + // Create a binary file (will fail to read as UTF-8) + fs::write(temp_dir.path().join("binary.bin"), [0xFF, 0xFE, 0xFD]).unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let results = call_search_files("searchable".to_string(), &state) + .await + .unwrap(); + + // Should only find the text file + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "text.txt"); + } + + #[tokio::test] + async fn test_search_files_empty_query() { + let temp_dir = TempDir::new().unwrap(); + fs::write(temp_dir.path().join("test.txt"), "content").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let results = call_search_files("".to_string(), &state).await.unwrap(); + + // Empty string is contained in all strings, so should match + assert_eq!(results.len(), 1); + } + + #[tokio::test] + async fn test_get_project_root_helper() { + // Test with no root set + let state = create_test_state(None); + let result = state.get_project_root(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "No project is currently open."); + + // Test with root set + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().to_path_buf(); + let state = create_test_state(Some(path.clone())); + let result = state.get_project_root(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), path); + } +} diff --git a/src-tauri/src/commands/shell.rs b/src-tauri/src/commands/shell.rs index ee1abc5..814456d 100644 --- a/src-tauri/src/commands/shell.rs +++ b/src-tauri/src/commands/shell.rs @@ -10,32 +10,26 @@ use tauri::State; /// Helper to get the root path (cloned) without joining fn get_project_root(state: &State<'_, SessionState>) -> Result { - let root_guard = state.project_root.lock().map_err(|e| e.to_string())?; - let root = root_guard - .as_ref() - .ok_or_else(|| "No project is currently open.".to_string())?; - Ok(root.clone()) + state.inner().get_project_root() } // ----------------------------------------------------------------------------- // Commands // ----------------------------------------------------------------------------- -#[derive(Serialize)] +#[derive(Serialize, Debug)] pub struct CommandOutput { stdout: String, stderr: String, exit_code: i32, } -#[tauri::command] -pub async fn exec_shell( +/// Execute shell command logic (pure function for testing) +async fn exec_shell_impl( command: String, args: Vec, - state: State<'_, SessionState>, + root: PathBuf, ) -> Result { - let root = get_project_root(&state)?; - // Security Allowlist let allowed_commands = [ "git", "cargo", "npm", "yarn", "pnpm", "node", "bun", "ls", "find", "grep", "mkdir", "rm", @@ -46,18 +40,6 @@ pub async fn exec_shell( return Err(format!("Command '{}' is not in the allowlist.", command)); } - // Execute command asynchronously - // Note: This blocks the async runtime thread unless we use tokio::process::Command, - // but tauri::command async wrapper handles offloading reasonably well. - // However, specifically for Tauri, standard Command in an async function runs on the thread pool. - // Ideally we'd use tokio::process::Command but we need to add 'tokio' with 'process' feature. - // For now, standard Command inside tauri async command (which runs on a separate thread) is acceptable - // or we can explicitly spawn_blocking. - // - // Actually, tauri::command async functions run on the tokio runtime. - // Calling std::process::Command::output() blocks the thread. - // We should use tauri::async_runtime::spawn_blocking. - let output = tauri::async_runtime::spawn_blocking(move || { Command::new(&command) .args(&args) @@ -74,3 +56,225 @@ pub async fn exec_shell( exit_code: output.status.code().unwrap_or(-1), }) } + +#[tauri::command] +pub async fn exec_shell( + command: String, + args: Vec, + state: State<'_, SessionState>, +) -> Result { + let root = get_project_root(&state)?; + exec_shell_impl(command, args, root).await +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::Mutex; + use tempfile::TempDir; + + /// Helper to create a test SessionState with a given root path + fn create_test_state(root: Option) -> SessionState { + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + SessionState { + project_root: Mutex::new(root), + cancel_tx, + cancel_rx, + } + } + + // Tests for get_project_root helper function + mod get_project_root_tests { + use super::*; + + #[test] + fn test_get_project_root_no_project() { + let state = create_test_state(None); + let result = state.get_project_root(); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "No project is currently open."); + } + + #[test] + fn test_get_project_root_success() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().to_path_buf(); + let state = create_test_state(Some(path.clone())); + let result = state.get_project_root(); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), path); + } + } + + // Tests for exec_shell command + mod exec_shell_tests { + use super::*; + + #[tokio::test] + async fn test_exec_shell_success() { + let temp_dir = TempDir::new().unwrap(); + fs::write(temp_dir.path().join("test.txt"), "hello").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let root = state.get_project_root().unwrap(); + let result = exec_shell_impl("ls".to_string(), vec![], root).await; + + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.stdout.contains("test.txt")); + assert_eq!(output.exit_code, 0); + } + + #[tokio::test] + async fn test_exec_shell_with_args() { + let temp_dir = TempDir::new().unwrap(); + fs::write(temp_dir.path().join("test.txt"), "hello world").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + // Use grep to search in file + let root = state.get_project_root().unwrap(); + let result = exec_shell_impl( + "grep".to_string(), + vec!["hello".to_string(), "test.txt".to_string()], + root, + ) + .await; + + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.stdout.contains("hello world")); + assert_eq!(output.exit_code, 0); + } + + #[tokio::test] + async fn test_exec_shell_command_not_in_allowlist() { + let temp_dir = TempDir::new().unwrap(); + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let root = state.get_project_root().unwrap(); + let result = exec_shell_impl("curl".to_string(), vec![], root).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not in the allowlist")); + } + + #[tokio::test] + async fn test_exec_shell_no_project_open() { + let state = create_test_state(None); + + let result = state.get_project_root(); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "No project is currently open."); + } + + #[tokio::test] + async fn test_exec_shell_command_failure() { + let temp_dir = TempDir::new().unwrap(); + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + // Try to list a nonexistent file + let root = state.get_project_root().unwrap(); + let result = exec_shell_impl( + "ls".to_string(), + vec!["nonexistent_file_xyz.txt".to_string()], + root, + ) + .await; + + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(!output.stderr.is_empty()); + assert_ne!(output.exit_code, 0); + } + + #[tokio::test] + async fn test_exec_shell_git_command() { + let temp_dir = TempDir::new().unwrap(); + + // Initialize git repo + std::process::Command::new("git") + .args(["init"]) + .current_dir(temp_dir.path()) + .output() + .unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let root = state.get_project_root().unwrap(); + let result = exec_shell_impl("git".to_string(), vec!["status".to_string()], root).await; + + assert!(result.is_ok()); + let output = result.unwrap(); + assert_eq!(output.exit_code, 0); + assert!(!output.stdout.is_empty()); + } + + #[tokio::test] + async fn test_exec_shell_mkdir_command() { + let temp_dir = TempDir::new().unwrap(); + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let root = state.get_project_root().unwrap(); + let result = + exec_shell_impl("mkdir".to_string(), vec!["test_dir".to_string()], root).await; + + assert!(result.is_ok()); + let output = result.unwrap(); + assert_eq!(output.exit_code, 0); + assert!(temp_dir.path().join("test_dir").exists()); + } + + #[tokio::test] + async fn test_exec_shell_all_allowed_commands() { + let temp_dir = TempDir::new().unwrap(); + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let allowed_commands = [ + "git", "cargo", "npm", "yarn", "pnpm", "node", "bun", "ls", "find", "grep", + "mkdir", "rm", "mv", "cp", "touch", "rustc", "rustfmt", + ]; + + let root = state.get_project_root().unwrap(); + for cmd in allowed_commands { + // Just verify the command is allowed, not necessarily successful + let result = exec_shell_impl(cmd.to_string(), vec![], root.clone()).await; + + // Should not fail with "not in allowlist" error + if result.is_err() { + assert!(!result.unwrap_err().contains("not in the allowlist")); + } + } + } + + #[tokio::test] + async fn test_exec_shell_output_encoding() { + let temp_dir = TempDir::new().unwrap(); + fs::write(temp_dir.path().join("test.txt"), "Hello 世界").unwrap(); + + let state = create_test_state(Some(temp_dir.path().to_path_buf())); + + let root = state.get_project_root().unwrap(); + let result = exec_shell_impl( + "grep".to_string(), + vec!["Hello".to_string(), "test.txt".to_string()], + root, + ) + .await; + + assert!(result.is_ok()); + let output = result.unwrap(); + // Should handle UTF-8 content + assert!(output.stdout.contains("Hello")); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5180afc..deeb908 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,9 @@ mod commands; mod llm; mod state; +#[cfg(test)] +pub mod test_utils; + use state::SessionState; #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/src-tauri/src/llm/prompts.rs b/src-tauri/src/llm/prompts.rs index 65870d4..14d4951 100644 --- a/src-tauri/src/llm/prompts.rs +++ b/src-tauri/src/llm/prompts.rs @@ -89,3 +89,148 @@ REMEMBER: Remember: You are an autonomous agent that can both explain concepts and take action. Choose appropriately based on the user's request. "#; + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_system_prompt_not_empty() { + assert!(SYSTEM_PROMPT.len() > 100); + } + + #[test] + fn test_system_prompt_contains_key_instructions() { + // Check for critical instruction sections + assert!(SYSTEM_PROMPT.contains("CRITICAL INSTRUCTIONS")); + assert!(SYSTEM_PROMPT.contains("YOUR CAPABILITIES")); + assert!(SYSTEM_PROMPT.contains("YOUR WORKFLOW")); + assert!(SYSTEM_PROMPT.contains("CRITICAL RULES")); + } + + #[test] + fn test_system_prompt_mentions_all_tools() { + // Verify all tools are mentioned + assert!(SYSTEM_PROMPT.contains("read_file")); + assert!(SYSTEM_PROMPT.contains("write_file")); + assert!(SYSTEM_PROMPT.contains("list_directory")); + assert!(SYSTEM_PROMPT.contains("search_files")); + assert!(SYSTEM_PROMPT.contains("exec_shell")); + } + + #[test] + fn test_system_prompt_has_example_section() { + assert!(SYSTEM_PROMPT.contains("EXAMPLES OF CORRECT BEHAVIOR")); + assert!(SYSTEM_PROMPT.contains("EXAMPLES OF INCORRECT BEHAVIOR")); + } + + #[test] + fn test_system_prompt_emphasizes_distinction() { + // Check that the prompt emphasizes the difference between examples and implementation + assert!(SYSTEM_PROMPT.contains("Distinguish Between Examples and Implementation")); + assert!(SYSTEM_PROMPT.contains("Teaching vs Implementing")); + } + + #[test] + fn test_system_prompt_has_security_warning() { + // Check for security-related instructions + assert!(SYSTEM_PROMPT.contains("Read Before Write")); + assert!(SYSTEM_PROMPT.contains("Complete Files Only")); + } + + #[test] + fn test_system_prompt_mentions_write_file_overwrites() { + // Important warning about write_file behavior + assert!(SYSTEM_PROMPT.contains("OVERWRITES")); + assert!(SYSTEM_PROMPT.contains("COMPLETE file content")); + } + + #[test] + fn test_system_prompt_has_correct_examples() { + // Check for example scenarios + assert!(SYSTEM_PROMPT.contains("Example 1")); + assert!(SYSTEM_PROMPT.contains("Example 2")); + assert!(SYSTEM_PROMPT.contains("Example 3")); + assert!(SYSTEM_PROMPT.contains("Example 4")); + } + + #[test] + fn test_system_prompt_discourages_placeholders() { + // Check that it warns against using placeholders + assert!(SYSTEM_PROMPT.contains("placeholders")); + assert!(SYSTEM_PROMPT.contains("rest of code")); + } + + #[test] + fn test_system_prompt_encourages_autonomy() { + // Check that it encourages the agent to take initiative + assert!(SYSTEM_PROMPT.contains("Take Initiative")); + assert!(SYSTEM_PROMPT.contains("autonomous")); + } + + #[test] + fn test_system_prompt_mentions_living_spec() { + // Check for reference to .living_spec + assert!(SYSTEM_PROMPT.contains(".living_spec/README.md")); + } + + #[test] + fn test_system_prompt_has_workflow_steps() { + // Check for workflow guidance + assert!(SYSTEM_PROMPT.contains("Understand")); + assert!(SYSTEM_PROMPT.contains("Explore")); + assert!(SYSTEM_PROMPT.contains("Implement")); + assert!(SYSTEM_PROMPT.contains("Verify")); + assert!(SYSTEM_PROMPT.contains("Report")); + } + + #[test] + fn test_system_prompt_uses_past_tense_for_reporting() { + // Check that it instructs to report in past tense + assert!(SYSTEM_PROMPT.contains("past tense")); + } + + #[test] + fn test_system_prompt_format_is_valid() { + // Basic format checks + assert!(SYSTEM_PROMPT.starts_with("You are an AI Agent")); + assert!(SYSTEM_PROMPT.contains("Remember:")); + } + + #[test] + fn test_system_prompt_has_keyword_guidance() { + // Check for keyword-based decision guidance + assert!(SYSTEM_PROMPT.contains("show")); + assert!(SYSTEM_PROMPT.contains("create")); + assert!(SYSTEM_PROMPT.contains("add")); + assert!(SYSTEM_PROMPT.contains("implement")); + assert!(SYSTEM_PROMPT.contains("fix")); + } + + #[test] + fn test_system_prompt_length_reasonable() { + // Ensure prompt is substantial but not excessively long + let line_count = SYSTEM_PROMPT.lines().count(); + assert!(line_count > 50); + assert!(line_count < 300); + } + + #[test] + fn test_system_prompt_has_shell_command_examples() { + // Check for shell command mentions + assert!(SYSTEM_PROMPT.contains("cargo")); + assert!(SYSTEM_PROMPT.contains("npm")); + assert!(SYSTEM_PROMPT.contains("git")); + } + + #[test] + fn test_system_prompt_warns_against_announcements() { + // Check that it discourages announcing actions + assert!(SYSTEM_PROMPT.contains("Don't announce")); + assert!(SYSTEM_PROMPT.contains("Be Direct")); + } +} diff --git a/src-tauri/src/llm/types.rs b/src-tauri/src/llm/types.rs index ec24027..101d5a0 100644 --- a/src-tauri/src/llm/types.rs +++ b/src-tauri/src/llm/types.rs @@ -73,3 +73,281 @@ pub trait ModelProvider: Send + Sync { tools: &[ToolDefinition], ) -> Result; } + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_role_serialization() { + let system = Role::System; + let user = Role::User; + let assistant = Role::Assistant; + let tool = Role::Tool; + + assert_eq!(serde_json::to_string(&system).unwrap(), r#""system""#); + assert_eq!(serde_json::to_string(&user).unwrap(), r#""user""#); + assert_eq!(serde_json::to_string(&assistant).unwrap(), r#""assistant""#); + assert_eq!(serde_json::to_string(&tool).unwrap(), r#""tool""#); + } + + #[test] + fn test_role_deserialization() { + let system: Role = serde_json::from_str(r#""system""#).unwrap(); + let user: Role = serde_json::from_str(r#""user""#).unwrap(); + let assistant: Role = serde_json::from_str(r#""assistant""#).unwrap(); + let tool: Role = serde_json::from_str(r#""tool""#).unwrap(); + + assert_eq!(system, Role::System); + assert_eq!(user, Role::User); + assert_eq!(assistant, Role::Assistant); + assert_eq!(tool, Role::Tool); + } + + #[test] + fn test_message_serialization_simple() { + let msg = Message { + role: Role::User, + content: "Hello".to_string(), + tool_calls: None, + tool_call_id: None, + }; + + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""role":"user""#)); + assert!(json.contains(r#""content":"Hello""#)); + assert!(!json.contains("tool_calls")); + assert!(!json.contains("tool_call_id")); + } + + #[test] + fn test_message_serialization_with_tool_calls() { + let msg = Message { + role: Role::Assistant, + content: "I'll help you with that".to_string(), + tool_calls: Some(vec![ToolCall { + id: Some("call_123".to_string()), + function: FunctionCall { + name: "read_file".to_string(), + arguments: r#"{"path":"test.txt"}"#.to_string(), + }, + kind: "function".to_string(), + }]), + tool_call_id: None, + }; + + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""role":"assistant""#)); + assert!(json.contains("tool_calls")); + assert!(json.contains("read_file")); + } + + #[test] + fn test_message_deserialization() { + let json = r#"{ + "role": "user", + "content": "Hello world" + }"#; + + let msg: Message = serde_json::from_str(json).unwrap(); + assert_eq!(msg.role, Role::User); + assert_eq!(msg.content, "Hello world"); + assert!(msg.tool_calls.is_none()); + assert!(msg.tool_call_id.is_none()); + } + + #[test] + fn test_tool_call_serialization() { + let tool_call = ToolCall { + id: Some("call_abc".to_string()), + function: FunctionCall { + name: "write_file".to_string(), + arguments: r#"{"path":"out.txt","content":"data"}"#.to_string(), + }, + kind: "function".to_string(), + }; + + let json = serde_json::to_string(&tool_call).unwrap(); + assert!(json.contains(r#""id":"call_abc""#)); + assert!(json.contains(r#""name":"write_file""#)); + assert!(json.contains(r#""type":"function""#)); + } + + #[test] + fn test_tool_call_deserialization() { + let json = r#"{ + "id": "call_xyz", + "function": { + "name": "list_directory", + "arguments": "{\"path\":\".\"}" + }, + "type": "function" + }"#; + + let tool_call: ToolCall = serde_json::from_str(json).unwrap(); + assert_eq!(tool_call.id, Some("call_xyz".to_string())); + assert_eq!(tool_call.function.name, "list_directory"); + assert_eq!(tool_call.kind, "function"); + } + + #[test] + fn test_function_call_with_complex_arguments() { + let func_call = FunctionCall { + name: "exec_shell".to_string(), + arguments: r#"{"command":"git","args":["status","--short"]}"#.to_string(), + }; + + let json = serde_json::to_string(&func_call).unwrap(); + assert!(json.contains("exec_shell")); + assert!(json.contains("git")); + assert!(json.contains("status")); + } + + #[test] + fn test_tool_definition_serialization() { + let tool_def = ToolDefinition { + kind: "function".to_string(), + function: ToolFunctionDefinition { + name: "read_file".to_string(), + description: "Reads a file from disk".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path" + } + }, + "required": ["path"] + }), + }, + }; + + let json = serde_json::to_string(&tool_def).unwrap(); + assert!(json.contains(r#""type":"function""#)); + assert!(json.contains("read_file")); + assert!(json.contains("Reads a file from disk")); + } + + #[test] + fn test_tool_definition_deserialization() { + let json = r#"{ + "type": "function", + "function": { + "name": "search_files", + "description": "Search for files", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"] + } + } + }"#; + + let tool_def: ToolDefinition = serde_json::from_str(json).unwrap(); + assert_eq!(tool_def.kind, "function"); + assert_eq!(tool_def.function.name, "search_files"); + assert_eq!(tool_def.function.description, "Search for files"); + } + + #[test] + fn test_completion_response_with_content() { + let response = CompletionResponse { + content: Some("Here is the answer".to_string()), + tool_calls: None, + }; + + assert!(response.content.is_some()); + assert!(response.tool_calls.is_none()); + } + + #[test] + fn test_completion_response_with_tool_calls() { + let response = CompletionResponse { + content: None, + tool_calls: Some(vec![ToolCall { + id: Some("call_1".to_string()), + function: FunctionCall { + name: "test_func".to_string(), + arguments: "{}".to_string(), + }, + kind: "function".to_string(), + }]), + }; + + assert!(response.content.is_none()); + assert!(response.tool_calls.is_some()); + assert_eq!(response.tool_calls.unwrap().len(), 1); + } + + #[test] + fn test_message_with_tool_call_id() { + let msg = Message { + role: Role::Tool, + content: "File content here".to_string(), + tool_calls: None, + tool_call_id: Some("call_123".to_string()), + }; + + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""role":"tool""#)); + assert!(json.contains(r#""tool_call_id":"call_123""#)); + } + + #[test] + fn test_tool_call_without_id() { + let tool_call = ToolCall { + id: None, + function: FunctionCall { + name: "test".to_string(), + arguments: "{}".to_string(), + }, + kind: "function".to_string(), + }; + + let json = serde_json::to_string(&tool_call).unwrap(); + // When id is None, it serializes as null, not omitted + assert!(json.contains(r#""id":null"#)); + } + + #[test] + fn test_message_clone() { + let msg = Message { + role: Role::User, + content: "test".to_string(), + tool_calls: None, + tool_call_id: None, + }; + + let cloned = msg.clone(); + assert_eq!(msg.role, cloned.role); + assert_eq!(msg.content, cloned.content); + } + + #[test] + fn test_role_equality() { + assert_eq!(Role::User, Role::User); + assert_ne!(Role::User, Role::Assistant); + assert_ne!(Role::System, Role::Tool); + } + + #[test] + fn test_tool_function_definition_with_no_parameters() { + let func_def = ToolFunctionDefinition { + name: "simple_tool".to_string(), + description: "A simple tool".to_string(), + parameters: serde_json::json!({}), + }; + + assert_eq!(func_def.name, "simple_tool"); + assert_eq!(func_def.parameters, serde_json::json!({})); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 6468d29..13414b8 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -18,3 +18,198 @@ impl Default for SessionState { } } } + +impl SessionState { + /// Get the project root path from the session state + /// Returns an error if no project is currently open + pub fn get_project_root(&self) -> Result { + let root_guard = self.project_root.lock().map_err(|e| e.to_string())?; + let root = root_guard + .as_ref() + .ok_or_else(|| "No project is currently open.".to_string())?; + Ok(root.clone()) + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_session_state_default() { + let state = SessionState::default(); + + // Check that project_root is None + let root = state.project_root.lock().unwrap(); + assert!(root.is_none()); + } + + #[test] + fn test_session_state_cancel_channel() { + let state = SessionState::default(); + + // Initial value should be false + assert!(!(*state.cancel_rx.borrow())); + + // Send a cancel signal + state.cancel_tx.send(true).unwrap(); + + // Receiver should now see true + assert!(*state.cancel_rx.borrow()); + } + + #[test] + fn test_session_state_set_project_root() { + let state = SessionState::default(); + + // Set a project root + let test_path = PathBuf::from("/test/path"); + { + let mut root = state.project_root.lock().unwrap(); + *root = Some(test_path.clone()); + } + + // Verify it was set + let root = state.project_root.lock().unwrap(); + assert_eq!(root.as_ref().unwrap(), &test_path); + } + + #[test] + fn test_session_state_clear_project_root() { + let state = SessionState::default(); + + // Set a project root + { + let mut root = state.project_root.lock().unwrap(); + *root = Some(PathBuf::from("/test/path")); + } + + // Clear it + { + let mut root = state.project_root.lock().unwrap(); + *root = None; + } + + // Verify it's cleared + let root = state.project_root.lock().unwrap(); + assert!(root.is_none()); + } + + #[test] + fn test_session_state_multiple_cancel_signals() { + let state = SessionState::default(); + + // Send multiple signals + state.cancel_tx.send(true).unwrap(); + assert!(*state.cancel_rx.borrow()); + + state.cancel_tx.send(false).unwrap(); + assert!(!(*state.cancel_rx.borrow())); + + state.cancel_tx.send(true).unwrap(); + assert!(*state.cancel_rx.borrow()); + } + + #[test] + fn test_session_state_cancel_rx_clone() { + let state = SessionState::default(); + + // Clone the receiver + let rx_clone = state.cancel_rx.clone(); + + // Send a signal + state.cancel_tx.send(true).unwrap(); + + // Both receivers should see the new value + assert!(*state.cancel_rx.borrow()); + assert!(*rx_clone.borrow()); + } + + #[test] + fn test_session_state_mutex_not_poisoned() { + let state = SessionState::default(); + + // Lock and unlock multiple times + for i in 0..5 { + let mut root = state.project_root.lock().unwrap(); + *root = Some(PathBuf::from(format!("/path/{}", i))); + } + + // Should still be able to lock + let root = state.project_root.lock().unwrap(); + assert!(root.is_some()); + } + + #[test] + fn test_session_state_project_root_with_different_paths() { + let state = SessionState::default(); + + let paths = vec![ + PathBuf::from("/absolute/path"), + PathBuf::from("relative/path"), + PathBuf::from("./current/dir"), + PathBuf::from("../parent/dir"), + ]; + + for path in paths { + let mut root = state.project_root.lock().unwrap(); + *root = Some(path.clone()); + drop(root); + + let root = state.project_root.lock().unwrap(); + assert_eq!(root.as_ref().unwrap(), &path); + } + } + + #[test] + fn test_session_state_cancel_channel_independent_of_root() { + let state = SessionState::default(); + + // Set project root + { + let mut root = state.project_root.lock().unwrap(); + *root = Some(PathBuf::from("/test")); + } + + // Cancel channel should still work independently + state.cancel_tx.send(true).unwrap(); + assert!(*state.cancel_rx.borrow()); + + // Clear project root + { + let mut root = state.project_root.lock().unwrap(); + *root = None; + } + + // Cancel channel should still work + state.cancel_tx.send(false).unwrap(); + assert!(!(*state.cancel_rx.borrow())); + } + + #[test] + fn test_session_state_multiple_instances() { + let state1 = SessionState::default(); + let state2 = SessionState::default(); + + // Set different values + { + let mut root1 = state1.project_root.lock().unwrap(); + *root1 = Some(PathBuf::from("/path1")); + } + { + let mut root2 = state2.project_root.lock().unwrap(); + *root2 = Some(PathBuf::from("/path2")); + } + + // Verify they're independent + let root1 = state1.project_root.lock().unwrap(); + let root2 = state2.project_root.lock().unwrap(); + assert_eq!(root1.as_ref().unwrap(), &PathBuf::from("/path1")); + assert_eq!(root2.as_ref().unwrap(), &PathBuf::from("/path2")); + } +} diff --git a/src-tauri/src/test_utils.rs b/src-tauri/src/test_utils.rs new file mode 100644 index 0000000..41f092b --- /dev/null +++ b/src-tauri/src/test_utils.rs @@ -0,0 +1,107 @@ +use crate::commands::fs::StoreOps; +use serde_json::json; +use std::collections::HashMap; +use std::sync::Mutex; + +/// Mock store for testing - stores data in memory +pub struct MockStore { + data: Mutex>, +} + +impl MockStore { + pub fn new() -> Self { + Self { + data: Mutex::new(HashMap::new()), + } + } + + /// Create a MockStore with initial data + pub fn with_data(initial: HashMap) -> Self { + Self { + data: Mutex::new(initial), + } + } +} + +impl Default for MockStore { + fn default() -> Self { + Self::new() + } +} + +impl StoreOps for MockStore { + fn get(&self, key: &str) -> Option { + self.data.lock().unwrap().get(key).cloned() + } + + fn set(&self, key: &str, value: serde_json::Value) { + self.data.lock().unwrap().insert(key.to_string(), value); + } + + fn delete(&self, key: &str) { + self.data.lock().unwrap().remove(key); + } + + fn save(&self) -> Result<(), String> { + // Mock implementation - always succeeds + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mock_store_new() { + let store = MockStore::new(); + assert!(store.get("key").is_none()); + } + + #[test] + fn test_mock_store_set_and_get() { + let store = MockStore::new(); + store.set("key", json!("value")); + assert_eq!(store.get("key"), Some(json!("value"))); + } + + #[test] + fn test_mock_store_delete() { + let store = MockStore::new(); + store.set("key", json!("value")); + store.delete("key"); + assert!(store.get("key").is_none()); + } + + #[test] + fn test_mock_store_save() { + let store = MockStore::new(); + assert!(store.save().is_ok()); + } + + #[test] + fn test_mock_store_overwrite() { + let store = MockStore::new(); + store.set("key", json!("old")); + store.set("key", json!("new")); + assert_eq!(store.get("key"), Some(json!("new"))); + } + + #[test] + fn test_mock_store_multiple_keys() { + let store = MockStore::new(); + store.set("key1", json!("value1")); + store.set("key2", json!("value2")); + assert_eq!(store.get("key1"), Some(json!("value1"))); + assert_eq!(store.get("key2"), Some(json!("value2"))); + } + + #[test] + fn test_mock_store_with_data() { + let mut initial = HashMap::new(); + initial.insert("existing".to_string(), json!("data")); + + let store = MockStore::with_data(initial); + assert_eq!(store.get("existing"), Some(json!("data"))); + } +}