Skip to main content

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}