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