Skip to main content

actl_core/
selector.rs

1//! Strict, persistent UIA queries. No fuzzy fallback or implicit ordinal selection.
2use crate::CtlError;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(deny_unknown_fields)]
7pub struct Filter {
8    #[serde(skip_serializing_if = "Option::is_none")]
9    pub role: Option<String>,
10    #[serde(skip_serializing_if = "Option::is_none")]
11    pub id: Option<String>,
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub name: Option<String>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub name_contains: Option<String>,
16}
17
18impl Filter {
19    fn validate(&self) -> Result<(), CtlError> {
20        let fields = [&self.role, &self.id, &self.name, &self.name_contains];
21        if fields.iter().all(|v| v.is_none()) {
22            return Err(CtlError::protocol(
23                "uia filter requires at least one condition",
24            ));
25        }
26        if self.name.is_some() && self.name_contains.is_some() {
27            return Err(CtlError::protocol(
28                "uia name and name_contains are mutually exclusive",
29            ));
30        }
31        for value in fields.into_iter().flatten() {
32            if value.is_empty() || value.len() > 4096 || value.contains('\0') {
33                return Err(CtlError::protocol(
34                    "uia conditions require 1-4096 UTF-8 bytes without NUL",
35                ));
36            }
37        }
38        Ok(())
39    }
40
41    pub fn matches(&self, role: Option<&str>, id: Option<&str>, name: Option<&str>) -> bool {
42        self.role
43            .as_ref()
44            .is_none_or(|v| role.is_some_and(|r| r.eq_ignore_ascii_case(v)))
45            && self.id.as_ref().is_none_or(|v| id == Some(v.as_str()))
46            && self.name.as_ref().is_none_or(|v| name == Some(v.as_str()))
47            && self
48                .name_contains
49                .as_ref()
50                .is_none_or(|v| name.is_some_and(|n| n.contains(v)))
51    }
52}
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum Scope {
57    Children,
58    #[default]
59    Descendants,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct Selector {
65    #[serde(rename = "match")]
66    pub filter: Filter,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub within: Option<Filter>,
69    #[serde(default)]
70    pub scope: Scope,
71}
72
73impl Selector {
74    pub fn parse(raw: &str) -> Result<Self, CtlError> {
75        if raw.len() > 16_384 {
76            return Err(CtlError::protocol("uia selector exceeds 16 KiB"));
77        }
78        let selector: Self = serde_json::from_str(raw)
79            .map_err(|e| CtlError::protocol(format!("invalid uia selector: {e}")))?;
80        selector.filter.validate()?;
81        if let Some(within) = &selector.within {
82            within.validate()?;
83        }
84        Ok(selector)
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn strict_conditions_and_literal_punctuation() {
94        let s =
95            Selector::parse(r##"{"match":{"role":"button","id":"x#2","name":"Save > \"copy\""}}"##)
96                .unwrap();
97        assert!(
98            s.filter
99                .matches(Some("Button"), Some("x#2"), Some("Save > \"copy\""))
100        );
101        assert!(
102            !s.filter
103                .matches(Some("Edit"), Some("x#2"), Some("Save > \"copy\""))
104        );
105        assert!(
106            !s.filter
107                .matches(Some("Button"), None, Some("Save > \"copy\""))
108        );
109        assert!(
110            !s.filter
111                .matches(Some("Button"), Some("x#2"), Some("save > \"copy\""))
112        );
113    }
114
115    #[test]
116    fn rejects_ambiguous_or_unbounded_syntax() {
117        for raw in [
118            r#"{}"#,
119            r#"{"match":{}}"#,
120            r#"{"match":{"name":""}}"#,
121            r#"{"match":{"name":"x","name_contains":"x"}}"#,
122            r#"{"match":{"id":"a","id":"b"}}"#,
123            r#"{"match":{"id":"a"},"match":{"id":"b"}}"#,
124            r#"{"match":{"role":"Button","typo":1}}"#,
125            r#"{"match":{"id":"x"},"within":{}}"#,
126            r#"{"match":{"id":"x"},"within":{"within":{"id":"y"}}}"#,
127            r#"{"match":{"id":"x"},"scope":"siblings"}"#,
128            r#"{"match":{"id":"x"},"nth":1}"#,
129            r#"{"match":{"id":"\u0000"}}"#,
130        ] {
131            assert!(Selector::parse(raw).is_err(), "{raw}");
132        }
133        assert!(Selector::parse(&" ".repeat(16_385)).is_err());
134    }
135
136    #[test]
137    fn contains_is_explicit_and_case_sensitive() {
138        let s = Selector::parse(
139            r#"{"match":{"name_contains":"Save"},"within":{"role":"Pane"},"scope":"children"}"#,
140        )
141        .unwrap();
142        assert!(s.filter.matches(None, None, Some("Save copy")));
143        assert!(!s.filter.matches(None, None, Some("save copy")));
144        assert_eq!(s.scope, Scope::Children);
145    }
146
147    #[test]
148    fn target_roundtrip_keeps_the_same_semantics() {
149        let raw = r#"uia:{"match":{"name":"保存","role":"Button"},"within":{"id":"panel"}}"#;
150        let target = crate::parse_target(raw).unwrap();
151        assert_eq!(crate::parse_target(&target.describe()).unwrap(), target);
152    }
153}