Skip to main content

fastskill_core/core/repository/
client.rs

1//! Repository client abstraction for unified skill access
2
3use crate::core::metadata::SkillMetadata;
4use crate::core::registry::{RegistryClient, RegistryConfig as OldRegistryConfig};
5use crate::core::registry_index::{ListSkillsOptions, SkillSummary};
6use crate::core::repository::{RepositoryConfig, RepositoryDefinition, RepositoryType};
7use crate::core::service::{ServiceError, SkillId};
8use crate::core::sources::{SourceConfig, SourceDefinition, SourcesManager};
9use reqwest::Client;
10use std::sync::Arc;
11
12/// Error type for repository client operations
13#[derive(Debug, thiserror::Error)]
14pub enum RepositoryClientError {
15    #[error("Repository error: {0}")]
16    Service(#[from] ServiceError),
17    #[error("Client error: {0}")]
18    Client(String),
19    #[error("Not implemented for this repository type")]
20    NotImplemented,
21}
22
23/// Trait for unified repository clients
24#[async_trait::async_trait]
25pub trait RepositoryClient: Send + Sync {
26    /// List all skills in the repository
27    async fn list_skills(&self) -> Result<Vec<SkillMetadata>, RepositoryClientError>;
28
29    /// Get a specific skill by ID and optional version
30    async fn get_skill(
31        &self,
32        id: &str,
33        version: Option<&str>,
34    ) -> Result<Option<SkillMetadata>, RepositoryClientError>;
35
36    /// Search for skills matching a query
37    async fn search(&self, query: &str) -> Result<Vec<SkillMetadata>, RepositoryClientError>;
38
39    /// Download a skill package
40    async fn download(&self, id: &str, version: &str) -> Result<Vec<u8>, RepositoryClientError>;
41
42    /// Get all versions for a skill
43    async fn get_versions(&self, id: &str) -> Result<Vec<String>, RepositoryClientError>;
44}
45
46/// Create a repository client from a repository definition
47pub async fn create_client(
48    repo: &RepositoryDefinition,
49) -> Result<Arc<dyn RepositoryClient + Send + Sync>, ServiceError> {
50    match repo.repo_type {
51        RepositoryType::GitMarketplace | RepositoryType::ZipUrl | RepositoryType::Local => {
52            Ok(Arc::new(MarketplaceRepositoryClient::new(repo)?))
53        }
54        RepositoryType::HttpRegistry => Ok(Arc::new(CratesRegistryClient::new(repo)?)),
55    }
56}
57
58/// Marketplace-based repository client (wraps SourcesManager logic)
59pub struct MarketplaceRepositoryClient {
60    sources_manager: SourcesManager,
61    source_name: String,
62}
63
64impl MarketplaceRepositoryClient {
65    pub fn new(repo: &RepositoryDefinition) -> Result<Self, ServiceError> {
66        // Convert RepositoryConfig to SourceConfig
67        let source_config = match &repo.config {
68            RepositoryConfig::GitMarketplace { url, branch, tag } => {
69                // Convert auth
70                let auth = repo.auth.as_ref().and_then(|a| {
71                    match a {
72                        crate::core::repository::RepositoryAuth::Pat { env_var } => {
73                            Some(crate::core::sources::SourceAuth::Pat {
74                                env_var: env_var.clone(),
75                            })
76                        }
77                        crate::core::repository::RepositoryAuth::SshKey { path } => {
78                            Some(crate::core::sources::SourceAuth::SshKey { path: path.clone() })
79                        }
80                        crate::core::repository::RepositoryAuth::Basic {
81                            username,
82                            password_env,
83                        } => Some(crate::core::sources::SourceAuth::Basic {
84                            username: username.clone(),
85                            password_env: password_env.clone(),
86                        }),
87                        _ => None, // Other auth types not supported for marketplace
88                    }
89                });
90
91                SourceConfig::Git {
92                    url: url.clone(),
93                    branch: branch.clone(),
94                    tag: tag.clone(),
95                    auth,
96                }
97            }
98            RepositoryConfig::ZipUrl { base_url } => {
99                let auth = repo.auth.as_ref().and_then(|a| match a {
100                    crate::core::repository::RepositoryAuth::Pat { env_var } => {
101                        Some(crate::core::sources::SourceAuth::Pat {
102                            env_var: env_var.clone(),
103                        })
104                    }
105                    crate::core::repository::RepositoryAuth::Basic {
106                        username,
107                        password_env,
108                    } => Some(crate::core::sources::SourceAuth::Basic {
109                        username: username.clone(),
110                        password_env: password_env.clone(),
111                    }),
112                    _ => None,
113                });
114
115                SourceConfig::ZipUrl {
116                    base_url: base_url.clone(),
117                    auth,
118                }
119            }
120            RepositoryConfig::Local { path } => SourceConfig::Local { path: path.clone() },
121            RepositoryConfig::HttpRegistry { .. } => {
122                return Err(ServiceError::Custom(
123                    "HttpRegistry should use CratesRegistryClient".to_string(),
124                ));
125            }
126        };
127
128        // Create a temporary sources manager with just this source
129        let temp_path = std::env::temp_dir().join(format!("fastskill-repo-{}", repo.name));
130        let mut sources_manager = SourcesManager::new(temp_path);
131        sources_manager
132            .load()
133            .map_err(|e| ServiceError::Custom(format!("Failed to load sources manager: {}", e)))?;
134
135        let source_def = SourceDefinition {
136            name: repo.name.clone(),
137            priority: repo.priority,
138            source: source_config,
139        };
140
141        sources_manager
142            .add_source_with_priority(repo.name.clone(), source_def.source.clone(), repo.priority)
143            .map_err(|e| ServiceError::Custom(format!("Failed to add source: {}", e)))?;
144
145        Ok(Self {
146            sources_manager,
147            source_name: repo.name.clone(),
148        })
149    }
150}
151
152#[async_trait::async_trait]
153impl RepositoryClient for MarketplaceRepositoryClient {
154    async fn list_skills(&self) -> Result<Vec<SkillMetadata>, RepositoryClientError> {
155        let source_def = self
156            .sources_manager
157            .get_source(&self.source_name)
158            .ok_or_else(|| RepositoryClientError::Client("Source not found".to_string()))?;
159
160        let skill_infos = self
161            .sources_manager
162            .get_skills_from_source(&self.source_name, source_def)
163            .await
164            .map_err(|e| RepositoryClientError::Client(format!("Failed to get skills: {}", e)))?;
165
166        use crate::core::service::SkillId;
167        use chrono::Utc;
168
169        Ok(skill_infos
170            .into_iter()
171            .filter_map(|info| {
172                SkillId::new(info.id.clone()).ok().map(|id| SkillMetadata {
173                    id,
174                    name: info.name,
175                    description: info.description,
176                    version: info.version.unwrap_or_else(|| "1.0.0".to_string()),
177                    author: None,
178                    enabled: true,
179                    token_estimate: 0,
180                    last_updated: Utc::now(),
181                })
182            })
183            .collect())
184    }
185
186    async fn get_skill(
187        &self,
188        id: &str,
189        version: Option<&str>,
190    ) -> Result<Option<SkillMetadata>, RepositoryClientError> {
191        use crate::core::service::SkillId;
192        let skill_id = SkillId::new(id.to_string())
193            .map_err(|e| RepositoryClientError::Client(format!("Invalid skill ID: {}", e)))?;
194        let skills = self.list_skills().await?;
195        Ok(skills
196            .into_iter()
197            .find(|s| s.id == skill_id && version.map(|v| s.version == v).unwrap_or(true)))
198    }
199
200    async fn search(&self, query: &str) -> Result<Vec<SkillMetadata>, RepositoryClientError> {
201        let skills = self.list_skills().await?;
202        let query_lower = query.to_lowercase();
203        Ok(skills
204            .into_iter()
205            .filter(|s| {
206                s.id.to_string().to_lowercase().contains(&query_lower)
207                    || s.name.to_lowercase().contains(&query_lower)
208                    || s.description.to_lowercase().contains(&query_lower)
209            })
210            .collect())
211    }
212
213    async fn download(&self, _id: &str, _version: &str) -> Result<Vec<u8>, RepositoryClientError> {
214        // For marketplace repositories, we need to get the download URL from marketplace.json
215        // This is a simplified implementation - full implementation would fetch marketplace.json
216        Err(RepositoryClientError::NotImplemented)
217    }
218
219    async fn get_versions(&self, id: &str) -> Result<Vec<String>, RepositoryClientError> {
220        use crate::core::service::SkillId;
221        let skill_id = SkillId::new(id.to_string())
222            .map_err(|e| RepositoryClientError::Client(format!("Invalid skill ID: {}", e)))?;
223        let skills = self.list_skills().await?;
224        Ok(skills
225            .into_iter()
226            .filter(|s| s.id == skill_id)
227            .map(|s| s.version)
228            .collect())
229    }
230}
231
232/// HTTP registry client (wraps RegistryClient logic)
233pub struct CratesRegistryClient {
234    registry_client: RegistryClient,
235    index_url: String,
236    auth: Option<crate::core::registry::config::AuthConfig>,
237}
238
239impl CratesRegistryClient {
240    pub fn new(repo: &RepositoryDefinition) -> Result<Self, ServiceError> {
241        let index_url = match &repo.config {
242            RepositoryConfig::HttpRegistry { index_url } => index_url.clone(),
243            _ => {
244                return Err(ServiceError::Custom(
245                    "HttpRegistry requires index_url".to_string(),
246                ))
247            }
248        };
249
250        // Validate URL is not empty
251        if index_url.trim().is_empty() {
252            return Err(ServiceError::Custom(
253                "index_url cannot be empty".to_string(),
254            ));
255        }
256
257        // Validate URL format
258        if url::Url::parse(&index_url).is_err() {
259            return Err(ServiceError::Custom(format!(
260                "Invalid index_url format: {}",
261                index_url
262            )));
263        }
264
265        // Convert auth
266        let auth = repo.auth.as_ref().map(|a| {
267            match a {
268                crate::core::repository::RepositoryAuth::Pat { env_var } => {
269                    crate::core::registry::config::AuthConfig::Pat {
270                        env_var: env_var.clone(),
271                    }
272                }
273                crate::core::repository::RepositoryAuth::Ssh { key_path } => {
274                    crate::core::registry::config::AuthConfig::Ssh {
275                        key_path: key_path.clone(),
276                    }
277                }
278                crate::core::repository::RepositoryAuth::ApiKey { env_var } => {
279                    crate::core::registry::config::AuthConfig::ApiKey {
280                        env_var: env_var.clone(),
281                    }
282                }
283                _ => {
284                    // Fallback to PAT for unsupported types
285                    crate::core::registry::config::AuthConfig::Pat {
286                        env_var: "GITHUB_TOKEN".to_string(),
287                    }
288                }
289            }
290        });
291
292        let registry_config = OldRegistryConfig {
293            name: repo.name.clone(),
294            registry_type: "git".to_string(),
295            index_url,
296            auth,
297            storage: repo
298                .storage
299                .clone()
300                .map(|s| crate::core::registry::config::StorageConfig {
301                    storage_type: s.storage_type,
302                    repository: s.repository,
303                    bucket: s.bucket,
304                    region: s.region,
305                    endpoint: s.endpoint,
306                    base_url: s.base_url,
307                }),
308        };
309
310        let registry_client = RegistryClient::new(registry_config.clone())?;
311
312        Ok(Self {
313            registry_client,
314            index_url: registry_config.index_url.clone(),
315            auth: registry_config.auth.clone(),
316        })
317    }
318
319    /// Fetch skills from the registry HTTP API endpoint
320    pub async fn fetch_skills(
321        &self,
322        options: &ListSkillsOptions,
323    ) -> Result<Vec<SkillSummary>, RepositoryClientError> {
324        use crate::core::registry::auth::Auth;
325
326        // Build the API endpoint URL
327        let base_url = self.index_url.trim_end_matches('/');
328        let mut url = format!("{}/api/registry/index/skills", base_url);
329
330        // Add query parameters
331        let mut query_params = Vec::new();
332        if let Some(ref scope) = options.scope {
333            query_params.push(("scope", scope.clone()));
334        }
335        if options.all_versions {
336            query_params.push(("all_versions", "true".to_string()));
337        }
338        if options.include_pre_release {
339            query_params.push(("include_pre_release", "true".to_string()));
340        }
341
342        if !query_params.is_empty() {
343            let mut url_obj = url::Url::parse(&url)
344                .map_err(|e| RepositoryClientError::Client(format!("Invalid URL: {}", e)))?;
345            for (k, v) in query_params {
346                url_obj.query_pairs_mut().append_pair(k, &v);
347            }
348            url = url_obj.to_string();
349        }
350
351        // Create HTTP client
352        let client = Client::builder()
353            .user_agent("fastskill/0.8.6")
354            .build()
355            .map_err(|e| {
356                RepositoryClientError::Client(format!("Failed to create HTTP client: {}", e))
357            })?;
358
359        // Build request
360        let mut request = client.get(&url);
361
362        // Add authentication if configured
363        if let Some(ref auth_config) = self.auth {
364            let auth: Option<Box<dyn Auth>> = match auth_config {
365                crate::core::registry::config::AuthConfig::Pat { env_var } => Some(Box::new(
366                    crate::core::registry::auth::GitHubPat::new(env_var.clone()),
367                )),
368                crate::core::registry::config::AuthConfig::Ssh { key_path } => Some(Box::new(
369                    crate::core::registry::auth::SshKey::new(key_path.clone()),
370                )),
371                crate::core::registry::config::AuthConfig::ApiKey { env_var } => Some(Box::new(
372                    crate::core::registry::auth::ApiKey::new(env_var.clone()),
373                )),
374            };
375
376            if let Some(auth) = auth {
377                if auth.is_configured() {
378                    if let Ok(header_value) = auth.get_auth_header() {
379                        request = request.header("Authorization", header_value);
380                    }
381                }
382            }
383        }
384
385        // Send request
386        let response = request
387            .send()
388            .await
389            .map_err(|e| RepositoryClientError::Client(format!("HTTP request failed: {}", e)))?;
390
391        // Handle HTTP status codes
392        let status = response.status();
393        match status.as_u16() {
394            200 => {
395                // Parse JSON response
396                let summaries: Vec<SkillSummary> = response.json().await.map_err(|e| {
397                    RepositoryClientError::Client(format!("Failed to parse JSON response: {}", e))
398                })?;
399                Ok(summaries)
400            }
401            400 => Err(RepositoryClientError::Client(
402                "Bad request: Invalid query parameters".to_string(),
403            )),
404            401 => Err(RepositoryClientError::Client(
405                "Unauthorized: Authentication required for this scope".to_string(),
406            )),
407            403 => Err(RepositoryClientError::Client(
408                "Forbidden: Access denied to this scope".to_string(),
409            )),
410            404 => Err(RepositoryClientError::Client(
411                "Not found: Registry endpoint not available".to_string(),
412            )),
413            500..=599 => Err(RepositoryClientError::Client(format!(
414                "Server error: HTTP {}",
415                status
416            ))),
417            _ => Err(RepositoryClientError::Client(format!(
418                "Unexpected HTTP status: {}",
419                status
420            ))),
421        }
422    }
423}
424
425#[async_trait::async_trait]
426impl RepositoryClient for CratesRegistryClient {
427    async fn list_skills(&self) -> Result<Vec<SkillMetadata>, RepositoryClientError> {
428        // Use default options (no filtering)
429        let options = ListSkillsOptions::default();
430        let summaries = self.fetch_skills(&options).await?;
431
432        // Convert SkillSummary to SkillMetadata
433        let metadata: Vec<SkillMetadata> = summaries
434            .into_iter()
435            .filter_map(|s| {
436                SkillId::new(s.id.clone()).ok().map(|id| SkillMetadata {
437                    id,
438                    name: s.name.clone(),
439                    description: s.description.clone(),
440                    version: s.latest_version.clone(),
441                    author: None, // Not available in SkillSummary
442                    enabled: true,
443                    token_estimate: s.description.len() / 4, // Rough estimate
444                    last_updated: s.published_at.unwrap_or_else(chrono::Utc::now),
445                })
446            })
447            .collect();
448
449        Ok(metadata)
450    }
451
452    async fn get_skill(
453        &self,
454        id: &str,
455        version: Option<&str>,
456    ) -> Result<Option<SkillMetadata>, RepositoryClientError> {
457        let entries = self
458            .registry_client
459            .get_skill(id)
460            .await
461            .map_err(RepositoryClientError::Service)?;
462
463        if entries.is_empty() {
464            return Ok(None);
465        }
466
467        // Filter by version if specified
468        let entry = if let Some(ver) = version {
469            entries.into_iter().find(|e| e.vers == ver)
470        } else {
471            // Get latest version
472            entries.into_iter().max_by_key(|e| e.vers.clone())
473        };
474
475        use crate::core::service::SkillId;
476        use chrono::Utc;
477
478        Ok(entry.and_then(|e| {
479            SkillId::new(e.name.clone().to_string())
480                .ok()
481                .map(|id| SkillMetadata {
482                    id,
483                    name: e.name.clone(),
484                    description: e
485                        .metadata
486                        .as_ref()
487                        .and_then(|m| m.description.clone())
488                        .unwrap_or_else(|| "".to_string()),
489                    version: e.vers,
490                    author: e.metadata.as_ref().and_then(|m| m.author.clone()),
491                    enabled: true,
492                    token_estimate: 0,
493                    last_updated: Utc::now(),
494                })
495        }))
496    }
497
498    async fn search(&self, query: &str) -> Result<Vec<SkillMetadata>, RepositoryClientError> {
499        // Use RegistryClient's search method
500        let results = self
501            .registry_client
502            .search(query)
503            .await
504            .map_err(RepositoryClientError::Service)?;
505
506        Ok(results)
507    }
508
509    async fn download(&self, id: &str, version: &str) -> Result<Vec<u8>, RepositoryClientError> {
510        let data = self
511            .registry_client
512            .download(id, version)
513            .await
514            .map_err(RepositoryClientError::Service)?;
515        Ok(data)
516    }
517
518    async fn get_versions(&self, id: &str) -> Result<Vec<String>, RepositoryClientError> {
519        let versions = self
520            .registry_client
521            .get_versions(id)
522            .await
523            .map_err(RepositoryClientError::Service)?;
524        Ok(versions)
525    }
526}