Skip to main content

edgefirst_client/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4use crate::Progress;
5use tokio::sync::{AcquireError, watch};
6
7/// Comprehensive error type for EdgeFirst Studio Client operations.
8///
9/// This enum covers all possible error conditions that can occur when using
10/// the EdgeFirst Studio Client, from network issues to authentication problems
11/// and data validation errors.
12#[derive(Debug)]
13pub enum Error {
14    /// An I/O error occurred during file operations.
15    IoError(std::io::Error),
16    /// Configuration parsing or loading error.
17    ConfigError(config::ConfigError),
18    /// JSON serialization or deserialization error.
19    JsonError(serde_json::Error),
20    /// HTTP request error from the reqwest client.
21    HttpError(reqwest::Error),
22    /// Maximum number of retries exceeded for an operation.
23    MaxRetriesExceeded(u32),
24    /// URL parsing error.
25    UrlParseError(url::ParseError),
26    /// RPC error with error code and message from the server.
27    RpcError(i32, String),
28    /// Invalid RPC request ID format.
29    InvalidRpcId(String),
30    /// Environment variable error.
31    EnvError(std::env::VarError),
32    /// Semaphore acquisition error for concurrent operations.
33    SemaphoreError(AcquireError),
34    /// Async task join error.
35    JoinError(tokio::task::JoinError),
36    /// Error sending progress updates.
37    ProgressSendError(watch::error::SendError<Progress>),
38    /// Error receiving progress updates.
39    ProgressRecvError(watch::error::RecvError),
40    /// Path prefix stripping error.
41    StripPrefixError(std::path::StripPrefixError),
42    /// Integer parsing error.
43    ParseIntError(std::num::ParseIntError),
44    /// Server returned an invalid or unexpected response.
45    InvalidResponse,
46    /// Requested functionality is not yet implemented.
47    NotImplemented,
48    /// File part size exceeds the maximum allowed limit.
49    PartTooLarge,
50    /// Invalid file type provided.
51    InvalidFileType(String),
52    /// Invalid annotation type provided.
53    InvalidAnnotationType(String),
54    /// Unsupported file format.
55    UnsupportedFormat(String),
56    /// Required image files are missing from the dataset.
57    MissingImages(String),
58    /// Required annotation files are missing from the dataset.
59    MissingAnnotations(String),
60    /// Referenced label is missing or not found.
61    MissingLabel(String),
62    /// Invalid parameters provided to an operation.
63    InvalidParameters(String),
64    /// Attempted to use a feature that is not enabled.
65    FeatureNotEnabled(String),
66    /// Authentication token is empty or not provided.
67    EmptyToken,
68    /// Authentication token format is invalid.
69    InvalidToken,
70    /// Authentication token has expired.
71    TokenExpired,
72    /// User is not authorized to perform the requested operation.
73    Unauthorized,
74    /// Invalid or missing ETag header in HTTP response.
75    InvalidEtag(String),
76    /// Token storage operation error.
77    StorageError(String),
78    /// Polars dataframe operation error (only with "polars" feature).
79    #[cfg(feature = "polars")]
80    PolarsError(polars::error::PolarsError),
81    /// COCO format parsing or validation error.
82    CocoError(String),
83    /// ZIP archive read/write error.
84    ZipError(String),
85    /// Server reported the addressed task does not exist.
86    TaskNotFound(crate::api::TaskID),
87    /// Server rejected the call for authorization reasons.
88    /// String identifies the operation that was denied (e.g., `"task.chart.add"`).
89    PermissionDenied(String),
90    /// Server rejected the payload as too large.
91    /// `method` identifies the RPC method; `size_hint` is the body size
92    /// if the client could compute it pre-send.
93    PayloadTooLarge {
94        method: String,
95        size_hint: Option<u64>,
96    },
97    /// Refusing to point the client at a non-loopback `http://` URL.
98    /// Studio bearer tokens ride in the `Authorization` header, and plain
99    /// HTTP would leak them in the clear. Loopback URLs (`127.0.0.1`,
100    /// `::1`, `localhost`) are permitted because traffic never leaves
101    /// the machine — that's how wiremock and local dev servers connect.
102    InsecureUrl(String),
103}
104
105impl From<std::io::Error> for Error {
106    fn from(err: std::io::Error) -> Self {
107        Error::IoError(err)
108    }
109}
110
111impl From<config::ConfigError> for Error {
112    fn from(err: config::ConfigError) -> Self {
113        Error::ConfigError(err)
114    }
115}
116
117impl From<serde_json::Error> for Error {
118    fn from(err: serde_json::Error) -> Self {
119        Error::JsonError(err)
120    }
121}
122
123impl From<reqwest::Error> for Error {
124    fn from(err: reqwest::Error) -> Self {
125        Error::HttpError(err)
126    }
127}
128
129impl From<url::ParseError> for Error {
130    fn from(err: url::ParseError) -> Self {
131        Error::UrlParseError(err)
132    }
133}
134
135impl From<std::env::VarError> for Error {
136    fn from(err: std::env::VarError) -> Self {
137        Error::EnvError(err)
138    }
139}
140
141impl From<AcquireError> for Error {
142    fn from(err: AcquireError) -> Self {
143        Error::SemaphoreError(err)
144    }
145}
146
147impl From<tokio::task::JoinError> for Error {
148    fn from(err: tokio::task::JoinError) -> Self {
149        Error::JoinError(err)
150    }
151}
152
153impl From<watch::error::SendError<Progress>> for Error {
154    fn from(err: watch::error::SendError<Progress>) -> Self {
155        Error::ProgressSendError(err)
156    }
157}
158
159impl From<watch::error::RecvError> for Error {
160    fn from(err: watch::error::RecvError) -> Self {
161        Error::ProgressRecvError(err)
162    }
163}
164
165impl From<std::path::StripPrefixError> for Error {
166    fn from(err: std::path::StripPrefixError) -> Self {
167        Error::StripPrefixError(err)
168    }
169}
170
171impl From<std::num::ParseIntError> for Error {
172    fn from(err: std::num::ParseIntError) -> Self {
173        Error::ParseIntError(err)
174    }
175}
176
177impl From<crate::storage::StorageError> for Error {
178    fn from(err: crate::storage::StorageError) -> Self {
179        Error::StorageError(err.to_string())
180    }
181}
182
183#[cfg(feature = "polars")]
184impl From<polars::error::PolarsError> for Error {
185    fn from(err: polars::error::PolarsError) -> Self {
186        Error::PolarsError(err)
187    }
188}
189
190impl From<zip::result::ZipError> for Error {
191    fn from(err: zip::result::ZipError) -> Self {
192        Error::ZipError(err.to_string())
193    }
194}
195
196impl std::fmt::Display for Error {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            Error::IoError(e) => write!(f, "I/O error: {}", e),
200            Error::ConfigError(e) => write!(f, "Configuration error: {}", e),
201            Error::JsonError(e) => write!(f, "JSON error: {}", e),
202            Error::HttpError(e) => write!(f, "HTTP error: {}", e),
203            Error::MaxRetriesExceeded(n) => write!(f, "Maximum retries ({}) exceeded", n),
204            Error::UrlParseError(e) => write!(f, "URL parse error: {}", e),
205            Error::RpcError(code, msg) => write!(f, "RPC error {}: {}", code, msg),
206            Error::InvalidRpcId(id) => write!(f, "Invalid RPC ID: {}", id),
207            Error::EnvError(e) => write!(f, "Environment variable error: {}", e),
208            Error::SemaphoreError(e) => write!(f, "Semaphore error: {}", e),
209            Error::JoinError(e) => write!(f, "Task join error: {}", e),
210            Error::ProgressSendError(e) => write!(f, "Progress send error: {}", e),
211            Error::ProgressRecvError(e) => write!(f, "Progress receive error: {}", e),
212            Error::StripPrefixError(e) => write!(f, "Path prefix error: {}", e),
213            Error::ParseIntError(e) => write!(f, "Integer parse error: {}", e),
214            Error::InvalidResponse => write!(f, "Invalid server response"),
215            Error::NotImplemented => write!(f, "Not implemented"),
216            Error::PartTooLarge => write!(f, "File part size exceeds maximum limit"),
217            Error::InvalidFileType(s) => write!(f, "Invalid file type: {}", s),
218            Error::InvalidAnnotationType(s) => write!(f, "Invalid annotation type: {}", s),
219            Error::UnsupportedFormat(s) => write!(f, "Unsupported format: {}", s),
220            Error::MissingImages(s) => write!(f, "Missing images: {}", s),
221            Error::MissingAnnotations(s) => write!(f, "Missing annotations: {}", s),
222            Error::MissingLabel(s) => write!(f, "Missing label: {}", s),
223            Error::InvalidParameters(s) => write!(f, "Invalid parameters: {}", s),
224            Error::FeatureNotEnabled(s) => write!(f, "Feature not enabled: {}", s),
225            Error::EmptyToken => write!(f, "Authentication token is empty"),
226            Error::InvalidToken => write!(f, "Invalid authentication token"),
227            Error::TokenExpired => write!(f, "Authentication token has expired"),
228            Error::Unauthorized => write!(f, "Unauthorized access"),
229            Error::InvalidEtag(s) => write!(f, "Invalid ETag header: {}", s),
230            Error::StorageError(s) => write!(f, "Token storage error: {}", s),
231            #[cfg(feature = "polars")]
232            Error::PolarsError(e) => write!(f, "Polars error: {}", e),
233            Error::CocoError(s) => write!(f, "COCO format error: {}", s),
234            Error::ZipError(s) => write!(f, "ZIP error: {}", s),
235            Error::TaskNotFound(id) => write!(f, "task not found: {}", id),
236            Error::PermissionDenied(op) => write!(f, "permission denied: {}", op),
237            Error::PayloadTooLarge { method, .. } => write!(f, "payload too large for {}", method),
238            Error::InsecureUrl(url) => write!(
239                f,
240                "refusing insecure URL '{}': Studio bearer tokens require HTTPS \
241                 (loopback http is allowed for tests/dev)",
242                url
243            ),
244        }
245    }
246}
247
248impl std::error::Error for Error {
249    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
250        match self {
251            Error::IoError(e) => Some(e),
252            Error::ConfigError(e) => Some(e),
253            Error::JsonError(e) => Some(e),
254            Error::HttpError(e) => Some(e),
255            Error::UrlParseError(e) => Some(e),
256            Error::EnvError(e) => Some(e),
257            Error::JoinError(e) => Some(e),
258            Error::StripPrefixError(e) => Some(e),
259            Error::ParseIntError(e) => Some(e),
260            #[cfg(feature = "polars")]
261            Error::PolarsError(e) => Some(e),
262            _ => None,
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::path::Path;
271
272    // Tests for wrapped error types - follow the pattern:
273    // 1. Create inner error
274    // 2. Capture inner error string
275    // 3. Wrap to custom Error type
276    // 4. Capture wrapped error string
277    // 5. Verify inner string is substring of wrapped string
278
279    #[test]
280    fn test_io_error_wrapping() {
281        // 1. Create inner error
282        let inner_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
283        // 2. Capture inner error string
284        let inner_str = inner_err.to_string();
285        // 3. Wrap to custom Error type
286        let wrapped_err: Error = inner_err.into();
287        // 4. Capture wrapped error string
288        let wrapped_str = wrapped_err.to_string();
289        // 5. Verify inner string is substring of wrapped string
290        assert!(
291            wrapped_str.contains(&inner_str),
292            "Wrapped error '{}' should contain inner error '{}'",
293            wrapped_str,
294            inner_str
295        );
296        assert!(wrapped_str.starts_with("I/O error: "));
297    }
298
299    #[test]
300    fn test_config_error_wrapping() {
301        // 1. Create inner error - Force a config error by trying to deserialize empty
302        //    config to a required struct
303        #[derive(Debug, serde::Deserialize)]
304        #[allow(dead_code)]
305        struct RequiredField {
306            required: String,
307        }
308
309        let inner_err = config::Config::builder()
310            .build()
311            .unwrap()
312            .try_deserialize::<RequiredField>()
313            .unwrap_err();
314        // 2. Capture inner error string
315        let inner_str = inner_err.to_string();
316        // 3. Wrap to custom Error type
317        let wrapped_err: Error = inner_err.into();
318        // 4. Capture wrapped error string
319        let wrapped_str = wrapped_err.to_string();
320        // 5. Verify inner string is substring of wrapped string
321        assert!(
322            wrapped_str.contains(&inner_str),
323            "Wrapped error '{}' should contain inner error '{}'",
324            wrapped_str,
325            inner_str
326        );
327        assert!(wrapped_str.starts_with("Configuration error: "));
328    }
329
330    #[test]
331    fn test_json_error_wrapping() {
332        // 1. Create inner error - invalid JSON
333        let inner_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
334        // 2. Capture inner error string
335        let inner_str = inner_err.to_string();
336        // 3. Wrap to custom Error type
337        let wrapped_err: Error = inner_err.into();
338        // 4. Capture wrapped error string
339        let wrapped_str = wrapped_err.to_string();
340        // 5. Verify inner string is substring of wrapped string
341        assert!(
342            wrapped_str.contains(&inner_str),
343            "Wrapped error '{}' should contain inner error '{}'",
344            wrapped_str,
345            inner_str
346        );
347        assert!(wrapped_str.starts_with("JSON error: "));
348    }
349
350    #[test]
351    fn test_url_parse_error_wrapping() {
352        // 1. Create inner error - invalid URL
353        let inner_err = url::Url::parse("not a valid url").unwrap_err();
354        // 2. Capture inner error string
355        let inner_str = inner_err.to_string();
356        // 3. Wrap to custom Error type
357        let wrapped_err: Error = inner_err.into();
358        // 4. Capture wrapped error string
359        let wrapped_str = wrapped_err.to_string();
360        // 5. Verify inner string is substring of wrapped string
361        assert!(
362            wrapped_str.contains(&inner_str),
363            "Wrapped error '{}' should contain inner error '{}'",
364            wrapped_str,
365            inner_str
366        );
367        assert!(wrapped_str.starts_with("URL parse error: "));
368    }
369
370    #[test]
371    fn test_env_error_wrapping() {
372        // 1. Create inner error - missing environment variable
373        let inner_err = std::env::var("NONEXISTENT_VAR_12345").unwrap_err();
374        // 2. Capture inner error string
375        let inner_str = inner_err.to_string();
376        // 3. Wrap to custom Error type
377        let wrapped_err: Error = inner_err.into();
378        // 4. Capture wrapped error string
379        let wrapped_str = wrapped_err.to_string();
380        // 5. Verify inner string is substring of wrapped string
381        assert!(
382            wrapped_str.contains(&inner_str),
383            "Wrapped error '{}' should contain inner error '{}'",
384            wrapped_str,
385            inner_str
386        );
387        assert!(wrapped_str.starts_with("Environment variable error: "));
388    }
389
390    #[test]
391    fn test_strip_prefix_error_wrapping() {
392        // 1. Create inner error - strip non-existent prefix
393        let path = Path::new("/foo/bar");
394        let prefix = Path::new("/baz");
395        let inner_err = path.strip_prefix(prefix).unwrap_err();
396        // 2. Capture inner error string
397        let inner_str = inner_err.to_string();
398        // 3. Wrap to custom Error type
399        let wrapped_err: Error = inner_err.into();
400        // 4. Capture wrapped error string
401        let wrapped_str = wrapped_err.to_string();
402        // 5. Verify inner string is substring of wrapped string
403        assert!(
404            wrapped_str.contains(&inner_str),
405            "Wrapped error '{}' should contain inner error '{}'",
406            wrapped_str,
407            inner_str
408        );
409        assert!(wrapped_str.starts_with("Path prefix error: "));
410    }
411
412    #[test]
413    fn test_parse_int_error_wrapping() {
414        // 1. Create inner error - invalid integer string
415        let inner_err = "not a number".parse::<i32>().unwrap_err();
416        // 2. Capture inner error string
417        let inner_str = inner_err.to_string();
418        // 3. Wrap to custom Error type
419        let wrapped_err: Error = inner_err.into();
420        // 4. Capture wrapped error string
421        let wrapped_str = wrapped_err.to_string();
422        // 5. Verify inner string is substring of wrapped string
423        assert!(
424            wrapped_str.contains(&inner_str),
425            "Wrapped error '{}' should contain inner error '{}'",
426            wrapped_str,
427            inner_str
428        );
429        assert!(wrapped_str.starts_with("Integer parse error: "));
430    }
431
432    #[cfg(feature = "polars")]
433    #[test]
434    fn test_polars_error_wrapping() {
435        // 1. Create inner error - duplicate column names cause an error
436        use polars::prelude::*;
437        let inner_err = DataFrame::new_infer_height(vec![
438            Series::new("a".into(), &[1, 2, 3]).into(),
439            Series::new("a".into(), &[4, 5, 6]).into(),
440        ])
441        .unwrap_err();
442        // 2. Capture inner error string
443        let inner_str = inner_err.to_string();
444        // 3. Wrap to custom Error type
445        let wrapped_err: Error = inner_err.into();
446        // 4. Capture wrapped error string
447        let wrapped_str = wrapped_err.to_string();
448        // 5. Verify inner string is substring of wrapped string
449        assert!(
450            wrapped_str.contains(&inner_str),
451            "Wrapped error '{}' should contain inner error '{}'",
452            wrapped_str,
453            inner_str
454        );
455        assert!(wrapped_str.starts_with("Polars error: "));
456    }
457
458    // Tests for wrapped primitive types - follow the pattern:
459    // 1. Create random primitive value
460    // 2. Capture the primitive as string
461    // 3. Wrap to custom Error type
462    // 4. Capture wrapped error string
463    // 5. Verify primitive string is substring of wrapped string
464
465    #[test]
466    fn test_max_retries_exceeded() {
467        // 1. Create primitive value
468        let retry_count = 42u32;
469        // 2. Capture primitive as string
470        let primitive_str = retry_count.to_string();
471        // 3. Wrap to custom Error type
472        let wrapped_err = Error::MaxRetriesExceeded(retry_count);
473        // 4. Capture wrapped error string
474        let wrapped_str = wrapped_err.to_string();
475        // 5. Verify primitive string is substring of wrapped string
476        assert!(
477            wrapped_str.contains(&primitive_str),
478            "Wrapped error '{}' should contain retry count '{}'",
479            wrapped_str,
480            primitive_str
481        );
482        assert!(wrapped_str.starts_with("Maximum retries"));
483    }
484
485    #[test]
486    fn test_rpc_error() {
487        // 1. Create primitive values
488        let error_code = -32600;
489        let error_msg = "Invalid Request";
490        // 2. Capture primitives as strings
491        let code_str = error_code.to_string();
492        // 3. Wrap to custom Error type
493        let wrapped_err = Error::RpcError(error_code, error_msg.to_string());
494        // 4. Capture wrapped error string
495        let wrapped_str = wrapped_err.to_string();
496        // 5. Verify primitive strings are substrings of wrapped string
497        assert!(
498            wrapped_str.contains(&code_str),
499            "Wrapped error '{}' should contain error code '{}'",
500            wrapped_str,
501            code_str
502        );
503        assert!(
504            wrapped_str.contains(error_msg),
505            "Wrapped error '{}' should contain error message '{}'",
506            wrapped_str,
507            error_msg
508        );
509        assert!(wrapped_str.starts_with("RPC error"));
510    }
511
512    #[test]
513    fn test_invalid_rpc_id() {
514        // 1. Create primitive value
515        let invalid_id = "not-a-valid-id-123";
516        // 2. Capture primitive as string (already a string)
517        // 3. Wrap to custom Error type
518        let wrapped_err = Error::InvalidRpcId(invalid_id.to_string());
519        // 4. Capture wrapped error string
520        let wrapped_str = wrapped_err.to_string();
521        // 5. Verify primitive string is substring of wrapped string
522        assert!(
523            wrapped_str.contains(invalid_id),
524            "Wrapped error '{}' should contain invalid ID '{}'",
525            wrapped_str,
526            invalid_id
527        );
528        assert!(wrapped_str.starts_with("Invalid RPC ID: "));
529    }
530
531    #[test]
532    fn test_invalid_file_type() {
533        // 1. Create primitive value
534        let file_type = "unknown_format";
535        // 2. Capture primitive as string (already a string)
536        // 3. Wrap to custom Error type
537        let wrapped_err = Error::InvalidFileType(file_type.to_string());
538        // 4. Capture wrapped error string
539        let wrapped_str = wrapped_err.to_string();
540        // 5. Verify primitive string is substring of wrapped string
541        assert!(
542            wrapped_str.contains(file_type),
543            "Wrapped error '{}' should contain file type '{}'",
544            wrapped_str,
545            file_type
546        );
547        assert!(wrapped_str.starts_with("Invalid file type: "));
548    }
549
550    #[test]
551    fn test_invalid_annotation_type() {
552        // 1. Create primitive value
553        let annotation_type = "unsupported_annotation";
554        // 2. Capture primitive as string (already a string)
555        // 3. Wrap to custom Error type
556        let wrapped_err = Error::InvalidAnnotationType(annotation_type.to_string());
557        // 4. Capture wrapped error string
558        let wrapped_str = wrapped_err.to_string();
559        // 5. Verify primitive string is substring of wrapped string
560        assert!(
561            wrapped_str.contains(annotation_type),
562            "Wrapped error '{}' should contain annotation type '{}'",
563            wrapped_str,
564            annotation_type
565        );
566        assert!(wrapped_str.starts_with("Invalid annotation type: "));
567    }
568
569    #[test]
570    fn test_unsupported_format() {
571        // 1. Create primitive value
572        let format = "xyz_format";
573        // 2. Capture primitive as string (already a string)
574        // 3. Wrap to custom Error type
575        let wrapped_err = Error::UnsupportedFormat(format.to_string());
576        // 4. Capture wrapped error string
577        let wrapped_str = wrapped_err.to_string();
578        // 5. Verify primitive string is substring of wrapped string
579        assert!(
580            wrapped_str.contains(format),
581            "Wrapped error '{}' should contain format '{}'",
582            wrapped_str,
583            format
584        );
585        assert!(wrapped_str.starts_with("Unsupported format: "));
586    }
587
588    #[test]
589    fn test_missing_images() {
590        // 1. Create primitive value
591        let details = "image001.jpg, image002.jpg";
592        // 2. Capture primitive as string (already a string)
593        // 3. Wrap to custom Error type
594        let wrapped_err = Error::MissingImages(details.to_string());
595        // 4. Capture wrapped error string
596        let wrapped_str = wrapped_err.to_string();
597        // 5. Verify primitive string is substring of wrapped string
598        assert!(
599            wrapped_str.contains(details),
600            "Wrapped error '{}' should contain details '{}'",
601            wrapped_str,
602            details
603        );
604        assert!(wrapped_str.starts_with("Missing images: "));
605    }
606
607    #[test]
608    fn test_missing_annotations() {
609        // 1. Create primitive value
610        let details = "annotations.json";
611        // 2. Capture primitive as string (already a string)
612        // 3. Wrap to custom Error type
613        let wrapped_err = Error::MissingAnnotations(details.to_string());
614        // 4. Capture wrapped error string
615        let wrapped_str = wrapped_err.to_string();
616        // 5. Verify primitive string is substring of wrapped string
617        assert!(
618            wrapped_str.contains(details),
619            "Wrapped error '{}' should contain details '{}'",
620            wrapped_str,
621            details
622        );
623        assert!(wrapped_str.starts_with("Missing annotations: "));
624    }
625
626    #[test]
627    fn test_missing_label() {
628        // 1. Create primitive value
629        let label = "person";
630        // 2. Capture primitive as string (already a string)
631        // 3. Wrap to custom Error type
632        let wrapped_err = Error::MissingLabel(label.to_string());
633        // 4. Capture wrapped error string
634        let wrapped_str = wrapped_err.to_string();
635        // 5. Verify primitive string is substring of wrapped string
636        assert!(
637            wrapped_str.contains(label),
638            "Wrapped error '{}' should contain label '{}'",
639            wrapped_str,
640            label
641        );
642        assert!(wrapped_str.starts_with("Missing label: "));
643    }
644
645    #[test]
646    fn test_invalid_parameters() {
647        // 1. Create primitive value
648        let params = "batch_size must be positive";
649        // 2. Capture primitive as string (already a string)
650        // 3. Wrap to custom Error type
651        let wrapped_err = Error::InvalidParameters(params.to_string());
652        // 4. Capture wrapped error string
653        let wrapped_str = wrapped_err.to_string();
654        // 5. Verify primitive string is substring of wrapped string
655        assert!(
656            wrapped_str.contains(params),
657            "Wrapped error '{}' should contain params '{}'",
658            wrapped_str,
659            params
660        );
661        assert!(wrapped_str.starts_with("Invalid parameters: "));
662    }
663
664    #[test]
665    fn test_feature_not_enabled() {
666        // 1. Create primitive value
667        let feature = "polars";
668        // 2. Capture primitive as string (already a string)
669        // 3. Wrap to custom Error type
670        let wrapped_err = Error::FeatureNotEnabled(feature.to_string());
671        // 4. Capture wrapped error string
672        let wrapped_str = wrapped_err.to_string();
673        // 5. Verify primitive string is substring of wrapped string
674        assert!(
675            wrapped_str.contains(feature),
676            "Wrapped error '{}' should contain feature '{}'",
677            wrapped_str,
678            feature
679        );
680        assert!(wrapped_str.starts_with("Feature not enabled: "));
681    }
682
683    #[test]
684    fn test_invalid_etag() {
685        // 1. Create primitive value
686        let etag = "malformed-etag-value";
687        // 2. Capture primitive as string (already a string)
688        // 3. Wrap to custom Error type
689        let wrapped_err = Error::InvalidEtag(etag.to_string());
690        // 4. Capture wrapped error string
691        let wrapped_str = wrapped_err.to_string();
692        // 5. Verify primitive string is substring of wrapped string
693        assert!(
694            wrapped_str.contains(etag),
695            "Wrapped error '{}' should contain etag '{}'",
696            wrapped_str,
697            etag
698        );
699        assert!(wrapped_str.starts_with("Invalid ETag header: "));
700    }
701
702    // Tests for simple errors without wrapped content
703    // Just verify they can be created and displayed
704
705    #[test]
706    fn test_invalid_response() {
707        let err = Error::InvalidResponse;
708        let err_str = err.to_string();
709        assert_eq!(err_str, "Invalid server response");
710    }
711
712    #[test]
713    fn test_not_implemented() {
714        let err = Error::NotImplemented;
715        let err_str = err.to_string();
716        assert_eq!(err_str, "Not implemented");
717    }
718
719    #[test]
720    fn test_part_too_large() {
721        let err = Error::PartTooLarge;
722        let err_str = err.to_string();
723        assert_eq!(err_str, "File part size exceeds maximum limit");
724    }
725
726    #[test]
727    fn test_empty_token() {
728        let err = Error::EmptyToken;
729        let err_str = err.to_string();
730        assert_eq!(err_str, "Authentication token is empty");
731    }
732
733    #[test]
734    fn test_invalid_token() {
735        let err = Error::InvalidToken;
736        let err_str = err.to_string();
737        assert_eq!(err_str, "Invalid authentication token");
738    }
739
740    #[test]
741    fn test_token_expired() {
742        let err = Error::TokenExpired;
743        let err_str = err.to_string();
744        assert_eq!(err_str, "Authentication token has expired");
745    }
746
747    #[test]
748    fn test_unauthorized() {
749        let err = Error::Unauthorized;
750        let err_str = err.to_string();
751        assert_eq!(err_str, "Unauthorized access");
752    }
753
754    // ----------------------------------------------------------------------
755    // DE-2565 typed variants.
756    // ----------------------------------------------------------------------
757
758    #[test]
759    fn test_task_not_found_display_contains_id() {
760        // TaskID Displays as `task-{hex}`; 0x1092 == 4242.
761        let task_id = crate::api::TaskID::from(0x1092u64);
762        let err = Error::TaskNotFound(task_id);
763        let err_str = err.to_string();
764        assert!(
765            err_str.contains("task-1092"),
766            "Display should include the task ID prefix+hex, got: {err_str}"
767        );
768        assert!(err_str.starts_with("task not found"));
769    }
770
771    #[test]
772    fn test_permission_denied_display_contains_method() {
773        let err = Error::PermissionDenied("task.chart.add".to_string());
774        let err_str = err.to_string();
775        assert!(
776            err_str.contains("task.chart.add"),
777            "Display should include the method name, got: {err_str}"
778        );
779        assert!(err_str.starts_with("permission denied"));
780    }
781
782    #[test]
783    fn test_payload_too_large_display_contains_method() {
784        let err = Error::PayloadTooLarge {
785            method: "val.data.upload".to_string(),
786            size_hint: Some(123456),
787        };
788        let err_str = err.to_string();
789        assert!(
790            err_str.contains("val.data.upload"),
791            "Display should include the method name, got: {err_str}"
792        );
793        assert!(err_str.starts_with("payload too large"));
794    }
795
796    #[test]
797    fn test_payload_too_large_with_no_size_hint() {
798        // Size hint is optional; Display should still work when None.
799        let err = Error::PayloadTooLarge {
800            method: "task.data.upload".to_string(),
801            size_hint: None,
802        };
803        let err_str = err.to_string();
804        assert!(err_str.contains("task.data.upload"));
805    }
806
807    #[test]
808    fn test_typed_variants_have_no_source() {
809        // None of the DE-2565 typed variants wrap an inner std::error::Error,
810        // so source() should return None for them. This guards against
811        // accidentally wrapping them in something that does.
812        use std::error::Error as _;
813
814        let task_not_found = Error::TaskNotFound(crate::api::TaskID::from(1u64));
815        assert!(task_not_found.source().is_none());
816
817        let perm_denied = Error::PermissionDenied("op".into());
818        assert!(perm_denied.source().is_none());
819
820        let too_large = Error::PayloadTooLarge {
821            method: "m".into(),
822            size_hint: Some(1),
823        };
824        assert!(too_large.source().is_none());
825    }
826
827    // ----------------------------------------------------------------------
828    // Variants tracked under pre-existing tests but not covered for source().
829    // ----------------------------------------------------------------------
830
831    #[test]
832    fn test_coco_error_display() {
833        let err = Error::CocoError("missing categories array".into());
834        let err_str = err.to_string();
835        assert!(err_str.contains("missing categories array"));
836        assert!(err_str.starts_with("COCO format error:"));
837    }
838
839    #[test]
840    fn test_zip_error_display() {
841        let err = Error::ZipError("invalid central directory".into());
842        let err_str = err.to_string();
843        assert!(err_str.contains("invalid central directory"));
844        assert!(err_str.starts_with("ZIP error:"));
845    }
846
847    #[test]
848    fn test_storage_error_display() {
849        let err = Error::StorageError("keychain locked".into());
850        let err_str = err.to_string();
851        assert!(err_str.contains("keychain locked"));
852        assert!(err_str.starts_with("Token storage error:"));
853    }
854}