diff --git a/Cargo.lock b/Cargo.lock index 193c740cc..6ebfcba47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2828,7 +2828,7 @@ dependencies = [ [[package]] name = "grepapp_haystack" -version = "1.11.0" +version = "1.12.0" dependencies = [ "anyhow", "haystack_core", @@ -2979,9 +2979,10 @@ dependencies = [ [[package]] name = "haystack_core" -version = "1.11.0" +version = "1.12.0" dependencies = [ "terraphim_types", + "tokio", ] [[package]] @@ -8239,7 +8240,7 @@ dependencies = [ [[package]] name = "terraphim-cli" -version = "1.11.0" +version = "1.12.0" dependencies = [ "anyhow", "assert_cmd", @@ -8301,7 +8302,7 @@ dependencies = [ [[package]] name = "terraphim-repl" -version = "1.11.0" +version = "1.12.0" dependencies = [ "anyhow", "colored 2.2.0", @@ -8326,7 +8327,7 @@ dependencies = [ [[package]] name = "terraphim-session-analyzer" -version = "1.11.0" +version = "1.12.0" dependencies = [ "aho-corasick", "anyhow", @@ -8365,7 +8366,7 @@ dependencies = [ [[package]] name = "terraphim_agent" -version = "1.11.0" +version = "1.12.0" dependencies = [ "ahash 0.8.12", "anyhow", @@ -8838,7 +8839,7 @@ dependencies = [ [[package]] name = "terraphim_middleware" -version = "1.11.0" +version = "1.12.0" dependencies = [ "ahash 0.8.12", "async-trait", @@ -9013,7 +9014,7 @@ dependencies = [ [[package]] name = "terraphim_server" -version = "1.11.0" +version = "1.12.0" dependencies = [ "ahash 0.8.12", "anyhow", @@ -9090,7 +9091,7 @@ dependencies = [ [[package]] name = "terraphim_sessions" -version = "1.11.0" +version = "1.12.0" dependencies = [ "anyhow", "async-trait", @@ -9176,14 +9177,14 @@ dependencies = [ [[package]] name = "terraphim_test_utils" -version = "1.11.0" +version = "1.12.0" dependencies = [ "rustc_version", ] [[package]] name = "terraphim_tinyclaw" -version = "1.11.0" +version = "1.12.0" dependencies = [ "anyhow", "async-trait", diff --git a/crates/haystack_core/Cargo.toml b/crates/haystack_core/Cargo.toml index 112fbc1fb..71d1f8e9e 100644 --- a/crates/haystack_core/Cargo.toml +++ b/crates/haystack_core/Cargo.toml @@ -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"] } diff --git a/crates/haystack_core/src/lib.rs b/crates/haystack_core/src/lib.rs index 543115399..c8b86996a 100644 --- a/crates/haystack_core/src/lib.rs +++ b/crates/haystack_core/src/lib.rs @@ -6,3 +6,140 @@ pub trait HaystackProvider { #[allow(async_fn_in_trait)] async fn search(&self, query: &SearchQuery) -> Result, 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, + } + + impl TestProvider { + fn with_docs(documents: Vec) -> 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, 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, 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() {} + assert_send_sync::(); + } + + #[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 & ")) + .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); + } + } +} diff --git a/crates/terraphim_hooks/src/replacement.rs b/crates/terraphim_hooks/src/replacement.rs index 2a9c1df79..74c36f8ed 100644 --- a/crates/terraphim_hooks/src/replacement.rs +++ b/crates/terraphim_hooks/src/replacement.rs @@ -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); + } } diff --git a/crates/terraphim_onepassword_cli/src/lib.rs b/crates/terraphim_onepassword_cli/src/lib.rs index 624f04f2b..a239f635e 100644 --- a/crates/terraphim_onepassword_cli/src/lib.rs +++ b/crates/terraphim_onepassword_cli/src/lib.rs @@ -311,3 +311,248 @@ impl std::fmt::Display for OnePasswordRef { write!(f, "op://{}/{}/{}", self.vault, self.item, self.field) } } + +#[cfg(test)] +mod tests { + use super::*; + + // --- OnePasswordRef parsing tests --- + + #[test] + fn test_parse_valid_reference() { + let loader = OnePasswordLoader::new(); + let parsed = loader + .parse_reference("op://my-vault/my-item/my-field") + .unwrap(); + assert_eq!(parsed.vault, "my-vault"); + assert_eq!(parsed.item, "my-item"); + assert_eq!(parsed.field, "my-field"); + } + + #[test] + fn test_parse_reference_display_round_trip() { + let loader = OnePasswordLoader::new(); + let reference = "op://vault/item/field"; + let parsed = loader.parse_reference(reference).unwrap(); + assert_eq!(parsed.to_string(), reference); + } + + #[test] + fn test_parse_reference_missing_prefix() { + let loader = OnePasswordLoader::new(); + let result = loader.parse_reference("vault/item/field"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_reference_missing_field() { + let loader = OnePasswordLoader::new(); + let result = loader.parse_reference("op://vault/item"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_reference_extra_components() { + let loader = OnePasswordLoader::new(); + let result = loader.parse_reference("op://vault/item/field/extra"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_reference_empty_components() { + let loader = OnePasswordLoader::new(); + // Regex requires at least one char per component + let result = loader.parse_reference("op:////"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_reference_with_special_chars() { + let loader = OnePasswordLoader::new(); + let parsed = loader + .parse_reference("op://my-vault_123/my.item/api-key") + .unwrap(); + assert_eq!(parsed.vault, "my-vault_123"); + assert_eq!(parsed.item, "my.item"); + assert_eq!(parsed.field, "api-key"); + } + + #[test] + fn test_onepassword_ref_equality() { + let ref1 = OnePasswordRef { + vault: "v".to_string(), + item: "i".to_string(), + field: "f".to_string(), + }; + let ref2 = OnePasswordRef { + vault: "v".to_string(), + item: "i".to_string(), + field: "f".to_string(), + }; + assert_eq!(ref1, ref2); + } + + #[test] + fn test_onepassword_ref_serde_round_trip() { + let op_ref = OnePasswordRef { + vault: "my-vault".to_string(), + item: "my-item".to_string(), + field: "password".to_string(), + }; + let json = serde_json::to_string(&op_ref).unwrap(); + let deserialized: OnePasswordRef = serde_json::from_str(&json).unwrap(); + assert_eq!(op_ref, deserialized); + } + + // --- EnvironmentLoader tests --- + + #[test] + fn test_reference_to_env_var_basic() { + let loader = EnvironmentLoader::new(); + let env_var = loader + .reference_to_env_var("op://my-vault/my-item/password") + .unwrap(); + assert_eq!(env_var, "MY_VAULT_MY_ITEM_PASSWORD"); + } + + #[test] + fn test_reference_to_env_var_hyphens_replaced() { + let loader = EnvironmentLoader::new(); + let env_var = loader + .reference_to_env_var("op://my-vault/my-item/api-key") + .unwrap(); + assert_eq!(env_var, "MY_VAULT_MY_ITEM_API-KEY"); + } + + #[test] + fn test_reference_to_env_var_non_reference_passthrough() { + let loader = EnvironmentLoader::new(); + let result = loader.reference_to_env_var("plain-text").unwrap(); + assert_eq!(result, "plain-text"); + } + + #[test] + fn test_reference_to_env_var_invalid_format() { + let loader = EnvironmentLoader::new(); + let result = loader.reference_to_env_var("op://vault-only"); + assert!(result.is_err()); + } + + // --- OnePasswordLoader constructor tests --- + + #[test] + fn test_loader_default_timeout() { + let loader = OnePasswordLoader::new(); + assert_eq!(loader.timeout_seconds, 30); + } + + #[test] + fn test_loader_default_trait() { + let loader = OnePasswordLoader::default(); + assert_eq!(loader.timeout_seconds, 30); + } + + // --- Error display tests --- + + #[test] + fn test_error_display_cli_not_found() { + let err = OnePasswordError::CliNotFound; + let msg = err.to_string(); + assert!(msg.contains("1Password CLI not found")); + } + + #[test] + fn test_error_display_not_authenticated() { + let err = OnePasswordError::NotAuthenticated; + let msg = err.to_string(); + assert!(msg.contains("Not authenticated")); + } + + #[test] + fn test_error_display_invalid_reference() { + let err = OnePasswordError::InvalidReference { + reference: "bad-ref".to_string(), + }; + assert!(err.to_string().contains("bad-ref")); + } + + #[test] + fn test_error_display_secret_not_found() { + let err = OnePasswordError::SecretNotFound { + reference: "op://v/i/f".to_string(), + }; + assert!(err.to_string().contains("op://v/i/f")); + } + + // --- SecretLoader trait tests for EnvironmentLoader --- + + #[tokio::test] + async fn test_env_loader_non_reference_passthrough() { + let loader = EnvironmentLoader::new(); + let result = loader.resolve_secret("not-a-reference").await.unwrap(); + assert_eq!(result, "not-a-reference"); + } + + #[tokio::test] + async fn test_env_loader_resolve_from_env() { + let loader = EnvironmentLoader::new(); + // Set a test env var + // SAFETY: test is single-threaded and env var is unique to this test + unsafe { + std::env::set_var("TEST_VAULT_TEST_ITEM_API_KEY", "secret123"); + } + let result = loader + .resolve_secret("op://test-vault/test-item/api_key") + .await + .unwrap(); + assert_eq!(result, "secret123"); + unsafe { + std::env::remove_var("TEST_VAULT_TEST_ITEM_API_KEY"); + } + } + + #[tokio::test] + async fn test_env_loader_missing_env_var() { + let loader = EnvironmentLoader::new(); + let result = loader.resolve_secret("op://nonexistent/vault/field").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_env_loader_is_always_available() { + let loader = EnvironmentLoader::new(); + assert!(loader.is_available().await); + } + + #[tokio::test] + async fn test_env_loader_process_config_no_references() { + let loader = EnvironmentLoader::new(); + let config = r#"{"key": "plain-value"}"#; + let result = loader.process_config(config).await.unwrap(); + assert_eq!(result, config); + } + + #[tokio::test] + async fn test_env_loader_process_config_with_reference() { + let loader = EnvironmentLoader::new(); + // SAFETY: test is single-threaded and env var is unique to this test + unsafe { + std::env::set_var("MYVAULT_MYITEM_TOKEN", "resolved-token"); + } + let config = r#"{"api_token": "op://myvault/myitem/token"}"#; + let result = loader.process_config(config).await.unwrap(); + assert_eq!(result, r#"{"api_token": "resolved-token"}"#); + unsafe { + std::env::remove_var("MYVAULT_MYITEM_TOKEN"); + } + } + + // --- OnePasswordLoader resolve_secret passthrough --- + + #[tokio::test] + async fn test_op_loader_non_reference_passthrough() { + let loader = OnePasswordLoader::new(); + let result = loader.resolve_secret("plain-value").await.unwrap(); + assert_eq!(result, "plain-value"); + } +} diff --git a/crates/terraphim_sessions/src/connector/native.rs b/crates/terraphim_sessions/src/connector/native.rs index 604309607..5c4f723bb 100644 --- a/crates/terraphim_sessions/src/connector/native.rs +++ b/crates/terraphim_sessions/src/connector/native.rs @@ -321,4 +321,155 @@ mod tests { assert_eq!(connector.source_id(), "claude-code-native"); assert_eq!(connector.display_name(), "Claude Code (Native)"); } + + #[test] + fn test_parse_timestamp_valid_formats() { + assert!(parse_timestamp("2024-01-15T10:30:00Z").is_some()); + assert!(parse_timestamp("2024-12-31T23:59:59.999Z").is_some()); + } + + #[test] + fn test_parse_timestamp_invalid() { + assert!(parse_timestamp("not-a-timestamp").is_none()); + assert!(parse_timestamp("").is_none()); + } + + #[test] + fn test_parse_user_entry() { + let json = r#"{"sessionId":"abc","cwd":"/tmp","timestamp":"2024-01-15T10:30:00Z","message":{"role":"user","content":"hello"}}"#; + let entry: LogEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.session_id, Some("abc".to_string())); + assert_eq!(entry.cwd, Some("/tmp".to_string())); + assert!(matches!(entry.message, EntryMessage::User { .. })); + } + + #[test] + fn test_parse_assistant_entry_with_text() { + let json = r#"{"sessionId":"abc","timestamp":"2024-01-15T10:30:00Z","message":{"role":"assistant","content":[{"type":"text","text":"response here"}]}}"#; + let entry: LogEntry = serde_json::from_str(json).unwrap(); + if let EntryMessage::Assistant { content } = &entry.message { + assert_eq!(content.len(), 1); + assert!( + matches!(&content[0], AssistantContentBlock::Text { text } if text == "response here") + ); + } else { + panic!("Expected Assistant message"); + } + } + + #[test] + fn test_parse_assistant_entry_with_tool_use() { + let json = r#"{"sessionId":"abc","timestamp":"2024-01-15T10:30:00Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool1","name":"Read","input":{"path":"/tmp"}}]}}"#; + let entry: LogEntry = serde_json::from_str(json).unwrap(); + if let EntryMessage::Assistant { content } = &entry.message { + assert_eq!(content.len(), 1); + assert!( + matches!(&content[0], AssistantContentBlock::ToolUse { name, .. } if name == "Read") + ); + } else { + panic!("Expected Assistant message"); + } + } + + #[test] + fn test_parse_tool_result_entry() { + let json = r#"{"sessionId":"abc","timestamp":"2024-01-15T10:30:00Z","message":{"role":"tool_result","content":[{"content":"file contents here"}]}}"#; + let entry: LogEntry = serde_json::from_str(json).unwrap(); + if let EntryMessage::ToolResult { content } = &entry.message { + assert_eq!(content.len(), 1); + assert_eq!(content[0].content, "file contents here"); + } else { + panic!("Expected ToolResult message"); + } + } + + #[test] + fn test_parse_entry_missing_optional_fields() { + let json = + r#"{"timestamp":"2024-01-15T10:30:00Z","message":{"role":"user","content":"hello"}}"#; + let entry: LogEntry = serde_json::from_str(json).unwrap(); + assert!(entry.session_id.is_none()); + assert!(entry.cwd.is_none()); + } + + #[tokio::test] + async fn test_parse_session_from_jsonl_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test-session.jsonl"); + let content = [ + r#"{"sessionId":"sess1","cwd":"/project","timestamp":"2024-01-15T10:00:00Z","message":{"role":"user","content":"How do I use tokio?"}}"#, + r#"{"sessionId":"sess1","cwd":"/project","timestamp":"2024-01-15T10:00:05Z","message":{"role":"assistant","content":[{"type":"text","text":"Here is how you use tokio..."}]}}"#, + ].join("\n"); + tokio::fs::write(&file_path, content).await.unwrap(); + + let connector = NativeClaudeConnector; + let session = connector + .parse_session_file(&file_path) + .await + .unwrap() + .unwrap(); + + assert_eq!(session.external_id, "sess1"); + assert_eq!(session.messages.len(), 2); + assert_eq!(session.messages[0].role, crate::model::MessageRole::User); + assert_eq!(session.messages[0].content, "How do I use tokio?"); + assert_eq!( + session.messages[1].role, + crate::model::MessageRole::Assistant + ); + assert!(session.started_at.is_some()); + assert!(session.ended_at.is_some()); + } + + #[tokio::test] + async fn test_parse_session_empty_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("empty.jsonl"); + tokio::fs::write(&file_path, "").await.unwrap(); + + let connector = NativeClaudeConnector; + let result = connector.parse_session_file(&file_path).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_parse_session_malformed_lines_skipped() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("mixed.jsonl"); + let content = [ + "not valid json", + r#"{"sessionId":"sess1","timestamp":"2024-01-15T10:00:00Z","message":{"role":"user","content":"hello"}}"#, + "also not valid", + ].join("\n"); + tokio::fs::write(&file_path, content).await.unwrap(); + + let connector = NativeClaudeConnector; + let session = connector + .parse_session_file(&file_path) + .await + .unwrap() + .unwrap(); + assert_eq!(session.messages.len(), 1); + } + + #[tokio::test] + async fn test_import_with_limit() { + let dir = tempfile::tempdir().unwrap(); + // Create multiple session files + for i in 0..5 { + let file_path = dir.path().join(format!("session-{}.jsonl", i)); + let content = format!( + r#"{{"sessionId":"sess{}","timestamp":"2024-01-15T10:00:00Z","message":{{"role":"user","content":"msg {}"}}}}"#, + i, i + ); + tokio::fs::write(&file_path, content).await.unwrap(); + } + + let connector = NativeClaudeConnector; + let options = ImportOptions::default() + .with_path(dir.path().to_path_buf()) + .with_limit(2); + let sessions = connector.import(&options).await.unwrap(); + assert_eq!(sessions.len(), 2); + } } diff --git a/crates/terraphim_sessions/src/model.rs b/crates/terraphim_sessions/src/model.rs index c484b1890..8ed72793b 100644 --- a/crates/terraphim_sessions/src/model.rs +++ b/crates/terraphim_sessions/src/model.rs @@ -285,4 +285,190 @@ mod tests { assert_eq!(session.user_message_count(), 2); assert_eq!(session.assistant_message_count(), 1); } + + #[test] + fn test_message_role_display() { + assert_eq!(MessageRole::User.to_string(), "user"); + assert_eq!(MessageRole::Assistant.to_string(), "assistant"); + assert_eq!(MessageRole::System.to_string(), "system"); + assert_eq!(MessageRole::Tool.to_string(), "tool"); + assert_eq!(MessageRole::Other.to_string(), "other"); + } + + #[test] + fn test_message_role_from_aliases() { + assert_eq!(MessageRole::from("bot"), MessageRole::Assistant); + assert_eq!(MessageRole::from("model"), MessageRole::Assistant); + assert_eq!(MessageRole::from("tool_result"), MessageRole::Tool); + } + + #[test] + fn test_content_block_as_text() { + let text_block = ContentBlock::Text { + text: "hello".to_string(), + }; + assert_eq!(text_block.as_text(), Some("hello")); + + let tool_block = ContentBlock::ToolUse { + id: "1".to_string(), + name: "Read".to_string(), + input: serde_json::Value::Null, + }; + assert_eq!(tool_block.as_text(), None); + } + + #[test] + fn test_message_has_tool_use() { + let mut msg = Message::text(0, MessageRole::Assistant, "text"); + assert!(!msg.has_tool_use()); + + msg.blocks.push(ContentBlock::ToolUse { + id: "1".to_string(), + name: "Write".to_string(), + input: serde_json::Value::Null, + }); + assert!(msg.has_tool_use()); + } + + #[test] + fn test_message_tool_names() { + let mut msg = Message::text(0, MessageRole::Assistant, "text"); + msg.blocks.push(ContentBlock::ToolUse { + id: "1".to_string(), + name: "Read".to_string(), + input: serde_json::Value::Null, + }); + msg.blocks.push(ContentBlock::ToolUse { + id: "2".to_string(), + name: "Write".to_string(), + input: serde_json::Value::Null, + }); + + let names = msg.tool_names(); + assert_eq!(names, vec!["Read", "Write"]); + } + + #[test] + fn test_session_tools_used() { + let mut msg = Message::text(0, MessageRole::Assistant, "text"); + msg.blocks.push(ContentBlock::ToolUse { + id: "1".to_string(), + name: "Read".to_string(), + input: serde_json::Value::Null, + }); + let mut msg2 = Message::text(1, MessageRole::Assistant, "text2"); + msg2.blocks.push(ContentBlock::ToolUse { + id: "2".to_string(), + name: "Read".to_string(), + input: serde_json::Value::Null, + }); + msg2.blocks.push(ContentBlock::ToolUse { + id: "3".to_string(), + name: "Write".to_string(), + input: serde_json::Value::Null, + }); + + let session = Session { + id: "test".to_string(), + source: "test".to_string(), + external_id: "test".to_string(), + title: None, + source_path: PathBuf::from("."), + started_at: None, + ended_at: None, + messages: vec![msg, msg2], + metadata: SessionMetadata::default(), + }; + + let tools = session.tools_used(); + assert_eq!(tools, vec!["Read", "Write"]); + } + + #[test] + fn test_session_summary_short_message() { + let session = Session { + id: "test".to_string(), + source: "test".to_string(), + external_id: "test".to_string(), + title: None, + source_path: PathBuf::from("."), + started_at: None, + ended_at: None, + messages: vec![Message::text(0, MessageRole::User, "Short question")], + metadata: SessionMetadata::default(), + }; + assert_eq!(session.summary(), Some("Short question".to_string())); + } + + #[test] + fn test_session_summary_long_message_truncated() { + let long_text = "a".repeat(200); + let session = Session { + id: "test".to_string(), + source: "test".to_string(), + external_id: "test".to_string(), + title: None, + source_path: PathBuf::from("."), + started_at: None, + ended_at: None, + messages: vec![Message::text(0, MessageRole::User, long_text)], + metadata: SessionMetadata::default(), + }; + let summary = session.summary().unwrap(); + assert!(summary.ends_with("...")); + assert!(summary.len() <= 103); // 100 chars + "..." + } + + #[test] + fn test_session_summary_no_user_messages() { + let session = Session { + id: "test".to_string(), + source: "test".to_string(), + external_id: "test".to_string(), + title: None, + source_path: PathBuf::from("."), + started_at: None, + ended_at: None, + messages: vec![Message::text(0, MessageRole::Assistant, "response")], + metadata: SessionMetadata::default(), + }; + assert!(session.summary().is_none()); + } + + #[test] + fn test_session_duration_with_timestamps() { + let start: jiff::Timestamp = "2024-01-15T10:00:00Z".parse().unwrap(); + let end: jiff::Timestamp = "2024-01-15T10:05:00Z".parse().unwrap(); + + let session = Session { + id: "test".to_string(), + source: "test".to_string(), + external_id: "test".to_string(), + title: None, + source_path: PathBuf::from("."), + started_at: Some(start), + ended_at: Some(end), + messages: vec![], + metadata: SessionMetadata::default(), + }; + + let duration = session.duration_ms().unwrap(); + assert_eq!(duration, 300_000); // 5 minutes in ms + } + + #[test] + fn test_session_duration_no_timestamps() { + let session = Session { + id: "test".to_string(), + source: "test".to_string(), + external_id: "test".to_string(), + title: None, + source_path: PathBuf::from("."), + started_at: None, + ended_at: None, + messages: vec![], + metadata: SessionMetadata::default(), + }; + assert!(session.duration_ms().is_none()); + } } diff --git a/crates/terraphim_sessions/src/service.rs b/crates/terraphim_sessions/src/service.rs index a51e3053f..ea7ba99c4 100644 --- a/crates/terraphim_sessions/src/service.rs +++ b/crates/terraphim_sessions/src/service.rs @@ -265,4 +265,168 @@ mod tests { assert_eq!(stats.total_sessions, 0); assert_eq!(stats.total_messages, 0); } + + fn make_test_session(id: &str, source: &str, messages: Vec) -> Session { + Session { + id: id.to_string(), + source: source.to_string(), + external_id: id.to_string(), + title: Some(format!("Session {}", id)), + source_path: std::path::PathBuf::from("."), + started_at: None, + ended_at: None, + messages, + metadata: crate::model::SessionMetadata::default(), + } + } + + #[tokio::test] + async fn test_load_and_list_sessions() { + let service = SessionService::new(); + let sessions = vec![ + make_test_session("s1", "test", vec![]), + make_test_session("s2", "test", vec![]), + ]; + service.load_sessions(sessions).await; + + let listed = service.list_sessions().await; + assert_eq!(listed.len(), 2); + assert_eq!(service.session_count().await, 2); + } + + #[tokio::test] + async fn test_get_session_by_id() { + let service = SessionService::new(); + let sessions = vec![make_test_session("s1", "test", vec![])]; + service.load_sessions(sessions).await; + + let found = service.get_session(&"s1".to_string()).await; + assert!(found.is_some()); + assert_eq!(found.unwrap().id, "s1"); + + let not_found = service.get_session(&"nonexistent".to_string()).await; + assert!(not_found.is_none()); + } + + #[tokio::test] + async fn test_search_by_title() { + let service = SessionService::new(); + let sessions = vec![ + make_test_session("s1", "test", vec![]), + make_test_session("s2", "test", vec![]), + ]; + service.load_sessions(sessions).await; + + let results = service.search("Session s1").await; + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "s1"); + } + + #[tokio::test] + async fn test_search_by_message_content() { + use crate::model::{Message, MessageRole}; + let service = SessionService::new(); + let sessions = vec![make_test_session( + "s1", + "test", + vec![Message::text( + 0, + MessageRole::User, + "How to use Rust async?", + )], + )]; + service.load_sessions(sessions).await; + + let results = service.search("rust async").await; + assert_eq!(results.len(), 1); + } + + #[tokio::test] + async fn test_search_case_insensitive() { + let service = SessionService::new(); + let sessions = vec![make_test_session("s1", "test", vec![])]; + service.load_sessions(sessions).await; + + let results = service.search("SESSION S1").await; + assert_eq!(results.len(), 1); + } + + #[tokio::test] + async fn test_search_no_results() { + let service = SessionService::new(); + let sessions = vec![make_test_session("s1", "test", vec![])]; + service.load_sessions(sessions).await; + + let results = service.search("nonexistent-query").await; + assert!(results.is_empty()); + } + + #[tokio::test] + async fn test_sessions_by_source() { + let service = SessionService::new(); + let sessions = vec![ + make_test_session("s1", "claude", vec![]), + make_test_session("s2", "cursor", vec![]), + make_test_session("s3", "claude", vec![]), + ]; + service.load_sessions(sessions).await; + + let claude_sessions = service.sessions_by_source("claude").await; + assert_eq!(claude_sessions.len(), 2); + + let cursor_sessions = service.sessions_by_source("cursor").await; + assert_eq!(cursor_sessions.len(), 1); + } + + #[tokio::test] + async fn test_clear_cache() { + let service = SessionService::new(); + let sessions = vec![make_test_session("s1", "test", vec![])]; + service.load_sessions(sessions).await; + assert_eq!(service.session_count().await, 1); + + service.clear_cache().await; + assert_eq!(service.session_count().await, 0); + } + + #[tokio::test] + async fn test_statistics_with_data() { + use crate::model::{Message, MessageRole}; + let service = SessionService::new(); + let sessions = vec![ + make_test_session( + "s1", + "claude", + vec![ + Message::text(0, MessageRole::User, "Hello"), + Message::text(1, MessageRole::Assistant, "Hi"), + ], + ), + make_test_session( + "s2", + "cursor", + vec![Message::text(0, MessageRole::User, "Help")], + ), + ]; + service.load_sessions(sessions).await; + + let stats = service.statistics().await; + assert_eq!(stats.total_sessions, 2); + assert_eq!(stats.total_messages, 3); + assert_eq!(stats.total_user_messages, 2); + assert_eq!(stats.total_assistant_messages, 1); + assert_eq!(stats.sessions_by_source.get("claude"), Some(&1)); + assert_eq!(stats.sessions_by_source.get("cursor"), Some(&1)); + } + + #[tokio::test] + async fn test_load_sessions_deduplicates_by_id() { + let service = SessionService::new(); + let sessions = vec![ + make_test_session("s1", "test", vec![]), + make_test_session("s1", "test", vec![]), // duplicate + ]; + service.load_sessions(sessions).await; + assert_eq!(service.session_count().await, 1); + } }