Skip to main content

smix_ffi/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3
4//! smix-ffi — UniFFI v0.29+ FFI scaffolding crate (v7.0 c2 MVP).
5//!
6//! Wraps Rust stone API (smix-selector + smix-selector-resolver + smix-screen)
7//! into cross-language bindings via UniFFI. v7.0 c3+ 起 expand 到 ~10
8//! runner async fn + cancellation siblings per design.md D8 + spike #4
9//! workaround.
10//!
11//! Generated bindings (post-build):
12//!   - Swift via `uniffi-bindgen-swift` → `bindings/swift/SmixFFI.swift`
13//!   - Kotlin via `uniffi-bindgen --language kotlin` → `bindings/kotlin/...`
14
15use smix_screen::A11yNode;
16use smix_selector::Selector;
17
18// Include UniFFI scaffolding generated by build.rs from src/smix.udl
19uniffi::include_scaffolding!("smix");
20
21/// FFI error variants (mirror UDL `[Error]` enum).
22#[derive(Debug, thiserror::Error)]
23pub enum FfiError {
24    /// `tree_json` failed JSON parse to A11yNode.
25    #[error("invalid tree JSON: {0}")]
26    InvalidTreeJson(String),
27    /// `selector_json` failed JSON parse to Selector.
28    #[error("invalid selector JSON: {0}")]
29    InvalidSelectorJson(String),
30}
31
32/// Resolve a selector against an a11y tree, both passed as JSON strings.
33///
34/// Returns matched node ids (`Vec<String>`) — empty when no match.
35///
36/// # Errors
37/// - [`FfiError::InvalidTreeJson`] when `tree_json` fails to deserialize.
38/// - [`FfiError::InvalidSelectorJson`] when `selector_json` fails to deserialize.
39pub fn resolve_selector(tree_json: String, selector_json: String) -> Result<Vec<String>, FfiError> {
40    let tree: A11yNode =
41        serde_json::from_str(&tree_json).map_err(|e| FfiError::InvalidTreeJson(e.to_string()))?;
42    let selector: Selector = serde_json::from_str(&selector_json)
43        .map_err(|e| FfiError::InvalidSelectorJson(e.to_string()))?;
44
45    // v7.2 c5: respect index modifiers (first/last/nth).
46    //
47    // When the selector carries any index modifier, dispatch through
48    // `resolve_selector` (single result; applies index pick) to satisfy
49    // Playwright-shape "give me THE nth match" semantics. Otherwise fall
50    // back to `resolve_selector_all` which returns every match.
51    if has_index_modifier(&selector) {
52        let result = smix_selector_resolver::resolve_selector(&tree, &selector);
53        Ok(result
54            .map(|node| vec![node.identifier.clone().unwrap_or_default()])
55            .unwrap_or_default())
56    } else {
57        let matches = smix_selector_resolver::resolve_selector_all(&tree, &selector);
58        Ok(matches
59            .iter()
60            .map(|node| node.identifier.clone().unwrap_or_default())
61            .collect())
62    }
63}
64
65/// True when the selector (recursively) carries any index modifier
66/// (`first` / `last` / `nth`). Used to choose between `resolve_selector_all`
67/// (no index) and `resolve_selector` (single result, applies index pick).
68fn has_index_modifier(selector: &Selector) -> bool {
69    use smix_selector::Selector as S;
70    match selector {
71        S::Text { modifiers, .. }
72        | S::Id { modifiers, .. }
73        | S::Label { modifiers, .. }
74        | S::Role { modifiers, .. }
75        | S::LocalizedText { modifiers, .. } => modifiers_has_index(modifiers),
76        S::Anchor { index, .. } => {
77            index.nth.is_some() || index.first.is_some() || index.last.is_some()
78        }
79        // Other variants (Focused / OcrText / AnchorRelative / Point /
80        // etc.) — index modifiers are either N/A or absent from the v7.2
81        // c5 fixture surface. Future fixtures touching these can extend
82        // this match.
83        _ => false,
84    }
85}
86
87fn modifiers_has_index(m: &smix_selector::Modifiers) -> bool {
88    m.nth.is_some() || m.first.is_some() || m.last.is_some()
89}
90
91/// Count selector matches against a tree. Returns total match count
92/// (skips index modifier per Playwright "match all" semantics, mirroring
93/// `resolve_selector_all` behavior).
94///
95/// # Errors
96/// - [`FfiError::InvalidTreeJson`] when `tree_json` fails to deserialize.
97/// - [`FfiError::InvalidSelectorJson`] when `selector_json` fails to deserialize.
98pub fn resolve_selector_count(tree_json: String, selector_json: String) -> Result<u32, FfiError> {
99    let tree: A11yNode =
100        serde_json::from_str(&tree_json).map_err(|e| FfiError::InvalidTreeJson(e.to_string()))?;
101    let selector: Selector = serde_json::from_str(&selector_json)
102        .map_err(|e| FfiError::InvalidSelectorJson(e.to_string()))?;
103    let matches = smix_selector_resolver::resolve_selector_all(&tree, &selector);
104    Ok(u32::try_from(matches.len()).unwrap_or(u32::MAX))
105}
106
107/// Resolve selector and return each match's accessibility label.
108/// Returns a `Vec<String>` aligned 1:1 with the matched candidate set;
109/// empty string when the match's `.label` is None.
110///
111/// # Errors
112/// - [`FfiError::InvalidTreeJson`] when `tree_json` fails to deserialize.
113/// - [`FfiError::InvalidSelectorJson`] when `selector_json` fails to deserialize.
114pub fn resolve_selector_labels(
115    tree_json: String,
116    selector_json: String,
117) -> Result<Vec<String>, FfiError> {
118    let tree: A11yNode =
119        serde_json::from_str(&tree_json).map_err(|e| FfiError::InvalidTreeJson(e.to_string()))?;
120    let selector: Selector = serde_json::from_str(&selector_json)
121        .map_err(|e| FfiError::InvalidSelectorJson(e.to_string()))?;
122    let matches = smix_selector_resolver::resolve_selector_all(&tree, &selector);
123    Ok(matches
124        .iter()
125        .map(|node| node.label.clone().unwrap_or_default())
126        .collect())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// Sanity: empty tree + id selector that doesn't exist → empty Vec.
134    /// Mirrors `crates/smix-core-conformance/fixtures/spike-001-empty-tree.json`.
135    #[test]
136    fn spike_001_empty_tree_id_miss() {
137        let tree_json = r#"{"rawType":"other","bounds":{"x":0,"y":0,"w":393,"h":852},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]}"#;
138        let selector_json = r#"{"id":"nope"}"#;
139        let result = resolve_selector(tree_json.into(), selector_json.into()).unwrap();
140        assert!(
141            result.is_empty(),
142            "expected empty match on empty tree, got {result:?}"
143        );
144    }
145
146    #[test]
147    fn invalid_tree_json_returns_err() {
148        let result = resolve_selector("not json".into(), "{}".into());
149        assert!(matches!(result, Err(FfiError::InvalidTreeJson(_))));
150    }
151
152    #[test]
153    fn invalid_selector_json_returns_err() {
154        let tree_json = r#"{"rawType":"other","bounds":{"x":0,"y":0,"w":393,"h":852},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]}"#;
155        let result = resolve_selector(tree_json.into(), "not json".into());
156        assert!(matches!(result, Err(FfiError::InvalidSelectorJson(_))));
157    }
158
159    #[test]
160    fn resolve_selector_count_returns_zero_on_empty_tree() {
161        let tree_json = r#"{"rawType":"other","bounds":{"x":0,"y":0,"w":393,"h":852},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]}"#;
162        let selector_json = r#"{"id":"nope"}"#;
163        let n = resolve_selector_count(tree_json.into(), selector_json.into()).unwrap();
164        assert_eq!(n, 0);
165    }
166
167    #[test]
168    fn resolve_selector_count_matches_multiple() {
169        let tree_json = r#"{"rawType":"other","bounds":{"x":0,"y":0,"w":393,"h":852},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[{"rawType":"button","role":"button","identifier":"a","label":"Item","bounds":{"x":0,"y":0,"w":10,"h":10},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]},{"rawType":"button","role":"button","identifier":"b","label":"Item","bounds":{"x":0,"y":20,"w":10,"h":10},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]}]}"#;
170        let selector_json = r#"{"label":"Item"}"#;
171        let n = resolve_selector_count(tree_json.into(), selector_json.into()).unwrap();
172        assert_eq!(n, 2);
173    }
174
175    #[test]
176    fn resolve_selector_labels_returns_each_match_label() {
177        let tree_json = r#"{"rawType":"other","bounds":{"x":0,"y":0,"w":393,"h":852},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[{"rawType":"button","role":"button","identifier":"a","label":"First","bounds":{"x":0,"y":0,"w":10,"h":10},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]},{"rawType":"button","role":"button","identifier":"b","label":"Second","bounds":{"x":0,"y":20,"w":10,"h":10},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]}]}"#;
178        let selector_json = r#"{"role":"button"}"#;
179        let labels = resolve_selector_labels(tree_json.into(), selector_json.into()).unwrap();
180        assert_eq!(labels, vec!["First".to_string(), "Second".to_string()]);
181    }
182}