Skip to main content

fastskill_core/search/
remote.rs

1//! Remote search implementation for repository catalogs
2//!
3//! This module handles searching through remote skill repositories
4//! configured in the user's repository configuration.
5
6use super::{SearchError, SearchQuery, SearchResultItem};
7use crate::core::manifest::SkillProjectToml;
8use crate::core::project;
9use crate::core::repository::{RepositoryDefinition, RepositoryManager};
10use std::env;
11
12/// Execute remote search query
13pub 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    // Load repository definitions from default locations
20    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, // Remote search doesn't provide similarity scores
50                                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            } // Skip repositories that fail to load when searching across all repos
75        }
76    }
77
78    // Sort by repository name for consistent ordering
79    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
91/// Load repository definitions from default configuration locations
92fn 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    // Try to find skill-project.toml
97    let project_file = project::resolve_project_file(&current_dir);
98    if !project_file.found {
99        return Ok(Vec::new()); // No skill-project.toml found
100    }
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    // Extract repositories from [tool.fastskill]
112    let repositories = project
113        .tool
114        .and_then(|t| t.fastskill)
115        .and_then(|f| f.repositories)
116        .unwrap_or_default();
117
118    // Convert manifest::RepositoryDefinition to repository::RepositoryDefinition
119    let converted_repos = repositories
120        .into_iter()
121        .map(convert_repository_definition)
122        .collect();
123    Ok(converted_repos)
124}
125
126/// Convert manifest::RepositoryDefinition to repository::RepositoryDefinition
127fn convert_repository_definition(
128    manifest_repo: crate::core::manifest::RepositoryDefinition,
129) -> RepositoryDefinition {
130    use crate::core::repository::{RepositoryAuth, RepositoryConfig, RepositoryType};
131
132    // Convert repository type
133    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    // Convert connection to config
141    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    // Convert auth
161    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, // Not used in manifest format
174    }
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}