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