fastskill_core/search/
local.rs1use super::{SearchError, SearchQuery, SearchResultItem};
7use crate::{EmbeddingService, FastSkillService};
8
9pub 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 perform_text_search(service, &query.query, query.limit).await?
18 }
19 Some(true) => {
20 perform_embedding_search(service, &query.query, query.limit).await?
22 }
23 None => {
24 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
38async 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), 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
90async 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 let api_key = load_openai_api_key()?;
112
113 let embedding_service = crate::OpenAIEmbeddingService::from_config(embedding_config, api_key);
115
116 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 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 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}