1use crate::core::service::ServiceError;
4use crate::security::validate_path_component;
5use chrono::{DateTime, Utc};
6use semver;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use walkdir::WalkDir;
14
15pub struct ScopedSkillName;
17
18impl ScopedSkillName {
19 pub fn normalize(scoped_name: &str) -> String {
27 let trimmed = scoped_name.trim();
28
29 let without_at = trimmed.strip_prefix('@').unwrap_or(trimmed);
31
32 without_at.replace(':', "/")
34 }
35}
36
37pub fn create_registry_structure(base_path: &Path) -> Result<(), ServiceError> {
39 fs::create_dir_all(base_path).map_err(ServiceError::Io)?;
40 Ok(())
41}
42
43pub fn get_skill_index_path(registry_path: &Path, skill_id: &str) -> Result<PathBuf, ServiceError> {
47 let parts: Vec<&str> = skill_id.split('/').collect();
50
51 if parts.len() != 2 {
53 return Err(ServiceError::Validation(format!(
54 "Invalid skill_id format: must be 'org/package', got: {}",
55 skill_id
56 )));
57 }
58
59 let org = validate_path_component(parts[0])
61 .map_err(|e| ServiceError::Validation(format!("Invalid org: {}", e)))?;
62 let package = validate_path_component(parts[1])
63 .map_err(|e| ServiceError::Validation(format!("Invalid package: {}", e)))?;
64
65 Ok(registry_path.join(&org).join(&package))
66}
67
68pub fn update_skill_version(
71 skill_id: &str,
72 version: &str,
73 metadata: &VersionMetadata,
74 registry_path: &Path,
75) -> Result<(), ServiceError> {
76 let index_path = get_skill_index_path(registry_path, skill_id)?;
78
79 if let Some(parent) = index_path.parent() {
81 fs::create_dir_all(parent).map_err(ServiceError::Io)?;
82 }
83
84 let entry = VersionEntry {
86 scoped_name: None,
87 name: skill_id.to_string(),
88 vers: version.to_string(),
89 deps: metadata.deps.clone(),
90 cksum: metadata.cksum.clone(),
91 features: metadata.features.clone(),
92 yanked: metadata.yanked,
93 links: metadata.links.clone(),
94 download_url: metadata.download_url.clone(),
95 published_at: metadata.published_at.clone(),
96 metadata: metadata.metadata.clone(),
97 };
98
99 let line = serde_json::to_string(&entry)
101 .map_err(|e| ServiceError::Custom(format!("Failed to serialize index entry: {}", e)))?;
102
103 let mut file = fs::OpenOptions::new()
105 .create(true)
106 .append(true)
107 .open(&index_path)
108 .map_err(ServiceError::Io)?;
109
110 writeln!(file, "{}", line).map_err(ServiceError::Io)?;
111
112 Ok(())
113}
114
115pub fn read_skill_versions(
118 registry_path: &Path,
119 skill_id: &str,
120) -> Result<Vec<VersionEntry>, ServiceError> {
121 let index_path = get_skill_index_path(registry_path, skill_id)?;
122
123 if !index_path.exists() {
124 return Ok(Vec::new());
125 }
126
127 let safe_index_path = index_path.canonicalize().map_err(ServiceError::Io)?;
129
130 let canonical_registry = registry_path.canonicalize().map_err(ServiceError::Io)?;
132 if !safe_index_path.starts_with(&canonical_registry) {
133 return Err(ServiceError::Custom(
134 "Index path escapes registry directory".to_string(),
135 ));
136 }
137
138 let content = fs::read_to_string(&safe_index_path).map_err(ServiceError::Io)?;
139
140 let mut entries = Vec::new();
141
142 for line in content.lines() {
144 let line = line.trim();
145 if line.is_empty() {
146 continue;
147 }
148
149 match serde_json::from_str::<VersionEntry>(line) {
150 Ok(entry) => entries.push(entry),
151 Err(e) => {
152 use tracing::error;
155 error!(
156 "Index file corruption detected for skill {}: Failed to parse entry: {} (line: {})",
157 skill_id, e, line
158 );
159 }
161 }
162 }
163
164 Ok(entries)
165}
166
167pub fn get_version_metadata(
169 skill_id: &str,
170 zip_path: &Path,
171 download_url: &str,
172) -> Result<VersionMetadata, ServiceError> {
173 let cksum = calculate_file_checksum(zip_path)?;
175
176 Ok(VersionMetadata {
177 name: skill_id.to_string(),
178 vers: String::new(), deps: Vec::new(),
180 cksum,
181 features: HashMap::new(),
182 yanked: false,
183 links: None,
184 download_url: download_url.to_string(),
185 published_at: Utc::now().to_rfc3339(),
186 metadata: None, })
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct VersionMetadata {
193 pub name: String,
194 pub vers: String,
195 pub deps: Vec<Dependency>,
196 pub cksum: String,
197 pub features: HashMap<String, Vec<String>>,
198 pub yanked: bool,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub links: Option<String>,
201 pub download_url: String,
202 pub published_at: String,
203 #[serde(default)]
204 pub metadata: Option<IndexMetadata>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct IndexMetadata {
210 pub description: Option<String>,
211 pub author: Option<String>,
212 pub license: Option<String>,
213 pub repository: Option<String>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct VersionEntry {
219 pub name: String,
220 pub vers: String,
221 pub deps: Vec<Dependency>,
222 pub cksum: String,
223 pub features: HashMap<String, Vec<String>>,
224 pub yanked: bool,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub links: Option<String>,
227 pub download_url: String,
228 pub published_at: String,
229 #[serde(default)]
230 pub metadata: Option<IndexMetadata>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub scoped_name: Option<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct Dependency {
239 pub name: String,
240 pub req: String,
241 pub features: Vec<String>,
242 pub optional: bool,
243 pub default_features: bool,
244 pub target: Option<String>,
245 pub kind: Option<String>,
246}
247
248#[derive(Debug, Clone, Default)]
250pub struct ListSkillsOptions {
251 pub scope: Option<String>,
252 pub all_versions: bool,
253 pub include_pre_release: bool,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct SkillSummary {
259 pub id: String,
260 pub scope: String,
261 pub name: String,
262 pub description: String,
263 pub latest_version: String,
264 #[serde(
265 serialize_with = "serialize_datetime_option",
266 deserialize_with = "deserialize_datetime_option"
267 )]
268 pub published_at: Option<DateTime<Utc>>,
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub versions: Option<Vec<String>>,
271}
272
273fn serialize_datetime_option<S>(
275 dt: &Option<DateTime<Utc>>,
276 serializer: S,
277) -> Result<S::Ok, S::Error>
278where
279 S: serde::Serializer,
280{
281 match dt {
282 Some(dt) => serializer.serialize_str(&dt.to_rfc3339()),
283 None => serializer.serialize_none(),
284 }
285}
286
287fn deserialize_datetime_option<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
289where
290 D: serde::Deserializer<'de>,
291{
292 use serde::Deserialize;
293 let s: Option<String> = Option::deserialize(deserializer)?;
294 match s {
295 Some(s) => DateTime::parse_from_rfc3339(&s)
296 .map(|dt| Some(dt.with_timezone(&Utc)))
297 .map_err(serde::de::Error::custom),
298 None => Ok(None),
299 }
300}
301
302fn calculate_file_checksum(file_path: &Path) -> Result<String, ServiceError> {
304 let mut file = fs::File::open(file_path).map_err(ServiceError::Io)?;
305
306 let mut hasher = Sha256::new();
307 let mut buffer = [0; 8192];
308
309 loop {
310 let bytes_read = file.read(&mut buffer).map_err(ServiceError::Io)?;
311
312 if bytes_read == 0 {
313 break;
314 }
315
316 hasher.update(&buffer[..bytes_read]);
317 }
318
319 let hash = format!("sha256:{:x}", hasher.finalize());
320 Ok(hash)
321}
322
323pub fn migrate_index_format(
327 old_registry_path: &Path,
328 new_registry_path: &Path,
329) -> Result<usize, ServiceError> {
330 use walkdir::WalkDir;
331
332 let mut migrated_count = 0;
333
334 let index_dir = old_registry_path.join("index");
336 if !index_dir.exists() {
337 return Ok(0);
338 }
339
340 let mut skill_versions: std::collections::HashMap<String, Vec<(String, VersionEntry)>> =
342 std::collections::HashMap::new();
343
344 for entry in WalkDir::new(&index_dir) {
345 let entry = entry.map_err(|e| ServiceError::Io(e.into()))?;
346 let path = entry.path();
347
348 if path.is_file() {
349 if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
351 if let Some((skill_id, version)) = parse_old_index_filename(file_name) {
352 let content = fs::read_to_string(path).map_err(ServiceError::Io)?;
354
355 if let Ok(entry) = serde_json::from_str::<VersionEntry>(&content) {
356 skill_versions
357 .entry(skill_id)
358 .or_default()
359 .push((version, entry));
360 }
361 }
362 }
363 }
364 }
365
366 for (skill_id, versions) in skill_versions {
368 let new_index_path = get_skill_index_path(new_registry_path, &skill_id)?;
369
370 if let Some(parent) = new_index_path.parent() {
372 fs::create_dir_all(parent).map_err(ServiceError::Io)?;
373 }
374
375 let mut file = fs::File::create(&new_index_path).map_err(ServiceError::Io)?;
377
378 for (_, entry) in versions {
379 let line = serde_json::to_string(&entry)
380 .map_err(|e| ServiceError::Custom(format!("Failed to serialize entry: {}", e)))?;
381 writeln!(file, "{}", line).map_err(ServiceError::Io)?;
382 }
383
384 migrated_count += 1;
385 }
386
387 Ok(migrated_count)
388}
389
390fn parse_old_index_filename(filename: &str) -> Option<(String, String)> {
392 let without_ext = filename.strip_suffix(".json")?;
393 if let Some(last_dash) = without_ext.rfind('-') {
394 let skill_id = without_ext[..last_dash].to_string();
395 let version = without_ext[last_dash + 1..].to_string();
396 Some((skill_id, version))
397 } else {
398 None
399 }
400}
401
402fn extract_scope(skill_id: &str) -> Option<(String, String)> {
404 let parts: Vec<&str> = skill_id.split('/').collect();
405 if parts.len() == 2 {
406 Some((parts[0].to_string(), parts[1].to_string()))
407 } else {
408 None
409 }
410}
411
412fn is_pre_release(version: &str) -> bool {
414 semver::Version::parse(version)
415 .map(|v| !v.pre.is_empty())
416 .unwrap_or(false)
417}
418
419fn determine_latest_version(
422 entries: &[VersionEntry],
423 include_pre_release: bool,
424) -> Option<&VersionEntry> {
425 let mut valid_entries: Vec<&VersionEntry> = entries
426 .iter()
427 .filter(|e| !e.yanked)
428 .filter(|e| include_pre_release || !is_pre_release(&e.vers))
429 .collect();
430
431 if valid_entries.is_empty() {
432 return None;
433 }
434
435 valid_entries.sort_by(|a, b| {
437 let ver_a = semver::Version::parse(&a.vers).ok();
438 let ver_b = semver::Version::parse(&b.vers).ok();
439 match (ver_a, ver_b) {
440 (Some(va), Some(vb)) => vb.cmp(&va), (Some(_), None) => std::cmp::Ordering::Less,
442 (None, Some(_)) => std::cmp::Ordering::Greater,
443 (None, None) => b.vers.cmp(&a.vers), }
445 });
446
447 valid_entries.first().copied()
448}
449
450pub async fn scan_registry_index(
453 registry_path: &Path,
454 options: &ListSkillsOptions,
455) -> Result<Vec<SkillSummary>, ServiceError> {
456 use tracing::{error, warn};
457
458 if !registry_path.exists() {
459 return Ok(Vec::new());
460 }
461
462 let canonical_registry = registry_path.canonicalize().map_err(ServiceError::Io)?;
464
465 let mut skill_map: std::collections::HashMap<String, Vec<VersionEntry>> =
466 std::collections::HashMap::new();
467
468 for entry in WalkDir::new(&canonical_registry).min_depth(1) {
470 let entry = entry.map_err(|e| ServiceError::Io(e.into()))?;
471 let path = entry.path();
472
473 if path.is_dir()
475 || path
476 .file_name()
477 .and_then(|n| n.to_str())
478 .map(|n| n.starts_with('.'))
479 .unwrap_or(false)
480 {
481 continue;
482 }
483
484 let relative_path = path.strip_prefix(registry_path).map_err(|e| {
487 ServiceError::Io(std::io::Error::new(
488 std::io::ErrorKind::InvalidInput,
489 format!("Failed to strip prefix: {}", e),
490 ))
491 })?;
492
493 let parts: Vec<&str> = relative_path
494 .components()
495 .filter_map(|c| c.as_os_str().to_str())
496 .collect();
497
498 if parts.len() < 2 {
500 warn!("Skipping invalid path structure: {:?}", path);
501 continue;
502 }
503
504 let skill_id = format!("{}/{}", parts[0], parts[1]);
506
507 if extract_scope(&skill_id).is_none() {
509 warn!("Skipping invalid skill_id format: {}", skill_id);
510 continue;
511 }
512
513 if let Some(ref filter_scope) = options.scope {
515 if let Some((scope, _)) = extract_scope(&skill_id) {
516 if scope != *filter_scope {
517 continue;
518 }
519 } else {
520 continue;
521 }
522 }
523
524 match read_skill_versions(registry_path, &skill_id) {
526 Ok(entries) => {
527 if !entries.is_empty() {
528 skill_map.insert(skill_id, entries);
529 }
530 }
531 Err(e) => {
532 error!("Failed to read index file for skill {}: {}", skill_id, e);
533 }
535 }
536 }
537
538 let mut summaries = Vec::new();
539
540 for (skill_id, entries) in skill_map {
541 let (scope, name) = extract_scope(&skill_id).ok_or_else(|| {
542 ServiceError::Custom(format!("Invalid skill_id format: {}", skill_id))
543 })?;
544
545 if options.all_versions {
546 let mut seen_versions = std::collections::HashSet::new();
548 for entry in entries {
549 if entry.yanked {
551 continue;
552 }
553
554 if !options.include_pre_release && is_pre_release(&entry.vers) {
556 continue;
557 }
558
559 if seen_versions.contains(&entry.vers) {
561 warn!(
562 "Duplicate version {} for skill {}, skipping",
563 entry.vers, skill_id
564 );
565 continue;
566 }
567 seen_versions.insert(entry.vers.clone());
568
569 let published_at = entry.published_at.parse::<DateTime<Utc>>().ok();
571
572 let description = entry
574 .metadata
575 .as_ref()
576 .and_then(|m| m.description.clone())
577 .unwrap_or_default();
578
579 summaries.push(SkillSummary {
580 id: skill_id.clone(),
581 scope: scope.clone(),
582 name: name.clone(),
583 description,
584 latest_version: entry.vers.clone(),
585 published_at,
586 versions: None,
587 });
588 }
589 } else {
590 if let Some(latest_entry) =
592 determine_latest_version(&entries, options.include_pre_release)
593 {
594 let published_at = latest_entry.published_at.parse::<DateTime<Utc>>().ok();
596
597 let description = latest_entry
599 .metadata
600 .as_ref()
601 .and_then(|m| m.description.clone())
602 .unwrap_or_default();
603
604 let versions = if options.include_pre_release {
606 Some(
607 entries
608 .iter()
609 .filter(|e| !e.yanked)
610 .map(|e| e.vers.clone())
611 .collect(),
612 )
613 } else {
614 Some(
615 entries
616 .iter()
617 .filter(|e| !e.yanked && !is_pre_release(&e.vers))
618 .map(|e| e.vers.clone())
619 .collect(),
620 )
621 };
622
623 summaries.push(SkillSummary {
624 id: skill_id,
625 scope,
626 name,
627 description,
628 latest_version: latest_entry.vers.clone(),
629 published_at,
630 versions,
631 });
632 }
633 }
634 }
635
636 summaries.sort_by(|a, b| a.id.cmp(&b.id));
638
639 Ok(summaries)
640}
641
642#[cfg(test)]
643#[allow(clippy::unwrap_used)]
644mod tests {
645 use super::*;
646 use tempfile::TempDir;
647
648 #[test]
649 fn test_get_skill_index_path() {
650 let temp_dir = TempDir::new().unwrap();
651 let registry_path = temp_dir.path();
652
653 let path = get_skill_index_path(registry_path, "acme/web-scraper").unwrap();
655 assert_eq!(path, registry_path.join("acme").join("web-scraper"));
656
657 let path = get_skill_index_path(registry_path, "myorg/data-processor").unwrap();
659 assert_eq!(path, registry_path.join("myorg").join("data-processor"));
660
661 let path = get_skill_index_path(registry_path, "a/tool").unwrap();
663 assert_eq!(path, registry_path.join("a").join("tool"));
664
665 assert!(get_skill_index_path(registry_path, "invalid-format").is_err());
667 assert!(get_skill_index_path(registry_path, "single").is_err());
668 assert!(get_skill_index_path(registry_path, "a/b/c").is_err());
669 }
670
671 #[test]
672 fn test_update_and_read_skill_versions() {
673 let temp_dir = TempDir::new().unwrap();
674 let registry_path = temp_dir.path();
675
676 let skill_id = "acme/test-skill";
677 let version1 = "1.0.0";
678 let version2 = "1.0.1";
679
680 let metadata1 = VersionMetadata {
682 name: skill_id.to_string(),
683 vers: version1.to_string(),
684 deps: Vec::new(),
685 cksum: "sha256:test1".to_string(),
686 features: HashMap::new(),
687 yanked: false,
688 links: None,
689 download_url: "http://example.com/v1.zip".to_string(),
690 published_at: "2024-01-01T00:00:00Z".to_string(),
691 metadata: None,
692 };
693
694 let metadata2 = VersionMetadata {
695 name: skill_id.to_string(),
696 vers: version2.to_string(),
697 deps: Vec::new(),
698 cksum: "sha256:test2".to_string(),
699 features: HashMap::new(),
700 yanked: false,
701 links: None,
702 download_url: "http://example.com/v2.zip".to_string(),
703 published_at: "2024-01-02T00:00:00Z".to_string(),
704 metadata: None,
705 };
706
707 update_skill_version(skill_id, version1, &metadata1, registry_path).unwrap();
709
710 update_skill_version(skill_id, version2, &metadata2, registry_path).unwrap();
712
713 let entries = read_skill_versions(registry_path, skill_id).unwrap();
715 assert_eq!(entries.len(), 2);
716 assert_eq!(entries[0].vers, version1);
717 assert_eq!(entries[1].vers, version2);
718 }
719
720 #[test]
721 fn test_newline_delimited_format() {
722 let temp_dir = TempDir::new().unwrap();
723 let registry_path = temp_dir.path();
724
725 let skill_id = "acme/test";
726 let metadata = VersionMetadata {
727 name: skill_id.to_string(),
728 vers: "1.0.0".to_string(),
729 deps: Vec::new(),
730 cksum: "sha256:test".to_string(),
731 features: HashMap::new(),
732 yanked: false,
733 links: None,
734 download_url: "http://example.com/test.zip".to_string(),
735 published_at: "2024-01-01T00:00:00Z".to_string(),
736 metadata: None,
737 };
738
739 update_skill_version(skill_id, "1.0.0", &metadata, registry_path).unwrap();
740
741 let index_path = get_skill_index_path(registry_path, skill_id).unwrap();
743 let content = fs::read_to_string(&index_path).unwrap();
744
745 let lines: Vec<&str> = content.lines().collect();
747 assert_eq!(lines.len(), 1);
748
749 assert!(!content.contains("\n "));
751 assert!(!content.contains(" "));
752 }
753}