1use anyhow::Error as AnyError;
2use std::fmt;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ErrorCategory {
7 UserInput,
9 ConfigValidation,
11 NotFound,
13 InvalidState,
15 SecurityDenied,
17 ExternalDependency,
19 InternalInvariant,
21}
22
23impl ErrorCategory {
24 pub fn as_str(self) -> &'static str {
26 match self {
27 Self::UserInput => "user_input",
28 Self::ConfigValidation => "config_validation",
29 Self::NotFound => "not_found",
30 Self::InvalidState => "invalid_state",
31 Self::SecurityDenied => "security_denied",
32 Self::ExternalDependency => "external_dependency",
33 Self::InternalInvariant => "internal_invariant",
34 }
35 }
36}
37
38#[derive(Debug)]
40pub struct OrchestratorError {
41 category: ErrorCategory,
42 operation: &'static str,
43 subject: Option<String>,
44 source: AnyError,
45}
46
47impl OrchestratorError {
48 pub fn new(
50 category: ErrorCategory,
51 operation: &'static str,
52 source: impl Into<AnyError>,
53 ) -> Self {
54 Self {
55 category,
56 operation,
57 subject: None,
58 source: source.into(),
59 }
60 }
61
62 pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
64 self.subject = Some(subject.into());
65 self
66 }
67
68 pub fn category(&self) -> ErrorCategory {
70 self.category
71 }
72
73 pub fn operation(&self) -> &'static str {
75 self.operation
76 }
77
78 pub fn subject(&self) -> Option<&str> {
80 self.subject.as_deref()
81 }
82
83 pub fn message(&self) -> String {
85 self.source.to_string()
86 }
87
88 pub fn user_input(operation: &'static str, source: impl Into<AnyError>) -> Self {
90 Self::new(ErrorCategory::UserInput, operation, source)
91 }
92
93 pub fn config_validation(operation: &'static str, source: impl Into<AnyError>) -> Self {
95 Self::new(ErrorCategory::ConfigValidation, operation, source)
96 }
97
98 pub fn not_found(operation: &'static str, source: impl Into<AnyError>) -> Self {
100 Self::new(ErrorCategory::NotFound, operation, source)
101 }
102
103 pub fn invalid_state(operation: &'static str, source: impl Into<AnyError>) -> Self {
105 Self::new(ErrorCategory::InvalidState, operation, source)
106 }
107
108 pub fn security_denied(operation: &'static str, source: impl Into<AnyError>) -> Self {
110 Self::new(ErrorCategory::SecurityDenied, operation, source)
111 }
112
113 pub fn external_dependency(operation: &'static str, source: impl Into<AnyError>) -> Self {
115 Self::new(ErrorCategory::ExternalDependency, operation, source)
116 }
117
118 pub fn internal_invariant(operation: &'static str, source: impl Into<AnyError>) -> Self {
120 Self::new(ErrorCategory::InternalInvariant, operation, source)
121 }
122}
123
124impl fmt::Display for OrchestratorError {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 match &self.subject {
127 Some(subject) => write!(f, "{} [{}]: {}", self.operation, subject, self.source),
128 None => write!(f, "{}: {}", self.operation, self.source),
129 }
130 }
131}
132
133impl std::error::Error for OrchestratorError {
134 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
135 Some(self.source.root_cause())
136 }
137}
138
139pub type Result<T> = std::result::Result<T, OrchestratorError>;
141
142impl From<anyhow::Error> for OrchestratorError {
143 fn from(value: anyhow::Error) -> Self {
144 OrchestratorError::internal_invariant("internal", value)
145 }
146}
147
148impl From<std::io::Error> for OrchestratorError {
149 fn from(value: std::io::Error) -> Self {
150 OrchestratorError::external_dependency("internal", value)
151 }
152}
153
154impl From<rusqlite::Error> for OrchestratorError {
155 fn from(value: rusqlite::Error) -> Self {
156 OrchestratorError::external_dependency("internal", value)
157 }
158}
159
160impl From<serde_json::Error> for OrchestratorError {
161 fn from(value: serde_json::Error) -> Self {
162 OrchestratorError::internal_invariant("internal", value)
163 }
164}
165
166impl From<serde_yaml::Error> for OrchestratorError {
167 fn from(value: serde_yaml::Error) -> Self {
168 OrchestratorError::internal_invariant("internal", value)
169 }
170}
171
172fn classify_by_message(operation: &'static str, error: AnyError) -> OrchestratorError {
173 let message = error.to_string();
174 let lower = message.to_ascii_lowercase();
175
176 if lower.starts_with("[invalid_")
177 || lower.contains(" category: validation")
178 || lower.contains(" validation ")
179 || lower.contains("must define at least one")
180 || lower.contains("metadata.project=")
181 {
182 return OrchestratorError::config_validation(operation, error);
183 }
184
185 if lower.contains("not found")
186 || lower.contains("does not exist")
187 || lower.contains("no such")
188 || lower.contains("unknown resource type")
189 || lower.contains("unknown list resource type")
190 || lower.contains("unknown builtin provider")
191 || lower.contains("provider '") && lower.contains("not found")
192 {
193 return OrchestratorError::not_found(operation, error);
194 }
195
196 if lower.starts_with("use --force")
197 || lower.contains("no resumable task found")
198 || lower.contains("daemon is ")
199 || lower.contains("no active encryption key")
200 || lower.contains("no incomplete rotation found")
201 || lower.contains("cannot begin rotation")
202 || lower.contains("task-scoped workflow accepts at most one")
203 || lower.contains("no qa/security markdown files found")
204 {
205 return OrchestratorError::invalid_state(operation, error);
206 }
207
208 if lower.contains("client certificate")
209 || lower.contains("permission denied")
210 || lower.contains("unauthenticated")
211 || lower.contains("access denied")
212 {
213 return OrchestratorError::security_denied(operation, error);
214 }
215
216 if lower.contains("task_id or --latest required")
217 || lower.contains("invalid ")
218 || lower.contains("cannot be empty")
219 || lower.contains("cannot include '..'")
220 || lower.contains("must be a relative path")
221 || lower.contains("label selector")
222 || lower.contains("no valid --target-file entries found")
223 {
224 return OrchestratorError::user_input(operation, error);
225 }
226
227 if lower.contains("failed to")
228 || lower.contains("sqlite")
229 || lower.contains("database")
230 || lower.contains("i/o")
231 || lower.contains("io error")
232 || lower.contains("connection")
233 || lower.contains("timeout")
234 || lower.contains("transport")
235 || lower.contains("git ")
236 {
237 return OrchestratorError::external_dependency(operation, error);
238 }
239
240 OrchestratorError::internal_invariant(operation, error)
241}
242
243pub fn classify_task_error(
245 operation: &'static str,
246 error: impl Into<AnyError>,
247) -> OrchestratorError {
248 classify_by_message(operation, error.into())
249}
250
251pub fn classify_resource_error(
253 operation: &'static str,
254 error: impl Into<AnyError>,
255) -> OrchestratorError {
256 classify_by_message(operation, error.into())
257}
258
259pub fn classify_store_error(
261 operation: &'static str,
262 error: impl Into<AnyError>,
263) -> OrchestratorError {
264 classify_by_message(operation, error.into())
265}
266
267pub fn classify_system_error(
269 operation: &'static str,
270 error: impl Into<AnyError>,
271) -> OrchestratorError {
272 classify_by_message(operation, error.into())
273}
274
275pub fn classify_secret_error(
277 operation: &'static str,
278 error: impl Into<AnyError>,
279) -> OrchestratorError {
280 classify_by_message(operation, error.into())
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn classify_task_latest_missing_as_invalid_state() {
289 let err = classify_task_error("task.start", anyhow::anyhow!("no resumable task found"));
290 assert_eq!(err.category(), ErrorCategory::InvalidState);
291 }
292
293 #[test]
294 fn classify_manifest_policy_error_as_config_validation() {
295 let err = classify_system_error(
296 "system.manifest_validate",
297 anyhow::anyhow!(
298 "[INVALID_WORKSPACE] workspace 'default' qa_targets cannot be empty\n category: validation"
299 ),
300 );
301 assert_eq!(err.category(), ErrorCategory::ConfigValidation);
302 }
303
304 #[test]
305 fn classify_missing_project_as_not_found() {
306 let err = classify_resource_error(
307 "resource.get",
308 anyhow::anyhow!("project not found: missing-project"),
309 );
310 assert_eq!(err.category(), ErrorCategory::NotFound);
311 }
312
313 #[test]
314 fn classify_invalid_target_file_as_user_input() {
315 let err = classify_task_error(
316 "task.create",
317 anyhow::anyhow!("no valid --target-file entries found"),
318 );
319 assert_eq!(err.category(), ErrorCategory::UserInput);
320 }
321
322 #[test]
323 fn classify_secret_rotation_without_key_as_invalid_state() {
324 let err = classify_secret_error(
325 "secret.rotate",
326 anyhow::anyhow!(
327 "SecretStore write blocked: no active encryption key (all keys revoked or retired)"
328 ),
329 );
330 assert_eq!(err.category(), ErrorCategory::InvalidState);
331 }
332
333 #[test]
336 fn error_category_as_str_all_variants() {
337 assert_eq!(ErrorCategory::UserInput.as_str(), "user_input");
338 assert_eq!(
339 ErrorCategory::ConfigValidation.as_str(),
340 "config_validation"
341 );
342 assert_eq!(ErrorCategory::NotFound.as_str(), "not_found");
343 assert_eq!(ErrorCategory::InvalidState.as_str(), "invalid_state");
344 assert_eq!(ErrorCategory::SecurityDenied.as_str(), "security_denied");
345 assert_eq!(
346 ErrorCategory::ExternalDependency.as_str(),
347 "external_dependency"
348 );
349 assert_eq!(
350 ErrorCategory::InternalInvariant.as_str(),
351 "internal_invariant"
352 );
353 }
354
355 #[test]
358 fn orchestrator_error_new_and_accessors() {
359 let err = OrchestratorError::new(
360 ErrorCategory::NotFound,
361 "resource.get",
362 anyhow::anyhow!("widget not found"),
363 );
364 assert_eq!(err.category(), ErrorCategory::NotFound);
365 assert_eq!(err.operation(), "resource.get");
366 assert_eq!(err.subject(), None);
367 assert_eq!(err.message(), "widget not found");
368 }
369
370 #[test]
371 fn orchestrator_error_with_subject() {
372 let err = OrchestratorError::new(
373 ErrorCategory::UserInput,
374 "task.create",
375 anyhow::anyhow!("bad input"),
376 )
377 .with_subject("my-task");
378 assert_eq!(err.subject(), Some("my-task"));
379 assert_eq!(err.category(), ErrorCategory::UserInput);
380 assert_eq!(err.operation(), "task.create");
381 assert_eq!(err.message(), "bad input");
382 }
383
384 #[test]
387 fn display_without_subject() {
388 let err = OrchestratorError::new(
389 ErrorCategory::InternalInvariant,
390 "op",
391 anyhow::anyhow!("boom"),
392 );
393 let display = format!("{}", err);
394 assert_eq!(display, "op: boom");
395 }
396
397 #[test]
398 fn display_with_subject() {
399 let err = OrchestratorError::new(
400 ErrorCategory::NotFound,
401 "resource.get",
402 anyhow::anyhow!("missing"),
403 )
404 .with_subject("proj-1");
405 let display = format!("{}", err);
406 assert_eq!(display, "resource.get [proj-1]: missing");
407 }
408
409 #[test]
412 fn from_anyhow_error() {
413 let anyhow_err = anyhow::anyhow!("anyhow failure");
414 let err: OrchestratorError = anyhow_err.into();
415 assert_eq!(err.category(), ErrorCategory::InternalInvariant);
416 assert_eq!(err.operation(), "internal");
417 }
418
419 #[test]
420 fn from_io_error() {
421 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file gone");
422 let err: OrchestratorError = io_err.into();
423 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
424 assert_eq!(err.operation(), "internal");
425 }
426
427 #[test]
428 fn from_rusqlite_error() {
429 let sqlite_err = rusqlite::Error::SqliteFailure(
430 rusqlite::ffi::Error::new(1),
431 Some("db locked".to_string()),
432 );
433 let err: OrchestratorError = sqlite_err.into();
434 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
435 assert_eq!(err.operation(), "internal");
436 }
437
438 #[test]
439 fn from_serde_json_error() {
440 let json_err = serde_json::from_str::<serde_json::Value>("{{bad").unwrap_err();
441 let err: OrchestratorError = json_err.into();
442 assert_eq!(err.category(), ErrorCategory::InternalInvariant);
443 assert_eq!(err.operation(), "internal");
444 }
445
446 #[test]
447 fn from_serde_yaml_error() {
448 let yaml_err = serde_yaml::from_str::<serde_yaml::Value>(":\n :\n- -").unwrap_err();
449 let err: OrchestratorError = yaml_err.into();
450 assert_eq!(err.category(), ErrorCategory::InternalInvariant);
451 assert_eq!(err.operation(), "internal");
452 }
453
454 #[test]
457 fn classify_permission_denied_as_security() {
458 let err = classify_task_error("op", anyhow::anyhow!("Permission denied for resource"));
459 assert_eq!(err.category(), ErrorCategory::SecurityDenied);
460 }
461
462 #[test]
463 fn classify_access_denied_as_security() {
464 let err = classify_task_error("op", anyhow::anyhow!("Access denied by policy"));
465 assert_eq!(err.category(), ErrorCategory::SecurityDenied);
466 }
467
468 #[test]
469 fn classify_client_certificate_as_security() {
470 let err = classify_task_error("op", anyhow::anyhow!("client certificate expired"));
471 assert_eq!(err.category(), ErrorCategory::SecurityDenied);
472 }
473
474 #[test]
475 fn classify_unauthenticated_as_security() {
476 let err = classify_task_error("op", anyhow::anyhow!("unauthenticated request"));
477 assert_eq!(err.category(), ErrorCategory::SecurityDenied);
478 }
479
480 #[test]
481 fn classify_failed_to_as_external_dependency() {
482 let err = classify_task_error("op", anyhow::anyhow!("failed to connect to server"));
483 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
484 }
485
486 #[test]
487 fn classify_sqlite_as_external_dependency() {
488 let err = classify_task_error("op", anyhow::anyhow!("sqlite error: disk full"));
489 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
490 }
491
492 #[test]
493 fn classify_timeout_as_external_dependency() {
494 let err = classify_task_error("op", anyhow::anyhow!("request timeout after 30s"));
495 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
496 }
497
498 #[test]
499 fn classify_database_as_external_dependency() {
500 let err = classify_task_error("op", anyhow::anyhow!("database connection lost"));
501 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
502 }
503
504 #[test]
505 fn classify_io_error_message_as_external_dependency() {
506 let err = classify_task_error("op", anyhow::anyhow!("i/o error reading file"));
507 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
508 }
509
510 #[test]
511 fn classify_connection_as_external_dependency() {
512 let err = classify_task_error("op", anyhow::anyhow!("connection refused"));
513 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
514 }
515
516 #[test]
517 fn classify_transport_as_external_dependency() {
518 let err = classify_task_error("op", anyhow::anyhow!("transport layer error"));
519 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
520 }
521
522 #[test]
523 fn classify_git_as_external_dependency() {
524 let err = classify_task_error("op", anyhow::anyhow!("git push failed"));
525 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
526 }
527
528 #[test]
529 fn classify_invalid_prefix_as_user_input() {
530 let err = classify_task_error("op", anyhow::anyhow!("invalid port number"));
531 assert_eq!(err.category(), ErrorCategory::UserInput);
532 }
533
534 #[test]
535 fn classify_cannot_be_empty_as_user_input() {
536 let err = classify_task_error("op", anyhow::anyhow!("name cannot be empty"));
537 assert_eq!(err.category(), ErrorCategory::UserInput);
538 }
539
540 #[test]
541 fn classify_task_id_required_as_user_input() {
542 let err = classify_task_error("op", anyhow::anyhow!("task_id or --latest required"));
543 assert_eq!(err.category(), ErrorCategory::UserInput);
544 }
545
546 #[test]
547 fn classify_cannot_include_dotdot_as_user_input() {
548 let err = classify_task_error("op", anyhow::anyhow!("path cannot include '..'"));
549 assert_eq!(err.category(), ErrorCategory::UserInput);
550 }
551
552 #[test]
553 fn classify_must_be_relative_path_as_user_input() {
554 let err = classify_task_error("op", anyhow::anyhow!("must be a relative path"));
555 assert_eq!(err.category(), ErrorCategory::UserInput);
556 }
557
558 #[test]
559 fn classify_label_selector_as_user_input() {
560 let err = classify_task_error("op", anyhow::anyhow!("bad label selector syntax"));
561 assert_eq!(err.category(), ErrorCategory::UserInput);
562 }
563
564 #[test]
565 fn classify_fallback_as_internal_invariant() {
566 let err = classify_task_error("op", anyhow::anyhow!("something completely unexpected"));
567 assert_eq!(err.category(), ErrorCategory::InternalInvariant);
568 }
569
570 #[test]
571 fn classify_validation_keyword_as_config_validation() {
572 let err = classify_task_error(
573 "op",
574 anyhow::anyhow!("field has category: validation error"),
575 );
576 assert_eq!(err.category(), ErrorCategory::ConfigValidation);
577 }
578
579 #[test]
580 fn classify_must_define_at_least_one_as_config_validation() {
581 let err = classify_task_error("op", anyhow::anyhow!("must define at least one target"));
582 assert_eq!(err.category(), ErrorCategory::ConfigValidation);
583 }
584
585 #[test]
586 fn classify_metadata_project_as_config_validation() {
587 let err = classify_task_error("op", anyhow::anyhow!("metadata.project=foo is invalid"));
588 assert_eq!(err.category(), ErrorCategory::ConfigValidation);
589 }
590
591 #[test]
592 fn classify_validation_word_as_config_validation() {
593 let err = classify_task_error("op", anyhow::anyhow!("schema validation failed"));
594 assert_eq!(err.category(), ErrorCategory::ConfigValidation);
595 }
596
597 #[test]
598 fn classify_does_not_exist_as_not_found() {
599 let err = classify_resource_error("op", anyhow::anyhow!("resource does not exist"));
600 assert_eq!(err.category(), ErrorCategory::NotFound);
601 }
602
603 #[test]
604 fn classify_no_such_as_not_found() {
605 let err = classify_resource_error("op", anyhow::anyhow!("no such file or directory"));
606 assert_eq!(err.category(), ErrorCategory::NotFound);
607 }
608
609 #[test]
610 fn classify_unknown_resource_type_as_not_found() {
611 let err = classify_resource_error("op", anyhow::anyhow!("unknown resource type 'Foo'"));
612 assert_eq!(err.category(), ErrorCategory::NotFound);
613 }
614
615 #[test]
616 fn classify_unknown_list_resource_type_as_not_found() {
617 let err = classify_resource_error("op", anyhow::anyhow!("unknown list resource type 'X'"));
618 assert_eq!(err.category(), ErrorCategory::NotFound);
619 }
620
621 #[test]
622 fn classify_unknown_builtin_provider_as_not_found() {
623 let err = classify_resource_error("op", anyhow::anyhow!("unknown builtin provider 'Y'"));
624 assert_eq!(err.category(), ErrorCategory::NotFound);
625 }
626
627 #[test]
628 fn classify_use_force_as_invalid_state() {
629 let err = classify_task_error("op", anyhow::anyhow!("use --force to override"));
630 assert_eq!(err.category(), ErrorCategory::InvalidState);
631 }
632
633 #[test]
634 fn classify_daemon_is_as_invalid_state() {
635 let err = classify_system_error("op", anyhow::anyhow!("daemon is not running"));
636 assert_eq!(err.category(), ErrorCategory::InvalidState);
637 }
638
639 #[test]
640 fn classify_no_incomplete_rotation_as_invalid_state() {
641 let err = classify_secret_error("op", anyhow::anyhow!("no incomplete rotation found"));
642 assert_eq!(err.category(), ErrorCategory::InvalidState);
643 }
644
645 #[test]
646 fn classify_cannot_begin_rotation_as_invalid_state() {
647 let err = classify_secret_error("op", anyhow::anyhow!("cannot begin rotation now"));
648 assert_eq!(err.category(), ErrorCategory::InvalidState);
649 }
650
651 #[test]
652 fn classify_task_scoped_workflow_limit_as_invalid_state() {
653 let err = classify_task_error(
654 "op",
655 anyhow::anyhow!("task-scoped workflow accepts at most one step"),
656 );
657 assert_eq!(err.category(), ErrorCategory::InvalidState);
658 }
659
660 #[test]
661 fn classify_no_qa_markdown_as_invalid_state() {
662 let err = classify_system_error(
663 "op",
664 anyhow::anyhow!("no qa/security markdown files found in directory"),
665 );
666 assert_eq!(err.category(), ErrorCategory::InvalidState);
667 }
668
669 #[test]
670 fn classify_io_error_keyword_as_external_dependency() {
671 let err = classify_task_error("op", anyhow::anyhow!("io error on socket"));
672 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
673 }
674
675 #[test]
678 fn convenience_builders_set_correct_category() {
679 let cases: Vec<(OrchestratorError, ErrorCategory)> = vec![
680 (
681 OrchestratorError::user_input("op", anyhow::anyhow!("e")),
682 ErrorCategory::UserInput,
683 ),
684 (
685 OrchestratorError::config_validation("op", anyhow::anyhow!("e")),
686 ErrorCategory::ConfigValidation,
687 ),
688 (
689 OrchestratorError::not_found("op", anyhow::anyhow!("e")),
690 ErrorCategory::NotFound,
691 ),
692 (
693 OrchestratorError::invalid_state("op", anyhow::anyhow!("e")),
694 ErrorCategory::InvalidState,
695 ),
696 (
697 OrchestratorError::security_denied("op", anyhow::anyhow!("e")),
698 ErrorCategory::SecurityDenied,
699 ),
700 (
701 OrchestratorError::external_dependency("op", anyhow::anyhow!("e")),
702 ErrorCategory::ExternalDependency,
703 ),
704 (
705 OrchestratorError::internal_invariant("op", anyhow::anyhow!("e")),
706 ErrorCategory::InternalInvariant,
707 ),
708 ];
709 for (err, expected) in cases {
710 assert_eq!(err.category(), expected);
711 }
712 }
713
714 #[test]
717 fn std_error_source_returns_root_cause() {
718 let err = OrchestratorError::new(
719 ErrorCategory::InternalInvariant,
720 "op",
721 anyhow::anyhow!("root cause"),
722 );
723 let std_err: &dyn std::error::Error = &err;
724 assert!(std_err.source().is_some());
725 }
726
727 #[test]
730 fn classify_store_error_delegates() {
731 let err = classify_store_error("store.put", anyhow::anyhow!("database locked"));
732 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
733 assert_eq!(err.operation(), "store.put");
734 }
735
736 #[test]
737 fn classify_system_error_delegates() {
738 let err = classify_system_error("sys.check", anyhow::anyhow!("timeout waiting"));
739 assert_eq!(err.category(), ErrorCategory::ExternalDependency);
740 assert_eq!(err.operation(), "sys.check");
741 }
742
743 #[test]
744 fn classify_resource_error_delegates() {
745 let err =
746 classify_resource_error("res.list", anyhow::anyhow!("unknown list resource type"));
747 assert_eq!(err.category(), ErrorCategory::NotFound);
748 assert_eq!(err.operation(), "res.list");
749 }
750}