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.
|
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-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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!({}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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