Skip to main content

smix_error/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-error — ExpectationFailure + FailureCode + AI-readable
6//! `to_prompt()` + `build_suggestions` (stone, cold path).
7//!
8//! `build_suggestions` behavior: threshold > 0.5, top 3, sort by score
9//! descending → field (name > id > text) → DFS index ascending.
10//!
11//! # Why a separate stone
12//!
13//! SDK / driver / runner-client all throw / catch `ExpectationFailure`. As
14//! the canonical failure type it must live in a leaf crate that everyone
15//! can depend on. Failure messages MUST be AI-readable — `to_prompt()`
16//! is the canonical render.
17
18#![doc(html_root_url = "https://docs.smix.dev/smix-error")]
19
20use serde::{Deserialize, Serialize};
21use smix_screen::ElementSummary;
22use smix_selector::{Selector, describe_selector};
23use std::fmt;
24
25/// All failure codes smix surfaces back to the SDK / MCP / CLI
26/// (SCREAMING_SNAKE_CASE wire).
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
29// New codes keep arriving — two in two releases — and on an exhaustive
30// enum each one is a major, because a consumer matching every arm stops
31// compiling. That is the wrong price for smix naming a failure more
32// precisely, and it was about to be paid for the second time.
33//
34// The cost lands once, here, in a major that was already owed: every
35// downstream `match` needs a catch-all arm from now on. What it buys is
36// that the next code is additive.
37//
38// The guard that made a new variant fail to compile lives inside this
39// crate now (see `the_vocabulary_is_pinned` below). From out here
40// `non_exhaustive` would have made it accept anything, so it moved
41// rather than being weakened.
42#[non_exhaustive]
43pub enum FailureCode {
44    /// Selector matched zero elements in the visible tree.
45    ElementNotFound,
46    /// Element matched but failed the visibility filter.
47    NotVisible,
48    /// Element matched but `enabled = false`.
49    NotEnabled,
50    /// Selector matched multiple elements (when uniqueness was required).
51    Ambiguous,
52    /// Operation exceeded the implicit-wait budget.
53    Timeout,
54    /// `expect` assertion (e.g. `toHaveText`) did not hold.
55    AssertionFailed,
56    /// Target app exited or never launched.
57    AppNotRunning,
58    /// Simulator device is not booted.
59    SimulatorNotBooted,
60    /// The touch was synthesised, and it did not land inside the
61    /// element the selector matched.
62    ///
63    /// Distinct from `ElementNotFound` because the two send a reader
64    /// somewhere different: not-found means fix the selector, missed
65    /// means the element was there and the touch went elsewhere — a
66    /// stale frame, or something moved between the tree fetch and the
67    /// tap.
68    TapMissed,
69    /// The screen is described in one coordinate space and the touch
70    /// would be delivered in another, so no aim can land where the tree
71    /// says the element is.
72    ///
73    /// Distinct from `TapMissed`, and the distinction is the whole
74    /// point: a miss says the element was there and the touch went
75    /// elsewhere, which invites another attempt with a better point.
76    /// There is no better point here — whatever is passed gets
77    /// recomputed against the app's frame and then read against the
78    /// device's. Retrying is the trap, and the consumer who found this
79    /// spent an afternoon in it.
80    CoordinateSpaceMismatch,
81    /// Catch-all for runner / driver / IO failures.
82    DriverError,
83}
84
85/// Structured failure thrown by SDK matchers and driver calls. Shape
86/// is AI-feed-back-ready.
87///
88/// `selector` is the typed `Selector` rather than the wire
89/// `serde_json::Value` — callers raising failures from the driver / SDK
90/// side already have the typed selector, and `to_prompt` invokes
91/// `describe_selector` for stable rendering.
92///
93/// Serializes as `{ ok: false, code, message, selector, suggestions,
94/// visibleElements, hint, screenshot, deviceLog }`; the `ok = false`
95/// discriminant identifies the failure branch of a `Result` wire.
96#[derive(Clone, Debug, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct ExpectationFailure {
99    /// Always `false` for failures. Round-trip discriminator for
100    /// `Result<T, ExpectationFailure>` wire.
101    pub ok: False,
102    /// Failure code discriminator.
103    pub code: FailureCode,
104    /// Human-readable summary (and AI-readable when fed into `to_prompt`).
105    pub message: String,
106    /// Originating selector, when one is available.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub selector: Option<Selector>,
109    /// Ranked near-miss suggestions (edit-distance to label / id / etc.).
110    #[serde(default)]
111    pub suggestions: Vec<String>,
112    /// Snapshot of visible+enabled elements at failure time.
113    #[serde(default)]
114    pub visible_elements: Vec<ElementSummary>,
115    /// Optional one-line hint (e.g. "try `app.wait_for(...)` first").
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub hint: Option<String>,
118    /// The smix that produced this failure.
119    ///
120    /// Not decoration. A consumer wrote up two defects against smix
121    /// behaviour that had been fixed hours earlier, quoting an error
122    /// message the current build no longer emits — they had no way to
123    /// tell whether the smix in front of them contained the fix for
124    /// the thing they had just hit. A failure that names its version
125    /// makes that answerable from the failure itself.
126    ///
127    /// Set by [`ExpectationFailure::new`] from the crate version, so no
128    /// call site can forget it.
129    #[serde(default)]
130    pub smix_version: String,
131    /// base64-encoded PNG; omitted from default rendering to keep logs lean.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub screenshot: Option<String>,
134    /// Last-N-lines of captured device log, folded into AI-readable
135    /// output for failure-window system context.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub device_log: Vec<String>,
138}
139
140/// Newtype around `false` — used as the [`ExpectationFailure::ok`]
141/// discriminator. Serializes/deserializes as the JSON literal `false`.
142#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(transparent)]
144pub struct False(pub bool);
145
146/// Builder/Init form for ergonomic construction.
147#[derive(Default)]
148pub struct FailureInit {
149    /// Optional failure code (defaults to `DriverError` if `None`).
150    pub code: Option<FailureCode>,
151    /// Human-readable / AI-readable message.
152    pub message: String,
153    /// Originating selector, when one is available.
154    pub selector: Option<Selector>,
155    /// Pre-built ranked near-miss suggestions.
156    pub suggestions: Vec<String>,
157    /// Captured visible+enabled elements at failure time.
158    pub visible_elements: Vec<ElementSummary>,
159    /// Optional one-line hint string.
160    pub hint: Option<String>,
161    /// Optional base64 PNG screenshot at failure time.
162    pub screenshot: Option<String>,
163    /// Optional captured device log tail.
164    pub device_log: Vec<String>,
165}
166
167impl ExpectationFailure {
168    /// Construct from an init struct. `code` defaults to `DriverError`
169    /// if `init.code` was None (defensive; SDK callers should always
170    /// set it).
171    pub fn new(init: FailureInit) -> Self {
172        ExpectationFailure {
173            ok: False(false),
174            code: init.code.unwrap_or(FailureCode::DriverError),
175            message: init.message,
176            selector: init.selector,
177            suggestions: init.suggestions,
178            visible_elements: init.visible_elements,
179            hint: init.hint,
180            smix_version: env!("CARGO_PKG_VERSION").to_string(),
181            screenshot: init.screenshot,
182            device_log: init.device_log,
183        }
184    }
185
186    /// AI-facing rendering. Designed so the output can be pasted back
187    /// as a user message into a coding agent.
188    #[must_use]
189    pub fn to_prompt(&self) -> String {
190        let mut lines: Vec<String> = Vec::new();
191        lines.push(format!(
192            "FAIL [{}]: {}",
193            format_code(self.code),
194            self.message
195        ));
196        // The version, on every failure, because the reader's next
197        // question after "what went wrong" is often "is my smix old".
198        // A consumer once wrote up two defects that had been fixed
199        // hours earlier, quoting a message this build no longer emits.
200        if !self.smix_version.is_empty() {
201            lines.push(format!("  smix: {}", self.smix_version));
202        }
203        if let Some(sel) = &self.selector {
204            lines.push(format!("  selector: {}", describe_selector(sel)));
205        }
206        if !self.suggestions.is_empty() {
207            lines.push("  suggestions:".into());
208            for s in &self.suggestions {
209                lines.push(format!("    - {}", s));
210            }
211        }
212        if !self.visible_elements.is_empty() {
213            let n = self.visible_elements.len().min(10);
214            lines.push(format!("  visible elements (top {}):", n));
215            for el in self.visible_elements.iter().take(10) {
216                lines.push(format!("    - {}", render_element(el)));
217            }
218        }
219        if let Some(h) = &self.hint {
220            lines.push(format!("  hint: {}", h));
221        }
222        if !self.device_log.is_empty() {
223            // LOG_PROMPT_CAP — defensive ceiling for AI prompt size.
224            const LOG_PROMPT_CAP: usize = 200;
225            let n = self.device_log.len();
226            lines.push(format!("  device log (last {} lines):", n));
227            let start = n.saturating_sub(LOG_PROMPT_CAP);
228            for dl in &self.device_log[start..] {
229                lines.push(format!("    - {}", dl));
230            }
231        }
232        lines.join("\n")
233    }
234}
235
236impl fmt::Display for ExpectationFailure {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        write!(f, "{}", self.to_prompt())
239    }
240}
241
242impl std::error::Error for ExpectationFailure {}
243
244fn format_code(c: FailureCode) -> &'static str {
245    match c {
246        FailureCode::ElementNotFound => "ELEMENT_NOT_FOUND",
247        FailureCode::NotVisible => "NOT_VISIBLE",
248        FailureCode::NotEnabled => "NOT_ENABLED",
249        FailureCode::Ambiguous => "AMBIGUOUS",
250        FailureCode::Timeout => "TIMEOUT",
251        FailureCode::AssertionFailed => "ASSERTION_FAILED",
252        FailureCode::AppNotRunning => "APP_NOT_RUNNING",
253        FailureCode::SimulatorNotBooted => "SIMULATOR_NOT_BOOTED",
254        FailureCode::TapMissed => "TAP_MISSED",
255        FailureCode::CoordinateSpaceMismatch => "COORDINATE_SPACE_MISMATCH",
256        FailureCode::DriverError => "DRIVER_ERROR",
257    }
258}
259
260fn render_element(el: &ElementSummary) -> String {
261    let role_str = el.role.map(|r| r.as_str()).unwrap_or("unknown");
262    let mut bits: Vec<String> = vec![role_str.to_string()];
263    if let Some(n) = &el.name {
264        bits.push(format!("name={:?}", n));
265    }
266    if let Some(i) = &el.id {
267        bits.push(format!("id=\"{}\"", i));
268    }
269    if let Some(t) = &el.text
270        && Some(t) != el.name.as_ref()
271    {
272        bits.push(format!("text={:?}", t));
273    }
274    if !el.enabled {
275        bits.push("disabled".into());
276    }
277    bits.join(" ")
278}
279
280// -------------------- buildSuggestions ----------------------------------
281
282const SUGGESTION_THRESHOLD: f64 = 0.5;
283const SUGGESTION_TOP_N: usize = 3;
284
285/// Generate "Did you mean ...?" suggestions from the current visible
286/// element list. Contract: threshold > 0.5, top 3, score desc →
287/// field (name > text) → DFS index asc.
288///
289/// `target = None` → empty vec.
290#[must_use]
291pub fn build_suggestions(target: Option<&str>, visible: &[ElementSummary]) -> Vec<String> {
292    let Some(target) = target else {
293        return Vec::new();
294    };
295    let lower_target = target.to_lowercase();
296    let mut candidates: Vec<(f64, &'static str, String, usize)> = Vec::new();
297    for (i, el) in visible.iter().enumerate() {
298        let mut best: Option<(f64, &'static str, String)> = None;
299        if let Some(name) = &el.name
300            && !name.is_empty()
301        {
302            let s = similarity(&name.to_lowercase(), &lower_target);
303            if s > SUGGESTION_THRESHOLD {
304                best = Some((s, "name", name.clone()));
305            }
306        }
307        if let Some(id) = &el.id
308            && !id.is_empty()
309        {
310            let s = similarity(&id.to_lowercase(), &lower_target);
311            if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
312                best = Some((s, "id", id.clone()));
313            }
314        }
315        if let Some(text) = &el.text
316            && !text.is_empty()
317        {
318            let s = similarity(&text.to_lowercase(), &lower_target);
319            if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
320                best = Some((s, "text", text.clone()));
321            }
322        }
323        if let Some((score, field, value)) = best {
324            candidates.push((score, field, value, i));
325        }
326    }
327    // Sort: score desc → field name>text → index asc.
328    let field_rank = |f: &str| match f {
329        "name" => 0,
330        "id" => 1,
331        _ => 2, // text
332    };
333    candidates.sort_by(|a, b| {
334        b.0.partial_cmp(&a.0)
335            .unwrap_or(std::cmp::Ordering::Equal)
336            .then_with(|| field_rank(a.1).cmp(&field_rank(b.1)))
337            .then_with(|| a.3.cmp(&b.3))
338    });
339    candidates
340        .into_iter()
341        .take(SUGGESTION_TOP_N)
342        .map(|(score, field, value, _)| {
343            format!(
344                "Did you mean {:?}? (similarity {:.2}, field {})",
345                value, score, field
346            )
347        })
348        .collect()
349}
350
351/// Normalized `[0, 1]` string similarity. 1 = identical,
352/// 0 = completely different.
353#[must_use]
354pub fn similarity(a: &str, b: &str) -> f64 {
355    if a == b {
356        return 1.0;
357    }
358    let (longer, shorter) = if a.chars().count() >= b.chars().count() {
359        (a, b)
360    } else {
361        (b, a)
362    };
363    let llen = longer.chars().count();
364    if llen == 0 {
365        return 1.0;
366    }
367    let dist = edit_distance(longer, shorter);
368    (llen as f64 - dist as f64) / llen as f64
369}
370
371/// Levenshtein edit distance (Wagner-Fischer).
372#[must_use]
373pub fn edit_distance(a: &str, b: &str) -> usize {
374    let a_chars: Vec<char> = a.chars().collect();
375    let b_chars: Vec<char> = b.chars().collect();
376    let a_len = a_chars.len();
377    let b_len = b_chars.len();
378    let mut dp: Vec<usize> = (0..=b_len).collect();
379    for i in 1..=a_len {
380        let mut prev = dp[0];
381        dp[0] = i;
382        for j in 1..=b_len {
383            let tmp = dp[j];
384            dp[j] = if a_chars[i - 1] == b_chars[j - 1] {
385                prev
386            } else {
387                1 + dp[j].min(dp[j - 1]).min(prev)
388            };
389            prev = tmp;
390        }
391    }
392    dp[b_len]
393}
394
395#[cfg(test)]
396mod vocabulary {
397    use super::*;
398
399    /// A new `FailureCode` must not compile until it has been thought
400    /// about everywhere.
401    ///
402    /// This match is exhaustive on purpose and lives inside the crate on
403    /// purpose: `#[non_exhaustive]` makes an outside `match` accept
404    /// anything, so the guard that used to sit in
405    /// `tests/sdk_failure_code_parity.rs` would have started passing for
406    /// every future variant. Adding a code should break this line, then
407    /// the wire-string arm below it, then the three SDK declarations the
408    /// parity test reads, then the errors guide.
409    #[test]
410    fn the_vocabulary_is_pinned() {
411        let every = [
412            FailureCode::ElementNotFound,
413            FailureCode::NotVisible,
414            FailureCode::NotEnabled,
415            FailureCode::Ambiguous,
416            FailureCode::Timeout,
417            FailureCode::AssertionFailed,
418            FailureCode::AppNotRunning,
419            FailureCode::SimulatorNotBooted,
420            FailureCode::TapMissed,
421            FailureCode::CoordinateSpaceMismatch,
422            FailureCode::DriverError,
423        ];
424        for code in every {
425            match code {
426                FailureCode::ElementNotFound
427                | FailureCode::NotVisible
428                | FailureCode::NotEnabled
429                | FailureCode::Ambiguous
430                | FailureCode::Timeout
431                | FailureCode::AssertionFailed
432                | FailureCode::AppNotRunning
433                | FailureCode::SimulatorNotBooted
434                | FailureCode::TapMissed
435                | FailureCode::CoordinateSpaceMismatch
436                | FailureCode::DriverError => {}
437            }
438            // Every code renders as a wire string, and a code whose
439            // string nobody wrote would reach a consumer as whatever
440            // the formatter defaults to.
441            assert!(!format_code(code).is_empty(), "{code:?} has no wire string");
442        }
443        assert_eq!(
444            every.len(),
445            11,
446            "counted off the list above, not remembered"
447        );
448    }
449}