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