Skip to main content

cosh_tools/computer/
errors.rs

1//! Error-rendering component — turns xa11y's classified platform errors
2//! into messages that tell the model (and the user reading the trace) what
3//! to do next.
4//!
5//! This is a COMPONENT, not a tool: it owns the `Error` variant → guidance
6//! mapping and nothing else. Every computer tool routes its `map_err`
7//! layer through [`render`] so a broken platform condition never reaches
8//! the model as a bare `Platform error (-1): …` — the error text is the
9//! one channel that always gets through, so it must carry the fix.
10//!
11//! Design (plan phase 7): xa11y already classifies these conditions —
12//! `PermissionDenied` carries consent instructions, `SelectorNotMatched`
13//! and `Timeout` carry a [`Diagnosis`] — so this layer NEVER flattens that
14//! context away; it prepends the tool/context prefix, renders the variant
15//! guidance, and appends the diagnosis verbatim.
16use xa11y::Error;
17
18/// Render an xa11y error for the model, prefixed by the failing tool and
19/// what it was doing (`ctx`, e.g. `"resolve application"`).
20///
21/// The mapping is total: every [`Error`] variant produces either concrete
22/// next-step guidance or — where the error already says exactly what is
23/// wrong (`InvalidSelector`, `InvalidActionData`) — the verbatim display
24/// plus the fix that applies to the CALL SHAPE (an argument problem is not
25/// a platform problem; the model fixes it by editing the call, not by
26/// changing system settings).
27pub fn render(tool: &str, ctx: &str, err: &Error) -> String {
28    let head = format!("{tool}: {ctx}: ");
29    match err {
30        Error::PermissionDenied { instructions } => format!(
31            "{head}permission denied — {instructions} After granting, retry the \
32             same call; the consent applies to the whole terminal session"
33        ),
34        Error::AccessibilityNotEnabled { app, instructions } => {
35            format!("{head}accessibility not enabled for {app} — {instructions}")
36        }
37        Error::SelectorNotMatched {
38            selector,
39            diagnosis,
40        } => {
41            let mut out = format!(
42                "{head}no element matched `{selector}` — re-capture with \
43                 computer_snapshot and match an element that exists NOW \
44                 (role[name='…'] is stable; a bare :nth index goes stale \
45                 after any UI change)"
46            );
47            out.push_str(&diagnosis_suffix(diagnosis.as_deref()));
48            out
49        }
50        Error::ElementStale { selector } => format!(
51            "{head}element `{selector}` went stale (the UI changed between \
52             capture and action) — re-snapshot and act on the fresh tree"
53        ),
54        Error::ActionNotSupported { action, role } => format!(
55            "{head}action `{action}` is not supported on a {role} element — \
56             try the action appropriate to its role instead (`press` on \
57             buttons, `toggle` on check boxes, `show_menu` on menu items), \
58             or act on a parent container"
59        ),
60        Error::TextValueNotSupported => format!(
61            "{head}this element does not accept text input through the \
62             accessibility bridge — for a slider/spinner use `set_numeric_value` \
63             with `numeric_value`, otherwise click it first (computer_control, \
64             the real click is the one reliable focus mover) and type with a \
65             keyboard step instead"
66        ),
67        Error::Timeout { elapsed, diagnosis } => {
68            let mut out = format!(
69                "{head}timed out after {elapsed:.1?} — if the target is slow \
70                 to appear, raise `timeout_ms` or add a `wait` step before \
71                 this one"
72            );
73            out.push_str(&diagnosis_suffix(diagnosis.as_deref()));
74            out
75        }
76        Error::InvalidSelector { selector, message } => {
77            format!(
78                "{head}selector `{selector}` is invalid: {message} — fix the selector syntax and retry (no platform call was made)"
79            )
80        }
81        Error::InvalidActionData { message } => {
82            format!(
83                "{head}invalid action data: {message} — fix the call arguments and retry (no platform call was made)"
84            )
85        }
86        Error::InvalidConfig { message } => {
87            format!(
88                "{head}invalid configuration: {message} — fix the environment setting and restart the session"
89            )
90        }
91        Error::NoElementBounds => format!(
92            "{head}the element matched but has no on-screen bounds — it may \
93             sit in a collapsed container or be virtual; scroll it into \
94             view (computer_act `scroll_into_view`) or act on a visible \
95             ancestor instead"
96        ),
97        Error::Unsupported { feature } => format!(
98            "{head}`{feature}` has no implementation on this platform/session — a \
99             capability limit, not a fixable call error. Input synthesis and \
100             screen capture both depend on the session type (native X11, or \
101             Wayland with the right portal grants), so pick an approach that \
102             fits what this session exposes"
103        ),
104        Error::Platform { code, message } => format!(
105            "{head}platform error ({code}): {message} — a provider-level failure \
106             whose meaning depends on the operation (an accessibility-backend \
107             fault, a capture or image-processing failure, or an argument the \
108             backend rejected); one retry is reasonable, but if unchanged \
109             arguments fail the same way, stop and report it to the user \
110             instead of looping"
111        ),
112        // `Error` is #[non_exhaustive]: future variants must still render
113        // with the tool prefix and a next-step suggestion, never a bare
114        // Display string.
115        other => format!(
116            "{head}{} — re-capture with computer_snapshot to check the \
117             current state before retrying",
118            other
119        ),
120    }
121}
122
123/// Render an APPLICATION-resolution miss (`App::by_name`/`by_pid`), the
124/// same rendering [`render`] produces plus the transient-shell hint: a
125/// flyout (Quick Settings, Notification Center, a shell context menu) is
126/// never an application — it does not appear in `computer_apps` and no app
127/// name can reach it, so the fix is surface targeting, not another lookup.
128pub fn render_app_miss(tool: &str, err: &Error) -> String {
129    let out = render(tool, "resolve application", err);
130    match err {
131        Error::SelectorNotMatched { .. } => format!(
132            "{out} — a name that matches no APPLICATION may belong to a \
133             transient shell surface (Quick Settings, Notification Center, \
134             a shell menu): those never appear as applications. Target them \
135             with `surface` instead (e.g. `\"flyout\"` for an open flyout, \
136             `\"menu_bar\"` for menus)"
137        ),
138        _ => out,
139    }
140}
141
142/// Render the attached [`Diagnosis`] — verbatim, since xa11y's rendering
143/// already carries the condition, last-observed state, near-miss
144/// candidates and scope dump (and its Display already opens with its own
145/// `; `). Nothing here may flatten it.
146fn diagnosis_suffix(diagnosis: Option<&xa11y::Diagnosis>) -> String {
147    match diagnosis {
148        None => String::new(),
149        Some(d) => {
150            let rendered = d.to_string();
151            if rendered.starts_with(';') {
152                format!(" {rendered}")
153            } else {
154                format!("; {rendered}")
155            }
156        }
157    }
158}