presentar_yaml/
pacha.rs

1//! Pacha protocol loader for data sources and models.
2//!
3//! Handles `pacha://` URIs for loading data and models from the Sovereign AI Stack.
4//!
5//! # URI Format
6//!
7//! ```text
8//! pacha://[host]/path/to/resource[?query]
9//!
10//! Examples:
11//! - pacha://data/metrics           - Local data file
12//! - pacha://models/classifier      - Local model file
13//! - pacha://localhost:8080/api/v1  - Local Pacha server
14//! ```
15
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19/// Pacha resource types.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ResourceType {
22    /// Data resource (.ald, .csv, .json)
23    Data,
24    /// Model resource (.apr)
25    Model,
26    /// API endpoint
27    Api,
28}
29
30/// Parsed Pacha URI.
31#[derive(Debug, Clone, PartialEq)]
32pub struct PachaUri {
33    /// Resource type
34    pub resource_type: ResourceType,
35    /// Host (if remote)
36    pub host: Option<String>,
37    /// Port (if specified)
38    pub port: Option<u16>,
39    /// Resource path
40    pub path: String,
41    /// Query parameters
42    pub query: HashMap<String, String>,
43}
44
45impl PachaUri {
46    /// Parse a pacha:// URI string.
47    ///
48    /// # Errors
49    ///
50    /// Returns error if the URI is malformed.
51    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..]; // Skip "pacha://"
57
58        // Split query string
59        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        // Check for host:port
68        let (host, port, path) = if path_part.contains(':') && !path_part.starts_with('/') {
69            // Has host:port
70            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            // No host, just path like "data/metrics"
87            (None, None, format!("/{}", path_part))
88        };
89
90        // Determine resource type from path
91        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    /// Check if this is a local resource.
109    #[must_use]
110    pub fn is_local(&self) -> bool {
111        self.host.is_none() || self.host.as_deref() == Some("localhost")
112    }
113
114    /// Check if this is a remote resource.
115    #[must_use]
116    pub fn is_remote(&self) -> bool {
117        !self.is_local()
118    }
119
120    /// Get the local file path for this resource.
121    ///
122    /// # Arguments
123    ///
124    /// * `base_dir` - Base directory for resolving relative paths
125    #[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/// Pacha protocol error.
145#[derive(Debug, Clone, PartialEq)]
146pub enum PachaError {
147    /// Invalid protocol (not pacha://)
148    InvalidProtocol(String),
149    /// Resource not found
150    NotFound(String),
151    /// Connection error
152    ConnectionError(String),
153    /// Parse error
154    ParseError(String),
155    /// IO error
156    IoError(String),
157    /// Unsupported format
158    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
176/// Pacha resource loader.
177pub struct PachaLoader {
178    /// Base directory for local resources
179    base_dir: PathBuf,
180    /// Cache of loaded resources
181    cache: HashMap<String, LoadedResource>,
182}
183
184/// A loaded resource.
185#[derive(Debug, Clone)]
186pub struct LoadedResource {
187    /// Resource URI
188    pub uri: String,
189    /// Raw data bytes
190    pub data: Vec<u8>,
191    /// Content type
192    pub content_type: ContentType,
193    /// Last modified timestamp
194    pub last_modified: Option<u64>,
195}
196
197/// Content type of loaded resource.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum ContentType {
200    /// Alimentar dataset
201    Ald,
202    /// Aprender model
203    Apr,
204    /// JSON data
205    Json,
206    /// CSV data
207    Csv,
208    /// Unknown/binary
209    Binary,
210}
211
212impl ContentType {
213    /// Detect content type from file extension.
214    #[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    /// Get file extension for content type.
226    #[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    /// Create a new loader with the given base directory.
240    #[must_use]
241    pub fn new(base_dir: PathBuf) -> Self {
242        Self {
243            base_dir,
244            cache: HashMap::new(),
245        }
246    }
247
248    /// Create a loader using current directory.
249    #[must_use]
250    pub fn current_dir() -> Self {
251        Self::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
252    }
253
254    /// Load a resource from a pacha:// URI.
255    ///
256    /// # Errors
257    ///
258    /// Returns error if the resource cannot be loaded.
259    pub fn load(&mut self, uri: &str) -> Result<&LoadedResource, PachaError> {
260        // Check cache first
261        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    /// Load without caching.
272    ///
273    /// # Errors
274    ///
275    /// Returns error if the resource cannot be loaded.
276    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        // Load from local filesystem
289        let path = parsed.to_local_path(&self.base_dir);
290
291        // Try with various extensions if no extension specified
292        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    /// Clear the resource cache.
335    pub fn clear_cache(&mut self) {
336        self.cache.clear();
337    }
338
339    /// Get a cached resource if available.
340    #[must_use]
341    pub fn get_cached(&self, uri: &str) -> Option<&LoadedResource> {
342        self.cache.get(uri)
343    }
344
345    /// Check if a resource is cached.
346    #[must_use]
347    pub fn is_cached(&self, uri: &str) -> bool {
348        self.cache.contains_key(uri)
349    }
350}
351
352// =============================================================================
353// HTTP Client Abstraction
354// =============================================================================
355
356/// HTTP request method.
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum HttpMethod {
359    /// GET request
360    Get,
361    /// POST request
362    Post,
363    /// PUT request
364    Put,
365    /// DELETE request
366    Delete,
367}
368
369impl HttpMethod {
370    /// Get method as string.
371    #[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/// HTTP request configuration.
383#[derive(Debug, Clone)]
384pub struct HttpRequest {
385    /// Request URL
386    pub url: String,
387    /// HTTP method
388    pub method: HttpMethod,
389    /// Request headers
390    pub headers: HashMap<String, String>,
391    /// Request body (for POST/PUT)
392    pub body: Option<Vec<u8>>,
393    /// Timeout in milliseconds
394    pub timeout_ms: Option<u64>,
395}
396
397impl HttpRequest {
398    /// Create a new GET request.
399    #[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    /// Create a new POST request.
411    #[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    /// Set a header.
423    #[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    /// Set the request body.
430    #[must_use]
431    pub fn with_body(mut self, body: Vec<u8>) -> Self {
432        self.body = Some(body);
433        self
434    }
435
436    /// Set timeout in milliseconds.
437    #[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/// HTTP response.
445#[derive(Debug, Clone)]
446pub struct HttpResponse {
447    /// HTTP status code
448    pub status: u16,
449    /// Response headers
450    pub headers: HashMap<String, String>,
451    /// Response body
452    pub body: Vec<u8>,
453}
454
455impl HttpResponse {
456    /// Check if the response is successful (2xx).
457    #[must_use]
458    pub const fn is_success(&self) -> bool {
459        self.status >= 200 && self.status < 300
460    }
461
462    /// Get a header value.
463    #[must_use]
464    pub fn get_header(&self, name: &str) -> Option<&str> {
465        // Case-insensitive header lookup
466        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    /// Get content type from headers.
474    #[must_use]
475    pub fn content_type(&self) -> Option<&str> {
476        self.get_header("content-type")
477    }
478
479    /// Detect ContentType from response.
480    #[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
494/// HTTP client trait for platform-specific implementations.
495pub trait HttpClient: Send + Sync {
496    /// Perform an HTTP request.
497    ///
498    /// # Errors
499    ///
500    /// Returns error if the request fails.
501    fn request(&self, req: HttpRequest) -> Result<HttpResponse, PachaError>;
502}
503
504/// A no-op HTTP client that always returns an error.
505/// Used as fallback when no real client is available.
506#[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/// Retry configuration for HTTP requests.
518#[derive(Debug, Clone)]
519pub struct RetryConfig {
520    /// Maximum number of retry attempts
521    pub max_attempts: u32,
522    /// Initial delay between retries (milliseconds)
523    pub initial_delay_ms: u64,
524    /// Maximum delay between retries (milliseconds)
525    pub max_delay_ms: u64,
526    /// Backoff multiplier
527    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    /// Calculate delay for a given attempt number.
543    #[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    /// Check if we should retry.
553    #[must_use]
554    pub const fn should_retry(&self, attempt: u32) -> bool {
555        attempt < self.max_attempts
556    }
557}
558
559/// Remote Pacha loader with HTTP support.
560pub struct RemotePachaLoader<C: HttpClient = NoopHttpClient> {
561    /// HTTP client
562    client: C,
563    /// Retry configuration
564    retry_config: RetryConfig,
565    /// Cache of loaded resources
566    cache: HashMap<String, LoadedResource>,
567    /// Cache TTL in milliseconds (None = no expiry)
568    cache_ttl_ms: Option<u64>,
569}
570
571impl<C: HttpClient> RemotePachaLoader<C> {
572    /// Create a new remote loader with the given client.
573    #[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    /// Set retry configuration.
584    #[must_use]
585    pub fn with_retry(mut self, config: RetryConfig) -> Self {
586        self.retry_config = config;
587        self
588    }
589
590    /// Set cache TTL in milliseconds.
591    #[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    /// Load a resource from a remote pacha:// URI.
598    ///
599    /// # Errors
600    ///
601    /// Returns error if the resource cannot be loaded.
602    pub fn load(&mut self, uri: &str) -> Result<&LoadedResource, PachaError> {
603        // Check cache first
604        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    /// Load a resource without caching.
614    ///
615    /// # Errors
616    ///
617    /// Returns error if the resource cannot be loaded.
618    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        // Build request with retry support
630        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                // Would sleep here in real impl, but we're sync
635                // In WASM, the caller would handle async retry
636            }
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, // Could parse from headers
650                    });
651                }
652                Ok(response) => {
653                    last_error =
654                        PachaError::ConnectionError(format!("HTTP {} error", response.status));
655                    // Don't retry 4xx errors
656                    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    /// Build HTTP URL from parsed Pacha URI.
670    fn build_http_url(&self, parsed: &PachaUri) -> String {
671        let scheme = "https"; // Default to HTTPS for remote
672        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    /// Clear the cache.
691    pub fn clear_cache(&mut self) {
692        self.cache.clear();
693    }
694}
695
696/// Parse refresh interval string to milliseconds.
697///
698/// Supports formats: "1s", "5m", "1h", "30s", "100ms"
699///
700/// # Errors
701///
702/// Returns None if the format is invalid.
703#[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    // Find where the number ends and unit begins
711    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, // Default to seconds
732        _ => return None,
733    };
734
735    Some(ms as u64)
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741
742    // =========================================================================
743    // PachaUri parsing tests
744    // =========================================================================
745
746    #[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()); // localhost is still local
777    }
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    // =========================================================================
807    // ResourceType tests
808    // =========================================================================
809
810    #[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    // =========================================================================
831    // ContentType tests
832    // =========================================================================
833
834    #[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    // =========================================================================
853    // Local path tests
854    // =========================================================================
855
856    #[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    // =========================================================================
871    // PachaLoader tests
872    // =========================================================================
873
874    #[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    // =========================================================================
895    // Refresh interval parsing tests
896    // =========================================================================
897
898    #[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)); // Default to seconds
903    }
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    // =========================================================================
937    // PachaError display tests
938    // =========================================================================
939
940    #[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    // =========================================================================
972    // HTTP Client tests
973    // =========================================================================
974
975    #[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        // Case-insensitive lookup
1056        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    // =========================================================================
1111    // RetryConfig tests
1112    // =========================================================================
1113
1114    #[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); // initial
1129        assert_eq!(config.delay_for_attempt(2), 1000); // 500 * 2
1130        assert_eq!(config.delay_for_attempt(3), 2000); // 500 * 4
1131    }
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        // Should be capped at max_delay_ms
1141        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    // =========================================================================
1159    // RemotePachaLoader tests
1160    // =========================================================================
1161
1162    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        // First load - use host:port format for remote
1237        let result = loader.load("pacha://server.com:8080/data");
1238        assert!(result.is_ok());
1239        assert!(!loader.cache.is_empty());
1240
1241        // Clear cache
1242        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}