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