1use serde::{Deserialize, Serialize};
7
8pub mod keys;
9pub mod snapshot;
10pub mod state;
11pub mod target;
12pub mod timing;
13pub mod trace;
14
15pub use keys::{Key, KeySpec, parse_key_expr};
16pub use snapshot::{
17 ElementOut, INTERACTIVE_ROLES, SnapshotBuilder, SnapshotOutput, UiNode, is_interactive_role,
18 new_snapshot_id,
19};
20pub use target::{Target, parse_target};
21
22pub const ENVELOPE_VERSION: &str = "1";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
28pub enum ErrorCode {
29 StaleRef,
31 NotFound,
33 PermDenied,
35 Timeout,
37 Ambiguous,
39 NotActionable,
41 AssertionFailed,
43 Protocol,
45 Internal,
47 Aborted,
49}
50
51impl ErrorCode {
52 pub fn recovery_hint(self) -> &'static str {
54 match self {
55 Self::StaleRef => "run `snapshot` to refresh refs, then retry",
56 Self::NotFound => "relax the selector or run `find` to inspect candidates",
57 Self::PermDenied => {
58 "the target runs at a higher integrity level; ask the user to relaunch actl elevated — do not retry"
59 }
60 Self::Timeout => "re-evaluate preconditions; the UI may be busy or slow",
61 Self::Ambiguous => "tighten the selector or pick by index",
62 Self::NotActionable => {
63 "scroll or focus the element first; for value writes consider `set-value`"
64 }
65 Self::AssertionFailed => {
66 "inspect the prior step's result; the expected state did not materialize"
67 }
68 Self::Protocol => "fix the command arguments (see `actl <cmd> -h`)",
69 Self::Internal => {
70 "retry once; if it persists, report an issue with the `evidence` payload"
71 }
72 Self::Aborted => {
73 "user requested stop; clear the stop flag (actl-signal UI or delete the file) and retry"
74 }
75 }
76 }
77}
78
79#[derive(Debug, thiserror::Error)]
82#[error("{code:?}: {message}")]
83pub struct CtlError {
84 pub code: ErrorCode,
85 pub message: String,
86 pub evidence: Option<serde_json::Value>,
89}
90
91impl CtlError {
92 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
93 Self {
94 code,
95 message: message.into(),
96 evidence: None,
97 }
98 }
99
100 pub fn with_evidence(
101 code: ErrorCode,
102 message: impl Into<String>,
103 evidence: serde_json::Value,
104 ) -> Self {
105 Self {
106 code,
107 message: message.into(),
108 evidence: Some(evidence),
109 }
110 }
111
112 pub fn protocol(message: impl Into<String>) -> Self {
113 Self::new(ErrorCode::Protocol, message)
114 }
115
116 pub fn internal(message: impl Into<String>) -> Self {
117 Self::new(ErrorCode::Internal, message)
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct Envelope<T> {
125 pub version: String,
126 pub ok: bool,
127 pub command: String,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub data: Option<T>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub snapshot_id: Option<String>,
132 pub duration_ms: u64,
133}
134
135impl<T> Envelope<T> {
136 pub fn success(command: impl Into<String>, data: Option<T>, duration_ms: u64) -> Self {
137 Self {
138 version: ENVELOPE_VERSION.to_owned(),
139 ok: true,
140 command: command.into(),
141 data,
142 snapshot_id: None,
143 duration_ms,
144 }
145 }
146
147 pub fn with_snapshot_id(mut self, id: impl Into<String>) -> Self {
148 self.snapshot_id = Some(id.into());
149 self
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct ErrorEnvelope {
156 pub version: String,
157 pub ok: bool,
158 pub command: String,
159 pub error: ErrorBody,
160 pub duration_ms: u64,
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164pub struct ErrorBody {
165 pub code: ErrorCode,
166 pub message: String,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub hint: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub evidence: Option<serde_json::Value>,
173}
174
175impl ErrorEnvelope {
176 pub fn new(command: impl Into<String>, err: &CtlError, duration_ms: u64) -> Self {
177 Self {
178 version: ENVELOPE_VERSION.to_owned(),
179 ok: false,
180 command: command.into(),
181 error: ErrorBody {
182 code: err.code,
183 message: err.message.clone(),
184 hint: Some(err.code.recovery_hint().to_owned()),
185 evidence: err.evidence.clone(),
186 },
187 duration_ms,
188 }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use serde_json::json;
196
197 #[test]
198 fn error_codes_serialize_as_screaming_snake_case() {
199 assert_eq!(
200 serde_json::to_string(&ErrorCode::StaleRef).unwrap(),
201 r#""STALE_REF""#
202 );
203 assert_eq!(
204 serde_json::to_string(&ErrorCode::AssertionFailed).unwrap(),
205 r#""ASSERTION_FAILED""#
206 );
207 assert_eq!(
208 serde_json::to_string(&ErrorCode::Internal).unwrap(),
209 r#""INTERNAL""#
210 );
211 }
212
213 #[test]
214 fn every_error_code_has_a_recovery_hint() {
215 let all = [
216 ErrorCode::StaleRef,
217 ErrorCode::NotFound,
218 ErrorCode::PermDenied,
219 ErrorCode::Timeout,
220 ErrorCode::Ambiguous,
221 ErrorCode::NotActionable,
222 ErrorCode::AssertionFailed,
223 ErrorCode::Protocol,
224 ErrorCode::Aborted,
225 ErrorCode::Internal,
226 ];
227 for c in all {
228 assert!(!c.recovery_hint().is_empty(), "{c:?} missing hint");
229 }
230 }
231
232 #[test]
233 fn success_envelope_shape_matches_contract() {
234 let data = json!({ "action": "click", "ref": "@e3" });
235 let env = Envelope::success("click", Some(&data), 42);
236 let json: serde_json::Value = serde_json::to_value(&env).unwrap();
237 assert_eq!(json["version"], "1");
238 assert_eq!(json["ok"], true);
239 assert_eq!(json["command"], "click");
240 assert_eq!(json["duration_ms"], 42);
241 assert_eq!(json["data"]["action"], "click");
242 }
243
244 #[test]
245 fn none_data_and_snapshot_id_are_omitted() {
246 let env = Envelope::success("snapshot", None::<u8>, 5);
247 let json = serde_json::to_string(&env).unwrap();
248 assert!(!json.contains("\"data\""));
249 assert!(!json.contains("\"snapshot_id\""));
250 }
251
252 #[test]
253 fn snapshot_id_round_trips() {
254 let env = Envelope::success("snapshot", None::<u8>, 5).with_snapshot_id("s8f3k2p9");
255 let json: serde_json::Value = serde_json::to_value(&env).unwrap();
256 assert_eq!(json["snapshot_id"], "s8f3k2p9");
257 }
258
259 #[test]
260 fn error_envelope_shape_matches_contract() {
261 let err = CtlError::new(ErrorCode::StaleRef, "element @e3 no longer resolves");
262 let env = ErrorEnvelope::new("click", &err, 17);
263 let json: serde_json::Value = serde_json::to_value(&env).unwrap();
264 assert_eq!(json["ok"], false);
265 assert_eq!(json["command"], "click");
266 assert_eq!(json["error"]["code"], "STALE_REF");
267 assert!(json["error"]["hint"].as_str().unwrap().contains("snapshot"));
268 assert!(json.get("evidence").is_none());
269 }
270}