Skip to main content

fastskill_core/search/
local.rs

1//! Local search implementation for installed/local skills
2//!
3//! This module handles searching through skills that are installed locally,
4//! using either embedding-based semantic search or fallback text search.
5
6use super::{SearchError, SearchQuery, SearchResultItem};
7use crate::{EmbeddingService, FastSkillService};
8
9/// Execute local search query
10pub async fn execute_local_search(
11    query: SearchQuery,
12    service: &FastSkillService,
13) -> Result<Vec<SearchResultItem>, SearchError> {
14    let results = match query.embedding {
15        Some(false) => {
16            // --embedding false: use text search only
17            perform_text_search(service, &query.query, query.limit).await?
18        }
19        Some(true) => {
20            // --embedding true: use embedding search only, no fallback
21            perform_embedding_search(service, &query.query, query.limit).await?
22        }
23        None => {
24            // No flag: try embedding, fall back to text on config error
25            match perform_embedding_search(service, &query.query, query.limit).await {
26                Ok(r) => r,
27                Err(SearchError::Config(_)) => {
28                    perform_text_search(service, &query.query, query.limit).await?
29                }
30                Err(e) => return Err(e),
31            }
32        }
33    };
34
35    Ok(results)
36}
37
38/// Text/fuzzy search fallback when embedding or OPENAI_API_KEY is not available.
39async fn perform_text_search(
40    service: &FastSkillService,
41    query: &str,
42    limit: usize,
43) -> Result<Vec<SearchResultItem>, SearchError> {
44    let meta_list = service
45        .metadata_service()
46        .search_skills(query)
47        .await
48        .map_err(|e| SearchError::Validation(format!("Text search failed: {}", e)))?;
49
50    let mut results = Vec::new();
51    for meta in meta_list.into_iter().take(limit) {
52        let Some(skill_def) = service
53            .skill_manager()
54            .get_skill(&meta.id)
55            .await
56            .map_err(|e| SearchError::Validation(format!("Lookup failed: {}", e)))?
57        else {
58            continue;
59        };
60
61        let skill_path = skill_def
62            .skill_file
63            .parent()
64            .map(std::path::Path::to_path_buf)
65            .unwrap_or_else(|| skill_def.skill_file.clone());
66
67        let result_item = SearchResultItem {
68            id: meta.id.as_str().to_string(),
69            name: if meta.name.is_empty() {
70                meta.id.as_str().to_string()
71            } else {
72                meta.name
73            },
74            description: if meta.description.is_empty() {
75                None
76            } else {
77                Some(meta.description)
78            },
79            source: "local".to_string(),
80            similarity: Some(1.0), // Text search has no similarity score
81            path: Some(skill_path.to_string_lossy().to_string()),
82            repository: None,
83        };
84
85        results.push(result_item);
86    }
87    Ok(results)
88}
89
90/// Perform embedding-based search
91async fn perform_embedding_search(
92    service: &FastSkillService,
93    query: &str,
94    limit: usize,
95) -> Result<Vec<SearchResultItem>, SearchError> {
96    let embedding_config = service
97        .config()
98        .embedding
99        .as_ref()
100        .ok_or_else(|| {
101            SearchError::Config(
102                "Embedding configuration required but not found. Please configure embedding settings in skill-project.toml and set OPENAI_API_KEY environment variable.".to_string()
103            )
104        })?;
105
106    let vector_index_service = service
107        .vector_index_service()
108        .ok_or_else(|| SearchError::Config("Vector index service not available".to_string()))?;
109
110    // Get API key from environment
111    let api_key = load_openai_api_key()?;
112
113    // Initialize embedding service
114    let embedding_service = crate::OpenAIEmbeddingService::from_config(embedding_config, api_key);
115
116    // Generate query embedding
117    let query_embedding = embedding_service.embed_query(query).await.map_err(|e| {
118        SearchError::Validation(format!("Failed to generate query embedding: {}", e))
119    })?;
120
121    // Search vector index
122    let matches = vector_index_service
123        .search_similar(&query_embedding, limit)
124        .await
125        .map_err(|e| SearchError::Validation(format!("Vector search failed: {}", e)))?;
126
127    // Convert to SearchResultItem
128    let results = matches
129        .into_iter()
130        .map(|skill_match| {
131            let name = skill_match
132                .skill
133                .frontmatter_json
134                .get("name")
135                .and_then(|v| v.as_str())
136                .unwrap_or(&skill_match.skill.id)
137                .to_string();
138
139            let description = skill_match
140                .skill
141                .frontmatter_json
142                .get("description")
143                .and_then(|v| v.as_str())
144                .map(|s| s.to_string());
145
146            SearchResultItem {
147                id: skill_match.skill.id,
148                name,
149                description,
150                source: "local".to_string(),
151                similarity: Some(skill_match.similarity),
152                path: Some(skill_match.skill.skill_path.to_string_lossy().to_string()),
153                repository: None,
154            }
155        })
156        .collect();
157
158    Ok(results)
159}
160
161fn load_openai_api_key() -> Result<String, SearchError> {
162    let api_key = std::env::var("OPENAI_API_KEY").map_err(|e| {
163        SearchError::Config(format!(
164            "Failed to get OPENAI_API_KEY from environment: {}",
165            e
166        ))
167    })?;
168
169    if api_key.trim().is_empty() {
170        return Err(SearchError::Config(
171            "OPENAI_API_KEY environment variable is set but empty".to_string(),
172        ));
173    }
174
175    Ok(api_key)
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
180mod tests {
181    use super::load_openai_api_key;
182    use super::SearchError;
183    use once_cell::sync::Lazy;
184    use std::sync::Mutex;
185
186    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
187
188    #[test]
189    fn load_openai_api_key_rejects_empty_values() {
190        let _lock = ENV_LOCK.lock().expect("failed to lock env mutex");
191
192        unsafe {
193            std::env::set_var("OPENAI_API_KEY", "   ");
194        }
195
196        let result = load_openai_api_key();
197        match result {
198            Err(SearchError::Config(msg)) => {
199                assert!(msg.contains("set but empty"), "unexpected message: {msg}");
200            }
201            other => panic!("expected config error, got: {:?}", other),
202        }
203
204        unsafe {
205            std::env::remove_var("OPENAI_API_KEY");
206        }
207    }
208}