1use crate::CtlError;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Target {
13 Uia(Box<crate::selector::Selector>),
15 Ref(u32),
17 Name(String),
19 Id(String),
21 Role(String),
23 RoleAt(String, u32),
25 IdAt(String, u32),
29 Coord(i32, i32),
33}
34
35impl Target {
36 pub fn describe(&self) -> String {
38 match self {
39 Target::Uia(selector) => format!("uia:{}", serde_json::json!(selector)),
40 Target::Ref(n) => format!("@e{n}"),
41 Target::Name(s) => format!("name:{s}"),
42 Target::Id(s) => format!("id:{s}"),
43 Target::Role(s) => format!("role:{s}"),
44 Target::RoleAt(s, n) => format!("role:{s}#{n}"),
45 Target::IdAt(s, n) => format!("id:{s}#{n}"),
46 Target::Coord(x, y) => format!("xy:{x},{y}"),
47 }
48 }
49}
50
51pub fn parse_target(raw: &str) -> Result<Target, CtlError> {
53 if let Some(json) = raw.strip_prefix("uia:") {
54 return crate::selector::Selector::parse(json).map(|s| Target::Uia(Box::new(s)));
55 }
56 if let Some(n) = raw.strip_prefix("@e") {
57 let n: u32 = n.parse().map_err(|_| bad(raw))?;
58 if n == 0 {
59 return Err(bad(raw)); }
61 return Ok(Target::Ref(n));
62 }
63 if let Some(rest) = raw.strip_prefix("xy:") {
65 if let Some((xs, ys)) = rest.split_once(',')
66 && let (Ok(x), Ok(y)) = (xs.trim().parse::<i32>(), ys.trim().parse::<i32>())
67 {
68 return Ok(Target::Coord(x, y));
69 }
70 return Err(bad(raw));
71 }
72 for (prefix, build) in [
73 (
74 "name:",
75 (|s: String| Target::Name(s)) as fn(String) -> Target,
76 ),
77 ("id:", |s: String| Target::Id(s)),
78 ("role:", |s: String| Target::Role(s)),
79 ] {
80 if let Some(rest) = raw.strip_prefix(prefix) {
81 if rest.is_empty() {
82 return Err(bad(raw));
83 }
84 if (prefix == "role:" || prefix == "id:")
86 && let Some((key, idx)) = rest.rsplit_once('#')
87 {
88 let n: u32 = idx.parse().map_err(|_| bad(raw))?;
89 if key.is_empty() || n < 1 {
90 return Err(bad(raw));
91 }
92 return Ok(if prefix == "role:" {
93 Target::RoleAt(key.to_string(), n)
94 } else {
95 Target::IdAt(key.to_string(), n)
96 });
97 }
98 return Ok(build(rest.to_string()));
99 }
100 }
101 Err(bad(raw))
102}
103
104fn bad(raw: &str) -> CtlError {
105 CtlError::protocol(format!(
106 "invalid target {raw:?}: expected @eN, name:<substr>, id:<AutomationId>, role:<Type> or xy:<x>,<y>"
107 ))
108}
109
110pub fn fuzzy_contains(haystack: &str, needle: &str) -> bool {
113 let norm = |s: &str| {
114 s.split_whitespace()
115 .collect::<Vec<_>>()
116 .join(" ")
117 .to_lowercase()
118 };
119 let h = norm(haystack);
120 let n = norm(needle);
121 !n.is_empty() && h.contains(&n)
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::ErrorCode;
128
129 #[test]
130 fn parses_all_forms() {
131 assert_eq!(parse_target("@e3").unwrap(), Target::Ref(3));
132 assert_eq!(
133 parse_target("name:确定").unwrap(),
134 Target::Name("确定".into())
135 );
136 assert_eq!(
137 parse_target("id:num1Button").unwrap(),
138 Target::Id("num1Button".into())
139 );
140 assert_eq!(
141 parse_target("role:Button").unwrap(),
142 Target::Role("Button".into())
143 );
144 assert_eq!(
145 parse_target("role:Button#2").unwrap(),
146 Target::RoleAt("Button".into(), 2)
147 );
148 assert_eq!(
149 parse_target("id:RootWebArea#2").unwrap(),
150 Target::IdAt("RootWebArea".into(), 2)
151 );
152 assert_eq!(
153 parse_target("xy:1420,856").unwrap(),
154 Target::Coord(1420, 856)
155 );
156 assert_eq!(
158 parse_target("xy:-100,200").unwrap(),
159 Target::Coord(-100, 200)
160 );
161 assert_eq!(parse_target("xy:0,0").unwrap(), Target::Coord(0, 0));
162 }
163
164 #[test]
165 fn coord_edges_rejected() {
166 for bad in ["xy:100", "xy:100,", "xy:,200", "xy:a,b", "xy:", "xy:1,2,3"] {
167 let err = parse_target(bad).unwrap_err();
168 assert_eq!(err.code, ErrorCode::Protocol, "{bad:?} should be rejected");
169 }
170 }
171
172 #[test]
173 fn ordinal_edges() {
174 for bad in [
175 "role:Button#0",
176 "role:#2",
177 "role:Button#",
178 "role:Button#x",
179 "id:Foo#0",
180 "id:#2",
181 "id:Foo#",
182 "id:Foo#x",
183 ] {
184 assert!(parse_target(bad).is_err(), "{bad:?} should be rejected");
185 }
186 }
187
188 #[test]
189 fn fuzzy_matches_case_and_whitespace_insensitive() {
190 assert!(fuzzy_contains("保存 设置", "保存 设置"));
191 assert!(fuzzy_contains("Open File", "open file"));
192 assert!(!fuzzy_contains("Open", "open file"));
193 assert!(!fuzzy_contains("anything", ""));
194 }
195
196 #[test]
197 fn rejects_bare_words_and_edge_cases() {
198 for bad in [
199 "button", "@e", "@e0", "@ex", "name:", "id:", "role:", "@e-1",
200 ] {
201 let err = parse_target(bad).unwrap_err();
202 assert_eq!(err.code, ErrorCode::Protocol, "{bad:?} should be PROTOCOL");
203 }
204 }
205
206 #[test]
207 fn describe_round_trips_for_messages() {
208 assert_eq!(parse_target("@e7").unwrap().describe(), "@e7");
209 }
210}