Skip to main content

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}