Wrote some tests.
This commit is contained in:
66
README.md
66
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
|
||||
```
|
||||
|
||||
31
src-tauri/.config/nextest.toml
Normal file
31
src-tauri/.config/nextest.toml
Normal file
@@ -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
|
||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -2082,6 +2082,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-store",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<serde_json::Value>;
|
||||
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<tauri::Wry>,
|
||||
}
|
||||
|
||||
impl<'a> StoreOps for TauriStoreWrapper<'a> {
|
||||
fn get(&self, key: &str) -> Option<serde_json::Value> {
|
||||
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<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
// 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<PathBuf, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<Option<String>, 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<Option<String>, String> {
|
||||
pub async fn get_current_project(
|
||||
app: AppHandle,
|
||||
state: State<'_, SessionState>,
|
||||
) -> Result<Option<String>, 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<Option<String>, 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<Option<String>, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_model_preference(app: AppHandle, model: String) -> Result<(), String> {
|
||||
pub async fn get_model_preference(app: AppHandle) -> Result<Option<String>, 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<String, String> {
|
||||
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<String, String> {
|
||||
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<S
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_file(
|
||||
path: String,
|
||||
content: String,
|
||||
state: State<'_, SessionState>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result<String, String> {
|
||||
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<Vec<FileEntry>, String> {
|
||||
let full_path = resolve_path(&state, &path)?;
|
||||
|
||||
/// List directory implementation (pure function for testing)
|
||||
async fn list_directory_impl(full_path: PathBuf) -> Result<Vec<FileEntry>, 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<Vec<FileEntry>, 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<PathBuf>) -> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,23 +11,47 @@ use tauri::State;
|
||||
|
||||
/// Helper to get the root path (cloned) without joining
|
||||
fn get_project_root(state: &State<'_, SessionState>) -> Result<PathBuf, String> {
|
||||
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<SearchResult>` 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<PathBuf>) -> 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<Vec<SearchResult>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,32 +10,26 @@ use tauri::State;
|
||||
|
||||
/// Helper to get the root path (cloned) without joining
|
||||
fn get_project_root(state: &State<'_, SessionState>) -> Result<PathBuf, String> {
|
||||
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<String>,
|
||||
state: State<'_, SessionState>,
|
||||
root: PathBuf,
|
||||
) -> Result<CommandOutput, String> {
|
||||
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<String>,
|
||||
state: State<'_, SessionState>,
|
||||
) -> Result<CommandOutput, String> {
|
||||
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<PathBuf>) -> 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,3 +73,281 @@ pub trait ModelProvider: Send + Sync {
|
||||
tools: &[ToolDefinition],
|
||||
) -> Result<CompletionResponse, String>;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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!({}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
107
src-tauri/src/test_utils.rs
Normal file
107
src-tauri/src/test_utils.rs
Normal file
@@ -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<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
impl MockStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
data: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a MockStore with initial data
|
||||
pub fn with_data(initial: HashMap<String, serde_json::Value>) -> 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<serde_json::Value> {
|
||||
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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user