1use 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}