smix-ffi 1.0.3

smix-ffi — UniFFI v0.29+ scaffolding crate. Wraps smix-selector-resolver + smix-screen stone API for cross-language consumption (Swift Package / Kotlin .aar / Expo RN). C-ABI via uniffi-bindgen. iOS sim + macOS + Android targets via cargo build per-triple + xcodebuild -create-xcframework (iOS) / cargo-ndk (Android).
Documentation
#![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()]);
    }
}