1use crate::CtlError;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Target {
11 Ref(u32),
13 Name(String),
15 Id(String),
17 Role(String),
19 RoleAt(String, u32),
21}
22
23impl Target {
24 pub fn describe(&self) -> String {
26 match self {
27 Target::Ref(n) => format!("@e{n}"),
28 Target::Name(s) => format!("name:{s}"),
29 Target::Id(s) => format!("id:{s}"),
30 Target::Role(s) => format!("role:{s}"),
31 Target::RoleAt(s, n) => format!("role:{s}#{n}"),
32 }
33 }
34}
35
36pub fn parse_target(raw: &str) -> Result<Target, CtlError> {
38 if let Some(n) = raw.strip_prefix("@e") {
39 let n: u32 = n.parse().map_err(|_| bad(raw))?;
40 if n == 0 {
41 return Err(bad(raw)); }
43 return Ok(Target::Ref(n));
44 }
45 for (prefix, build) in [
46 (
47 "name:",
48 (|s: String| Target::Name(s)) as fn(String) -> Target,
49 ),
50 ("id:", |s: String| Target::Id(s)),
51 ("role:", |s: String| Target::Role(s)),
52 ] {
53 if let Some(rest) = raw.strip_prefix(prefix) {
54 if rest.is_empty() {
55 return Err(bad(raw));
56 }
57 if prefix == "role:" {
59 if let Some((role, idx)) = rest.rsplit_once('#') {
60 let n: u32 = idx.parse().map_err(|_| bad(raw))?;
61 if !role.is_empty() && n >= 1 {
62 return Ok(Target::RoleAt(role.to_string(), n));
63 }
64 return Err(bad(raw));
65 }
66 }
67 return Ok(build(rest.to_string()));
68 }
69 }
70 Err(bad(raw))
71}
72
73fn bad(raw: &str) -> CtlError {
74 CtlError::protocol(format!(
75 "invalid target {raw:?}: expected @eN, name:<substr>, id:<AutomationId> or role:<Type>"
76 ))
77}
78
79pub fn fuzzy_contains(haystack: &str, needle: &str) -> bool {
82 let norm = |s: &str| {
83 s.split_whitespace()
84 .collect::<Vec<_>>()
85 .join(" ")
86 .to_lowercase()
87 };
88 let h = norm(haystack);
89 let n = norm(needle);
90 !n.is_empty() && h.contains(&n)
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use crate::ErrorCode;
97
98 #[test]
99 fn parses_all_four_forms() {
100 assert_eq!(parse_target("@e3").unwrap(), Target::Ref(3));
101 assert_eq!(
102 parse_target("name:确定").unwrap(),
103 Target::Name("确定".into())
104 );
105 assert_eq!(
106 parse_target("id:num1Button").unwrap(),
107 Target::Id("num1Button".into())
108 );
109 assert_eq!(
110 parse_target("role:Button").unwrap(),
111 Target::Role("Button".into())
112 );
113 assert_eq!(
114 parse_target("role:Button#2").unwrap(),
115 Target::RoleAt("Button".into(), 2)
116 );
117 }
118
119 #[test]
120 fn role_ordinal_edges() {
121 for bad in ["role:Button#0", "role:#2", "role:Button#", "role:Button#x"] {
122 assert!(parse_target(bad).is_err(), "{bad:?} should be rejected");
123 }
124 }
125
126 #[test]
127 fn fuzzy_matches_case_and_whitespace_insensitive() {
128 assert!(fuzzy_contains("保存 设置", "保存 设置"));
129 assert!(fuzzy_contains("Open File", "open file"));
130 assert!(!fuzzy_contains("Open", "open file"));
131 assert!(!fuzzy_contains("anything", ""));
132 }
133
134 #[test]
135 fn rejects_bare_words_and_edge_cases() {
136 for bad in [
137 "button", "@e", "@e0", "@ex", "name:", "id:", "role:", "@e-1",
138 ] {
139 let err = parse_target(bad).unwrap_err();
140 assert_eq!(err.code, ErrorCode::Protocol, "{bad:?} should be PROTOCOL");
141 }
142 }
143
144 #[test]
145 fn describe_round_trips_for_messages() {
146 assert_eq!(parse_target("@e7").unwrap().describe(), "@e7");
147 }
148}