1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use tokio::sync::RwLock;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SourcesConfig {
13 #[serde(default)]
14 pub sources: Vec<SourceDefinition>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SourceDefinition {
20 pub name: String,
21 #[serde(default = "default_priority")]
22 pub priority: u32,
23 pub source: SourceConfig,
24}
25
26impl SourceDefinition {
27 pub fn supports_listing(&self) -> bool {
31 matches!(
32 &self.source,
33 SourceConfig::Git { .. } | SourceConfig::ZipUrl { .. } | SourceConfig::Local { .. }
34 )
35 }
36}
37
38fn default_priority() -> u32 {
40 0
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(tag = "type")]
46pub enum SourceAuth {
47 #[serde(rename = "pat")]
48 Pat { env_var: String },
49 #[serde(rename = "ssh-key")]
50 SshKey { path: PathBuf },
51 #[serde(rename = "basic")]
52 Basic {
53 username: String,
54 password_env: String,
55 },
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(tag = "type")]
61pub enum SourceConfig {
62 #[serde(rename = "git")]
63 Git {
64 url: String,
65 #[serde(default)]
66 branch: Option<String>,
67 #[serde(default)]
68 tag: Option<String>,
69 #[serde(default)]
70 auth: Option<SourceAuth>,
71 },
72 #[serde(rename = "zip-url")]
73 ZipUrl {
74 base_url: String,
75 #[serde(default)]
76 auth: Option<SourceAuth>,
77 },
78 #[serde(rename = "local")]
79 Local { path: PathBuf },
80}
81
82#[derive(Debug, Clone)]
84pub struct SkillInfo {
85 pub id: String,
86 pub name: String,
87 pub description: String,
88 pub version: Option<String>,
89 pub source_name: String,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct MarketplaceJson {
95 pub version: String,
96 pub skills: Vec<MarketplaceSkill>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct MarketplaceSkill {
102 pub id: String,
103 pub name: String,
104 pub description: String,
105 pub version: String,
106 #[serde(default)]
107 pub author: Option<String>,
108 #[serde(default)]
109 pub download_url: Option<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ClaudeCodeMarketplaceJson {
116 pub name: String,
117 #[serde(default)]
118 pub owner: Option<ClaudeCodeOwner>,
119 #[serde(default)]
120 pub metadata: Option<ClaudeCodeMetadata>,
121 pub plugins: Vec<ClaudeCodePlugin>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ClaudeCodeOwner {
127 pub name: String,
128 #[serde(default)]
129 pub email: Option<String>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ClaudeCodeMetadata {
135 #[serde(default)]
136 pub description: Option<String>,
137 #[serde(default)]
138 pub version: Option<String>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ClaudeCodePlugin {
144 pub name: String,
145 #[serde(default)]
146 pub description: Option<String>,
147 #[serde(default)]
148 pub source: Option<String>,
149 #[serde(default)]
150 pub strict: Option<bool>,
151 pub skills: Vec<String>, }
153
154#[derive(Debug, Clone)]
158struct CachedMarketplace {
159 data: MarketplaceJson,
160 fetched_at: DateTime<Utc>,
161 ttl_seconds: u64,
162}
163
164impl CachedMarketplace {
165 fn is_expired(&self) -> bool {
166 let now = Utc::now();
167 let elapsed = (now - self.fetched_at).num_seconds() as u64;
168 elapsed > self.ttl_seconds
169 }
170}
171
172pub struct SourcesManager {
174 pub(crate) config_path: PathBuf,
175 sources: HashMap<String, SourceDefinition>,
176 marketplace_cache: Arc<RwLock<HashMap<String, CachedMarketplace>>>,
177 cache_ttl_seconds: u64,
178}
179
180impl SourcesManager {
181 pub fn new(config_path: PathBuf) -> Self {
183 Self {
184 config_path,
185 sources: HashMap::new(),
186 marketplace_cache: Arc::new(RwLock::new(HashMap::new())),
187 cache_ttl_seconds: 300, }
189 }
190
191 pub fn with_cache_ttl(config_path: PathBuf, cache_ttl_seconds: u64) -> Self {
193 Self {
194 config_path,
195 sources: HashMap::new(),
196 marketplace_cache: Arc::new(RwLock::new(HashMap::new())),
197 cache_ttl_seconds,
198 }
199 }
200
201 pub fn load(&mut self) -> Result<(), SourcesError> {
203 if !self.config_path.exists() {
204 let config = SourcesConfig {
206 sources: Vec::new(),
207 };
208 config.save_to_file(&self.config_path)?;
209 self.sources = HashMap::new();
210 return Ok(());
211 }
212
213 let config = SourcesConfig::load_from_file(&self.config_path)?;
214 let mut sorted_sources: Vec<SourceDefinition> = config.sources;
216 sorted_sources.sort_by_key(|s| s.priority);
217
218 self.sources = sorted_sources
219 .into_iter()
220 .map(|source| (source.name.clone(), source))
221 .collect();
222
223 Ok(())
224 }
225
226 pub fn save(&self) -> Result<(), SourcesError> {
228 let mut sources: Vec<SourceDefinition> = self.sources.values().cloned().collect();
229 sources.sort_by_key(|s| s.priority);
231 let config = SourcesConfig { sources };
232 config.save_to_file(&self.config_path)?;
233 Ok(())
234 }
235
236 pub fn add_source(&mut self, name: String, config: SourceConfig) -> Result<(), SourcesError> {
238 self.add_source_with_priority(name, config, 0)
239 }
240
241 pub fn add_source_with_priority(
243 &mut self,
244 name: String,
245 config: SourceConfig,
246 priority: u32,
247 ) -> Result<(), SourcesError> {
248 if self.sources.contains_key(&name) {
249 return Err(SourcesError::AlreadyExists(name));
250 }
251
252 let definition = SourceDefinition {
253 name: name.clone(),
254 priority,
255 source: config,
256 };
257
258 self.sources.insert(name, definition);
259 Ok(())
260 }
261
262 pub fn remove_source(&mut self, name: &str) -> Result<(), SourcesError> {
264 if self.sources.remove(name).is_none() {
265 return Err(SourcesError::SourceNotFound(name.to_string()));
266 }
267 Ok(())
268 }
269
270 pub fn get_source(&self, name: &str) -> Option<&SourceDefinition> {
272 self.sources.get(name)
273 }
274
275 pub fn list_sources(&self) -> Vec<&SourceDefinition> {
277 let mut sources: Vec<&SourceDefinition> = self.sources.values().collect();
278 sources.sort_by_key(|s| s.priority);
279 sources
280 }
281
282 pub async fn clear_cache(&self) {
284 let mut cache = self.marketplace_cache.write().await;
285 cache.clear();
286 }
287
288 pub async fn get_available_skills(&self) -> Result<Vec<SkillInfo>, SourcesError> {
290 let mut all_skills = Vec::new();
291
292 let mut sources: Vec<(&String, &SourceDefinition)> = self.sources.iter().collect();
294 sources.sort_by_key(|(_, def)| def.priority);
295
296 for (source_name, source_def) in sources {
297 let skills = self.get_skills_from_source(source_name, source_def).await?;
298 all_skills.extend(skills);
299 }
300
301 Ok(all_skills)
302 }
303
304 pub async fn get_skills_from_source(
306 &self,
307 source_name: &str,
308 source_def: &SourceDefinition,
309 ) -> Result<Vec<SkillInfo>, SourcesError> {
310 match &source_def.source {
311 SourceConfig::Git { url, branch, .. } => {
312 self.load_marketplace_from_url_with_branch(url, branch.as_deref(), source_name)
315 .await
316 }
317 SourceConfig::ZipUrl { base_url, .. } => {
318 self.load_marketplace_from_url_with_branch(base_url, None, source_name)
320 .await
321 }
322 SourceConfig::Local { path } => {
323 self.scan_local_source(path, source_name).await
325 }
326 }
327 }
328
329 async fn convert_claude_to_fastskill_format(
332 &self,
333 claude_marketplace: ClaudeCodeMarketplaceJson,
334 base_url: String,
335 _source_name: &str,
336 ) -> Result<MarketplaceJson, SourcesError> {
337 let mut skills = Vec::new();
338 let owner_name = claude_marketplace.owner.as_ref().map(|o| o.name.clone());
339 let metadata_version = claude_marketplace
340 .metadata
341 .as_ref()
342 .and_then(|m| m.version.clone());
343
344 for plugin in claude_marketplace.plugins {
345 let plugin_source = plugin.source.as_deref().unwrap_or("./");
346
347 for skill_path in plugin.skills {
348 let resolved_path = if skill_path.starts_with("./") {
350 format!(
352 "{}{}",
353 plugin_source.trim_end_matches('/'),
354 &skill_path[1..]
355 )
356 } else if skill_path.starts_with('/') {
357 skill_path.trim_start_matches('/').to_string()
359 } else {
360 format!("{}/{}", plugin_source.trim_end_matches('/'), skill_path)
362 };
363
364 let skill_id = resolved_path
366 .trim_end_matches('/')
367 .split('/')
368 .next_back()
369 .unwrap_or(&resolved_path)
370 .to_string();
371
372 let description = plugin
374 .description
375 .clone()
376 .or_else(|| {
377 claude_marketplace
378 .metadata
379 .as_ref()
380 .and_then(|m| m.description.clone())
381 })
382 .unwrap_or_else(|| format!("Skill from {}", plugin.name));
383
384 let download_url = if base_url.contains("github.com")
386 && !base_url.contains("raw.githubusercontent.com")
387 {
388 let repo_path = base_url
389 .trim_start_matches("https://github.com/")
390 .trim_start_matches("http://github.com/")
391 .trim_end_matches(".git")
392 .trim_end_matches('/');
393 Some(format!(
394 "https://github.com/{}/tree/main/{}",
395 repo_path, resolved_path
396 ))
397 } else if !base_url.is_empty() {
398 let base = base_url.trim_end_matches('/');
399 Some(format!("{}/{}", base, resolved_path))
400 } else {
401 None
402 };
403
404 skills.push(MarketplaceSkill {
405 id: skill_id.clone(),
406 name: skill_id.clone(), description,
408 version: metadata_version
409 .clone()
410 .unwrap_or_else(|| "1.0.0".to_string()),
411 author: owner_name.clone(),
412 download_url,
413 });
414 }
415 }
416
417 Ok(MarketplaceJson {
418 version: "1.0".to_string(),
419 skills,
420 })
421 }
422
423 async fn try_fetch_marketplace(
427 &self,
428 url: &str,
429 base_repo_url: Option<&str>,
430 ) -> Result<MarketplaceJson, SourcesError> {
431 let client = reqwest::Client::new();
432 let response = client.get(url).send().await.map_err(|e| {
433 SourcesError::Network(format!("Failed to fetch marketplace.json: {}", e))
434 })?;
435
436 if !response.status().is_success() {
437 return Err(SourcesError::Network(format!(
438 "Failed to fetch marketplace.json: HTTP {}",
439 response.status()
440 )));
441 }
442
443 let claude_marketplace: ClaudeCodeMarketplaceJson = response.json().await.map_err(|e| {
445 SourcesError::Parse(format!(
446 "Failed to parse Claude Code marketplace.json: {}",
447 e
448 ))
449 })?;
450
451 let base_url = if let Some(repo_url) = base_repo_url {
454 repo_url.to_string()
455 } else if url.contains("raw.githubusercontent.com") {
456 let parts: Vec<&str> = url.split('/').collect();
460 if parts.len() >= 5 {
461 let owner = parts[3];
462 let repo = parts[4];
463 format!("https://github.com/{}/{}", owner, repo)
464 } else {
465 String::new()
466 }
467 } else {
468 if let Some(pos) = url.rfind('/') {
470 url[..pos].to_string()
471 } else {
472 url.to_string()
473 }
474 };
475
476 let marketplace = self
478 .convert_claude_to_fastskill_format(claude_marketplace, base_url, "")
479 .await?;
480
481 for skill in &marketplace.skills {
483 if skill.id.is_empty() || skill.name.is_empty() || skill.description.is_empty() {
484 return Err(SourcesError::Parse(
485 "Invalid marketplace.json: skills must have id, name, and description"
486 .to_string(),
487 ));
488 }
489 }
490
491 Ok(marketplace)
492 }
493
494 async fn load_marketplace_from_url_with_branch(
497 &self,
498 base_url: &str,
499 branch: Option<&str>,
500 source_name: &str,
501 ) -> Result<Vec<SkillInfo>, SourcesError> {
502 let branch_name = branch.unwrap_or("main");
506 let (claude_plugin_url, root_url) =
507 if base_url.contains("github.com") && !base_url.contains("raw.githubusercontent.com") {
508 let repo_path = base_url
510 .trim_start_matches("https://github.com/")
511 .trim_start_matches("http://github.com/")
512 .trim_end_matches(".git")
513 .trim_end_matches('/');
514 (
515 format!(
516 "https://raw.githubusercontent.com/{}/{}/.claude-plugin/marketplace.json",
517 repo_path, branch_name
518 ),
519 format!(
520 "https://raw.githubusercontent.com/{}/{}/marketplace.json",
521 repo_path, branch_name
522 ),
523 )
524 } else {
525 let base = if base_url.ends_with('/') {
526 base_url.to_string()
527 } else {
528 format!("{}/", base_url)
529 };
530 (
531 format!("{}.claude-plugin/marketplace.json", base),
532 format!("{}marketplace.json", base),
533 )
534 };
535
536 let cache_key = claude_plugin_url.clone();
538 {
539 let cache = self.marketplace_cache.read().await;
540 if let Some(cached) = cache.get(&cache_key) {
541 if !cached.is_expired() {
542 return Ok(cached
543 .data
544 .skills
545 .iter()
546 .map(|skill| SkillInfo {
547 id: skill.id.clone(),
548 name: skill.name.clone(),
549 description: skill.description.clone(),
550 version: Some(skill.version.clone()),
551 source_name: source_name.to_string(),
552 })
553 .collect());
554 }
555 }
556 if let Some(cached) = cache.get(&root_url) {
558 if !cached.is_expired() {
559 return Ok(cached
560 .data
561 .skills
562 .iter()
563 .map(|skill| SkillInfo {
564 id: skill.id.clone(),
565 name: skill.name.clone(),
566 description: skill.description.clone(),
567 version: Some(skill.version.clone()),
568 source_name: source_name.to_string(),
569 })
570 .collect());
571 }
572 }
573 }
574
575 let (marketplace, successful_url) = match self
577 .try_fetch_marketplace(&claude_plugin_url, Some(base_url))
578 .await
579 {
580 Ok(m) => {
581 tracing::debug!(
582 "Loaded marketplace.json from Claude Code standard location: {}",
583 claude_plugin_url
584 );
585 (m, claude_plugin_url.clone())
586 }
587 Err(e) => {
588 tracing::debug!(
590 "Claude Code location failed ({}), trying root location: {}",
591 e,
592 root_url
593 );
594 match self.try_fetch_marketplace(&root_url, Some(base_url)).await {
595 Ok(m) => {
596 tracing::debug!("Loaded marketplace.json from root location: {}", root_url);
597 (m, root_url.clone())
598 }
599 Err(e2) => {
600 return Err(SourcesError::Network(format!(
601 "Failed to fetch marketplace.json from both locations. Claude Code location (.claude-plugin/marketplace.json): {}. Root location (marketplace.json): {}",
602 e, e2
603 )));
604 }
605 }
606 }
607 };
608
609 {
610 let mut cache = self.marketplace_cache.write().await;
611 cache.insert(
612 successful_url.clone(),
613 CachedMarketplace {
614 data: marketplace.clone(),
615 fetched_at: Utc::now(),
616 ttl_seconds: self.cache_ttl_seconds,
617 },
618 );
619 }
620
621 Ok(marketplace
623 .skills
624 .iter()
625 .map(|skill| SkillInfo {
626 id: skill.id.clone(),
627 name: skill.name.clone(),
628 description: skill.description.clone(),
629 version: Some(skill.version.clone()),
630 source_name: source_name.to_string(),
631 })
632 .collect())
633 }
634
635 pub async fn get_marketplace_json(
638 &self,
639 source_name: &str,
640 ) -> Result<MarketplaceJson, SourcesError> {
641 let source_def = self
642 .sources
643 .get(source_name)
644 .ok_or_else(|| SourcesError::SourceNotFound(source_name.to_string()))?;
645
646 let (base_url, branch) = match &source_def.source {
647 SourceConfig::Git { url, branch, .. } => {
648 (url.as_str(), branch.as_deref().unwrap_or("main"))
649 }
650 SourceConfig::ZipUrl { base_url, .. } => (base_url.as_str(), ""),
651 SourceConfig::Local { .. } => {
652 return Err(SourcesError::Network(
653 "Local sources do not support marketplace.json".to_string(),
654 ));
655 }
656 };
657
658 let (claude_plugin_url, root_url) =
662 if base_url.contains("github.com") && !base_url.contains("raw.githubusercontent.com") {
663 let repo_path = base_url
665 .trim_start_matches("https://github.com/")
666 .trim_start_matches("http://github.com/")
667 .trim_end_matches(".git")
668 .trim_end_matches('/');
669 (
670 format!(
671 "https://raw.githubusercontent.com/{}/{}/.claude-plugin/marketplace.json",
672 repo_path, branch
673 ),
674 format!(
675 "https://raw.githubusercontent.com/{}/{}/marketplace.json",
676 repo_path, branch
677 ),
678 )
679 } else {
680 let base = if base_url.ends_with('/') {
681 base_url.to_string()
682 } else {
683 format!("{}/", base_url)
684 };
685 (
686 format!("{}.claude-plugin/marketplace.json", base),
687 format!("{}marketplace.json", base),
688 )
689 };
690
691 {
693 let cache = self.marketplace_cache.read().await;
694 if let Some(cached) = cache.get(&claude_plugin_url) {
695 if !cached.is_expired() {
696 return Ok(cached.data.clone());
697 }
698 }
699 if let Some(cached) = cache.get(&root_url) {
701 if !cached.is_expired() {
702 return Ok(cached.data.clone());
703 }
704 }
705 }
706
707 let (marketplace, successful_url) = match self
709 .try_fetch_marketplace(&claude_plugin_url, Some(base_url))
710 .await
711 {
712 Ok(m) => {
713 tracing::debug!(
714 "Loaded marketplace.json from Claude Code standard location: {}",
715 claude_plugin_url
716 );
717 (m, claude_plugin_url.clone())
718 }
719 Err(e) => {
720 tracing::debug!(
722 "Claude Code location failed ({}), trying root location: {}",
723 e,
724 root_url
725 );
726 match self.try_fetch_marketplace(&root_url, Some(base_url)).await {
727 Ok(m) => {
728 tracing::debug!("Loaded marketplace.json from root location: {}", root_url);
729 (m, root_url.clone())
730 }
731 Err(e2) => {
732 return Err(SourcesError::Network(format!(
733 "Failed to fetch marketplace.json from both locations. Claude Code location (.claude-plugin/marketplace.json): {}. Root location (marketplace.json): {}",
734 e, e2
735 )));
736 }
737 }
738 }
739 };
740
741 {
743 let mut cache = self.marketplace_cache.write().await;
744 cache.insert(
745 successful_url.clone(),
746 CachedMarketplace {
747 data: marketplace.clone(),
748 fetched_at: Utc::now(),
749 ttl_seconds: self.cache_ttl_seconds,
750 },
751 );
752 }
753
754 Ok(marketplace)
755 }
756
757 async fn scan_local_source(
759 &self,
760 path: &PathBuf,
761 source_name: &str,
762 ) -> Result<Vec<SkillInfo>, SourcesError> {
763 use walkdir::WalkDir;
764
765 let resolved_path = if path.is_absolute() {
766 path.clone()
767 } else {
768 std::env::current_dir()
770 .map_err(SourcesError::Io)?
771 .join(path)
772 };
773
774 if !resolved_path.exists() {
775 return Err(SourcesError::NotFound(resolved_path));
776 }
777
778 if !resolved_path.is_dir() {
779 return Err(SourcesError::Io(std::io::Error::new(
780 std::io::ErrorKind::NotADirectory,
781 format!("Path is not a directory: {}", resolved_path.display()),
782 )));
783 }
784
785 let mut skills = Vec::new();
786
787 for entry in WalkDir::new(&resolved_path)
789 .into_iter()
790 .filter_map(|e| e.ok())
791 {
792 let entry_path = entry.path();
793 if entry_path.is_file()
794 && entry_path.file_name() == Some(std::ffi::OsStr::new("SKILL.md"))
795 {
796 if let Some(skill_dir) = entry_path.parent() {
798 if let Ok(skill_info) =
800 self.extract_skill_info_from_path(skill_dir, source_name)
801 {
802 skills.push(skill_info);
803 }
804 }
805 }
806 }
807
808 Ok(skills)
809 }
810
811 fn extract_skill_info_from_path(
813 &self,
814 skill_path: &Path,
815 source_name: &str,
816 ) -> Result<SkillInfo, SourcesError> {
817 use std::fs;
818
819 let skill_file = skill_path.join("SKILL.md");
820 if !skill_file.exists() {
821 return Err(SourcesError::NotFound(skill_file));
822 }
823
824 let content = fs::read_to_string(&skill_file).map_err(SourcesError::Io)?;
826
827 let (id, name, description, version) =
829 self.parse_skill_frontmatter(&content, skill_path)?;
830
831 Ok(SkillInfo {
832 id,
833 name,
834 description,
835 version: Some(version),
836 source_name: source_name.to_string(),
837 })
838 }
839
840 fn parse_skill_frontmatter(
842 &self,
843 content: &str,
844 skill_path: &Path,
845 ) -> Result<(String, String, String, String), SourcesError> {
846 if !content.starts_with("---\n") {
848 let id = skill_path
850 .file_name()
851 .and_then(|n| n.to_str())
852 .unwrap_or("unknown")
853 .to_string();
854 return Ok((
855 id.clone(),
856 id.clone(),
857 "No description".to_string(),
858 "1.0.0".to_string(),
859 ));
860 }
861
862 let end_marker = content[4..]
864 .find("---\n")
865 .ok_or_else(|| SourcesError::Parse("Invalid frontmatter format".to_string()))?;
866
867 let frontmatter = &content[4..end_marker + 4];
868
869 let mut id = None;
871 let mut name = None;
872 let mut description = None;
873 let mut version = None;
874
875 for line in frontmatter.lines() {
876 let line = line.trim();
877 if line.starts_with("id:") {
878 id = line
879 .split(':')
880 .nth(1)
881 .map(|s| s.trim().trim_matches('"').to_string());
882 } else if line.starts_with("name:") {
883 name = line
884 .split(':')
885 .nth(1)
886 .map(|s| s.trim().trim_matches('"').to_string());
887 } else if line.starts_with("description:") {
888 description = line
889 .split(':')
890 .nth(1)
891 .map(|s| s.trim().trim_matches('"').to_string());
892 } else if line.starts_with("version:") {
893 version = line
894 .split(':')
895 .nth(1)
896 .map(|s| s.trim().trim_matches('"').to_string());
897 }
898 }
899
900 let skill_id = id.unwrap_or_else(|| {
902 skill_path
903 .file_name()
904 .and_then(|n| n.to_str())
905 .unwrap_or("unknown")
906 .to_string()
907 });
908
909 Ok((
910 skill_id.clone(),
911 name.unwrap_or(skill_id.clone()),
912 description.unwrap_or_else(|| "No description".to_string()),
913 version.unwrap_or_else(|| "1.0.0".to_string()),
914 ))
915 }
916}
917
918impl SourcesConfig {
919 pub fn load_from_file(path: &Path) -> Result<Self, SourcesError> {
921 if !path.exists() {
922 return Err(SourcesError::NotFound(path.to_path_buf()));
923 }
924
925 let content = std::fs::read_to_string(path).map_err(SourcesError::Io)?;
926
927 let config: SourcesConfig =
928 toml::from_str(&content).map_err(|e| SourcesError::Parse(e.to_string()))?;
929
930 Ok(config)
931 }
932
933 pub fn save_to_file(&self, path: &Path) -> Result<(), SourcesError> {
935 let content =
936 toml::to_string_pretty(self).map_err(|e| SourcesError::Serialize(e.to_string()))?;
937
938 std::fs::write(path, content).map_err(SourcesError::Io)?;
939
940 Ok(())
941 }
942}
943
944#[derive(Debug, thiserror::Error)]
946pub enum SourcesError {
947 #[error("Sources config file not found: {0}")]
948 NotFound(PathBuf),
949
950 #[error("IO error: {0}")]
951 Io(#[from] std::io::Error),
952
953 #[error("Parse error: {0}")]
954 Parse(String),
955
956 #[error("Serialize error: {0}")]
957 Serialize(String),
958
959 #[error("Source already exists: {0}")]
960 AlreadyExists(String),
961
962 #[error("Source not found: {0}")]
963 SourceNotFound(String),
964
965 #[error("Network error: {0}")]
966 Network(String),
967
968 #[error("Git error: {0}")]
969 Git(String),
970}
971
972#[cfg(test)]
973#[allow(clippy::unwrap_used)]
974mod tests {
975 use super::*;
976 use tempfile::NamedTempFile;
977
978 #[test]
979 fn test_sources_config_parsing() {
980 let toml_content = r#"
981 [[sources]]
982 name = "team-tools"
983 source = { type = "git", url = "https://github.com/org/team-plugins.git", branch = "main" }
984
985 [[sources]]
986 name = "official-skills"
987 source = { type = "zip-url", base_url = "https://skills.example.com/" }
988
989 [[sources]]
990 name = "local"
991 source = { type = "local", path = "./local-sources" }
992 "#;
993
994 let config: SourcesConfig = toml::from_str(toml_content).unwrap();
995
996 assert_eq!(config.sources.len(), 3);
997 assert_eq!(config.sources[0].name, "team-tools");
998 assert_eq!(config.sources[1].name, "official-skills");
999 assert_eq!(config.sources[2].name, "local");
1000 }
1001
1002 #[test]
1003 fn test_source_config_variants() {
1004 let git_config = SourceConfig::Git {
1006 url: "https://github.com/org/repo.git".to_string(),
1007 branch: Some("main".to_string()),
1008 tag: None,
1009 auth: None,
1010 };
1011
1012 let zip_config = SourceConfig::ZipUrl {
1014 base_url: "https://skills.example.com/".to_string(),
1015 auth: None,
1016 };
1017
1018 let local_config = SourceConfig::Local {
1020 path: PathBuf::from("./local-sources"),
1021 };
1022
1023 let git_toml = toml::to_string(&git_config).unwrap();
1025 assert!(git_toml.contains("type = \"git\""));
1026
1027 let zip_toml = toml::to_string(&zip_config).unwrap();
1028 assert!(zip_toml.contains("type = \"zip-url\""));
1029
1030 let local_toml = toml::to_string(&local_config).unwrap();
1031 assert!(local_toml.contains("type = \"local\""));
1032 }
1033
1034 #[tokio::test]
1035 async fn test_sources_manager() {
1036 let temp_file = NamedTempFile::new().unwrap();
1037 let config_path = temp_file.path().to_path_buf();
1038
1039 let mut manager = SourcesManager::new(config_path.clone());
1040
1041 manager.load().unwrap();
1043 assert_eq!(manager.list_sources().len(), 0);
1044
1045 manager
1047 .add_source(
1048 "team-tools".to_string(),
1049 SourceConfig::Git {
1050 url: "https://github.com/org/team-plugins.git".to_string(),
1051 branch: Some("main".to_string()),
1052 tag: None,
1053 auth: None,
1054 },
1055 )
1056 .unwrap();
1057
1058 manager
1059 .add_source(
1060 "official".to_string(),
1061 SourceConfig::ZipUrl {
1062 base_url: "https://skills.example.com/".to_string(),
1063 auth: None,
1064 },
1065 )
1066 .unwrap();
1067
1068 assert_eq!(manager.list_sources().len(), 2);
1069
1070 manager.save().unwrap();
1072
1073 let mut new_manager = SourcesManager::new(config_path);
1074 new_manager.load().unwrap();
1075 assert_eq!(new_manager.list_sources().len(), 2);
1076 }
1077}