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