1use 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}