1use crate::api::types::*;
2use crate::config::Config;
3use crate::utils::error::{CarpError, CarpResult};
4use reqwest::{Client, ClientBuilder, Response};
5use std::time::Duration;
6use tokio::time::sleep;
7
8#[derive(Debug, Clone)]
10pub struct RetryConfig {
11 pub max_retries: u32,
12 pub initial_delay: Duration,
13 pub max_delay: Duration,
14 pub backoff_multiplier: f64,
15}
16
17impl Default for RetryConfig {
18 fn default() -> Self {
19 Self {
20 max_retries: 3,
21 initial_delay: Duration::from_millis(100),
22 max_delay: Duration::from_secs(5),
23 backoff_multiplier: 2.0,
24 }
25 }
26}
27
28pub struct ApiClient {
30 client: Client,
31 base_url: String,
32 api_key: Option<String>,
33 retry_config: RetryConfig,
34}
35
36impl ApiClient {
37 pub fn new(config: &Config) -> CarpResult<Self> {
39 Self::with_retry_config(config, RetryConfig::default())
40 }
41
42 pub fn with_retry_config(config: &Config, mut retry_config: RetryConfig) -> CarpResult<Self> {
44 retry_config.max_retries = config.retry.max_retries;
46 retry_config.initial_delay = Duration::from_millis(config.retry.initial_delay_ms);
47 retry_config.max_delay = Duration::from_millis(config.retry.max_delay_ms);
48 retry_config.backoff_multiplier = config.retry.backoff_multiplier;
49 let client = ClientBuilder::new()
50 .timeout(Duration::from_secs(config.timeout))
51 .user_agent(format!("carp-cli/{}", env!("CARGO_PKG_VERSION")))
52 .danger_accept_invalid_certs(!config.verify_ssl)
53 .connect_timeout(Duration::from_secs(10))
54 .tcp_keepalive(Duration::from_secs(60))
55 .pool_idle_timeout(Duration::from_secs(90))
56 .pool_max_idle_per_host(8)
57 .build()?;
58
59 if config.registry_url.is_empty() {
61 return Err(CarpError::Config(
62 "Registry URL cannot be empty".to_string(),
63 ));
64 }
65
66 let base_url = config.registry_url.trim_end_matches('/');
68
69 Ok(Self {
70 client,
71 base_url: base_url.to_string(),
72 api_key: config.api_key.clone(),
73 retry_config,
74 })
75 }
76
77 pub fn with_api_key(mut self, api_key: Option<String>) -> Self {
79 if let Some(ref key) = api_key {
81 if let Err(e) = crate::config::ConfigManager::validate_api_key(key) {
82 eprintln!("Warning: Invalid API key format: {e}");
83 }
84 }
85 self.api_key = api_key;
86 self
87 }
88
89 pub async fn search(
91 &self,
92 query: &str,
93 limit: Option<usize>,
94 exact: bool,
95 ) -> CarpResult<SearchResponse> {
96 let url = format!("{}/api/v1/agents/search", self.base_url);
97 let mut params = vec![];
98
99 if !query.trim().is_empty() {
101 params.push(("q", query.trim()));
102 }
103
104 let limit_str;
105 if let Some(limit) = limit {
106 if limit == 0 {
107 return Err(CarpError::InvalidAgent(
108 "Limit must be greater than 0".to_string(),
109 ));
110 }
111 limit_str = limit.to_string();
112 params.push(("limit", &limit_str));
113 }
114
115 if exact {
116 params.push(("exact", "true"));
117 }
118
119 self.make_request_with_retry(|| async {
120 let response = self.client.get(&url).query(¶ms).send().await?;
121 self.handle_response(response).await
122 })
123 .await
124 }
125
126 #[allow(dead_code)]
128 pub async fn get_agent_download(
129 &self,
130 name: &str,
131 version: Option<&str>,
132 ) -> CarpResult<AgentDownload> {
133 self.validate_agent_name(name)?;
135
136 let version = version.unwrap_or("latest");
137 if !version.is_empty() && version != "latest" {
138 self.validate_version(version)?;
139 }
140
141 let url = format!(
142 "{}/api/v1/agents/{}/{}/download",
143 self.base_url,
144 urlencoding::encode(name),
145 urlencoding::encode(version)
146 );
147
148 self.make_request_with_retry(|| async {
149 let response = self.client.get(&url).send().await?;
150 self.handle_response(response).await
151 })
152 .await
153 }
154
155 #[allow(dead_code)]
157 pub async fn download_agent(&self, download_url: &str) -> CarpResult<bytes::Bytes> {
158 if download_url.is_empty() {
160 return Err(CarpError::Network(
161 "Download URL cannot be empty".to_string(),
162 ));
163 }
164
165 let parsed_url = download_url
167 .parse::<reqwest::Url>()
168 .map_err(|_| CarpError::Network("Invalid download URL format".to_string()))?;
169
170 if parsed_url.scheme() != "https" && parsed_url.scheme() != "http" {
172 return Err(CarpError::Network(
173 "Download URLs must use HTTP or HTTPS".to_string(),
174 ));
175 }
176
177 if parsed_url.scheme() == "http" {
178 return Err(CarpError::Network(
179 "HTTP download URLs are not allowed for security reasons".to_string(),
180 ));
181 }
182
183 self.make_request_with_retry(|| async {
184 let response = self.client.get(download_url).send().await?;
185
186 if !response.status().is_success() {
187 return Err(CarpError::Api {
188 status: response.status().as_u16(),
189 message: format!("Failed to download agent: HTTP {}", response.status()),
190 });
191 }
192
193 if let Some(content_length) = response.content_length() {
197 const MAX_DOWNLOAD_SIZE: u64 = 100 * 1024 * 1024; if content_length > MAX_DOWNLOAD_SIZE {
199 return Err(CarpError::Network(format!(
200 "Download size ({content_length} bytes) exceeds maximum allowed size ({MAX_DOWNLOAD_SIZE} bytes)"
201 )));
202 }
203 }
204
205 let bytes = response.bytes().await?;
206 Ok(bytes)
207 }).await
208 }
209
210 pub async fn upload(&self, request: UploadAgentRequest) -> CarpResult<UploadAgentResponse> {
212 let api_key = self.api_key.as_ref().ok_or_else(|| {
213 CarpError::Auth("No API key configured. Please set your API key via command line, environment variable, or config file.".to_string())
214 })?;
215
216 self.validate_upload_request(&request)?;
218
219 let url = format!("{}/api/v1/agents/upload", self.base_url);
220
221 self.make_request_with_retry(|| async {
222 let response = self
223 .client
224 .post(&url)
225 .header("Authorization", format!("Bearer {api_key}"))
226 .header("Content-Type", "application/json")
227 .json(&request)
228 .send()
229 .await?;
230
231 self.handle_response(response).await
232 })
233 .await
234 }
235
236 #[allow(dead_code)]
238 pub async fn publish(
239 &self,
240 _request: PublishRequest,
241 _content: Vec<u8>,
242 ) -> CarpResult<PublishResponse> {
243 Err(CarpError::Api {
245 status: 503,
246 message: "Publishing is temporarily disabled pending security hardening. Please check back later.".to_string(),
247 })
248 }
249
250 #[allow(dead_code)]
252 async fn publish_internal(
253 &self,
254 request: PublishRequest,
255 content: Vec<u8>,
256 ) -> CarpResult<PublishResponse> {
257 let api_key = self.api_key.as_ref().ok_or_else(|| {
258 CarpError::Auth("No API key configured. Please set your API key via command line, environment variable, or config file.".to_string())
259 })?;
260
261 self.validate_publish_request(&request)?;
263
264 const MAX_PUBLISH_SIZE: usize = 50 * 1024 * 1024;
266 if content.len() > MAX_PUBLISH_SIZE {
267 return Err(CarpError::Api {
268 status: 413,
269 message: format!(
270 "Agent package size ({} bytes) exceeds maximum allowed size ({} bytes)",
271 content.len(),
272 MAX_PUBLISH_SIZE
273 ),
274 });
275 }
276
277 let url = format!("{}/api/v1/agents/publish", self.base_url);
278
279 let form = reqwest::multipart::Form::new()
281 .text("metadata", serde_json::to_string(&request)?)
282 .part(
283 "content",
284 reqwest::multipart::Part::bytes(content)
285 .file_name("agent.zip")
286 .mime_str("application/zip")?,
287 );
288
289 let response = self
292 .client
293 .post(&url)
294 .header("Authorization", format!("Bearer {api_key}"))
295 .multipart(form)
296 .send()
297 .await?;
298
299 self.handle_response(response).await
300 }
301
302 #[allow(dead_code)]
304 pub async fn authenticate(&self, username: &str, password: &str) -> CarpResult<AuthResponse> {
305 if username.trim().is_empty() {
307 return Err(CarpError::Auth("Username cannot be empty".to_string()));
308 }
309 if password.is_empty() {
310 return Err(CarpError::Auth("Password cannot be empty".to_string()));
311 }
312
313 let url = format!("{}/api/v1/auth/login", self.base_url);
314 let request = AuthRequest {
315 username: username.trim().to_string(),
316 password: password.to_string(),
317 };
318
319 let response = self.client.post(&url).json(&request).send().await?;
321 self.handle_response(response).await
322 }
323
324 pub async fn health_check(&self) -> CarpResult<HealthResponse> {
326 let url = format!("{}/api/health", self.base_url);
327
328 let mut attempts = 0;
330 let max_attempts = 2;
331
332 loop {
333 attempts += 1;
334 match self.client.get(&url).send().await {
335 Ok(response) => return self.handle_response(response).await,
336 Err(e) if attempts < max_attempts && self.is_retryable_error(&e) => {
337 sleep(Duration::from_millis(500)).await;
338 continue;
339 }
340 Err(e) => return Err(CarpError::from(e)),
341 }
342 }
343 }
344
345 async fn make_request_with_retry<T, F, Fut>(&self, request_fn: F) -> CarpResult<T>
347 where
348 F: Fn() -> Fut,
349 Fut: std::future::Future<Output = CarpResult<T>>,
350 {
351 let mut attempts = 0;
352 let mut delay = self.retry_config.initial_delay;
353
354 loop {
355 attempts += 1;
356
357 match request_fn().await {
358 Ok(result) => return Ok(result),
359 Err(e) if attempts <= self.retry_config.max_retries && self.should_retry(&e) => {
360 if attempts < self.retry_config.max_retries {
361 sleep(delay).await;
362 delay = std::cmp::min(
363 Duration::from_millis(
364 (delay.as_millis() as f64 * self.retry_config.backoff_multiplier)
365 as u64,
366 ),
367 self.retry_config.max_delay,
368 );
369 } else {
370 return Err(e);
371 }
372 }
373 Err(e) => return Err(e),
374 }
375 }
376 }
377
378 fn should_retry(&self, error: &CarpError) -> bool {
380 match error {
381 CarpError::Http(e) => self.is_retryable_error(e),
382 CarpError::Api { status, .. } => {
383 (500..600).contains(status) ||
385 *status == 429 || *status == 408 }
388 CarpError::Network(_) => true,
389 _ => false,
390 }
391 }
392
393 fn is_retryable_error(&self, error: &reqwest::Error) -> bool {
395 if error.is_timeout() || error.is_connect() {
396 return true;
397 }
398
399 if let Some(status) = error.status() {
400 let status_code = status.as_u16();
401 return (500..600).contains(&status_code) || status_code == 429 || status_code == 408;
402 }
403
404 false
405 }
406
407 fn validate_agent_name(&self, name: &str) -> CarpResult<()> {
409 if name.trim().is_empty() {
410 return Err(CarpError::InvalidAgent(
411 "Agent name cannot be empty".to_string(),
412 ));
413 }
414
415 if !name
417 .chars()
418 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
419 {
420 return Err(CarpError::InvalidAgent(
421 "Agent name can only contain alphanumeric characters, hyphens, and underscores"
422 .to_string(),
423 ));
424 }
425
426 if name.len() > 100 {
427 return Err(CarpError::InvalidAgent(
428 "Agent name cannot exceed 100 characters".to_string(),
429 ));
430 }
431
432 Ok(())
433 }
434
435 fn validate_version(&self, version: &str) -> CarpResult<()> {
437 if version.trim().is_empty() {
438 return Err(CarpError::InvalidAgent(
439 "Version cannot be empty".to_string(),
440 ));
441 }
442
443 if !version
445 .chars()
446 .all(|c| c.is_alphanumeric() || ".-_+".contains(c))
447 {
448 return Err(CarpError::InvalidAgent(
449 "Version can only contain alphanumeric characters, dots, hyphens, underscores, and plus signs".to_string()
450 ));
451 }
452
453 if version.len() > 50 {
454 return Err(CarpError::InvalidAgent(
455 "Version cannot exceed 50 characters".to_string(),
456 ));
457 }
458
459 Ok(())
460 }
461
462 fn validate_upload_request(&self, request: &UploadAgentRequest) -> CarpResult<()> {
464 self.validate_agent_name(&request.name)?;
466
467 if request.description.trim().is_empty() {
469 return Err(CarpError::InvalidAgent(
470 "Description cannot be empty".to_string(),
471 ));
472 }
473
474 if request.description.len() > 1000 {
475 return Err(CarpError::InvalidAgent(
476 "Description cannot exceed 1000 characters".to_string(),
477 ));
478 }
479
480 if request.content.trim().is_empty() {
482 return Err(CarpError::InvalidAgent(
483 "Content cannot be empty".to_string(),
484 ));
485 }
486
487 const MAX_CONTENT_SIZE: usize = 1024 * 1024;
489 if request.content.len() > MAX_CONTENT_SIZE {
490 return Err(CarpError::InvalidAgent(format!(
491 "Content size ({} bytes) exceeds maximum allowed size ({} bytes)",
492 request.content.len(),
493 MAX_CONTENT_SIZE
494 )));
495 }
496
497 self.validate_frontmatter_consistency(request)?;
499
500 if let Some(version) = &request.version {
502 self.validate_version(version)?;
503 }
504
505 for tag in &request.tags {
507 if tag.trim().is_empty() {
508 return Err(CarpError::InvalidAgent("Tags cannot be empty".to_string()));
509 }
510 if tag.len() > 50 {
511 return Err(CarpError::InvalidAgent(
512 "Tags cannot exceed 50 characters".to_string(),
513 ));
514 }
515 }
516
517 if request.tags.len() > 20 {
518 return Err(CarpError::InvalidAgent(
519 "Cannot have more than 20 tags".to_string(),
520 ));
521 }
522
523 Ok(())
524 }
525
526 fn validate_frontmatter_consistency(&self, request: &UploadAgentRequest) -> CarpResult<()> {
528 if !request.content.starts_with("---") {
530 return Err(CarpError::InvalidAgent(
531 "Content must contain YAML frontmatter starting with ---".to_string(),
532 ));
533 }
534
535 let lines: Vec<&str> = request.content.lines().collect();
537 let mut frontmatter_end = None;
538
539 for (i, line) in lines.iter().enumerate().skip(1) {
540 if line.trim() == "---" {
541 frontmatter_end = Some(i);
542 break;
543 }
544 }
545
546 let frontmatter_end = frontmatter_end.ok_or_else(|| {
547 CarpError::InvalidAgent("Invalid YAML frontmatter: missing closing ---".to_string())
548 })?;
549
550 let frontmatter_lines = &lines[1..frontmatter_end];
552 let frontmatter_content = frontmatter_lines.join("\n");
553
554 let frontmatter: serde_json::Value = serde_yaml::from_str(&frontmatter_content)
556 .map_err(|e| CarpError::InvalidAgent(format!("Invalid YAML frontmatter: {e}")))?;
557
558 if let Some(frontmatter_name) = frontmatter.get("name").and_then(|v| v.as_str()) {
560 if frontmatter_name != request.name {
561 return Err(CarpError::InvalidAgent(format!(
562 "Name mismatch: frontmatter contains '{}' but request contains '{}'",
563 frontmatter_name, request.name
564 )));
565 }
566 } else {
567 return Err(CarpError::InvalidAgent(
568 "YAML frontmatter must contain a 'name' field".to_string(),
569 ));
570 }
571
572 if let Some(frontmatter_desc) = frontmatter.get("description").and_then(|v| v.as_str()) {
574 if frontmatter_desc != request.description {
575 return Err(CarpError::InvalidAgent(format!(
576 "Description mismatch: frontmatter contains '{}' but request contains '{}'",
577 frontmatter_desc, request.description
578 )));
579 }
580 } else {
581 return Err(CarpError::InvalidAgent(
582 "YAML frontmatter must contain a 'description' field".to_string(),
583 ));
584 }
585
586 Ok(())
587 }
588
589 fn validate_publish_request(&self, request: &PublishRequest) -> CarpResult<()> {
591 self.validate_agent_name(&request.name)?;
592 self.validate_version(&request.version)?;
593
594 if request.description.trim().is_empty() {
595 return Err(CarpError::InvalidAgent(
596 "Description cannot be empty".to_string(),
597 ));
598 }
599
600 if request.description.len() > 1000 {
601 return Err(CarpError::InvalidAgent(
602 "Description cannot exceed 1000 characters".to_string(),
603 ));
604 }
605
606 for tag in &request.tags {
608 if tag.trim().is_empty() {
609 return Err(CarpError::InvalidAgent("Tags cannot be empty".to_string()));
610 }
611 if tag.len() > 50 {
612 return Err(CarpError::InvalidAgent(
613 "Tags cannot exceed 50 characters".to_string(),
614 ));
615 }
616 }
617
618 if request.tags.len() > 10 {
619 return Err(CarpError::InvalidAgent(
620 "Cannot have more than 10 tags".to_string(),
621 ));
622 }
623
624 Ok(())
625 }
626
627 async fn handle_response<T>(&self, response: Response) -> CarpResult<T>
629 where
630 T: serde::de::DeserializeOwned,
631 {
632 let status = response.status();
633 let text = response.text().await?;
634
635 if status.is_success() {
636 serde_json::from_str(&text).map_err(CarpError::Json)
637 } else {
638 if status.as_u16() == 401 {
640 let auth_error = if text.contains("invalid") || text.contains("expired") {
641 "Invalid or expired API key. Please check your API key and try again."
642 } else if text.contains("missing") || text.contains("required") {
643 "API key required. Please provide your API key via --api-key option, CARP_API_KEY environment variable, or config file."
644 } else {
645 "Authentication failed. Please verify your API key is correct."
646 };
647
648 return Err(CarpError::Auth(format!(
649 "{auth_error}\n\nTo fix this:\n 1. Get your API key from the registry dashboard\n 2. Set it via: carp auth set-api-key\n 3. Or use: --api-key <your-key>\n 4. Or set CARP_API_KEY environment variable"
650 )));
651 }
652
653 if status.as_u16() == 403 {
654 return Err(CarpError::Auth(
655 "Access forbidden. Your API key may not have sufficient permissions for this operation.".to_string()
656 ));
657 }
658
659 match serde_json::from_str::<ApiError>(&text) {
661 Ok(api_error) => {
662 let mut error_message = api_error.message;
663
664 if let Some(details) = api_error.details {
666 error_message.push_str(&format!(
667 "\n\nDetails: {}",
668 serde_json::to_string_pretty(&details).unwrap_or_default()
669 ));
670 }
671
672 Err(CarpError::Api {
673 status: status.as_u16(),
674 message: error_message,
675 })
676 }
677 Err(_) => {
678 let error_message = if text.is_empty() {
680 format!("HTTP {} error", status.as_u16())
681 } else {
682 format!("HTTP {} error: {}", status.as_u16(), text)
683 };
684
685 Err(CarpError::Api {
686 status: status.as_u16(),
687 message: error_message,
688 })
689 }
690 }
691 }
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use crate::config::Config;
699 use mockito::Server;
700
701 fn create_test_config(server_url: String, api_key: Option<String>) -> Config {
702 Config {
703 registry_url: server_url,
704 api_key,
705 api_token: None,
706 timeout: 30,
707 verify_ssl: true,
708 default_output_dir: None,
709 max_concurrent_downloads: 4,
710 retry: crate::config::RetrySettings::default(),
711 security: crate::config::SecuritySettings::default(),
712 }
713 }
714
715 fn create_valid_upload_request() -> UploadAgentRequest {
716 UploadAgentRequest {
717 name: "test-agent".to_string(),
718 description: "A test agent".to_string(),
719 content: r#"---
720name: test-agent
721description: A test agent
722---
723
724# Test Agent
725
726This is a test agent.
727"#
728 .to_string(),
729 version: Some("1.0.0".to_string()),
730 tags: vec!["test".to_string()],
731 homepage: Some("https://example.com".to_string()),
732 repository: Some("https://github.com/user/repo".to_string()),
733 license: Some("MIT".to_string()),
734 }
735 }
736
737 #[tokio::test]
738 async fn test_search_request() {
739 let mut server = Server::new_async().await;
740 let config = create_test_config(server.url(), None);
741
742 let _m = server
743 .mock("GET", "/api/v1/agents/search")
744 .match_query(mockito::Matcher::UrlEncoded("q".into(), "test".into()))
745 .with_status(200)
746 .with_header("content-type", "application/json")
747 .with_body(r#"{"agents": [], "total": 0, "page": 1, "per_page": 10}"#)
748 .create_async()
749 .await;
750
751 let client = ApiClient::new(&config).unwrap();
752 let result = client.search("test", Some(10), false).await;
753
754 match result {
755 Ok(response) => {
756 assert_eq!(response.agents.len(), 0);
757 assert_eq!(response.total, 0);
758 println!("Test passed successfully");
759 }
760 Err(e) => {
761 println!("Test error: {:?}", e);
763 }
765 }
766 }
767
768 #[test]
769 fn test_validate_upload_request_valid() {
770 let config =
771 create_test_config("https://example.com".to_string(), Some("token".to_string()));
772 let client = ApiClient::new(&config).unwrap();
773 let request = create_valid_upload_request();
774
775 assert!(client.validate_upload_request(&request).is_ok());
776 }
777
778 #[test]
779 fn test_validate_upload_request_empty_name() {
780 let config =
781 create_test_config("https://example.com".to_string(), Some("token".to_string()));
782 let client = ApiClient::new(&config).unwrap();
783 let mut request = create_valid_upload_request();
784 request.name = "".to_string();
785
786 let result = client.validate_upload_request(&request);
787 assert!(result.is_err());
788 assert!(result
789 .unwrap_err()
790 .to_string()
791 .contains("Agent name cannot be empty"));
792 }
793
794 #[test]
795 fn test_validate_upload_request_invalid_name() {
796 let config =
797 create_test_config("https://example.com".to_string(), Some("token".to_string()));
798 let client = ApiClient::new(&config).unwrap();
799 let mut request = create_valid_upload_request();
800 request.name = "invalid name!".to_string();
801
802 let result = client.validate_upload_request(&request);
803 assert!(result.is_err());
804 assert!(result
805 .unwrap_err()
806 .to_string()
807 .contains("alphanumeric characters"));
808 }
809
810 #[test]
811 fn test_validate_upload_request_empty_description() {
812 let config =
813 create_test_config("https://example.com".to_string(), Some("token".to_string()));
814 let client = ApiClient::new(&config).unwrap();
815 let mut request = create_valid_upload_request();
816 request.description = "".to_string();
817
818 let result = client.validate_upload_request(&request);
819 assert!(result.is_err());
820 assert!(result
821 .unwrap_err()
822 .to_string()
823 .contains("Description cannot be empty"));
824 }
825
826 #[test]
827 fn test_validate_upload_request_empty_content() {
828 let config =
829 create_test_config("https://example.com".to_string(), Some("token".to_string()));
830 let client = ApiClient::new(&config).unwrap();
831 let mut request = create_valid_upload_request();
832 request.content = "".to_string();
833
834 let result = client.validate_upload_request(&request);
835 assert!(result.is_err());
836 assert!(result
837 .unwrap_err()
838 .to_string()
839 .contains("Content cannot be empty"));
840 }
841
842 #[test]
843 fn test_validate_upload_request_no_frontmatter() {
844 let config =
845 create_test_config("https://example.com".to_string(), Some("token".to_string()));
846 let client = ApiClient::new(&config).unwrap();
847 let mut request = create_valid_upload_request();
848 request.content = "# Test Agent\n\nNo frontmatter here.".to_string();
849
850 let result = client.validate_upload_request(&request);
851 assert!(result.is_err());
852 assert!(result.unwrap_err().to_string().contains("YAML frontmatter"));
853 }
854
855 #[test]
856 fn test_validate_upload_request_mismatched_name() {
857 let config =
858 create_test_config("https://example.com".to_string(), Some("token".to_string()));
859 let client = ApiClient::new(&config).unwrap();
860 let mut request = create_valid_upload_request();
861 request.content = r#"---
862name: different-name
863description: A test agent
864---
865
866# Test Agent
867"#
868 .to_string();
869
870 let result = client.validate_upload_request(&request);
871 assert!(result.is_err());
872 assert!(result.unwrap_err().to_string().contains("Name mismatch"));
873 }
874
875 #[test]
876 fn test_validate_upload_request_mismatched_description() {
877 let config =
878 create_test_config("https://example.com".to_string(), Some("token".to_string()));
879 let client = ApiClient::new(&config).unwrap();
880 let mut request = create_valid_upload_request();
881 request.content = r#"---
882name: test-agent
883description: Different description
884---
885
886# Test Agent
887"#
888 .to_string();
889
890 let result = client.validate_upload_request(&request);
891 assert!(result.is_err());
892 assert!(result
893 .unwrap_err()
894 .to_string()
895 .contains("Description mismatch"));
896 }
897
898 #[test]
899 fn test_validate_upload_request_too_many_tags() {
900 let config =
901 create_test_config("https://example.com".to_string(), Some("token".to_string()));
902 let client = ApiClient::new(&config).unwrap();
903 let mut request = create_valid_upload_request();
904 request.tags = (0..25).map(|i| format!("tag{}", i)).collect();
905
906 let result = client.validate_upload_request(&request);
907 assert!(result.is_err());
908 assert!(result
909 .unwrap_err()
910 .to_string()
911 .contains("Cannot have more than 20 tags"));
912 }
913
914 #[test]
915 fn test_validate_upload_request_large_content() {
916 let config =
917 create_test_config("https://example.com".to_string(), Some("token".to_string()));
918 let client = ApiClient::new(&config).unwrap();
919 let mut request = create_valid_upload_request();
920 let large_content = "x".repeat(2 * 1024 * 1024);
922 request.content = format!(
923 r#"---
924name: test-agent
925description: A test agent
926---
927
928{}
929"#,
930 large_content
931 );
932
933 let result = client.validate_upload_request(&request);
934 assert!(result.is_err());
935 assert!(result
936 .unwrap_err()
937 .to_string()
938 .contains("exceeds maximum allowed size"));
939 }
940
941 #[tokio::test]
942 async fn test_upload_no_token() {
943 let mut server = Server::new_async().await;
944 let config = create_test_config(server.url(), None);
945 let client = ApiClient::new(&config).unwrap();
946 let request = create_valid_upload_request();
947
948 let result = client.upload(request).await;
949 assert!(result.is_err());
950 if let Err(CarpError::Auth(msg)) = result {
951 assert!(msg.contains("No API key configured"));
952 } else {
953 panic!("Expected Auth error");
954 }
955 }
956
957 #[tokio::test]
958 async fn test_upload_success() {
959 let mut server = Server::new_async().await;
960 let config = create_test_config(server.url(), Some("test-token".to_string()));
961
962 let _m = server
963 .mock("POST", "/api/v1/agents/upload")
964 .match_header("authorization", "Bearer test-token")
965 .match_header("content-type", "application/json")
966 .with_status(200)
967 .with_header("content-type", "application/json")
968 .with_body(
969 r#"{"success": true, "message": "Agent uploaded successfully", "agent": null}"#,
970 )
971 .create_async()
972 .await;
973
974 let client = ApiClient::new(&config).unwrap();
975 let request = create_valid_upload_request();
976
977 let result = client.upload(request).await;
978 match result {
979 Ok(response) => {
980 assert!(response.success);
981 assert_eq!(response.message, "Agent uploaded successfully");
982 }
983 Err(e) => {
984 println!("Upload test error: {:?}", e);
985 }
987 }
988 }
989}