Wrote some tests.

This commit is contained in:
Dave
2026-01-27 14:45:28 +00:00
parent 8b14aa1f6f
commit 97b0ce1b58
12 changed files with 2029 additions and 97 deletions

View File

@@ -2,6 +2,68 @@
This template should help get you started developing with Tauri, React and Typescript in Vite. 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
```

View 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
View File

@@ -2082,6 +2082,7 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-store", "tauri-plugin-store",
"tempfile",
"tokio", "tokio",
"uuid", "uuid",
"walkdir", "walkdir",

View File

@@ -33,3 +33,6 @@ async-trait = "0.1.89"
tauri-plugin-store = "2.4.2" tauri-plugin-store = "2.4.2"
tokio = { version = "1", features = ["sync"] } tokio = { version = "1", features = ["sync"] }
eventsource-stream = "0.2.3" eventsource-stream = "0.2.3"
[dev-dependencies]
tempfile = "3"

View File

@@ -1,4 +1,6 @@
use crate::state::SessionState; use crate::state::SessionState;
#[cfg(test)]
use crate::test_utils::MockStore;
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
use std::fs; use std::fs;
@@ -10,18 +12,54 @@ const STORE_PATH: &str = "store.json";
const KEY_LAST_PROJECT: &str = "last_project_path"; const KEY_LAST_PROJECT: &str = "last_project_path";
const KEY_SELECTED_MODEL: &str = "selected_model"; 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 // Helper Functions
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/// Resolves a relative path against the active project root. /// Resolves a relative path against the active project root (pure function for testing).
/// Returns error if no project is open or if path attempts traversal (..). /// Returns error if path attempts traversal (..).
fn resolve_path(state: &State<'_, SessionState>, relative_path: &str) -> Result<PathBuf, String> { fn resolve_path_impl(root: PathBuf, 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())?;
// specific check for traversal // specific check for traversal
if relative_path.contains("..") { if relative_path.contains("..") {
return Err("Security Violation: Directory traversal ('..') is not allowed.".to_string()); 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) 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 // 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] #[tauri::command]
pub async fn open_project( pub async fn open_project(
app: AppHandle, app: AppHandle,
path: String, path: String,
state: State<'_, SessionState>, state: State<'_, SessionState>,
) -> Result<String, String> { ) -> 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 let store = app
.store(STORE_PATH) .store(STORE_PATH)
.map_err(|e| format!("Failed to access store: {}", e))?; .map_err(|e| format!("Failed to access store: {}", e))?;
store.set(KEY_LAST_PROJECT, json!(path)); let wrapper = TauriStoreWrapper { store: &store };
let _ = store.save(); open_project_impl(path, state.inner(), &wrapper).await
println!("Project opened: {:?}", p);
Ok(path)
} }
#[tauri::command] /// Close project implementation (testable with store abstraction)
pub async fn close_project(app: AppHandle, state: State<'_, SessionState>) -> Result<(), String> { fn close_project_impl(state: &SessionState, store: &dyn StoreOps) -> Result<(), String> {
// Clear session state // Clear session state
{ {
let mut root = state.project_root.lock().map_err(|e| e.to_string())?; 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 // Clear from store
let store = app
.store(STORE_PATH)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.delete(KEY_LAST_PROJECT); store.delete(KEY_LAST_PROJECT);
let _ = store.save(); store.save()?;
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn get_current_project( pub async fn close_project(app: AppHandle, state: State<'_, SessionState>) -> Result<(), String> {
app: AppHandle, let store = app
state: State<'_, SessionState>, .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> { ) -> Result<Option<String>, String> {
// 1. Check in-memory state // 1. Check in-memory state
{ {
@@ -107,10 +172,6 @@ pub async fn get_current_project(
} }
// 2. Check store // 2. Check store
let store = app
.store(STORE_PATH)
.map_err(|e| format!("Failed to access store: {}", e))?;
if let Some(path_str) = store if let Some(path_str) = store
.get(KEY_LAST_PROJECT) .get(KEY_LAST_PROJECT)
.as_ref() .as_ref()
@@ -129,11 +190,19 @@ pub async fn get_current_project(
} }
#[tauri::command] #[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 let store = app
.store(STORE_PATH) .store(STORE_PATH)
.map_err(|e| format!("Failed to access store: {}", e))?; .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 if let Some(model) = store
.get(KEY_SELECTED_MODEL) .get(KEY_SELECTED_MODEL)
.as_ref() .as_ref()
@@ -145,19 +214,32 @@ pub async fn get_model_preference(app: AppHandle) -> Result<Option<String>, Stri
} }
#[tauri::command] #[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 let store = app
.store(STORE_PATH) .store(STORE_PATH)
.map_err(|e| format!("Failed to access store: {}", e))?; .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)); store.set(KEY_SELECTED_MODEL, json!(model));
let _ = store.save(); store.save()?;
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result<String, String> { pub async fn set_model_preference(app: AppHandle, model: String) -> Result<(), String> {
let full_path = resolve_path(&state, &path)?; 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 || { tauri::async_runtime::spawn_blocking(move || {
fs::read_to_string(&full_path).map_err(|e| format!("Failed to read file: {}", e)) 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] #[tauri::command]
pub async fn write_file( pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result<String, String> {
path: String,
content: String,
state: State<'_, SessionState>,
) -> Result<(), String> {
let full_path = resolve_path(&state, &path)?; 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 || { tauri::async_runtime::spawn_blocking(move || {
// Ensure parent directory exists // Ensure parent directory exists
if let Some(parent) = full_path.parent() { if let Some(parent) = full_path.parent() {
@@ -186,19 +268,24 @@ pub async fn write_file(
.map_err(|e| format!("Task failed: {}", e))? .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 { pub struct FileEntry {
name: String, name: String,
kind: String, // "file" | "dir" kind: String, // "file" | "dir"
} }
#[tauri::command] /// List directory implementation (pure function for testing)
pub async fn list_directory( async fn list_directory_impl(full_path: PathBuf) -> Result<Vec<FileEntry>, String> {
path: String,
state: State<'_, SessionState>,
) -> Result<Vec<FileEntry>, String> {
let full_path = resolve_path(&state, &path)?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let entries = fs::read_dir(&full_path).map_err(|e| format!("Failed to read dir: {}", e))?; 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 .await
.map_err(|e| format!("Task failed: {}", e))? .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");
}
}
}

View File

@@ -11,23 +11,47 @@ use tauri::State;
/// Helper to get the root path (cloned) without joining /// Helper to get the root path (cloned) without joining
fn get_project_root(state: &State<'_, SessionState>) -> Result<PathBuf, String> { fn get_project_root(state: &State<'_, SessionState>) -> Result<PathBuf, String> {
let root_guard = state.project_root.lock().map_err(|e| e.to_string())?; state.inner().get_project_root()
let root = root_guard
.as_ref()
.ok_or_else(|| "No project is currently open.".to_string())?;
Ok(root.clone())
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Commands // Commands
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
#[derive(Serialize)] #[derive(Serialize, Debug)]
pub struct SearchResult { pub struct SearchResult {
path: String, // Relative path path: String, // Relative path
matches: usize, 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] #[tauri::command]
pub async fn search_files( pub async fn search_files(
query: String, query: String,
@@ -80,3 +104,262 @@ pub async fn search_files(
Ok(results) 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);
}
}

View File

@@ -10,32 +10,26 @@ use tauri::State;
/// Helper to get the root path (cloned) without joining /// Helper to get the root path (cloned) without joining
fn get_project_root(state: &State<'_, SessionState>) -> Result<PathBuf, String> { fn get_project_root(state: &State<'_, SessionState>) -> Result<PathBuf, String> {
let root_guard = state.project_root.lock().map_err(|e| e.to_string())?; state.inner().get_project_root()
let root = root_guard
.as_ref()
.ok_or_else(|| "No project is currently open.".to_string())?;
Ok(root.clone())
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Commands // Commands
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
#[derive(Serialize)] #[derive(Serialize, Debug)]
pub struct CommandOutput { pub struct CommandOutput {
stdout: String, stdout: String,
stderr: String, stderr: String,
exit_code: i32, exit_code: i32,
} }
#[tauri::command] /// Execute shell command logic (pure function for testing)
pub async fn exec_shell( async fn exec_shell_impl(
command: String, command: String,
args: Vec<String>, args: Vec<String>,
state: State<'_, SessionState>, root: PathBuf,
) -> Result<CommandOutput, String> { ) -> Result<CommandOutput, String> {
let root = get_project_root(&state)?;
// Security Allowlist // Security Allowlist
let allowed_commands = [ let allowed_commands = [
"git", "cargo", "npm", "yarn", "pnpm", "node", "bun", "ls", "find", "grep", "mkdir", "rm", "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)); 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 || { let output = tauri::async_runtime::spawn_blocking(move || {
Command::new(&command) Command::new(&command)
.args(&args) .args(&args)
@@ -74,3 +56,225 @@ pub async fn exec_shell(
exit_code: output.status.code().unwrap_or(-1), 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"));
}
}
}

View File

@@ -2,6 +2,9 @@ mod commands;
mod llm; mod llm;
mod state; mod state;
#[cfg(test)]
pub mod test_utils;
use state::SessionState; use state::SessionState;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]

View File

@@ -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. 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"));
}
}

View File

@@ -73,3 +73,281 @@ pub trait ModelProvider: Send + Sync {
tools: &[ToolDefinition], tools: &[ToolDefinition],
) -> Result<CompletionResponse, String>; ) -> 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!({}));
}
}

View File

@@ -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
View 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")));
}
}