1use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ResourceType {
22 Data,
24 Model,
26 Api,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub struct PachaUri {
33 pub resource_type: ResourceType,
35 pub host: Option<String>,
37 pub port: Option<u16>,
39 pub path: String,
41 pub query: HashMap<String, String>,
43}
44
45impl PachaUri {
46 pub fn parse(uri: &str) -> Result<Self, PachaError> {
52 if !uri.starts_with("pacha://") {
53 return Err(PachaError::InvalidProtocol(uri.to_string()));
54 }
55
56 let rest = &uri[8..]; let (path_part, query) = if let Some(idx) = rest.find('?') {
60 let query_str = &rest[idx + 1..];
61 let query = parse_query(query_str);
62 (&rest[..idx], query)
63 } else {
64 (rest, HashMap::new())
65 };
66
67 let (host, port, path) = if path_part.contains(':') && !path_part.starts_with('/') {
69 let parts: Vec<&str> = path_part.splitn(2, '/').collect();
71 let host_port = parts[0];
72 let path = if parts.len() > 1 {
73 format!("/{}", parts[1])
74 } else {
75 "/".to_string()
76 };
77
78 let hp: Vec<&str> = host_port.split(':').collect();
79 let host = hp[0].to_string();
80 let port = hp.get(1).and_then(|p| p.parse().ok());
81
82 (Some(host), port, path)
83 } else if path_part.starts_with('/') {
84 (None, None, path_part.to_string())
85 } else {
86 (None, None, format!("/{}", path_part))
88 };
89
90 let resource_type = if path.starts_with("/models") || path.starts_with("/model") {
92 ResourceType::Model
93 } else if path.starts_with("/api") {
94 ResourceType::Api
95 } else {
96 ResourceType::Data
97 };
98
99 Ok(Self {
100 resource_type,
101 host,
102 port,
103 path,
104 query,
105 })
106 }
107
108 #[must_use]
110 pub fn is_local(&self) -> bool {
111 self.host.is_none() || self.host.as_deref() == Some("localhost")
112 }
113
114 #[must_use]
116 pub fn is_remote(&self) -> bool {
117 !self.is_local()
118 }
119
120 #[must_use]
126 pub fn to_local_path(&self, base_dir: &Path) -> PathBuf {
127 let path = self.path.trim_start_matches('/');
128 base_dir.join(path)
129 }
130}
131
132fn parse_query(query: &str) -> HashMap<String, String> {
133 let mut map = HashMap::new();
134 for pair in query.split('&') {
135 if let Some(idx) = pair.find('=') {
136 let key = &pair[..idx];
137 let value = &pair[idx + 1..];
138 map.insert(key.to_string(), value.to_string());
139 }
140 }
141 map
142}
143
144#[derive(Debug, Clone, PartialEq)]
146pub enum PachaError {
147 InvalidProtocol(String),
149 NotFound(String),
151 ConnectionError(String),
153 ParseError(String),
155 IoError(String),
157 UnsupportedFormat(String),
159}
160
161impl std::fmt::Display for PachaError {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 match self {
164 Self::InvalidProtocol(uri) => write!(f, "Invalid protocol: {uri}"),
165 Self::NotFound(path) => write!(f, "Resource not found: {path}"),
166 Self::ConnectionError(msg) => write!(f, "Connection error: {msg}"),
167 Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
168 Self::IoError(msg) => write!(f, "IO error: {msg}"),
169 Self::UnsupportedFormat(fmt) => write!(f, "Unsupported format: {fmt}"),
170 }
171 }
172}
173
174impl std::error::Error for PachaError {}
175
176pub struct PachaLoader {
178 base_dir: PathBuf,
180 cache: HashMap<String, LoadedResource>,
182}
183
184#[derive(Debug, Clone)]
186pub struct LoadedResource {
187 pub uri: String,
189 pub data: Vec<u8>,
191 pub content_type: ContentType,
193 pub last_modified: Option<u64>,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum ContentType {
200 Ald,
202 Apr,
204 Json,
206 Csv,
208 Binary,
210}
211
212impl ContentType {
213 #[must_use]
215 pub fn from_extension(ext: &str) -> Self {
216 match ext.to_lowercase().as_str() {
217 "ald" => Self::Ald,
218 "apr" => Self::Apr,
219 "json" => Self::Json,
220 "csv" => Self::Csv,
221 _ => Self::Binary,
222 }
223 }
224
225 #[must_use]
227 pub const fn extension(&self) -> &'static str {
228 match self {
229 Self::Ald => "ald",
230 Self::Apr => "apr",
231 Self::Json => "json",
232 Self::Csv => "csv",
233 Self::Binary => "bin",
234 }
235 }
236}
237
238impl PachaLoader {
239 #[must_use]
241 pub fn new(base_dir: PathBuf) -> Self {
242 Self {
243 base_dir,
244 cache: HashMap::new(),
245 }
246 }
247
248 #[must_use]
250 pub fn current_dir() -> Self {
251 Self::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
252 }
253
254 pub fn load(&mut self, uri: &str) -> Result<&LoadedResource, PachaError> {
260 if self.cache.contains_key(uri) {
262 return Ok(self.cache.get(uri).unwrap());
263 }
264
265 let parsed = PachaUri::parse(uri)?;
266 let resource = self.load_uri(&parsed, uri)?;
267 self.cache.insert(uri.to_string(), resource);
268 Ok(self.cache.get(uri).unwrap())
269 }
270
271 pub fn load_fresh(&self, uri: &str) -> Result<LoadedResource, PachaError> {
277 let parsed = PachaUri::parse(uri)?;
278 self.load_uri(&parsed, uri)
279 }
280
281 fn load_uri(&self, parsed: &PachaUri, uri: &str) -> Result<LoadedResource, PachaError> {
282 if parsed.is_remote() && parsed.host.as_deref() != Some("localhost") {
283 return Err(PachaError::ConnectionError(
284 "Remote Pacha servers not yet supported".to_string(),
285 ));
286 }
287
288 let path = parsed.to_local_path(&self.base_dir);
290
291 let paths_to_try = if path.extension().is_none() {
293 vec![
294 path.with_extension("ald"),
295 path.with_extension("apr"),
296 path.with_extension("json"),
297 path.with_extension("csv"),
298 path.clone(),
299 ]
300 } else {
301 vec![path.clone()]
302 };
303
304 for try_path in paths_to_try {
305 if try_path.exists() {
306 let data =
307 std::fs::read(&try_path).map_err(|e| PachaError::IoError(e.to_string()))?;
308
309 let content_type = try_path
310 .extension()
311 .and_then(|e| e.to_str())
312 .map(ContentType::from_extension)
313 .unwrap_or(ContentType::Binary);
314
315 let last_modified = try_path
316 .metadata()
317 .ok()
318 .and_then(|m| m.modified().ok())
319 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
320 .map(|d| d.as_secs());
321
322 return Ok(LoadedResource {
323 uri: uri.to_string(),
324 data,
325 content_type,
326 last_modified,
327 });
328 }
329 }
330
331 Err(PachaError::NotFound(path.display().to_string()))
332 }
333
334 pub fn clear_cache(&mut self) {
336 self.cache.clear();
337 }
338
339 #[must_use]
341 pub fn get_cached(&self, uri: &str) -> Option<&LoadedResource> {
342 self.cache.get(uri)
343 }
344
345 #[must_use]
347 pub fn is_cached(&self, uri: &str) -> bool {
348 self.cache.contains_key(uri)
349 }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum HttpMethod {
359 Get,
361 Post,
363 Put,
365 Delete,
367}
368
369impl HttpMethod {
370 #[must_use]
372 pub const fn as_str(&self) -> &'static str {
373 match self {
374 Self::Get => "GET",
375 Self::Post => "POST",
376 Self::Put => "PUT",
377 Self::Delete => "DELETE",
378 }
379 }
380}
381
382#[derive(Debug, Clone)]
384pub struct HttpRequest {
385 pub url: String,
387 pub method: HttpMethod,
389 pub headers: HashMap<String, String>,
391 pub body: Option<Vec<u8>>,
393 pub timeout_ms: Option<u64>,
395}
396
397impl HttpRequest {
398 #[must_use]
400 pub fn get(url: impl Into<String>) -> Self {
401 Self {
402 url: url.into(),
403 method: HttpMethod::Get,
404 headers: HashMap::new(),
405 body: None,
406 timeout_ms: Some(30_000),
407 }
408 }
409
410 #[must_use]
412 pub fn post(url: impl Into<String>) -> Self {
413 Self {
414 url: url.into(),
415 method: HttpMethod::Post,
416 headers: HashMap::new(),
417 body: None,
418 timeout_ms: Some(30_000),
419 }
420 }
421
422 #[must_use]
424 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
425 self.headers.insert(name.into(), value.into());
426 self
427 }
428
429 #[must_use]
431 pub fn with_body(mut self, body: Vec<u8>) -> Self {
432 self.body = Some(body);
433 self
434 }
435
436 #[must_use]
438 pub const fn with_timeout(mut self, ms: u64) -> Self {
439 self.timeout_ms = Some(ms);
440 self
441 }
442}
443
444#[derive(Debug, Clone)]
446pub struct HttpResponse {
447 pub status: u16,
449 pub headers: HashMap<String, String>,
451 pub body: Vec<u8>,
453}
454
455impl HttpResponse {
456 #[must_use]
458 pub const fn is_success(&self) -> bool {
459 self.status >= 200 && self.status < 300
460 }
461
462 #[must_use]
464 pub fn get_header(&self, name: &str) -> Option<&str> {
465 let lower = name.to_lowercase();
467 self.headers
468 .iter()
469 .find(|(k, _)| k.to_lowercase() == lower)
470 .map(|(_, v)| v.as_str())
471 }
472
473 #[must_use]
475 pub fn content_type(&self) -> Option<&str> {
476 self.get_header("content-type")
477 }
478
479 #[must_use]
481 pub fn detect_content_type(&self) -> ContentType {
482 if let Some(ct) = self.content_type() {
483 if ct.contains("json") {
484 return ContentType::Json;
485 }
486 if ct.contains("csv") {
487 return ContentType::Csv;
488 }
489 }
490 ContentType::Binary
491 }
492}
493
494pub trait HttpClient: Send + Sync {
496 fn request(&self, req: HttpRequest) -> Result<HttpResponse, PachaError>;
502}
503
504#[derive(Debug, Default)]
507pub struct NoopHttpClient;
508
509impl HttpClient for NoopHttpClient {
510 fn request(&self, _req: HttpRequest) -> Result<HttpResponse, PachaError> {
511 Err(PachaError::ConnectionError(
512 "HTTP client not available - use WASM WebFetch or configure a client".to_string(),
513 ))
514 }
515}
516
517#[derive(Debug, Clone)]
519pub struct RetryConfig {
520 pub max_attempts: u32,
522 pub initial_delay_ms: u64,
524 pub max_delay_ms: u64,
526 pub backoff_multiplier: f32,
528}
529
530impl Default for RetryConfig {
531 fn default() -> Self {
532 Self {
533 max_attempts: 3,
534 initial_delay_ms: 500,
535 max_delay_ms: 10_000,
536 backoff_multiplier: 2.0,
537 }
538 }
539}
540
541impl RetryConfig {
542 #[must_use]
544 pub fn delay_for_attempt(&self, attempt: u32) -> u64 {
545 if attempt == 0 {
546 return 0;
547 }
548 let delay = self.initial_delay_ms as f32 * self.backoff_multiplier.powi(attempt as i32 - 1);
549 (delay as u64).min(self.max_delay_ms)
550 }
551
552 #[must_use]
554 pub const fn should_retry(&self, attempt: u32) -> bool {
555 attempt < self.max_attempts
556 }
557}
558
559pub struct RemotePachaLoader<C: HttpClient = NoopHttpClient> {
561 client: C,
563 retry_config: RetryConfig,
565 cache: HashMap<String, LoadedResource>,
567 cache_ttl_ms: Option<u64>,
569}
570
571impl<C: HttpClient> RemotePachaLoader<C> {
572 #[must_use]
574 pub fn new(client: C) -> Self {
575 Self {
576 client,
577 retry_config: RetryConfig::default(),
578 cache: HashMap::new(),
579 cache_ttl_ms: None,
580 }
581 }
582
583 #[must_use]
585 pub fn with_retry(mut self, config: RetryConfig) -> Self {
586 self.retry_config = config;
587 self
588 }
589
590 #[must_use]
592 pub const fn with_cache_ttl(mut self, ms: u64) -> Self {
593 self.cache_ttl_ms = Some(ms);
594 self
595 }
596
597 pub fn load(&mut self, uri: &str) -> Result<&LoadedResource, PachaError> {
603 if self.cache.contains_key(uri) {
605 return Ok(self.cache.get(uri).expect("just checked"));
606 }
607
608 let resource = self.load_fresh(uri)?;
609 self.cache.insert(uri.to_string(), resource);
610 Ok(self.cache.get(uri).expect("just inserted"))
611 }
612
613 pub fn load_fresh(&self, uri: &str) -> Result<LoadedResource, PachaError> {
619 let parsed = PachaUri::parse(uri)?;
620
621 if !parsed.is_remote() {
622 return Err(PachaError::ConnectionError(
623 "Use PachaLoader for local resources".to_string(),
624 ));
625 }
626
627 let http_url = self.build_http_url(&parsed);
628
629 let mut last_error = PachaError::ConnectionError("No attempts made".to_string());
631
632 for attempt in 0..self.retry_config.max_attempts {
633 if attempt > 0 {
634 }
637
638 let req = HttpRequest::get(&http_url)
639 .with_header("Accept", "application/json, application/octet-stream, */*")
640 .with_header("User-Agent", "Presentar/0.1");
641
642 match self.client.request(req) {
643 Ok(response) if response.is_success() => {
644 let content_type = response.detect_content_type();
645 return Ok(LoadedResource {
646 uri: uri.to_string(),
647 data: response.body,
648 content_type,
649 last_modified: None, });
651 }
652 Ok(response) => {
653 last_error =
654 PachaError::ConnectionError(format!("HTTP {} error", response.status));
655 if response.status >= 400 && response.status < 500 {
657 break;
658 }
659 }
660 Err(e) => {
661 last_error = e;
662 }
663 }
664 }
665
666 Err(last_error)
667 }
668
669 fn build_http_url(&self, parsed: &PachaUri) -> String {
671 let scheme = "https"; let host = parsed.host.as_deref().unwrap_or("localhost");
673 let port = parsed.port.map_or(String::new(), |p| format!(":{p}"));
674
675 let mut url = format!("{scheme}://{host}{port}{}", parsed.path);
676
677 if !parsed.query.is_empty() {
678 let query: Vec<String> = parsed
679 .query
680 .iter()
681 .map(|(k, v)| format!("{k}={v}"))
682 .collect();
683 url.push('?');
684 url.push_str(&query.join("&"));
685 }
686
687 url
688 }
689
690 pub fn clear_cache(&mut self) {
692 self.cache.clear();
693 }
694}
695
696#[must_use]
704pub fn parse_refresh_interval(interval: &str) -> Option<u64> {
705 let interval = interval.trim();
706 if interval.is_empty() {
707 return None;
708 }
709
710 let mut num_end = interval.len();
712 for (i, c) in interval.char_indices() {
713 if !c.is_ascii_digit() && c != '.' {
714 num_end = i;
715 break;
716 }
717 }
718
719 if num_end == 0 {
720 return None;
721 }
722
723 let num: f64 = interval[..num_end].parse().ok()?;
724 let unit = &interval[num_end..];
725
726 let ms = match unit {
727 "ms" => num,
728 "s" => num * 1000.0,
729 "m" => num * 60_000.0,
730 "h" => num * 3_600_000.0,
731 "" => num * 1000.0, _ => return None,
733 };
734
735 Some(ms as u64)
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 #[test]
747 fn test_parse_simple_data_uri() {
748 let uri = PachaUri::parse("pacha://data/metrics").unwrap();
749 assert_eq!(uri.resource_type, ResourceType::Data);
750 assert!(uri.host.is_none());
751 assert!(uri.port.is_none());
752 assert_eq!(uri.path, "/data/metrics");
753 assert!(uri.is_local());
754 }
755
756 #[test]
757 fn test_parse_model_uri() {
758 let uri = PachaUri::parse("pacha://models/classifier").unwrap();
759 assert_eq!(uri.resource_type, ResourceType::Model);
760 assert_eq!(uri.path, "/models/classifier");
761 }
762
763 #[test]
764 fn test_parse_api_uri() {
765 let uri = PachaUri::parse("pacha://api/v1/data").unwrap();
766 assert_eq!(uri.resource_type, ResourceType::Api);
767 assert_eq!(uri.path, "/api/v1/data");
768 }
769
770 #[test]
771 fn test_parse_uri_with_host() {
772 let uri = PachaUri::parse("pacha://localhost:8080/data/metrics").unwrap();
773 assert_eq!(uri.host, Some("localhost".to_string()));
774 assert_eq!(uri.port, Some(8080));
775 assert_eq!(uri.path, "/data/metrics");
776 assert!(uri.is_local()); }
778
779 #[test]
780 fn test_parse_uri_with_remote_host() {
781 let uri = PachaUri::parse("pacha://server.example.com:9000/api/v1").unwrap();
782 assert_eq!(uri.host, Some("server.example.com".to_string()));
783 assert_eq!(uri.port, Some(9000));
784 assert!(uri.is_remote());
785 }
786
787 #[test]
788 fn test_parse_uri_with_query() {
789 let uri = PachaUri::parse("pacha://data/metrics?limit=100&format=json").unwrap();
790 assert_eq!(uri.query.get("limit"), Some(&"100".to_string()));
791 assert_eq!(uri.query.get("format"), Some(&"json".to_string()));
792 }
793
794 #[test]
795 fn test_parse_invalid_protocol() {
796 let result = PachaUri::parse("http://example.com");
797 assert!(matches!(result, Err(PachaError::InvalidProtocol(_))));
798 }
799
800 #[test]
801 fn test_parse_empty_path() {
802 let uri = PachaUri::parse("pacha://localhost:8080").unwrap();
803 assert_eq!(uri.path, "/");
804 }
805
806 #[test]
811 fn test_resource_type_detection() {
812 assert_eq!(
813 PachaUri::parse("pacha://data/foo").unwrap().resource_type,
814 ResourceType::Data
815 );
816 assert_eq!(
817 PachaUri::parse("pacha://models/bar").unwrap().resource_type,
818 ResourceType::Model
819 );
820 assert_eq!(
821 PachaUri::parse("pacha://model/baz").unwrap().resource_type,
822 ResourceType::Model
823 );
824 assert_eq!(
825 PachaUri::parse("pacha://api/v1").unwrap().resource_type,
826 ResourceType::Api
827 );
828 }
829
830 #[test]
835 fn test_content_type_from_extension() {
836 assert_eq!(ContentType::from_extension("ald"), ContentType::Ald);
837 assert_eq!(ContentType::from_extension("apr"), ContentType::Apr);
838 assert_eq!(ContentType::from_extension("json"), ContentType::Json);
839 assert_eq!(ContentType::from_extension("csv"), ContentType::Csv);
840 assert_eq!(ContentType::from_extension("unknown"), ContentType::Binary);
841 }
842
843 #[test]
844 fn test_content_type_extension() {
845 assert_eq!(ContentType::Ald.extension(), "ald");
846 assert_eq!(ContentType::Apr.extension(), "apr");
847 assert_eq!(ContentType::Json.extension(), "json");
848 assert_eq!(ContentType::Csv.extension(), "csv");
849 assert_eq!(ContentType::Binary.extension(), "bin");
850 }
851
852 #[test]
857 fn test_to_local_path() {
858 let uri = PachaUri::parse("pacha://data/metrics").unwrap();
859 let path = uri.to_local_path(Path::new("/app"));
860 assert_eq!(path, PathBuf::from("/app/data/metrics"));
861 }
862
863 #[test]
864 fn test_to_local_path_nested() {
865 let uri = PachaUri::parse("pacha://data/nested/deep/file").unwrap();
866 let path = uri.to_local_path(Path::new("/base"));
867 assert_eq!(path, PathBuf::from("/base/data/nested/deep/file"));
868 }
869
870 #[test]
875 fn test_loader_new() {
876 let loader = PachaLoader::new(PathBuf::from("/test"));
877 assert!(!loader.is_cached("pacha://data/test"));
878 }
879
880 #[test]
881 fn test_loader_not_found() {
882 let loader = PachaLoader::new(PathBuf::from("/nonexistent"));
883 let result = loader.load_fresh("pacha://data/missing");
884 assert!(matches!(result, Err(PachaError::NotFound(_))));
885 }
886
887 #[test]
888 fn test_loader_remote_not_supported() {
889 let loader = PachaLoader::new(PathBuf::from("."));
890 let result = loader.load_fresh("pacha://remote.server.com:8080/data");
891 assert!(matches!(result, Err(PachaError::ConnectionError(_))));
892 }
893
894 #[test]
899 fn test_parse_refresh_seconds() {
900 assert_eq!(parse_refresh_interval("1s"), Some(1000));
901 assert_eq!(parse_refresh_interval("30s"), Some(30000));
902 assert_eq!(parse_refresh_interval("5"), Some(5000)); }
904
905 #[test]
906 fn test_parse_refresh_milliseconds() {
907 assert_eq!(parse_refresh_interval("100ms"), Some(100));
908 assert_eq!(parse_refresh_interval("500ms"), Some(500));
909 }
910
911 #[test]
912 fn test_parse_refresh_minutes() {
913 assert_eq!(parse_refresh_interval("1m"), Some(60000));
914 assert_eq!(parse_refresh_interval("5m"), Some(300000));
915 }
916
917 #[test]
918 fn test_parse_refresh_hours() {
919 assert_eq!(parse_refresh_interval("1h"), Some(3600000));
920 assert_eq!(parse_refresh_interval("2h"), Some(7200000));
921 }
922
923 #[test]
924 fn test_parse_refresh_fractional() {
925 assert_eq!(parse_refresh_interval("1.5s"), Some(1500));
926 assert_eq!(parse_refresh_interval("0.5m"), Some(30000));
927 }
928
929 #[test]
930 fn test_parse_refresh_invalid() {
931 assert_eq!(parse_refresh_interval(""), None);
932 assert_eq!(parse_refresh_interval("abc"), None);
933 assert_eq!(parse_refresh_interval("1x"), None);
934 }
935
936 #[test]
941 fn test_error_display() {
942 let err = PachaError::NotFound("test/path".to_string());
943 assert!(err.to_string().contains("not found"));
944 assert!(err.to_string().contains("test/path"));
945 }
946
947 #[test]
948 fn test_error_types() {
949 assert!(matches!(
950 PachaError::InvalidProtocol("x".to_string()),
951 PachaError::InvalidProtocol(_)
952 ));
953 assert!(matches!(
954 PachaError::ConnectionError("x".to_string()),
955 PachaError::ConnectionError(_)
956 ));
957 assert!(matches!(
958 PachaError::ParseError("x".to_string()),
959 PachaError::ParseError(_)
960 ));
961 assert!(matches!(
962 PachaError::IoError("x".to_string()),
963 PachaError::IoError(_)
964 ));
965 assert!(matches!(
966 PachaError::UnsupportedFormat("x".to_string()),
967 PachaError::UnsupportedFormat(_)
968 ));
969 }
970
971 #[test]
976 fn test_http_method_as_str() {
977 assert_eq!(HttpMethod::Get.as_str(), "GET");
978 assert_eq!(HttpMethod::Post.as_str(), "POST");
979 assert_eq!(HttpMethod::Put.as_str(), "PUT");
980 assert_eq!(HttpMethod::Delete.as_str(), "DELETE");
981 }
982
983 #[test]
984 fn test_http_request_get() {
985 let req = HttpRequest::get("https://example.com/data");
986 assert_eq!(req.url, "https://example.com/data");
987 assert_eq!(req.method, HttpMethod::Get);
988 assert!(req.body.is_none());
989 assert_eq!(req.timeout_ms, Some(30_000));
990 }
991
992 #[test]
993 fn test_http_request_post() {
994 let req = HttpRequest::post("https://example.com/api");
995 assert_eq!(req.method, HttpMethod::Post);
996 }
997
998 #[test]
999 fn test_http_request_with_header() {
1000 let req = HttpRequest::get("http://test.com")
1001 .with_header("Authorization", "Bearer token123")
1002 .with_header("Content-Type", "application/json");
1003
1004 assert_eq!(
1005 req.headers.get("Authorization"),
1006 Some(&"Bearer token123".to_string())
1007 );
1008 assert_eq!(
1009 req.headers.get("Content-Type"),
1010 Some(&"application/json".to_string())
1011 );
1012 }
1013
1014 #[test]
1015 fn test_http_request_with_body() {
1016 let body = b"test body".to_vec();
1017 let req = HttpRequest::post("http://test.com").with_body(body.clone());
1018 assert_eq!(req.body, Some(body));
1019 }
1020
1021 #[test]
1022 fn test_http_request_with_timeout() {
1023 let req = HttpRequest::get("http://test.com").with_timeout(5000);
1024 assert_eq!(req.timeout_ms, Some(5000));
1025 }
1026
1027 #[test]
1028 fn test_http_response_is_success() {
1029 let make_response = |status| HttpResponse {
1030 status,
1031 headers: HashMap::new(),
1032 body: vec![],
1033 };
1034
1035 assert!(make_response(200).is_success());
1036 assert!(make_response(201).is_success());
1037 assert!(make_response(299).is_success());
1038 assert!(!make_response(300).is_success());
1039 assert!(!make_response(400).is_success());
1040 assert!(!make_response(500).is_success());
1041 }
1042
1043 #[test]
1044 fn test_http_response_get_header() {
1045 let mut headers = HashMap::new();
1046 headers.insert("Content-Type".to_string(), "application/json".to_string());
1047 headers.insert("X-Custom".to_string(), "value".to_string());
1048
1049 let response = HttpResponse {
1050 status: 200,
1051 headers,
1052 body: vec![],
1053 };
1054
1055 assert_eq!(
1057 response.get_header("content-type"),
1058 Some("application/json")
1059 );
1060 assert_eq!(
1061 response.get_header("Content-Type"),
1062 Some("application/json")
1063 );
1064 assert_eq!(
1065 response.get_header("CONTENT-TYPE"),
1066 Some("application/json")
1067 );
1068 assert_eq!(response.get_header("x-custom"), Some("value"));
1069 assert!(response.get_header("nonexistent").is_none());
1070 }
1071
1072 #[test]
1073 fn test_http_response_detect_content_type() {
1074 let make_response = |ct: &str| {
1075 let mut headers = HashMap::new();
1076 headers.insert("Content-Type".to_string(), ct.to_string());
1077 HttpResponse {
1078 status: 200,
1079 headers,
1080 body: vec![],
1081 }
1082 };
1083
1084 assert_eq!(
1085 make_response("application/json").detect_content_type(),
1086 ContentType::Json
1087 );
1088 assert_eq!(
1089 make_response("application/json; charset=utf-8").detect_content_type(),
1090 ContentType::Json
1091 );
1092 assert_eq!(
1093 make_response("text/csv").detect_content_type(),
1094 ContentType::Csv
1095 );
1096 assert_eq!(
1097 make_response("application/octet-stream").detect_content_type(),
1098 ContentType::Binary
1099 );
1100 }
1101
1102 #[test]
1103 fn test_noop_http_client() {
1104 let client = NoopHttpClient;
1105 let req = HttpRequest::get("http://test.com");
1106 let result = client.request(req);
1107 assert!(matches!(result, Err(PachaError::ConnectionError(_))));
1108 }
1109
1110 #[test]
1115 fn test_retry_config_default() {
1116 let config = RetryConfig::default();
1117 assert_eq!(config.max_attempts, 3);
1118 assert_eq!(config.initial_delay_ms, 500);
1119 assert_eq!(config.max_delay_ms, 10_000);
1120 assert_eq!(config.backoff_multiplier, 2.0);
1121 }
1122
1123 #[test]
1124 fn test_retry_config_delay_for_attempt() {
1125 let config = RetryConfig::default();
1126
1127 assert_eq!(config.delay_for_attempt(0), 0);
1128 assert_eq!(config.delay_for_attempt(1), 500); assert_eq!(config.delay_for_attempt(2), 1000); assert_eq!(config.delay_for_attempt(3), 2000); }
1132
1133 #[test]
1134 fn test_retry_config_delay_capped() {
1135 let config = RetryConfig {
1136 max_delay_ms: 1000,
1137 ..RetryConfig::default()
1138 };
1139
1140 assert_eq!(config.delay_for_attempt(5), 1000);
1142 }
1143
1144 #[test]
1145 fn test_retry_config_should_retry() {
1146 let config = RetryConfig {
1147 max_attempts: 3,
1148 ..RetryConfig::default()
1149 };
1150
1151 assert!(config.should_retry(0));
1152 assert!(config.should_retry(1));
1153 assert!(config.should_retry(2));
1154 assert!(!config.should_retry(3));
1155 assert!(!config.should_retry(4));
1156 }
1157
1158 struct MockHttpClient {
1163 response: HttpResponse,
1164 }
1165
1166 impl HttpClient for MockHttpClient {
1167 fn request(&self, _req: HttpRequest) -> Result<HttpResponse, PachaError> {
1168 Ok(self.response.clone())
1169 }
1170 }
1171
1172 #[test]
1173 fn test_remote_loader_new() {
1174 let loader = RemotePachaLoader::new(NoopHttpClient);
1175 assert!(loader.cache.is_empty());
1176 }
1177
1178 #[test]
1179 fn test_remote_loader_with_retry() {
1180 let config = RetryConfig {
1181 max_attempts: 5,
1182 ..RetryConfig::default()
1183 };
1184 let loader = RemotePachaLoader::new(NoopHttpClient).with_retry(config);
1185 assert_eq!(loader.retry_config.max_attempts, 5);
1186 }
1187
1188 #[test]
1189 fn test_remote_loader_with_cache_ttl() {
1190 let loader = RemotePachaLoader::new(NoopHttpClient).with_cache_ttl(60_000);
1191 assert_eq!(loader.cache_ttl_ms, Some(60_000));
1192 }
1193
1194 #[test]
1195 fn test_remote_loader_load_fresh_local_fails() {
1196 let loader = RemotePachaLoader::new(NoopHttpClient);
1197 let result = loader.load_fresh("pacha://data/local");
1198 assert!(matches!(result, Err(PachaError::ConnectionError(_))));
1199 }
1200
1201 #[test]
1202 fn test_remote_loader_load_success() {
1203 let client = MockHttpClient {
1204 response: HttpResponse {
1205 status: 200,
1206 headers: {
1207 let mut h = HashMap::new();
1208 h.insert("Content-Type".to_string(), "application/json".to_string());
1209 h
1210 },
1211 body: b"{}".to_vec(),
1212 },
1213 };
1214
1215 let loader = RemotePachaLoader::new(client);
1216 let result = loader.load_fresh("pacha://remote.server.com:8080/api/data");
1217 assert!(result.is_ok());
1218
1219 let resource = result.unwrap();
1220 assert_eq!(resource.data, b"{}");
1221 assert_eq!(resource.content_type, ContentType::Json);
1222 }
1223
1224 #[test]
1225 fn test_remote_loader_caching() {
1226 let client = MockHttpClient {
1227 response: HttpResponse {
1228 status: 200,
1229 headers: HashMap::new(),
1230 body: b"test".to_vec(),
1231 },
1232 };
1233
1234 let mut loader = RemotePachaLoader::new(client);
1235
1236 let result = loader.load("pacha://server.com:8080/data");
1238 assert!(result.is_ok());
1239 assert!(!loader.cache.is_empty());
1240
1241 loader.clear_cache();
1243 assert!(loader.cache.is_empty());
1244 }
1245
1246 #[test]
1247 fn test_build_http_url() {
1248 let loader = RemotePachaLoader::new(NoopHttpClient);
1249
1250 let uri1 = PachaUri::parse("pacha://server.com:8080/api/data").unwrap();
1251 assert_eq!(
1252 loader.build_http_url(&uri1),
1253 "https://server.com:8080/api/data"
1254 );
1255
1256 let uri2 = PachaUri::parse("pacha://server.com/data?limit=10").unwrap();
1257 assert!(loader.build_http_url(&uri2).contains("limit=10"));
1258 }
1259}