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