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