use super::{SearchError, SearchQuery, SearchResultItem};
use crate::{EmbeddingService, FastSkillService};
pub async fn execute_local_search(
query: SearchQuery,
service: &FastSkillService,
) -> Result<Vec<SearchResultItem>, SearchError> {
let results = match query.embedding {
Some(false) => {
perform_text_search(service, &query.query, query.limit).await?
}
Some(true) => {
perform_embedding_search(service, &query.query, query.limit).await?
}
None => {
match perform_embedding_search(service, &query.query, query.limit).await {
Ok(r) => r,
Err(SearchError::Config(_)) => {
perform_text_search(service, &query.query, query.limit).await?
}
Err(e) => return Err(e),
}
}
};
Ok(results)
}
async fn perform_text_search(
service: &FastSkillService,
query: &str,
limit: usize,
) -> Result<Vec<SearchResultItem>, SearchError> {
let meta_list = service
.metadata_service()
.search_skills(query)
.await
.map_err(|e| SearchError::Validation(format!("Text search failed: {}", e)))?;
let mut results = Vec::new();
for meta in meta_list.into_iter().take(limit) {
let Some(skill_def) = service
.skill_manager()
.get_skill(&meta.id)
.await
.map_err(|e| SearchError::Validation(format!("Lookup failed: {}", e)))?
else {
continue;
};
let skill_path = skill_def
.skill_file
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| skill_def.skill_file.clone());
let result_item = SearchResultItem {
id: meta.id.as_str().to_string(),
name: if meta.name.is_empty() {
meta.id.as_str().to_string()
} else {
meta.name
},
description: if meta.description.is_empty() {
None
} else {
Some(meta.description)
},
source: "local".to_string(),
similarity: Some(1.0), path: Some(skill_path.to_string_lossy().to_string()),
repository: None,
};
results.push(result_item);
}
Ok(results)
}
async fn perform_embedding_search(
service: &FastSkillService,
query: &str,
limit: usize,
) -> Result<Vec<SearchResultItem>, SearchError> {
let embedding_config = service
.config()
.embedding
.as_ref()
.ok_or_else(|| {
SearchError::Config(
"Embedding configuration required but not found. Please configure embedding settings in skill-project.toml and set OPENAI_API_KEY environment variable.".to_string()
)
})?;
let vector_index_service = service
.vector_index_service()
.ok_or_else(|| SearchError::Config("Vector index service not available".to_string()))?;
let api_key = load_openai_api_key()?;
let embedding_service = crate::OpenAIEmbeddingService::from_config(embedding_config, api_key);
let query_embedding = embedding_service.embed_query(query).await.map_err(|e| {
SearchError::Validation(format!("Failed to generate query embedding: {}", e))
})?;
let matches = vector_index_service
.search_similar(&query_embedding, limit)
.await
.map_err(|e| SearchError::Validation(format!("Vector search failed: {}", e)))?;
let results = matches
.into_iter()
.map(|skill_match| {
let name = skill_match
.skill
.frontmatter_json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&skill_match.skill.id)
.to_string();
let description = skill_match
.skill
.frontmatter_json
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
SearchResultItem {
id: skill_match.skill.id,
name,
description,
source: "local".to_string(),
similarity: Some(skill_match.similarity),
path: Some(skill_match.skill.skill_path.to_string_lossy().to_string()),
repository: None,
}
})
.collect();
Ok(results)
}
fn load_openai_api_key() -> Result<String, SearchError> {
let api_key = std::env::var("OPENAI_API_KEY").map_err(|e| {
SearchError::Config(format!(
"Failed to get OPENAI_API_KEY from environment: {}",
e
))
})?;
if api_key.trim().is_empty() {
return Err(SearchError::Config(
"OPENAI_API_KEY environment variable is set but empty".to_string(),
));
}
Ok(api_key)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::load_openai_api_key;
use super::SearchError;
use once_cell::sync::Lazy;
use std::sync::Mutex;
static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
#[test]
fn load_openai_api_key_rejects_empty_values() {
let _lock = ENV_LOCK.lock().expect("failed to lock env mutex");
unsafe {
std::env::set_var("OPENAI_API_KEY", " ");
}
let result = load_openai_api_key();
match result {
Err(SearchError::Config(msg)) => {
assert!(msg.contains("set but empty"), "unexpected message: {msg}");
}
other => panic!("expected config error, got: {:?}", other),
}
unsafe {
std::env::remove_var("OPENAI_API_KEY");
}
}
}