Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/haystack_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ repository = "https://github.com/terraphim/terraphim-ai"

[dependencies]
terraphim_types = { path = "../terraphim_types", version = "1.0.0" }

[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
137 changes: 137 additions & 0 deletions crates/haystack_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,140 @@ pub trait HaystackProvider {
#[allow(async_fn_in_trait)]
async fn search(&self, query: &SearchQuery) -> Result<Vec<Document>, Self::Error>;
}

#[cfg(test)]
mod tests {
use super::*;
use terraphim_types::NormalizedTermValue;

/// A concrete test provider that returns pre-configured documents.
struct TestProvider {
documents: Vec<Document>,
}

impl TestProvider {
fn with_docs(documents: Vec<Document>) -> Self {
Self { documents }
}

fn empty() -> Self {
Self {
documents: Vec::new(),
}
}
}

/// Error type for the test provider.
#[derive(Debug)]
struct TestProviderError(String);

impl std::fmt::Display for TestProviderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "TestProviderError: {}", self.0)
}
}

impl HaystackProvider for TestProvider {
type Error = TestProviderError;

async fn search(&self, _query: &SearchQuery) -> Result<Vec<Document>, Self::Error> {
Ok(self.documents.clone())
}
}

/// A provider that always returns an error.
struct FailingProvider;

impl HaystackProvider for FailingProvider {
type Error = TestProviderError;

async fn search(&self, _query: &SearchQuery) -> Result<Vec<Document>, Self::Error> {
Err(TestProviderError("search failed".to_string()))
}
}

fn make_query(term: &str) -> SearchQuery {
SearchQuery {
search_term: NormalizedTermValue::from(term),
..Default::default()
}
}

fn make_document(id: &str, title: &str) -> Document {
Document {
id: id.to_string(),
title: title.to_string(),
..Default::default()
}
}

#[tokio::test]
async fn test_provider_returns_documents() {
let provider = TestProvider::with_docs(vec![
make_document("1", "First Result"),
make_document("2", "Second Result"),
]);
let results = provider.search(&make_query("test")).await.unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].title, "First Result");
assert_eq!(results[1].title, "Second Result");
}

#[tokio::test]
async fn test_provider_returns_empty_results() {
let provider = TestProvider::empty();
let results = provider.search(&make_query("nothing")).await.unwrap();
assert!(results.is_empty());
}

#[tokio::test]
async fn test_provider_error_propagation() {
let provider = FailingProvider;
let result = provider.search(&make_query("test")).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("search failed"));
}

#[tokio::test]
async fn test_error_type_is_send_sync() {
fn assert_send_sync<T: Send + Sync + 'static>() {}
assert_send_sync::<TestProviderError>();
}

#[tokio::test]
async fn test_provider_with_empty_search_term() {
let provider = TestProvider::with_docs(vec![make_document("1", "Doc")]);
let results = provider.search(&make_query("")).await.unwrap();
assert_eq!(results.len(), 1);
}

#[tokio::test]
async fn test_provider_with_special_characters_in_query() {
let provider = TestProvider::with_docs(vec![make_document("1", "Doc")]);
let results = provider
.search(&make_query("test & <script>alert(1)</script>"))
.await
.unwrap();
assert_eq!(results.len(), 1);
}

#[tokio::test]
async fn test_concurrent_searches() {
let provider =
std::sync::Arc::new(TestProvider::with_docs(vec![make_document("1", "Result")]));

let mut handles = Vec::new();
for _ in 0..10 {
let p = provider.clone();
handles.push(tokio::spawn(async move {
p.search(&make_query("concurrent")).await.unwrap()
}));
}

for handle in handles {
let results = handle.await.unwrap();
assert_eq!(results.len(), 1);
}
}
}
111 changes: 111 additions & 0 deletions crates/terraphim_hooks/src/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,115 @@ mod tests {
assert_eq!(result.result, "original");
assert_eq!(result.error, Some("error msg".to_string()));
}

#[test]
fn test_replacement_multiple_terms_in_one_text() {
let thesaurus = create_test_thesaurus();
let service = ReplacementService::new(thesaurus);

// Both npm and yarn should be replaced by bun
let result = service.replace("npm install && yarn add foo").unwrap();
assert!(result.changed);
assert_eq!(result.result, "bun install && bun add foo");
}

#[test]
fn test_replacement_service_pnpm() {
let thesaurus = create_test_thesaurus();
let service = ReplacementService::new(thesaurus);

let result = service.replace("pnpm install express").unwrap();
assert!(result.changed);
assert_eq!(result.result, "bun install express");
}

#[test]
fn test_find_matches_returns_matched_terms() {
let thesaurus = create_test_thesaurus();
let service = ReplacementService::new(thesaurus);

let matches = service.find_matches("npm install && yarn add").unwrap();
assert!(!matches.is_empty());
let match_terms: Vec<&str> = matches.iter().map(|m| m.term.as_str()).collect();
assert!(match_terms.contains(&"npm"));
assert!(match_terms.contains(&"yarn"));
}

#[test]
fn test_contains_matches_true() {
let thesaurus = create_test_thesaurus();
let service = ReplacementService::new(thesaurus);
assert!(service.contains_matches("npm install"));
}

#[test]
fn test_contains_matches_false() {
let thesaurus = create_test_thesaurus();
let service = ReplacementService::new(thesaurus);
assert!(!service.contains_matches("cargo build"));
}

#[test]
fn test_replace_fail_open_on_valid_input() {
let thesaurus = create_test_thesaurus();
let service = ReplacementService::new(thesaurus);

let result = service.replace_fail_open("npm install");
assert!(result.changed);
assert_eq!(result.result, "bun install");
assert!(result.error.is_none());
}

#[test]
fn test_hook_result_success_when_unchanged() {
let result = HookResult::success("same".to_string(), "same".to_string());
assert!(!result.changed);
assert_eq!(result.replacements, 0);
}

#[test]
fn test_hook_result_serde_round_trip() {
let result = HookResult::success("npm".to_string(), "bun".to_string());
let json = serde_json::to_string(&result).unwrap();
let deserialized: HookResult = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.result, "bun");
assert_eq!(deserialized.original, "npm");
assert!(deserialized.changed);
}

#[test]
fn test_hook_result_fail_open_serialization_skips_none_error() {
let result = HookResult::pass_through("text".to_string());
let json = serde_json::to_string(&result).unwrap();
assert!(!json.contains("error"));
}

#[test]
fn test_replacement_with_unicode_text() {
let thesaurus = create_test_thesaurus();
let service = ReplacementService::new(thesaurus);

let result = service.replace("npm install -- emoji").unwrap();
assert!(result.changed);
assert!(result.result.starts_with("bun"));
}

#[test]
fn test_replacement_preserves_surrounding_text() {
let thesaurus = create_test_thesaurus();
let service = ReplacementService::new(thesaurus);

let result = service.replace("before npm after").unwrap();
assert!(result.changed);
assert_eq!(result.result, "before bun after");
}

#[test]
fn test_with_link_type_builder_pattern() {
let thesaurus = create_test_thesaurus();
let service = ReplacementService::new(thesaurus).with_link_type(LinkType::PlainText);
// Just verify it compiles and doesn't panic
let result = service.replace("npm install").unwrap();
assert!(result.changed);
}
}
Loading
Loading