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