Skip to main content

fastskill_core/core/
repository.rs

1//! Unified repository system for managing skill storage locations
2//!
3//! This module provides a unified repositories.toml configuration for all repository types.
4
5pub mod client;
6
7pub use client::{CratesRegistryClient, RepositoryClient, RepositoryClientError};
8
9use crate::core::service::ServiceError;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::sync::Arc;
14use tokio::sync::RwLock;
15
16/// Main repositories configuration
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RepositoriesConfig {
19    #[serde(default)]
20    pub repositories: Vec<RepositoryDefinition>,
21}
22
23/// Repository definition
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RepositoryDefinition {
26    pub name: String,
27    #[serde(rename = "type")]
28    pub repo_type: RepositoryType,
29    #[serde(default = "default_priority")]
30    pub priority: u32,
31    #[serde(flatten)]
32    pub config: RepositoryConfig,
33    #[serde(default)]
34    pub auth: Option<RepositoryAuth>,
35    #[serde(default)]
36    pub storage: Option<StorageConfig>,
37}
38
39/// Default priority value (0 = highest priority)
40fn default_priority() -> u32 {
41    0
42}
43
44/// Repository type
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "kebab-case")]
47pub enum RepositoryType {
48    /// Git repository with marketplace.json
49    GitMarketplace,
50    /// HTTP-based registry with flat index layout
51    HttpRegistry,
52    /// ZIP URL base with marketplace.json
53    ZipUrl,
54    /// Local directory
55    Local,
56}
57
58/// Unified repository configuration
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(untagged)]
61pub enum RepositoryConfig {
62    /// Git marketplace configuration
63    GitMarketplace {
64        url: String,
65        #[serde(default)]
66        branch: Option<String>,
67        #[serde(default)]
68        tag: Option<String>,
69    },
70    /// HTTP registry configuration
71    HttpRegistry { index_url: String },
72    /// ZIP URL configuration
73    ZipUrl { base_url: String },
74    /// Local path configuration
75    Local { path: PathBuf },
76}
77
78/// Unified authentication configuration
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(tag = "type")]
81pub enum RepositoryAuth {
82    #[serde(rename = "pat")]
83    Pat { env_var: String },
84    #[serde(rename = "ssh-key")]
85    SshKey { path: PathBuf },
86    #[serde(rename = "ssh")]
87    Ssh { key_path: PathBuf },
88    #[serde(rename = "basic")]
89    Basic {
90        username: String,
91        password_env: String,
92    },
93    #[serde(rename = "api_key")]
94    ApiKey { env_var: String },
95}
96
97/// Storage backend configuration
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct StorageConfig {
100    #[serde(rename = "type")]
101    pub storage_type: String,
102    #[serde(default)]
103    pub repository: Option<String>,
104    #[serde(default)]
105    pub bucket: Option<String>,
106    #[serde(default)]
107    pub region: Option<String>,
108    #[serde(default)]
109    pub endpoint: Option<String>,
110    #[serde(default)]
111    pub base_url: Option<String>,
112}
113
114/// Repository manager for handling multiple repositories
115pub struct RepositoryManager {
116    config_path: PathBuf,
117    repositories: HashMap<String, RepositoryDefinition>,
118    clients: Arc<RwLock<HashMap<String, Arc<dyn RepositoryClient + Send + Sync>>>>,
119}
120
121impl RepositoryManager {
122    /// Create a new repository manager
123    pub fn new(config_path: PathBuf) -> Self {
124        Self {
125            config_path,
126            repositories: HashMap::new(),
127            clients: Arc::new(RwLock::new(HashMap::new())),
128        }
129    }
130
131    /// Create a repository manager from a list of repository definitions
132    /// Used when loading from skill-project.toml instead of repositories.toml
133    pub fn from_definitions(definitions: Vec<RepositoryDefinition>) -> Self {
134        let mut repo_map: HashMap<String, RepositoryDefinition> = HashMap::new();
135        let mut sorted_repos = definitions;
136        sorted_repos.sort_by_key(|r| r.priority);
137
138        for repo in sorted_repos {
139            repo_map.entry(repo.name.clone()).or_insert(repo);
140        }
141
142        // Determine config path: try to use skill-project.toml, otherwise use empty path
143        let config_path = std::env::current_dir()
144            .ok()
145            .and_then(|dir| {
146                let project_file = crate::core::project::resolve_project_file(&dir);
147                if project_file.found {
148                    Some(project_file.path)
149                } else {
150                    None
151                }
152            })
153            .unwrap_or_default();
154
155        Self {
156            config_path,
157            repositories: repo_map,
158            clients: Arc::new(RwLock::new(HashMap::new())),
159        }
160    }
161
162    /// Load repositories from TOML file
163    /// Loads from repositories.toml only
164    pub fn load(&mut self) -> Result<(), ServiceError> {
165        if self.config_path.exists() {
166            // Load from unified repositories.toml
167            let content = std::fs::read_to_string(&self.config_path).map_err(ServiceError::Io)?;
168
169            let config: RepositoriesConfig = toml::from_str(&content).map_err(|e| {
170                ServiceError::Custom(format!("Failed to parse repositories config: {}", e))
171            })?;
172
173            // T068: Repository priority conflict resolution (first occurrence wins)
174            // Sort repositories by priority (lower number = higher priority)
175            let mut sorted_repos: Vec<RepositoryDefinition> = config.repositories;
176            sorted_repos.sort_by_key(|r| r.priority);
177
178            // First occurrence wins - only insert if name doesn't already exist
179            let mut repo_map: HashMap<String, RepositoryDefinition> = HashMap::new();
180            for repo in sorted_repos {
181                repo_map.entry(repo.name.clone()).or_insert(repo);
182            }
183            self.repositories = repo_map;
184
185            return Ok(());
186        }
187
188        // Create default empty config if no configs exist
189        let config = RepositoriesConfig {
190            repositories: Vec::new(),
191        };
192        self.save_config(&config)?;
193        self.repositories = HashMap::new();
194        Ok(())
195    }
196
197    /// Save repositories to TOML file
198    pub fn save(&self) -> Result<(), ServiceError> {
199        // Check if config_path is a skill-project.toml file
200        if self.config_path.file_name().and_then(|n| n.to_str()) == Some("skill-project.toml") {
201            self.save_to_project_file()
202        } else {
203            // Old repositories.toml format
204            let mut repos: Vec<RepositoryDefinition> =
205                self.repositories.values().cloned().collect();
206            repos.sort_by_key(|r| r.priority);
207            let config = RepositoriesConfig {
208                repositories: repos,
209            };
210            self.save_config(&config)
211        }
212    }
213
214    /// Save repositories to skill-project.toml
215    fn save_to_project_file(&self) -> Result<(), ServiceError> {
216        use crate::core::manifest::SkillProjectToml;
217
218        // Ensure parent directory exists
219        if let Some(parent) = self.config_path.parent() {
220            std::fs::create_dir_all(parent).map_err(ServiceError::Io)?;
221        }
222
223        // Load existing project file or create new one
224        let mut project = if self.config_path.exists() {
225            SkillProjectToml::load_from_file(&self.config_path).map_err(|e| {
226                ServiceError::Custom(format!("Failed to load skill-project.toml: {}", e))
227            })?
228        } else {
229            // Create minimal project file
230            SkillProjectToml {
231                metadata: None,
232                dependencies: None,
233                tool: None,
234            }
235        };
236
237        // Convert RepositoryDefinition back to manifest format
238        let manifest_repos: Vec<crate::core::manifest::RepositoryDefinition> = self
239            .repositories
240            .values()
241            .map(|repo| self.convert_to_manifest_repo(repo))
242            .collect();
243
244        // Update tool.fastskill.repositories
245        if project.tool.is_none() {
246            project.tool = Some(crate::core::manifest::ToolSection {
247                fastskill: Some(crate::core::manifest::FastSkillToolConfig {
248                    skills_directory: None,
249                    embedding: None,
250                    repositories: Some(manifest_repos),
251                    server: None,
252                    install_depth: 5,
253                    skip_transitive: false,
254                    eval: None,
255                }),
256            });
257        } else if let Some(ref mut tool) = project.tool {
258            if tool.fastskill.is_none() {
259                tool.fastskill = Some(crate::core::manifest::FastSkillToolConfig {
260                    skills_directory: None,
261                    embedding: None,
262                    repositories: Some(manifest_repos),
263                    server: None,
264                    install_depth: 5,
265                    skip_transitive: false,
266                    eval: None,
267                });
268            } else if let Some(ref mut fastskill) = tool.fastskill {
269                fastskill.repositories = Some(manifest_repos);
270            }
271        }
272
273        // Save the project file
274        project.save_to_file(&self.config_path).map_err(|e| {
275            ServiceError::Custom(format!("Failed to save skill-project.toml: {}", e))
276        })?;
277
278        Ok(())
279    }
280
281    /// Convert RepositoryDefinition to manifest format
282    fn convert_to_manifest_repo(
283        &self,
284        repo: &RepositoryDefinition,
285    ) -> crate::core::manifest::RepositoryDefinition {
286        use crate::core::manifest::{
287            AuthConfig, AuthType, RepositoryConnection, RepositoryType as ManifestType,
288        };
289
290        let repo_type = match repo.repo_type {
291            RepositoryType::HttpRegistry => ManifestType::HttpRegistry,
292            RepositoryType::GitMarketplace => ManifestType::GitMarketplace,
293            RepositoryType::ZipUrl => ManifestType::ZipUrl,
294            RepositoryType::Local => ManifestType::Local,
295        };
296
297        let connection = match &repo.config {
298            RepositoryConfig::HttpRegistry { index_url } => RepositoryConnection::HttpRegistry {
299                index_url: index_url.clone(),
300            },
301            RepositoryConfig::GitMarketplace {
302                url,
303                branch,
304                tag: _,
305            } => RepositoryConnection::GitMarketplace {
306                url: url.clone(),
307                branch: branch.clone(),
308            },
309            RepositoryConfig::ZipUrl { base_url } => RepositoryConnection::ZipUrl {
310                zip_url: base_url.clone(),
311            },
312            RepositoryConfig::Local { path } => RepositoryConnection::Local {
313                path: path.to_string_lossy().to_string(),
314            },
315        };
316
317        // Convert auth - manifest format only supports PAT currently
318        let auth = repo.auth.as_ref().and_then(|a| match a {
319            RepositoryAuth::Pat { env_var } => Some(AuthConfig {
320                r#type: AuthType::Pat,
321                env_var: Some(env_var.clone()),
322            }),
323            // Other auth types not supported in manifest format yet
324            _ => None,
325        });
326
327        crate::core::manifest::RepositoryDefinition {
328            name: repo.name.clone(),
329            r#type: repo_type,
330            priority: repo.priority,
331            connection,
332            auth,
333        }
334    }
335
336    /// Internal helper to save config (for old repositories.toml format)
337    fn save_config(&self, config: &RepositoriesConfig) -> Result<(), ServiceError> {
338        // Ensure parent directory exists
339        if let Some(parent) = self.config_path.parent() {
340            std::fs::create_dir_all(parent).map_err(ServiceError::Io)?;
341        }
342
343        let content = toml::to_string_pretty(config).map_err(|e| {
344            ServiceError::Custom(format!("Failed to serialize repositories config: {}", e))
345        })?;
346
347        std::fs::write(&self.config_path, content).map_err(ServiceError::Io)?;
348
349        Ok(())
350    }
351
352    /// Add a new repository
353    pub fn add_repository(
354        &mut self,
355        name: String,
356        definition: RepositoryDefinition,
357    ) -> Result<(), ServiceError> {
358        if self.repositories.contains_key(&name) {
359            return Err(ServiceError::Custom(format!(
360                "Repository '{}' already exists",
361                name
362            )));
363        }
364
365        self.repositories.insert(name, definition);
366        Ok(())
367    }
368
369    /// Remove a repository
370    pub fn remove_repository(&mut self, name: &str) -> Result<(), ServiceError> {
371        if self.repositories.remove(name).is_none() {
372            return Err(ServiceError::Custom(format!(
373                "Repository '{}' not found",
374                name
375            )));
376        }
377        // Also remove client if it exists
378        if let Ok(mut clients) = self.clients.try_write() {
379            clients.remove(name);
380        }
381        Ok(())
382    }
383
384    /// Get a repository by name
385    pub fn get_repository(&self, name: &str) -> Option<&RepositoryDefinition> {
386        self.repositories.get(name)
387    }
388
389    /// List all repositories (sorted by priority)
390    pub fn list_repositories(&self) -> Vec<&RepositoryDefinition> {
391        let mut repos: Vec<&RepositoryDefinition> = self.repositories.values().collect();
392        repos.sort_by_key(|r| r.priority);
393        repos
394    }
395
396    /// Get or create a repository client
397    pub async fn get_client(
398        &self,
399        name: &str,
400    ) -> Result<Arc<dyn RepositoryClient + Send + Sync>, ServiceError> {
401        // Check cache first
402        {
403            let clients = self.clients.read().await;
404            if let Some(client) = clients.get(name) {
405                return Ok(Arc::clone(client));
406            }
407        }
408
409        // Create new client
410        let repo = self
411            .repositories
412            .get(name)
413            .ok_or_else(|| ServiceError::Custom(format!("Repository '{}' not found", name)))?;
414
415        let client_arc = client::create_client(repo).await?;
416
417        // Cache it
418        let mut clients = self.clients.write().await;
419        clients.insert(name.to_string(), client_arc.clone());
420        Ok(client_arc)
421    }
422
423    /// Get default repository (first by priority, or one named "default")
424    pub fn get_default_repository(&self) -> Option<&RepositoryDefinition> {
425        // First try to find one named "default"
426        if let Some(repo) = self.repositories.get("default") {
427            return Some(repo);
428        }
429
430        // Otherwise return first by priority
431        let mut repos: Vec<&RepositoryDefinition> = self.repositories.values().collect();
432        repos.sort_by_key(|r| r.priority);
433        repos.first().copied()
434    }
435}