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")]
29pub enum FailureCode {
30    /// Selector matched zero elements in the visible tree.
31    ElementNotFound,
32    /// Element matched but failed the visibility filter.
33    NotVisible,
34    /// Element matched but `enabled = false`.
35    NotEnabled,
36    /// Selector matched multiple elements (when uniqueness was required).
37    Ambiguous,
38    /// Operation exceeded the implicit-wait budget.
39    Timeout,
40    /// `expect` assertion (e.g. `toHaveText`) did not hold.
41    AssertionFailed,
42    /// Target app exited or never launched.
43    AppNotRunning,
44    /// Simulator device is not booted.
45    SimulatorNotBooted,
46    /// The touch was synthesised, and it did not land inside the
47    /// element the selector matched.
48    ///
49    /// Distinct from `ElementNotFound` because the two send a reader
50    /// somewhere different: not-found means fix the selector, missed
51    /// means the element was there and the touch went elsewhere — a
52    /// stale frame, or something moved between the tree fetch and the
53    /// tap.
54    TapMissed,
55    /// Catch-all for runner / driver / IO failures.
56    DriverError,
57}
58
59/// Structured failure thrown by SDK matchers and driver calls. Shape
60/// is AI-feed-back-ready.
61///
62/// `selector` is the typed `Selector` rather than the wire
63/// `serde_json::Value` — callers raising failures from the driver / SDK
64/// side already have the typed selector, and `to_prompt` invokes
65/// `describe_selector` for stable rendering.
66///
67/// Serializes as `{ ok: false, code, message, selector, suggestions,
68/// visibleElements, hint, screenshot, deviceLog }`; the `ok = false`
69/// discriminant identifies the failure branch of a `Result` wire.
70#[derive(Clone, Debug, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct ExpectationFailure {
73    /// Always `false` for failures. Round-trip discriminator for
74    /// `Result<T, ExpectationFailure>` wire.
75    pub ok: False,
76    /// Failure code discriminator.
77    pub code: FailureCode,
78    /// Human-readable summary (and AI-readable when fed into `to_prompt`).
79    pub message: String,
80    /// Originating selector, when one is available.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub selector: Option<Selector>,
83    /// Ranked near-miss suggestions (edit-distance to label / id / etc.).
84    #[serde(default)]
85    pub suggestions: Vec<String>,
86    /// Snapshot of visible+enabled elements at failure time.
87    #[serde(default)]
88    pub visible_elements: Vec<ElementSummary>,
89    /// Optional one-line hint (e.g. "try `app.wait_for(...)` first").
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub hint: Option<String>,
92    /// The smix that produced this failure.
93    ///
94    /// Not decoration. A consumer wrote up two defects against smix
95    /// behaviour that had been fixed hours earlier, quoting an error
96    /// message the current build no longer emits — they had no way to
97    /// tell whether the smix in front of them contained the fix for
98    /// the thing they had just hit. A failure that names its version
99    /// makes that answerable from the failure itself.
100    ///
101    /// Set by [`ExpectationFailure::new`] from the crate version, so no
102    /// call site can forget it.
103    #[serde(default)]
104    pub smix_version: String,
105    /// base64-encoded PNG; omitted from default rendering to keep logs lean.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub screenshot: Option<String>,
108    /// Last-N-lines of captured device log, folded into AI-readable
109    /// output for failure-window system context.
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub device_log: Vec<String>,
112}
113
114/// Newtype around `false` — used as the [`ExpectationFailure::ok`]
115/// discriminator. Serializes/deserializes as the JSON literal `false`.
116#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(transparent)]
118pub struct False(pub bool);
119
120/// Builder/Init form for ergonomic construction.
121#[derive(Default)]
122pub struct FailureInit {
123    /// Optional failure code (defaults to `DriverError` if `None`).
124    pub code: Option<FailureCode>,
125    /// Human-readable / AI-readable message.
126    pub message: String,
127    /// Originating selector, when one is available.
128    pub selector: Option<Selector>,
129    /// Pre-built ranked near-miss suggestions.
130    pub suggestions: Vec<String>,
131    /// Captured visible+enabled elements at failure time.
132    pub visible_elements: Vec<ElementSummary>,
133    /// Optional one-line hint string.
134    pub hint: Option<String>,
135    /// Optional base64 PNG screenshot at failure time.
136    pub screenshot: Option<String>,
137    /// Optional captured device log tail.
138    pub device_log: Vec<String>,
139}
140
141impl ExpectationFailure {
142    /// Construct from an init struct. `code` defaults to `DriverError`
143    /// if `init.code` was None (defensive; SDK callers should always
144    /// set it).
145    pub fn new(init: FailureInit) -> Self {
146        ExpectationFailure {
147            ok: False(false),
148            code: init.code.unwrap_or(FailureCode::DriverError),
149            message: init.message,
150            selector: init.selector,
151            suggestions: init.suggestions,
152            visible_elements: init.visible_elements,
153            hint: init.hint,
154            smix_version: env!("CARGO_PKG_VERSION").to_string(),
155            screenshot: init.screenshot,
156            device_log: init.device_log,
157        }
158    }
159
160    /// AI-facing rendering. Designed so the output can be pasted back
161    /// as a user message into a coding agent.
162    #[must_use]
163    pub fn to_prompt(&self) -> String {
164        let mut lines: Vec<String> = Vec::new();
165        lines.push(format!(
166            "FAIL [{}]: {}",
167            format_code(self.code),
168            self.message
169        ));
170        // The version, on every failure, because the reader's next
171        // question after "what went wrong" is often "is my smix old".
172        // A consumer once wrote up two defects that had been fixed
173        // hours earlier, quoting a message this build no longer emits.
174        if !self.smix_version.is_empty() {
175            lines.push(format!("  smix: {}", self.smix_version));
176        }
177        if let Some(sel) = &self.selector {
178            lines.push(format!("  selector: {}", describe_selector(sel)));
179        }
180        if !self.suggestions.is_empty() {
181            lines.push("  suggestions:".into());
182            for s in &self.suggestions {
183                lines.push(format!("    - {}", s));
184            }
185        }
186        if !self.visible_elements.is_empty() {
187            let n = self.visible_elements.len().min(10);
188            lines.push(format!("  visible elements (top {}):", n));
189            for el in self.visible_elements.iter().take(10) {
190                lines.push(format!("    - {}", render_element(el)));
191            }
192        }
193        if let Some(h) = &self.hint {
194            lines.push(format!("  hint: {}", h));
195        }
196        if !self.device_log.is_empty() {
197            // LOG_PROMPT_CAP — defensive ceiling for AI prompt size.
198            const LOG_PROMPT_CAP: usize = 200;
199            let n = self.device_log.len();
200            lines.push(format!("  device log (last {} lines):", n));
201            let start = n.saturating_sub(LOG_PROMPT_CAP);
202            for dl in &self.device_log[start..] {
203                lines.push(format!("    - {}", dl));
204            }
205        }
206        lines.join("\n")
207    }
208}
209
210impl fmt::Display for ExpectationFailure {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        write!(f, "{}", self.to_prompt())
213    }
214}
215
216impl std::error::Error for ExpectationFailure {}
217
218fn format_code(c: FailureCode) -> &'static str {
219    match c {
220        FailureCode::ElementNotFound => "ELEMENT_NOT_FOUND",
221        FailureCode::NotVisible => "NOT_VISIBLE",
222        FailureCode::NotEnabled => "NOT_ENABLED",
223        FailureCode::Ambiguous => "AMBIGUOUS",
224        FailureCode::Timeout => "TIMEOUT",
225        FailureCode::AssertionFailed => "ASSERTION_FAILED",
226        FailureCode::AppNotRunning => "APP_NOT_RUNNING",
227        FailureCode::SimulatorNotBooted => "SIMULATOR_NOT_BOOTED",
228        FailureCode::TapMissed => "TAP_MISSED",
229        FailureCode::DriverError => "DRIVER_ERROR",
230    }
231}
232
233fn render_element(el: &ElementSummary) -> String {
234    let role_str = el.role.map(|r| r.as_str()).unwrap_or("unknown");
235    let mut bits: Vec<String> = vec![role_str.to_string()];
236    if let Some(n) = &el.name {
237        bits.push(format!("name={:?}", n));
238    }
239    if let Some(i) = &el.id {
240        bits.push(format!("id=\"{}\"", i));
241    }
242    if let Some(t) = &el.text
243        && Some(t) != el.name.as_ref()
244    {
245        bits.push(format!("text={:?}", t));
246    }
247    if !el.enabled {
248        bits.push("disabled".into());
249    }
250    bits.join(" ")
251}
252
253// -------------------- buildSuggestions ----------------------------------
254
255const SUGGESTION_THRESHOLD: f64 = 0.5;
256const SUGGESTION_TOP_N: usize = 3;
257
258/// Generate "Did you mean ...?" suggestions from the current visible
259/// element list. Contract: threshold > 0.5, top 3, score desc →
260/// field (name > text) → DFS index asc.
261///
262/// `target = None` → empty vec.
263#[must_use]
264pub fn build_suggestions(target: Option<&str>, visible: &[ElementSummary]) -> Vec<String> {
265    let Some(target) = target else {
266        return Vec::new();
267    };
268    let lower_target = target.to_lowercase();
269    let mut candidates: Vec<(f64, &'static str, String, usize)> = Vec::new();
270    for (i, el) in visible.iter().enumerate() {
271        let mut best: Option<(f64, &'static str, String)> = None;
272        if let Some(name) = &el.name
273            && !name.is_empty()
274        {
275            let s = similarity(&name.to_lowercase(), &lower_target);
276            if s > SUGGESTION_THRESHOLD {
277                best = Some((s, "name", name.clone()));
278            }
279        }
280        if let Some(id) = &el.id
281            && !id.is_empty()
282        {
283            let s = similarity(&id.to_lowercase(), &lower_target);
284            if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
285                best = Some((s, "id", id.clone()));
286            }
287        }
288        if let Some(text) = &el.text
289            && !text.is_empty()
290        {
291            let s = similarity(&text.to_lowercase(), &lower_target);
292            if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
293                best = Some((s, "text", text.clone()));
294            }
295        }
296        if let Some((score, field, value)) = best {
297            candidates.push((score, field, value, i));
298        }
299    }
300    // Sort: score desc → field name>text → index asc.
301    let field_rank = |f: &str| match f {
302        "name" => 0,
303        "id" => 1,
304        _ => 2, // text
305    };
306    candidates.sort_by(|a, b| {
307        b.0.partial_cmp(&a.0)
308            .unwrap_or(std::cmp::Ordering::Equal)
309            .then_with(|| field_rank(a.1).cmp(&field_rank(b.1)))
310            .then_with(|| a.3.cmp(&b.3))
311    });
312    candidates
313        .into_iter()
314        .take(SUGGESTION_TOP_N)
315        .map(|(score, field, value, _)| {
316            format!(
317                "Did you mean {:?}? (similarity {:.2}, field {})",
318                value, score, field
319            )
320        })
321        .collect()
322}
323
324/// Normalized `[0, 1]` string similarity. 1 = identical,
325/// 0 = completely different.
326#[must_use]
327pub fn similarity(a: &str, b: &str) -> f64 {
328    if a == b {
329        return 1.0;
330    }
331    let (longer, shorter) = if a.chars().count() >= b.chars().count() {
332        (a, b)
333    } else {
334        (b, a)
335    };
336    let llen = longer.chars().count();
337    if llen == 0 {
338        return 1.0;
339    }
340    let dist = edit_distance(longer, shorter);
341    (llen as f64 - dist as f64) / llen as f64
342}
343
344/// Levenshtein edit distance (Wagner-Fischer).
345#[must_use]
346pub fn edit_distance(a: &str, b: &str) -> usize {
347    let a_chars: Vec<char> = a.chars().collect();
348    let b_chars: Vec<char> = b.chars().collect();
349    let a_len = a_chars.len();
350    let b_len = b_chars.len();
351    let mut dp: Vec<usize> = (0..=b_len).collect();
352    for i in 1..=a_len {
353        let mut prev = dp[0];
354        dp[0] = i;
355        for j in 1..=b_len {
356            let tmp = dp[j];
357            dp[j] = if a_chars[i - 1] == b_chars[j - 1] {
358                prev
359            } else {
360                1 + dp[j].min(dp[j - 1]).min(prev)
361            };
362            prev = tmp;
363        }
364    }
365    dp[b_len]
366}