use crate::agent::document::JACSDocument;
use crate::error::JacsError;
use serde::{Deserialize, Serialize};
pub trait SearchProvider: Send + Sync {
fn search(&self, query: SearchQuery) -> Result<SearchResults, JacsError>;
fn capabilities(&self) -> SearchCapabilities;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchQuery {
pub query: String,
pub jacs_type: Option<String>,
pub agent_id: Option<String>,
pub field_filter: Option<FieldFilter>,
pub limit: usize,
pub offset: usize,
pub min_score: Option<f64>,
}
impl Default for SearchQuery {
fn default() -> Self {
Self {
query: String::new(),
jacs_type: None,
agent_id: None,
field_filter: None,
limit: 10,
offset: 0,
min_score: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldFilter {
pub field_path: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResults {
pub results: Vec<SearchHit>,
pub total_count: usize,
pub method: SearchMethod,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchHit {
pub document: JACSDocument,
pub score: f64,
pub matched_fields: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SearchMethod {
FullText,
Vector,
Hybrid,
FieldMatch,
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SearchCapabilities {
pub fulltext: bool,
pub vector: bool,
pub hybrid: bool,
pub field_filter: bool,
}
impl SearchCapabilities {
pub fn none() -> Self {
Self {
fulltext: false,
vector: false,
hybrid: false,
field_filter: false,
}
}
}
impl Default for SearchCapabilities {
fn default() -> Self {
Self::none()
}
}
pub trait EmbeddingProvider: Send + Sync {
fn embed(&self, content: &str) -> Result<Vec<f64>, JacsError>;
fn dimensions(&self) -> usize;
fn model_id(&self) -> &str;
}
pub struct NoopEmbeddingProvider;
impl EmbeddingProvider for NoopEmbeddingProvider {
fn embed(&self, _content: &str) -> Result<Vec<f64>, JacsError> {
Err(JacsError::SearchError(
"Embedding not configured: no EmbeddingProvider was supplied. \
To use vector search, provide an EmbeddingProvider implementation \
when configuring your storage backend."
.to_string(),
))
}
fn dimensions(&self) -> usize {
0
}
fn model_id(&self) -> &str {
"noop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn search_provider_is_object_safe() {
fn _assert_object_safe(_: &dyn SearchProvider) {}
}
#[test]
fn search_provider_is_send_sync() {
fn _assert_send_sync<T: Send + Sync + ?Sized>() {}
_assert_send_sync::<dyn SearchProvider>();
}
#[test]
fn search_query_default_is_sensible() {
let query = SearchQuery::default();
assert_eq!(query.query, "");
assert_eq!(query.limit, 10);
assert_eq!(query.offset, 0);
assert!(query.jacs_type.is_none());
assert!(query.agent_id.is_none());
assert!(query.field_filter.is_none());
assert!(query.min_score.is_none());
}
#[test]
fn search_query_with_all_fields() {
let query = SearchQuery {
query: "authentication middleware".to_string(),
jacs_type: Some("artifact".to_string()),
agent_id: Some("agent-123".to_string()),
field_filter: Some(FieldFilter {
field_path: "category".to_string(),
value: "security".to_string(),
}),
limit: 20,
offset: 5,
min_score: Some(0.7),
};
assert_eq!(query.query, "authentication middleware");
assert_eq!(query.jacs_type.as_deref(), Some("artifact"));
assert_eq!(query.agent_id.as_deref(), Some("agent-123"));
assert!(query.field_filter.is_some());
assert_eq!(query.limit, 20);
assert_eq!(query.offset, 5);
assert_eq!(query.min_score, Some(0.7));
}
#[test]
fn search_results_with_unsupported_method() {
let results = SearchResults {
results: vec![],
total_count: 0,
method: SearchMethod::Unsupported,
};
assert_eq!(results.method, SearchMethod::Unsupported);
assert_eq!(results.total_count, 0);
assert!(results.results.is_empty());
}
#[test]
fn search_hit_can_be_constructed() {
let doc = JACSDocument {
id: "doc-1".to_string(),
version: "v1".to_string(),
jacs_type: "artifact".to_string(),
value: json!({"jacsId": "doc-1", "jacsVersion": "v1", "jacsType": "artifact"}),
};
let hit = SearchHit {
document: doc,
score: 0.95,
matched_fields: vec!["content".to_string()],
};
assert_eq!(hit.score, 0.95);
assert_eq!(hit.matched_fields, vec!["content"]);
assert_eq!(hit.document.id, "doc-1");
}
#[test]
fn search_method_has_all_five_variants() {
let methods = vec![
SearchMethod::FullText,
SearchMethod::Vector,
SearchMethod::Hybrid,
SearchMethod::FieldMatch,
SearchMethod::Unsupported,
];
for i in 0..methods.len() {
for j in (i + 1)..methods.len() {
assert_ne!(methods[i], methods[j]);
}
}
}
#[test]
fn search_capabilities_none_returns_all_false() {
let caps = SearchCapabilities::none();
assert!(!caps.fulltext);
assert!(!caps.vector);
assert!(!caps.hybrid);
assert!(!caps.field_filter);
}
#[test]
fn search_capabilities_default_is_none() {
let caps = SearchCapabilities::default();
assert_eq!(caps, SearchCapabilities::none());
}
#[test]
fn search_capabilities_reports_correctly() {
let caps = SearchCapabilities {
fulltext: true,
vector: false,
hybrid: false,
field_filter: true,
};
assert!(caps.fulltext);
assert!(!caps.vector);
assert!(!caps.hybrid);
assert!(caps.field_filter);
}
#[test]
fn field_filter_can_be_constructed() {
let filter = FieldFilter {
field_path: "jacsCommitmentStatus".to_string(),
value: "active".to_string(),
};
assert_eq!(filter.field_path, "jacsCommitmentStatus");
assert_eq!(filter.value, "active");
}
#[test]
fn embedding_provider_is_object_safe() {
let provider: Box<dyn EmbeddingProvider> = Box::new(NoopEmbeddingProvider);
assert_eq!(provider.dimensions(), 0);
assert_eq!(provider.model_id(), "noop");
}
#[test]
fn embedding_provider_box_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Box<dyn EmbeddingProvider>>();
}
#[test]
fn noop_embed_returns_error() {
let provider = NoopEmbeddingProvider;
let result = provider.embed("test content");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("not configured"),
"Error should explain that embedding is not configured, got: {}",
err_msg
);
}
#[test]
fn noop_dimensions_is_zero() {
let provider = NoopEmbeddingProvider;
assert_eq!(provider.dimensions(), 0);
}
#[test]
fn noop_model_id_is_noop() {
let provider = NoopEmbeddingProvider;
assert_eq!(provider.model_id(), "noop");
}
struct MockEmbeddingProvider {
dims: usize,
model: String,
}
impl MockEmbeddingProvider {
fn new(dims: usize, model: &str) -> Self {
Self {
dims,
model: model.to_string(),
}
}
}
impl EmbeddingProvider for MockEmbeddingProvider {
fn embed(&self, _content: &str) -> Result<Vec<f64>, JacsError> {
Ok(vec![0.1; self.dims])
}
fn dimensions(&self) -> usize {
self.dims
}
fn model_id(&self) -> &str {
&self.model
}
}
#[test]
fn mock_provider_can_be_created_and_called() {
let provider = MockEmbeddingProvider::new(1536, "text-embedding-3-small");
assert_eq!(provider.dimensions(), 1536);
assert_eq!(provider.model_id(), "text-embedding-3-small");
let embedding = provider.embed("hello world").expect("embed should succeed");
assert_eq!(embedding.len(), 1536);
assert!((embedding[0] - 0.1).abs() < f64::EPSILON);
}
#[test]
fn mock_provider_works_as_trait_object() {
let provider: Box<dyn EmbeddingProvider> =
Box::new(MockEmbeddingProvider::new(768, "all-MiniLM-L6-v2"));
assert_eq!(provider.dimensions(), 768);
assert_eq!(provider.model_id(), "all-MiniLM-L6-v2");
let embedding = provider.embed("test").expect("embed should succeed");
assert_eq!(embedding.len(), 768);
}
#[test]
fn mock_provider_is_send_sync() {
let provider: Box<dyn EmbeddingProvider> =
Box::new(MockEmbeddingProvider::new(3, "test-model"));
let handle =
std::thread::spawn(move || provider.embed("cross-thread").expect("embed should work"));
let result = handle.join().expect("thread should complete");
assert_eq!(result.len(), 3);
}
}