smix_recorder_ir/lib.rs
1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-recorder-ir — IRAction (intermediate representation) +
6//! RecorderError + sort helper (stone, cold path).
7//!
8//! Ported from now-retired TS source: `src/recorder/ir.ts` (v2.0 c3 + v2.0 c4 cleanup
9//! failure reasons). Each [`IRAction`] is one user-visible side-effecting
10//! step in a recorded session; the IR feeds generator-smix-ts +
11//! generator-maestro-yaml dual output (c{N}+ port).
12//!
13//! Scope note (TS v2.0 c3): IR is captured from the **smix API channel**
14//! (host-side instrumentation in `RecordSession`), not from sim-side AX
15//! notification swizzle. The swizzle path was dug in v2.0 c2/c3 S1 and
16//! confirmed unable to surface user-tap events from outside the smix API
17//! channel. "User taps sim screen manually with smix watching" is a
18//! separate architecture (v2.1 字面方向 paused — see docs/roadmap.md).
19
20#![doc(html_root_url = "https://docs.smix.dev/smix-recorder-ir")]
21
22use serde::{Deserialize, Serialize};
23use smix_input::{KeyName, SwipeDirection};
24use smix_selector::Selector;
25use std::fmt;
26
27/// One user-visible side-effecting step in a recorded session.
28///
29/// Wire serializes as an externally-tagged enum keyed by `kind` (跟 TS
30/// `{ kind: 'tap', selector: ..., timestampMs: ... }` 1:1 — serde
31/// `tag = "kind", rename_all = "camelCase"` emits exactly the same shape).
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33#[serde(tag = "kind", rename_all = "camelCase")]
34pub enum IRAction {
35 /// Tap an element matched by `selector`.
36 Tap {
37 /// Selector picking the tap target.
38 selector: Selector,
39 /// Capture-time timestamp in milliseconds.
40 #[serde(rename = "timestampMs")]
41 timestamp_ms: f64,
42 },
43 /// Tap an element and type `text` into it.
44 Fill {
45 /// Selector picking the input field.
46 selector: Selector,
47 /// Text to type after the tap.
48 text: String,
49 /// Capture-time timestamp in milliseconds.
50 #[serde(rename = "timestampMs")]
51 timestamp_ms: f64,
52 },
53 /// Tap an element and clear its value.
54 Clear {
55 /// Selector picking the input to clear.
56 selector: Selector,
57 /// Capture-time timestamp in milliseconds.
58 #[serde(rename = "timestampMs")]
59 timestamp_ms: f64,
60 },
61 /// Press a single keyboard key.
62 PressKey {
63 /// Key name (`Return` / `Tab` / arrows / etc.).
64 key: KeyName,
65 /// Capture-time timestamp in milliseconds.
66 #[serde(rename = "timestampMs")]
67 timestamp_ms: f64,
68 },
69 /// Swipe in a direction, optionally anchored to an element.
70 Swipe {
71 /// Swipe direction.
72 direction: SwipeDirection,
73 /// Optional anchor element (swipe starts from its centroid).
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 from: Option<Selector>,
76 /// Capture-time timestamp in milliseconds.
77 #[serde(rename = "timestampMs")]
78 timestamp_ms: f64,
79 },
80 /// Navigate back (system back gesture / button).
81 GoBack {
82 /// Capture-time timestamp in milliseconds.
83 #[serde(rename = "timestampMs")]
84 timestamp_ms: f64,
85 },
86 /// Block playback until an element becomes visible.
87 WaitFor {
88 /// Selector to wait for.
89 selector: Selector,
90 /// Capture-time timestamp in milliseconds.
91 #[serde(rename = "timestampMs")]
92 timestamp_ms: f64,
93 },
94 /// Dismiss the on-screen keyboard.
95 HideKeyboard {
96 /// Capture-time timestamp in milliseconds.
97 #[serde(rename = "timestampMs")]
98 timestamp_ms: f64,
99 },
100}
101
102impl IRAction {
103 /// Timestamp in milliseconds (Unix epoch or session-relative, depending
104 /// on capture site). Stable accessor across variants.
105 #[must_use]
106 pub fn timestamp_ms(&self) -> f64 {
107 match self {
108 IRAction::Tap { timestamp_ms, .. }
109 | IRAction::Fill { timestamp_ms, .. }
110 | IRAction::Clear { timestamp_ms, .. }
111 | IRAction::PressKey { timestamp_ms, .. }
112 | IRAction::Swipe { timestamp_ms, .. }
113 | IRAction::GoBack { timestamp_ms }
114 | IRAction::WaitFor { timestamp_ms, .. }
115 | IRAction::HideKeyboard { timestamp_ms } => *timestamp_ms,
116 }
117 }
118
119 /// Wire `kind` string for log / generator / debug rendering. Mirrors
120 /// the camelCase serde tag.
121 #[must_use]
122 pub fn kind(&self) -> &'static str {
123 match self {
124 IRAction::Tap { .. } => "tap",
125 IRAction::Fill { .. } => "fill",
126 IRAction::Clear { .. } => "clear",
127 IRAction::PressKey { .. } => "pressKey",
128 IRAction::Swipe { .. } => "swipe",
129 IRAction::GoBack { .. } => "goBack",
130 IRAction::WaitFor { .. } => "waitFor",
131 IRAction::HideKeyboard { .. } => "hideKeyboard",
132 }
133 }
134}
135
136// -------------------- RecorderError --------------------------------------
137
138/// Reasons a recorder session might fail. v2.0 c4 extended with three
139/// `cleanup-*` variants to surface claude-CLI AI-cleanup failures
140/// distinctly from the recording itself.
141#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143pub enum RecorderErrorReason {
144 /// Session had zero actions when generation was requested.
145 EmptySession,
146 /// One action in the session was structurally invalid.
147 MalformedAction,
148 /// Claude CLI cleanup subprocess exited non-zero.
149 CleanupFailed,
150 /// Claude CLI cleanup returned an empty body.
151 CleanupEmptyOutput,
152 /// Claude CLI cleanup returned a body that failed validation.
153 CleanupInvalidOutput,
154}
155
156impl RecorderErrorReason {
157 /// kebab-case wire string.
158 #[must_use]
159 pub fn as_str(self) -> &'static str {
160 match self {
161 RecorderErrorReason::EmptySession => "empty-session",
162 RecorderErrorReason::MalformedAction => "malformed-action",
163 RecorderErrorReason::CleanupFailed => "cleanup-failed",
164 RecorderErrorReason::CleanupEmptyOutput => "cleanup-empty-output",
165 RecorderErrorReason::CleanupInvalidOutput => "cleanup-invalid-output",
166 }
167 }
168}
169
170impl fmt::Display for RecorderErrorReason {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 f.write_str(self.as_str())
173 }
174}
175
176/// Recorder-side failure, distinct from SDK-side ExpectationFailure.
177/// CLI / SDK consumers may surface this directly or wrap with context.
178#[derive(Clone, Debug)]
179pub struct RecorderError {
180 /// Failure-reason discriminator.
181 pub reason: RecorderErrorReason,
182 /// Free-form human-readable detail.
183 pub message: String,
184}
185
186impl RecorderError {
187 /// Construct a [`RecorderError`] with reason + message.
188 #[must_use]
189 pub fn new<S: Into<String>>(reason: RecorderErrorReason, message: S) -> Self {
190 RecorderError {
191 reason,
192 message: message.into(),
193 }
194 }
195}
196
197impl fmt::Display for RecorderError {
198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 write!(f, "RecorderError[{}]: {}", self.reason, self.message)
200 }
201}
202
203impl std::error::Error for RecorderError {}
204
205// -------------------- sort helper ----------------------------------------
206
207/// Sort actions by timestamp ascending. Idempotent. Used when multiple
208/// recorders feed the same session (e.g. parallel cells, future feature)
209/// to merge chronologically. Mirrors TS `sortByTimestamp` 1:1.
210#[must_use]
211pub fn sort_by_timestamp(actions: &[IRAction]) -> Vec<IRAction> {
212 let mut out = actions.to_vec();
213 out.sort_by(|a, b| {
214 a.timestamp_ms()
215 .partial_cmp(&b.timestamp_ms())
216 .unwrap_or(std::cmp::Ordering::Equal)
217 });
218 out
219}