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