1use std::fmt;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ErrorCategory {
5 Usage,
6 Runtime,
7}
8
9impl ErrorCategory {
10 pub fn as_str(self) -> &'static str {
11 match self {
12 Self::Usage => "usage",
13 Self::Runtime => "runtime",
14 }
15 }
16}
17
18#[derive(Debug, Clone)]
19pub struct CliError {
20 message: String,
21 exit_code: u8,
22 category: ErrorCategory,
23 operation: Option<String>,
24 hints: Vec<String>,
25}
26
27impl CliError {
28 pub fn usage(message: impl Into<String>) -> Self {
29 Self::new(message, 2, ErrorCategory::Usage)
30 }
31
32 pub fn runtime(message: impl Into<String>) -> Self {
33 Self::new(message, 1, ErrorCategory::Runtime)
34 }
35
36 pub fn unsupported_platform() -> Self {
37 Self::usage("macos-agent is only supported on macOS")
38 }
39
40 pub fn timeout(operation: &str, timeout_ms: u64) -> Self {
41 let mut err = Self::runtime(format!("{operation} timed out after {timeout_ms}ms"))
42 .with_operation(operation)
43 .with_hint(
44 "Increase --timeout-ms for slower apps or enable --retries for transient failures.",
45 );
46 if operation.starts_with("ax.") {
47 err = err.with_hint(
48 "For large UI trees, reduce --max-depth/--limit before retrying to keep AX queries bounded.",
49 );
50 }
51 err
52 }
53
54 pub fn ax_payload_encode(operation: &str, detail: impl Into<String>) -> Self {
55 Self::runtime(format!(
56 "{operation} failed: unable to encode AX request payload ({})",
57 detail.into().trim()
58 ))
59 .with_operation(operation)
60 .with_hint("Simplify selector/text input and retry.")
61 }
62
63 pub fn ax_parse_failure(operation: &str, detail: impl Into<String>) -> Self {
64 Self::runtime(format!(
65 "{operation} failed: invalid AX backend JSON response ({})",
66 detail.into().trim()
67 ))
68 .with_operation(operation)
69 .with_hint("Run `macos-agent preflight --include-probes --strict` to verify Accessibility/Automation access.")
70 .with_hint("Review preflight `ax_backend_capabilities` to confirm backend support and fallback behavior.")
71 .with_hint("Use --trace to capture raw backend output for diagnosis.")
72 }
73
74 pub fn ax_contract_failure(operation: &str, detail: impl Into<String>) -> Self {
75 Self::runtime(format!(
76 "{operation} failed: AX backend contract violation ({})",
77 detail.into().trim()
78 ))
79 .with_operation(operation)
80 .with_hint("Adjust AX selector filters so exactly one element is targeted.")
81 .with_hint("For attr/action/session/watch flows, ensure Hammerspoon backend is available.")
82 }
83
84 pub fn exit_code(&self) -> u8 {
85 self.exit_code
86 }
87
88 pub fn category(&self) -> ErrorCategory {
89 self.category
90 }
91
92 pub fn operation(&self) -> Option<&str> {
93 self.operation.as_deref()
94 }
95
96 pub fn hints(&self) -> &[String] {
97 &self.hints
98 }
99
100 pub fn message(&self) -> &str {
101 &self.message
102 }
103
104 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
105 let hint = hint.into();
106 if !hint.trim().is_empty() {
107 self.hints.push(hint);
108 }
109 self
110 }
111
112 pub fn with_operation(mut self, operation: impl Into<String>) -> Self {
113 let operation = operation.into();
114 if !operation.trim().is_empty() {
115 self.operation = Some(operation);
116 }
117 self
118 }
119}
120
121impl CliError {
122 fn new(message: impl Into<String>, exit_code: u8, category: ErrorCategory) -> Self {
123 let message = message
124 .into()
125 .trim()
126 .trim_start_matches("error:")
127 .trim()
128 .to_string();
129 Self {
130 message,
131 exit_code,
132 category,
133 operation: None,
134 hints: Vec::new(),
135 }
136 }
137}
138
139impl fmt::Display for CliError {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 write!(f, "error: {}", self.message)
142 }
143}
144
145impl std::error::Error for CliError {}