1use std::path::PathBuf;
45use std::time::Duration;
46
47use reqwest::Client;
48use serde::{Deserialize, Serialize};
49use thiserror::Error;
50
51pub const DEFAULT_REGISTRY_URL: &str = "https://registry.supernovae.studio/api/v1";
53
54pub const REGISTRY_URL_ENV: &str = "NIKA_REGISTRY_URL";
56
57pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
59
60#[derive(Error, Debug)]
62pub enum RegistryApiError {
63 #[error("Network error: {0}")]
64 NetworkError(#[from] reqwest::Error),
65
66 #[error("Package not found: {0}")]
67 PackageNotFound(String),
68
69 #[error("Version not found: {0}@{1}")]
70 VersionNotFound(String, String),
71
72 #[error("API error: {status} - {message}")]
73 ApiError { status: u16, message: String },
74
75 #[error("Invalid response: {0}")]
76 InvalidResponse(String),
77
78 #[error("IO error: {0}")]
79 IoError(#[from] std::io::Error),
80
81 #[error("Rate limited: retry after {retry_after} seconds")]
82 RateLimited { retry_after: u64 },
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PackageInfo {
88 pub name: String,
90
91 pub latest_version: String,
93
94 #[serde(default)]
96 pub description: Option<String>,
97
98 #[serde(default)]
100 pub authors: Option<Vec<String>>,
101
102 #[serde(default)]
104 pub license: Option<String>,
105
106 #[serde(default)]
108 pub repository: Option<String>,
109
110 #[serde(default)]
112 pub keywords: Option<Vec<String>>,
113
114 #[serde(default)]
116 pub downloads: Option<u64>,
117
118 #[serde(default)]
120 pub versions: Vec<String>,
121
122 #[serde(default)]
124 pub created_at: Option<String>,
125
126 #[serde(default)]
128 pub updated_at: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct VersionInfo {
134 pub name: String,
136
137 pub version: String,
139
140 #[serde(default)]
142 pub description: Option<String>,
143
144 #[serde(default)]
146 pub dependencies: Option<std::collections::HashMap<String, String>>,
147
148 #[serde(default)]
150 pub skills: Option<Vec<SkillInfo>>,
151
152 #[serde(default)]
154 pub size: Option<u64>,
155
156 #[serde(default)]
158 pub checksum: Option<String>,
159
160 #[serde(default)]
162 pub published_at: Option<String>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct SkillInfo {
168 pub name: String,
170
171 pub path: String,
173
174 #[serde(default)]
176 pub description: Option<String>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct SearchResult {
182 pub name: String,
184
185 pub version: String,
187
188 #[serde(default)]
190 pub description: Option<String>,
191
192 #[serde(default)]
194 pub keywords: Option<Vec<String>>,
195
196 #[serde(default)]
198 pub downloads: Option<u64>,
199
200 #[serde(default)]
202 pub score: Option<f64>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct SearchResponse {
208 pub total: usize,
210
211 pub page: usize,
213
214 pub per_page: usize,
216
217 pub results: Vec<SearchResult>,
219}
220
221#[derive(Debug, Clone)]
225pub struct RegistryClient {
226 client: Client,
227 base_url: String,
228}
229
230impl RegistryClient {
231 pub fn new() -> Result<Self, RegistryApiError> {
237 let base_url =
238 std::env::var(REGISTRY_URL_ENV).unwrap_or_else(|_| DEFAULT_REGISTRY_URL.to_string());
239
240 let client = Client::builder()
241 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
242 .user_agent(format!("nika/{}", env!("CARGO_PKG_VERSION")))
243 .build()?;
244
245 Ok(Self { client, base_url })
246 }
247
248 pub fn with_url(base_url: impl Into<String>) -> Result<Self, RegistryApiError> {
252 let client = Client::builder()
253 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
254 .user_agent(format!("nika/{}", env!("CARGO_PKG_VERSION")))
255 .build()?;
256
257 Ok(Self {
258 client,
259 base_url: base_url.into(),
260 })
261 }
262
263 pub fn with_timeout(timeout_secs: u64) -> Result<Self, RegistryApiError> {
267 let base_url =
268 std::env::var(REGISTRY_URL_ENV).unwrap_or_else(|_| DEFAULT_REGISTRY_URL.to_string());
269
270 let client = Client::builder()
271 .timeout(Duration::from_secs(timeout_secs))
272 .user_agent(format!("nika/{}", env!("CARGO_PKG_VERSION")))
273 .build()?;
274
275 Ok(Self { client, base_url })
276 }
277
278 pub async fn get_package(&self, name: &str) -> Result<PackageInfo, RegistryApiError> {
292 let url = format!("{}/packages/{}", self.base_url, encode_package_name(name));
293
294 let response = self.client.get(&url).send().await?;
295
296 match response.status().as_u16() {
297 200 => response
298 .json::<PackageInfo>()
299 .await
300 .map_err(|e| RegistryApiError::InvalidResponse(e.to_string())),
301 404 => Err(RegistryApiError::PackageNotFound(name.to_string())),
302 429 => {
303 let retry_after = response
304 .headers()
305 .get("retry-after")
306 .and_then(|v| v.to_str().ok())
307 .and_then(|v| v.parse().ok())
308 .unwrap_or(60);
309 Err(RegistryApiError::RateLimited { retry_after })
310 }
311 status => {
312 let message = response.text().await.unwrap_or_default();
313 Err(RegistryApiError::ApiError { status, message })
314 }
315 }
316 }
317
318 pub async fn get_version(
325 &self,
326 name: &str,
327 version: &str,
328 ) -> Result<VersionInfo, RegistryApiError> {
329 let url = format!(
330 "{}/packages/{}/{}",
331 self.base_url,
332 encode_package_name(name),
333 version
334 );
335
336 let response = self.client.get(&url).send().await?;
337
338 match response.status().as_u16() {
339 200 => response
340 .json::<VersionInfo>()
341 .await
342 .map_err(|e| RegistryApiError::InvalidResponse(e.to_string())),
343 404 => Err(RegistryApiError::VersionNotFound(
344 name.to_string(),
345 version.to_string(),
346 )),
347 429 => {
348 let retry_after = response
349 .headers()
350 .get("retry-after")
351 .and_then(|v| v.to_str().ok())
352 .and_then(|v| v.parse().ok())
353 .unwrap_or(60);
354 Err(RegistryApiError::RateLimited { retry_after })
355 }
356 status => {
357 let message = response.text().await.unwrap_or_default();
358 Err(RegistryApiError::ApiError { status, message })
359 }
360 }
361 }
362
363 pub async fn get_versions(&self, name: &str) -> Result<Vec<String>, RegistryApiError> {
373 let url = format!(
374 "{}/packages/{}/versions",
375 self.base_url,
376 encode_package_name(name)
377 );
378
379 let response = self.client.get(&url).send().await?;
380
381 match response.status().as_u16() {
382 200 => {
383 #[derive(Deserialize)]
384 struct VersionsResponse {
385 versions: Vec<String>,
386 }
387 let resp: VersionsResponse = response
388 .json()
389 .await
390 .map_err(|e| RegistryApiError::InvalidResponse(e.to_string()))?;
391 Ok(resp.versions)
392 }
393 404 => Err(RegistryApiError::PackageNotFound(name.to_string())),
394 429 => {
395 let retry_after = 60;
396 Err(RegistryApiError::RateLimited { retry_after })
397 }
398 status => {
399 let message = response.text().await.unwrap_or_default();
400 Err(RegistryApiError::ApiError { status, message })
401 }
402 }
403 }
404
405 pub async fn search(
423 &self,
424 query: &str,
425 page: usize,
426 per_page: usize,
427 ) -> Result<SearchResponse, RegistryApiError> {
428 let url = format!(
429 "{}/search?q={}&page={}&per_page={}",
430 self.base_url,
431 urlencoding::encode(query),
432 page,
433 per_page.min(100)
434 );
435
436 let response = self.client.get(&url).send().await?;
437
438 match response.status().as_u16() {
439 200 => response
440 .json::<SearchResponse>()
441 .await
442 .map_err(|e| RegistryApiError::InvalidResponse(e.to_string())),
443 429 => {
444 let retry_after = 60;
445 Err(RegistryApiError::RateLimited { retry_after })
446 }
447 status => {
448 let message = response.text().await.unwrap_or_default();
449 Err(RegistryApiError::ApiError { status, message })
450 }
451 }
452 }
453
454 pub async fn download(&self, name: &str, version: &str) -> Result<Vec<u8>, RegistryApiError> {
465 let url = format!(
466 "{}/packages/{}/{}/download",
467 self.base_url,
468 encode_package_name(name),
469 version
470 );
471
472 let response = self.client.get(&url).send().await?;
473
474 match response.status().as_u16() {
475 200 => response
476 .bytes()
477 .await
478 .map(|b| b.to_vec())
479 .map_err(RegistryApiError::from),
480 404 => Err(RegistryApiError::VersionNotFound(
481 name.to_string(),
482 version.to_string(),
483 )),
484 429 => {
485 let retry_after = 60;
486 Err(RegistryApiError::RateLimited { retry_after })
487 }
488 status => {
489 let message = response.text().await.unwrap_or_default();
490 Err(RegistryApiError::ApiError { status, message })
491 }
492 }
493 }
494
495 pub async fn download_and_extract(
507 &self,
508 name: &str,
509 version: &str,
510 target_dir: &PathBuf,
511 ) -> Result<PathBuf, RegistryApiError> {
512 use flate2::read::GzDecoder;
513 use tar::Archive;
514
515 let bytes = self.download(name, version).await?;
516
517 std::fs::create_dir_all(target_dir)?;
519
520 let gz = GzDecoder::new(bytes.as_slice());
522 let mut archive = Archive::new(gz);
523 archive.unpack(target_dir)?;
524
525 Ok(target_dir.clone())
526 }
527
528 pub async fn package_exists(&self, name: &str) -> Result<bool, RegistryApiError> {
530 match self.get_package(name).await {
531 Ok(_) => Ok(true),
532 Err(RegistryApiError::PackageNotFound(_)) => Ok(false),
533 Err(e) => Err(e),
534 }
535 }
536
537 pub async fn version_exists(
539 &self,
540 name: &str,
541 version: &str,
542 ) -> Result<bool, RegistryApiError> {
543 match self.get_version(name, version).await {
544 Ok(_) => Ok(true),
545 Err(RegistryApiError::VersionNotFound(_, _)) => Ok(false),
546 Err(e) => Err(e),
547 }
548 }
549
550 pub fn base_url(&self) -> &str {
552 &self.base_url
553 }
554}
555
556fn encode_package_name(name: &str) -> String {
560 name.replace('/', "%2F")
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[test]
569 fn test_encode_package_name() {
570 assert_eq!(encode_package_name("@nika/core"), "@nika%2Fcore");
571 assert_eq!(encode_package_name("simple-pkg"), "simple-pkg");
572 assert_eq!(
573 encode_package_name("@workflows/seo-audit"),
574 "@workflows%2Fseo-audit"
575 );
576 }
577
578 #[test]
579 fn test_registry_client_default() {
580 let client = RegistryClient::new().unwrap();
581 assert!(client.base_url.contains("registry") || client.base_url.contains("supernovae"));
583 }
584
585 #[test]
586 fn test_registry_client_with_url() {
587 let client = RegistryClient::with_url("https://custom.registry.local/api").unwrap();
588 assert_eq!(client.base_url, "https://custom.registry.local/api");
589 }
590
591 #[test]
592 fn test_package_info_deserialize() {
593 let json = r#"{
594 "name": "@nika/core",
595 "latest_version": "1.0.0",
596 "description": "Core skills",
597 "versions": ["1.0.0", "0.9.0"]
598 }"#;
599
600 let info: PackageInfo = serde_json::from_str(json).unwrap();
601 assert_eq!(info.name, "@nika/core");
602 assert_eq!(info.latest_version, "1.0.0");
603 assert_eq!(info.versions.len(), 2);
604 }
605
606 #[test]
607 fn test_version_info_deserialize() {
608 let json = r#"{
609 "name": "@nika/core",
610 "version": "1.0.0",
611 "description": "Core skills package",
612 "skills": [
613 {"name": "brainstorm", "path": "skills/brainstorm.md"}
614 ]
615 }"#;
616
617 let info: VersionInfo = serde_json::from_str(json).unwrap();
618 assert_eq!(info.name, "@nika/core");
619 assert_eq!(info.version, "1.0.0");
620 assert!(info.skills.is_some());
621 assert_eq!(info.skills.as_ref().unwrap().len(), 1);
622 }
623
624 #[test]
625 fn test_search_response_deserialize() {
626 let json = r#"{
627 "total": 42,
628 "page": 1,
629 "per_page": 20,
630 "results": [
631 {
632 "name": "@nika/core",
633 "version": "1.0.0",
634 "description": "Core package",
635 "score": 0.95
636 }
637 ]
638 }"#;
639
640 let response: SearchResponse = serde_json::from_str(json).unwrap();
641 assert_eq!(response.total, 42);
642 assert_eq!(response.results.len(), 1);
643 assert_eq!(response.results[0].name, "@nika/core");
644 }
645
646 #[test]
647 fn test_skill_info_deserialize() {
648 let json = r#"{
649 "name": "brainstorm",
650 "path": "skills/brainstorm.skill.md",
651 "description": "Collaborative ideation"
652 }"#;
653
654 let skill: SkillInfo = serde_json::from_str(json).unwrap();
655 assert_eq!(skill.name, "brainstorm");
656 assert_eq!(skill.path, "skills/brainstorm.skill.md");
657 }
658
659 #[test]
660 fn test_registry_api_error_display() {
661 let err = RegistryApiError::PackageNotFound("@test/pkg".to_string());
662 assert_eq!(err.to_string(), "Package not found: @test/pkg");
663
664 let err = RegistryApiError::VersionNotFound("@test/pkg".to_string(), "1.0.0".to_string());
665 assert_eq!(err.to_string(), "Version not found: @test/pkg@1.0.0");
666
667 let err = RegistryApiError::RateLimited { retry_after: 60 };
668 assert_eq!(err.to_string(), "Rate limited: retry after 60 seconds");
669 }
670
671 #[test]
672 fn test_package_info_optional_fields() {
673 let json = r#"{
674 "name": "@minimal/pkg",
675 "latest_version": "0.1.0",
676 "versions": []
677 }"#;
678
679 let info: PackageInfo = serde_json::from_str(json).unwrap();
680 assert!(info.description.is_none());
681 assert!(info.authors.is_none());
682 assert!(info.license.is_none());
683 assert!(info.downloads.is_none());
684 }
685}