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