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