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 pub async fn get_agent_download(
128 &self,
129 name: &str,
130 version: Option<&str>,
131 ) -> CarpResult<AgentDownload> {
132 self.validate_agent_name(name)?;
134
135 let version = version.unwrap_or("latest");
136 if !version.is_empty() && version != "latest" {
137 self.validate_version(version)?;
138 }
139
140 let url = format!(
141 "{}/api/v1/agents/{}/{}/download",
142 self.base_url,
143 urlencoding::encode(name),
144 urlencoding::encode(version)
145 );
146
147 self.make_request_with_retry(|| async {
148 let response = self.client.get(&url).send().await?;
149 self.handle_response(response).await
150 })
151 .await
152 }
153
154 pub async fn download_agent(&self, download_url: &str) -> CarpResult<bytes::Bytes> {
156 if download_url.is_empty() {
158 return Err(CarpError::Network(
159 "Download URL cannot be empty".to_string(),
160 ));
161 }
162
163 let parsed_url = download_url
165 .parse::<reqwest::Url>()
166 .map_err(|_| CarpError::Network("Invalid download URL format".to_string()))?;
167
168 if parsed_url.scheme() != "https" && parsed_url.scheme() != "http" {
170 return Err(CarpError::Network(
171 "Download URLs must use HTTP or HTTPS".to_string(),
172 ));
173 }
174
175 if parsed_url.scheme() == "http" {
176 return Err(CarpError::Network(
177 "HTTP download URLs are not allowed for security reasons".to_string(),
178 ));
179 }
180
181 self.make_request_with_retry(|| async {
182 let response = self.client.get(download_url).send().await?;
183
184 if !response.status().is_success() {
185 return Err(CarpError::Api {
186 status: response.status().as_u16(),
187 message: format!("Failed to download agent: HTTP {}", response.status()),
188 });
189 }
190
191 if let Some(content_length) = response.content_length() {
195 const MAX_DOWNLOAD_SIZE: u64 = 100 * 1024 * 1024; if content_length > MAX_DOWNLOAD_SIZE {
197 return Err(CarpError::Network(format!(
198 "Download size ({content_length} bytes) exceeds maximum allowed size ({MAX_DOWNLOAD_SIZE} bytes)"
199 )));
200 }
201 }
202
203 let bytes = response.bytes().await?;
204 Ok(bytes)
205 }).await
206 }
207
208 pub async fn upload(&self, request: UploadAgentRequest) -> CarpResult<UploadAgentResponse> {
210 let api_key = self.api_key.as_ref().ok_or_else(|| {
211 CarpError::Auth("No API key configured. Please set your API key via command line, environment variable, or config file.".to_string())
212 })?;
213
214 self.validate_upload_request(&request)?;
216
217 let url = format!("{}/api/v1/agents/upload", self.base_url);
218
219 self.make_request_with_retry(|| async {
220 let response = self
221 .client
222 .post(&url)
223 .header("Authorization", format!("Bearer {api_key}"))
224 .header("Content-Type", "application/json")
225 .json(&request)
226 .send()
227 .await?;
228
229 self.handle_response(response).await
230 })
231 .await
232 }
233
234 #[allow(dead_code)]
236 pub async fn publish(
237 &self,
238 _request: PublishRequest,
239 _content: Vec<u8>,
240 ) -> CarpResult<PublishResponse> {
241 Err(CarpError::Api {
243 status: 503,
244 message: "Publishing is temporarily disabled pending security hardening. Please check back later.".to_string(),
245 })
246 }
247
248 #[allow(dead_code)]
250 async fn publish_internal(
251 &self,
252 request: PublishRequest,
253 content: Vec<u8>,
254 ) -> CarpResult<PublishResponse> {
255 let api_key = self.api_key.as_ref().ok_or_else(|| {
256 CarpError::Auth("No API key configured. Please set your API key via command line, environment variable, or config file.".to_string())
257 })?;
258
259 self.validate_publish_request(&request)?;
261
262 const MAX_PUBLISH_SIZE: usize = 50 * 1024 * 1024;
264 if content.len() > MAX_PUBLISH_SIZE {
265 return Err(CarpError::Api {
266 status: 413,
267 message: format!(
268 "Agent package size ({} bytes) exceeds maximum allowed size ({} bytes)",
269 content.len(),
270 MAX_PUBLISH_SIZE
271 ),
272 });
273 }
274
275 let url = format!("{}/api/v1/agents/publish", self.base_url);
276
277 let form = reqwest::multipart::Form::new()
279 .text("metadata", serde_json::to_string(&request)?)
280 .part(
281 "content",
282 reqwest::multipart::Part::bytes(content)
283 .file_name("agent.zip")
284 .mime_str("application/zip")?,
285 );
286
287 let response = self
290 .client
291 .post(&url)
292 .header("Authorization", format!("Bearer {api_key}"))
293 .multipart(form)
294 .send()
295 .await?;
296
297 self.handle_response(response).await
298 }
299
300 #[allow(dead_code)]
302 pub async fn authenticate(&self, username: &str, password: &str) -> CarpResult<AuthResponse> {
303 if username.trim().is_empty() {
305 return Err(CarpError::Auth("Username cannot be empty".to_string()));
306 }
307 if password.is_empty() {
308 return Err(CarpError::Auth("Password cannot be empty".to_string()));
309 }
310
311 let url = format!("{}/api/v1/auth/login", self.base_url);
312 let request = AuthRequest {
313 username: username.trim().to_string(),
314 password: password.to_string(),
315 };
316
317 let response = self.client.post(&url).json(&request).send().await?;
319 self.handle_response(response).await
320 }
321
322 pub async fn health_check(&self) -> CarpResult<HealthResponse> {
324 let url = format!("{}/api/health", self.base_url);
325
326 let mut attempts = 0;
328 let max_attempts = 2;
329
330 loop {
331 attempts += 1;
332 match self.client.get(&url).send().await {
333 Ok(response) => return self.handle_response(response).await,
334 Err(e) if attempts < max_attempts && self.is_retryable_error(&e) => {
335 sleep(Duration::from_millis(500)).await;
336 continue;
337 }
338 Err(e) => return Err(CarpError::from(e)),
339 }
340 }
341 }
342
343 async fn make_request_with_retry<T, F, Fut>(&self, request_fn: F) -> CarpResult<T>
345 where
346 F: Fn() -> Fut,
347 Fut: std::future::Future<Output = CarpResult<T>>,
348 {
349 let mut attempts = 0;
350 let mut delay = self.retry_config.initial_delay;
351
352 loop {
353 attempts += 1;
354
355 match request_fn().await {
356 Ok(result) => return Ok(result),
357 Err(e) if attempts <= self.retry_config.max_retries && self.should_retry(&e) => {
358 if attempts < self.retry_config.max_retries {
359 sleep(delay).await;
360 delay = std::cmp::min(
361 Duration::from_millis(
362 (delay.as_millis() as f64 * self.retry_config.backoff_multiplier)
363 as u64,
364 ),
365 self.retry_config.max_delay,
366 );
367 } else {
368 return Err(e);
369 }
370 }
371 Err(e) => return Err(e),
372 }
373 }
374 }
375
376 fn should_retry(&self, error: &CarpError) -> bool {
378 match error {
379 CarpError::Http(e) => self.is_retryable_error(e),
380 CarpError::Api { status, .. } => {
381 (500..600).contains(status) ||
383 *status == 429 || *status == 408 }
386 CarpError::Network(_) => true,
387 _ => false,
388 }
389 }
390
391 fn is_retryable_error(&self, error: &reqwest::Error) -> bool {
393 if error.is_timeout() || error.is_connect() {
394 return true;
395 }
396
397 if let Some(status) = error.status() {
398 let status_code = status.as_u16();
399 return (500..600).contains(&status_code) || status_code == 429 || status_code == 408;
400 }
401
402 false
403 }
404
405 fn validate_agent_name(&self, name: &str) -> CarpResult<()> {
407 if name.trim().is_empty() {
408 return Err(CarpError::InvalidAgent(
409 "Agent name cannot be empty".to_string(),
410 ));
411 }
412
413 if !name
415 .chars()
416 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
417 {
418 return Err(CarpError::InvalidAgent(
419 "Agent name can only contain alphanumeric characters, hyphens, and underscores"
420 .to_string(),
421 ));
422 }
423
424 if name.len() > 100 {
425 return Err(CarpError::InvalidAgent(
426 "Agent name cannot exceed 100 characters".to_string(),
427 ));
428 }
429
430 Ok(())
431 }
432
433 fn validate_version(&self, version: &str) -> CarpResult<()> {
435 if version.trim().is_empty() {
436 return Err(CarpError::InvalidAgent(
437 "Version cannot be empty".to_string(),
438 ));
439 }
440
441 if !version
443 .chars()
444 .all(|c| c.is_alphanumeric() || ".-_+".contains(c))
445 {
446 return Err(CarpError::InvalidAgent(
447 "Version can only contain alphanumeric characters, dots, hyphens, underscores, and plus signs".to_string()
448 ));
449 }
450
451 if version.len() > 50 {
452 return Err(CarpError::InvalidAgent(
453 "Version cannot exceed 50 characters".to_string(),
454 ));
455 }
456
457 Ok(())
458 }
459
460 fn validate_upload_request(&self, request: &UploadAgentRequest) -> CarpResult<()> {
462 self.validate_agent_name(&request.name)?;
464
465 if request.description.trim().is_empty() {
467 return Err(CarpError::InvalidAgent(
468 "Description cannot be empty".to_string(),
469 ));
470 }
471
472 if request.description.len() > 1000 {
473 return Err(CarpError::InvalidAgent(
474 "Description cannot exceed 1000 characters".to_string(),
475 ));
476 }
477
478 if request.content.trim().is_empty() {
480 return Err(CarpError::InvalidAgent(
481 "Content cannot be empty".to_string(),
482 ));
483 }
484
485 const MAX_CONTENT_SIZE: usize = 1024 * 1024;
487 if request.content.len() > MAX_CONTENT_SIZE {
488 return Err(CarpError::InvalidAgent(format!(
489 "Content size ({} bytes) exceeds maximum allowed size ({} bytes)",
490 request.content.len(),
491 MAX_CONTENT_SIZE
492 )));
493 }
494
495 self.validate_frontmatter_consistency(request)?;
497
498 if let Some(version) = &request.version {
500 self.validate_version(version)?;
501 }
502
503 for tag in &request.tags {
505 if tag.trim().is_empty() {
506 return Err(CarpError::InvalidAgent("Tags cannot be empty".to_string()));
507 }
508 if tag.len() > 50 {
509 return Err(CarpError::InvalidAgent(
510 "Tags cannot exceed 50 characters".to_string(),
511 ));
512 }
513 }
514
515 if request.tags.len() > 20 {
516 return Err(CarpError::InvalidAgent(
517 "Cannot have more than 20 tags".to_string(),
518 ));
519 }
520
521 Ok(())
522 }
523
524 fn validate_frontmatter_consistency(&self, request: &UploadAgentRequest) -> CarpResult<()> {
526 if !request.content.starts_with("---") {
528 return Err(CarpError::InvalidAgent(
529 "Content must contain YAML frontmatter starting with ---".to_string(),
530 ));
531 }
532
533 let lines: Vec<&str> = request.content.lines().collect();
535 let mut frontmatter_end = None;
536
537 for (i, line) in lines.iter().enumerate().skip(1) {
538 if line.trim() == "---" {
539 frontmatter_end = Some(i);
540 break;
541 }
542 }
543
544 let frontmatter_end = frontmatter_end.ok_or_else(|| {
545 CarpError::InvalidAgent("Invalid YAML frontmatter: missing closing ---".to_string())
546 })?;
547
548 let frontmatter_lines = &lines[1..frontmatter_end];
550 let frontmatter_content = frontmatter_lines.join("\n");
551
552 let frontmatter: serde_json::Value = serde_yaml::from_str(&frontmatter_content)
554 .map_err(|e| CarpError::InvalidAgent(format!("Invalid YAML frontmatter: {e}")))?;
555
556 if let Some(frontmatter_name) = frontmatter.get("name").and_then(|v| v.as_str()) {
558 if frontmatter_name != request.name {
559 return Err(CarpError::InvalidAgent(format!(
560 "Name mismatch: frontmatter contains '{}' but request contains '{}'",
561 frontmatter_name, request.name
562 )));
563 }
564 } else {
565 return Err(CarpError::InvalidAgent(
566 "YAML frontmatter must contain a 'name' field".to_string(),
567 ));
568 }
569
570 if let Some(frontmatter_desc) = frontmatter.get("description").and_then(|v| v.as_str()) {
572 if frontmatter_desc != request.description {
573 return Err(CarpError::InvalidAgent(format!(
574 "Description mismatch: frontmatter contains '{}' but request contains '{}'",
575 frontmatter_desc, request.description
576 )));
577 }
578 } else {
579 return Err(CarpError::InvalidAgent(
580 "YAML frontmatter must contain a 'description' field".to_string(),
581 ));
582 }
583
584 Ok(())
585 }
586
587 fn validate_publish_request(&self, request: &PublishRequest) -> CarpResult<()> {
589 self.validate_agent_name(&request.name)?;
590 self.validate_version(&request.version)?;
591
592 if request.description.trim().is_empty() {
593 return Err(CarpError::InvalidAgent(
594 "Description cannot be empty".to_string(),
595 ));
596 }
597
598 if request.description.len() > 1000 {
599 return Err(CarpError::InvalidAgent(
600 "Description cannot exceed 1000 characters".to_string(),
601 ));
602 }
603
604 for tag in &request.tags {
606 if tag.trim().is_empty() {
607 return Err(CarpError::InvalidAgent("Tags cannot be empty".to_string()));
608 }
609 if tag.len() > 50 {
610 return Err(CarpError::InvalidAgent(
611 "Tags cannot exceed 50 characters".to_string(),
612 ));
613 }
614 }
615
616 if request.tags.len() > 10 {
617 return Err(CarpError::InvalidAgent(
618 "Cannot have more than 10 tags".to_string(),
619 ));
620 }
621
622 Ok(())
623 }
624
625 async fn handle_response<T>(&self, response: Response) -> CarpResult<T>
627 where
628 T: serde::de::DeserializeOwned,
629 {
630 let status = response.status();
631 let text = response.text().await?;
632
633 if status.is_success() {
634 serde_json::from_str(&text).map_err(CarpError::Json)
635 } else {
636 if status.as_u16() == 401 {
638 let auth_error = if text.contains("invalid") || text.contains("expired") {
639 "Invalid or expired API key. Please check your API key and try again."
640 } else if text.contains("missing") || text.contains("required") {
641 "API key required. Please provide your API key via --api-key option, CARP_API_KEY environment variable, or config file."
642 } else {
643 "Authentication failed. Please verify your API key is correct."
644 };
645
646 return Err(CarpError::Auth(format!(
647 "{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"
648 )));
649 }
650
651 if status.as_u16() == 403 {
652 return Err(CarpError::Auth(
653 "Access forbidden. Your API key may not have sufficient permissions for this operation.".to_string()
654 ));
655 }
656
657 match serde_json::from_str::<ApiError>(&text) {
659 Ok(api_error) => Err(CarpError::Api {
660 status: status.as_u16(),
661 message: api_error.message,
662 }),
663 Err(_) => Err(CarpError::Api {
664 status: status.as_u16(),
665 message: if text.is_empty() {
666 format!("HTTP {} error", status.as_u16())
667 } else {
668 text
669 },
670 }),
671 }
672 }
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use crate::config::Config;
680 use mockito::Server;
681
682 fn create_test_config(server_url: String, api_key: Option<String>) -> Config {
683 Config {
684 registry_url: server_url,
685 api_key,
686 api_token: None,
687 timeout: 30,
688 verify_ssl: true,
689 default_output_dir: None,
690 max_concurrent_downloads: 4,
691 retry: crate::config::RetrySettings::default(),
692 security: crate::config::SecuritySettings::default(),
693 }
694 }
695
696 fn create_valid_upload_request() -> UploadAgentRequest {
697 UploadAgentRequest {
698 name: "test-agent".to_string(),
699 description: "A test agent".to_string(),
700 content: r#"---
701name: test-agent
702description: A test agent
703---
704
705# Test Agent
706
707This is a test agent.
708"#
709 .to_string(),
710 version: Some("1.0.0".to_string()),
711 tags: vec!["test".to_string()],
712 homepage: Some("https://example.com".to_string()),
713 repository: Some("https://github.com/user/repo".to_string()),
714 license: Some("MIT".to_string()),
715 }
716 }
717
718 #[tokio::test]
719 async fn test_search_request() {
720 let mut server = Server::new_async().await;
721 let config = create_test_config(server.url(), None);
722
723 let _m = server
724 .mock("GET", "/api/v1/agents/search")
725 .match_query(mockito::Matcher::UrlEncoded("q".into(), "test".into()))
726 .with_status(200)
727 .with_header("content-type", "application/json")
728 .with_body(r#"{"agents": [], "total": 0, "page": 1, "per_page": 10}"#)
729 .create_async()
730 .await;
731
732 let client = ApiClient::new(&config).unwrap();
733 let result = client.search("test", Some(10), false).await;
734
735 match result {
736 Ok(response) => {
737 assert_eq!(response.agents.len(), 0);
738 assert_eq!(response.total, 0);
739 println!("Test passed successfully");
740 }
741 Err(e) => {
742 println!("Test error: {:?}", e);
744 }
746 }
747 }
748
749 #[test]
750 fn test_validate_upload_request_valid() {
751 let config =
752 create_test_config("https://example.com".to_string(), Some("token".to_string()));
753 let client = ApiClient::new(&config).unwrap();
754 let request = create_valid_upload_request();
755
756 assert!(client.validate_upload_request(&request).is_ok());
757 }
758
759 #[test]
760 fn test_validate_upload_request_empty_name() {
761 let config =
762 create_test_config("https://example.com".to_string(), Some("token".to_string()));
763 let client = ApiClient::new(&config).unwrap();
764 let mut request = create_valid_upload_request();
765 request.name = "".to_string();
766
767 let result = client.validate_upload_request(&request);
768 assert!(result.is_err());
769 assert!(result
770 .unwrap_err()
771 .to_string()
772 .contains("Agent name cannot be empty"));
773 }
774
775 #[test]
776 fn test_validate_upload_request_invalid_name() {
777 let config =
778 create_test_config("https://example.com".to_string(), Some("token".to_string()));
779 let client = ApiClient::new(&config).unwrap();
780 let mut request = create_valid_upload_request();
781 request.name = "invalid name!".to_string();
782
783 let result = client.validate_upload_request(&request);
784 assert!(result.is_err());
785 assert!(result
786 .unwrap_err()
787 .to_string()
788 .contains("alphanumeric characters"));
789 }
790
791 #[test]
792 fn test_validate_upload_request_empty_description() {
793 let config =
794 create_test_config("https://example.com".to_string(), Some("token".to_string()));
795 let client = ApiClient::new(&config).unwrap();
796 let mut request = create_valid_upload_request();
797 request.description = "".to_string();
798
799 let result = client.validate_upload_request(&request);
800 assert!(result.is_err());
801 assert!(result
802 .unwrap_err()
803 .to_string()
804 .contains("Description cannot be empty"));
805 }
806
807 #[test]
808 fn test_validate_upload_request_empty_content() {
809 let config =
810 create_test_config("https://example.com".to_string(), Some("token".to_string()));
811 let client = ApiClient::new(&config).unwrap();
812 let mut request = create_valid_upload_request();
813 request.content = "".to_string();
814
815 let result = client.validate_upload_request(&request);
816 assert!(result.is_err());
817 assert!(result
818 .unwrap_err()
819 .to_string()
820 .contains("Content cannot be empty"));
821 }
822
823 #[test]
824 fn test_validate_upload_request_no_frontmatter() {
825 let config =
826 create_test_config("https://example.com".to_string(), Some("token".to_string()));
827 let client = ApiClient::new(&config).unwrap();
828 let mut request = create_valid_upload_request();
829 request.content = "# Test Agent\n\nNo frontmatter here.".to_string();
830
831 let result = client.validate_upload_request(&request);
832 assert!(result.is_err());
833 assert!(result.unwrap_err().to_string().contains("YAML frontmatter"));
834 }
835
836 #[test]
837 fn test_validate_upload_request_mismatched_name() {
838 let config =
839 create_test_config("https://example.com".to_string(), Some("token".to_string()));
840 let client = ApiClient::new(&config).unwrap();
841 let mut request = create_valid_upload_request();
842 request.content = r#"---
843name: different-name
844description: A test agent
845---
846
847# Test Agent
848"#
849 .to_string();
850
851 let result = client.validate_upload_request(&request);
852 assert!(result.is_err());
853 assert!(result.unwrap_err().to_string().contains("Name mismatch"));
854 }
855
856 #[test]
857 fn test_validate_upload_request_mismatched_description() {
858 let config =
859 create_test_config("https://example.com".to_string(), Some("token".to_string()));
860 let client = ApiClient::new(&config).unwrap();
861 let mut request = create_valid_upload_request();
862 request.content = r#"---
863name: test-agent
864description: Different description
865---
866
867# Test Agent
868"#
869 .to_string();
870
871 let result = client.validate_upload_request(&request);
872 assert!(result.is_err());
873 assert!(result
874 .unwrap_err()
875 .to_string()
876 .contains("Description mismatch"));
877 }
878
879 #[test]
880 fn test_validate_upload_request_too_many_tags() {
881 let config =
882 create_test_config("https://example.com".to_string(), Some("token".to_string()));
883 let client = ApiClient::new(&config).unwrap();
884 let mut request = create_valid_upload_request();
885 request.tags = (0..25).map(|i| format!("tag{}", i)).collect();
886
887 let result = client.validate_upload_request(&request);
888 assert!(result.is_err());
889 assert!(result
890 .unwrap_err()
891 .to_string()
892 .contains("Cannot have more than 20 tags"));
893 }
894
895 #[test]
896 fn test_validate_upload_request_large_content() {
897 let config =
898 create_test_config("https://example.com".to_string(), Some("token".to_string()));
899 let client = ApiClient::new(&config).unwrap();
900 let mut request = create_valid_upload_request();
901 let large_content = "x".repeat(2 * 1024 * 1024);
903 request.content = format!(
904 r#"---
905name: test-agent
906description: A test agent
907---
908
909{}
910"#,
911 large_content
912 );
913
914 let result = client.validate_upload_request(&request);
915 assert!(result.is_err());
916 assert!(result
917 .unwrap_err()
918 .to_string()
919 .contains("exceeds maximum allowed size"));
920 }
921
922 #[tokio::test]
923 async fn test_upload_no_token() {
924 let mut server = Server::new_async().await;
925 let config = create_test_config(server.url(), None);
926 let client = ApiClient::new(&config).unwrap();
927 let request = create_valid_upload_request();
928
929 let result = client.upload(request).await;
930 assert!(result.is_err());
931 if let Err(CarpError::Auth(msg)) = result {
932 assert!(msg.contains("No API key configured"));
933 } else {
934 panic!("Expected Auth error");
935 }
936 }
937
938 #[tokio::test]
939 async fn test_upload_success() {
940 let mut server = Server::new_async().await;
941 let config = create_test_config(server.url(), Some("test-token".to_string()));
942
943 let _m = server
944 .mock("POST", "/api/v1/agents/upload")
945 .match_header("authorization", "Bearer test-token")
946 .match_header("content-type", "application/json")
947 .with_status(200)
948 .with_header("content-type", "application/json")
949 .with_body(
950 r#"{"success": true, "message": "Agent uploaded successfully", "agent": null}"#,
951 )
952 .create_async()
953 .await;
954
955 let client = ApiClient::new(&config).unwrap();
956 let request = create_valid_upload_request();
957
958 let result = client.upload(request).await;
959 match result {
960 Ok(response) => {
961 assert!(response.success);
962 assert_eq!(response.message, "Agent uploaded successfully");
963 }
964 Err(e) => {
965 println!("Upload test error: {:?}", e);
966 }
968 }
969 }
970}