Skip to main content

perfgate_error/
lib.rs

1//! Unified error types for the perfgate ecosystem.
2//!
3//! This crate provides a single, comprehensive error type that unifies all error
4//! variants from across the perfgate crates. It enables seamless error propagation
5//! and conversion between different error types.
6//!
7//! Part of the [perfgate](https://github.com/EffortlessMetrics/perfgate) workspace.
8//!
9//! # Error Categories
10//!
11//! The [`PerfgateError`] enum is organized into categories:
12//!
13//! - **Validation**: Bench name validation, config validation
14//! - **Stats**: Statistical computation errors (no samples)
15//! - **Adapter**: Process execution, I/O, platform-specific errors
16//! - **Config**: Configuration parsing and validation errors
17//! - **IO**: File system and network I/O errors
18//! - **Paired**: Paired benchmark errors
19//! - **Auth**: Authentication and authorization errors
20//!
21//! # Example
22//!
23//! ```
24//! use perfgate_error::{PerfgateError, ValidationError};
25//!
26//! fn validate_name(name: &str) -> Result<(), PerfgateError> {
27//!     if name.is_empty() {
28//!         return Err(ValidationError::Empty.into());
29//!     }
30//!     Ok(())
31//! }
32//!
33//! let err = validate_name("").unwrap_err();
34//! assert!(matches!(err, PerfgateError::Validation(ValidationError::Empty)));
35//! ```
36
37use std::fmt;
38
39pub const BENCH_NAME_MAX_LEN: usize = 64;
40pub const BENCH_NAME_PATTERN: &str = r"^[a-z0-9_.\-/]+$";
41
42#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43pub enum ValidationError {
44    #[error("bench name must not be empty")]
45    Empty,
46
47    #[error("bench name {name:?} exceeds maximum length of {max_len} characters")]
48    TooLong { name: String, max_len: usize },
49
50    #[error(
51        "bench name {name:?} contains invalid characters; \
52         allowed: lowercase alphanumeric, dots, underscores, hyphens, slashes"
53    )]
54    InvalidCharacters { name: String },
55
56    #[error(
57        "bench name {name:?} contains an empty path segment \
58         (leading, trailing, or consecutive slashes are forbidden)"
59    )]
60    EmptySegment { name: String },
61
62    #[error(
63        "bench name {name:?} contains a {segment:?} path segment (path traversal is forbidden)"
64    )]
65    PathTraversal { name: String, segment: String },
66}
67
68impl ValidationError {
69    pub fn name(&self) -> &str {
70        match self {
71            ValidationError::Empty => "",
72            ValidationError::TooLong { name, .. } => name,
73            ValidationError::InvalidCharacters { name } => name,
74            ValidationError::EmptySegment { name } => name,
75            ValidationError::PathTraversal { name, .. } => name,
76        }
77    }
78}
79
80pub fn validate_bench_name(name: &str) -> std::result::Result<(), ValidationError> {
81    if name.is_empty() {
82        return Err(ValidationError::Empty);
83    }
84    if name.len() > BENCH_NAME_MAX_LEN {
85        return Err(ValidationError::TooLong {
86            name: name.to_string(),
87            max_len: BENCH_NAME_MAX_LEN,
88        });
89    }
90    if !name.chars().all(|c| {
91        c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.' || c == '/' || c == '-'
92    }) {
93        return Err(ValidationError::InvalidCharacters {
94            name: name.to_string(),
95        });
96    }
97    for segment in name.split('/') {
98        if segment.is_empty() {
99            return Err(ValidationError::EmptySegment {
100                name: name.to_string(),
101            });
102        }
103        if segment == "." || segment == ".." {
104            return Err(ValidationError::PathTraversal {
105                name: name.to_string(),
106                segment: segment.to_string(),
107            });
108        }
109    }
110    Ok(())
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
114pub enum StatsError {
115    #[error("no samples to summarize")]
116    NoSamples,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
120pub enum PairedError {
121    #[error("no samples to summarize")]
122    NoSamples,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
126pub enum AdapterError {
127    #[error("command argv must not be empty")]
128    EmptyArgv,
129
130    #[error("command timed out")]
131    Timeout,
132
133    #[error("timeout is not supported on this platform")]
134    TimeoutUnsupported,
135
136    #[error("failed to execute command {command:?}: {reason}")]
137    RunCommand { command: String, reason: String },
138
139    #[error("{0}")]
140    Other(String),
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
144pub enum ConfigValidationError {
145    #[error("bench name validation: {0}")]
146    BenchName(String),
147
148    #[error("config validation: {0}")]
149    ConfigFile(String),
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
153pub enum IoError {
154    #[error("baseline not found at {path:?}; run 'perfgate promote' to establish one")]
155    BaselineNotFound { path: String },
156
157    #[error("baseline resolve: {0}")]
158    BaselineResolve(String),
159
160    #[error("write artifacts: {0}")]
161    ArtifactWrite(String),
162
163    #[error("failed to execute command {command:?}: {reason}")]
164    RunCommand { command: String, reason: String },
165
166    #[error("IO error: {0}")]
167    Other(String),
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
171pub enum AuthError {
172    #[error("missing authentication header")]
173    MissingAuth,
174
175    #[error("invalid API key format")]
176    InvalidKeyFormat,
177
178    #[error("invalid API key")]
179    InvalidKey,
180
181    #[error("API key has expired")]
182    ExpiredKey,
183
184    #[error("invalid JWT token: {0}")]
185    InvalidToken(String),
186
187    #[error("JWT token has expired")]
188    ExpiredToken,
189
190    #[error("insufficient permissions: required {required}, has {actual}")]
191    InsufficientPermissions { required: String, actual: String },
192}
193
194#[derive(Debug, thiserror::Error)]
195pub enum PerfgateError {
196    Validation(#[from] ValidationError),
197    Stats(#[from] StatsError),
198    Adapter(#[from] AdapterError),
199    Config(#[from] ConfigValidationError),
200    Io(#[from] IoError),
201    Paired(#[from] PairedError),
202    Auth(#[from] AuthError),
203}
204
205impl fmt::Display for PerfgateError {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        match self {
208            PerfgateError::Validation(e) => write!(f, "{}", e),
209            PerfgateError::Stats(e) => write!(f, "{}", e),
210            PerfgateError::Adapter(e) => write!(f, "{}", e),
211            PerfgateError::Config(e) => write!(f, "{}", e),
212            PerfgateError::Io(e) => write!(f, "{}", e),
213            PerfgateError::Paired(e) => write!(f, "{}", e),
214            PerfgateError::Auth(e) => write!(f, "{}", e),
215        }
216    }
217}
218
219impl From<std::io::Error> for IoError {
220    fn from(err: std::io::Error) -> Self {
221        IoError::Other(err.to_string())
222    }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
226pub enum ErrorCategory {
227    Validation,
228    Stats,
229    Adapter,
230    Config,
231    Io,
232    Paired,
233    Auth,
234}
235
236impl PerfgateError {
237    pub fn category(&self) -> ErrorCategory {
238        match self {
239            PerfgateError::Validation(_) => ErrorCategory::Validation,
240            PerfgateError::Stats(_) => ErrorCategory::Stats,
241            PerfgateError::Adapter(_) => ErrorCategory::Adapter,
242            PerfgateError::Config(_) => ErrorCategory::Config,
243            PerfgateError::Io(_) => ErrorCategory::Io,
244            PerfgateError::Paired(_) => ErrorCategory::Paired,
245            PerfgateError::Auth(_) => ErrorCategory::Auth,
246        }
247    }
248
249    pub fn is_recoverable(&self) -> bool {
250        match self {
251            PerfgateError::Validation(_) => false,
252            PerfgateError::Stats(StatsError::NoSamples) => false,
253            PerfgateError::Adapter(AdapterError::EmptyArgv) => false,
254            PerfgateError::Adapter(AdapterError::Timeout) => true,
255            PerfgateError::Adapter(AdapterError::TimeoutUnsupported) => false,
256            PerfgateError::Adapter(AdapterError::RunCommand { .. }) => true,
257            PerfgateError::Adapter(AdapterError::Other(_)) => true,
258            PerfgateError::Config(_) => false,
259            PerfgateError::Io(_) => true,
260            PerfgateError::Paired(PairedError::NoSamples) => false,
261            PerfgateError::Auth(_) => false,
262        }
263    }
264
265    pub fn exit_code(&self) -> i32 {
266        match self {
267            PerfgateError::Validation(_) => 1,
268            PerfgateError::Stats(_) => 1,
269            PerfgateError::Adapter(AdapterError::Timeout) => 1,
270            PerfgateError::Adapter(AdapterError::EmptyArgv) => 1,
271            PerfgateError::Adapter(AdapterError::TimeoutUnsupported) => 1,
272            PerfgateError::Adapter(AdapterError::RunCommand { .. }) => 1,
273            PerfgateError::Adapter(AdapterError::Other(_)) => 1,
274            PerfgateError::Config(_) => 1,
275            PerfgateError::Io(_) => 1,
276            PerfgateError::Paired(_) => 1,
277            PerfgateError::Auth(_) => 1,
278        }
279    }
280}
281
282impl ErrorCategory {
283    pub fn as_str(&self) -> &'static str {
284        match self {
285            ErrorCategory::Validation => "validation",
286            ErrorCategory::Stats => "stats",
287            ErrorCategory::Adapter => "adapter",
288            ErrorCategory::Config => "config",
289            ErrorCategory::Io => "io",
290            ErrorCategory::Paired => "paired",
291            ErrorCategory::Auth => "auth",
292        }
293    }
294}
295
296impl fmt::Display for ErrorCategory {
297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298        write!(f, "{}", self.as_str())
299    }
300}
301
302pub type Result<T> = std::result::Result<T, PerfgateError>;
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn validation_error_empty() {
310        let err = ValidationError::Empty;
311        assert!(err.to_string().contains("empty"));
312    }
313
314    #[test]
315    fn validation_error_too_long() {
316        let err = ValidationError::TooLong {
317            name: "test".to_string(),
318            max_len: 64,
319        };
320        assert!(err.to_string().contains("exceeds maximum length"));
321    }
322
323    #[test]
324    fn validation_error_invalid_chars() {
325        let err = ValidationError::InvalidCharacters {
326            name: "TEST".to_string(),
327        };
328        assert!(err.to_string().contains("invalid characters"));
329    }
330
331    #[test]
332    fn validation_error_path_traversal() {
333        let err = ValidationError::PathTraversal {
334            name: "../test".to_string(),
335            segment: "..".to_string(),
336        };
337        assert!(err.to_string().contains("path traversal"));
338    }
339
340    #[test]
341    fn adapter_error_empty_argv() {
342        let err = AdapterError::EmptyArgv;
343        assert!(err.to_string().contains("argv"));
344    }
345
346    #[test]
347    fn adapter_error_timeout() {
348        let err = AdapterError::Timeout;
349        assert!(err.to_string().contains("timed out"));
350    }
351
352    #[test]
353    fn adapter_error_timeout_unsupported() {
354        let err = AdapterError::TimeoutUnsupported;
355        assert!(err.to_string().contains("not supported"));
356    }
357
358    #[test]
359    fn adapter_error_other() {
360        let err = AdapterError::Other("something went wrong".to_string());
361        assert!(err.to_string().contains("something went wrong"));
362    }
363
364    #[test]
365    fn config_validation_error_bench_name() {
366        let err = ConfigValidationError::BenchName("invalid name".to_string());
367        assert!(err.to_string().contains("bench name"));
368    }
369
370    #[test]
371    fn config_validation_error_config_file() {
372        let err = ConfigValidationError::ConfigFile("missing field".to_string());
373        assert!(err.to_string().contains("config"));
374    }
375
376    #[test]
377    fn io_error_baseline_resolve() {
378        let err = IoError::BaselineResolve("file not found".to_string());
379        assert!(err.to_string().contains("baseline resolve"));
380    }
381
382    #[test]
383    fn io_error_artifact_write() {
384        let err = IoError::ArtifactWrite("permission denied".to_string());
385        assert!(err.to_string().contains("write artifacts"));
386    }
387
388    #[test]
389    fn io_error_run_command() {
390        let err = IoError::RunCommand {
391            command: "r".to_string(),
392            reason: "spawn failed".to_string(),
393        };
394        assert!(err.to_string().contains("failed to execute command"));
395    }
396
397    #[test]
398    fn perfgate_error_from_validation() {
399        let err: PerfgateError = ValidationError::Empty.into();
400        assert!(matches!(
401            err,
402            PerfgateError::Validation(ValidationError::Empty)
403        ));
404        assert_eq!(err.category(), ErrorCategory::Validation);
405    }
406
407    #[test]
408    fn perfgate_error_from_stats() {
409        let err: PerfgateError = StatsError::NoSamples.into();
410        assert!(matches!(err, PerfgateError::Stats(StatsError::NoSamples)));
411        assert_eq!(err.category(), ErrorCategory::Stats);
412    }
413
414    #[test]
415    fn perfgate_error_from_adapter() {
416        let err: PerfgateError = AdapterError::Timeout.into();
417        assert!(matches!(err, PerfgateError::Adapter(AdapterError::Timeout)));
418        assert_eq!(err.category(), ErrorCategory::Adapter);
419    }
420
421    #[test]
422    fn perfgate_error_from_config() {
423        let err: PerfgateError = ConfigValidationError::BenchName("test".to_string()).into();
424        assert!(matches!(
425            err,
426            PerfgateError::Config(ConfigValidationError::BenchName(_))
427        ));
428        assert_eq!(err.category(), ErrorCategory::Config);
429    }
430
431    #[test]
432    fn perfgate_error_from_io() {
433        let err: PerfgateError = IoError::BaselineResolve("test".to_string()).into();
434        assert!(matches!(
435            err,
436            PerfgateError::Io(IoError::BaselineResolve(_))
437        ));
438        assert_eq!(err.category(), ErrorCategory::Io);
439    }
440
441    #[test]
442    fn perfgate_error_from_paired() {
443        let err: PerfgateError = PairedError::NoSamples.into();
444        assert!(matches!(err, PerfgateError::Paired(PairedError::NoSamples)));
445        assert_eq!(err.category(), ErrorCategory::Paired);
446    }
447
448    #[test]
449    fn error_category_display() {
450        assert_eq!(ErrorCategory::Validation.to_string(), "validation");
451        assert_eq!(ErrorCategory::Stats.to_string(), "stats");
452        assert_eq!(ErrorCategory::Adapter.to_string(), "adapter");
453        assert_eq!(ErrorCategory::Config.to_string(), "config");
454        assert_eq!(ErrorCategory::Io.to_string(), "io");
455        assert_eq!(ErrorCategory::Paired.to_string(), "paired");
456        assert_eq!(ErrorCategory::Auth.to_string(), "auth");
457    }
458
459    #[test]
460    fn is_recoverable_timeout() {
461        let err = PerfgateError::Adapter(AdapterError::Timeout);
462        assert!(err.is_recoverable());
463    }
464
465    #[test]
466    fn is_not_recoverable_validation() {
467        let err = PerfgateError::Validation(ValidationError::Empty);
468        assert!(!err.is_recoverable());
469    }
470
471    #[test]
472    fn is_not_recoverable_empty_argv() {
473        let err = PerfgateError::Adapter(AdapterError::EmptyArgv);
474        assert!(!err.is_recoverable());
475    }
476
477    #[test]
478    fn exit_code_always_positive() {
479        let errors: Vec<PerfgateError> = vec![
480            ValidationError::Empty.into(),
481            StatsError::NoSamples.into(),
482            AdapterError::Timeout.into(),
483            ConfigValidationError::BenchName("test".to_string()).into(),
484            IoError::Other("test".to_string()).into(),
485            PairedError::NoSamples.into(),
486            AuthError::MissingAuth.into(),
487        ];
488
489        for err in errors {
490            assert!(err.exit_code() > 0);
491        }
492    }
493
494    #[test]
495    fn from_std_io_error() {
496        let std_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
497        let err = PerfgateError::Io(IoError::from(std_err));
498        assert!(matches!(err, PerfgateError::Io(IoError::Other(_))));
499    }
500
501    #[test]
502    fn result_type_alias() {
503        fn might_fail() -> Result<String> {
504            Err(PerfgateError::Validation(ValidationError::Empty))
505        }
506        let result = might_fail();
507        assert!(result.is_err());
508    }
509
510    #[test]
511    fn validate_bench_name_valid() {
512        assert!(validate_bench_name("my-bench").is_ok());
513        assert!(validate_bench_name("bench_a").is_ok());
514        assert!(validate_bench_name("path/to/bench").is_ok());
515        assert!(validate_bench_name("bench.v2").is_ok());
516        assert!(validate_bench_name("a").is_ok());
517        assert!(validate_bench_name("123").is_ok());
518    }
519
520    #[test]
521    fn validate_bench_name_invalid() {
522        assert!(validate_bench_name("bench|name").is_err());
523        assert!(validate_bench_name("").is_err());
524        assert!(validate_bench_name("bench name").is_err());
525        assert!(validate_bench_name("bench@name").is_err());
526    }
527
528    #[test]
529    fn validate_bench_name_path_traversal() {
530        assert!(validate_bench_name("../bench").is_err());
531        assert!(validate_bench_name("bench/../x").is_err());
532        assert!(validate_bench_name("./bench").is_err());
533        assert!(validate_bench_name("bench/.").is_err());
534    }
535
536    #[test]
537    fn validate_bench_name_empty_segments() {
538        assert!(validate_bench_name("/bench").is_err());
539        assert!(validate_bench_name("bench/").is_err());
540        assert!(validate_bench_name("bench//x").is_err());
541        assert!(validate_bench_name("/").is_err());
542    }
543
544    #[test]
545    fn validate_bench_name_length_cap() {
546        let name_64 = "a".repeat(BENCH_NAME_MAX_LEN);
547        assert!(validate_bench_name(&name_64).is_ok());
548
549        let name_65 = "a".repeat(BENCH_NAME_MAX_LEN + 1);
550        assert!(validate_bench_name(&name_65).is_err());
551    }
552
553    #[test]
554    fn validate_bench_name_case() {
555        assert!(validate_bench_name("MyBench").is_err());
556        assert!(validate_bench_name("BENCH").is_err());
557        assert!(validate_bench_name("benchA").is_err());
558    }
559
560    #[test]
561    fn validation_error_name_accessor() {
562        let err = ValidationError::TooLong {
563            name: "test".to_string(),
564            max_len: 64,
565        };
566        assert_eq!(err.name(), "test");
567
568        let err = ValidationError::Empty;
569        assert_eq!(err.name(), "");
570    }
571
572    #[test]
573    fn validation_error_empty_segment() {
574        let err = ValidationError::EmptySegment {
575            name: "bench//x".to_string(),
576        };
577        let msg = err.to_string();
578        assert!(msg.contains("empty path segment"));
579        assert!(msg.contains("bench//x"));
580    }
581
582    #[test]
583    fn stats_error_no_samples_display() {
584        let err = StatsError::NoSamples;
585        assert_eq!(err.to_string(), "no samples to summarize");
586    }
587
588    #[test]
589    fn paired_error_no_samples_display() {
590        let err = PairedError::NoSamples;
591        assert_eq!(err.to_string(), "no samples to summarize");
592    }
593
594    #[test]
595    fn io_error_other_display() {
596        let err = IoError::Other("disk full".to_string());
597        assert!(err.to_string().contains("disk full"));
598    }
599
600    #[test]
601    fn perfgate_error_transparent_display_forwards() {
602        let inner = ValidationError::InvalidCharacters {
603            name: "MY_BENCH".to_string(),
604        };
605        let outer: PerfgateError = inner.clone().into();
606        assert_eq!(outer.to_string(), inner.to_string());
607    }
608
609    #[test]
610    fn perfgate_error_transparent_display_stats() {
611        let inner = StatsError::NoSamples;
612        let outer: PerfgateError = inner.clone().into();
613        assert_eq!(outer.to_string(), inner.to_string());
614    }
615
616    #[test]
617    fn perfgate_error_transparent_display_io() {
618        let inner = IoError::BaselineResolve("baselines/bench.json".to_string());
619        let outer: PerfgateError = inner.clone().into();
620        assert_eq!(outer.to_string(), inner.to_string());
621        assert!(outer.to_string().contains("baselines/bench.json"));
622    }
623
624    #[test]
625    fn validation_display_contains_bench_name() {
626        let err = ValidationError::TooLong {
627            name: "my-long-bench-name".to_string(),
628            max_len: 64,
629        };
630        assert!(err.to_string().contains("my-long-bench-name"));
631        assert!(err.to_string().contains("64"));
632
633        let err = ValidationError::InvalidCharacters {
634            name: "BAD_NAME".to_string(),
635        };
636        assert!(err.to_string().contains("BAD_NAME"));
637
638        let err = ValidationError::PathTraversal {
639            name: "foo/../bar".to_string(),
640            segment: "..".to_string(),
641        };
642        assert!(err.to_string().contains("foo/../bar"));
643        assert!(err.to_string().contains(".."));
644    }
645
646    #[test]
647    fn io_error_contains_file_path() {
648        let err = IoError::BaselineResolve("baselines/perf.json not found".to_string());
649        assert!(err.to_string().contains("baselines/perf.json"));
650
651        let err = IoError::ArtifactWrite("artifacts/perfgate/run.json".to_string());
652        assert!(err.to_string().contains("artifacts/perfgate/run.json"));
653
654        let err = IoError::RunCommand {
655            command: "/usr/bin/echo".to_string(),
656            reason: "failed to spawn".to_string(),
657        };
658        assert!(err.to_string().contains("/usr/bin/echo"));
659    }
660
661    #[test]
662    fn exit_code_is_always_one() {
663        let errors: Vec<PerfgateError> = vec![
664            ValidationError::Empty.into(),
665            ValidationError::TooLong {
666                name: "x".into(),
667                max_len: 64,
668            }
669            .into(),
670            ValidationError::InvalidCharacters { name: "X".into() }.into(),
671            ValidationError::EmptySegment { name: "/x".into() }.into(),
672            ValidationError::PathTraversal {
673                name: "..".into(),
674                segment: "..".into(),
675            }
676            .into(),
677            StatsError::NoSamples.into(),
678            AdapterError::EmptyArgv.into(),
679            AdapterError::Timeout.into(),
680            AdapterError::TimeoutUnsupported.into(),
681            AdapterError::Other("err".into()).into(),
682            ConfigValidationError::BenchName("b".into()).into(),
683            ConfigValidationError::ConfigFile("c".into()).into(),
684            IoError::BaselineResolve("r".into()).into(),
685            IoError::ArtifactWrite("w".into()).into(),
686            IoError::RunCommand {
687                command: "r".into(),
688                reason: "err".into(),
689            }
690            .into(),
691            IoError::Other("o".into()).into(),
692            PairedError::NoSamples.into(),
693            AuthError::MissingAuth.into(),
694        ];
695        for err in &errors {
696            assert_eq!(err.exit_code(), 1, "exit_code for {:?}", err);
697        }
698    }
699
700    #[test]
701    fn error_category_as_str_matches_display() {
702        let categories = [
703            ErrorCategory::Validation,
704            ErrorCategory::Stats,
705            ErrorCategory::Adapter,
706            ErrorCategory::Config,
707            ErrorCategory::Io,
708            ErrorCategory::Paired,
709            ErrorCategory::Auth,
710        ];
711        for cat in &categories {
712            assert_eq!(cat.as_str(), cat.to_string());
713        }
714    }
715
716    #[test]
717    fn from_std_io_error_to_io_error() {
718        let std_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
719        let err: IoError = std_err.into();
720        assert!(matches!(err, IoError::Other(_)));
721        assert!(err.to_string().contains("access denied"));
722    }
723
724    #[test]
725    fn is_recoverable_io_errors() {
726        let cases = vec![
727            PerfgateError::Io(IoError::BaselineResolve("x".into())),
728            PerfgateError::Io(IoError::ArtifactWrite("x".into())),
729            PerfgateError::Io(IoError::RunCommand {
730                command: "x".into(),
731                reason: "y".into(),
732            }),
733            PerfgateError::Io(IoError::Other("x".into())),
734        ];
735        for err in cases {
736            assert!(
737                err.is_recoverable(),
738                "IO errors should be recoverable: {:?}",
739                err
740            );
741        }
742    }
743
744    #[test]
745    fn is_not_recoverable_config_errors() {
746        let err = PerfgateError::Config(ConfigValidationError::BenchName("x".into()));
747        assert!(!err.is_recoverable());
748        let err = PerfgateError::Config(ConfigValidationError::ConfigFile("x".into()));
749        assert!(!err.is_recoverable());
750    }
751
752    #[test]
753    fn is_recoverable_adapter_other() {
754        let err = PerfgateError::Adapter(AdapterError::Other("transient".into()));
755        assert!(err.is_recoverable());
756    }
757
758    #[test]
759    fn is_not_recoverable_timeout_unsupported() {
760        let err = PerfgateError::Adapter(AdapterError::TimeoutUnsupported);
761        assert!(!err.is_recoverable());
762    }
763
764    #[test]
765    fn is_not_recoverable_paired_no_samples() {
766        let err = PerfgateError::Paired(PairedError::NoSamples);
767        assert!(!err.is_recoverable());
768    }
769
770    #[test]
771    fn auth_error_missing_auth() {
772        let err = AuthError::MissingAuth;
773        assert!(err.to_string().contains("missing authentication"));
774    }
775
776    #[test]
777    fn auth_error_insufficient_permissions() {
778        let err = AuthError::InsufficientPermissions {
779            required: "admin".into(),
780            actual: "read".into(),
781        };
782        assert!(err.to_string().contains("insufficient permissions"));
783        assert!(err.to_string().contains("admin"));
784        assert!(err.to_string().contains("read"));
785    }
786}
787
788#[cfg(test)]
789mod property_tests {
790    use super::*;
791    use proptest::prelude::*;
792
793    fn error_message_strategy() -> impl Strategy<Value = String> {
794        "[a-zA-Z0-9 ]{1,50}"
795    }
796
797    proptest! {
798        #[test]
799        fn prop_adapter_error_other_preserves_message(msg in error_message_strategy()) {
800            let err = AdapterError::Other(msg.clone());
801            let displayed = err.to_string();
802            prop_assert!(displayed.contains(&msg));
803        }
804
805        #[test]
806        fn prop_io_error_other_preserves_message(msg in error_message_strategy()) {
807            let err = IoError::Other(msg.clone());
808            let displayed = err.to_string();
809            prop_assert!(displayed.contains(&msg));
810        }
811
812        #[test]
813        fn prop_config_error_preserves_message(msg in error_message_strategy()) {
814            let err = ConfigValidationError::BenchName(msg.clone());
815            let displayed = err.to_string();
816            prop_assert!(displayed.contains(&msg));
817        }
818
819        #[test]
820        fn prop_error_category_consistent(
821            msg in error_message_strategy()
822        ) {
823            let errors: Vec<PerfgateError> = vec![
824                PerfgateError::Validation(ValidationError::Empty),
825                PerfgateError::Stats(StatsError::NoSamples),
826                PerfgateError::Adapter(AdapterError::Other(msg.clone())),
827                PerfgateError::Config(ConfigValidationError::ConfigFile(msg.clone())),
828                PerfgateError::Io(IoError::Other(msg.clone())),
829                PerfgateError::Paired(PairedError::NoSamples),
830                PerfgateError::Auth(AuthError::MissingAuth),
831            ];
832
833            for err in errors {
834                let category = err.category();
835                let displayed = category.to_string();
836                prop_assert!(!displayed.is_empty());
837                prop_assert!(err.exit_code() > 0);
838            }
839        }
840
841        #[test]
842        fn prop_validate_bench_name_valid_chars(
843            name in "[a-z0-9_.\\-/]{1,64}"
844        ) {
845            let result = validate_bench_name(&name);
846            if !name.contains("..") && !name.contains("./") && !name.starts_with('/') && !name.ends_with('/') && !name.contains("//") {
847                let has_invalid = name.split('/').any(|s| s == "." || s == ".." || s.is_empty());
848                if !has_invalid {
849                    prop_assert!(result.is_ok(), "name '{}' should be valid", name);
850                }
851            }
852        }
853    }
854}