Skip to main content

perfgate_types/
error.rs

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