fastskill_core/search/
remote.rs1use super::{SearchError, SearchQuery, SearchResultItem};
7use crate::core::manifest::SkillProjectToml;
8use crate::core::project;
9use crate::core::repository::{RepositoryDefinition, RepositoryManager};
10use std::env;
11
12pub async fn execute_remote_search(
14 query: SearchQuery,
15 repository_filter: Option<String>,
16) -> Result<Vec<SearchResultItem>, SearchError> {
17 let strict_repository = repository_filter.is_some();
18
19 let definitions = load_repository_definitions()?;
21
22 let repo_manager = RepositoryManager::from_definitions(definitions);
23
24 let repos = if let Some(repo_name) = repository_filter {
25 vec![repo_manager
26 .get_repository(&repo_name)
27 .ok_or_else(|| SearchError::Config(format!("Repository '{}' not found", repo_name)))?]
28 } else {
29 repo_manager.list_repositories()
30 };
31
32 let mut all_results = Vec::new();
33
34 for repo in repos {
35 match repo_manager.get_client(&repo.name).await {
36 Ok(client) => {
37 match client.search(&query.query).await {
38 Ok(results) => {
39 for result in results {
40 let result_item = SearchResultItem {
41 id: result.name.clone(),
42 name: result.name,
43 description: if result.description.is_empty() {
44 None
45 } else {
46 Some(result.description)
47 },
48 source: repo.name.clone(),
49 similarity: None, path: None,
51 repository: Some(repo.name.clone()),
52 };
53 all_results.push(result_item);
54 }
55 }
56 Err(e) => {
57 if strict_repository {
58 return Err(SearchError::Repository(format!(
59 "Search on '{}' failed: {}",
60 repo.name, e
61 )));
62 }
63 }
64 }
65 }
66 Err(e) => {
67 if strict_repository {
68 return Err(SearchError::Repository(format!(
69 "Failed to load client for '{}': {}",
70 repo.name, e
71 )));
72 }
73 continue;
74 } }
76 }
77
78 all_results.sort_by(|a, b| {
80 a.repository
81 .as_deref()
82 .unwrap_or("")
83 .cmp(b.repository.as_deref().unwrap_or(""))
84 .then_with(|| a.name.cmp(&b.name))
85 });
86 all_results.truncate(query.limit);
87
88 Ok(all_results)
89}
90
91fn load_repository_definitions() -> Result<Vec<RepositoryDefinition>, SearchError> {
93 let current_dir = env::current_dir()
94 .map_err(|e| SearchError::Config(format!("Failed to get current directory: {}", e)))?;
95
96 let project_file = project::resolve_project_file(¤t_dir);
98 if !project_file.found {
99 return Ok(Vec::new()); }
101 let project_path = project_file.path;
102
103 let project = SkillProjectToml::load_from_file(&project_path).map_err(|e| {
104 SearchError::Config(format!(
105 "Failed to load skill-project.toml from {}: {}",
106 project_path.display(),
107 e
108 ))
109 })?;
110
111 let repositories = project
113 .tool
114 .and_then(|t| t.fastskill)
115 .and_then(|f| f.repositories)
116 .unwrap_or_default();
117
118 let converted_repos = repositories
120 .into_iter()
121 .map(convert_repository_definition)
122 .collect();
123 Ok(converted_repos)
124}
125
126fn convert_repository_definition(
128 manifest_repo: crate::core::manifest::RepositoryDefinition,
129) -> RepositoryDefinition {
130 use crate::core::repository::{RepositoryAuth, RepositoryConfig, RepositoryType};
131
132 let repo_type = match manifest_repo.r#type {
134 crate::core::manifest::RepositoryType::HttpRegistry => RepositoryType::HttpRegistry,
135 crate::core::manifest::RepositoryType::GitMarketplace => RepositoryType::GitMarketplace,
136 crate::core::manifest::RepositoryType::ZipUrl => RepositoryType::ZipUrl,
137 crate::core::manifest::RepositoryType::Local => RepositoryType::Local,
138 };
139
140 let config = match manifest_repo.connection {
142 crate::core::manifest::RepositoryConnection::HttpRegistry { index_url } => {
143 RepositoryConfig::HttpRegistry { index_url }
144 }
145 crate::core::manifest::RepositoryConnection::GitMarketplace { url, branch } => {
146 RepositoryConfig::GitMarketplace {
147 url,
148 branch,
149 tag: None,
150 }
151 }
152 crate::core::manifest::RepositoryConnection::ZipUrl { zip_url } => {
153 RepositoryConfig::ZipUrl { base_url: zip_url }
154 }
155 crate::core::manifest::RepositoryConnection::Local { path } => RepositoryConfig::Local {
156 path: std::path::PathBuf::from(path),
157 },
158 };
159
160 let auth = manifest_repo.auth.map(|a| match a.r#type {
162 crate::core::manifest::AuthType::Pat => RepositoryAuth::Pat {
163 env_var: a.env_var.unwrap_or_else(|| "PAT_TOKEN".to_string()),
164 },
165 });
166
167 RepositoryDefinition {
168 name: manifest_repo.name,
169 repo_type,
170 priority: manifest_repo.priority,
171 config,
172 auth,
173 storage: None, }
175}
176
177#[cfg(test)]
178#[allow(
179 clippy::unwrap_used,
180 clippy::expect_used,
181 clippy::panic,
182 clippy::await_holding_lock
183)]
184mod tests {
185 use super::*;
186 use once_cell::sync::Lazy;
187 use std::fs;
188 use std::path::Path;
189 use std::sync::{Mutex, MutexGuard};
190 use tempfile::TempDir;
191
192 static CWD_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
193
194 struct CurrentDirGuard {
195 previous: std::path::PathBuf,
196 }
197
198 impl CurrentDirGuard {
199 fn set(path: &Path) -> Self {
200 let previous = std::env::current_dir().expect("failed to read current directory");
201 std::env::set_current_dir(path).expect("failed to set current directory");
202 Self { previous }
203 }
204 }
205
206 impl Drop for CurrentDirGuard {
207 fn drop(&mut self) {
208 let _ = std::env::set_current_dir(&self.previous);
209 }
210 }
211
212 fn enter_temp_workspace() -> (MutexGuard<'static, ()>, TempDir, CurrentDirGuard) {
213 let lock = CWD_LOCK.lock().expect("failed to lock cwd mutex");
214 let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
215 let guard = CurrentDirGuard::set(temp_dir.path());
216 (lock, temp_dir, guard)
217 }
218
219 fn write_project_with_invalid_repo(repo_name: &str) {
220 let project_toml = format!(
221 r#"
222[dependencies]
223
224[[tool.fastskill.repositories]]
225name = "{repo_name}"
226type = "http-registry"
227priority = 0
228index_url = "not-a-valid-url"
229"#
230 );
231
232 fs::write("skill-project.toml", project_toml)
233 .expect("failed to write skill-project.toml for test");
234 }
235
236 fn sample_query() -> SearchQuery {
237 SearchQuery {
238 query: "test".to_string(),
239 scope: super::super::SearchScope::Remote,
240 limit: 10,
241 embedding: None,
242 }
243 }
244
245 #[tokio::test]
246 async fn remote_repo_filter_returns_repository_error_on_client_init_failure() {
247 let (_lock, _temp_dir, _guard) = enter_temp_workspace();
248 write_project_with_invalid_repo("broken");
249
250 let result = execute_remote_search(sample_query(), Some("broken".to_string())).await;
251 match result {
252 Err(SearchError::Repository(msg)) => {
253 assert!(msg.contains("broken"), "unexpected message: {msg}");
254 }
255 other => panic!("expected repository error, got: {:?}", other),
256 }
257 }
258
259 #[tokio::test]
260 async fn remote_all_repos_skips_invalid_repository_client_errors() {
261 let (_lock, _temp_dir, _guard) = enter_temp_workspace();
262 write_project_with_invalid_repo("broken");
263
264 let result = execute_remote_search(sample_query(), None).await;
265 assert!(result.is_ok(), "search across all repos should not fail");
266 assert!(result.unwrap().is_empty());
267 }
268}