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 > 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    /// Catch-all for runner / driver / IO failures.
47    DriverError,
48}
49
50/// Structured failure thrown by SDK matchers and driver calls. Shape
51/// is AI-feed-back-ready.
52///
53/// `selector` is the typed `Selector` rather than the wire
54/// `serde_json::Value` — callers raising failures from the driver / SDK
55/// side already have the typed selector, and `to_prompt` invokes
56/// `describe_selector` for stable rendering.
57///
58/// Serializes as `{ ok: false, code, message, selector, suggestions,
59/// visibleElements, hint, screenshot, deviceLog }`; the `ok = false`
60/// discriminant identifies the failure branch of a `Result` wire.
61#[derive(Clone, Debug, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct ExpectationFailure {
64    /// Always `false` for failures. Round-trip discriminator for
65    /// `Result<T, ExpectationFailure>` wire.
66    pub ok: False,
67    /// Failure code discriminator.
68    pub code: FailureCode,
69    /// Human-readable summary (and AI-readable when fed into `to_prompt`).
70    pub message: String,
71    /// Originating selector, when one is available.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub selector: Option<Selector>,
74    /// Ranked near-miss suggestions (edit-distance to label / id / etc.).
75    #[serde(default)]
76    pub suggestions: Vec<String>,
77    /// Snapshot of visible+enabled elements at failure time.
78    #[serde(default)]
79    pub visible_elements: Vec<ElementSummary>,
80    /// Optional one-line hint (e.g. "try `app.wait_for(...)` first").
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub hint: Option<String>,
83    /// base64-encoded PNG; omitted from default rendering to keep logs lean.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub screenshot: Option<String>,
86    /// Last-N-lines of captured device log, folded into AI-readable
87    /// output for failure-window system context.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub device_log: Vec<String>,
90}
91
92/// Newtype around `false` — used as the [`ExpectationFailure::ok`]
93/// discriminator. Serializes/deserializes as the JSON literal `false`.
94#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(transparent)]
96pub struct False(pub bool);
97
98/// Builder/Init form for ergonomic construction.
99#[derive(Default)]
100pub struct FailureInit {
101    /// Optional failure code (defaults to `DriverError` if `None`).
102    pub code: Option<FailureCode>,
103    /// Human-readable / AI-readable message.
104    pub message: String,
105    /// Originating selector, when one is available.
106    pub selector: Option<Selector>,
107    /// Pre-built ranked near-miss suggestions.
108    pub suggestions: Vec<String>,
109    /// Captured visible+enabled elements at failure time.
110    pub visible_elements: Vec<ElementSummary>,
111    /// Optional one-line hint string.
112    pub hint: Option<String>,
113    /// Optional base64 PNG screenshot at failure time.
114    pub screenshot: Option<String>,
115    /// Optional captured device log tail.
116    pub device_log: Vec<String>,
117}
118
119impl ExpectationFailure {
120    /// Construct from an init struct. `code` defaults to `DriverError`
121    /// if `init.code` was None (defensive; SDK callers should always
122    /// set it).
123    pub fn new(init: FailureInit) -> Self {
124        ExpectationFailure {
125            ok: False(false),
126            code: init.code.unwrap_or(FailureCode::DriverError),
127            message: init.message,
128            selector: init.selector,
129            suggestions: init.suggestions,
130            visible_elements: init.visible_elements,
131            hint: init.hint,
132            screenshot: init.screenshot,
133            device_log: init.device_log,
134        }
135    }
136
137    /// AI-facing rendering. Designed so the output can be pasted back
138    /// as a user message into a coding agent.
139    #[must_use]
140    pub fn to_prompt(&self) -> String {
141        let mut lines: Vec<String> = Vec::new();
142        lines.push(format!(
143            "FAIL [{}]: {}",
144            format_code(self.code),
145            self.message
146        ));
147        if let Some(sel) = &self.selector {
148            lines.push(format!("  selector: {}", describe_selector(sel)));
149        }
150        if !self.suggestions.is_empty() {
151            lines.push("  suggestions:".into());
152            for s in &self.suggestions {
153                lines.push(format!("    - {}", s));
154            }
155        }
156        if !self.visible_elements.is_empty() {
157            let n = self.visible_elements.len().min(10);
158            lines.push(format!("  visible elements (top {}):", n));
159            for el in self.visible_elements.iter().take(10) {
160                lines.push(format!("    - {}", render_element(el)));
161            }
162        }
163        if let Some(h) = &self.hint {
164            lines.push(format!("  hint: {}", h));
165        }
166        if !self.device_log.is_empty() {
167            // LOG_PROMPT_CAP — defensive ceiling for AI prompt size.
168            const LOG_PROMPT_CAP: usize = 200;
169            let n = self.device_log.len();
170            lines.push(format!("  device log (last {} lines):", n));
171            let start = n.saturating_sub(LOG_PROMPT_CAP);
172            for dl in &self.device_log[start..] {
173                lines.push(format!("    - {}", dl));
174            }
175        }
176        lines.join("\n")
177    }
178}
179
180impl fmt::Display for ExpectationFailure {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(f, "{}", self.to_prompt())
183    }
184}
185
186impl std::error::Error for ExpectationFailure {}
187
188fn format_code(c: FailureCode) -> &'static str {
189    match c {
190        FailureCode::ElementNotFound => "ELEMENT_NOT_FOUND",
191        FailureCode::NotVisible => "NOT_VISIBLE",
192        FailureCode::NotEnabled => "NOT_ENABLED",
193        FailureCode::Ambiguous => "AMBIGUOUS",
194        FailureCode::Timeout => "TIMEOUT",
195        FailureCode::AssertionFailed => "ASSERTION_FAILED",
196        FailureCode::AppNotRunning => "APP_NOT_RUNNING",
197        FailureCode::SimulatorNotBooted => "SIMULATOR_NOT_BOOTED",
198        FailureCode::DriverError => "DRIVER_ERROR",
199    }
200}
201
202fn render_element(el: &ElementSummary) -> String {
203    let role_str = el.role.map(|r| r.as_str()).unwrap_or("unknown");
204    let mut bits: Vec<String> = vec![role_str.to_string()];
205    if let Some(n) = &el.name {
206        bits.push(format!("name={:?}", n));
207    }
208    if let Some(i) = &el.id {
209        bits.push(format!("id=\"{}\"", i));
210    }
211    if let Some(t) = &el.text
212        && Some(t) != el.name.as_ref()
213    {
214        bits.push(format!("text={:?}", t));
215    }
216    if !el.enabled {
217        bits.push("disabled".into());
218    }
219    bits.join(" ")
220}
221
222// -------------------- buildSuggestions ----------------------------------
223
224const SUGGESTION_THRESHOLD: f64 = 0.5;
225const SUGGESTION_TOP_N: usize = 3;
226
227/// Generate "Did you mean ...?" suggestions from the current visible
228/// element list. Contract: threshold > 0.5, top 3, score desc →
229/// field (name > text) → DFS index asc.
230///
231/// `target = None` → empty vec.
232#[must_use]
233pub fn build_suggestions(target: Option<&str>, visible: &[ElementSummary]) -> Vec<String> {
234    let Some(target) = target else {
235        return Vec::new();
236    };
237    let lower_target = target.to_lowercase();
238    let mut candidates: Vec<(f64, &'static str, String, usize)> = Vec::new();
239    for (i, el) in visible.iter().enumerate() {
240        let mut best: Option<(f64, &'static str, String)> = None;
241        if let Some(name) = &el.name
242            && !name.is_empty()
243        {
244            let s = similarity(&name.to_lowercase(), &lower_target);
245            if s > SUGGESTION_THRESHOLD {
246                best = Some((s, "name", name.clone()));
247            }
248        }
249        if let Some(text) = &el.text
250            && !text.is_empty()
251        {
252            let s = similarity(&text.to_lowercase(), &lower_target);
253            if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
254                best = Some((s, "text", text.clone()));
255            }
256        }
257        if let Some((score, field, value)) = best {
258            candidates.push((score, field, value, i));
259        }
260    }
261    // Sort: score desc → field name>text → index asc.
262    candidates.sort_by(|a, b| {
263        b.0.partial_cmp(&a.0)
264            .unwrap_or(std::cmp::Ordering::Equal)
265            .then_with(|| match (a.1, b.1) {
266                ("name", "text") => std::cmp::Ordering::Less,
267                ("text", "name") => std::cmp::Ordering::Greater,
268                _ => std::cmp::Ordering::Equal,
269            })
270            .then_with(|| a.3.cmp(&b.3))
271    });
272    candidates
273        .into_iter()
274        .take(SUGGESTION_TOP_N)
275        .map(|(score, field, value, _)| {
276            format!(
277                "Did you mean {:?}? (similarity {:.2}, field {})",
278                value, score, field
279            )
280        })
281        .collect()
282}
283
284/// Normalized `[0, 1]` string similarity. 1 = identical,
285/// 0 = completely different.
286#[must_use]
287pub fn similarity(a: &str, b: &str) -> f64 {
288    if a == b {
289        return 1.0;
290    }
291    let (longer, shorter) = if a.chars().count() >= b.chars().count() {
292        (a, b)
293    } else {
294        (b, a)
295    };
296    let llen = longer.chars().count();
297    if llen == 0 {
298        return 1.0;
299    }
300    let dist = edit_distance(longer, shorter);
301    (llen as f64 - dist as f64) / llen as f64
302}
303
304/// Levenshtein edit distance (Wagner-Fischer).
305#[must_use]
306pub fn edit_distance(a: &str, b: &str) -> usize {
307    let a_chars: Vec<char> = a.chars().collect();
308    let b_chars: Vec<char> = b.chars().collect();
309    let a_len = a_chars.len();
310    let b_len = b_chars.len();
311    let mut dp: Vec<usize> = (0..=b_len).collect();
312    for i in 1..=a_len {
313        let mut prev = dp[0];
314        dp[0] = i;
315        for j in 1..=b_len {
316            let tmp = dp[j];
317            dp[j] = if a_chars[i - 1] == b_chars[j - 1] {
318                prev
319            } else {
320                1 + dp[j].min(dp[j - 1]).min(prev)
321            };
322            prev = tmp;
323        }
324    }
325    dp[b_len]
326}