#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
//! smix-ffi — UniFFI v0.29+ FFI scaffolding crate (v7.0 c2 MVP).
//!
//! Wraps Rust stone API (smix-selector + smix-selector-resolver + smix-screen)
//! into cross-language bindings via UniFFI. v7.0 c3+ 起 expand 到 ~10
//! runner async fn + cancellation siblings per design.md D8 + spike #4
//! workaround.
//!
//! Generated bindings (post-build):
//! - Swift via `uniffi-bindgen-swift` → `bindings/swift/SmixFFI.swift`
//! - Kotlin via `uniffi-bindgen --language kotlin` → `bindings/kotlin/...`
use smix_screen::A11yNode;
use smix_selector::Selector;
// Include UniFFI scaffolding generated by build.rs from src/smix.udl
uniffi::include_scaffolding!("smix");
/// FFI error variants (mirror UDL `[Error]` enum).
#[derive(Debug, thiserror::Error)]
pub enum FfiError {
/// `tree_json` failed JSON parse to A11yNode.
#[error("invalid tree JSON: {0}")]
InvalidTreeJson(String),
/// `selector_json` failed JSON parse to Selector.
#[error("invalid selector JSON: {0}")]
InvalidSelectorJson(String),
}
/// Resolve a selector against an a11y tree, both passed as JSON strings.
///
/// Returns matched node ids (`Vec<String>`) — empty when no match.
///
/// # Errors
/// - [`FfiError::InvalidTreeJson`] when `tree_json` fails to deserialize.
/// - [`FfiError::InvalidSelectorJson`] when `selector_json` fails to deserialize.
pub fn resolve_selector(tree_json: String, selector_json: String) -> Result<Vec<String>, FfiError> {
let tree: A11yNode =
serde_json::from_str(&tree_json).map_err(|e| FfiError::InvalidTreeJson(e.to_string()))?;
let selector: Selector = serde_json::from_str(&selector_json)
.map_err(|e| FfiError::InvalidSelectorJson(e.to_string()))?;
// v7.2 c5: respect index modifiers (first/last/nth).
//
// When the selector carries any index modifier, dispatch through
// `resolve_selector` (single result; applies index pick) to satisfy
// Playwright-shape "give me THE nth match" semantics. Otherwise fall
// back to `resolve_selector_all` which returns every match.
if has_index_modifier(&selector) {
let result = smix_selector_resolver::resolve_selector(&tree, &selector);
Ok(result
.map(|node| vec![node.identifier.clone().unwrap_or_default()])
.unwrap_or_default())
} else {
let matches = smix_selector_resolver::resolve_selector_all(&tree, &selector);
Ok(matches
.iter()
.map(|node| node.identifier.clone().unwrap_or_default())
.collect())
}
}
/// True when the selector (recursively) carries any index modifier
/// (`first` / `last` / `nth`). Used to choose between `resolve_selector_all`
/// (no index) and `resolve_selector` (single result, applies index pick).
fn has_index_modifier(selector: &Selector) -> bool {
use smix_selector::Selector as S;
match selector {
S::Text { modifiers, .. }
| S::Id { modifiers, .. }
| S::Label { modifiers, .. }
| S::Role { modifiers, .. }
| S::LocalizedText { modifiers, .. } => modifiers_has_index(modifiers),
S::Anchor { index, .. } => {
index.nth.is_some() || index.first.is_some() || index.last.is_some()
}
// Other variants (Focused / OcrText / AnchorRelative / Point /
// etc.) — index modifiers are either N/A or absent from the v7.2
// c5 fixture surface. Future fixtures touching these can extend
// this match.
_ => false,
}
}
fn modifiers_has_index(m: &smix_selector::Modifiers) -> bool {
m.nth.is_some() || m.first.is_some() || m.last.is_some()
}
/// Count selector matches against a tree. Returns total match count
/// (skips index modifier per Playwright "match all" semantics, mirroring
/// `resolve_selector_all` behavior).
///
/// # Errors
/// - [`FfiError::InvalidTreeJson`] when `tree_json` fails to deserialize.
/// - [`FfiError::InvalidSelectorJson`] when `selector_json` fails to deserialize.
pub fn resolve_selector_count(tree_json: String, selector_json: String) -> Result<u32, FfiError> {
let tree: A11yNode =
serde_json::from_str(&tree_json).map_err(|e| FfiError::InvalidTreeJson(e.to_string()))?;
let selector: Selector = serde_json::from_str(&selector_json)
.map_err(|e| FfiError::InvalidSelectorJson(e.to_string()))?;
let matches = smix_selector_resolver::resolve_selector_all(&tree, &selector);
Ok(u32::try_from(matches.len()).unwrap_or(u32::MAX))
}
/// Resolve selector and return each match's accessibility label.
/// Returns a `Vec<String>` aligned 1:1 with the matched candidate set;
/// empty string when the match's `.label` is None.
///
/// # Errors
/// - [`FfiError::InvalidTreeJson`] when `tree_json` fails to deserialize.
/// - [`FfiError::InvalidSelectorJson`] when `selector_json` fails to deserialize.
pub fn resolve_selector_labels(
tree_json: String,
selector_json: String,
) -> Result<Vec<String>, FfiError> {
let tree: A11yNode =
serde_json::from_str(&tree_json).map_err(|e| FfiError::InvalidTreeJson(e.to_string()))?;
let selector: Selector = serde_json::from_str(&selector_json)
.map_err(|e| FfiError::InvalidSelectorJson(e.to_string()))?;
let matches = smix_selector_resolver::resolve_selector_all(&tree, &selector);
Ok(matches
.iter()
.map(|node| node.label.clone().unwrap_or_default())
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
/// Sanity: empty tree + id selector that doesn't exist → empty Vec.
/// Mirrors `crates/smix-core-conformance/fixtures/spike-001-empty-tree.json`.
#[test]
fn spike_001_empty_tree_id_miss() {
let tree_json = r#"{"rawType":"other","bounds":{"x":0,"y":0,"w":393,"h":852},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]}"#;
let selector_json = r#"{"id":"nope"}"#;
let result = resolve_selector(tree_json.into(), selector_json.into()).unwrap();
assert!(
result.is_empty(),
"expected empty match on empty tree, got {result:?}"
);
}
#[test]
fn invalid_tree_json_returns_err() {
let result = resolve_selector("not json".into(), "{}".into());
assert!(matches!(result, Err(FfiError::InvalidTreeJson(_))));
}
#[test]
fn invalid_selector_json_returns_err() {
let tree_json = r#"{"rawType":"other","bounds":{"x":0,"y":0,"w":393,"h":852},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]}"#;
let result = resolve_selector(tree_json.into(), "not json".into());
assert!(matches!(result, Err(FfiError::InvalidSelectorJson(_))));
}
#[test]
fn resolve_selector_count_returns_zero_on_empty_tree() {
let tree_json = r#"{"rawType":"other","bounds":{"x":0,"y":0,"w":393,"h":852},"enabled":true,"selected":false,"hasFocus":false,"visible":true,"children":[]}"#;
let selector_json = r#"{"id":"nope"}"#;
let n = resolve_selector_count(tree_json.into(), selector_json.into()).unwrap();
assert_eq!(n, 0);
}
#[test]
fn resolve_selector_count_matches_multiple() {
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":[]}]}"#;
let selector_json = r#"{"label":"Item"}"#;
let n = resolve_selector_count(tree_json.into(), selector_json.into()).unwrap();
assert_eq!(n, 2);
}
#[test]
fn resolve_selector_labels_returns_each_match_label() {
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":[]}]}"#;
let selector_json = r#"{"role":"button"}"#;
let labels = resolve_selector_labels(tree_json.into(), selector_json.into()).unwrap();
assert_eq!(labels, vec!["First".to_string(), "Second".to_string()]);
}
}