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